]> Piment Noir Git Repositories - e-mobility-charging-stations-simulator.git/commitdiff
refactor(crypto): modernize crypto APIs and harden certificate handling
authorJérôme Benoit <jerome.benoit@sap.com>
Tue, 7 Apr 2026 22:30:11 +0000 (00:30 +0200)
committerJérôme Benoit <jerome.benoit@sap.com>
Tue, 7 Apr 2026 22:30:11 +0000 (00:30 +0200)
- 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
src/charging-station/ocpp/2.0/Asn1DerUtils.ts
src/charging-station/ocpp/2.0/OCPP20CertificateManager.ts
src/charging-station/ocpp/2.0/OCPP20VariableRegistry.ts
src/charging-station/ocpp/OCPPSignedMeterDataGenerator.ts
src/charging-station/ocpp/OCPPSignedMeterValueUtils.ts
src/types/ocpp/2.0/Variables.ts
tests/charging-station/ocpp/2.0/Asn1DerUtils.test.ts
tests/charging-station/ocpp/2.0/OCPP20CertificateManager.test.ts
tests/charging-station/ocpp/2.0/OCPP20CertificateTestData.ts

index 6b33ebfb6b60d81fc33961662509bc8b63fdfb34..57d73f5c230e08d89a95a17bd0c96e5f5d848b88 100644 (file)
@@ -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 => {
index 0cbabb8f1256aa711414aa45bddf2773dc5fc813..f314b4157c0a764527618199e6e826a1081de080 100644 (file)
@@ -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
+}
index 338b839e20b59d672c5d248594dcbffece6c83fd..2e43be421d6fb12bac32239137c81b836620fecd 100644 (file)
@@ -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<DeleteCertificateResult> {
     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<string> {
+    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<boolean> {
     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<string> {
     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<string> {
+    // 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`
index f66af2f82ab1cf47794cd3b0837fd16b19f69299..97d2dde7343c8d7a4d9e2809a38ec7e6a1f75247 100644 (file)
@@ -2161,7 +2161,7 @@ export const VARIABLE_REGISTRY: Record<string, VariableMetadata> = {
     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,
index cd49afa6945ec47980a9e036f595440a8aed9092..d416f0123b074894e71b34c7702e68ec75560d06 100644 (file)
@@ -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
index c17603377b12aa39f638ef85bdc0c995f021619b..fb2bd6c3fd759070b2222fe641206e844d1a4db0 100644 (file)
@@ -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
   }
 }
index 0f12c9e9f0c680072f62a4e8d6138bb9ca9cb4af..dc1722ebfdd31d195eb5c22e552d9b2dff02e577 100644 (file)
@@ -98,6 +98,7 @@ export enum OCPP20VendorVariableName {
 }
 
 export enum PersistenceEnumType {
+  Ephemeral = 'Ephemeral',
   Persistent = 'Persistent',
   Volatile = 'Volatile',
 }
index 7083c0f968222a3da35bb752462a64f6bc09bfef..cdb6ab7a232e3d4a10f018fcf2fa7131db99785b 100644 (file)
@@ -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)
+    })
+  })
 })
index 3327bea314e8a5e2a82e308cb03b0301f3004a4b..c29251c8f89872cb8154238f1661bca9d6c21a38 100644 (file)
@@ -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'))
+    })
   })
 })
index 13c2a5400c0e344d331b36bbfdcdc0f1b8cccefe..2164717a961d7c83521fbfece578bfc86a227a55 100644 (file)
@@ -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)
 // ============================================================================