From: Jérôme Benoit Date: Tue, 7 Apr 2026 20:42:02 +0000 (+0200) Subject: feat(ocpp): add signing prerequisites validation and EC curve auto-derivation X-Git-Tag: ocpp-server@v4.4.0~5 X-Git-Url: https://git.piment-noir.org/?a=commitdiff_plain;h=7b072f9dc1f4095335c4aeecacb5a40c023d1d2d;p=e-mobility-charging-stations-simulator.git feat(ocpp): add signing prerequisites validation and EC curve auto-derivation - 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 --- diff --git a/src/charging-station/ocpp/1.6/OCPP16ServiceUtils.ts b/src/charging-station/ocpp/1.6/OCPP16ServiceUtils.ts index 2168e4c0..09f195e1 100644 --- a/src/charging-station/ocpp/1.6/OCPP16ServiceUtils.ts +++ b/src/charging-station/ocpp/1.6/OCPP16ServiceUtils.ts @@ -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, } } } diff --git a/src/charging-station/ocpp/OCPPServiceUtils.ts b/src/charging-station/ocpp/OCPPServiceUtils.ts index a1bbf3e2..87257d5a 100644 --- a/src/charging-station/ocpp/OCPPServiceUtils.ts +++ b/src/charging-station/ocpp/OCPPServiceUtils.ts @@ -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}` + ) } } } diff --git a/src/charging-station/ocpp/OCPPSignedMeterValueUtils.ts b/src/charging-station/ocpp/OCPPSignedMeterValueUtils.ts index 599df8bb..675abcc2 100644 --- a/src/charging-station/ocpp/OCPPSignedMeterValueUtils.ts +++ b/src/charging-station/ocpp/OCPPSignedMeterValueUtils.ts @@ -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([ + ['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( Object.values(PublicKeyWithSignedMeterValueEnumType) ) diff --git a/tests/charging-station/ocpp/OCPPSignedMeterValueUtils.test.ts b/tests/charging-station/ocpp/OCPPSignedMeterValueUtils.test.ts index eead34cf..fd277d4f 100644 --- a/tests/charging-station/ocpp/OCPPSignedMeterValueUtils.test.ts +++ b/tests/charging-station/ocpp/OCPPSignedMeterValueUtils.test.ts @@ -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')) + }) + }) })