import { X509Certificate } from 'node:crypto'; import { readFileSync } from 'node:fs'; import { join } from 'node:path'; import AdmZip from 'adm-zip'; import { describe, expect, it } from 'vitest'; import { certItemFromDer, detectBlob } from './cert-model'; import type { CertItem } from './cert-types'; const fx = (name: string) => readFileSync(join(__dirname, '__fixtures__', name)); const ctx = (path: string) => ({ file: 0, path, passwords: [] as string[] }); function certs(name: string): CertItem[] { const r = detectBlob(fx(name), ctx(name)); return r.items.filter((i): i is CertItem => i.kind === 'certificate'); } describe('detectBlob: Zertifikate', () => { it('RSA-Serverzertifikat: alle Felder aus node:crypto', () => { const [c] = certs('rsa-leaf.pem'); const x = new X509Certificate(fx('rsa-leaf.pem')); expect(c.cn).toBe('www.example.test'); expect(c.san).toEqual(['www.example.test', 'example.test']); expect(c.issuerCn).toBe('Tessera Test Inter RSA'); expect(c.role).toBe('end-entity'); expect(c.keyType).toBe('RSA'); expect(c.keyBits).toBe(2048); expect(c.curve).toBeNull(); expect(c.isCa).toBe(false); expect(c.selfSigned).toBe(false); expect(c.aiaIssuerUrls).toEqual(['http://pki.example.test/rsa-inter.cer']); expect(c.sha256).toBe(x.fingerprint256); expect(c.sha1).toBe(x.fingerprint); expect(c.id).toBe(`c-${x.fingerprint256.replace(/:/g, '').slice(0, 16).toLowerCase()}`); expect(c.sources).toEqual([{ file: 0, path: 'rsa-leaf.pem' }]); expect(c.pem.startsWith('-----BEGIN CERTIFICATE-----')).toBe(true); expect(c.baseName).toBe('www.example.test'); expect(c.keyId).toMatch(/^k-[0-9a-f]{16}$/); expect(c.isExpired).toBe(false); expect(c.daysLeft).toBeGreaterThan(30000); }); it('Zwischenzertifikat und Stammzertifikat bekommen ihre Rolle', () => { expect(certs('rsa-inter.pem')[0].role).toBe('intermediate'); expect(certs('rsa-inter.pem')[0].baseName).toBe('Tessera_Test_Inter_RSA'); const root = certs('rsa-root.pem')[0]; expect(root.role).toBe('root'); expect(root.selfSigned).toBe(true); expect(root.isCa).toBe(true); }); it('EC-Zertifikate: Schluesseltyp und Kurve', () => { const leaf = certs('ec-leaf.pem')[0]; expect(leaf.keyType).toBe('EC'); expect(leaf.curve).toBe('P-256'); expect(leaf.keyBits).toBe(256); const root = certs('ec-root.pem')[0]; expect(root.curve).toBe('P-384'); expect(root.keyBits).toBe(384); expect(root.isCa).toBe(true); expect(root.selfSigned).toBe(true); expect(root.role).toBe('root'); }); it('selbstsigniert, aber keine CA: bleibt Serverzertifikat', () => { const c = certs('selfsigned-leaf.pem')[0]; expect(c.selfSigned).toBe(true); expect(c.isCa).toBe(false); expect(c.role).toBe('end-entity'); }); it('abgelaufenes Zwischenzertifikat wird als abgelaufen gemeldet', () => { const c = certs('rsa-inter-expired.pem')[0]; expect(c.isExpired).toBe(true); expect(c.daysLeft).toBeLessThan(0); }); it('DER ergibt dieselbe Kennung wie PEM', () => { expect(certs('ec-leaf.cer')[0].id).toBe(certs('ec-leaf.pem')[0].id); expect(certs('rsa-leaf.cer')[0].id).toBe(certs('rsa-leaf.pem')[0].id); }); it('Fullchain mit BOM, CRLF und Text drumherum: drei Zertifikate', () => { const body = fx('ec-fullchain.pem').toString('utf8').replace(/\n/g, '\r\n'); const messy = Buffer.concat([ Buffer.from([0xef, 0xbb, 0xbf]), Buffer.from(`Bag Attributes\r\n friendlyName: x\r\n${body}\r\nEnde der Datei\r\n`, 'utf8'), ]); const r = detectBlob(messy, ctx('messy.pem')); expect(r.items).toHaveLength(3); expect(r.ignored).toEqual([]); expect(r.items.map((i) => (i as CertItem).role).sort()).toEqual([ 'end-entity', 'intermediate', 'root', ]); }); it('TRUSTED CERTIFICATE liefert das Zertifikat', () => { const [c] = certs('rsa-trusted.pem'); expect(c.id).toBe(certs('rsa-leaf.pem')[0].id); }); it('kaputte Eingaben werden gemeldet, ohne zu werfen', () => { const half = fx('rsa-leaf.cer').subarray(0, 300); const brokenBase64 = Buffer.from( '-----BEGIN CERTIFICATE-----\nMIIDzTCC@@@@@@@@@!!!\n-----END CERTIFICATE-----\n', ); const random = Buffer.from(Array.from({ length: 512 }, (_, i) => (i * 37 + 11) % 256)); for (const blob of [random, Buffer.alloc(0), half, brokenBase64, Buffer.from('hallo welt')]) { const r = detectBlob(blob, ctx('x.bin')); expect(r.items).toEqual([]); expect(r.ignored).toEqual([{ file: 0, path: 'x.bin', reason: 'unknown' }]); } }); it('ein kaputter Block neben einem guten verhindert das Zertifikat nicht', () => { const mixed = Buffer.concat([ Buffer.from('-----BEGIN CERTIFICATE-----\n@@@@\n-----END CERTIFICATE-----\n'), fx('rsa-leaf.pem'), ]); const r = detectBlob(mixed, ctx('mixed.pem')); expect(r.items).toHaveLength(1); expect(r.ignored).toEqual([]); }); it('certItemFromDer erzeugt Kennung und Quelle', () => { const der = fx('rsa-leaf.cer'); const item = certItemFromDer(der, { file: 3, path: 'a/b.cer' }); expect(item.sources).toEqual([{ file: 3, path: 'a/b.cer' }]); expect(item.id).toMatch(/^c-[0-9a-f]{16}$/); }); }); describe('detectBlob: PKCS#7', () => { it.each([ 'rsa-chain.p7b', 'rsa-chain.p7c', 'ec-chain.p7b', ])('%s liefert drei Zertifikate mit unveraenderten Fingerabdruecken', (name) => { const r = detectBlob(fx(name), ctx(name)); expect(r.ignored).toEqual([]); expect(r.items).toHaveLength(3); const prefix = name.startsWith('rsa') ? 'rsa' : 'ec'; const expected = ['leaf', 'inter', 'root'].map((p) => certs(`${prefix}-${p}.pem`)[0].id); expect(r.items.map((i) => i.id).sort()).toEqual([...expected].sort()); expect((r.items[0] as CertItem).sources).toEqual([{ file: 0, path: name }]); }); it('PKCS#7 als DER ohne Endung wird erkannt (Inhalt, nicht Name)', () => { const r = detectBlob(fx('rsa-chain.p7c'), ctx('irgendwas.dat')); expect(r.items).toHaveLength(3); }); it('abgeschnittenes PKCS#7 ergibt unbekannt, keinen Fehler', () => { const r = detectBlob(fx('rsa-chain.p7c').subarray(0, 400), ctx('halb.p7c')); expect(r.items).toEqual([]); expect(r.ignored).toEqual([{ file: 0, path: 'halb.p7c', reason: 'unknown' }]); }); it('PKCS#7-Block neben einem Zertifikat im selben Text', () => { const both = Buffer.concat([fx('rsa-chain.p7b'), Buffer.from('\n'), fx('ec-leaf.pem')]); const r = detectBlob(both, ctx('beides.pem')); expect(r.items).toHaveLength(4); }); }); describe('detectBlob: ZIP', () => { function zip(entries: Record): Buffer { const z = new AdmZip(); for (const [name, data] of Object.entries(entries)) z.addFile(name, data); return z.toBuffer(); } it('oeffnet ein ZIP an den Anfangsbytes und gibt jedem Teil den Pfad "zip/eintrag"', () => { const blob = zip({ 'ServerCertificate.crt': fx('ec-leaf.pem'), 'Intermediate/CA.crt': fx('ec-inter.pem'), 'chain.p7b': fx('rsa-chain.p7b'), 'readme.txt': Buffer.from('Bitte lesen'), '__MACOSX/._x': Buffer.from('mac'), }); const r = detectBlob(blob, { file: 2, path: 'bundle.dat', passwords: [] }); const ec = r.items.find((i) => (i as CertItem).cn === 'ec.example.test'); expect(ec?.sources).toEqual([{ file: 2, path: 'bundle.dat/ServerCertificate.crt' }]); expect(r.items).toHaveLength(5); expect(r.ignored).toEqual([{ file: 2, path: 'bundle.dat/readme.txt', reason: 'unknown' }]); }); it('ein ZIP im ZIP: nestedZip mit Pfad, der Rest wird gelesen', () => { const inner = zip({ 'x.pem': fx('rsa-root.pem') }); const blob = zip({ 'a.pem': fx('rsa-leaf.pem'), 'inner.zip': inner }); const r = detectBlob(blob, ctx('v.zip')); expect(r.items).toHaveLength(1); expect(r.ignored).toEqual([{ file: 0, path: 'v.zip/inner.zip', reason: 'nestedZip' }]); }); it('kaputtes ZIP und verschluesseltes ZIP werden gemeldet', () => { const broken = Buffer.concat([Buffer.from('PK\x03\x04', 'binary'), Buffer.alloc(100, 9)]); expect(detectBlob(broken, ctx('b.zip')).ignored).toEqual([ { file: 0, path: 'b.zip', reason: 'brokenZip' }, ]); expect(detectBlob(fx('encrypted-entry.zip'), ctx('e.zip')).ignored).toEqual([ { file: 0, path: 'e.zip/rsa-leaf.pem', reason: 'encryptedZip' }, ]); }); it('ein ZIP ganz ohne lesbare Teile ergibt einen Eintrag unbekannt fuer das ZIP', () => { const r = detectBlob(new AdmZip().toBuffer(), ctx('leer.zip')); expect(r.ignored).toEqual([{ file: 0, path: 'leer.zip', reason: 'unknown' }]); }); });