From bf74116bb0d94d71ee7c98025818a5b7b1d5506e Mon Sep 17 00:00:00 2001 From: Elliot Sabitov <> Date: Thu, 21 Nov 2024 17:07:21 -0500 Subject: [PATCH] feat: added certificateFileHash field to the Certificate record to maintain a unique hash for a cert in the DB to ensure that we can persist a matching unique record for each cert file that is uploaded into fileAccess feat: created InstallCertificateAttempt and DeleteCertificateAttempt repositories to help keep track of certs that are being installed / deleted so that the InstalledCertificates repository can be updated appropriately when successful responses from the charger are received feat: created UploadExistingCertificate endpoint to support being able to upload a certificate and tie it as an installed certificate on the charger, to be used during initial set up to upload certificates into COS that are already set up on the charger feat: created RegenerateExistingCertificate to generate a new certificate that is signed by old feat: adjusting InstallCertificate endpoint to get a certificate hash for the certificate that is being installed, and if a Certificate record does not yet exist for the provided certificate / hash, then a new record is created and the cert file is uploaded via fileAccess. The InstallCertificateAttempt record is created in pending state (status null) which will later be used to handle appropriately setting InstalledCertificate record feat: adjusted InstallCertificate handler to check for and update applicable pending InstallCertificateAttempt record and if the InstallCertificate status was successful, then InstalledCertificate record will be created / updated appropriately feat: adjusting GetGetInstalledCertificateIds handler to appropriately update InstalledCertificate records where previously we were deleting all and bulk inserting, now it will look for existing matches and update or insert new feat: adjusting DeleteCertificate handler to appropriately update InstalledCertificate records fix: CORS issue happening because OPTIONS was missing from list of supported methods --- 00_Base/src/index.ts | 8 +- 00_Base/src/ocpp/persistence/namespace.ts | 6 +- 01_Data/src/index.ts | 4 + .../dtos/RegenerateExistingCertificate.ts | 3 + .../dtos/UploadExistingCertificate.ts | 6 + 01_Data/src/interfaces/index.ts | 4 +- .../src/interfaces/queries/RootCertificate.ts | 11 + 01_Data/src/interfaces/repositories.ts | 12 +- 01_Data/src/layers/sequelize/index.ts | 4 +- .../model/Certificate/Certificate.ts | 3 + .../Certificate/DeleteCertificateAttempt.ts | 39 ++ .../Certificate/InstallCertificateAttempt.ts | 40 ++ .../model/Certificate/InstalledCertificate.ts | 30 +- .../sequelize/model/Certificate/index.ts | 2 + .../model/Location/ChargingStation.ts | 4 + .../repository/DeleteCertificateAttempt.ts | 16 + .../repository/InstallCertificateAttempt.ts | 16 + .../sequelize/repository/RepositoryStore.ts | 16 +- 01_Data/src/layers/sequelize/util.ts | 4 + 03_Modules/Certificates/src/module/api.ts | 395 +++++++++++++++++- 03_Modules/Certificates/src/module/module.ts | 242 ++++++++--- Server/src/index.ts | 82 ++-- 22 files changed, 826 insertions(+), 121 deletions(-) create mode 100644 01_Data/src/interfaces/dtos/RegenerateExistingCertificate.ts create mode 100644 01_Data/src/interfaces/dtos/UploadExistingCertificate.ts create mode 100644 01_Data/src/layers/sequelize/model/Certificate/DeleteCertificateAttempt.ts create mode 100644 01_Data/src/layers/sequelize/model/Certificate/InstallCertificateAttempt.ts create mode 100644 01_Data/src/layers/sequelize/repository/DeleteCertificateAttempt.ts create mode 100644 01_Data/src/layers/sequelize/repository/InstallCertificateAttempt.ts diff --git a/00_Base/src/index.ts b/00_Base/src/index.ts index 9a5188e1..296e9d3e 100644 --- a/00_Base/src/index.ts +++ b/00_Base/src/index.ts @@ -44,9 +44,7 @@ export { MessageTypeId, OcppError, } from './ocpp/rpc/message'; -export { - ChargingStationSequenceType, -} from './ocpp/model/enums/requestIds'; +export { ChargingStationSequenceType } from './ocpp/model/enums/requestIds'; export { IFileAccess } from './interfaces/fileAccess'; // Persistence Interfaces @@ -248,3 +246,7 @@ export { UnauthorizedError } from './interfaces/api/exception/UnauthorizedError' export { AuthorizationSecurity } from './interfaces/api/AuthorizationSecurity'; export { Ajv }; export declare type Constructable = new (...args: any[]) => T; +export { + IMessageQuerystringSchema, + IMessageQuerystring, +} from './interfaces/api/MessageQuerystring'; diff --git a/00_Base/src/ocpp/persistence/namespace.ts b/00_Base/src/ocpp/persistence/namespace.ts index 8b1d8abf..74742cf1 100644 --- a/00_Base/src/ocpp/persistence/namespace.ts +++ b/00_Base/src/ocpp/persistence/namespace.ts @@ -13,7 +13,11 @@ export enum Namespace { BootConfig = 'Boot', Certificate = 'Certificate', InstalledCertificate = 'InstalledCertificate', + InstallCertificateAttempt = 'InstallCertificateAttempt', + DeleteCertificateAttempt = 'DeleteCertificateAttempt', CertificateChain = 'CertificateChain', + UploadExistingCertificate = 'UploadExistingCertificate', + RegenerateExistingCertificate = 'RegenerateExistingCertificate', ChargingNeeds = 'ChargingNeeds', ChargingProfile = 'ChargingProfile', ChargingSchedule = 'ChargingSchedule', @@ -50,5 +54,5 @@ export enum Namespace { VariableMonitoringType = 'VariableMonitoring', VariableMonitoringStatus = 'VariableMonitoringStatus', VariableStatus = 'VariableStatus', - VariableType = 'Variable' + VariableType = 'Variable', } diff --git a/01_Data/src/index.ts b/01_Data/src/index.ts index 9ae68da4..c8d40797 100644 --- a/01_Data/src/index.ts +++ b/01_Data/src/index.ts @@ -33,6 +33,8 @@ export { VariableStatus, Certificate, InstalledCertificate, + InstallCertificateAttempt, + DeleteCertificateAttempt, CountryNameEnumType, TransactionEvent, IdToken, @@ -52,6 +54,8 @@ export { SequelizeCallMessageRepository, SequelizeCertificateRepository, SequelizeInstalledCertificateRepository, + SequelizeInstallCertificateAttemptRepository, + SequelizeDeleteCertificateAttemptRepository, SequelizeChargingProfileRepository, SequelizeChargingStationSecurityInfoRepository, SequelizeDeviceModelRepository, diff --git a/01_Data/src/interfaces/dtos/RegenerateExistingCertificate.ts b/01_Data/src/interfaces/dtos/RegenerateExistingCertificate.ts new file mode 100644 index 00000000..a2fc3293 --- /dev/null +++ b/01_Data/src/interfaces/dtos/RegenerateExistingCertificate.ts @@ -0,0 +1,3 @@ +export class RegenerateExistingCertificate { + installedCertificateId!: number; +} diff --git a/01_Data/src/interfaces/dtos/UploadExistingCertificate.ts b/01_Data/src/interfaces/dtos/UploadExistingCertificate.ts new file mode 100644 index 00000000..e59d57bc --- /dev/null +++ b/01_Data/src/interfaces/dtos/UploadExistingCertificate.ts @@ -0,0 +1,6 @@ +import { GetCertificateIdUseEnumType } from '@citrineos/base'; + +export class UploadExistingCertificate { + certificate!: string; + certificateType!: GetCertificateIdUseEnumType; +} diff --git a/01_Data/src/interfaces/index.ts b/01_Data/src/interfaces/index.ts index b94db7d3..751e56e8 100644 --- a/01_Data/src/interfaces/index.ts +++ b/01_Data/src/interfaces/index.ts @@ -15,7 +15,7 @@ export { TariffQueryString, TariffQuerySchema } from './queries/Tariff'; export { ModelKeyQuerystring, ModelKeyQuerystringSchema } from './queries/Model'; export { NetworkProfileQuerystring, NetworkProfileQuerySchema, NetworkProfileDeleteQuerystring, NetworkProfileDeleteQuerySchema } from './queries/NetworkProfile'; export { UpdateTlsCertificateQueryString, TlsCertificateSchema, UpdateTlsCertificateQuerySchema } from './queries/TlsCertificate'; -export { GenerateCertificateChainSchema, InstallRootCertificateSchema } from './queries/RootCertificate'; +export { GenerateCertificateChainSchema, InstallRootCertificateSchema, UploadExistingCertificateSchema, RegenerateInstalledCertificateSchema } from './queries/RootCertificate'; export { CreateSubscriptionSchema } from './queries/Subscription'; // Data projection models @@ -27,3 +27,5 @@ export { default as TariffSchema } from './projections/schemas/TariffSchema.json export { TlsCertificatesRequest } from './dtos/TlsCertificatesRequest'; export { GenerateCertificateChainRequest } from './dtos/GenerateCertificateChainRequest'; export { InstallRootCertificateRequest } from './dtos/InstallRootCertificateRequest'; +export { UploadExistingCertificate } from './dtos/UploadExistingCertificate'; +export { RegenerateExistingCertificate } from './dtos/RegenerateExistingCertificate'; diff --git a/01_Data/src/interfaces/queries/RootCertificate.ts b/01_Data/src/interfaces/queries/RootCertificate.ts index 762cd0ef..d285def6 100644 --- a/01_Data/src/interfaces/queries/RootCertificate.ts +++ b/01_Data/src/interfaces/queries/RootCertificate.ts @@ -31,3 +31,14 @@ export const InstallRootCertificateSchema = QuerySchema( ], ['stationId', 'certificateType', 'tenantId'], ); + +export const UploadExistingCertificateSchema = QuerySchema( + 'UploadExistingCertificateSchema', + [ + ['certificate', 'string'], + ['certificateType', 'string'], + ], + ['certificate', 'certificateType'], +); + +export const RegenerateInstalledCertificateSchema = QuerySchema('RegenerateInstalledCertificateSchema', [['installedCertificateId', 'number']], ['installedCertificateId']); diff --git a/01_Data/src/interfaces/repositories.ts b/01_Data/src/interfaces/repositories.ts index f1006412..1f115cf6 100644 --- a/01_Data/src/interfaces/repositories.ts +++ b/01_Data/src/interfaces/repositories.ts @@ -52,6 +52,7 @@ import { ChargingStationSequence, type Component, CompositeSchedule, + DeleteCertificateAttempt, type EventData, Evse, type Location, @@ -73,6 +74,7 @@ import { TariffQueryString } from './queries/Tariff'; import { LocalListVersion } from '../layers/sequelize/model/Authorization/LocalListVersion'; import { SendLocalList } from '../layers/sequelize/model/Authorization/SendLocalList'; import { InstalledCertificate } from '../layers/sequelize/model/Certificate/InstalledCertificate'; +import { InstallCertificateAttempt } from '../layers/sequelize/model/Certificate/InstallCertificateAttempt'; export interface IAuthorizationRepository extends CrudRepository { createOrUpdateByQuerystring: (value: AuthorizationData, query: AuthorizationQuerystring) => Promise; @@ -194,7 +196,9 @@ export interface ICertificateRepository extends CrudRepository { createOrUpdateCertificate(certificate: Certificate): Promise; } -export interface IInstalledCertificateRepository extends CrudRepository { } +export interface IInstalledCertificateRepository extends CrudRepository {} +export interface IInstallCertificateAttemptRepository extends CrudRepository {} +export interface IDeleteCertificateAttemptRepository extends CrudRepository {} export interface IChargingProfileRepository extends CrudRepository { createOrUpdateChargingProfile(chargingProfile: ChargingProfileType, stationId: string, evseId?: number | null, chargingLimitSource?: ChargingLimitSourceEnumType, isActive?: boolean): Promise; @@ -210,7 +214,7 @@ export interface IReservationRepository extends CrudRepository { createOrUpdateReservation(reserveNowRequest: ReserveNowRequest, stationId: string, isActive?: boolean): Promise; } -export interface ICallMessageRepository extends CrudRepository { } +export interface ICallMessageRepository extends CrudRepository {} export interface IChargingStationSecurityInfoRepository extends CrudRepository { readChargingStationPublicKeyFileId(stationId: string): Promise; @@ -221,6 +225,4 @@ export interface IChargingStationSequenceRepository extends CrudRepository; } -export interface IServerNetworkProfileRepository extends CrudRepository { - -} +export interface IServerNetworkProfileRepository extends CrudRepository {} diff --git a/01_Data/src/layers/sequelize/index.ts b/01_Data/src/layers/sequelize/index.ts index cd78b679..7cfc8dc6 100644 --- a/01_Data/src/layers/sequelize/index.ts +++ b/01_Data/src/layers/sequelize/index.ts @@ -14,7 +14,7 @@ export { ChargingStationSequence } from './model/ChargingStationSequence'; export { MessageInfo } from './model/MessageInfo'; export { Tariff } from './model/Tariff'; export { Subscription } from './model/Subscription'; -export { Certificate, SignatureAlgorithmEnumType, CountryNameEnumType, InstalledCertificate } from './model/Certificate'; +export { Certificate, SignatureAlgorithmEnumType, CountryNameEnumType, InstalledCertificate, InstallCertificateAttempt, DeleteCertificateAttempt } from './model/Certificate'; export { ChargingProfile, ChargingNeeds, ChargingSchedule, CompositeSchedule, SalesTariff } from './model/ChargingProfile'; export { CallMessage } from './model/CallMessage'; export { Reservation } from './model/Reservation'; @@ -35,6 +35,8 @@ export { SequelizeTariffRepository } from './repository/Tariff'; export { SequelizeSubscriptionRepository } from './repository/Subscription'; export { SequelizeCertificateRepository } from './repository/Certificate'; export { SequelizeInstalledCertificateRepository } from './repository/InstalledCertificate'; +export { SequelizeInstallCertificateAttemptRepository } from './repository/InstallCertificateAttempt'; +export { SequelizeDeleteCertificateAttemptRepository } from './repository/DeleteCertificateAttempt'; export { SequelizeChargingProfileRepository } from './repository/ChargingProfile'; export { SequelizeCallMessageRepository } from './repository/CallMessage'; export { SequelizeReservationRepository } from './repository/Reservation'; diff --git a/01_Data/src/layers/sequelize/model/Certificate/Certificate.ts b/01_Data/src/layers/sequelize/model/Certificate/Certificate.ts index 9d9f3d3b..fc5d1c44 100644 --- a/01_Data/src/layers/sequelize/model/Certificate/Certificate.ts +++ b/01_Data/src/layers/sequelize/model/Certificate/Certificate.ts @@ -63,6 +63,9 @@ export class Certificate extends Model { @Column(DataType.STRING) declare certificateFileId?: string | null; + @Column(DataType.STRING) + declare certificateFileHash?: string | null; + @Column(DataType.STRING) declare privateKeyFileId?: string | null; diff --git a/01_Data/src/layers/sequelize/model/Certificate/DeleteCertificateAttempt.ts b/01_Data/src/layers/sequelize/model/Certificate/DeleteCertificateAttempt.ts new file mode 100644 index 00000000..50e68d55 --- /dev/null +++ b/01_Data/src/layers/sequelize/model/Certificate/DeleteCertificateAttempt.ts @@ -0,0 +1,39 @@ +import { DeleteCertificateStatusEnumType, HashAlgorithmEnumType, Namespace } from '@citrineos/base'; +import { BelongsTo, Column, DataType, ForeignKey, Model, Table } from 'sequelize-typescript'; +import { ChargingStation } from '../Location'; + +@Table +export class DeleteCertificateAttempt extends Model { + static readonly MODEL_NAME: string = Namespace.DeleteCertificateAttempt; + + @ForeignKey(() => ChargingStation) + @Column({ + type: DataType.STRING(36), + allowNull: false, + }) + declare stationId: string; + + @BelongsTo(() => ChargingStation) + station?: ChargingStation; + + @Column({ + type: DataType.ENUM('SHA256', 'SHA384', 'SHA512'), + allowNull: false, + }) + declare hashAlgorithm: HashAlgorithmEnumType; + + @Column(DataType.STRING) + declare issuerNameHash: string; + + @Column(DataType.STRING) + declare issuerKeyHash: string; + + @Column(DataType.STRING) + declare serialNumber: string; + + @Column({ + type: DataType.ENUM('Accepted', 'Failed', 'NotFound'), + allowNull: true, + }) + declare status?: DeleteCertificateStatusEnumType | null; +} diff --git a/01_Data/src/layers/sequelize/model/Certificate/InstallCertificateAttempt.ts b/01_Data/src/layers/sequelize/model/Certificate/InstallCertificateAttempt.ts new file mode 100644 index 00000000..25ae2ba1 --- /dev/null +++ b/01_Data/src/layers/sequelize/model/Certificate/InstallCertificateAttempt.ts @@ -0,0 +1,40 @@ +import { type CustomDataType, GetCertificateIdUseEnumType, InstallCertificateStatusEnumType, Namespace } from '@citrineos/base'; +import { BelongsTo, Column, DataType, ForeignKey, Model, Table } from 'sequelize-typescript'; +import { ChargingStation } from '../Location'; +import { Certificate } from './Certificate'; + +@Table +export class InstallCertificateAttempt extends Model { + static readonly MODEL_NAME: string = Namespace.InstallCertificateAttempt; + + @ForeignKey(() => ChargingStation) + @Column({ + type: DataType.STRING(36), + allowNull: false, + }) + declare stationId: string; + + @BelongsTo(() => ChargingStation) + station?: ChargingStation; + + @Column({ + type: DataType.ENUM('V2GRootCertificate', 'MORootCertificate', 'CSMSRootCertificate', 'V2GCertificateChain', 'ManufacturerRootCertificate'), + allowNull: false, + }) + declare certificateType: GetCertificateIdUseEnumType; + + @ForeignKey(() => Certificate) + @Column(DataType.INTEGER) + declare certificateId: number; + + @BelongsTo(() => Certificate) + certificate?: Certificate; + + @Column({ + type: DataType.ENUM('Accepted', 'Rejected', 'Failed'), + allowNull: true, + }) + declare status?: InstallCertificateStatusEnumType | null; + + declare customData?: CustomDataType | null; +} diff --git a/01_Data/src/layers/sequelize/model/Certificate/InstalledCertificate.ts b/01_Data/src/layers/sequelize/model/Certificate/InstalledCertificate.ts index ea2cefab..5284ea7b 100644 --- a/01_Data/src/layers/sequelize/model/Certificate/InstalledCertificate.ts +++ b/01_Data/src/layers/sequelize/model/Certificate/InstalledCertificate.ts @@ -1,9 +1,10 @@ -import { CertificateHashDataType, GetCertificateIdUseEnumType, HashAlgorithmEnumType, Namespace } from '@citrineos/base'; -import { Column, DataType, ForeignKey, Model, Table } from 'sequelize-typescript'; +import { GetCertificateIdUseEnumType, HashAlgorithmEnumType, Namespace } from '@citrineos/base'; +import { BelongsTo, Column, DataType, ForeignKey, Model, Table } from 'sequelize-typescript'; import { ChargingStation } from '../Location'; +import { Certificate } from './Certificate'; @Table -export class InstalledCertificate extends Model implements CertificateHashDataType { +export class InstalledCertificate extends Model { static readonly MODEL_NAME: string = Namespace.InstalledCertificate; @ForeignKey(() => ChargingStation) @@ -15,31 +16,38 @@ export class InstalledCertificate extends Model implements CertificateHashDataTy @Column({ type: DataType.STRING, - allowNull: false, + allowNull: true, }) - declare hashAlgorithm: HashAlgorithmEnumType; + declare hashAlgorithm?: HashAlgorithmEnumType | undefined; @Column({ type: DataType.STRING, - allowNull: false, + allowNull: true, }) - declare issuerNameHash: string; + declare issuerNameHash?: string | undefined; @Column({ type: DataType.STRING, - allowNull: false, + allowNull: true, }) - declare issuerKeyHash: string; + declare issuerKeyHash?: string | undefined; @Column({ type: DataType.STRING, - allowNull: false, + allowNull: true, }) - declare serialNumber: string; + declare serialNumber?: string | undefined; @Column({ type: DataType.ENUM('V2GRootCertificate', 'MORootCertificate', 'CSMSRootCertificate', 'V2GCertificateChain', 'ManufacturerRootCertificate'), allowNull: false, }) declare certificateType: GetCertificateIdUseEnumType; + + @ForeignKey(() => Certificate) + @Column(DataType.INTEGER) + declare certificateId?: number | null; + + @BelongsTo(() => Certificate) + certificate!: Certificate; } diff --git a/01_Data/src/layers/sequelize/model/Certificate/index.ts b/01_Data/src/layers/sequelize/model/Certificate/index.ts index ff4d9ced..13b9e740 100644 --- a/01_Data/src/layers/sequelize/model/Certificate/index.ts +++ b/01_Data/src/layers/sequelize/model/Certificate/index.ts @@ -4,6 +4,8 @@ export { Certificate } from './Certificate'; export { InstalledCertificate } from './InstalledCertificate'; +export { InstallCertificateAttempt } from './InstallCertificateAttempt'; +export { DeleteCertificateAttempt } from './DeleteCertificateAttempt'; export const enum SignatureAlgorithmEnumType { RSA = 'SHA256withRSA', diff --git a/01_Data/src/layers/sequelize/model/Location/ChargingStation.ts b/01_Data/src/layers/sequelize/model/Location/ChargingStation.ts index fd2d5893..34a0f038 100644 --- a/01_Data/src/layers/sequelize/model/Location/ChargingStation.ts +++ b/01_Data/src/layers/sequelize/model/Location/ChargingStation.ts @@ -8,6 +8,7 @@ import { Location } from './Location'; import { StatusNotification } from './StatusNotification'; import { ChargingStationNetworkProfile } from './ChargingStationNetworkProfile'; import { SetNetworkProfile } from './SetNetworkProfile'; +import { InstalledCertificate } from '../Certificate'; /** * Represents a charging station. @@ -31,6 +32,9 @@ export class ChargingStation extends Model { @HasMany(() => StatusNotification) declare statusNotifications?: StatusNotificationRequest[]; + @HasMany(() => InstalledCertificate) + declare installedCertificates?: InstalledCertificate[]; + /** * The business Location of the charging station. Optional in case a charging station is not yet in the field, or retired. */ diff --git a/01_Data/src/layers/sequelize/repository/DeleteCertificateAttempt.ts b/01_Data/src/layers/sequelize/repository/DeleteCertificateAttempt.ts new file mode 100644 index 00000000..b8bb3327 --- /dev/null +++ b/01_Data/src/layers/sequelize/repository/DeleteCertificateAttempt.ts @@ -0,0 +1,16 @@ +// Copyright Contributors to the CitrineOS Project +// +// SPDX-License-Identifier: Apache 2.0 + +import { SequelizeRepository } from './Base'; +import { SystemConfig } from '@citrineos/base'; +import { Sequelize } from 'sequelize-typescript'; +import { ILogObj, Logger } from 'tslog'; +import { DeleteCertificateAttempt } from '../model/Certificate'; +import { IDeleteCertificateAttemptRepository } from '../../../interfaces'; + +export class SequelizeDeleteCertificateAttemptRepository extends SequelizeRepository implements IDeleteCertificateAttemptRepository { + constructor(config: SystemConfig, logger?: Logger, sequelizeInstance?: Sequelize) { + super(config, DeleteCertificateAttempt.MODEL_NAME, logger, sequelizeInstance); + } +} diff --git a/01_Data/src/layers/sequelize/repository/InstallCertificateAttempt.ts b/01_Data/src/layers/sequelize/repository/InstallCertificateAttempt.ts new file mode 100644 index 00000000..be531dcf --- /dev/null +++ b/01_Data/src/layers/sequelize/repository/InstallCertificateAttempt.ts @@ -0,0 +1,16 @@ +// Copyright Contributors to the CitrineOS Project +// +// SPDX-License-Identifier: Apache 2.0 + +import { SequelizeRepository } from './Base'; +import { IInstallCertificateAttemptRepository } from '../../../interfaces'; +import { SystemConfig } from '@citrineos/base'; +import { Sequelize } from 'sequelize-typescript'; +import { ILogObj, Logger } from 'tslog'; +import { InstallCertificateAttempt } from '../model/Certificate/InstallCertificateAttempt'; + +export class SequelizeInstallCertificateAttemptRepository extends SequelizeRepository implements IInstallCertificateAttemptRepository { + constructor(config: SystemConfig, logger?: Logger, sequelizeInstance?: Sequelize) { + super(config, InstallCertificateAttempt.MODEL_NAME, logger, sequelizeInstance); + } +} diff --git a/01_Data/src/layers/sequelize/repository/RepositoryStore.ts b/01_Data/src/layers/sequelize/repository/RepositoryStore.ts index a231fc44..934cde7c 100644 --- a/01_Data/src/layers/sequelize/repository/RepositoryStore.ts +++ b/01_Data/src/layers/sequelize/repository/RepositoryStore.ts @@ -5,7 +5,10 @@ import { ICertificateRepository, IChargingProfileRepository, IChargingStationSequenceRepository, + IDeleteCertificateAttemptRepository, IDeviceModelRepository, + IInstallCertificateAttemptRepository, + IInstalledCertificateRepository, ILocalAuthListRepository, ILocationRepository, IMessageInfoRepository, @@ -36,8 +39,11 @@ import { SequelizeRepository } from './Base'; import { SequelizeReservationRepository } from './Reservation'; import { SequelizeCallMessageRepository } from './CallMessage'; import { SequelizeLocalAuthListRepository } from './LocalAuthList'; -import {SequelizeChargingStationSequenceRepository} from "./ChargingStationSequence"; -import {SequelizeChargingProfileRepository} from "./ChargingProfile"; +import { SequelizeChargingStationSequenceRepository } from './ChargingStationSequence'; +import { SequelizeChargingProfileRepository } from './ChargingProfile'; +import { SequelizeInstalledCertificateRepository } from './InstalledCertificate'; +import { SequelizeInstallCertificateAttemptRepository } from './InstallCertificateAttempt'; +import { SequelizeDeleteCertificateAttemptRepository } from './DeleteCertificateAttempt'; export class RepositoryStore { sequelizeInstance: Sequelize; @@ -45,6 +51,9 @@ export class RepositoryStore { bootRepository: IBootRepository; callMessageRepository: ICallMessageRepository; certificateRepository: ICertificateRepository; + installedCertificateRepository: IInstalledCertificateRepository; + installCertificateAttemptRepository: IInstallCertificateAttemptRepository; + deleteCertificateAttemptRepository: IDeleteCertificateAttemptRepository; deviceModelRepository: IDeviceModelRepository; localAuthListRepository: ILocalAuthListRepository; locationRepository: ILocationRepository; @@ -65,6 +74,9 @@ export class RepositoryStore { this.bootRepository = new SequelizeBootRepository(config, logger, sequelizeInstance); this.callMessageRepository = new SequelizeCallMessageRepository(config, logger, sequelizeInstance); this.certificateRepository = new SequelizeCertificateRepository(config, logger, sequelizeInstance); + this.installedCertificateRepository = new SequelizeInstalledCertificateRepository(config, logger, sequelizeInstance); + this.installCertificateAttemptRepository = new SequelizeInstallCertificateAttemptRepository(config, logger, sequelizeInstance); + this.deleteCertificateAttemptRepository = new SequelizeDeleteCertificateAttemptRepository(config, logger, sequelizeInstance); this.deviceModelRepository = new SequelizeDeviceModelRepository(config, logger, sequelizeInstance); this.localAuthListRepository = new SequelizeLocalAuthListRepository(config, logger, sequelizeInstance); this.locationRepository = new SequelizeLocationRepository(config, logger, sequelizeInstance); diff --git a/01_Data/src/layers/sequelize/util.ts b/01_Data/src/layers/sequelize/util.ts index e9febf69..db064922 100644 --- a/01_Data/src/layers/sequelize/util.ts +++ b/01_Data/src/layers/sequelize/util.ts @@ -23,10 +23,12 @@ import { ChargingStationSequence, Component, CompositeSchedule, + DeleteCertificateAttempt, EventData, Evse, IdToken, IdTokenInfo, + InstallCertificateAttempt, InstalledCertificate, Location, MeterValue, @@ -122,6 +124,8 @@ export class DefaultSequelizeInstance { CallMessage, Certificate, InstalledCertificate, + InstallCertificateAttempt, + DeleteCertificateAttempt, ChargingNeeds, ChargingProfile, ChargingSchedule, diff --git a/03_Modules/Certificates/src/module/api.ts b/03_Modules/Certificates/src/module/api.ts index a8639b8c..8b44afef 100644 --- a/03_Modules/Certificates/src/module/api.ts +++ b/03_Modules/Certificates/src/module/api.ts @@ -12,11 +12,14 @@ import { CertificateSignedRequestSchema, DeleteCertificateRequest, DeleteCertificateRequestSchema, + GetCertificateIdUseEnumType, GetInstalledCertificateIdsRequest, GetInstalledCertificateIdsRequestSchema, HttpMethod, IFileAccess, IMessageConfirmation, + IMessageQuerystring, + IMessageQuerystringSchema, InstallCertificateRequest, InstallCertificateRequestSchema, Namespace, @@ -27,6 +30,7 @@ import { FastifyInstance, FastifyRequest } from 'fastify'; import { ILogObj, Logger } from 'tslog'; import { ICertificatesModuleApi } from './interface'; import { CertificatesModule } from './module'; +import crypto from 'crypto'; import { generateCertificate, generateCSR, @@ -35,15 +39,22 @@ import { import { Certificate, CountryNameEnumType, + DeleteCertificateAttempt, GenerateCertificateChainRequest, GenerateCertificateChainSchema, + InstallCertificateAttempt, + InstalledCertificate, InstallRootCertificateRequest, InstallRootCertificateSchema, + RegenerateExistingCertificate, + RegenerateInstalledCertificateSchema, SignatureAlgorithmEnumType, TlsCertificateSchema, TlsCertificatesRequest, UpdateTlsCertificateQuerySchema, UpdateTlsCertificateQueryString, + UploadExistingCertificate, + UploadExistingCertificateSchema, } from '@citrineos/data'; import fs from 'fs'; import moment from 'moment'; @@ -115,12 +126,67 @@ export class CertificatesModuleApi CallAction.InstallCertificate, InstallCertificateRequestSchema, ) - installCertificate( + async installCertificate( identifier: string, tenantId: string, request: InstallCertificateRequest, callbackUrl?: string, ): Promise { + const certificate = request.certificate; + const hash = this.getCertificateHash(certificate); + const existingPendingInstallCertificateAttempt = + await this._module.installCertificateAttemptRepository.readOnlyOneByQuery( + { + where: { + stationId: identifier, + certificateType: request.certificateType, + status: null, + }, + include: [ + { + model: Certificate, + where: { + certificateFileHash: hash, + }, + }, + ], + }, + ); + if (!existingPendingInstallCertificateAttempt) { + const { + serialNumber, + issuerName, + organizationName, + commonName, + countryName, + validBefore, + signatureAlgorithm, + } = this.extractCertificateDetails(certificate); + let existingCertificate = + await this._module.certificateRepository.readOnlyOneByQuery({ + where: { + certificateFileHash: hash, + }, + }); + if (!existingCertificate) { + existingCertificate = await this.createNewCertificate( + certificate, + serialNumber, + issuerName, + organizationName, + commonName, + countryName, + validBefore, + signatureAlgorithm, + ); + } + const installCertificateAttempt = new InstallCertificateAttempt(); + installCertificateAttempt.stationId = identifier; + installCertificateAttempt.certificateType = + request.certificateType as unknown as GetCertificateIdUseEnumType; + installCertificateAttempt.certificateId = existingCertificate.id; + await installCertificateAttempt.save(); + } return this._module.sendCall( identifier, tenantId, @@ -153,12 +219,36 @@ export class CertificatesModuleApi CallAction.DeleteCertificate, DeleteCertificateRequestSchema, ) - deleteCertificate( + async deleteCertificate( identifier: string, tenantId: string, request: DeleteCertificateRequest, callbackUrl?: string, ): Promise { + const certificateHashData = request.certificateHashData; + const existingPendingDeleteCertificateAttempt = + await this._module.deleteCertificateAttemptRepository.readOnlyOneByQuery({ + where: { + stationId: identifier, + hashAlgorithm: certificateHashData.hashAlgorithm, + issuerNameHash: certificateHashData.issuerNameHash, + issuerKeyHash: certificateHashData.issuerKeyHash, + serialNumber: certificateHashData.serialNumber, + status: null, + }, + }); + if (!existingPendingDeleteCertificateAttempt) { + const deleteCertificateAttempt = new DeleteCertificateAttempt(); + deleteCertificateAttempt.stationId = identifier; + deleteCertificateAttempt.hashAlgorithm = + certificateHashData.hashAlgorithm; + deleteCertificateAttempt.issuerNameHash = + certificateHashData.issuerNameHash; + deleteCertificateAttempt.issuerKeyHash = + certificateHashData.issuerKeyHash; + deleteCertificateAttempt.serialNumber = certificateHashData.serialNumber; + await deleteCertificateAttempt.save(); + } return this._module.sendCall( identifier, tenantId, @@ -401,7 +491,7 @@ export class CertificatesModuleApi request: FastifyRequest<{ Body: InstallRootCertificateRequest; }>, - ): Promise { + ): Promise { const installReq = request.body as InstallRootCertificateRequest; this._logger.info( `Installing ${installReq.certificateType} on charger ${installReq.stationId}`, @@ -436,6 +526,279 @@ export class CertificatesModuleApi } this._logger.debug('InstallCertificate confirmation sent:', confirmation); }); + + return { + success: true, + }; + } + + @AsDataEndpoint( + Namespace.UploadExistingCertificate, + HttpMethod.Post, + IMessageQuerystringSchema, + UploadExistingCertificateSchema, + ) + async uploadExistingCertificate( + request: FastifyRequest<{ + Body: UploadExistingCertificate; + Querystring: IMessageQuerystring; + }>, + ): Promise { + const uploadExistingCertificate = request.body as UploadExistingCertificate; + const messageQuerystring = request.query as IMessageQuerystring; + this._logger.info( + `Uploading existing ${uploadExistingCertificate.certificateType} certificate for charger ${messageQuerystring.identifier}`, + ); + const certificate = uploadExistingCertificate.certificate; + const { + serialNumber, + issuerName, + organizationName, + commonName, + countryName, + validBefore, + signatureAlgorithm, + } = this.extractCertificateDetails(certificate); + + let existingInstalledCertificate = + await this._module.installedCertificateRepository.readOnlyOneByQuery({ + where: { + stationId: messageQuerystring.identifier, + certificateType: uploadExistingCertificate.certificateType, + }, + }); + + if (existingInstalledCertificate) { + let existingCertificate: Certificate | undefined | null = + await existingInstalledCertificate.$get('certificate'); + if (existingCertificate && existingCertificate.certificateFileId) { + throw new Error( + 'Cannot upload exiting certificate because it already exists', + ); + } else if ( + existingCertificate && + !existingCertificate.certificateFileId + ) { + // set file where previously undefined + existingCertificate.certificateFileId = + await this._fileAccess.uploadFile( + `Existing_Key_${serialNumber}.pem`, + Buffer.from(certificate), + 'filePath', // TODO: should we use the same folder? + ); + await existingCertificate.save(); + } else { + // check if certificate record exists but not tied to installed certificate + existingCertificate = + await this._module.certificateRepository.readOnlyOneByQuery({ + where: { + certificateFileHash: this.getCertificateHash(certificate), + }, + }); + if (!existingCertificate) { + // create new certificate record + existingCertificate = await this.createNewCertificate( + certificate, + serialNumber, + issuerName, + organizationName, + commonName, + countryName, + validBefore, + signatureAlgorithm, + ); + } + existingInstalledCertificate.certificateId = existingCertificate.id; + existingInstalledCertificate = + await existingInstalledCertificate.save(); + } + } else { + // create new certificate record + const newCertificate: Certificate = await this.createNewCertificate( + certificate, + serialNumber, + issuerName, + organizationName, + commonName, + countryName, + validBefore, + signatureAlgorithm, + ); + existingInstalledCertificate = new InstalledCertificate(); + existingInstalledCertificate.stationId = messageQuerystring.identifier; + existingInstalledCertificate.certificateId = newCertificate.id; + existingInstalledCertificate.certificateType = + uploadExistingCertificate.certificateType; + existingInstalledCertificate = await existingInstalledCertificate.save(); + } + return existingInstalledCertificate; + } + + @AsDataEndpoint( + Namespace.RegenerateExistingCertificate, + HttpMethod.Post, + IMessageQuerystringSchema, + RegenerateInstalledCertificateSchema, + ) + async regenerateExistingCertificate( + request: FastifyRequest<{ + Body: RegenerateExistingCertificate; + Querystring: IMessageQuerystring; + }>, + ): Promise { + const installedCertificateId = request.body.installedCertificateId; + const stationId = request.query.identifier; + this._logger.info( + `Regenerating existing certificate ${installedCertificateId} for charger ${stationId}`, + ); + const existingInstalledCertificate = + await this._module.installedCertificateRepository.readOnlyOneByQuery({ + where: { + id: installedCertificateId, + stationId: stationId, + }, + }); + if (!existingInstalledCertificate) { + throw new Error('Installed certificate not found'); + } + const existingCertificateRecord = + await existingInstalledCertificate.$get('certificate'); + if (!existingCertificateRecord) { + throw new Error('Certificate not found'); + } + const fileId = existingCertificateRecord.certificateFileId; + if (!fileId) { + throw new Error('Certificate file not found'); + } + const existingCertificateBuffer = await this._fileAccess.getFile(fileId); + const existingCertificate = existingCertificateBuffer.toString(); + const [newCertificatePem, _newPrivateKeyPem] = generateCertificate( + existingCertificateRecord, + this._logger, + undefined, + existingCertificate, + ); + const newCertificateHash = this.getCertificateHash(newCertificatePem); + let newCertificateRecord = new Certificate(); + newCertificateRecord.serialNumber = moment().valueOf(); + newCertificateRecord.issuerName = existingCertificateRecord.issuerName; + newCertificateRecord.organizationName = + existingCertificateRecord.organizationName; + newCertificateRecord.commonName = existingCertificateRecord.commonName; + newCertificateRecord.keyLength = existingCertificateRecord.keyLength; + newCertificateRecord.validBefore = existingCertificateRecord.validBefore; + newCertificateRecord.signatureAlgorithm = + existingCertificateRecord.signatureAlgorithm; + newCertificateRecord.countryName = existingCertificateRecord.countryName; + newCertificateRecord.isCA = existingCertificateRecord.isCA; + newCertificateRecord.pathLen = existingCertificateRecord.pathLen; + newCertificateRecord.signedBy = existingCertificateRecord.signedBy; + newCertificateRecord.certificateFileHash = + existingCertificateRecord.certificateFileHash; + newCertificateRecord.certificateFileId = await this._fileAccess.uploadFile( + `Regenerated_Certificate_${newCertificateRecord.serialNumber}.pem`, + Buffer.from(newCertificatePem), + ); + newCertificateRecord.certificateFileHash = newCertificateHash; + newCertificateRecord = await newCertificateRecord.save(); + existingInstalledCertificate.certificateId = newCertificateRecord.id; + await existingInstalledCertificate.save(); + return existingInstalledCertificate; + } + + private createNewCertificate = async ( + certificate: string, + serialNumber: number | null, + issuerName: string | null, + organizationName: string | null, + commonName: string | null, + countryName: CountryNameEnumType | null, + validBefore: Date | null, + signatureAlgorithm: SignatureAlgorithmEnumType | null, + ) => { + const certificateHash = this.getCertificateHash(certificate); + const newCertificate = new Certificate(); + newCertificate.serialNumber = serialNumber!; + newCertificate.issuerName = issuerName!; + newCertificate.organizationName = organizationName!; + newCertificate.commonName = commonName!; + newCertificate.countryName = countryName!; + newCertificate.validBefore = validBefore?.toISOString()!; + newCertificate.signatureAlgorithm = signatureAlgorithm!; + newCertificate.certificateFileId = await this._fileAccess.uploadFile( + `Existing_Key_${serialNumber}.pem`, + Buffer.from(certificate), + ); + newCertificate.certificateFileHash = certificateHash; + return await newCertificate.save(); + }; + + private parseX509Date(date: string): Date | null { + if (/^\d{14}Z$/.test(date)) { + // GeneralizedTime: YYYYMMDDHHMMSSZ + const year = parseInt(date.slice(0, 4), 10); + const month = parseInt(date.slice(4, 6), 10) - 1; + const day = parseInt(date.slice(6, 8), 10); + const hour = parseInt(date.slice(8, 10), 10); + const minute = parseInt(date.slice(10, 12), 10); + const second = parseInt(date.slice(12, 14), 10); + return new Date(Date.UTC(year, month, day, hour, minute, second)); + } else if (/^\d{12}Z$/.test(date)) { + // UTCTime: YYMMDDHHMMSSZ + let year = parseInt(date.slice(0, 2), 10); + year += year < 50 ? 2000 : 1900; // Adjust for 21st/20th century + const month = parseInt(date.slice(2, 4), 10) - 1; + const day = parseInt(date.slice(4, 6), 10); + const hour = parseInt(date.slice(6, 8), 10); + const minute = parseInt(date.slice(8, 10), 10); + const second = parseInt(date.slice(10, 12), 10); + return new Date(Date.UTC(year, month, day, hour, minute, second)); + } else { + console.error(`Invalid X.509 date format: ${date}`); + return null; + } + } + + private extractCertificateDetails(pemString: string): { + serialNumber: number | null; + issuerName: string | null; + organizationName: string | null; + commonName: string | null; + countryName: CountryNameEnumType | null; + validBefore: Date | null; + signatureAlgorithm: SignatureAlgorithmEnumType | null; + } { + try { + const cert = new jsrsasign.X509(); + cert.readCertPEM(pemString); + + // Extract details + const serialNumber = parseInt(cert.getSerialNumberHex()); + const issuerName = cert.getIssuerString(); + const organizationName = + cert.getSubjectString().match(/\/O=([^\/]+)/)?.[1] || null; + const commonName = + cert.getSubjectString().match(/\/CN=([^\/]+)/)?.[1] || null; + const countryName = (cert.getSubjectString().match(/\/C=([^\/]+)/)?.[1] || + null) as CountryNameEnumType | null; + const notAfter = cert.getNotAfter(); + const validBefore = this.parseX509Date(notAfter); + const signatureAlgorithm = + cert.getSignatureAlgorithmField() as SignatureAlgorithmEnumType; + + return { + serialNumber, + issuerName, + organizationName, + commonName, + countryName, + validBefore, + signatureAlgorithm, + }; + } catch (error) { + console.error('Error extracting certificate details:', error); + throw new Error('Invalid PEM format or unsupported certificate'); + } } /** @@ -574,6 +937,30 @@ export class CertificatesModuleApi return [signedCertificate, privateKeyPem]; } + /** + * Generate a hash (fingerprint) from a certificate PEM string. + * @param pemString The certificate PEM string. + * @returns A SHA-256 hash of the certificate's DER encoding. + */ + private getCertificateHash(pemString: string): string { + try { + const cert = new jsrsasign.X509(); + cert.readCertPEM(pemString); + + // Get the raw DER encoding of the certificate + const derHex = cert.hex; + const derBuffer = Buffer.from(derHex, 'hex'); + + // Compute SHA-256 hash + const hash = crypto.createHash('sha256').update(derBuffer).digest('hex'); + + return hash; + } catch (error) { + console.error('Error generating certificate hash:', error); + throw new Error('Invalid PEM format or unsupported certificate'); + } + } + /** * Store certificate in file storage and db. * @param certificateEntity certificate to be stored in db @@ -590,6 +977,7 @@ export class CertificatesModuleApi filePrefix: PemType, filePath?: string, ): Promise { + const certificateHash = this.getCertificateHash(certPem); // Store certificate and private key in file storage certificateEntity.privateKeyFileId = await this._fileAccess.uploadFile( `${filePrefix}_Key_${certificateEntity.serialNumber}.pem`, @@ -601,6 +989,7 @@ export class CertificatesModuleApi Buffer.from(certPem), filePath, ); + certificateEntity.certificateFileHash = certificateHash; // Store certificate in db const certObj = new jsrsasign.X509(); certObj.readCertPEM(certPem); diff --git a/03_Modules/Certificates/src/module/module.ts b/03_Modules/Certificates/src/module/module.ts index 50580b0c..b5e217fa 100644 --- a/03_Modules/Certificates/src/module/module.ts +++ b/03_Modules/Certificates/src/module/module.ts @@ -13,6 +13,7 @@ import { CertificateSignedResponse, CertificateSigningUseEnumType, DeleteCertificateResponse, + DeleteCertificateStatusEnumType, ErrorCode, EventGroup, GenericStatusEnumType, @@ -24,20 +25,22 @@ import { GetInstalledCertificateIdsResponse, HandlerProperties, ICache, + IFileAccess, IMessage, IMessageHandler, IMessageSender, InstallCertificateResponse, + InstallCertificateStatusEnumType, Iso15118EVCertificateStatusEnumType, - Namespace, SignCertificateRequest, SignCertificateResponse, SystemConfig, } from '@citrineos/base'; -import { Op } from 'sequelize'; import { ICertificateRepository, + IDeleteCertificateAttemptRepository, IDeviceModelRepository, + IInstallCertificateAttemptRepository, IInstalledCertificateRepository, ILocationRepository, InstalledCertificate, @@ -87,8 +90,11 @@ export class CertificatesModule extends AbstractModule { protected _deviceModelRepository: IDeviceModelRepository; protected _certificateRepository: ICertificateRepository; protected _installedCertificateRepository: IInstalledCertificateRepository; + protected _installCertificateAttemptRepository: IInstallCertificateAttemptRepository; + protected _deleteCertificateAttemptRepository: IDeleteCertificateAttemptRepository; protected _locationRepository: ILocationRepository; protected _certificateAuthorityService: CertificateAuthorityService; + protected _fileAccess: IFileAccess; /** * Constructor @@ -117,6 +123,18 @@ export class CertificatesModule extends AbstractModule { * represents a repository for accessing and manipulating variable data. * If no `deviceModelRepository` is provided, a default {@link sequelize.CertificateRepository} instance is created and used. * + * @param {IInstalledCertificateRepository} [installedCertificateRepository] - An optional parameter of type {@link IInstalledCertificateRepository} which + * represents a repository for accessing and manipulating installed certificate data. + * If no `installedCertificateRepository` is provided, a default {@link sequelize.InstalledCertificateRepository} instance is created and used. + * + * @param {IInstallCertificateAttemptRepository} [installCertificateAttemptRepository] - An optional parameter of type {@link IInstallCertificateAttemptRepository} which + * represents a repository for accessing and manipulating installed certificate attempt data. + * If no `installCertificateAttemptRepository` is provided, a default {@link sequelize.InstallCertificateAttemptRepository} instance is created and used. + * + * @param {IDeleteCertificateAttemptRepository} [deleteCertificateAttemptRepository] - An optional parameter of type {@link IDeleteCertificateAttemptRepository} which + * represents a repository for accessing and manipulating deleted certificate attempt data. + * If no `deleteCertificateAttemptRepository` is provided, a default {@link sequelize.DeleteCertificateAttemptRepository} instance is created and used. + * * @param {ILocationRepository} [locationRepository] - An optional parameter of type {@link ILocationRepository} which * represents a repository for accessing and manipulating variable data. * If no `deviceModelRepository` is provided, a default {@link sequelize.LocationRepository} instance is created and used. @@ -129,9 +147,13 @@ export class CertificatesModule extends AbstractModule { cache: ICache, sender: IMessageSender, handler: IMessageHandler, + fileAccess: IFileAccess, logger?: Logger, deviceModelRepository?: IDeviceModelRepository, certificateRepository?: ICertificateRepository, + installedCertificateRepository?: IInstalledCertificateRepository, + installCertificateAttemptRepository?: IInstallCertificateAttemptRepository, + deleteCertificateAttemptRepository?: IDeleteCertificateAttemptRepository, locationRepository?: ILocationRepository, certificateAuthorityService?: CertificateAuthorityService, ) { @@ -160,14 +182,24 @@ export class CertificatesModule extends AbstractModule { certificateRepository || new sequelize.SequelizeCertificateRepository(config, logger); this._installedCertificateRepository = + installedCertificateRepository || new sequelize.SequelizeInstalledCertificateRepository(config, logger); + this._installCertificateAttemptRepository = + installCertificateAttemptRepository || + new sequelize.SequelizeInstallCertificateAttemptRepository( + config, + logger, + ); + this._deleteCertificateAttemptRepository = + deleteCertificateAttemptRepository || + new sequelize.SequelizeDeleteCertificateAttemptRepository(config, logger); this._locationRepository = locationRepository || new sequelize.SequelizeLocationRepository(config, logger); - this._certificateAuthorityService = certificateAuthorityService || new CertificateAuthorityService(config, this._logger); + this._fileAccess = fileAccess; this._logger.info(`Initialized in ${timer.end()}ms...`); } @@ -180,6 +212,18 @@ export class CertificatesModule extends AbstractModule { return this._certificateRepository; } + get installedCertificateRepository(): IInstalledCertificateRepository { + return this._installedCertificateRepository; + } + + get installCertificateAttemptRepository(): IInstallCertificateAttemptRepository { + return this._installCertificateAttemptRepository; + } + + get deleteCertificateAttemptRepository(): IDeleteCertificateAttemptRepository { + return this._deleteCertificateAttemptRepository; + } + /** * Handle requests */ @@ -325,6 +369,44 @@ export class CertificatesModule extends AbstractModule { props?: HandlerProperties, ): Promise { this._logger.debug('DeleteCertificate received:', message, props); + const stationId = message.context.stationId; + const existingPendingDeleteCertificateAttempt = + await this.deleteCertificateAttemptRepository.readOnlyOneByQuery({ + where: { + stationId, + status: null, + }, + }); + // should always be true + if (existingPendingDeleteCertificateAttempt) { + existingPendingDeleteCertificateAttempt.status = message.payload.status; + await existingPendingDeleteCertificateAttempt.save(); + if ( + existingPendingDeleteCertificateAttempt.status === + DeleteCertificateStatusEnumType.Accepted + ) { + const existingInstalledCertificates = + await this.installedCertificateRepository.readAllByQuery({ + where: { + stationId, + hashAlgorithm: + existingPendingDeleteCertificateAttempt.hashAlgorithm, + issuerNameHash: + existingPendingDeleteCertificateAttempt.issuerNameHash, + issuerKeyHash: + existingPendingDeleteCertificateAttempt.issuerKeyHash, + serialNumber: + existingPendingDeleteCertificateAttempt.serialNumber, + }, + }); + // should always be true + if (existingInstalledCertificates) { + for (let existingInstalledCertificate of existingInstalledCertificates) { + await existingInstalledCertificate.destroy(); + } + } + } + } } @AsHandler(CallAction.GetInstalledCertificateIds) @@ -335,39 +417,50 @@ export class CertificatesModule extends AbstractModule { this._logger.debug('GetInstalledCertificateIds received:', message, props); const certificateHashDataList: CertificateHashDataChainType[] = message.payload.certificateHashDataChain!; - // persist installed certificate information if (certificateHashDataList && certificateHashDataList.length > 0) { - // delete previous hashes for station - await this.deleteExistingMatchingCertificateHashes( - message.context.stationId, - certificateHashDataList, - ); - // save new hashes - const records = certificateHashDataList.map( - (certificateHashDataWrap: CertificateHashDataChainType) => { - const certificateHashData = - certificateHashDataWrap.certificateHashData; - const certificateType = certificateHashDataWrap.certificateType; - return { - stationId: message.context.stationId, - hashAlgorithm: certificateHashData.hashAlgorithm, - issuerNameHash: certificateHashData.issuerNameHash, - issuerKeyHash: certificateHashData.issuerKeyHash, - serialNumber: certificateHashData.serialNumber, - certificateType: certificateType, - } as InstalledCertificate; - }, - ); - this._logger.info('Attempting to save', records); - const response = await this._installedCertificateRepository.bulkCreate( - records, - Namespace.InstalledCertificate, - ); - if (response.length === records.length) { - this._logger.info( - 'Successfully updated installed certificate information for station', - message.context.stationId, - ); + for (let certificateHashDataWrap of certificateHashDataList) { + const certificateHashData = certificateHashDataWrap.certificateHashData; + const certificateType = certificateHashDataWrap.certificateType; + let existingInstalledCertificate = + await this._installedCertificateRepository.readOnlyOneByQuery({ + where: { + stationId: message.context.stationId, + certificateType: certificateType, + serialNumber: certificateHashData.serialNumber, + }, + }); + if (existingInstalledCertificate) { + existingInstalledCertificate.hashAlgorithm = + certificateHashData.hashAlgorithm; + existingInstalledCertificate.issuerNameHash = + certificateHashData.issuerNameHash; + existingInstalledCertificate.issuerKeyHash = + certificateHashData.issuerKeyHash; + existingInstalledCertificate.serialNumber = + certificateHashData.serialNumber; + await existingInstalledCertificate.save(); + this._logger.debug( + 'Updated installed certificate record', + existingInstalledCertificate, + ); + } else { + existingInstalledCertificate = new InstalledCertificate(); + existingInstalledCertificate.hashAlgorithm = + certificateHashData.hashAlgorithm; + existingInstalledCertificate.issuerNameHash = + certificateHashData.issuerNameHash; + existingInstalledCertificate.issuerKeyHash = + certificateHashData.issuerKeyHash; + existingInstalledCertificate.serialNumber = + certificateHashData.serialNumber; + existingInstalledCertificate.stationId = message.context.stationId; + existingInstalledCertificate.certificateType = certificateType; + await existingInstalledCertificate.save(); + this._logger.debug( + 'Created new installed certificate record', + existingInstalledCertificate, + ); + } } } } @@ -378,6 +471,57 @@ export class CertificatesModule extends AbstractModule { props?: HandlerProperties, ): Promise { this._logger.debug('InstallCertificate received:', message, props); + const stationId = message.context.stationId; + const existingPendingInstallCertificateAttempt = + await this.installCertificateAttemptRepository.readOnlyOneByQuery({ + where: { + stationId, + status: null, + }, + }); + // should always be true + if (existingPendingInstallCertificateAttempt) { + existingPendingInstallCertificateAttempt.status = message.payload.status; + await existingPendingInstallCertificateAttempt.save(); + if ( + existingPendingInstallCertificateAttempt.status === + InstallCertificateStatusEnumType.Accepted + ) { + const existingInstalledCertificate = + await this.installedCertificateRepository.readOnlyOneByQuery({ + where: { + stationId, + certificateType: + existingPendingInstallCertificateAttempt.certificateType, + }, + }); + if (existingInstalledCertificate) { + existingInstalledCertificate.certificateId = + existingPendingInstallCertificateAttempt.certificateId; + await existingInstalledCertificate.save(); + } else { + const certificate = + await existingPendingInstallCertificateAttempt.$get('certificate'); + if (certificate && certificate.certificateFileId) { + const certificateBuffer = await this._fileAccess.getFile( + certificate.certificateFileId, + ); + const certificateString = certificateBuffer.toString(); + const cert = new jsrsasign.X509(); + cert.readCertPEM(certificateString); + const serialNumber = parseInt(cert.getSerialNumberHex()); + const installedCertificate = new InstalledCertificate(); + installedCertificate.stationId = stationId; + installedCertificate.serialNumber = serialNumber.toString(); + installedCertificate.certificateId = + existingPendingInstallCertificateAttempt.certificateId; + installedCertificate.certificateType = + existingPendingInstallCertificateAttempt.certificateType; + await installedCertificate.save(); + } + } + } + } } private async _verifySignCertRequest( @@ -441,32 +585,4 @@ export class CertificatesModule extends AbstractModule { `Verified SignCertRequest for station ${stationId} successfully.`, ); } - - private async deleteExistingMatchingCertificateHashes( - stationId: string, - certificateHashDataList: CertificateHashDataChainType[], - ) { - try { - const certificateTypes = certificateHashDataList.map( - (certificateHashData) => { - return certificateHashData.certificateType; - }, - ); - if (certificateTypes && certificateTypes.length > 0) { - await this._installedCertificateRepository.deleteAllByQuery({ - where: { - stationId, - certificateType: { - [Op.in]: certificateTypes, - }, - }, - }); - } - } catch (error: any) { - this._logger.error( - 'GetInstalledCertificateIds failed to delete previous certificates', - error, - ); - } - } } diff --git a/Server/src/index.ts b/Server/src/index.ts index c89bc6db..f4ff8b28 100644 --- a/Server/src/index.ts +++ b/Server/src/index.ts @@ -55,10 +55,17 @@ import { import { EVDriverModule, EVDriverModuleApi } from '@citrineos/evdriver'; import { ReportingModule, ReportingModuleApi } from '@citrineos/reporting'; import { + InternalSmartCharging, + ISmartCharging, SmartChargingModule, SmartChargingModuleApi, } from '@citrineos/smartcharging'; -import { RepositoryStore, sequelize, Sequelize, ServerNetworkProfile } from '@citrineos/data'; +import { + RepositoryStore, + sequelize, + Sequelize, + ServerNetworkProfile, +} from '@citrineos/data'; import { type FastifyRouteSchemaDef, type FastifySchemaCompiler, @@ -70,10 +77,6 @@ import { WebhookDispatcher, } from '@citrineos/ocpprouter'; import { TenantModule, TenantModuleApi } from '@citrineos/tenant'; -import { - InternalSmartCharging, - ISmartCharging, -} from '@citrineos/smartcharging'; import cors from '@fastify/cors'; interface ModuleConfig { @@ -141,7 +144,7 @@ export class CitrineOSServer { // enable cors (this._server as any).register(cors, { origin: true, // This can be customized to specify allowed origins - methods: ['GET', 'POST', 'PUT', 'DELETE'], // Specify allowed HTTP methods + methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], // Specify allowed HTTP methods }); // Add health check @@ -243,26 +246,34 @@ export class CitrineOSServer { } protected async _syncWebsocketConfig() { - this._config.util.networkConnection.websocketServers.forEach(async websocketServerConfig => { - const [serverNetworkProfile] = await ServerNetworkProfile.findOrBuild({ - where: { - id: websocketServerConfig.id - } - }); - serverNetworkProfile.host = websocketServerConfig.host; - serverNetworkProfile.port = websocketServerConfig.port; - serverNetworkProfile.pingInterval = websocketServerConfig.pingInterval; - serverNetworkProfile.protocol = websocketServerConfig.protocol; - serverNetworkProfile.messageTimeout = this._config.maxCallLengthSeconds; - serverNetworkProfile.securityProfile = websocketServerConfig.securityProfile; - serverNetworkProfile.allowUnknownChargingStations = websocketServerConfig.allowUnknownChargingStations; - serverNetworkProfile.tlsKeyFilePath = websocketServerConfig.tlsKeyFilePath; - serverNetworkProfile.tlsCertificateChainFilePath = websocketServerConfig.tlsCertificateChainFilePath; - serverNetworkProfile.mtlsCertificateAuthorityKeyFilePath = websocketServerConfig.mtlsCertificateAuthorityKeyFilePath; - serverNetworkProfile.rootCACertificateFilePath = websocketServerConfig.rootCACertificateFilePath; - - serverNetworkProfile.save(); - }) + this._config.util.networkConnection.websocketServers.forEach( + async (websocketServerConfig) => { + const [serverNetworkProfile] = await ServerNetworkProfile.findOrBuild({ + where: { + id: websocketServerConfig.id, + }, + }); + serverNetworkProfile.host = websocketServerConfig.host; + serverNetworkProfile.port = websocketServerConfig.port; + serverNetworkProfile.pingInterval = websocketServerConfig.pingInterval; + serverNetworkProfile.protocol = websocketServerConfig.protocol; + serverNetworkProfile.messageTimeout = this._config.maxCallLengthSeconds; + serverNetworkProfile.securityProfile = + websocketServerConfig.securityProfile; + serverNetworkProfile.allowUnknownChargingStations = + websocketServerConfig.allowUnknownChargingStations; + serverNetworkProfile.tlsKeyFilePath = + websocketServerConfig.tlsKeyFilePath; + serverNetworkProfile.tlsCertificateChainFilePath = + websocketServerConfig.tlsCertificateChainFilePath; + serverNetworkProfile.mtlsCertificateAuthorityKeyFilePath = + websocketServerConfig.mtlsCertificateAuthorityKeyFilePath; + serverNetworkProfile.rootCACertificateFilePath = + websocketServerConfig.rootCACertificateFilePath; + + serverNetworkProfile.save(); + }, + ); } protected _createSender(): IMessageSender { @@ -366,7 +377,6 @@ export class CitrineOSServer { this._repositoryStore.subscriptionRepository, ); - const router = new MessageRouterImpl( this._config, this._cache, @@ -401,9 +411,13 @@ export class CitrineOSServer { this._cache, this._createSender(), this._createHandler(), + this._fileAccess, this._logger, this._repositoryStore.deviceModelRepository, this._repositoryStore.certificateRepository, + this._repositoryStore.installedCertificateRepository, + this._repositoryStore.installCertificateAttemptRepository, + this._repositoryStore.deleteCertificateAttemptRepository, this._repositoryStore.locationRepository, ); this.modules.push(module); @@ -669,17 +683,23 @@ export class CitrineOSServer { } private initIdGenerator() { - this._idGenerator = new IdGenerator(this._repositoryStore.chargingStationSequenceRepository); + this._idGenerator = new IdGenerator( + this._repositoryStore.chargingStationSequenceRepository, + ); } private initCertificateAuthorityService() { - this._certificateAuthorityService = new CertificateAuthorityService(this._config, this._logger); + this._certificateAuthorityService = new CertificateAuthorityService( + this._config, + this._logger, + ); } private initSmartChargingService() { - this._smartChargingService = new InternalSmartCharging(this._repositoryStore.chargingProfileRepository); + this._smartChargingService = new InternalSmartCharging( + this._repositoryStore.chargingProfileRepository, + ); } - } async function main() {