From 66d6967de66c742f8db1a9ea1e81f1a5c56c2ff7 Mon Sep 17 00:00:00 2001 From: =?utf8?q?J=C3=A9r=C3=B4me=20Benoit?= Date: Wed, 8 Apr 2026 00:30:11 +0200 Subject: [PATCH] refactor(crypto): modernize crypto APIs and harden certificate handling MIME-Version: 1.0 Content-Type: text/plain; charset=utf8 Content-Transfer-Encoding: 8bit - Migrate createHash() to crypto.hash() one-shot API (Node.js 22+) - Migrate createSign() to crypto.sign() one-shot API - Add derLength() bounds check for lengths > 0xFFFF - Change CertificatePrivateKey persistence to Ephemeral - Add logger.debug in deriveSigningMethodFromPublicKeyHex catch block - Harden validateCertificatePath with async realpath for symlink protection (falls back to resolve for not-yet-created paths) - Add X.509 chain validation in validateCertificateX509: verify issuer linkage and signatures for all certificates, validate validity period for entire chain (not just leaf), document leaf-first ordering - Implement RFC 6960 §4.1.1 compliant issuerNameHash via DER issuer extraction from certificate raw bytes (extractDerIssuer) - Move DER parsing functions (readDerLength, skipDerElement, extractDerIssuer) to Asn1DerUtils with proper JSDoc - Rename readDerTagLength to readDerLength for accuracy - Add unit tests for DER parsing functions and chain validation - Add real CA + leaf certificate test fixtures --- src/charging-station/Helpers.ts | 10 +- src/charging-station/ocpp/2.0/Asn1DerUtils.ts | 80 ++++++++++- .../ocpp/2.0/OCPP20CertificateManager.ts | 135 +++++++++++------- .../ocpp/2.0/OCPP20VariableRegistry.ts | 2 +- .../ocpp/OCPPSignedMeterDataGenerator.ts | 4 +- .../ocpp/OCPPSignedMeterValueUtils.ts | 6 +- src/types/ocpp/2.0/Variables.ts | 1 + .../ocpp/2.0/Asn1DerUtils.test.ts | 77 ++++++++++ .../ocpp/2.0/OCPP20CertificateManager.test.ts | 51 +++++-- .../ocpp/2.0/OCPP20CertificateTestData.ts | 26 ++++ 10 files changed, 314 insertions(+), 78 deletions(-) diff --git a/src/charging-station/Helpers.ts b/src/charging-station/Helpers.ts index 6b33ebfb..57d73f5c 100644 --- a/src/charging-station/Helpers.ts +++ b/src/charging-station/Helpers.ts @@ -16,7 +16,7 @@ import { toDate, } from 'date-fns' import { maxTime } from 'date-fns/constants' -import { createHash, randomBytes } from 'node:crypto' +import { hash, randomBytes } from 'node:crypto' import { basename, dirname, isAbsolute, join, parse, relative, resolve } from 'node:path' import { env } from 'node:process' import { fileURLToPath } from 'node:url' @@ -173,9 +173,11 @@ export const getHashId = (index: number, stationTemplate: ChargingStationTemplat meterType: stationTemplate.meterType, }), } - return createHash(Constants.DEFAULT_HASH_ALGORITHM) - .update(`${JSON.stringify(chargingStationInfo)}${getChargingStationId(index, stationTemplate)}`) - .digest('hex') + return hash( + Constants.DEFAULT_HASH_ALGORITHM, + `${JSON.stringify(chargingStationInfo)}${getChargingStationId(index, stationTemplate)}`, + 'hex' + ) } export const validateStationInfo = (chargingStation: ChargingStation): void => { diff --git a/src/charging-station/ocpp/2.0/Asn1DerUtils.ts b/src/charging-station/ocpp/2.0/Asn1DerUtils.ts index 0cbabb8f..f314b415 100644 --- a/src/charging-station/ocpp/2.0/Asn1DerUtils.ts +++ b/src/charging-station/ocpp/2.0/Asn1DerUtils.ts @@ -1,4 +1,4 @@ -import { createSign, generateKeyPairSync } from 'node:crypto' +import { generateKeyPairSync, sign } from 'node:crypto' // ASN.1 DER encoding helpers for PKCS#10 CSR generation (RFC 2986) @@ -26,6 +26,9 @@ export function derLength (length: number): Buffer { if (length < 0x100) { return Buffer.from([0x81, length]) } + if (length > 0xffff) { + throw new RangeError(`derLength: length ${String(length)} exceeds maximum supported (65535)`) + } return Buffer.from([0x82, (length >> 8) & 0xff, length & 0xff]) } @@ -110,6 +113,37 @@ const OID_COMMON_NAME = [0x55, 0x04, 0x03] // 2.5.4.10 — organizationName const OID_ORGANIZATION = [0x55, 0x04, 0x0a] +/** + * Extract the raw DER-encoded issuer DN from an X.509 certificate per RFC 6960 §4.1.1. + * @param raw - Full DER-encoded X.509 certificate (from X509Certificate.raw) + * @returns DER bytes of the issuer Name SEQUENCE + */ +export function extractDerIssuer (raw: Buffer): Buffer { + if (raw.length < 2 || raw[0] !== 0x30) { + throw new RangeError('DER: expected SEQUENCE tag (0x30) at start of X.509 certificate') + } + // Certificate ::= SEQUENCE { TBSCertificate SEQUENCE { ... } ... } + const { end: certValueStart } = readDerLength(raw, 1) + // TBSCertificate ::= SEQUENCE { version?, serialNumber, algorithm, issuer, ... } + let pos = certValueStart + const { end: tbsValueStart } = readDerLength(raw, pos + 1) + pos = tbsValueStart + + // Skip [0] EXPLICIT version if present (tag = 0xa0) + if (raw[pos] === 0xa0) { + pos = skipDerElement(raw, pos) + } + // Skip serialNumber (INTEGER) + pos = skipDerElement(raw, pos) + // Skip signature algorithm (SEQUENCE) + pos = skipDerElement(raw, pos) + + // issuer is the current element — capture its raw DER bytes + const issuerStart = pos + const issuerEnd = skipDerElement(raw, pos) + return raw.subarray(issuerStart, issuerEnd) +} + /** * Generate a PKCS#10 Certificate Signing Request (RFC 2986) using node:crypto. * @@ -133,9 +167,7 @@ export function generatePkcs10Csr (cn: string, org: string): string { const certificationRequestInfo = derSequence(version, subject, publicKeyDer, attributes) // Sign the CertificationRequestInfo with SHA-256 - const signer = createSign('SHA256') - signer.update(certificationRequestInfo) - const signature = signer.sign(privateKey) + const signature = sign('SHA256', certificationRequestInfo, privateKey) // AlgorithmIdentifier ::= SEQUENCE { algorithm OID, parameters NULL } const signatureAlgorithm = derSequence( @@ -151,3 +183,43 @@ export function generatePkcs10Csr (cn: string, org: string): string { const lines = base64.match(/.{1,64}/g) ?? [] return `-----BEGIN CERTIFICATE REQUEST-----\n${lines.join('\n')}\n-----END CERTIFICATE REQUEST-----` } + +/** + * Parse a DER length field starting at offset (after the tag byte). + * @param buf - DER-encoded buffer + * @param offset - Position of the first length byte + * @returns Object with end (position after length bytes) and decoded length value + */ +export function readDerLength (buf: Buffer, offset: number): { end: number; length: number } { + if (offset >= buf.length) { + throw new RangeError('DER: unexpected end of data') + } + const firstByte = buf[offset] + if (firstByte < 0x80) { + return { end: offset + 1, length: firstByte } + } + const numBytes = firstByte & 0x7f + if (numBytes === 0 || numBytes > 3 || offset + 1 + numBytes > buf.length) { + throw new RangeError('DER: invalid length encoding') + } + let length = 0 + for (let i = 0; i < numBytes; i++) { + length = length * 256 + buf[offset + 1 + i] + } + return { end: offset + 1 + numBytes, length } +} + +/** + * Skip a DER TLV element and return the offset after it. + * @param buf - DER-encoded buffer + * @param offset - Position of the tag byte + * @returns Offset immediately after the element's value + */ +export function skipDerElement (buf: Buffer, offset: number): number { + if (offset >= buf.length) { + throw new RangeError('DER: unexpected end of data') + } + const tagOffset = offset + 1 + const { end: valueStart, length } = readDerLength(buf, tagOffset) + return valueStart + length +} diff --git a/src/charging-station/ocpp/2.0/OCPP20CertificateManager.ts b/src/charging-station/ocpp/2.0/OCPP20CertificateManager.ts index 338b839e..2e43be42 100644 --- a/src/charging-station/ocpp/2.0/OCPP20CertificateManager.ts +++ b/src/charging-station/ocpp/2.0/OCPP20CertificateManager.ts @@ -1,7 +1,7 @@ // Copyright Jerome Benoit. 2021-2025. All Rights Reserved. -import { createHash, X509Certificate } from 'node:crypto' -import { mkdir, readdir, readFile, rm, stat, writeFile } from 'node:fs/promises' +import { hash, X509Certificate } from 'node:crypto' +import { mkdir, readdir, readFile, realpath, rm, stat, writeFile } from 'node:fs/promises' import { join, resolve, sep } from 'node:path' import type { ChargingStation } from '../../ChargingStation.js' @@ -17,6 +17,7 @@ import { InstallCertificateUseEnumType, } from '../../../types/index.js' import { convertToDate, getErrorMessage, isEmpty, isNotEmptyArray } from '../../../utils/index.js' +import { extractDerIssuer } from './Asn1DerUtils.js' /** * Interface for ChargingStation with certificate manager @@ -107,11 +108,8 @@ export class OCPP20CertificateManager { * - issuerKeyHash: Hash of the issuer's public key (from the issuer certificate) * - serialNumber: The certificate's serial number * @remarks - * **RFC 6960 §4.1.1 deviation**: Per RFC 6960, `issuerNameHash` must be the hash of the - * DER-encoded issuer distinguished name. This implementation hashes the string DN - * representation from `X509Certificate.issuer` as a simulation approximation. Full RFC 6960 - * compliance would require ASN.1/DER encoding of the issuer name, which is outside the scope - * of this simulator. See also: mock CSR generation in the SignCertificate handler. + * **RFC 6960 §4.1.1 compliant**: `issuerNameHash` is computed from the DER-encoded issuer + * distinguished name extracted from the raw X.509 certificate, per RFC 6960 requirements. * @param pemData - PEM-encoded certificate data * @param hashAlgorithm - Hash algorithm to use (default: SHA256) * @param issuerCertPem - Optional PEM-encoded issuer certificate for issuerKeyHash computation. @@ -136,10 +134,9 @@ export class OCPP20CertificateManager { const firstCertPem = this.extractFirstCertificate(pemData) const x509 = new X509Certificate(firstCertPem) - // RFC 6960 §4.1.1 deviation: spec requires hash of DER-encoded issuer distinguished name. - // Using string DN from X509Certificate.issuer as simulation approximation - // (ASN.1/DER encoding of the issuer name is out of scope for this simulator). - const issuerNameHash = createHash(algorithmName).update(x509.issuer).digest('hex') + // RFC 6960 §4.1.1: issuerNameHash is the hash of the DER-encoded issuer DN + const issuerDer = extractDerIssuer(x509.raw) + const issuerNameHash = hash(algorithmName, issuerDer, 'hex') // RFC 6960 §4.1.1: issuerKeyHash is the hash of the issuer certificate's public key // Determine which public key to use for issuerKeyHash @@ -167,7 +164,7 @@ export class OCPP20CertificateManager { }) as Buffer } - const issuerKeyHash = createHash(algorithmName).update(issuerPublicKeyDer).digest('hex') + const issuerKeyHash = hash(algorithmName, issuerPublicKeyDer, 'hex') const serialNumber = x509.serialNumber @@ -195,7 +192,7 @@ export class OCPP20CertificateManager { hashData: CertificateHashDataType ): Promise { try { - const basePath = this.getStationCertificatesBasePath(stationHashId) + const basePath = await this.getStationCertificatesBasePath(stationHashId) if (!(await this.pathExists(basePath))) { return { status: DeleteCertificateStatusEnumType.NotFound } @@ -213,7 +210,7 @@ export class OCPP20CertificateManager { for (const file of files) { const filePath = join(certTypeDir, file) - this.validateCertificatePath(filePath, OCPP20CertificateManager.BASE_CERT_PATH) + await this.validateCertificatePath(filePath, OCPP20CertificateManager.BASE_CERT_PATH) try { const pemData = await readFile(filePath, 'utf8') const certHash = this.computeCertificateHash(pemData, hashData.hashAlgorithm) @@ -246,12 +243,12 @@ export class OCPP20CertificateManager { * @param serialNumber - Certificate serial number * @returns Full path where the certificate should be stored */ - public getCertificatePath ( + public async getCertificatePath ( stationHashId: string, certType: CertificateSigningUseEnumType | InstallCertificateUseEnumType, serialNumber: string - ): string { - const basePath = this.getStationCertificatesBasePath(stationHashId) + ): Promise { + const basePath = await this.getStationCertificatesBasePath(stationHashId) const sanitizedSerial = this.sanitizeSerial(serialNumber) return join(basePath, certType, `${sanitizedSerial}.pem`) } @@ -271,7 +268,7 @@ export class OCPP20CertificateManager { const certificateHashDataChain: CertificateHashDataChainType[] = [] try { - const basePath = this.getStationCertificatesBasePath(stationHashId) + const basePath = await this.getStationCertificatesBasePath(stationHashId) if (!(await this.pathExists(basePath))) { return { certificateHashDataChain } @@ -295,7 +292,7 @@ export class OCPP20CertificateManager { for (const file of files) { const filePath = join(certTypeDir, file) - this.validateCertificatePath(filePath, OCPP20CertificateManager.BASE_CERT_PATH) + await this.validateCertificatePath(filePath, OCPP20CertificateManager.BASE_CERT_PATH) try { const pemData = await readFile(filePath, 'utf8') const hashData = this.computeCertificateHash(pemData, hashAlgorithm) @@ -332,7 +329,7 @@ export class OCPP20CertificateManager { hashAlgorithm?: HashAlgorithmEnumType ): Promise { try { - const certFilePath = this.getCertificatePath( + const certFilePath = await this.getCertificatePath( stationHashId, CertificateSigningUseEnumType.ChargingStationCertificate, '' @@ -350,7 +347,7 @@ export class OCPP20CertificateManager { for (const file of pemFiles) { const filePath = join(dirPath, file) - this.validateCertificatePath(filePath, OCPP20CertificateManager.BASE_CERT_PATH) + await this.validateCertificatePath(filePath, OCPP20CertificateManager.BASE_CERT_PATH) try { const pemData = await readFile(filePath, 'utf8') const hashData = this.computeCertificateHash( @@ -406,9 +403,9 @@ export class OCPP20CertificateManager { serialNumber = this.generateFallbackSerialNumber(firstCertPem) } - const filePath = this.getCertificatePath(stationHashId, certType, serialNumber) + const filePath = await this.getCertificatePath(stationHashId, certType, serialNumber) - this.validateCertificatePath(filePath, OCPP20CertificateManager.BASE_CERT_PATH) + await this.validateCertificatePath(filePath, OCPP20CertificateManager.BASE_CERT_PATH) const dirPath = resolve(filePath, '..') if (!(await this.pathExists(dirPath))) { @@ -452,37 +449,53 @@ export class OCPP20CertificateManager { } /** - * Validates a PEM certificate using X.509 structural parsing. - * Checks validity period (notBefore/notAfter) and issuer presence per A02.FR.06. - * - * **Design choice**: Only the first certificate in a PEM chain is validated. - * Full chain-of-trust verification (A02.FR.06 hierarchy check) is not implemented — - * the simulator performs structural validation only, consistent with the medium-depth - * X.509 scope defined in the audit remediation plan. - * @param pem - PEM-encoded certificate data (may contain a chain; only first cert is validated) + * Validates a PEM certificate chain using X.509 structural parsing. + * Checks validity period (notBefore/notAfter) for all certificates and verifies + * chain-of-trust by checking issuance and signature for each consecutive pair. + * @param pem - PEM-encoded certificate data (may contain a chain, ordered leaf → intermediate → root) * @returns Validation result with reason on failure */ public validateCertificateX509 (pem: string): ValidateCertificateX509Result { try { - const firstCertMatch = /-----BEGIN CERTIFICATE-----[\s\S]*?-----END CERTIFICATE-----/.exec( - pem + const pemCertificates = pem.match( + /-----BEGIN CERTIFICATE-----[\s\S]*?-----END CERTIFICATE-----/g ) - if (firstCertMatch == null) { + if (pemCertificates == null || pemCertificates.length === 0) { return { reason: 'No PEM certificate found', valid: false } } - const cert = new X509Certificate(firstCertMatch[0]) + + const certs = pemCertificates.map(p => new X509Certificate(p)) const now = new Date() - const validFromDate = convertToDate(cert.validFrom) - const validToDate = convertToDate(cert.validTo) - if (validFromDate != null && now < validFromDate) { - return { reason: 'Certificate is not yet valid', valid: false } - } - if (validToDate != null && now > validToDate) { - return { reason: 'Certificate has expired', valid: false } + + for (const cert of certs) { + const validFromDate = convertToDate(cert.validFrom) + const validToDate = convertToDate(cert.validTo) + if (validFromDate != null && now < validFromDate) { + return { reason: 'Certificate is not yet valid', valid: false } + } + if (validToDate != null && now > validToDate) { + return { reason: 'Certificate has expired', valid: false } + } + if (!cert.issuer.trim()) { + return { reason: 'Certificate has no issuer', valid: false } + } } - if (!cert.issuer.trim()) { - return { reason: 'Certificate has no issuer', valid: false } + + for (let i = 0; i < certs.length - 1; i++) { + if (!certs[i].checkIssued(certs[i + 1])) { + return { + reason: 'Certificate chain verification failed: issuer mismatch', + valid: false, + } + } + if (!certs[i].verify(certs[i + 1].publicKey)) { + return { + reason: 'Certificate chain verification failed: signature verification failed', + valid: false, + } + } } + return { valid: true } } catch (error) { return { @@ -519,10 +532,8 @@ export class OCPP20CertificateManager { // Use first 64 bytes as issuer name proxy: in DER-encoded X.509, the issuer DN // typically resides within this range, providing a stable hash for matching. const issuerNameSliceEnd = Math.min(64, contentBuffer.length) - const issuerNameHash = createHash(algorithmName) - .update(contentBuffer.subarray(0, issuerNameSliceEnd)) - .digest('hex') - const issuerKeyHash = createHash(algorithmName).update(contentBuffer).digest('hex') + const issuerNameHash = hash(algorithmName, contentBuffer.subarray(0, issuerNameSliceEnd), 'hex') + const issuerKeyHash = hash(algorithmName, contentBuffer, 'hex') const serialNumber = this.generateFallbackSerialNumber(pemData) @@ -546,7 +557,7 @@ export class OCPP20CertificateManager { } private generateFallbackSerialNumber (pemData: string): string { - return createHash('sha256').update(pemData).digest('hex').substring(0, 16).toUpperCase() + return hash('sha256', pemData, 'hex').substring(0, 16).toUpperCase() } private getHashAlgorithmName (hashAlgorithm: HashAlgorithmEnumType): string { @@ -568,14 +579,14 @@ export class OCPP20CertificateManager { * @returns Validated base path for certificate storage * @throws {Error} If path validation fails (path traversal attempt) */ - private getStationCertificatesBasePath (stationHashId: string): string { + private async getStationCertificatesBasePath (stationHashId: string): Promise { const sanitizedHashId = this.sanitizePath(stationHashId) const basePath = join( OCPP20CertificateManager.BASE_CERT_PATH, sanitizedHashId, OCPP20CertificateManager.CERT_FOLDER ) - this.validateCertificatePath(basePath, OCPP20CertificateManager.BASE_CERT_PATH) + await this.validateCertificatePath(basePath, OCPP20CertificateManager.BASE_CERT_PATH) return basePath } @@ -621,11 +632,25 @@ export class OCPP20CertificateManager { return serial.replace(/:/g, '-').replace(/[/\\<>"|?*]/g, '_') } - private validateCertificatePath (certificateFileName: string, baseDir: string): string { - const baseResolved = resolve(baseDir) - const fileResolved = resolve(baseDir, certificateFileName) + private async validateCertificatePath ( + certificateFileName: string, + baseDir: string + ): Promise { + // Resolve symlinks when paths exist; falls back to resolve() for not-yet-created paths + let baseResolved: string + try { + baseResolved = await realpath(resolve(baseDir)) + } catch { + baseResolved = resolve(baseDir) + } + + let fileResolved: string + try { + fileResolved = await realpath(resolve(baseDir, certificateFileName)) + } catch { + fileResolved = resolve(baseDir, certificateFileName) + } - // Check if resolved path is within the base directory if (!fileResolved.startsWith(baseResolved + sep) && fileResolved !== baseResolved) { throw new BaseError( `Path traversal attempt detected: certificate path '${certificateFileName}' resolves outside base directory` diff --git a/src/charging-station/ocpp/2.0/OCPP20VariableRegistry.ts b/src/charging-station/ocpp/2.0/OCPP20VariableRegistry.ts index f66af2f8..97d2dde7 100644 --- a/src/charging-station/ocpp/2.0/OCPP20VariableRegistry.ts +++ b/src/charging-station/ocpp/2.0/OCPP20VariableRegistry.ts @@ -2161,7 +2161,7 @@ export const VARIABLE_REGISTRY: Record = { description: 'Private key material upload placeholder; write-only for security.', maxLength: 2048, mutability: MutabilityEnumType.WriteOnly, - persistence: PersistenceEnumType.Persistent, + persistence: PersistenceEnumType.Ephemeral, supportedAttributes: [AttributeEnumType.Actual], variable: OCPP20VendorVariableName.CertificatePrivateKey, vendorSpecific: true, diff --git a/src/charging-station/ocpp/OCPPSignedMeterDataGenerator.ts b/src/charging-station/ocpp/OCPPSignedMeterDataGenerator.ts index cd49afa6..d416f012 100644 --- a/src/charging-station/ocpp/OCPPSignedMeterDataGenerator.ts +++ b/src/charging-station/ocpp/OCPPSignedMeterDataGenerator.ts @@ -1,4 +1,4 @@ -import { createHash } from 'node:crypto' +import { hash } from 'node:crypto' import { EncodingMethodEnumType, @@ -74,7 +74,7 @@ export const generateSignedMeterData = ( ], } - const simulatedSignature = createHash('sha256').update(JSON.stringify(ocmfPayload)).digest('hex') + const simulatedSignature = hash('sha256', JSON.stringify(ocmfPayload), 'hex') // OCMF includes the signing algorithm in the SA field of signedMeterData. // Per OCA Application Note Table 11: "If it is already included in the diff --git a/src/charging-station/ocpp/OCPPSignedMeterValueUtils.ts b/src/charging-station/ocpp/OCPPSignedMeterValueUtils.ts index c1760337..fb2bd6c3 100644 --- a/src/charging-station/ocpp/OCPPSignedMeterValueUtils.ts +++ b/src/charging-station/ocpp/OCPPSignedMeterValueUtils.ts @@ -6,6 +6,7 @@ import { type SampledValue, SigningMethodEnumType, } from '../../types/index.js' +import { logger } from '../../utils/index.js' export interface SampledValueSigningConfig extends SigningConfig { enabled: boolean @@ -50,7 +51,10 @@ export const deriveSigningMethodFromPublicKeyHex = ( } const namedCurve = key.asymmetricKeyDetails?.namedCurve return namedCurve != null ? NODE_CURVE_TO_SIGNING_METHOD.get(namedCurve) : undefined - } catch { + } catch (error) { + logger.debug( + `deriveSigningMethodFromPublicKeyHex: failed to parse public key: ${(error as Error).message}` + ) return undefined } } diff --git a/src/types/ocpp/2.0/Variables.ts b/src/types/ocpp/2.0/Variables.ts index 0f12c9e9..dc1722eb 100644 --- a/src/types/ocpp/2.0/Variables.ts +++ b/src/types/ocpp/2.0/Variables.ts @@ -98,6 +98,7 @@ export enum OCPP20VendorVariableName { } export enum PersistenceEnumType { + Ephemeral = 'Ephemeral', Persistent = 'Persistent', Volatile = 'Volatile', } diff --git a/tests/charging-station/ocpp/2.0/Asn1DerUtils.test.ts b/tests/charging-station/ocpp/2.0/Asn1DerUtils.test.ts index 7083c0f9..cdb6ab7a 100644 --- a/tests/charging-station/ocpp/2.0/Asn1DerUtils.test.ts +++ b/tests/charging-station/ocpp/2.0/Asn1DerUtils.test.ts @@ -4,15 +4,20 @@ */ import assert from 'node:assert/strict' +import { X509Certificate } from 'node:crypto' import { afterEach, describe, it } from 'node:test' import { derInteger, derLength, derSequence, + extractDerIssuer, generatePkcs10Csr, + readDerLength, + skipDerElement, } from '../../../../src/charging-station/ocpp/2.0/Asn1DerUtils.js' import { standardCleanup } from '../../../helpers/TestLifecycleHelpers.js' +import { VALID_X509_PEM_CERTIFICATE } from './OCPP20CertificateTestData.js' await describe('ASN.1 DER encoding utilities', async () => { afterEach(() => { @@ -111,4 +116,76 @@ await describe('ASN.1 DER encoding utilities', async () => { ) }) }) + + await describe('readDerLength', async () => { + await it('should parse short form length', () => { + const buf = Buffer.from([42]) + const result = readDerLength(buf, 0) + assert.strictEqual(result.length, 42) + assert.strictEqual(result.end, 1) + }) + + await it('should parse single-byte long form (0x81)', () => { + const buf = Buffer.from([0x81, 200]) + const result = readDerLength(buf, 0) + assert.strictEqual(result.length, 200) + assert.strictEqual(result.end, 2) + }) + + await it('should parse two-byte long form (0x82)', () => { + const buf = Buffer.from([0x82, 0x01, 0x2c]) + const result = readDerLength(buf, 0) + assert.strictEqual(result.length, 300) + assert.strictEqual(result.end, 3) + }) + + await it('should throw RangeError for empty buffer', () => { + assert.throws(() => readDerLength(Buffer.alloc(0), 0), RangeError) + }) + + await it('should throw RangeError for numBytes > 3', () => { + const buf = Buffer.from([0x84, 0x01, 0x00, 0x00, 0x00]) + assert.throws(() => readDerLength(buf, 0), RangeError) + }) + }) + + await describe('skipDerElement', async () => { + await it('should skip a short TLV element', () => { + const buf = Buffer.from([0x02, 0x01, 0x00]) + const nextOffset = skipDerElement(buf, 0) + assert.strictEqual(nextOffset, 3) + }) + + await it('should skip a longer TLV element', () => { + const buf = Buffer.from([0x30, 0x03, 0xaa, 0xbb, 0xcc, 0xff]) + const nextOffset = skipDerElement(buf, 0) + assert.strictEqual(nextOffset, 5) + }) + + await it('should throw RangeError for offset beyond buffer', () => { + assert.throws(() => skipDerElement(Buffer.from([0x02]), 5), RangeError) + }) + }) + + await describe('extractDerIssuer', async () => { + await it('should extract DER issuer bytes from a real X.509 certificate', () => { + const x509 = new X509Certificate(VALID_X509_PEM_CERTIFICATE) + const issuerDer = extractDerIssuer(x509.raw) + + assert.ok(Buffer.isBuffer(issuerDer)) + assert.ok(issuerDer.length > 0) + assert.strictEqual(issuerDer[0], 0x30) + }) + + await it('should produce consistent output for the same certificate', () => { + const x509 = new X509Certificate(VALID_X509_PEM_CERTIFICATE) + const first = extractDerIssuer(x509.raw) + const second = extractDerIssuer(x509.raw) + assert.deepStrictEqual(first, second) + }) + + await it('should throw RangeError for truncated DER data', () => { + assert.throws(() => extractDerIssuer(Buffer.from([0x30, 0x03])), RangeError) + }) + }) }) diff --git a/tests/charging-station/ocpp/2.0/OCPP20CertificateManager.test.ts b/tests/charging-station/ocpp/2.0/OCPP20CertificateManager.test.ts index 3327bea3..c29251c8 100644 --- a/tests/charging-station/ocpp/2.0/OCPP20CertificateManager.test.ts +++ b/tests/charging-station/ocpp/2.0/OCPP20CertificateManager.test.ts @@ -22,6 +22,8 @@ import { INVALID_PEM_CERTIFICATE_MISSING_MARKERS, INVALID_PEM_WRONG_MARKERS, VALID_PEM_CERTIFICATE_EXTENDED, + VALID_X509_CA_CERTIFICATE, + VALID_X509_LEAF_CERTIFICATE, VALID_X509_PEM_CERTIFICATE, } from './OCPP20CertificateTestData.js' @@ -336,8 +338,8 @@ await describe('I02-I04 - ISO15118 Certificate Management', async () => { beforeEach(() => { manager = new OCPP20CertificateManager() }) - await it('should return correct file path for certificate', () => { - const path = manager.getCertificatePath( + await it('should return correct file path for certificate', async () => { + const path = await manager.getCertificatePath( TEST_CHARGING_STATION_HASH_ID, TEST_CERT_TYPE, 'SERIAL-12345' @@ -351,8 +353,8 @@ await describe('I02-I04 - ISO15118 Certificate Management', async () => { assert.match(path, /\.pem$/) }) - await it('should handle special characters in serial number', () => { - const path = manager.getCertificatePath( + await it('should handle special characters in serial number', async () => { + const path = await manager.getCertificatePath( TEST_CHARGING_STATION_HASH_ID, TEST_CERT_TYPE, 'SERIAL:ABC/123' @@ -367,14 +369,14 @@ await describe('I02-I04 - ISO15118 Certificate Management', async () => { assert.ok(!filename.includes('/')) }) - await it('should return different paths for different certificate types', () => { - const csmsPath = manager.getCertificatePath( + await it('should return different paths for different certificate types', async () => { + const csmsPath = await manager.getCertificatePath( TEST_CHARGING_STATION_HASH_ID, InstallCertificateUseEnumType.CSMSRootCertificate, 'SERIAL-001' ) - const v2gPath = manager.getCertificatePath( + const v2gPath = await manager.getCertificatePath( TEST_CHARGING_STATION_HASH_ID, InstallCertificateUseEnumType.V2GRootCertificate, 'SERIAL-001' @@ -385,8 +387,8 @@ await describe('I02-I04 - ISO15118 Certificate Management', async () => { assert.ok(v2gPath.includes('V2GRootCertificate')) }) - await it('should return path following project convention', () => { - const path = manager.getCertificatePath( + await it('should return path following project convention', async () => { + const path = await manager.getCertificatePath( TEST_CHARGING_STATION_HASH_ID, TEST_CERT_TYPE, 'SERIAL-12345' @@ -437,10 +439,10 @@ await describe('I02-I04 - ISO15118 Certificate Management', async () => { assert.notStrictEqual(result, undefined) }) - await it('should sanitize station hash ID for filesystem safety', () => { + await it('should sanitize station hash ID for filesystem safety', async () => { const maliciousHashId = '../../../etc/passwd' - const path = manager.getCertificatePath(maliciousHashId, TEST_CERT_TYPE, 'SERIAL-001') + const path = await manager.getCertificatePath(maliciousHashId, TEST_CERT_TYPE, 'SERIAL-001') assert.ok(!path.includes('..')) }) @@ -475,5 +477,32 @@ await describe('I02-I04 - ISO15118 Certificate Management', async () => { assert.strictEqual(typeof result.reason, 'string') assert.ok(result.reason?.includes('No PEM certificate found')) }) + + await it('should return valid for a chain where leaf is signed by CA', () => { + const chain = `${VALID_X509_LEAF_CERTIFICATE}\n${VALID_X509_CA_CERTIFICATE}` + const result = manager.validateCertificateX509(chain) + + assert.strictEqual(result.valid, true) + }) + + await it('should return invalid when chain has issuer mismatch', () => { + const brokenChain = `${VALID_X509_LEAF_CERTIFICATE}\n${VALID_X509_PEM_CERTIFICATE}` + const result = manager.validateCertificateX509(brokenChain) + + assert.strictEqual(result.valid, false) + assert.ok( + // eslint-disable-next-line @typescript-eslint/prefer-nullish-coalescing + result.reason?.includes('issuer mismatch') || + result.reason?.includes('signature verification failed') + ) + }) + + await it('should return invalid when chain has expired intermediate', () => { + const chainWithExpired = `${VALID_X509_PEM_CERTIFICATE}\n${EXPIRED_X509_PEM_CERTIFICATE}` + const result = manager.validateCertificateX509(chainWithExpired) + + assert.strictEqual(result.valid, false) + assert.ok(result.reason?.includes('expired')) + }) }) }) diff --git a/tests/charging-station/ocpp/2.0/OCPP20CertificateTestData.ts b/tests/charging-station/ocpp/2.0/OCPP20CertificateTestData.ts index 13c2a540..2164717a 100644 --- a/tests/charging-station/ocpp/2.0/OCPP20CertificateTestData.ts +++ b/tests/charging-station/ocpp/2.0/OCPP20CertificateTestData.ts @@ -97,6 +97,32 @@ SIb3DQEBCwUAA0EAexpired== */ export const EMPTY_PEM_CERTIFICATE = '' +/** + * Real EC P-256 CA certificate (CN=TestRootCA, valid 2026-2036). + * Used as issuer for VALID_X509_LEAF_CERTIFICATE. + */ +export const VALID_X509_CA_CERTIFICATE = `-----BEGIN CERTIFICATE----- +MIIBGDCBwAIJAMY5KBDzNkHGMAoGCCqGSM49BAMCMBUxEzARBgNVBAMMClRlc3RS +b290Q0EwHhcNMjYwNDA3MjIxMjU1WhcNMzYwNDA0MjIxMjU1WjAVMRMwEQYDVQQD +DApUZXN0Um9vdENBMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEhOoqHSLrnwVr +0Nu78E9rGDQmhGK1qvr2W7ye7yXMHgfFGYMuVr7ejCj6TXk2YPSS8ADRwCRj8R6S +JFomGI2soDAKBggqhkjOPQQDAgNHADBEAiBDCuKOo0v3y/ZTGSf20GQyTtmfibV5 +5t1yXJkVTudOMgIgWqQXoyjI/k2s+T9U38X4S9yTeMkjeNb3FEYVq5kaA5w= +-----END CERTIFICATE-----` + +/** + * Real EC P-256 leaf certificate (CN=TestLeaf, valid 2026-2036). + * Signed by VALID_X509_CA_CERTIFICATE. + */ +export const VALID_X509_LEAF_CERTIFICATE = `-----BEGIN CERTIFICATE----- +MIIBGDCBvgIJAJn8LXzPXkayMAoGCCqGSM49BAMCMBUxEzARBgNVBAMMClRlc3RS +b290Q0EwHhcNMjYwNDA3MjIxMjU1WhcNMzYwNDA0MjIxMjU1WjATMREwDwYDVQQD +DAhUZXN0TGVhZjBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABDMWqNhqXlWIksJi +fYrEQLdPlWG4zAfvc/1q7npD5d+OJu4uwbseA2uXf8/wHgQYrpD3jkXT4b/amhyv +W1dCq+0wCgYIKoZIzj0EAwIDSQAwRgIhAPnXKKIs+8sE+W+3AH9zE3Z51I1ndCks +wD0Gud+kCORgAiEArgyP/lVR0Vh9NWe8iTNVXOyc4s8Jn0J+CF9UsUIGuFA= +-----END CERTIFICATE-----` + // ============================================================================ // Real X.509 Certificates (parseable by node:crypto X509Certificate) // ============================================================================ -- 2.43.0