]> Piment Noir Git Repositories - e-mobility-charging-stations-simulator.git/commitdiff
fix(ocpp20): implement proper M04.FR.06 guard via isChargingStationCertificateHash
authorJérôme Benoit <jerome.benoit@sap.com>
Sun, 15 Mar 2026 20:16:10 +0000 (21:16 +0100)
committerJérôme Benoit <jerome.benoit@sap.com>
Sun, 15 Mar 2026 20:16:10 +0000 (21:16 +0100)
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.

src/charging-station/ocpp/2.0/OCPP20CertificateManager.ts
src/charging-station/ocpp/2.0/OCPP20IncomingRequestService.ts
tests/charging-station/ocpp/2.0/OCPP20IncomingRequestService-DeleteCertificate.test.ts
tests/charging-station/ocpp/2.0/OCPP20TestUtils.ts

index 505edea8e124808d76ce84f5ed0127ee89822dda..c44fb2d8c915bce9cb75b146afe6c16201eb2137 100644 (file)
@@ -51,6 +51,11 @@ export interface OCPP20CertificateManagerInterface {
     filterTypes?: InstallCertificateUseEnumType[],
     hashAlgorithm?: HashAlgorithmEnumType
   ): GetInstalledCertificatesResult | Promise<GetInstalledCertificatesResult>
+  isChargingStationCertificateHash(
+    stationHashId: string,
+    certificateHashData: CertificateHashDataType,
+    hashAlgorithm?: HashAlgorithmEnumType
+  ): boolean | Promise<boolean>
   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<boolean> {
+    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
index a52418ef3b79364e59c76be877e0a041068aa328..c30e1e394f4d704801e68eeebe7e4237a3f37136 100644 (file)
@@ -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,
+          },
         }
       }
 
index d0b8dc2ae97c46a28a432e0e103e76a5c865eb29..1e37b3303164af6d0d6a801dd9c442f467e825a3 100644 (file)
@@ -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 = {
index 15de7686894b80a4624b4d2325e1d1297ed99e26..c210d14eb769ed80d33339695cc67951d155adf9 100644 (file)
@@ -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