]> Piment Noir Git Repositories - e-mobility-charging-stations-simulator.git/commitdiff
feat(ocpp): add signing prerequisites validation and EC curve auto-derivation
authorJérôme Benoit <jerome.benoit@sap.com>
Tue, 7 Apr 2026 20:42:02 +0000 (22:42 +0200)
committerJérôme Benoit <jerome.benoit@sap.com>
Tue, 7 Apr 2026 20:42:02 +0000 (22:42 +0200)
- Add deriveSigningMethodFromPublicKeyHex: extracts EC curve OID from
  ASN.1 DER public key and maps to SigningMethodEnumType
- Add validateSigningPrerequisites: checks public key presence, curve
  detection, and config/key curve consistency
- Integrate validation in OCPP 2.0 (OCPPServiceUtils.buildMeterValue)
  and OCPP 1.6 (readSigningConfigForConnector) signing paths
- On validation failure: log warning and gracefully fallback to unsigned
  meter values instead of producing invalid signed data
- Add unit tests for deriveSigningMethodFromPublicKeyHex and
  validateSigningPrerequisites covering all paths

src/charging-station/ocpp/1.6/OCPP16ServiceUtils.ts
src/charging-station/ocpp/OCPPServiceUtils.ts
src/charging-station/ocpp/OCPPSignedMeterValueUtils.ts
tests/charging-station/ocpp/OCPPSignedMeterValueUtils.test.ts

index 2168e4c0ceb00baf1fc4b56e273a6aa66d99b971..09f195e13c9941005863680ad1981fcfffbf6b93 100644 (file)
@@ -75,6 +75,7 @@ import {
   shouldIncludePublicKey,
   type SignedSampledValueResult,
   type SigningConfig,
+  validateSigningPrerequisites,
 } from '../OCPPSignedMeterValueUtils.js'
 import { OCPP16Constants } from './OCPP16Constants.js'
 import { buildOCPP16SampledValue, buildSignedOCPP16SampledValue } from './OCPP16RequestBuilders.js'
@@ -171,17 +172,19 @@ export class OCPP16ServiceUtils {
         chargingStation,
         connectorId
       )
-      const signedResult = OCPP16ServiceUtils.buildSignedSampledValue(
-        signingCfg,
-        meterStart ?? 0,
-        OCPP16MeterValueContext.TRANSACTION_BEGIN,
-        transactionId,
-        publicKeySentInTransaction,
-        meterValue.timestamp
-      )
-      meterValue.sampledValue.push(signedResult.sampledValue)
-      if (signedResult.publicKeyIncluded && connectorStatus != null) {
-        connectorStatus.publicKeySentInTransaction = true
+      if (signingCfg != null) {
+        const signedResult = OCPP16ServiceUtils.buildSignedSampledValue(
+          signingCfg,
+          meterStart ?? 0,
+          OCPP16MeterValueContext.TRANSACTION_BEGIN,
+          transactionId,
+          publicKeySentInTransaction,
+          meterValue.timestamp
+        )
+        meterValue.sampledValue.push(signedResult.sampledValue)
+        if (signedResult.publicKeyIncluded && connectorStatus != null) {
+          connectorStatus.publicKeySentInTransaction = true
+        }
       }
     }
     return meterValue
@@ -237,17 +240,19 @@ export class OCPP16ServiceUtils {
         chargingStation,
         connectorId
       )
-      const signedResult = OCPP16ServiceUtils.buildSignedSampledValue(
-        signingCfg,
-        meterStop ?? 0,
-        OCPP16MeterValueContext.TRANSACTION_END,
-        transactionId,
-        publicKeySentInTransaction,
-        meterValue.timestamp
-      )
-      meterValue.sampledValue.push(signedResult.sampledValue)
-      if (signedResult.publicKeyIncluded && connectorStatus != null) {
-        connectorStatus.publicKeySentInTransaction = true
+      if (signingCfg != null) {
+        const signedResult = OCPP16ServiceUtils.buildSignedSampledValue(
+          signingCfg,
+          meterStop ?? 0,
+          OCPP16MeterValueContext.TRANSACTION_END,
+          transactionId,
+          publicKeySentInTransaction,
+          meterValue.timestamp
+        )
+        meterValue.sampledValue.push(signedResult.sampledValue)
+        if (signedResult.publicKeyIncluded && connectorStatus != null) {
+          connectorStatus.publicKeySentInTransaction = true
+        }
       }
     }
     return meterValue
@@ -818,17 +823,19 @@ export class OCPP16ServiceUtils {
           chargingStation,
           connectorId
         )
