import { describe, expect, it, beforeEach, afterEach } from 'vitest'; import { mkdtemp, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import path from 'node:path'; import { webcrypto } from 'node:crypto'; import * as asn1js from 'asn1js'; import * as pkijs from 'pkijs'; import { LocalDevCaProvider } from '../local-dev-provider'; function pemToBer(pem: string): ArrayBuffer { const b64 = pem.replace(/-----BEGIN[^-]+-----/, '').replace(/-----END[^-]+-----/, '').replace(/\s+/g, ''); const bin = Buffer.from(b64, 'base64'); return bin.buffer.slice(bin.byteOffset, bin.byteOffset + bin.byteLength); } function berToPem(der: ArrayBuffer, label: string): string { const b64 = Buffer.from(der).toString('base64'); const lines = b64.match(/.{1,64}/g) ?? []; return `-----BEGIN ${label}-----\n${lines.join('\n')}\n-----END ${label}-----`; } /** Builds a real, validly-self-signed CSR — the same shape a browser's * WebCrypto-based plugin code would produce, just done here in Node so the * test needs no browser. */ async function buildRealCsr(commonName: string): Promise { const keys = await webcrypto.subtle.generateKey( { name: 'RSASSA-PKCS1-v1_5', modulusLength: 2048, publicExponent: new Uint8Array([1, 0, 1]), hash: 'SHA-256' }, true, ['sign', 'verify'], ) as CryptoKeyPair; const csr = new pkijs.CertificationRequest(); csr.version = 0; csr.subject.typesAndValues = [ new pkijs.AttributeTypeAndValue({ type: '2.5.4.3', value: new asn1js.Utf8String({ value: commonName }) }), ]; await csr.subjectPublicKeyInfo.importKey(keys.publicKey); await csr.sign(keys.privateKey, 'SHA-256'); return berToPem(csr.toSchema().toBER(false), 'CERTIFICATE REQUEST'); } describe('LocalDevCaProvider', () => { let stateDir: string; beforeEach(async () => { stateDir = await mkdtemp(path.join(tmpdir(), 'smime-dev-ca-test-')); process.env.ADMIN_STATE_DIR = stateDir; }); afterEach(async () => { delete process.env.ADMIN_STATE_DIR; await rm(stateDir, { recursive: true, force: true }); }); it('issues a certificate signed by its own CA, honouring server-chosen addresses only', async () => { const provider = new LocalDevCaProvider(); const csrPem = await buildRealCsr('CN the CSR asked for, should be ignored for SAN purposes'); const issued = await provider.enroll({ csrPem, addresses: ['alice@example.com', 'alice.alt@example.com'], commonName: 'Alice Example', }); expect(issued.certificatePem).toContain('BEGIN CERTIFICATE'); expect(issued.chainPem).toHaveLength(1); expect(issued.serialNumber).toMatch(/^[0-9a-f]+$/i); const leaf = pkijs.Certificate.fromBER(pemToBer(issued.certificatePem)); const caCert = pkijs.Certificate.fromBER(pemToBer(issued.chainPem[0])); // Real cryptographic chain verification, not just "a string looks like a cert". const chainVerified = await leaf.verify(caCert); expect(chainVerified).toBe(true); // The certificate must assert exactly the server-provided addresses, // never anything from the CSR's own (ignored) subject. rfc822Name SAN // entries are IA5String (plain ASCII) - checking the raw extension // bytes contain exactly these addresses, and nothing the CSR's own // subject claimed, is a robust check without fighting pkijs's // re-parse-from-schema API for a value this code itself just built. const sanExt = leaf.extensions?.find((e) => e.extnID === '2.5.29.17'); expect(sanExt).toBeDefined(); const sanRaw = Buffer.from(sanExt!.extnValue.valueBlock.valueHexView).toString('latin1'); expect(sanRaw).toContain('alice@example.com'); expect(sanRaw).toContain('alice.alt@example.com'); expect(sanRaw).not.toContain('CN the CSR asked for'); // extKeyUsage must include emailProtection (OID 1.3.6.1.5.5.7.3.4, // DER-encoded as the raw bytes below) - otherwise no real S/MIME client // accepts the certificate for signing/encryption. const ekuExt = leaf.extensions?.find((e) => e.extnID === '2.5.29.37'); expect(ekuExt).toBeDefined(); const emailProtectionOidDer = Buffer.from([0x06, 0x08, 0x2b, 0x06, 0x01, 0x05, 0x05, 0x07, 0x03, 0x04]); expect(Buffer.from(ekuExt!.extnValue.valueBlock.valueHexView).includes(emailProtectionOidDer)).toBe(true); }); it('rejects a CSR with a forged/mismatched signature', async () => { const provider = new LocalDevCaProvider(); const validPem = await buildRealCsr('Whatever'); // Corrupt one byte in the middle of the base64 body to break the signature // without breaking PEM framing. const lines = validPem.split('\n'); const bodyIdx = Math.floor(lines.length / 2); lines[bodyIdx] = lines[bodyIdx].slice(0, -4) + (lines[bodyIdx].slice(-4) === 'AAAA' ? 'BBBB' : 'AAAA'); const tamperedPem = lines.join('\n'); await expect( provider.enroll({ csrPem: tamperedPem, addresses: ['x@example.com'], commonName: 'X' }), ).rejects.toThrow(/parsed|signature/i); }); it('persists the same CA across calls (does not mint a new root every time)', async () => { const provider = new LocalDevCaProvider(); const chain1 = await provider.getChain(); const chain2 = await provider.getChain(); expect(chain1[0]).toBe(chain2[0]); }); it('records a revocation', async () => { const provider = new LocalDevCaProvider(); const csrPem = await buildRealCsr('Bob'); const issued = await provider.enroll({ csrPem, addresses: ['bob@example.com'], commonName: 'Bob' }); await expect(provider.revoke(issued.serialNumber, 'keyCompromise')).resolves.toBeUndefined(); const stateFile = path.join(stateDir, 'smime-dev-ca.json'); const stored = JSON.parse(await (await import('node:fs/promises')).readFile(stateFile, 'utf-8')); expect(stored.revokedSerials).toContain(issued.serialNumber); }); });