import AdmZip from 'adm-zip'; import * as forge from 'node-forge'; import { beforeAll, describe, expect, it } from 'vitest'; import { analyzeBundle, exportBundleItem, safeBaseName } from './cert-bundle'; /** * cert-bundle.spec (quick-261001-l4q) — Zertifikatspaket wie vom Aussteller: * Stamm -> Zwischen -> Server, dazu Schluessel, CSR und PFX, als ZIP. * Alles hier erzeugt (keine echten Kundendaten im Repo). */ interface Pki { rootPem: string; interPem: string; leafPem: string; keyPem: string; csrPem: string; pfx: Buffer; leafModulus: string; } let pki: Pki; function makeCert( subjectCn: string, pub: forge.pki.PublicKey, signer: forge.pki.PrivateKey, issuer: forge.pki.CertificateField[] | null, ca: boolean, serial: string, ): forge.pki.Certificate { const cert = forge.pki.createCertificate(); cert.publicKey = pub; cert.serialNumber = serial; cert.validity.notBefore = new Date(Date.now() - 86_400_000); cert.validity.notAfter = new Date(Date.now() + 90 * 86_400_000); const subject = [{ name: 'commonName', value: subjectCn }]; cert.setSubject(subject); cert.setIssuer(issuer ?? subject); const ext: object[] = [{ name: 'basicConstraints', cA: ca }]; if (!ca) ext.push({ name: 'subjectAltName', altNames: [{ type: 2, value: subjectCn }] }); cert.setExtensions(ext); cert.sign(signer as forge.pki.rsa.PrivateKey, forge.md.sha256.create()); return cert; } beforeAll(() => { const rootKeys = forge.pki.rsa.generateKeyPair(1024); const interKeys = forge.pki.rsa.generateKeyPair(1024); const leafKeys = forge.pki.rsa.generateKeyPair(1024); const root = makeCert('Test Root CA', rootKeys.publicKey, rootKeys.privateKey, null, true, '01'); const inter = makeCert( 'Test Intermediate CA', interKeys.publicKey, rootKeys.privateKey, root.subject.attributes, true, '02', ); const leaf = makeCert( 'www.example.test', leafKeys.publicKey, interKeys.privateKey, inter.subject.attributes, false, '03', ); const csr = forge.pki.createCertificationRequest(); csr.publicKey = leafKeys.publicKey; csr.setSubject([{ name: 'commonName', value: 'www.example.test' }]); csr.sign(leafKeys.privateKey, forge.md.sha256.create()); const p12 = forge.pkcs12.toPkcs12Asn1(leafKeys.privateKey, [leaf, inter], 'geheim', { algorithm: '3des', }); pki = { rootPem: forge.pki.certificateToPem(root), interPem: forge.pki.certificateToPem(inter), leafPem: forge.pki.certificateToPem(leaf), keyPem: forge.pki.privateKeyInfoToPem( forge.pki.wrapRsaPrivateKey(forge.pki.privateKeyToAsn1(leafKeys.privateKey)), ), csrPem: forge.pki.certificationRequestToPem(csr), pfx: Buffer.from(forge.asn1.toDer(p12).getBytes(), 'binary'), leafModulus: leafKeys.publicKey.n.toString(16), }; }, 60_000); function issuerZip(): Buffer { const zip = new AdmZip(); zip.addFile('www.example.test/www.example.test.pem', Buffer.from(pki.leafPem + pki.interPem)); zip.addFile('www.example.test/www.example.test.key', Buffer.from(pki.keyPem)); zip.addFile('www.example.test/www.example.test.csr', Buffer.from(pki.csrPem)); zip.addFile('www.example.test/www.example.test.pfx', pki.pfx); zip.addFile('www.example.test/.dnstxtrecord', Buffer.from('_dnsauth abc123')); return zip.toBuffer(); } describe('analyzeBundle', () => { it('ZIP vom Aussteller: erkennt jedes Teil, fasst PEM/PFX zusammen, ordnet Schluessel und Kette zu', () => { const r = analyzeBundle([{ originalname: 'paket.zip', buffer: issuerZip() }], 'geheim'); const kinds = r.items.map((i) => (i.kind === 'certificate' ? i.role : i.kind)); expect(kinds).toEqual(['end-entity', 'intermediate', 'privateKey', 'csr']); const [leaf, inter, key, csr] = r.items; expect(leaf.cn).toBe('www.example.test'); expect(leaf.san).toEqual(['www.example.test']); // in PEM UND PFX enthalten -> ein Eintrag mit beiden Quellen expect(leaf.sources.sort()).toEqual(['www.example.test.pem', 'www.example.test.pfx']); expect(leaf.chainIds).toEqual([inter.id]); expect(leaf.matchId).toBe(key.id); expect(key.matchId).toBe(leaf.id); expect(key.sources.sort()).toEqual(['www.example.test.key', 'www.example.test.pfx']); expect(csr.matchId).toBe(leaf.id); expect(csr.cn).toBe('www.example.test'); expect(leaf.baseName).toBe('www.example.test'); expect(r.locked).toEqual([]); expect(r.ignored).toEqual(['.dnstxtrecord']); }); it('PFX ohne passendes Passwort wird als gesperrt gemeldet, der Rest trotzdem erkannt', () => { const r = analyzeBundle([{ originalname: 'paket.zip', buffer: issuerZip() }], 'falsch'); expect(r.locked).toEqual(['www.example.test.pfx']); expect(r.items.filter((i) => i.kind === 'certificate')).toHaveLength(2); }); it('Stammzertifikat wird als root erkannt und an die Kette gehaengt', () => { const r = analyzeBundle( [ { originalname: 'chain.pem', buffer: Buffer.from(pki.leafPem + pki.interPem + pki.rootPem), }, ], '', ); expect(r.items.map((i) => i.role)).toEqual(['end-entity', 'intermediate', 'root']); expect(r.items[0].chainIds).toHaveLength(2); }); it('ohne Dateien -> 400', () => { expect(() => analyzeBundle([], '')).toThrow(/No files/); }); it('ZIP mit zu vielen Dateien -> 400', () => { const zip = new AdmZip(); for (let i = 0; i < 101; i++) zip.addFile(`f${i}.txt`, Buffer.from('x')); expect(() => analyzeBundle([{ originalname: 'gross.zip', buffer: zip.toBuffer() }], ''), ).toThrow(/too many files/); }); }); describe('exportBundleItem', () => { function bundle() { const r = analyzeBundle([{ originalname: 'paket.zip', buffer: issuerZip() }], 'geheim'); const byId = Object.fromEntries(r.items.map((i) => [i.id, i])); return { items: r.items, byId }; } const decode = (b64: string) => Buffer.from(b64, 'base64'); it('Zertifikat in jedem Format liest sich wieder ein', () => { const { items, byId } = bundle(); const leaf = items[0]; const chain = leaf.chainIds.map((id) => byId[id].pem); const keyPem = byId[leaf.matchId!].pem; const base = { kind: leaf.kind, pem: leaf.pem, baseName: leaf.baseName, chain, keyPem }; const crt = exportBundleItem({ ...base, format: 'crt' }); expect(crt.filename).toBe('www.example.test.crt'); expect( forge.pki.certificateFromPem(decode(crt.content).toString()).subject.getField('CN').value, ).toBe('www.example.test'); const cer = exportBundleItem({ ...base, format: 'cer' }); expect(cer.filename).toBe('www.example.test.cer'); forge.pki.certificateFromAsn1(forge.asn1.fromDer(decode(cer.content).toString('binary'))); const full = exportBundleItem({ ...base, format: 'fullchain' }); expect( decode(full.content) .toString() .match(/BEGIN CERTIFICATE/g), ).toHaveLength(2); const p7b = exportBundleItem({ ...base, format: 'p7b' }); const p7 = forge.pkcs7.messageFromPem(decode(p7b.content).toString()); expect('certificates' in p7 ? p7.certificates : []).toHaveLength(2); const pfx = exportBundleItem({ ...base, format: 'pfx', password: 'neu' }); expect(pfx.filename).toBe('www.example.test.pfx'); const p12 = forge.pkcs12.pkcs12FromAsn1( forge.asn1.fromDer(decode(pfx.content).toString('binary')), 'neu', ); expect(p12.getBags({ bagType: forge.pki.oids.certBag })[forge.pki.oids.certBag]).toHaveLength( 2, ); const keyBag = p12.getBags({ bagType: forge.pki.oids.pkcs8ShroudedKeyBag })[ forge.pki.oids.pkcs8ShroudedKeyBag ]![0]; expect((keyBag.key as forge.pki.rsa.PrivateKey).n.toString(16)).toBe(pki.leafModulus); }); it('PFX ohne Passwort -> 400', () => { const { items } = bundle(); expect(() => exportBundleItem({ kind: 'certificate', pem: items[0].pem, format: 'pfx' }), ).toThrow(/password is required/); }); it('Schluessel als PKCS#8, PKCS#1 und DER', () => { const { items } = bundle(); const key = items.find((i) => i.kind === 'privateKey')!; const base = { kind: key.kind, pem: key.pem, baseName: key.baseName }; expect(decode(exportBundleItem({ ...base, format: 'key' }).content).toString()).toContain( 'BEGIN PRIVATE KEY', ); const rsa = exportBundleItem({ ...base, format: 'key-rsa' }); expect(rsa.filename).toBe('www.example.test.rsa.key'); expect(decode(rsa.content).toString()).toContain('BEGIN RSA PRIVATE KEY'); const der = exportBundleItem({ ...base, format: 'key-der' }); const info = forge.asn1.fromDer(decode(der.content).toString('binary')); expect((forge.pki.privateKeyFromAsn1(info) as forge.pki.rsa.PrivateKey).n.toString(16)).toBe( pki.leafModulus, ); }); it('CSR als PEM und DER', () => { const { items } = bundle(); const csr = items.find((i) => i.kind === 'csr')!; const der = exportBundleItem({ kind: 'csr', pem: csr.pem, baseName: csr.baseName, format: 'csr-der', }); expect(der.filename).toBe('www.example.test.csr.der'); forge.pki.certificationRequestFromAsn1( forge.asn1.fromDer(decode(der.content).toString('binary')), ); }); it('unpassendes Format -> 400', () => { const { items } = bundle(); expect(() => exportBundleItem({ kind: 'csr', pem: items[3].pem, format: 'pfx', password: 'x' }), ).toThrow(); expect(() => exportBundleItem({ kind: 'privateKey', pem: 'kein pem', format: 'key-rsa' }), ).toThrow(/Failed to export/); }); }); describe('safeBaseName', () => { it('Platzhalter, Leerzeichen und Pfadteile werden entschaerft', () => { expect(safeBaseName('*.example.de', 'x')).toBe('wildcard.example.de'); expect(safeBaseName('Encryption Everywhere DV TLS CA - G1', 'x')).toBe( 'Encryption_Everywhere_DV_TLS_CA_-_G1', ); expect(safeBaseName('../../etc/passwd', 'x')).toBe('etc_passwd'); expect(safeBaseName('', 'fallback')).toBe('fallback'); }); });