-        const signedResult = OCPP16ServiceUtils.buildSignedSampledValue(
-          signingCfg,
-          energyWh,
-          OCPP16MeterValueContext.SAMPLE_PERIODIC,
-          transactionId,
-          publicKeySentInTransaction,
-          (meterValue as OCPP16MeterValue).timestamp
-        )
-        ;(meterValue as OCPP16MeterValue).sampledValue.push(signedResult.sampledValue)
-        if (signedResult.publicKeyIncluded) {
-          connectorStatus.publicKeySentInTransaction = true
+        if (signingCfg != null) {
+          const signedResult = OCPP16ServiceUtils.buildSignedSampledValue(
+            signingCfg,
+            energyWh,
+            OCPP16MeterValueContext.SAMPLE_PERIODIC,
+            transactionId,
+            publicKeySentInTransaction,
+            (meterValue as OCPP16MeterValue).timestamp
+          )
+          ;(meterValue as OCPP16MeterValue).sampledValue.push(signedResult.sampledValue)
+          if (signedResult.publicKeyIncluded) {
+            connectorStatus.publicKeySentInTransaction = true
+          }
         }
       }
       chargingStation.ocppRequestService
@@ -1037,21 +1044,34 @@ export class OCPP16ServiceUtils {
   private static readSigningConfigForConnector (
     chargingStation: ChargingStation,
     connectorId: number
-  ): SigningConfig {
+  ): SigningConfig | undefined {
+    const publicKeyHex = getConfigurationKey(
+      chargingStation,
+      `${OCPP16VendorParametersKey.MeterPublicKey}${connectorId.toString()}`
+    )?.value
+    const configuredSigningMethod = getConfigurationKey(
+      chargingStation,
+      OCPP16VendorParametersKey.SigningMethod
+    )?.value as SigningMethodEnumType | undefined
+
+    const prerequisiteResult = validateSigningPrerequisites(publicKeyHex, configuredSigningMethod)
+    if (!prerequisiteResult.enabled) {
+      logger.warn(
+        `${chargingStation.logPrefix()} OCPP16ServiceUtils.readSigningConfigForConnector: Signed meter values disabled for connector ${connectorId.toString()}: ${prerequisiteResult.reason}`
+      )
+      return undefined
+    }
+
     return {
       meterSerialNumber: chargingStation.stationInfo?.meterSerialNumber ?? 'SIMULATOR',
-      publicKeyHex: getConfigurationKey(
-        chargingStation,
-        `${OCPP16VendorParametersKey.MeterPublicKey}${connectorId.toString()}`
-      )?.value,
+      publicKeyHex,
       publicKeyWithSignedMeterValue: parsePublicKeyWithSignedMeterValue(
         getConfigurationKey(
           chargingStation,
           OCPP16VendorParametersKey.PublicKeyWithSignedMeterValue
         )?.value
       ),
-      signingMethod: getConfigurationKey(chargingStation, OCPP16VendorParametersKey.SigningMethod)
-        ?.value as SigningMethodEnumType | undefined,
+      signingMethod: prerequisiteResult.signingMethod,
     }
   }
 }
index a1bbf3e207a1d7c14a42a0b4b09f2bb340cee0eb..87257d5a743413c7690389268cf88e1350c8af0b 100644 (file)
@@ -69,6 +69,7 @@ import { OCPPConstants } from './OCPPConstants.js'
 import {
   parsePublicKeyWithSignedMeterValue,
   type SampledValueSigningConfig,
+  validateSigningPrerequisites,
 } from './OCPPSignedMeterValueUtils.js'
 
 const moduleName = 'OCPPServiceUtils'
@@ -969,22 +970,33 @@ export const buildMeterValue = (
               chargingStation,
               buildConfigKey(OCPP20ComponentName.FiscalMetering, VendorParametersKey.PublicKey)
             )?.value
