fcac0a3bfd
- Private Schlüssel (PKCS#1, PKCS#8, SEC1; PEM und DER; unverschlüsselt, verschlüsselt, klassisch verschlüsselt) für RSA und EC über node:crypto - PKCS#12 lesen (OpenSSL 3, kompatibel, RC2; EC-Zertifikate und -Schlüssel), auch ohne Endung und im ZIP - Zertifikatsanfragen (CSR) als PEM und DER mit Inhaber, SAN und Schlüssel - Zuordnung von Schlüssel und Anfrage zum Zertifikat (checkPrivateKey, SPKI-Vergleich) - Feld passwords je Datei, gesperrte Dateien fragen nach dem Passwort; kein Passwort in Antwort oder Log - Oberfläche: Passwortfeld mit Anzeigen/Verbergen, Schlüssel- und Anfragekarten im Reiter Analysieren Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
225 lines
9.2 KiB
TypeScript
225 lines
9.2 KiB
TypeScript
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);
|
|
});
|
|
});
|