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'
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 => {
-import { createSign, generateKeyPairSync } from 'node:crypto'
+import { generateKeyPairSync, sign } from 'node:crypto'
// ASN.1 DER encoding helpers for PKCS#10 CSR generation (RFC 2986)
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])
}
// 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.
*
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(
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
+}
// 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'
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
* - 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.
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
}) as Buffer
}
- const issuerKeyHash = createHash(algorithmName).update(issuerPublicKeyDer).digest('hex')
+ const issuerKeyHash = hash(algorithmName, issuerPublicKeyDer, 'hex')
const serialNumber = x509.serialNumber
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 }
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)
* @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`)
}
const certificateHashDataChain: CertificateHashDataChainType[] = []
try {
- const basePath = this.getStationCertificatesBasePath(stationHashId)
+ const basePath = await this.getStationCertificatesBasePath(stationHashId)
if (!(await this.pathExists(basePath))) {
return { certificateHashDataChain }
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)
hashAlgorithm?: HashAlgorithmEnumType
): Promise<boolean> {
try {
- const certFilePath = this.getCertificatePath(
+ const certFilePath = await this.getCertificatePath(
stationHashId,
CertificateSigningUseEnumType.ChargingStationCertificate,
''
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(
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))) {
}
/**
- * 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 {
// 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)
}
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 {
* @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
}
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`
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,
-import { createHash } from 'node:crypto'
+import { hash } from 'node:crypto'
import {
EncodingMethodEnumType,
],
}
- 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
type SampledValue,
SigningMethodEnumType,
} from '../../types/index.js'
+import { logger } from '../../utils/index.js'
export interface SampledValueSigningConfig extends SigningConfig {
enabled: boolean
}
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
}
}
}
export enum PersistenceEnumType {
+ Ephemeral = 'Ephemeral',
Persistent = 'Persistent',
Volatile = 'Volatile',
}
*/
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(() => {
)
})
})
+
+ 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)
+ })
+ })
})
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'
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'
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'
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'
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'
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('..'))
})
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'))
+ })
})
})
*/
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)
// ============================================================================