-            const signingMethod = getConfigurationKey(
+            const configuredSigningMethod = getConfigurationKey(
               chargingStation,
               buildConfigKey(OCPP20ComponentName.FiscalMetering, VendorParametersKey.SigningMethod)
             )?.value as SigningMethodEnumType | undefined
-            signingConfig = {
-              enabled: true,
-              meterSerialNumber: chargingStation.stationInfo.meterSerialNumber ?? 'UNKNOWN',
+
+            const prerequisiteResult = validateSigningPrerequisites(
               publicKeyHex,
-              publicKeySentInTransaction:
-                chargingStation.getConnectorStatus(connectorId)?.publicKeySentInTransaction ??
-                false,
-              publicKeyWithSignedMeterValue: parsePublicKeyWithSignedMeterValue(
-                publicKeyWithSignedMeterValueStr
-              ),
-              signingMethod,
-              transactionId,
+              configuredSigningMethod
+            )
+            if (prerequisiteResult.enabled) {
+              signingConfig = {
+                enabled: true,
+                meterSerialNumber: chargingStation.stationInfo.meterSerialNumber ?? 'UNKNOWN',
+                publicKeyHex,
+                publicKeySentInTransaction:
+                  chargingStation.getConnectorStatus(connectorId)?.publicKeySentInTransaction ??
+                  false,
+                publicKeyWithSignedMeterValue: parsePublicKeyWithSignedMeterValue(
+                  publicKeyWithSignedMeterValueStr
+                ),
+                signingMethod: prerequisiteResult.signingMethod,
+                transactionId,
+              }
+            } else {
+              logger.warn(
+                `${chargingStation.logPrefix()} ${moduleName}.buildMeterValue: Signed meter values disabled: ${prerequisiteResult.reason}`
+              )
             }
           }
         }
