139 lines
5.2 KiB
TypeScript
139 lines
5.2 KiB
TypeScript
/**
|
|
* Test helpers for generating real X.509 PEM certificates in unit tests.
|
|
*
|
|
* PR #501 (FED-M2-11) introduced strict `new X509Certificate(certPem)` parsing
|
|
* in both EnrollmentService.extractCertNotAfter and CaService.issueCert — dummy
|
|
* cert strings now throw `error:0680007B:asn1 encoding routines::header too long`.
|
|
*
|
|
* These helpers produce minimal but cryptographically valid self-signed EC P-256
|
|
* certificates via @peculiar/x509 + Node.js webcrypto, suitable for test mocks.
|
|
*
|
|
* Two variants:
|
|
* - makeSelfSignedCert() Plain cert — satisfies node:crypto X509Certificate parse.
|
|
* - makeMosaicIssuedCert(opts) Cert with custom Mosaic OID extensions — satisfies the
|
|
* CRIT-1 OID presence + value checks in CaService.issueCert.
|
|
*/
|
|
|
|
import { webcrypto } from 'node:crypto';
|
|
import {
|
|
X509CertificateGenerator,
|
|
Extension,
|
|
KeyUsagesExtension,
|
|
KeyUsageFlags,
|
|
BasicConstraintsExtension,
|
|
cryptoProvider,
|
|
} from '@peculiar/x509';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Internal helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Encode a string as an ASN.1 UTF8String TLV:
|
|
* 0x0C (tag) + 1-byte length (for strings ≤ 127 bytes) + UTF-8 bytes.
|
|
*
|
|
* CaService.issueCert reads the extension value as:
|
|
* decoder.decode(grantIdExt.value.slice(2))
|
|
* i.e. it skips the tag + length byte and decodes the remainder as UTF-8.
|
|
* So we must produce exactly this encoding as the OCTET STRING content.
|
|
*/
|
|
function encodeUtf8String(value: string): Uint8Array {
|
|
const utf8 = new TextEncoder().encode(value);
|
|
if (utf8.length > 127) {
|
|
throw new Error('encodeUtf8String: value too long for single-byte length encoding');
|
|
}
|
|
const buf = new Uint8Array(2 + utf8.length);
|
|
buf[0] = 0x0c; // ASN.1 UTF8String tag
|
|
buf[1] = utf8.length;
|
|
buf.set(utf8, 2);
|
|
return buf;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Mosaic OID constants (must match production CaService)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const OID_MOSAIC_GRANT_ID = '1.3.6.1.4.1.99999.1';
|
|
const OID_MOSAIC_SUBJECT_USER_ID = '1.3.6.1.4.1.99999.2';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Public API
|
|
// ---------------------------------------------------------------------------
|
|
|
|
/**
|
|
* Generate a minimal self-signed EC P-256 certificate valid for 1 day.
|
|
* CN=harness-test, no custom extensions.
|
|
*
|
|
* Suitable for:
|
|
* - EnrollmentService.extractCertNotAfter (just needs parseable PEM)
|
|
* - Any mock that returns certPem / certChainPem without OID checks
|
|
*/
|
|
export async function makeSelfSignedCert(): Promise<string> {
|
|
// Ensure @peculiar/x509 uses Node.js webcrypto (available as globalThis.crypto in Node 19+,
|
|
// but we set it explicitly here to be safe on all Node 18+ versions).
|
|
cryptoProvider.set(webcrypto as unknown as Parameters<typeof cryptoProvider.set>[0]);
|
|
|
|
const alg = { name: 'ECDSA', namedCurve: 'P-256', hash: 'SHA-256' } as const;
|
|
const keys = await webcrypto.subtle.generateKey(alg, false, ['sign', 'verify']);
|
|
|
|
const now = new Date();
|
|
const tomorrow = new Date(now.getTime() + 86_400_000);
|
|
|
|
const cert = await X509CertificateGenerator.createSelfSigned({
|
|
serialNumber: '01',
|
|
name: 'CN=harness-test',
|
|
notBefore: now,
|
|
notAfter: tomorrow,
|
|
signingAlgorithm: alg,
|
|
keys,
|
|
extensions: [
|
|
new BasicConstraintsExtension(false),
|
|
new KeyUsagesExtension(KeyUsageFlags.digitalSignature),
|
|
],
|
|
});
|
|
|
|
return cert.toString('pem');
|
|
}
|
|
|
|
/**
|
|
* Generate a self-signed EC P-256 certificate that contains the two custom
|
|
* Mosaic OID extensions required by CaService.issueCert's CRIT-1 check:
|
|
* OID 1.3.6.1.4.1.99999.1 → mosaic_grant_id (value = grantId)
|
|
* OID 1.3.6.1.4.1.99999.2 → mosaic_subject_user_id (value = subjectUserId)
|
|
*
|
|
* The extension value encoding matches the production parser's `.slice(2)` assumption:
|
|
* each extension value is an OCTET STRING wrapping an ASN.1 UTF8String TLV.
|
|
*/
|
|
export async function makeMosaicIssuedCert(opts: {
|
|
grantId: string;
|
|
subjectUserId: string;
|
|
}): Promise<string> {
|
|
// Ensure @peculiar/x509 uses Node.js webcrypto.
|
|
cryptoProvider.set(webcrypto as unknown as Parameters<typeof cryptoProvider.set>[0]);
|
|
|
|
const alg = { name: 'ECDSA', namedCurve: 'P-256', hash: 'SHA-256' } as const;
|
|
const keys = await webcrypto.subtle.generateKey(alg, false, ['sign', 'verify']);
|
|
|
|
const now = new Date();
|
|
const tomorrow = new Date(now.getTime() + 86_400_000);
|
|
|
|
const cert = await X509CertificateGenerator.createSelfSigned({
|
|
serialNumber: '01',
|
|
name: 'CN=mosaic-issued-test',
|
|
notBefore: now,
|
|
notAfter: tomorrow,
|
|
signingAlgorithm: alg,
|
|
keys,
|
|
extensions: [
|
|
new BasicConstraintsExtension(false),
|
|
new KeyUsagesExtension(KeyUsageFlags.digitalSignature),
|
|
// mosaic_grant_id — OID 1.3.6.1.4.1.99999.1
|
|
new Extension(OID_MOSAIC_GRANT_ID, false, encodeUtf8String(opts.grantId)),
|
|
// mosaic_subject_user_id — OID 1.3.6.1.4.1.99999.2
|
|
new Extension(OID_MOSAIC_SUBJECT_USER_ID, false, encodeUtf8String(opts.subjectUserId)),
|
|
],
|
|
});
|
|
|
|
return cert.toString('pem');
|
|
}
|