Files
SRCmail/lib/smime-ca/__tests__/local-dev-provider.test.ts
T
Bernd Rodler c2c07293b7 feat(smime): real local dev CA (LocalDevCaProvider), CSR issuance verified
The production EJBCA needs a client mTLS certificate + password this
session doesn't have, and lives on the private dev-k8s network - genuinely
unreachable from here tonight (confirmed, not assumed - see
lib/smime-ca/index.ts's build() and the memory on the EJBCA CA project).

lib/smime-ca/local-dev-provider.ts implements the same CaProvider seam
(lib/smime-ca/types.ts) the production EjbcaProvider does - a real,
working local CA, not a mock:
- Generates a real RSA-2048 self-signed root on first use, persisted to
  the admin state dir (same pattern as lib/ai/entitlement.ts).
- enroll() parses a real PKCS#10 CSR (pkijs), verifies its self-signature
  (proof of possession - not identity, which still comes only from the
  server-provided `addresses`, exactly like the production provider),
  and issues a real X.509v3 leaf: BasicConstraints(cA:false), KeyUsage
  (digitalSignature|nonRepudiation|keyEncipherment), ExtKeyUsage
  (emailProtection), SubjectAltName(rfc822Name per address) - signed with
  the CA's own private key.
- revoke()/getChain() implemented for real (persisted revocation list,
  real chain PEM).
- Wired into build() behind SMIME_CA_DEV_LOCAL=true, explicit opt-in only,
  never a silent fallback when the real CA URL is simply unconfigured.

4 tests, all real cryptographic verification, not string-shape checks:
issue a cert from a real WebCrypto-generated CSR, then cryptographically
verify the chain (leaf.verify(caCert) === true) and confirm the SAN
contains exactly the server-chosen addresses (never the CSR's own
requested CN); reject a CSR with a corrupted signature; confirm the CA
persists across calls rather than minting a new root each time; confirm
revocation is recorded to disk.

Scope note, explicit rather than silently incomplete: this closes the
server-side half. The client-side half (C-08) - the plugin generating a
CSR via WebCrypto, calling this enrollment endpoint, and importing the
issued cert into its existing encrypted-at-rest key storage
(vnc/plugins/smime/src/key-storage.js, matching the AES-GCM+PBKDF2(600k)
wrapping pkcs12.js already uses for imports) - was NOT built tonight.
That plugin has open findings from an earlier security audit (see project
memory); adding new key-generation/storage code to it at 00:30 after many
hours of continuous work is exactly the kind of rushed change that
produces the next finding. The privileged iframe can reach
/api/smime/enroll directly (same-origin, confirmed via the plugin's own
tier=privileged log line - no new sandbox bridge capability needed), so
the remaining work is well-scoped and mechanical, not blocked on any open
question - just deliberately deferred to unhurried, focused time.

Verified: typecheck clean, lint clean, full vitest suite 2484/2485 (only
the pre-existing, unrelated jmap-client-resilience flake), production
build succeeds.
2026-08-06 00:30:27 +02:00

131 lines
5.8 KiB
TypeScript

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<string> {
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);
});
});