index 599df8bbefb9abfe814d37edacd971b04ac63003..675abcc2746ef30524e471edf22f281e0c0c9d88 100644 (file)
@@ -2,7 +2,7 @@ import { BaseError } from '../../exception/index.js'
 import {
   PublicKeyWithSignedMeterValueEnumType,
   type SampledValue,
-  type SigningMethodEnumType,
+  SigningMethodEnumType,
 } from '../../types/index.js'
 
 export interface SampledValueSigningConfig extends SigningConfig {
@@ -24,6 +24,68 @@ export interface SigningConfig {
   signingMethod?: SigningMethodEnumType
 }
 
+// EC curve OID hex → SigningMethodEnumType (OCA Application Note Table 12)
+const EC_CURVE_OID_MAP = new Map<string, SigningMethodEnumType>([
+  ['06052b8104000a', SigningMethodEnumType.ECDSA_secp256k1_SHA256],
+  ['06052b8104001f', SigningMethodEnumType.ECDSA_secp192k1_SHA256],
+  ['06052b81040022', SigningMethodEnumType.ECDSA_secp384r1_SHA256],
+  ['06082a8648ce3d030101', SigningMethodEnumType.ECDSA_secp192r1_SHA256],
+  ['06082a8648ce3d030107', SigningMethodEnumType.ECDSA_secp256r1_SHA256],
+  ['06092b240303020801010b', SigningMethodEnumType.ECDSA_brainpool384r1_SHA256],
+  ['06092b2403030208010107', SigningMethodEnumType.ECDSA_brainpool256r1_SHA256],
+])
+
+export const deriveSigningMethodFromPublicKeyHex = (
+  publicKeyHex: string
+): SigningMethodEnumType | undefined => {
+  const hex = publicKeyHex.toLowerCase().replace(/[^0-9a-f]/g, '')
+  for (const [oid, method] of EC_CURVE_OID_MAP) {
+    if (hex.includes(oid)) {
+      return method
+    }
+  }
+  return undefined
+}
+
+export interface SigningPrerequisiteResult {
+  enabled: false
+  reason: string
+}
+
+export interface SigningPrerequisiteSuccess {
+  enabled: true
+  signingMethod: SigningMethodEnumType
+}
+
+export const validateSigningPrerequisites = (
+  publicKeyHex: string | undefined,
+  configuredSigningMethod: SigningMethodEnumType | undefined
+): SigningPrerequisiteResult | SigningPrerequisiteSuccess => {
+  if (publicKeyHex == null || publicKeyHex.length === 0) {
+    return { enabled: false, reason: 'Public key is not configured' }
+  }
+
+  const derivedMethod = deriveSigningMethodFromPublicKeyHex(publicKeyHex)
+
+  if (derivedMethod == null) {
+    return {
+      enabled: false,
+      reason: 'Cannot derive EC curve from public key hex — unsupported or malformed ASN.1 key',
+    }
+  }
+
+  if (configuredSigningMethod != null && configuredSigningMethod !== derivedMethod) {
+    return {
+      enabled: false,
+      reason:
+        `SigningMethod mismatch: configured '${configuredSigningMethod}' ` +
+        `but public key uses '${derivedMethod}'`,
+    }
+  }
+
+  return { enabled: true, signingMethod: configuredSigningMethod ?? derivedMethod }
+}
+
 const PUBLIC_KEY_WITH_SIGNED_METER_VALUE_VALUES = new Set<string>(
   Object.values(PublicKeyWithSignedMeterValueEnumType)
 )
index eead34cfbd009f7d97b7f34c4e0d3d0e775e179e..fd277d4f07e3463d796d7b233b6465e2fbf4dc84 100644 (file)
@@ -5,8 +5,16 @@
 import assert from 'node:assert/strict'
 import { describe, it } from 'node:test'
 
-import { shouldIncludePublicKey } from '../../../src/charging-station/ocpp/OCPPSignedMeterValueUtils.js'
-import { PublicKeyWithSignedMeterValueEnumType } from '../../../src/types/index.js'
+import {
+  deriveSigningMethodFromPublicKeyHex,
+  shouldIncludePublicKey,
+  validateSigningPrerequisites,
+} from '../../../src/charging-station/ocpp/OCPPSignedMeterValueUtils.js'
+import {
+  PublicKeyWithSignedMeterValueEnumType,
+  SigningMethodEnumType,
+} from '../../../src/types/index.js'
+import { TEST_PUBLIC_KEY_HEX } from '../ChargingStationTestConstants.js'
 
 await describe('SignedMeterValueUtils', async () => {
   await describe('PublicKeyWithSignedMeterValueEnumType', async () => {
@@ -74,4 +82,81 @@ await describe('SignedMeterValueUtils', async () => {
       )
     })
   })
+
+  await describe('deriveSigningMethodFromPublicKeyHex', async () => {
+    await it('should derive secp256k1 from OCA spec §5.3 public key', () => {
+      assert.strictEqual(
+        deriveSigningMethodFromPublicKeyHex(TEST_PUBLIC_KEY_HEX),
+        SigningMethodEnumType.ECDSA_secp256k1_SHA256
+      )
+    })
+
+    await it('should derive secp256r1 from key with OID 06082a8648ce3d030107', () => {
+      const secp256r1Key = '3059301306072a8648ce3d020106082a8648ce3d03010703420004abcd'
+      assert.strictEqual(
+        deriveSigningMethodFromPublicKeyHex(secp256r1Key),
+        SigningMethodEnumType.ECDSA_secp256r1_SHA256
+      )
+    })
+
+    await it('should handle mixed case hex', () => {
+      assert.strictEqual(
+        deriveSigningMethodFromPublicKeyHex(TEST_PUBLIC_KEY_HEX.toUpperCase()),
+        SigningMethodEnumType.ECDSA_secp256k1_SHA256
+      )
+    })
+
+    await it('should return undefined for unrecognized key', () => {
+      assert.strictEqual(deriveSigningMethodFromPublicKeyHex('deadbeef'), undefined)
+    })
+
+    await it('should return undefined for empty string', () => {
+      assert.strictEqual(deriveSigningMethodFromPublicKeyHex(''), undefined)
+    })
+  })
+
+  await describe('validateSigningPrerequisites', async () => {
+    await it('should return enabled with derived method when no configured method', () => {
+      const result = validateSigningPrerequisites(TEST_PUBLIC_KEY_HEX, undefined)
+      assert.strictEqual(result.enabled, true)
+      assert.strictEqual(
+        (result as { signingMethod: SigningMethodEnumType }).signingMethod,
+        SigningMethodEnumType.ECDSA_secp256k1_SHA256
+      )
+    })
+
+    await it('should return enabled when configured method matches key curve', () => {
+      const result = validateSigningPrerequisites(
+        TEST_PUBLIC_KEY_HEX,
+        SigningMethodEnumType.ECDSA_secp256k1_SHA256
+      )
+      assert.strictEqual(result.enabled, true)
+    })
+
+    await it('should return disabled when public key is undefined', () => {
+      const result = validateSigningPrerequisites(undefined, undefined)
+      assert.strictEqual(result.enabled, false)
+      assert.ok((result as { reason: string }).reason.includes('Public key'))
+    })
+
+    await it('should return disabled when public key is empty', () => {
+      const result = validateSigningPrerequisites('', undefined)
+      assert.strictEqual(result.enabled, false)
+    })
+
+    await it('should return disabled when key has unrecognized curve', () => {
+      const result = validateSigningPrerequisites('deadbeef', undefined)
+      assert.strictEqual(result.enabled, false)
+      assert.ok((result as { reason: string }).reason.includes('Cannot derive'))
+    })
+
+    await it('should return disabled when configured method mismatches key curve', () => {
+      const result = validateSigningPrerequisites(
+        TEST_PUBLIC_KEY_HEX,
+        SigningMethodEnumType.ECDSA_secp256r1_SHA256
+      )
+      assert.strictEqual(result.enabled, false)
+      assert.ok((result as { reason: string }).reason.includes('mismatch'))
+    })
+  })
 })