import { readFileSync } from 'node:fs'; import { join } from 'node:path'; import { describe, expect, it } from 'vitest'; import { buildChains, matchKeys } from './cert-chain'; import { detectBlob } from './cert-model'; import type { AnyItem, CertItem, CsrItem, KeyItem } from './cert-types'; const fx = (name: string) => readFileSync(join(__dirname, '__fixtures__', name)); function load(...names: string[]): CertItem[] { const items: CertItem[] = []; names.forEach((name, file) => { const result = detectBlob(fx(name), { file, path: name, passwords: [] }); for (const item of result.items) if (item.kind === 'certificate') items.push(item); }); return items; } const cnOf = (certs: CertItem[], id: string) => certs.find((c) => c.id === id)?.cn; const pathCns = (certs: CertItem[], path: string[]) => path.map((id) => cnOf(certs, id)); describe('buildChains', () => { it('Server + Zwischenzertifikat ohne Wurzel: unvollstaendig, Luecke nach der CA', () => { const certs = load('rsa-leaf.pem', 'rsa-inter.pem'); const { chains } = buildChains(certs); expect(chains).toHaveLength(1); expect(pathCns(certs, chains[0].path)).toEqual(['www.example.test', 'Tessera Test Inter RSA']); expect(chains[0].complete).toBe(false); expect(chains[0].rootId).toBeNull(); expect(chains[0].gap).toMatchObject({ kind: 'afterCa', missingIssuerCn: 'Tessera Test Root RSA', }); }); it('mit Wurzel: vollstaendig, rootId gesetzt, keine Luecke', () => { const certs = load('rsa-leaf.pem', 'rsa-inter.pem', 'rsa-root.pem'); const { chains } = buildChains(certs); expect(chains).toHaveLength(1); expect(chains[0].complete).toBe(true); expect(cnOf(certs, chains[0].rootId as string)).toBe('Tessera Test Root RSA'); expect(chains[0].gap).toBeNull(); expect(chains[0].path).toHaveLength(3); }); it('nur das Serverzertifikat: Luecke nach dem Server mit Adresse des Ausstellers', () => { const certs = load('rsa-leaf.pem'); const { chains } = buildChains(certs); expect(chains[0].gap).toMatchObject({ kind: 'afterLeaf', missingIssuerCn: 'Tessera Test Inter RSA', aiaUrls: ['http://pki.example.test/rsa-inter.cer'], }); }); it('gleichnamige CA mit anderem Schluessel (Attrappe) wird nie genommen', () => { const certs = load( 'rsa-leaf-noaki.pem', 'rsa-inter-decoy.pem', 'rsa-inter.pem', 'rsa-root.pem', ); const { chains } = buildChains(certs); expect(chains).toHaveLength(1); const names = chains[0].path.map((id) => certs.find((c) => c.id === id)); expect(names[1]?.sources[0].path).toBe('rsa-inter.pem'); expect(chains[0].complete).toBe(true); }); it('nur die Attrappe vorhanden: die Signaturpruefung lehnt sie ab, Luecke nach dem Server', () => { const certs = load('rsa-leaf-noaki.pem', 'rsa-inter-decoy.pem', 'rsa-root.pem'); const { chains } = buildChains(certs); expect(chains).toHaveLength(1); expect(chains[0].path).toHaveLength(1); expect(chains[0].gap?.kind).toBe('afterLeaf'); }); it('kreuzsigniertes Zwischenzertifikat fuehrt zur zweiten Wurzel', () => { const certs = load('rsa-leaf.pem', 'rsa-inter-cross.pem', 'rsa-root2.pem'); const { chains } = buildChains(certs); expect(chains[0].complete).toBe(true); expect(cnOf(certs, chains[0].rootId as string)).toBe('Tessera Test Root RSA 2'); }); it('beide Varianten vorhanden: Hauptkette ueber rsa-inter und rsa-root, mindestens eine Alternative', () => { const certs = load('rsa-leaf.pem', 'rsa-inter.pem', 'rsa-inter-cross.pem', 'rsa-root.pem'); const { chains } = buildChains(certs); expect(chains).toHaveLength(1); expect(chains[0].complete).toBe(true); expect(cnOf(certs, chains[0].rootId as string)).toBe('Tessera Test Root RSA'); expect(chains[0].alternatives).toBeGreaterThanOrEqual(1); }); it('abgelaufenes Zwischenzertifikat wird nicht als Hauptkette genommen', () => { const certs = load('rsa-leaf.pem', 'rsa-inter-expired.pem', 'rsa-inter.pem', 'rsa-root.pem'); const { chains } = buildChains(certs); const middle = certs.find((c) => c.id === chains[0].path[1]); expect(middle?.isExpired).toBe(false); expect(middle?.sources[0].path).toBe('rsa-inter.pem'); expect(chains[0].alternatives).toBeGreaterThanOrEqual(1); }); it('die Reihenfolge der Eingabe aendert die Hauptkette nicht', () => { const names = ['rsa-leaf.pem', 'rsa-inter-expired.pem', 'rsa-inter.pem', 'rsa-root.pem']; const forward = load(...names); const reversed = load(...[...names].reverse()); const a = buildChains(forward).chains[0]; const b = buildChains(reversed).chains[0]; expect(a.path).toEqual(b.path); expect(a.alternatives).toBe(b.alternatives); }); it('Zwischenzertifikat + Wurzel ohne Serverzertifikat: eine Kette mit dem Zwischenzertifikat als Kopf', () => { const certs = load('rsa-inter.pem', 'rsa-root.pem'); const { chains } = buildChains(certs); expect(chains).toHaveLength(1); expect(cnOf(certs, chains[0].headId)).toBe('Tessera Test Inter RSA'); expect(chains[0].complete).toBe(true); }); it('selbstsigniertes Serverzertifikat: Kette aus ihm selbst, vollstaendig, ohne Wurzel', () => { const certs = load('selfsigned-leaf.pem'); const { chains } = buildChains(certs); expect(chains[0].path).toEqual([certs[0].id]); expect(chains[0].complete).toBe(true); expect(chains[0].rootId).toBeNull(); expect(chains[0].gap).toBeNull(); }); it('EC-Kette wird genauso gebaut', () => { const certs = load('ec-leaf.pem', 'ec-inter.pem', 'ec-root.pem'); const { chains } = buildChains(certs); expect(pathCns(certs, chains[0].path)).toEqual([ 'ec.example.test', 'Tessera Test Inter EC', 'Tessera Test Root EC', ]); expect(chains[0].complete).toBe(true); }); it('mit vorgegebenem Kopf wird genau diese Kette gebaut', () => { const certs = load('rsa-leaf.pem', 'rsa-inter.pem', 'rsa-root.pem'); const inter = certs.find((c) => c.role === 'intermediate') as CertItem; const { chains } = buildChains(certs, [inter.id]); expect(chains).toHaveLength(1); expect(chains[0].headId).toBe(inter.id); }); it('leere Menge ergibt keine Ketten', () => { expect(buildChains([])).toEqual({ chains: [] }); }); }); describe('matchKeys', () => { const PASSWORD = 'Test-Pass-123'; function all(...names: [string, string?][]): AnyItem[] { const items: AnyItem[] = []; names.forEach(([name, password], file) => { const result = detectBlob(fx(name), { file, path: name, passwords: password ? [password] : [], ownPassword: password ?? '', }); items.push(...result.items); }); return items; } const certsOf = (items: AnyItem[]) => items.filter((i): i is CertItem => i.kind === 'certificate'); const keysOf = (items: AnyItem[]) => items.filter((i): i is KeyItem => i.kind === 'privateKey'); const csrsOf = (items: AnyItem[]) => items.filter((i): i is CsrItem => i.kind === 'csr'); it('ordnet Schluessel und Anfrage dem Serverzertifikat zu (RSA und EC)', () => { const items = all( ['rsa-leaf.pem'], ['rsa-leaf-key-enc-trad.pem', PASSWORD], ['rsa-leaf.csr'], ['ec-leaf.pem'], ['ec-leaf-key-sec1.der'], ['ec-leaf.csr.der'], ); matchKeys(certsOf(items), keysOf(items), csrsOf(items)); const certs = certsOf(items); const rsa = certs.find((c) => c.cn === 'www.example.test') as CertItem; const ec = certs.find((c) => c.cn === 'ec.example.test') as CertItem; const rsaKey = keysOf(items).find((k) => k.keyType === 'RSA') as KeyItem; const ecKey = keysOf(items).find((k) => k.keyType === 'EC') as KeyItem; const rsaCsr = csrsOf(items).find((r) => r.keyType === 'RSA') as CsrItem; const ecCsr = csrsOf(items).find((r) => r.keyType === 'EC') as CsrItem; expect(rsa.keyId).toBe(rsaKey.id); expect(rsaKey.certIds).toEqual([rsa.id]); expect(rsa.csrIds).toEqual([rsaCsr.id]); expect(rsaCsr.keyId).toBe(rsaKey.id); expect(rsaCsr.certIds).toEqual([rsa.id]); expect(ec.keyId).toBe(ecKey.id); expect(ecKey.certIds).toEqual([ec.id]); expect(ecCsr.keyId).toBe(ecKey.id); expect(ecCsr.certIds).toEqual([ec.id]); }); it('ein fremder Schluessel gehoert zu keinem Zertifikat', () => { const items = all(['rsa-leaf.pem'], ['ec-leaf-key.pem']); matchKeys(certsOf(items), keysOf(items), csrsOf(items)); expect(certsOf(items)[0].keyId).toBeNull(); expect(keysOf(items)[0].certIds).toEqual([]); }); it('Anfrage ohne Zertifikat und ohne Schluessel bleibt ohne Zuordnung', () => { const items = all(['ec-leaf.csr']); matchKeys(certsOf(items), keysOf(items), csrsOf(items)); expect(csrsOf(items)[0].keyId).toBeNull(); expect(csrsOf(items)[0].certIds).toEqual([]); }); it('Zwischenzertifikat und Wurzel bekommen nie einen Schluessel, die Zuordnung ist wiederholbar', () => { const items = all(['rsa-leaf.pem'], ['rsa-inter.pem'], ['rsa-leaf-key.pem']); matchKeys(certsOf(items), keysOf(items), csrsOf(items)); matchKeys(certsOf(items), keysOf(items), csrsOf(items)); const withKey = certsOf(items).filter((c) => c.keyId !== null); expect(withKey.map((c) => c.cn)).toEqual(['www.example.test']); expect(keysOf(items)[0].certIds).toHaveLength(1); }); });