From: Jérôme Benoit Date: Sun, 15 Mar 2026 20:16:10 +0000 (+0100) Subject: fix(ocpp20): implement proper M04.FR.06 guard via isChargingStationCertificateHash X-Git-Tag: ocpp-server@v3.1.0~12 X-Git-Url: https://git.piment-noir.org/?a=commitdiff_plain;h=47ced3bb98adf9723227ab83beaa720440b131a2;p=e-mobility-charging-stations-simulator.git fix(ocpp20): implement proper M04.FR.06 guard via isChargingStationCertificateHash The previous M04.FR.06 guard was ineffective: it called getInstalledCertificates() which scans root cert directories and maps types via mapInstallTypeToGetType(), which has no case for ChargingStationCertificate (stored via CertificateSigned, not InstallCertificate). The V2GCertificateChain filter never matched. Add isChargingStationCertificateHash() to OCPP20CertificateManager that directly scans the ChargingStationCertificate directory and compares certificate hashes. Use it in handleRequestDeleteCertificate for a reliable M04.FR.06 guard. --- diff --git a/src/charging-station/ocpp/2.0/OCPP20CertificateManager.ts b/src/charging-station/ocpp/2.0/OCPP20CertificateManager.ts index 505edea8..c44fb2d8 100644 --- a/src/charging-station/ocpp/2.0/OCPP20CertificateManager.ts +++ b/src/charging-station/ocpp/2.0/OCPP20CertificateManager.ts @@ -51,6 +51,11 @@ export interface OCPP20CertificateManagerInterface { filterTypes?: InstallCertificateUseEnumType[], hashAlgorithm?: HashAlgorithmEnumType ): GetInstalledCertificatesResult | Promise + isChargingStationCertificateHash( + stationHashId: string, + certificateHashData: CertificateHashDataType, + hashAlgorithm?: HashAlgorithmEnumType + ): boolean | Promise storeCertificate( stationHashId: string, certType: CertificateSigningUseEnumType | InstallCertificateUseEnumType, @@ -311,6 +316,62 @@ export class OCPP20CertificateManager { return { certificateHashDataChain } } + /** + * Checks whether the given certificate hash data matches a ChargingStationCertificate + * stored on disk for the specified station. + * @param stationHashId - Charging station unique identifier + * @param certificateHashData - Certificate hash data to check against stored CS certificates + * @param hashAlgorithm - Optional hash algorithm override (defaults to certificateHashData.hashAlgorithm) + * @returns true if the hash matches a stored ChargingStationCertificate, false otherwise + */ + public async isChargingStationCertificateHash ( + stationHashId: string, + certificateHashData: CertificateHashDataType, + hashAlgorithm?: HashAlgorithmEnumType + ): Promise { + try { + const certFilePath = this.getCertificatePath( + stationHashId, + CertificateSigningUseEnumType.ChargingStationCertificate, + '' + ) + // getCertificatePath returns basePath/ChargingStationCertificate/.pem + // We need the directory: basePath/ChargingStationCertificate + const dirPath = resolve(certFilePath, '..') + + if (!(await this.pathExists(dirPath))) { + return false + } + + const allFiles = await readdir(dirPath) + const pemFiles = allFiles.filter(f => f.endsWith('.pem')) + + for (const file of pemFiles) { + const filePath = join(dirPath, file) + this.validateCertificatePath(filePath, OCPP20CertificateManager.BASE_CERT_PATH) + try { + const pemData = await readFile(filePath, 'utf8') + const hashData = this.computeCertificateHash( + pemData, + hashAlgorithm ?? certificateHashData.hashAlgorithm + ) + if ( + hashData.serialNumber === certificateHashData.serialNumber && + hashData.issuerNameHash === certificateHashData.issuerNameHash && + hashData.issuerKeyHash === certificateHashData.issuerKeyHash + ) { + return true + } + } catch { + continue + } + } + } catch { + // Ignore errors — treat as "not a CS cert" + } + return false + } + /** * Stores a PEM certificate to the filesystem * @param stationHashId - Charging station unique identifier diff --git a/src/charging-station/ocpp/2.0/OCPP20IncomingRequestService.ts b/src/charging-station/ocpp/2.0/OCPP20IncomingRequestService.ts index a52418ef..c30e1e39 100644 --- a/src/charging-station/ocpp/2.0/OCPP20IncomingRequestService.ts +++ b/src/charging-station/ocpp/2.0/OCPP20IncomingRequestService.ts @@ -1597,35 +1597,22 @@ export class OCPP20IncomingRequestService extends OCPPIncomingRequestService { try { // M04.FR.06: Check if the certificate to delete is a ChargingStationCertificate - // If so, reject the deletion request - // Get all installed certificates using the request's hashAlgorithm for consistent comparison - const installedCerts = chargingStation.certificateManager.getInstalledCertificates( + const isCSCertResult = chargingStation.certificateManager.isChargingStationCertificateHash( chargingStation.stationInfo?.hashId ?? '', - undefined, - certificateHashData.hashAlgorithm + certificateHashData ) + const isCSCert = isCSCertResult instanceof Promise ? await isCSCertResult : isCSCertResult - const installedCertsResult = - installedCerts instanceof Promise ? await installedCerts : installedCerts - - for (const certChain of installedCertsResult.certificateHashDataChain) { - const certHash = certChain.certificateHashData - if ( - certChain.certificateType === GetCertificateIdUseEnumType.V2GCertificateChain && - certHash.serialNumber === certificateHashData.serialNumber && - certHash.issuerNameHash === certificateHashData.issuerNameHash && - certHash.issuerKeyHash === certificateHashData.issuerKeyHash - ) { - logger.warn( - `${chargingStation.logPrefix()} ${moduleName}.handleRequestDeleteCertificate: Attempted to delete ChargingStationCertificate (M04.FR.06)` - ) - return { - status: DeleteCertificateStatusEnumType.Failed, - statusInfo: { - additionalInfo: 'ChargingStationCertificate cannot be deleted (M04.FR.06)', - reasonCode: ReasonCodeEnumType.NotSupported, - }, - } + if (isCSCert) { + logger.warn( + `${chargingStation.logPrefix()} ${moduleName}.handleRequestDeleteCertificate: Attempted to delete ChargingStationCertificate (M04.FR.06)` + ) + return { + status: DeleteCertificateStatusEnumType.Failed, + statusInfo: { + additionalInfo: 'ChargingStationCertificate cannot be deleted (M04.FR.06)', + reasonCode: ReasonCodeEnumType.NotSupported, + }, } } diff --git a/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-DeleteCertificate.test.ts b/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-DeleteCertificate.test.ts index d0b8dc2a..1e37b330 100644 --- a/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-DeleteCertificate.test.ts +++ b/tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-DeleteCertificate.test.ts @@ -13,7 +13,6 @@ import { createTestableIncomingRequestService } from '../../../../src/charging-s import { OCPP20IncomingRequestService } from '../../../../src/charging-station/ocpp/2.0/OCPP20IncomingRequestService.js' import { DeleteCertificateStatusEnumType, - GetCertificateIdUseEnumType, HashAlgorithmEnumType, type OCPP20DeleteCertificateRequest, type OCPP20DeleteCertificateResponse, @@ -25,7 +24,6 @@ import { standardCleanup } from '../../../helpers/TestLifecycleHelpers.js' import { TEST_CHARGING_STATION_BASE_NAME } from '../../ChargingStationTestConstants.js' import { createMockChargingStation } from '../../ChargingStationTestUtils.js' import { - createMockCertificateHashDataChain, createMockCertificateManager, createStationWithCertificateManager, } from './OCPP20TestUtils.js' @@ -269,17 +267,12 @@ await describe('I04 - DeleteCertificate', async () => { await describe('M04.FR.06 - ChargingStationCertificate Protection', async () => { await it('should reject deletion of ChargingStationCertificate', async () => { - const chargingStationCertHash = createMockCertificateHashDataChain( - GetCertificateIdUseEnumType.V2GCertificateChain, - 'CHARGING_STATION_CERT_SERIAL' - ) - stationWithCertManager.certificateManager = createMockCertificateManager({ - getInstalledCertificatesResult: [chargingStationCertHash], + isChargingStationCertificateHashResult: true, }) const request: OCPP20DeleteCertificateRequest = { - certificateHashData: chargingStationCertHash.certificateHashData, + certificateHashData: VALID_CERTIFICATE_HASH_DATA, } const response: OCPP20DeleteCertificateResponse = @@ -295,7 +288,7 @@ await describe('I04 - DeleteCertificate', async () => { await it('should allow deletion of non-ChargingStationCertificate when no ChargingStationCertificate exists', async () => { stationWithCertManager.certificateManager = createMockCertificateManager({ deleteCertificateResult: { status: DeleteCertificateStatusEnumType.Accepted }, - getInstalledCertificatesResult: [], + isChargingStationCertificateHashResult: false, }) const request: OCPP20DeleteCertificateRequest = { diff --git a/tests/charging-station/ocpp/2.0/OCPP20TestUtils.ts b/tests/charging-station/ocpp/2.0/OCPP20TestUtils.ts index 15de7686..c210d14e 100644 --- a/tests/charging-station/ocpp/2.0/OCPP20TestUtils.ts +++ b/tests/charging-station/ocpp/2.0/OCPP20TestUtils.ts @@ -745,6 +745,8 @@ export interface MockCertificateManagerOptions { getInstalledCertificatesError?: Error /** Result to return from getInstalledCertificates (default: []) */ getInstalledCertificatesResult?: CertificateHashDataChainType[] + /** Result to return from isChargingStationCertificateHash (default: false) */ + isChargingStationCertificateHashResult?: boolean /** Error to throw when storeCertificate is called */ storeCertificateError?: Error /** Result to return from storeCertificate (default: { success: true }) */ @@ -835,6 +837,9 @@ export function createMockCertificateManager (options: MockCertificateManagerOpt certificateHashDataChain: options.getInstalledCertificatesResult ?? [], } }), + isChargingStationCertificateHash: mock.fn(() => { + return options.isChargingStationCertificateHashResult ?? false + }), storeCertificate: mock.fn(() => { if (options.storeCertificateError != null) { throw options.storeCertificateError