diff --git a/apps/api/src/cert-manager/cert-analyze.spec.ts b/apps/api/src/cert-manager/cert-analyze.spec.ts index 49e34ea..1d01806 100644 --- a/apps/api/src/cert-manager/cert-analyze.spec.ts +++ b/apps/api/src/cert-manager/cert-analyze.spec.ts @@ -3,7 +3,7 @@ import { join } from 'node:path'; import AdmZip from 'adm-zip'; import { describe, expect, it } from 'vitest'; import { analyzeWorkingSet, cleanSourcePath } from './cert-analyze'; -import type { CertItem } from './cert-types'; +import type { CertItem, CsrItem, KeyItem } from './cert-types'; const fx = (name: string) => readFileSync(join(__dirname, '__fixtures__', name)); const file = (name: string, as = name) => ({ originalname: as, buffer: fx(name) }); @@ -137,3 +137,129 @@ describe('cleanSourcePath', () => { expect(cleanSourcePath('x'.repeat(400))).toHaveLength(255); }); }); + +describe('analyzeWorkingSet: Schluessel, PFX und Anfragen mit Passwort je Datei', () => { + const PASSWORD = 'Test-Pass-123'; + const names = [ + 'rsa-leaf.pem', + 'rsa-inter.pem', + 'rsa-root.pem', + 'rsa-leaf-key-enc-trad.pem', + 'rsa-leaf.csr', + 'ec-compat.pfx', + 'ec-leaf.csr.der', + ]; + const withPasswords = analyzeWorkingSet( + names.map((n) => file(n)), + ['', '', '', PASSWORD, '', PASSWORD, ''], + ); + const certs = withPasswords.items.filter((i): i is CertItem => i.kind === 'certificate'); + const keys = withPasswords.items.filter((i): i is KeyItem => i.kind === 'privateKey'); + const csrs = withPasswords.items.filter((i): i is CsrItem => i.kind === 'csr'); + + it('erkennt Zertifikate, zwei Schluessel und zwei Anfragen, nichts bleibt gesperrt', () => { + expect(certs).toHaveLength(6); + expect(keys.map((k) => k.keyType).sort()).toEqual(['EC', 'RSA']); + expect(csrs.map((r) => r.keyType).sort()).toEqual(['EC', 'RSA']); + expect(withPasswords.locked).toEqual([]); + }); + + it('ordnet Schluessel und Anfragen zu: RSA-Server und der EC-Server aus der PFX', () => { + 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 = keys.find((k) => k.keyType === 'RSA') as KeyItem; + const ecKey = keys.find((k) => k.keyType === 'EC') as KeyItem; + expect(rsa.keyId).toBe(rsaKey.id); + expect(rsaKey.certIds).toEqual([rsa.id]); + expect(ec.keyId).toBe(ecKey.id); + const rsaCsr = csrs.find((r) => r.keyType === 'RSA') as CsrItem; + expect(rsa.csrIds).toEqual([rsaCsr.id]); + expect(rsaCsr.keyId).toBe(rsaKey.id); + expect(rsaCsr.certIds).toEqual([rsa.id]); + const ecCsr = csrs.find((r) => r.keyType === 'EC') as CsrItem; + expect(ecCsr.certIds).toEqual([ec.id]); + expect(ecCsr.keyId).toBe(ecKey.id); + }); + + it('Zertifikate zuerst, dann Schluessel, dann Anfragen', () => { + expect(withPasswords.items.map((i) => i.kind)).toEqual([ + ...Array(6).fill('certificate'), + 'privateKey', + 'privateKey', + 'csr', + 'csr', + ]); + }); + + it('die Antwort enthaelt kein Passwort', () => { + expect(JSON.stringify(withPasswords)).not.toContain(PASSWORD); + }); + + it('ohne Passwoerter: Schluessel und PFX sind gesperrt (passwordNeeded), der Rest bleibt', () => { + const r = analyzeWorkingSet(names.map((n) => file(n))); + expect(r.locked).toEqual([ + { + file: 3, + path: 'rsa-leaf-key-enc-trad.pem', + container: 'privateKey', + reason: 'passwordNeeded', + }, + { file: 5, path: 'ec-compat.pfx', container: 'pkcs12', reason: 'passwordNeeded' }, + ]); + expect(r.items.filter((i) => i.kind === 'certificate')).toHaveLength(3); + }); + + it('falsches Passwort fuer eine Datei: passwordWrong nur fuer diese Datei', () => { + const r = analyzeWorkingSet( + names.map((n) => file(n)), + ['', '', '', 'falsch', '', '', ''], + ); + const reasons = Object.fromEntries(r.locked.map((l) => [l.path, l.reason])); + expect(reasons).toEqual({ + 'rsa-leaf-key-enc-trad.pem': 'passwordWrong', + 'ec-compat.pfx': 'passwordNeeded', + }); + }); + + it('ein Passwort fuer Datei 1 oeffnet auch Datei 2, wenn es dasselbe ist', () => { + const r = analyzeWorkingSet([file('rsa-modern.pfx'), file('ec-compat.pfx')], [PASSWORD, '']); + expect(r.locked).toEqual([]); + expect(r.items.filter((i) => i.kind === 'certificate').length).toBeGreaterThanOrEqual(5); + }); + + it('eine PFX im ZIP: gesperrt mit dem Pfad im ZIP, entsperrt vom Passwort des ZIPs', () => { + const zip = new AdmZip(); + zip.addFile('Windows/server.pfx', fx('rsa-modern.pfx')); + zip.addFile('readme.txt', Buffer.from('hallo')); + const buffer = zip.toBuffer(); + const closed = analyzeWorkingSet([{ originalname: 'hersteller.zip', buffer }]); + expect(closed.locked).toEqual([ + { + file: 0, + path: 'hersteller.zip/Windows/server.pfx', + container: 'pkcs12', + reason: 'passwordNeeded', + }, + ]); + const open = analyzeWorkingSet([{ originalname: 'hersteller.zip', buffer }], [PASSWORD]); + expect(open.locked).toEqual([]); + expect(open.items.filter((i) => i.kind === 'certificate')).toHaveLength(3); + expect(open.items.filter((i) => i.kind === 'privateKey')).toHaveLength(1); + }); + + it('mehr als zehn verschiedene Passwoerter: die Datei probiert hoechstens zehn', () => { + // Das richtige steht an elfter Stelle der anderen Dateien und wird nicht mehr probiert. + const pws = [...Array.from({ length: 11 }, (_, i) => `falsch-${i}`), PASSWORD]; + const r = analyzeWorkingSet( + [ + file('rsa-modern.pfx'), + ...pws.map((_, i) => ({ + originalname: `x${i}.txt`, + buffer: Buffer.from('x'), + })), + ], + ['', ...pws], + ); + expect(r.locked[0]?.reason).toBe('passwordNeeded'); + }); +}); diff --git a/apps/api/src/cert-manager/cert-analyze.ts b/apps/api/src/cert-manager/cert-analyze.ts index 1077c77..d775c2f 100644 --- a/apps/api/src/cert-manager/cert-analyze.ts +++ b/apps/api/src/cert-manager/cert-analyze.ts @@ -1,19 +1,23 @@ -import { buildChains } from './cert-chain'; +import { buildChains, matchKeys } from './cert-chain'; +import { candidatePasswords } from './cert-keys'; import { detectBlob } from './cert-model'; import { cleanSourcePath } from './cert-names'; import type { AnalysisResult, AnyItem, CertItem, + CsrItem, IgnoredEntry, ItemSource, + KeyItem, LockedEntry, } from './cert-types'; /** * Fassade der Analyse (quick-261009-ikt, D-15): alle hochgeladenen Dateien erkennen, * gleiche Teile zusammenfassen und ordnen. Zustandslos; nichts wird gespeichert. - * Ketten ab Task 2; Schluessel/CSR-Zuordnung und gesperrte Container (Task 4) folgen. + * Passwoerter (Task 4): `passwords[i]` gehoert zu Datei i; jede Datei probiert zuerst ihr eigenes, + * danach die uebrigen verschiedenen (hoechstens 10). Passwoerter stehen in keiner Antwort. */ export { cleanSourcePath }; @@ -46,10 +50,17 @@ export function analyzeWorkingSet(files: AnalyzeFile[], passwords: string[] = [] const byId = new Map(); const ignored: IgnoredEntry[] = []; const locked: LockedEntry[] = []; + const lockedSeen = new Set(); files.forEach((f, index) => { const path = cleanSourcePath(f.originalname); - const result = detectBlob(f.buffer, { file: index, path, passwords }); + const own = passwords[index] ?? ''; + const result = detectBlob(f.buffer, { + file: index, + path, + passwords: candidatePasswords(own, passwords), + ownPassword: own, + }); for (const item of result.items) { const known = byId.get(item.id); if (!known) { @@ -61,10 +72,20 @@ export function analyzeWorkingSet(files: AnalyzeFile[], passwords: string[] = [] } } ignored.push(...result.ignored); - locked.push(...result.locked); + for (const entry of result.locked) { + const key = `${entry.file}|${entry.path}|${entry.container}|${entry.reason}`; + if (lockedSeen.has(key)) continue; + lockedSeen.add(key); + locked.push(entry); + } }); const items = orderItems([...byId.values()]); const certs = items.filter((i): i is CertItem => i.kind === 'certificate'); + matchKeys( + certs, + items.filter((i): i is KeyItem => i.kind === 'privateKey'), + items.filter((i): i is CsrItem => i.kind === 'csr'), + ); return { items, chains: buildChains(certs).chains, locked, ignored }; } diff --git a/apps/api/src/cert-manager/cert-chain.spec.ts b/apps/api/src/cert-manager/cert-chain.spec.ts index e5c2b65..556da40 100644 --- a/apps/api/src/cert-manager/cert-chain.spec.ts +++ b/apps/api/src/cert-manager/cert-chain.spec.ts @@ -1,9 +1,9 @@ import { readFileSync } from 'node:fs'; import { join } from 'node:path'; import { describe, expect, it } from 'vitest'; -import { buildChains } from './cert-chain'; +import { buildChains, matchKeys } from './cert-chain'; import { detectBlob } from './cert-model'; -import type { CertItem } from './cert-types'; +import type { AnyItem, CertItem, CsrItem, KeyItem } from './cert-types'; const fx = (name: string) => readFileSync(join(__dirname, '__fixtures__', name)); @@ -150,3 +150,75 @@ describe('buildChains', () => { 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); + }); +}); diff --git a/apps/api/src/cert-manager/cert-chain.ts b/apps/api/src/cert-manager/cert-chain.ts index 5d10ce0..3cad03e 100644 --- a/apps/api/src/cert-manager/cert-chain.ts +++ b/apps/api/src/cert-manager/cert-chain.ts @@ -1,5 +1,7 @@ -import { X509Certificate } from 'node:crypto'; -import type { CertItem, ChainGap, ChainInfo } from './cert-types'; +import { createPrivateKey, createPublicKey, X509Certificate } from 'node:crypto'; +import { csrPublicKeyOf } from './cert-csr'; +import { spkiDerOf } from './cert-keys'; +import type { CertItem, ChainGap, ChainInfo, CsrItem, KeyItem } from './cert-types'; /** * Kettenbau des Zertifikat-Managers (quick-261009-ikt, D-18). Reine Funktionen, kein Netz. @@ -146,3 +148,69 @@ export function buildChains(certs: CertItem[], headIds?: string[]): { chains: Ch const now = Date.now(); return { chains: heads.map((h) => chainOf(nodes, h, now)) }; } + +/** + * Ordnet Schluessel und Zertifikatsanfragen ihren Zertifikaten zu (D-18) und traegt die Zuordnung + * in die Eintraege ein (`keyId`, `certIds`, `csrIds`); vorherige Zuordnungen werden ersetzt. + * + * - Schluessel und Zertifikat: `cert.checkPrivateKey(key)`. + * - Anfrage und Zertifikat oder Schluessel: der oeffentliche Schluessel (SPKI-DER) ist derselbe. + * Nie ueber Namen oder Modulus-Zeichenketten: das geht bei EC nicht und waere bei RSA unsauber. + */ +export function matchKeys(certs: CertItem[], keys: KeyItem[], csrs: CsrItem[]): void { + const certObjects = certs.map((item) => { + item.keyId = null; + item.csrIds = []; + try { + const x = new X509Certificate(item.pem); + return { item, x, spki: spkiDerOf(x.publicKey) }; + } catch { + return { item, x: null, spki: null }; + } + }); + const keyObjects = keys.map((item) => { + item.certIds = []; + try { + const key = createPrivateKey(item.pem); + return { item, key, spki: spkiDerOf(createPublicKey(key)) }; + } catch { + return { item, key: null, spki: null }; + } + }); + + for (const c of certObjects) { + if (!c.x) continue; + for (const k of keyObjects) { + if (!k.key) continue; + let matches = false; + try { + matches = c.x.checkPrivateKey(k.key); + } catch { + // nicht pruefbar (unbekannter Schluesseltyp): kein Treffer + } + if (matches) { + c.item.keyId ??= k.item.id; + k.item.certIds.push(c.item.id); + } + } + } + + for (const csr of csrs) { + csr.keyId = null; + csr.certIds = []; + let spki: Buffer; + try { + spki = csrPublicKeyOf(csr.pem).spki; + } catch { + continue; + } + const key = keyObjects.find((k) => k.spki?.equals(spki)); + if (key) csr.keyId = key.item.id; + for (const c of certObjects) { + if (c.spki?.equals(spki)) { + csr.certIds.push(c.item.id); + c.item.csrIds.push(csr.id); + } + } + } +} diff --git a/apps/api/src/cert-manager/cert-csr.spec.ts b/apps/api/src/cert-manager/cert-csr.spec.ts new file mode 100644 index 0000000..d96b274 --- /dev/null +++ b/apps/api/src/cert-manager/cert-csr.spec.ts @@ -0,0 +1,84 @@ +import { X509Certificate } from 'node:crypto'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { csrPublicKeyOf } from './cert-csr'; +import { keyIdOf } from './cert-keys'; +import { detectBlob } from './cert-model'; +import type { CsrItem } from './cert-types'; + +const fx = (name: string) => readFileSync(join(__dirname, '__fixtures__', name)); + +function csrs(name: string): CsrItem[] { + const r = detectBlob(fx(name), { file: 0, path: name, passwords: [] }); + return r.items.filter((i): i is CsrItem => i.kind === 'csr'); +} + +describe('Zertifikatsanfragen (CSR)', () => { + it('RSA-CSR als PEM: Inhaber, SAN aus der extensionRequest, Schluessel', () => { + const [csr] = csrs('rsa-leaf.csr'); + expect(csr).toBeDefined(); + expect(csr.kind).toBe('csr'); + expect(csr.cn).toBe('www.example.test'); + expect(csr.organization).toBe('Tessera Test'); + expect(csr.san).toEqual(['www.example.test', 'example.test']); + expect(csr.keyType).toBe('RSA'); + expect(csr.keyBits).toBe(2048); + expect(csr.curve).toBeNull(); + expect(csr.id).toMatch(/^r-[0-9a-f]{16}$/); + expect(csr.pem.startsWith('-----BEGIN CERTIFICATE REQUEST-----')).toBe(true); + expect(csr.baseName).toBe('www.example.test'); + expect(csr.sources).toEqual([{ file: 0, path: 'rsa-leaf.csr' }]); + }); + + it('EC-CSR als PEM: P-256', () => { + const [csr] = csrs('ec-leaf.csr'); + expect(csr.cn).toBe('ec.example.test'); + expect(csr.keyType).toBe('EC'); + expect(csr.curve).toBe('P-256'); + expect(csr.keyBits).toBe(256); + expect(csr.san).toContain('ec.example.test'); + }); + + it('PEM und DER derselben Anfrage teilen die Kennung', () => { + expect(csrs('rsa-leaf.csr')[0].id).toBe(csrs('rsa-leaf.csr.der')[0].id); + expect(csrs('ec-leaf.csr')[0].id).toBe(csrs('ec-leaf.csr.der')[0].id); + expect(csrs('rsa-leaf.csr')[0].id).not.toBe(csrs('ec-leaf.csr')[0].id); + }); + + it('der Schluessel der Anfrage ist der des Zertifikats (SPKI)', () => { + for (const [csr, cert] of [ + ['rsa-leaf.csr', 'rsa-leaf.pem'], + ['ec-leaf.csr.der', 'ec-leaf.pem'], + ]) { + const [item] = csrs(csr); + const { id, spki } = csrPublicKeyOf(item.pem); + const x = new X509Certificate(fx(cert)); + expect(id).toBe(keyIdOf(x.publicKey)); + expect(spki.equals(x.publicKey.export({ type: 'spki', format: 'der' }))).toBe(true); + } + }); + + it('keyId und certIds setzt erst die Zuordnung', () => { + const [csr] = csrs('rsa-leaf.csr'); + expect(csr.keyId).toBeNull(); + expect(csr.certIds).toEqual([]); + }); + + it('ein Zertifikat oder Schluessel wird nie als CSR gelesen', () => { + for (const name of ['rsa-leaf.cer', 'ec-leaf-key-sec1.der', 'rsa-chain.p7c']) { + expect(csrs(name)).toEqual([]); + } + }); + + it('abgeschnittene und kaputte Anfragen ergeben nur „unbekannt“', () => { + const half = fx('rsa-leaf.csr.der').subarray(0, 200); + const r = detectBlob(half, { file: 4, path: 'halb.csr', passwords: [] }); + expect(r.items).toEqual([]); + expect(r.ignored).toEqual([{ file: 4, path: 'halb.csr', reason: 'unknown' }]); + const brokenPem = Buffer.from( + '-----BEGIN CERTIFICATE REQUEST-----\nQUJDREVGR0hJSktMTU5PUA==\n-----END CERTIFICATE REQUEST-----\n', + ); + expect(detectBlob(brokenPem, { file: 0, path: 'k.csr', passwords: [] }).items).toEqual([]); + }); +}); diff --git a/apps/api/src/cert-manager/cert-csr.ts b/apps/api/src/cert-manager/cert-csr.ts new file mode 100644 index 0000000..ce1bcef --- /dev/null +++ b/apps/api/src/cert-manager/cert-csr.ts @@ -0,0 +1,162 @@ +import { createPublicKey } from 'node:crypto'; +import * as forge from 'node-forge'; +import { describeKey, keyIdOf, sha256Hex, spkiDerOf } from './cert-keys'; +import { safeBaseName } from './cert-names'; +import type { CsrItem, ItemSource } from './cert-types'; + +/** + * Zertifikatsanfragen (CSR) des Zertifikat-Managers (quick-261009-ikt, D-05, D-16). + * + * Die CSR-Leser von forge koennen nur RSA. Darum wird der ASN.1-Aufbau selbst gelesen + * (CertificationRequestInfo: Version, Inhaber, oeffentlicher Schluessel, Attribute) und der + * Schluessel an node:crypto gegeben. Die Selbstunterschrift wird nicht geprueft: die Anfrage + * dient hier nur der Anzeige und der Zuordnung zu Zertifikat und Schluessel. + */ + +const OID_EXTENSION_REQUEST = '1.2.840.113549.1.9.14'; +const OID_SUBJECT_ALT_NAME = '2.5.29.17'; + +type Asn1 = forge.asn1.Asn1; + +function children(node: Asn1): Asn1[] { + return Array.isArray(node.value) ? (node.value as Asn1[]) : []; +} + +function readAsn1(der: Buffer): Asn1 { + // Typdefinition kennt nur `strict: boolean`; decodeBitStrings aus, damit der Schluessel + // (SPKI) beim erneuten Schreiben Byte fuer Byte dem Original entspricht. + const options = { decodeBitStrings: false } as unknown as boolean; + return forge.asn1.fromDer(forge.util.createBuffer(der.toString('binary')), options); +} + +function toBuffer(node: Asn1): Buffer { + return Buffer.from(forge.asn1.toDer(node).getBytes(), 'binary'); +} + +interface ParsedRequest { + subject: Asn1; + spki: Asn1; + attributes: Asn1 | null; +} + +/** Prueft den Aufbau streng genug, dass weder ein Zertifikat noch ein Schluessel als CSR durchgeht. */ +function parseRequest(der: Buffer): ParsedRequest { + const root = readAsn1(der); + const [info, algorithm, signature] = children(root); + if ( + root.type !== forge.asn1.Type.SEQUENCE || + children(root).length !== 3 || + algorithm.type !== forge.asn1.Type.SEQUENCE || + signature.type !== forge.asn1.Type.BITSTRING || + info.type !== forge.asn1.Type.SEQUENCE + ) { + throw new Error('not a certification request'); + } + const [version, subject, spki, attributes] = children(info); + if ( + version?.type !== forge.asn1.Type.INTEGER || + version.value !== '\x00' || + subject?.type !== forge.asn1.Type.SEQUENCE || + spki?.type !== forge.asn1.Type.SEQUENCE || + children(spki).length !== 2 || + children(spki)[1].type !== forge.asn1.Type.BITSTRING + ) { + throw new Error('not a certification request'); + } + const hasAttributes = + attributes?.tagClass === forge.asn1.Class.CONTEXT_SPECIFIC && attributes.type === 0; + return { subject, spki, attributes: hasAttributes ? attributes : null }; +} + +function formatIp(bytes: string): string | null { + if (bytes.length === 4) return [...bytes].map((c) => c.charCodeAt(0)).join('.'); + if (bytes.length === 16) { + const groups: string[] = []; + for (let i = 0; i < 16; i += 2) { + groups.push( + ((bytes.charCodeAt(i) << 8) | bytes.charCodeAt(i + 1)).toString(16).toUpperCase(), + ); + } + return groups.join(':'); + } + return null; +} + +/** SAN aus dem Attribut „extensionRequest“: DNS-Namen unveraendert, IP-Adressen mit Praefix 'IP:'. */ +function sanOf(attributes: Asn1 | null): string[] { + const names: string[] = []; + if (!attributes) return names; + for (const attribute of children(attributes)) { + const [oid, set] = children(attribute); + if (!oid || oid.type !== forge.asn1.Type.OID) continue; + if (forge.asn1.derToOid(oid.value as string) !== OID_EXTENSION_REQUEST) continue; + const extensionList = children(set ?? attribute)[0]; + const extensions = extensionList ? children(extensionList) : []; + for (const extension of extensions) { + const parts = children(extension); + if (parts[0]?.type !== forge.asn1.Type.OID) continue; + if (forge.asn1.derToOid(parts[0].value as string) !== OID_SUBJECT_ALT_NAME) continue; + const octets = parts[parts.length - 1]; + if (octets?.type !== forge.asn1.Type.OCTETSTRING) continue; + const generalNames = forge.asn1.fromDer(forge.util.createBuffer(octets.value as string)); + for (const name of children(generalNames)) { + if (name.tagClass !== forge.asn1.Class.CONTEXT_SPECIFIC) continue; + if (name.type === 2) names.push(name.value as string); // dNSName + if (name.type === 7) { + const ip = formatIp(name.value as string); // iPAddress + if (ip) names.push(`IP:${ip}`); + } + } + } + } + return names; +} + +/** forge.pki.RDNAttributesAsArray fehlt in den Typdefinitionen, ist aber Teil von forge (liest nur den Namen). */ +const rdnAttributesAsArray = ( + forge.pki as unknown as { + RDNAttributesAsArray(rdn: Asn1): { shortName?: string; value: unknown }[]; + } +).RDNAttributesAsArray; + +function rdnValue(subject: Asn1, shortName: string): string { + const attributes = rdnAttributesAsArray(subject); + const found = attributes.find((a) => a.shortName === shortName); + return typeof found?.value === 'string' ? found.value : ''; +} + +function pemOf(der: Buffer): string { + const lines = der.toString('base64').match(/.{1,64}/g) ?? []; + return `-----BEGIN CERTIFICATE REQUEST-----\n${lines.join('\n')}\n-----END CERTIFICATE REQUEST-----\n`; +} + +/** Baut den Eintrag aus einem DER-CSR. Wirft, wenn es keine Zertifikatsanfrage ist. `keyId` setzt matchKeys. */ +export function csrItemFromDer(der: Buffer, source: ItemSource): CsrItem { + const request = parseRequest(der); + const publicKey = createPublicKey({ key: toBuffer(request.spki), format: 'der', type: 'spki' }); + const details = describeKey(publicKey); + const cn = rdnValue(request.subject, 'CN'); + return { + id: `r-${sha256Hex(der).slice(0, 16)}`, + kind: 'csr', + sources: [{ ...source }], + pem: pemOf(der), + baseName: safeBaseName(cn, 'anfrage'), + cn, + organization: rdnValue(request.subject, 'O'), + san: sanOf(request.attributes), + keyType: details.keyType, + keyBits: details.keyBits, + curve: details.curve, + keyId: null, + certIds: [], + }; +} + +/** Kennung des Schluessels einer Anfrage (aus dem PEM des Eintrags), fuer die Zuordnung. */ +export function csrPublicKeyOf(pem: string): { id: string; spki: Buffer } { + const body = pem.replace(/-----(BEGIN|END) CERTIFICATE REQUEST-----/g, '').replace(/\s+/g, ''); + const request = parseRequest(Buffer.from(body, 'base64')); + const publicKey = createPublicKey({ key: toBuffer(request.spki), format: 'der', type: 'spki' }); + return { id: keyIdOf(publicKey), spki: spkiDerOf(publicKey) }; +} diff --git a/apps/api/src/cert-manager/cert-keys.spec.ts b/apps/api/src/cert-manager/cert-keys.spec.ts new file mode 100644 index 0000000..a4ee82e --- /dev/null +++ b/apps/api/src/cert-manager/cert-keys.spec.ts @@ -0,0 +1,169 @@ +import { createPrivateKey, createPublicKey } from 'node:crypto'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { candidatePasswords, keyIdOf, MAX_PASSWORDS } from './cert-keys'; +import { detectBlob } from './cert-model'; +import type { KeyItem } from './cert-types'; + +const fx = (name: string) => readFileSync(join(__dirname, '__fixtures__', name)); +const PASSWORD = 'Test-Pass-123'; + +function detect(name: string, own = '', others: string[] = []) { + return detectBlob(fx(name), { + file: 0, + path: name, + passwords: candidatePasswords(own, [own, ...others]), + ownPassword: own, + }); +} + +function keys(name: string, own = ''): KeyItem[] { + return detect(name, own).items.filter((i): i is KeyItem => i.kind === 'privateKey'); +} + +/** Kennung, die zur Schluesselhaelfte der Zertifikate in den Fixtures gehoert */ +function idOfFile(name: string): string { + return keyIdOf(createPublicKey(createPrivateKey(fx(name)))); +} + +const RSA_IDS = idOfFile('rsa-leaf-key.pem'); +const EC_IDS = idOfFile('ec-leaf-key.pem'); + +describe('Schluessel ohne Passwort', () => { + const plain = [ + ['rsa-leaf-key.pem', RSA_IDS, 'RSA'], + ['rsa-leaf-key-pkcs1.pem', RSA_IDS, 'RSA'], + ['rsa-leaf-key-pkcs8.der', RSA_IDS, 'RSA'], + ['rsa-leaf-key-pkcs1.der', RSA_IDS, 'RSA'], + ['ec-leaf-key.pem', EC_IDS, 'EC'], + ['ec-leaf-key-sec1.pem', EC_IDS, 'EC'], + ['ec-leaf-key-sec1.der', EC_IDS, 'EC'], + ] as const; + + it.each( + plain, + )('%s ergibt einen Eintrag mit derselben Kennung je Schluesselpaar', (name, id, type) => { + const result = detect(name); + expect(result.locked).toEqual([]); + expect(result.ignored).toEqual([]); + expect(result.items).toHaveLength(1); + const [key] = result.items as KeyItem[]; + expect(key.kind).toBe('privateKey'); + expect(key.id).toBe(id); + expect(key.keyType).toBe(type); + expect(key.wasEncrypted).toBe(false); + expect(key.pem.startsWith('-----BEGIN PRIVATE KEY-----')).toBe(true); + expect(key.sources).toEqual([{ file: 0, path: name }]); + expect(key.certIds).toEqual([]); + }); + + it('RSA 2048 und EC P-256 werden beschrieben', () => { + const rsa = keys('rsa-leaf-key.pem')[0]; + expect(rsa.keyBits).toBe(2048); + expect(rsa.curve).toBeNull(); + const ec = keys('ec-leaf-key.pem')[0]; + expect(ec.curve).toBe('P-256'); + expect(ec.keyBits).toBe(256); + }); + + it('das PEM des Eintrags ist der Schluessel selbst (PKCS#8, lesbar)', () => { + const k = keys('ec-leaf-key-sec1.pem')[0]; + expect(createPrivateKey(k.pem).asymmetricKeyType).toBe('ec'); + }); +}); + +describe('verschluesselte Schluessel', () => { + const encrypted = [ + ['rsa-leaf-key-enc-pkcs8.pem', RSA_IDS], + ['rsa-leaf-key-enc-trad.pem', RSA_IDS], + ['ec-leaf-key-enc-pkcs8.pem', EC_IDS], + ['ec-leaf-key-enc-trad.pem', EC_IDS], + ['ec-leaf-key-enc-pkcs8.der', EC_IDS], + ] as const; + + it.each(encrypted)('%s mit Passwort: derselbe Schluessel, wasEncrypted wahr', (name, id) => { + const result = detect(name, PASSWORD); + expect(result.locked).toEqual([]); + const [key] = result.items as KeyItem[]; + expect(key.id).toBe(id); + expect(key.wasEncrypted).toBe(true); + expect(key.pem.startsWith('-----BEGIN PRIVATE KEY-----')).toBe(true); + }); + + it.each(encrypted)('%s ohne Passwort: gesperrt (passwordNeeded)', (name) => { + const result = detect(name); + expect(result.items).toEqual([]); + expect(result.locked).toEqual([ + { file: 0, path: name, container: 'privateKey', reason: 'passwordNeeded' }, + ]); + }); + + it.each(encrypted)('%s mit falschem Passwort: passwordWrong', (name) => { + const result = detect(name, 'falsch'); + expect(result.items).toEqual([]); + expect(result.locked).toEqual([ + { file: 0, path: name, container: 'privateKey', reason: 'passwordWrong' }, + ]); + }); + + it('ein Passwort einer anderen Datei oeffnet die Datei, ohne dass sie ein eigenes hat', () => { + const result = detect('rsa-leaf-key-enc-trad.pem', '', [PASSWORD]); + expect(result.locked).toEqual([]); + expect(result.items).toHaveLength(1); + }); + + it('ein fremdes falsches Passwort bleibt „Passwort noetig“, nicht „falsch“', () => { + const result = detect('rsa-leaf-key-enc-pkcs8.pem', '', ['falsch']); + expect(result.locked[0].reason).toBe('passwordNeeded'); + }); + + it('die Antwort enthaelt das Passwort nicht', () => { + const json = JSON.stringify(detect('rsa-leaf-key-enc-pkcs8.pem', PASSWORD)); + expect(json).not.toContain(PASSWORD); + const locked = JSON.stringify(detect('rsa-leaf-key-enc-pkcs8.pem', 'geheim-xyz')); + expect(locked).not.toContain('geheim-xyz'); + }); +}); + +describe('Schluessel im Text', () => { + it('mehrere Bloecke in einer Datei: Zertifikat und Schluessel', () => { + const both = Buffer.concat([fx('rsa-leaf.pem'), Buffer.from('\n'), fx('rsa-leaf-key.pem')]); + const r = detectBlob(both, { file: 1, path: 'beides.pem', passwords: [] }); + expect(r.items.map((i) => i.kind).sort()).toEqual(['certificate', 'privateKey']); + }); + + it('ein Schluesselblock mit kaputtem Inhalt wird als nicht unterstuetzt gemeldet', () => { + const broken = Buffer.from( + '-----BEGIN PRIVATE KEY-----\nQUJDREVGR0hJSktMTU5PUA==\n-----END PRIVATE KEY-----\n', + ); + const r = detectBlob(broken, { file: 3, path: 'kaputt.pem', passwords: [] }); + expect(r.items).toEqual([]); + expect(r.ignored).toEqual([{ file: 3, path: 'kaputt.pem', reason: 'unsupportedKey' }]); + }); + + it('DER-Zufallsbytes werden nie als Schluessel gelesen', () => { + const r = detectBlob(Buffer.from([0x30, 0x03, 0x02, 0x01, 0x05]), { + file: 0, + path: 'x.bin', + passwords: [], + }); + expect(r.items).toEqual([]); + expect(r.ignored).toEqual([{ file: 0, path: 'x.bin', reason: 'unknown' }]); + }); +}); + +describe('candidatePasswords', () => { + it('das eigene zuerst, verschiedene Werte, leere fallen weg', () => { + expect(candidatePasswords('a', ['b', 'a', '', 'c', 'b'])).toEqual(['a', 'b', 'c']); + expect(candidatePasswords('', ['x'])).toEqual(['x']); + expect(candidatePasswords('', [])).toEqual([]); + }); + + it('hoechstens zehn verschiedene', () => { + const many = Array.from({ length: 25 }, (_, i) => `pw-${i}`); + const out = candidatePasswords('eigen', many); + expect(out).toHaveLength(MAX_PASSWORDS); + expect(out[0]).toBe('eigen'); + }); +}); diff --git a/apps/api/src/cert-manager/cert-keys.ts b/apps/api/src/cert-manager/cert-keys.ts new file mode 100644 index 0000000..df60a8a --- /dev/null +++ b/apps/api/src/cert-manager/cert-keys.ts @@ -0,0 +1,226 @@ +import { createHash, createPrivateKey, createPublicKey, type KeyObject } from 'node:crypto'; +import * as forge from 'node-forge'; +import type { IgnoredEntry, ItemSource, KeyItem, LockedEntry } from './cert-types'; + +/** + * Private Schluessel des Zertifikat-Managers (quick-261009-ikt, D-05, D-11, D-16). + * + * Alles laeuft ueber node:crypto (`createPrivateKey`): PKCS#1, PKCS#8 und SEC1, als PEM und als DER, + * unverschluesselt, als verschluesseltes PKCS#8 und klassisch verschluesselt (Proc-Type), fuer RSA, + * EC und alle weiteren Typen, die Node lesen kann. Passwoerter und Schluessel kommen hier nie in + * eine Ausgabe oder ein Log; Fehlertexte aus OpenSSL werden nicht weitergereicht. + */ + +/** Hoechstens so viele verschiedene Passwoerter werden je Datei probiert. */ +export const MAX_PASSWORDS = 10; + +/** Was die Erkennung eines Schluessels liefert (gleiche Form wie das Ergebnis von detectBlob). */ +export interface KeyDetectResult { + items: KeyItem[]; + ignored: IgnoredEntry[]; + locked: LockedEntry[]; +} + +export interface KeyContext { + file: number; + path: string; + /** Kandidaten, das Passwort der Datei zuerst; hoechstens MAX_PASSWORDS */ + passwords: string[]; + /** Das fuer genau diese Datei eingegebene Passwort (leer, wenn keines) */ + ownPassword?: string; +} + +export function sha256Hex(data: Buffer | string): string { + return createHash('sha256').update(data).digest('hex'); +} + +const CURVE_NAMES: Record = { + prime256v1: { name: 'P-256', bits: 256 }, + secp256r1: { name: 'P-256', bits: 256 }, + secp384r1: { name: 'P-384', bits: 384 }, + secp521r1: { name: 'P-521', bits: 521 }, +}; + +/** Beschreibung eines oeffentlichen oder privaten Schluessels, fuer Zertifikat, Schluessel und CSR gleich. */ +export function describeKey(key: KeyObject): { + keyType: string; + keyBits: number | null; + curve: string | null; +} { + const type = key.asymmetricKeyType ?? 'unknown'; + const details = key.asymmetricKeyDetails ?? {}; + if (type === 'rsa') { + return { keyType: 'RSA', keyBits: details.modulusLength ?? null, curve: null }; + } + if (type === 'ec') { + const raw = details.namedCurve ?? ''; + const known = CURVE_NAMES[raw]; + return { + keyType: 'EC', + keyBits: known?.bits ?? null, + curve: known?.name ?? (raw || null), + }; + } + if (type === 'ed25519') return { keyType: 'ED25519', keyBits: 256, curve: null }; + return { keyType: type.toUpperCase(), keyBits: null, curve: null }; +} + +/** SPKI-DER eines oeffentlichen Schluessels: die Grundlage jeder Zuordnung. */ +export function spkiDerOf(publicKey: KeyObject): Buffer { + return publicKey.export({ type: 'spki', format: 'der' }); +} + +/** 'k-' + 16 Hex von sha256 des SPKI-DER: gleiche Kennung fuer Zertifikat, Schluessel und CSR. */ +export function keyIdOf(publicKey: KeyObject): string { + return `k-${sha256Hex(spkiDerOf(publicKey)).slice(0, 16)}`; +} + +/** + * Passwoerter, die fuer eine Datei probiert werden: zuerst das eigene (falls eingegeben), danach die + * uebrigen verschiedenen Passwoerter der Anfrage, zusammen hoechstens MAX_PASSWORDS. Leere Werte fallen weg. + */ +export function candidatePasswords(own: string, all: string[]): string[] { + const out: string[] = []; + const add = (p: string) => { + if (p !== '' && !out.includes(p) && out.length < MAX_PASSWORDS) out.push(p); + }; + add(own); + for (const p of all) add(p); + return out; +} + +/** Baut den Eintrag aus einem lesbaren privaten Schluessel (unverschluesseltes PKCS#8 als PEM). */ +export function keyItemFromObject( + key: KeyObject, + source: ItemSource, + wasEncrypted: boolean, +): KeyItem { + const publicKey = createPublicKey(key); + const details = describeKey(key); + return { + id: keyIdOf(publicKey), + kind: 'privateKey', + sources: [{ ...source }], + pem: key.export({ type: 'pkcs8', format: 'pem' }) as string, + baseName: 'schluessel', + keyType: details.keyType, + keyBits: details.keyBits, + curve: details.curve, + wasEncrypted, + certIds: [], + }; +} + +function lockReason(ctx: KeyContext): 'passwordNeeded' | 'passwordWrong' { + return ctx.ownPassword ? 'passwordWrong' : 'passwordNeeded'; +} + +function locked(ctx: KeyContext): KeyDetectResult { + return { + items: [], + ignored: [], + locked: [{ file: ctx.file, path: ctx.path, container: 'privateKey', reason: lockReason(ctx) }], + }; +} + +function found(key: KeyObject, ctx: KeyContext, wasEncrypted: boolean): KeyDetectResult { + return { + items: [keyItemFromObject(key, { file: ctx.file, path: ctx.path }, wasEncrypted)], + ignored: [], + locked: [], + }; +} + +/** Probiert die Kandidaten der Reihe nach; der erste, der den Schluessel oeffnet, gewinnt. */ +function openWithPasswords( + open: (passphrase: string) => KeyObject, + ctx: KeyContext, +): KeyObject | null { + for (const passphrase of ctx.passwords) { + try { + return open(passphrase); + } catch { + // falsches Passwort (oder nicht unterstuetztes Verfahren): naechstes probieren + } + } + return null; +} + +/** + * Ein PEM-Block mit Etikett „... PRIVATE KEY“. `text` ist der ganze Block samt BEGIN/END-Zeilen, + * `encrypted` ist wahr bei „ENCRYPTED PRIVATE KEY“ oder einer Kopfzeile `Proc-Type: 4,ENCRYPTED`. + */ +export function keyFromPemBlock( + text: string, + label: string, + encrypted: boolean, + ctx: KeyContext, +): KeyDetectResult | null { + if (!label.endsWith('PRIVATE KEY')) return null; + if (!encrypted && label !== 'ENCRYPTED PRIVATE KEY') { + try { + return found(createPrivateKey(text), ctx, false); + } catch { + return { + items: [], + ignored: [{ file: ctx.file, path: ctx.path, reason: 'unsupportedKey' }], + locked: [], + }; + } + } + const key = openWithPasswords((passphrase) => createPrivateKey({ key: text, passphrase }), ctx); + return key ? found(key, ctx, true) : locked(ctx); +} + +/** ASN.1 mit unveraenderten Bitfolgen; Typdefinition kennt nur `strict: boolean`, forge nimmt ein Objekt. */ +function readAsn1(der: Buffer): forge.asn1.Asn1 { + const options = { decodeBitStrings: false, strict: false } as unknown as boolean; + return forge.asn1.fromDer(forge.util.createBuffer(der.toString('binary')), options); +} + +/** + * Verschluesseltes PKCS#8 (EncryptedPrivateKeyInfo) hat genau zwei Teile: die Verfahrensangabe + * (SEQUENCE mit einer Kennung) und die verschluesselten Daten (OCTET STRING). Anders als ein + * unverschluesselter Schluessel beginnt es nicht mit einer Versionsnummer. + */ +function isEncryptedPkcs8(root: forge.asn1.Asn1): boolean { + const parts = root.value; + if (root.type !== forge.asn1.Type.SEQUENCE || !Array.isArray(parts) || parts.length !== 2) { + return false; + } + const [algorithm, data] = parts as forge.asn1.Asn1[]; + const algorithmParts = algorithm.value; + return ( + algorithm.type === forge.asn1.Type.SEQUENCE && + Array.isArray(algorithmParts) && + (algorithmParts as forge.asn1.Asn1[])[0]?.type === forge.asn1.Type.OID && + data.type === forge.asn1.Type.OCTETSTRING + ); +} + +const DER_KEY_TYPES = ['pkcs8', 'pkcs1', 'sec1'] as const; + +/** Privater Schluessel als DER (PKCS#8, PKCS#1, SEC1 oder verschluesseltes PKCS#8). Kein Schluessel: null. */ +export function keyFromDer(der: Buffer, ctx: KeyContext): KeyDetectResult | null { + let root: forge.asn1.Asn1; + try { + root = readAsn1(der); + } catch { + return null; + } + if (isEncryptedPkcs8(root)) { + const key = openWithPasswords( + (passphrase) => createPrivateKey({ key: der, format: 'der', type: 'pkcs8', passphrase }), + ctx, + ); + return key ? found(key, ctx, true) : locked(ctx); + } + for (const type of DER_KEY_TYPES) { + try { + return found(createPrivateKey({ key: der, format: 'der', type }), ctx, false); + } catch { + // anderer Aufbau: naechsten Typ probieren + } + } + return null; +} diff --git a/apps/api/src/cert-manager/cert-manager.controller.spec.ts b/apps/api/src/cert-manager/cert-manager.controller.spec.ts index 61f19e4..6bcee5b 100644 --- a/apps/api/src/cert-manager/cert-manager.controller.spec.ts +++ b/apps/api/src/cert-manager/cert-manager.controller.spec.ts @@ -4,7 +4,7 @@ import { join } from 'node:path'; import { RequestMethod } from '@nestjs/common'; import { describe, expect, it } from 'vitest'; import { MODULE_SLUG_KEY } from '../module-registry/module.guard'; -import { CertManagerController, repairFileName } from './cert-manager.controller'; +import { CertManagerController, parsePasswords, repairFileName } from './cert-manager.controller'; const fx = (name: string) => readFileSync(join(__dirname, '__fixtures__', name)); const upload = (name: string, buffer: Buffer = fx(name)) => ({ @@ -89,6 +89,47 @@ describe('CertManagerController', () => { }); }); +describe('analyze: Feld passwords', () => { + const controller = new CertManagerController(); + const files = () => [upload('rsa-leaf-key-enc-trad.pem'), upload('ec-compat.pfx')]; + + it('das Passwort je Datei erreicht den Parser', () => { + const none = controller.analyze(files()); + expect(none.locked).toHaveLength(2); + const open = controller.analyze(files(), JSON.stringify(['Test-Pass-123', 'Test-Pass-123'])); + expect(open.locked).toEqual([]); + expect(open.items.some((i) => i.kind === 'privateKey')).toBe(true); + }); + + it('die Antwort enthaelt das Passwort nicht', () => { + const open = controller.analyze(files(), JSON.stringify(['Test-Pass-123', ''])); + expect(JSON.stringify(open)).not.toContain('Test-Pass-123'); + }); + + it('ohne das Feld gibt es keine Passwoerter', () => { + expect(parsePasswords(undefined)).toEqual([]); + expect(parsePasswords('')).toEqual([]); + }); + + it.each([ + ['kein JSON', 'nope'], + ['ein Objekt', '{"a":1}'], + ['eine Zahl in der Liste', '[1]'], + ['eine Zeichenkette statt Liste', '"x"'], + ['mehr als 30 Eintraege', JSON.stringify(Array(31).fill('a'))], + ['ein Eintrag ueber 1024 Zeichen', JSON.stringify(['a'.repeat(1025)])], + ])('%s: 400 invalidInput', (_name, raw) => { + expect(codeOf(() => controller.analyze(files(), raw))).toEqual({ + status: 400, + code: 'invalidInput', + }); + }); + + it('genau 30 Eintraege zu je 1024 Zeichen sind erlaubt', () => { + expect(parsePasswords(JSON.stringify(Array(30).fill('a'.repeat(1024))))).toHaveLength(30); + }); +}); + describe('repairFileName', () => { it('gewinnt UTF-8-Namen zurueck, die als Latin-1 ankamen', () => { const asLatin1 = Buffer.from('Zertifikat-Müller.pem', 'utf8').toString('latin1'); diff --git a/apps/api/src/cert-manager/cert-manager.controller.ts b/apps/api/src/cert-manager/cert-manager.controller.ts index 3f011af..5d0447c 100644 --- a/apps/api/src/cert-manager/cert-manager.controller.ts +++ b/apps/api/src/cert-manager/cert-manager.controller.ts @@ -12,6 +12,35 @@ export const CERT_MAX_FILES = 30; export const CERT_MAX_FILE_BYTES = 5 * 1024 * 1024; export const CERT_MAX_TOTAL_BYTES = 20 * 1024 * 1024; +/** Das Feld `passwords`: ein JSON-Array von Zeichenketten, je Datei eines (Task 4). */ +export const CERT_MAX_PASSWORDS = 30; +export const CERT_MAX_PASSWORD_CHARS = 1024; + +/** + * Liest das multipart-Feld `passwords`. Fehlt es, gibt es keine Passwoerter. Alles andere als ein + * JSON-Array aus hoechstens 30 Zeichenketten zu je hoechstens 1024 Zeichen: 400 invalidInput. + * Der Inhalt erscheint nie in einer Fehlermeldung. + */ +export function parsePasswords(raw: unknown): string[] { + if (raw === undefined || raw === null || raw === '') return []; + let value: unknown = raw; + if (typeof raw === 'string') { + try { + value = JSON.parse(raw); + } catch { + certError('invalidInput', 400, 'passwords must be a JSON array of strings'); + } + } + if ( + !Array.isArray(value) || + value.length > CERT_MAX_PASSWORDS || + value.some((p) => typeof p !== 'string' || p.length > CERT_MAX_PASSWORD_CHARS) + ) { + certError('invalidInput', 400, 'passwords must be a JSON array of strings'); + } + return value as string[]; +} + /** * multer liest Dateinamen als Latin-1. Waren es UTF-8-Bytes (Umlaute), den Namen zurueckgewinnen; * ist das Ergebnis kein gueltiges UTF-8, bleibt der Name wie er ist. @@ -29,7 +58,7 @@ export function repairFileName(name: string): string { * Geschuetzt durch den globalen JwtAuthGuard, den TenantGuard und @UseModule('cert-manager'). * * Routen (alle POST, 200): - * - analyze Task 1 mehrere Dateien (multipart) erkennen und zusammenfassen + * - analyze Task 1 mehrere Dateien (multipart) erkennen und zusammenfassen; Task 4: optionales Feld `passwords` * - build Task 2 Ausgabe bauen (JSON, eigene Grenze 512 KiB, siehe cert-json-body.ts), ab Task 5/6 erweitert * - fetch-issuer Task 7 fehlendes Zwischenzertifikat nur auf Knopfdruck holen */ @@ -41,7 +70,10 @@ export class CertManagerController { @UseInterceptors( FilesInterceptor('files', CERT_MAX_FILES, { limits: { fileSize: CERT_MAX_FILE_BYTES } }), ) - analyze(@UploadedFiles() files: UploadedFileLike[] | undefined): AnalysisResult { + analyze( + @UploadedFiles() files: UploadedFileLike[] | undefined, + @Body('passwords') rawPasswords?: unknown, + ): AnalysisResult { if (!files || files.length === 0) { certError('invalidInput', 400, 'No files provided'); } @@ -49,8 +81,10 @@ export class CertManagerController { if (total > CERT_MAX_TOTAL_BYTES) { certError('tooLarge', 413, 'Files together exceed 20 MiB'); } + const passwords = parsePasswords(rawPasswords); return analyzeWorkingSet( files.map((f) => ({ originalname: repairFileName(f.originalname), buffer: f.buffer })), + passwords, ); } diff --git a/apps/api/src/cert-manager/cert-model.spec.ts b/apps/api/src/cert-manager/cert-model.spec.ts index 7474487..e3d259d 100644 --- a/apps/api/src/cert-manager/cert-model.spec.ts +++ b/apps/api/src/cert-manager/cert-model.spec.ts @@ -34,7 +34,7 @@ describe('detectBlob: Zertifikate', () => { 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.keyId).toBeNull(); // die Zuordnung zu einem Schluessel setzt matchKeys expect(c.isExpired).toBe(false); expect(c.daysLeft).toBeGreaterThan(30000); }); diff --git a/apps/api/src/cert-manager/cert-model.ts b/apps/api/src/cert-manager/cert-model.ts index 4fa6029..514dd5a 100644 --- a/apps/api/src/cert-manager/cert-model.ts +++ b/apps/api/src/cert-manager/cert-model.ts @@ -1,6 +1,16 @@ -import { createHash, type KeyObject, X509Certificate } from 'node:crypto'; +import { X509Certificate } from 'node:crypto'; import * as forge from 'node-forge'; +import { csrItemFromDer } from './cert-csr'; +import { + describeKey, + keyFromDer, + keyFromPemBlock, + keyIdOf, + keyItemFromObject, + sha256Hex, +} from './cert-keys'; import { safeBaseName } from './cert-names'; +import { isPkcs12Der, readPkcs12 } from './cert-pkcs12'; import type { AnyItem, CertItem, @@ -11,6 +21,9 @@ import type { } from './cert-types'; import { expandZip, isZip } from './zip-expand'; +// Die Schluesselhelfer wohnen in cert-keys.ts (dort ohne Importschleife nutzbar); hier weiter erreichbar. +export { describeKey, keyIdOf, sha256Hex }; + /** * Der eine Parser des Zertifikat-Managers (quick-261009-ikt, D-08, D-16). * @@ -20,9 +33,9 @@ import { expandZip, isZip } from './zip-expand'; * * Erkennung nach Inhalt, nie nach Dateiendung. Jede Stufe steht in try/catch: eine kaputte * Datei ergibt hoechstens einen Eintrag „unbekannt“, nie einen Fehler fuer die ganze Anfrage. - * Stand Task 3: Zertifikate als PEM (auch TRUSTED CERTIFICATE) und als DER, PKCS#7 als PEM und DER - * (auch fuer EC, ueber den ASN.1-Lauf von forge) und ZIP (eine Ebene, Grenzen in zip-expand.ts). - * Schluessel, PKCS#12 und CSR (Task 4) sind benannte, noch leere Stufen. + * Stand Task 4: Zertifikate als PEM (auch TRUSTED CERTIFICATE) und als DER, PKCS#7 als PEM und DER + * (auch fuer EC, ueber den ASN.1-Lauf von forge), ZIP (eine Ebene, Grenzen in zip-expand.ts), + * private Schluessel (cert-keys.ts), PKCS#12 (cert-pkcs12.ts) und Zertifikatsanfragen (cert-csr.ts). */ export interface DetectContext { @@ -30,8 +43,10 @@ export interface DetectContext { file: number; /** Dateiname, bei ZIP-Inhalt "zip/eintrag" */ path: string; - /** Passwoerter fuer geschuetzte Container (ab Task 4) */ + /** Kandidaten fuer geschuetzte Container, das Passwort der Datei zuerst (hoechstens 10) */ passwords: string[]; + /** Das fuer genau diese Datei eingegebene Passwort; leer, wenn keines. Nur fuer „gesperrt: Passwort falsch“. */ + ownPassword?: string; } export interface DetectResult { @@ -42,50 +57,10 @@ export interface DetectResult { const CERT_LABELS = new Set(['CERTIFICATE', 'X509 CERTIFICATE', 'TRUSTED CERTIFICATE']); const PKCS7_LABELS = new Set(['PKCS7', 'CMS']); +const CSR_LABELS = new Set(['CERTIFICATE REQUEST', 'NEW CERTIFICATE REQUEST']); const PEM_BLOCK = /-----BEGIN ([A-Z0-9 ]+)-----([\s\S]*?)-----END \1-----/g; const BASE64_BODY = /^[A-Za-z0-9+/]+={0,2}$/; -const CURVE_NAMES: Record = { - prime256v1: { name: 'P-256', bits: 256 }, - secp256r1: { name: 'P-256', bits: 256 }, - secp384r1: { name: 'P-384', bits: 384 }, - secp521r1: { name: 'P-521', bits: 521 }, -}; - -export function sha256Hex(data: Buffer | string): string { - return createHash('sha256').update(data).digest('hex'); -} - -/** Beschreibung eines oeffentlichen oder privaten Schluessels, fuer Zertifikat, Schluessel und CSR gleich. */ -export function describeKey(key: KeyObject): { - keyType: string; - keyBits: number | null; - curve: string | null; -} { - const type = key.asymmetricKeyType ?? 'unknown'; - const details = key.asymmetricKeyDetails ?? {}; - if (type === 'rsa') { - return { keyType: 'RSA', keyBits: details.modulusLength ?? null, curve: null }; - } - if (type === 'ec') { - const raw = details.namedCurve ?? ''; - const known = CURVE_NAMES[raw]; - return { - keyType: 'EC', - keyBits: known?.bits ?? null, - curve: known?.name ?? (raw || null), - }; - } - if (type === 'ed25519') return { keyType: 'ED25519', keyBits: 256, curve: null }; - return { keyType: type.toUpperCase(), keyBits: null, curve: null }; -} - -/** 'k-' + 16 Hex von sha256 des SPKI-DER: gleiche Kennung fuer Zertifikat, Schluessel und CSR. */ -export function keyIdOf(publicKey: KeyObject): string { - const spki = publicKey.export({ type: 'spki', format: 'der' }); - return `k-${sha256Hex(spki).slice(0, 16)}`; -} - function firstValue(v: unknown): string { if (Array.isArray(v)) return typeof v[0] === 'string' ? v[0] : ''; return typeof v === 'string' ? v : ''; @@ -181,7 +156,7 @@ export function certItemFromDer(der: Buffer, source: ItemSource): CertItem { isCa, selfSigned, aiaIssuerUrls: aiaUrls(legacy.infoAccess), - keyId: keyIdOf(x.publicKey), + keyId: null, csrIds: [], }; } @@ -285,6 +260,8 @@ function pkcs7Items(der: Buffer, ctx: DetectContext): CertItem[] { interface PemBlock { label: string; + /** der Block samt BEGIN/END-Zeilen, unveraendert (fuer verschluesselte Schluessel) */ + text: string; der: Buffer | null; encrypted: boolean; } @@ -301,7 +278,7 @@ function pemBlocks(text: string): PemBlock[] { .join('') .replace(/\s+/g, ''); const valid = BASE64_BODY.test(body) && body.length % 4 === 0; - blocks.push({ label, der: valid ? Buffer.from(body, 'base64') : null, encrypted }); + blocks.push({ label, text: m[0], der: valid ? Buffer.from(body, 'base64') : null, encrypted }); } return blocks; } @@ -316,12 +293,31 @@ function certFromDer(der: Buffer, ctx: DetectContext): CertItem | null { } } +function mergeInto(result: DetectResult, part: DetectResult | null): void { + if (!part) return; + result.items.push(...part.items); + result.ignored.push(...part.ignored); + result.locked.push(...part.locked); +} + /** Text mit -----BEGIN-Bloecken: jeder Block nach seinem Etikett. */ function detectPem(blob: Buffer, ctx: DetectContext): DetectResult | null { - const text = blob.toString('utf8').replace(/^/, ''); + const text = blob.toString('utf8').replace(/^\uFEFF/, ''); if (!text.includes('-----BEGIN ')) return null; const result = emptyResult(); for (const block of pemBlocks(text)) { + // Verschluesselte Schluessel (Proc-Type) tragen im Rumpf Kopfzeilen; ihr Text geht unveraendert an Node. + if (block.label.endsWith('PRIVATE KEY')) { + try { + mergeInto( + result, + keyFromPemBlock(block.text, block.label, block.encrypted, keyContext(ctx)), + ); + } catch { + // kaputter Schluesselblock: die anderen Bloecke der Datei bleiben gueltig + } + continue; + } if (!block.der) continue; if (CERT_LABELS.has(block.label)) { const item = certFromDer(block.der, ctx); @@ -334,10 +330,17 @@ function detectPem(blob: Buffer, ctx: DetectContext): DetectResult | null { // kaputter PKCS#7-Block: die anderen Bloecke der Datei bleiben gueltig } } - // PRIVATE KEY, RSA/EC PRIVATE KEY, ENCRYPTED PRIVATE KEY und CERTIFICATE REQUEST - // (Task 4) folgen in dieser Schleife. + if (CSR_LABELS.has(block.label)) { + try { + result.items.push(csrItemFromDer(block.der, { file: ctx.file, path: ctx.path })); + } catch { + // keine lesbare Anfrage: ueberspringen + } + } } - return result.items.length > 0 ? result : null; + const empty = + result.items.length === 0 && result.ignored.length === 0 && result.locked.length === 0; + return empty ? null : result; } /** Einzelnes DER-Zertifikat (.cer/.crt/.der). */ @@ -347,9 +350,38 @@ function detectDerCertificate(blob: Buffer, ctx: DetectContext): DetectResult | return { items: [item], ignored: [], locked: [] }; } -/** PKCS#12 (Task 4). */ -function detectPkcs12(_blob: Buffer, _ctx: DetectContext): DetectResult | null { - return null; +function keyContext(ctx: DetectContext) { + return { + file: ctx.file, + path: ctx.path, + passwords: ctx.passwords, + ownPassword: ctx.ownPassword, + }; +} + +/** + * PKCS#12 (.pfx/.p12), erkannt am Aufbau und nicht an der Endung. Zertifikate und Schluessel werden + * zu gewoehnlichen Eintraegen mit dem Container als Quelle; ohne passendes Passwort: gesperrt. + */ +function detectPkcs12(blob: Buffer, ctx: DetectContext): DetectResult | null { + if (!isPkcs12Der(blob)) return null; + const read = readPkcs12(blob, ctx.passwords, ctx.ownPassword ?? ''); + if (!read.ok) { + return { + items: [], + ignored: [], + locked: [{ file: ctx.file, path: ctx.path, container: 'pkcs12', reason: read.reason }], + }; + } + const result = emptyResult(); + for (const der of read.contents.certDers) { + const item = certFromDer(der, ctx); + if (item) result.items.push(item); + } + for (const { key, shrouded } of read.contents.keys) { + result.items.push(keyItemFromObject(key, { file: ctx.file, path: ctx.path }, shrouded)); + } + return result.items.length > 0 ? result : null; } /** PKCS#7 signedData als DER. */ @@ -358,14 +390,18 @@ function detectPkcs7(blob: Buffer, ctx: DetectContext): DetectResult | null { return items.length > 0 ? { items, ignored: [], locked: [] } : null; } -/** Privater Schluessel als DER (Task 4). */ -function detectPrivateKey(_blob: Buffer, _ctx: DetectContext): DetectResult | null { - return null; +/** Privater Schluessel als DER (PKCS#8, PKCS#1, SEC1, verschluesseltes PKCS#8). */ +function detectPrivateKey(blob: Buffer, ctx: DetectContext): DetectResult | null { + return keyFromDer(blob, keyContext(ctx)); } -/** Zertifikatsanfrage als DER (Task 4). */ -function detectCsr(_blob: Buffer, _ctx: DetectContext): DetectResult | null { - return null; +/** Zertifikatsanfrage als DER. */ +function detectCsr(blob: Buffer, ctx: DetectContext): DetectResult | null { + return { + items: [csrItemFromDer(blob, { file: ctx.file, path: ctx.path })], + ignored: [], + locked: [], + }; } const STAGES: ((blob: Buffer, ctx: DetectContext) => DetectResult | null)[] = [ diff --git a/apps/api/src/cert-manager/cert-pkcs12.spec.ts b/apps/api/src/cert-manager/cert-pkcs12.spec.ts new file mode 100644 index 0000000..77fea55 --- /dev/null +++ b/apps/api/src/cert-manager/cert-pkcs12.spec.ts @@ -0,0 +1,112 @@ +import { X509Certificate } from 'node:crypto'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { candidatePasswords, keyIdOf } from './cert-keys'; +import { detectBlob } from './cert-model'; +import { isPkcs12Der, readPkcs12 } from './cert-pkcs12'; +import type { CertItem, KeyItem } from './cert-types'; + +const fx = (name: string) => readFileSync(join(__dirname, '__fixtures__', name)); +const PASSWORD = 'Test-Pass-123'; + +const sha = (name: string) => new X509Certificate(fx(name)).fingerprint256; + +describe('readPkcs12', () => { + const cases = [ + ['rsa-modern.pfx', 'rsa', ['rsa-leaf.pem', 'rsa-inter.pem', 'rsa-root.pem']], + ['rsa-compat.pfx', 'rsa', ['rsa-leaf.pem', 'rsa-inter.pem', 'rsa-root.pem']], + ['rsa-legacy.pfx', 'rsa', ['rsa-leaf.pem', 'rsa-inter.pem', 'rsa-root.pem']], + ['ec-modern.pfx', 'ec', ['ec-leaf.pem', 'ec-inter.pem', 'ec-root.pem']], + ['ec-compat.pfx', 'ec', ['ec-leaf.pem', 'ec-inter.pem', 'ec-root.pem']], + ] as const; + + it.each( + cases, + )('%s: Zertifikate (auch EC) und Schluessel mit dem Passwort', (name, type, certFiles) => { + const result = readPkcs12(fx(name), [PASSWORD], PASSWORD); + expect(result.ok).toBe(true); + if (!result.ok) return; + const fingerprints = result.contents.certDers.map((d) => new X509Certificate(d).fingerprint256); + expect(fingerprints.sort()).toEqual(certFiles.map(sha).sort()); + expect(result.contents.keys).toHaveLength(1); + expect(result.contents.keys[0].key.asymmetricKeyType).toBe(type); + expect(result.contents.keys[0].shrouded).toBe(true); + }); + + it('rsa-nopass.pfx oeffnet sich ohne Passwort (leeres Passwort)', () => { + const result = readPkcs12(fx('rsa-nopass.pfx'), []); + expect(result.ok).toBe(true); + if (result.ok) expect(result.contents.certDers).toHaveLength(3); + }); + + it('ohne Passwort: passwordNeeded, mit falschem: passwordWrong', () => { + expect(readPkcs12(fx('rsa-modern.pfx'), [])).toEqual({ ok: false, reason: 'passwordNeeded' }); + expect(readPkcs12(fx('rsa-modern.pfx'), ['falsch'], 'falsch')).toEqual({ + ok: false, + reason: 'passwordWrong', + }); + }); + + it('probiert die uebrigen Passwoerter der Anfrage, wenn das eigene nicht passt', () => { + const result = readPkcs12(fx('ec-compat.pfx'), ['falsch', 'noch-falsch', PASSWORD], 'falsch'); + expect(result.ok).toBe(true); + }); + + it('erkennt PKCS#12 am Aufbau, nicht an der Endung', () => { + expect(isPkcs12Der(fx('rsa-modern.bin'))).toBe(true); + expect(isPkcs12Der(fx('rsa-leaf.cer'))).toBe(false); + expect(isPkcs12Der(Buffer.from('kein Container'))).toBe(false); + }); +}); + +describe('detectBlob: PKCS#12', () => { + function detect(name: string, own = '', others: string[] = []) { + return detectBlob(fx(name), { + file: 0, + path: name, + passwords: candidatePasswords(own, [own, ...others]), + ownPassword: own, + }); + } + + it('ec-compat.pfx: EC-Zertifikate und EC-Schluessel werden Eintraege mit dem Container als Quelle', () => { + const r = detect('ec-compat.pfx', PASSWORD); + expect(r.locked).toEqual([]); + const certs = r.items.filter((i): i is CertItem => i.kind === 'certificate'); + expect(certs.map((c) => c.cn).sort()).toEqual([ + 'Tessera Test Inter EC', + 'Tessera Test Root EC', + 'ec.example.test', + ]); + expect(certs.every((c) => c.keyType === 'EC')).toBe(true); + expect(certs[0].sources).toEqual([{ file: 0, path: 'ec-compat.pfx' }]); + const key = r.items.find((i): i is KeyItem => i.kind === 'privateKey'); + const leaf = new X509Certificate(fx('ec-leaf.pem')); + expect(key?.id).toBe(keyIdOf(leaf.publicKey)); + expect(key?.wasEncrypted).toBe(true); + }); + + it('rsa-modern.bin wird am Inhalt erkannt', () => { + const r = detect('rsa-modern.bin', PASSWORD); + expect(r.items.filter((i) => i.kind === 'certificate')).toHaveLength(3); + expect(r.items.filter((i) => i.kind === 'privateKey')).toHaveLength(1); + }); + + it('ohne Passwort: gesperrt mit Container pkcs12', () => { + expect(detect('rsa-modern.pfx').locked).toEqual([ + { file: 0, path: 'rsa-modern.pfx', container: 'pkcs12', reason: 'passwordNeeded' }, + ]); + expect(detect('rsa-modern.pfx', 'falsch').locked[0].reason).toBe('passwordWrong'); + }); + + it('das Passwort einer anderen Datei oeffnet auch diese Datei', () => { + const r = detect('rsa-legacy.pfx', '', [PASSWORD]); + expect(r.locked).toEqual([]); + expect(r.items.length).toBeGreaterThan(0); + }); + + it('die Antwort enthaelt das Passwort nicht', () => { + expect(JSON.stringify(detect('rsa-modern.pfx', PASSWORD))).not.toContain(PASSWORD); + }); +}); diff --git a/apps/api/src/cert-manager/cert-pkcs12.ts b/apps/api/src/cert-manager/cert-pkcs12.ts new file mode 100644 index 0000000..7ebe9b3 --- /dev/null +++ b/apps/api/src/cert-manager/cert-pkcs12.ts @@ -0,0 +1,124 @@ +import { createPrivateKey, type KeyObject } from 'node:crypto'; +import * as forge from 'node-forge'; +import { MAX_PASSWORDS } from './cert-keys'; + +/** + * PKCS#12 (.pfx/.p12) des Zertifikat-Managers (quick-261009-ikt, D-20). + * + * Lesen laeuft ueber forge (`pkcs12FromAsn1`): OpenSSL-3-Standard (AES-256, SHA-256), das + * kompatible 3DES-Format und das alte RC2-Format werden gelesen. forge kennt aber nur RSA: + * bei EC-Zertifikaten ist `bag.cert` leer, bei EC-Schluesseln ist `bag.key` falsch. Darum wird + * das Zertifikat aus `bag.asn1` und der Schluessel aus dem PKCS#8-ASN.1 des Beutels gelesen und + * an node:crypto weitergegeben. Passwoerter erscheinen weder in Fehlern noch in Logs. + */ + +export interface Pkcs12Key { + key: KeyObject; + /** der Schluessel lag verschluesselt im Container (pkcs8ShroudedKeyBag) */ + shrouded: boolean; +} + +export interface Pkcs12Contents { + /** Zertifikate als DER, in der Reihenfolge des Containers */ + certDers: Buffer[]; + keys: Pkcs12Key[]; +} + +export type Pkcs12Read = + | { ok: true; contents: Pkcs12Contents } + | { ok: false; reason: 'passwordNeeded' | 'passwordWrong' }; + +function parseAsn1(der: Buffer): forge.asn1.Asn1 { + // Die Typdefinition kennt nur `strict: boolean`; forge nimmt zur Laufzeit ein Optionsobjekt. + const options = { strict: false } as unknown as boolean; + return forge.asn1.fromDer(forge.util.createBuffer(der.toString('binary')), options); +} + +/** + * PKCS#12 erkennt man am Aufbau: oberste SEQUENCE, deren erstes Element die INTEGER 3 ist + * (Version). Die Dateiendung spielt keine Rolle. + */ +export function isPkcs12Der(der: Buffer): boolean { + try { + const root = parseAsn1(der); + const first = Array.isArray(root.value) ? (root.value as forge.asn1.Asn1[])[0] : undefined; + return ( + root.type === forge.asn1.Type.SEQUENCE && + first?.type === forge.asn1.Type.INTEGER && + first.value === '\x03' + ); + } catch { + return false; + } +} + +function toBuffer(asn1: forge.asn1.Asn1): Buffer { + return Buffer.from(forge.asn1.toDer(asn1).getBytes(), 'binary'); +} + +function bagsOf(p12: forge.pkcs12.Pkcs12Pfx, bagType: string): forge.pkcs12.Bag[] { + return p12.getBags({ bagType })[bagType] ?? []; +} + +function extract(p12: forge.pkcs12.Pkcs12Pfx): Pkcs12Contents { + const certDers: Buffer[] = []; + for (const bag of bagsOf(p12, forge.pki.oids.certBag)) { + // RSA: bag.cert ist ein forge-Zertifikat; EC: bag.cert ist leer, das Zertifikat steht in bag.asn1. + const asn1 = + bag.asn1 ?? + (bag.cert ? forge.pki.certificateToAsn1(bag.cert as forge.pki.Certificate) : null); + if (asn1) certDers.push(toBuffer(asn1)); + } + const keys: Pkcs12Key[] = []; + const shroudedFirst: [string, boolean][] = [ + [forge.pki.oids.pkcs8ShroudedKeyBag, true], + [forge.pki.oids.keyBag, false], + ]; + for (const [bagType, shrouded] of shroudedFirst) { + for (const bag of bagsOf(p12, bagType)) { + try { + // RSA: forge hat den Schluessel gelesen; EC: bag.key ist falsch, bag.asn1 ist das PKCS#8. + const key = bag.key + ? createPrivateKey(forge.pki.privateKeyToPem(bag.key as forge.pki.rsa.PrivateKey)) + : createPrivateKey({ + key: toBuffer(bag.asn1 as forge.asn1.Asn1), + format: 'der', + type: 'pkcs8', + }); + keys.push({ key, shrouded }); + } catch { + // ein Schluessel, den Node nicht lesen kann, fehlt in der Auswertung; der Rest bleibt gueltig + } + } + } + return { certDers, keys }; +} + +/** + * Oeffnet einen PKCS#12-Container. Probiert das Passwort der Datei (`ownPassword`), das leere + * Passwort und danach die uebrigen `passwords`, zusammen hoechstens MAX_PASSWORDS + 1 Versuche. + * Ohne passendes Passwort: gesperrt; `passwordWrong`, wenn die Datei ein eigenes Passwort hatte. + * Ein Container, den forge aus anderem Grund nicht lesen kann, wirft (der Aufrufer meldet „unbekannt“). + */ +export function readPkcs12(der: Buffer, passwords: string[], ownPassword = ''): Pkcs12Read { + const asn1 = parseAsn1(der); + const tries: string[] = []; + for (const p of [ownPassword, '', ...passwords]) { + if (!tries.includes(p) && tries.length < MAX_PASSWORDS + 1) tries.push(p); + } + let passwordProblem = false; + let lastError: unknown = new Error('unreadable PKCS#12'); + for (const password of tries) { + try { + return { ok: true, contents: extract(forge.pkcs12.pkcs12FromAsn1(asn1, false, password)) }; + } catch (error) { + lastError = error; + const message = error instanceof Error ? error.message : ''; + if (/mac|password|decrypt|padding|invalid|asn\.?1|too few bytes/i.test(message)) { + passwordProblem = true; + } + } + } + if (!passwordProblem) throw lastError; + return { ok: false, reason: ownPassword ? 'passwordWrong' : 'passwordNeeded' }; +} diff --git a/apps/web/src/app/(portal)/modules/cert-manager/components/AnalyzeTab.test.tsx b/apps/web/src/app/(portal)/modules/cert-manager/components/AnalyzeTab.test.tsx index 4e9f8b2..fa03d74 100644 --- a/apps/web/src/app/(portal)/modules/cert-manager/components/AnalyzeTab.test.tsx +++ b/apps/web/src/app/(portal)/modules/cert-manager/components/AnalyzeTab.test.tsx @@ -3,7 +3,7 @@ import { NextIntlClientProvider } from 'next-intl'; import type { ReactElement } from 'react'; import { afterEach, describe, expect, it } from 'vitest'; import de from '@/messages/de.json'; -import type { AnalysisResult, CertItem, ChainInfo } from '../actions'; +import type { AnalysisResult, CertItem, ChainInfo, CsrItem, KeyItem } from '../actions'; import type { CertWorkspace } from '../use-cert-workspace'; import type { WorkingEntry } from '../working-set'; import { AnalyzeTab } from './AnalyzeTab'; @@ -69,6 +69,7 @@ function workspace( errorKey: null, addFiles: () => [], addText: () => null, + setPassword: () => {}, remove: () => {}, clear: () => {}, retry: () => {}, @@ -229,3 +230,134 @@ describe('AnalyzeTab', () => { expect(container.innerHTML).not.toContain('uppercase'); }); }); + +describe('AnalyzeTab: Schluessel, Anfragen, gesperrte Dateien', () => { + const keyed = cert({ + id: 'c-k', + cn: 'www.example.test', + role: 'end-entity', + keyId: 'k-1', + csrIds: ['r-1'], + }); + const key: KeyItem = { + id: 'k-1', + kind: 'privateKey', + sources: [{ file: 0, path: 'key.pem' }], + pem: 'PEM-KEY', + baseName: 'schluessel', + keyType: 'RSA', + keyBits: 2048, + curve: null, + wasEncrypted: true, + certIds: ['c-k'], + }; + const csr: CsrItem = { + id: 'r-1', + kind: 'csr', + sources: [{ file: 1, path: 'www.csr' }], + pem: 'PEM-CSR', + baseName: 'www.example.test', + cn: 'www.example.test', + organization: 'Tessera Test', + san: ['www.example.test', 'example.test'], + keyType: 'RSA', + keyBits: 2048, + curve: null, + keyId: 'k-1', + certIds: ['c-k'], + }; + const analysis: AnalysisResult = { + items: [keyed, key, csr], + chains: [], + locked: [ + { file: 2, path: 'vendor.pfx', container: 'pkcs12', reason: 'passwordNeeded' }, + { file: 3, path: 'zweiter.pfx', container: 'pkcs12', reason: 'passwordWrong' }, + ], + ignored: [], + }; + const entries = [entry('e1', 'key.pem'), entry('e2', 'www.csr')]; + + it('Zertifikat mit passendem Schluessel traegt den Hinweis, ohne Schluessel nicht', () => { + render(); + const [card] = screen.getAllByTestId('cert-card'); + expect(within(card).getByText('Passender Schlüssel vorhanden')).toBeInTheDocument(); + cleanup(); + render( + , + ); + expect(screen.queryByText('Passender Schlüssel vorhanden')).not.toBeInTheDocument(); + }); + + it('Schluesselkarte: Art, Groesse, „War verschlüsselt“, zu welchem Zertifikat er gehoert', () => { + render(); + const card = screen.getByTestId('key-card'); + expect(within(card).getByText('Privater Schlüssel')).toBeInTheDocument(); + expect(within(card).getByText('RSA, 2048 Bit')).toBeInTheDocument(); + expect(within(card).getByText('War verschlüsselt')).toBeInTheDocument(); + expect(within(card).getByText('www.example.test')).toBeInTheDocument(); + expect(card).toHaveTextContent('key.pem'); + }); + + it('Schluessel ohne Zertifikat sagt es ruhig', () => { + const lonely: AnalysisResult = { + items: [{ ...key, certIds: [] }], + chains: [], + locked: [], + ignored: [], + }; + render(); + expect(screen.getByText('Dazu liegt noch kein Zertifikat vor.')).toBeInTheDocument(); + }); + + it('Anfragekarte: Inhaber, Namen, Schluessel, Zuordnung', () => { + render(); + const card = screen.getByTestId('csr-card'); + expect(within(card).getByText('Zertifikatsanfrage (CSR)')).toBeInTheDocument(); + expect(within(card).getByText('Tessera Test')).toBeInTheDocument(); + expect(within(card).getByText('www.example.test, example.test')).toBeInTheDocument(); + expect(within(card).getByText('RSA, 2048 Bit')).toBeInTheDocument(); + expect(within(card).getByText('Passender Schlüssel').nextSibling).toHaveTextContent( + 'vorhanden', + ); + }); + + it('die Zusammenfassung nennt gesperrte Dateien und verweist auf den Reiter Dateien', () => { + render(); + const region = screen.getByRole('region', { name: 'Geschützte Dateien' }); + const rows = within(region).getAllByRole('listitem'); + expect(rows[0]).toHaveTextContent('vendor.pfx braucht ein Passwort'); + expect(rows[1]).toHaveTextContent('zweiter.pfx Das Passwort passt nicht'); + expect(region).toHaveTextContent('im Reiter „Dateien“'); + }); + + it('nur gesperrte Dateien: kein „nichts erkannt“, sondern die Zusammenfassung', () => { + render( + , + ); + expect( + screen.queryByText('In den Dateien wurde noch kein Zertifikat erkannt.'), + ).not.toBeInTheDocument(); + expect(screen.getByRole('region', { name: 'Geschützte Dateien' })).toBeInTheDocument(); + }); + + it('kein Passwort und kein Schluesselinhalt im Text', () => { + const { container } = render( + , + ); + expect(container.textContent).not.toContain('PEM-KEY'); + expect(container.textContent).not.toMatch(/[→←·]/); + }); +}); diff --git a/apps/web/src/app/(portal)/modules/cert-manager/components/AnalyzeTab.tsx b/apps/web/src/app/(portal)/modules/cert-manager/components/AnalyzeTab.tsx index 438fe89..54f93d6 100644 --- a/apps/web/src/app/(portal)/modules/cert-manager/components/AnalyzeTab.tsx +++ b/apps/web/src/app/(portal)/modules/cert-manager/components/AnalyzeTab.tsx @@ -1,7 +1,7 @@ 'use client'; import { useTranslations } from 'next-intl'; -import type { CertItem } from '../actions'; +import type { CertItem, CsrItem, KeyItem } from '../actions'; import type { CertWorkspace } from '../use-cert-workspace'; import { ChainView } from './ChainView'; import { ItemCard } from './ItemCard'; @@ -27,8 +27,14 @@ export function AnalyzeTab({ workspace }: AnalyzeTabProps) { } const certs = analysis.items.filter((i): i is CertItem => i.kind === 'certificate'); + const keys = analysis.items.filter((i): i is KeyItem => i.kind === 'privateKey'); + const csrs = analysis.items.filter((i): i is CsrItem => i.kind === 'csr'); - if (certs.length === 0 && analysis.ignored.length === 0) { + if ( + analysis.items.length === 0 && + analysis.ignored.length === 0 && + analysis.locked.length === 0 + ) { return

{t('analyze.nothingFound')}

; } @@ -52,12 +58,69 @@ export function AnalyzeTab({ workspace }: AnalyzeTabProps) {
    {certs.map((cert) => ( - + ))}
)} + {keys.length > 0 && ( +
+

{t('analyze.keysTitle')}

+
    + {keys.map((key) => ( + + ))} +
+
+ )} + + {csrs.length > 0 && ( +
+

{t('analyze.csrsTitle')}

+
    + {csrs.map((csr) => ( + + ))} +
+
+ )} + + {analysis.locked.length > 0 && ( +
+

{t('analyze.lockedTitle')}

+
    + {analysis.locked.map((l) => ( +
  • + {l.path}{' '} + {l.reason === 'passwordWrong' + ? t('analyze.lockedWrong') + : t('analyze.lockedNeeded')} +
  • + ))} +
+

{t('analyze.lockedHint')}

+
+ )} + {analysis.ignored.length > 0 && (

{t('analyze.ignoredTitle')}

diff --git a/apps/web/src/app/(portal)/modules/cert-manager/components/FilesTab.test.tsx b/apps/web/src/app/(portal)/modules/cert-manager/components/FilesTab.test.tsx index 4489da5..fdfb4a8 100644 --- a/apps/web/src/app/(portal)/modules/cert-manager/components/FilesTab.test.tsx +++ b/apps/web/src/app/(portal)/modules/cert-manager/components/FilesTab.test.tsx @@ -10,7 +10,7 @@ import { NextIntlClientProvider } from 'next-intl'; import type { ReactElement } from 'react'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import de from '@/messages/de.json'; -import type { AnalysisResult, CertItem } from '../actions'; +import type { AnalysisResult, CertItem, CsrItem, KeyItem } from '../actions'; import { CertManagerRequestError } from '../actions'; import { useCertWorkspace } from '../use-cert-workspace'; import { FilesTab } from './FilesTab'; @@ -290,4 +290,180 @@ describe('FilesTab', () => { expect(screen.getByRole('alert')).toHaveTextContent('länger als'); expect(mockAnalyze).not.toHaveBeenCalled(); }); + + describe('geschuetzte Dateien', () => { + const locked = (over: Partial = {}) => ({ + file: 0, + path: 'server.pfx', + container: 'pkcs12' as const, + reason: 'passwordNeeded' as const, + ...over, + }); + const keyItem = (): KeyItem => ({ + id: 'k-1', + kind: 'privateKey', + sources: [{ file: 0, path: 'key.pem' }], + pem: '', + baseName: 'schluessel', + keyType: 'EC', + keyBits: 256, + curve: 'P-256', + wasEncrypted: true, + certIds: [], + }); + const csrItem = (): CsrItem => ({ + id: 'r-1', + kind: 'csr', + sources: [{ file: 0, path: 'req.csr' }], + pem: '', + baseName: 'anfrage', + cn: 'www.example.test', + organization: '', + san: [], + keyType: 'RSA', + keyBits: 2048, + curve: null, + keyId: null, + certIds: [], + }); + + it('zeigt den Hinweis und ein Passwortfeld; „Entsperren“ sendet das Passwort in der Analyse', async () => { + mockAnalyze + .mockResolvedValueOnce({ + items: [], + chains: [], + locked: [locked()], + ignored: [], + } satisfies AnalysisResult) + .mockResolvedValueOnce({ + items: [cert('c-srv', 'www.example.test', 'end-entity', [0], ['server.pfx'])], + chains: [], + locked: [], + ignored: [], + } satisfies AnalysisResult); + render(); + selectFiles([makeFile('server.pfx')]); + expect( + await screen.findByText('„server.pfx“ ist mit einem Passwort geschützt.'), + ).toBeInTheDocument(); + const field = screen.getByLabelText('Passwort für „server.pfx“') as HTMLInputElement; + expect(field.type).toBe('password'); + expect(field).toHaveAttribute('autocomplete', 'off'); + const unlock = screen.getByRole('button', { name: 'Entsperren' }); + expect(unlock).toBeDisabled(); + fireEvent.change(field, { target: { value: 'geheim' } }); + fireEvent.click(unlock); + await screen.findByText('www.example.test'); + const sent = mockAnalyze.mock.calls.at(-1)?.[0] as { password: string }[]; + expect(sent.map((e) => e.password)).toEqual(['geheim']); + expect(screen.queryByLabelText('Passwort für „server.pfx“')).not.toBeInTheDocument(); + }); + + it('Enter im Feld entsperrt ebenfalls', async () => { + mockAnalyze.mockResolvedValue({ + items: [], + chains: [], + locked: [locked()], + ignored: [], + } satisfies AnalysisResult); + render(); + selectFiles([makeFile('server.pfx')]); + const field = await screen.findByLabelText('Passwort für „server.pfx“'); + fireEvent.change(field, { target: { value: 'geheim' } }); + fireEvent.submit(field.closest('form') as HTMLFormElement); + await waitFor(() => { + const last = mockAnalyze.mock.calls.at(-1)?.[0] as { password: string }[]; + expect(last[0].password).toBe('geheim'); + }); + }); + + it('ein falsches Passwort wird als solches gemeldet, das Feld bleibt', async () => { + mockAnalyze.mockResolvedValueOnce({ + items: [], + chains: [], + locked: [locked()], + ignored: [], + } satisfies AnalysisResult); + mockAnalyze.mockResolvedValueOnce({ + items: [], + chains: [], + locked: [locked({ reason: 'passwordWrong' })], + ignored: [], + } satisfies AnalysisResult); + render(); + selectFiles([makeFile('server.pfx')]); + const field = await screen.findByLabelText('Passwort für „server.pfx“'); + fireEvent.change(field, { target: { value: 'falsch' } }); + fireEvent.click(screen.getByRole('button', { name: 'Entsperren' })); + expect(await screen.findByText('Das Passwort passt nicht.')).toBeInTheDocument(); + expect(screen.getByLabelText('Passwort für „server.pfx“')).toBeInTheDocument(); + }); + + it('der Knopf zeigt und verbirgt das Passwort', async () => { + mockAnalyze.mockResolvedValue({ + items: [], + chains: [], + locked: [locked()], + ignored: [], + } satisfies AnalysisResult); + render(); + selectFiles([makeFile('server.pfx')]); + const field = (await screen.findByLabelText('Passwort für „server.pfx“')) as HTMLInputElement; + fireEvent.click(screen.getByRole('button', { name: 'Passwort anzeigen' })); + expect(field.type).toBe('text'); + fireEvent.click(screen.getByRole('button', { name: 'Passwort verbergen' })); + expect(field.type).toBe('password'); + }); + + it('eine gesperrte PFX bei vorhandenem Zertifikat samt Schluessel: ruhiger Hinweis, Feld erst nach „Trotzdem entsperren“', async () => { + const withKey = { + ...cert('c-srv', 'www.example.test', 'end-entity', [0], ['leaf.pem']), + keyId: 'k-1', + }; + mockAnalyze.mockResolvedValue({ + items: [withKey, keyItem()], + chains: [], + locked: [locked({ file: 1, path: 'server.pfx' })], + ignored: [], + } satisfies AnalysisResult); + render(); + selectFiles([makeFile('leaf.pem'), makeFile('server.pfx')]); + expect(await screen.findByText(/Sie brauchen die Datei nicht/)).toBeInTheDocument(); + expect(screen.queryByLabelText('Passwort für „server.pfx“')).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Trotzdem entsperren' })); + expect(screen.getByLabelText('Passwort für „server.pfx“')).toBeInTheDocument(); + }); + + it('Schluessel und Anfragen stehen je Eintrag wie Zertifikate, ohne Passwort im Text', async () => { + mockAnalyze.mockResolvedValue({ + items: [keyItem(), csrItem()], + chains: [], + locked: [], + ignored: [], + } satisfies AnalysisResult); + render(); + selectFiles([makeFile('key.pem'), makeFile('req.csr')]); + expect(await screen.findByText('Privater Schlüssel')).toBeInTheDocument(); + expect(screen.getByText('EC, Kurve P-256')).toBeInTheDocument(); + expect(screen.getByText('war verschlüsselt')).toBeInTheDocument(); + expect(screen.getByText('Zertifikatsanfrage (CSR)')).toBeInTheDocument(); + expect(screen.getByText('www.example.test')).toBeInTheDocument(); + }); + + it('kein dargestellter Text enthaelt das eingegebene Passwort', async () => { + mockAnalyze.mockResolvedValue({ + items: [], + chains: [], + locked: [locked({ reason: 'passwordWrong' })], + ignored: [], + } satisfies AnalysisResult); + const { container } = render(); + selectFiles([makeFile('server.pfx')]); + const field = await screen.findByLabelText('Passwort für „server.pfx“'); + fireEvent.change(field, { target: { value: 'geheim-xyz' } }); + fireEvent.click(screen.getByRole('button', { name: 'Entsperren' })); + await waitFor(() => expect(mockAnalyze).toHaveBeenCalledTimes(2)); + expect(container.textContent).not.toContain('geheim-xyz'); + }); + }); }); diff --git a/apps/web/src/app/(portal)/modules/cert-manager/components/FilesTab.tsx b/apps/web/src/app/(portal)/modules/cert-manager/components/FilesTab.tsx index 3d9cfa1..86ddd55 100644 --- a/apps/web/src/app/(portal)/modules/cert-manager/components/FilesTab.tsx +++ b/apps/web/src/app/(portal)/modules/cert-manager/components/FilesTab.tsx @@ -2,7 +2,7 @@ import { useTranslations } from 'next-intl'; import { useRef, useState } from 'react'; -import type { AnyItem, IgnoredEntry } from '../actions'; +import type { AnyItem, IgnoredEntry, LockedEntry } from '../actions'; import type { CertWorkspace } from '../use-cert-workspace'; import { formatBytes, @@ -13,6 +13,7 @@ import { type RejectedFile, type WorkingEntry, } from '../working-set'; +import { PasswordInput } from './PasswordInput'; /** Farben der Rollenmarken (wie in der frueheren Uebersicht). */ export const ROLE_STYLES: Record = { @@ -48,8 +49,86 @@ function roleKey(item: AnyItem): string { return item.kind === 'certificate' ? item.role : item.kind; } -function itemName(item: AnyItem): string { - return item.kind === 'privateKey' ? item.baseName : item.cn || item.baseName; +function keyDescription( + item: Extract, + t: ReturnType, +): string { + if (item.keyType === 'RSA') return t('analyze.keyRsa', { bits: item.keyBits ?? 0 }); + if (item.keyType === 'EC') return t('analyze.keyEc', { curve: item.curve ?? '' }); + return item.keyType; +} + +function itemName(item: AnyItem, t: ReturnType): string { + return item.kind === 'privateKey' ? keyDescription(item, t) : item.cn || item.baseName; +} + +interface UnlockPanelProps { + entry: WorkingEntry; + locked: LockedEntry[]; + /** Die Datei wird nicht gebraucht (Zertifikat und Schluessel liegen schon vor): ruhiger Hinweis statt Feld */ + optional: boolean; + onUnlock: (password: string) => void; +} + +/** + * Gesperrte Datei: sagt, welche Teile ein Passwort brauchen, und nimmt es entgegen (Enter oder + * „Entsperren“). Ist die Datei entbehrlich, steht zuerst nur ein ruhiger Hinweis mit „Trotzdem entsperren“. + */ +function UnlockPanel({ entry, locked, optional, onUnlock }: UnlockPanelProps) { + const t = useTranslations('certManager'); + const [value, setValue] = useState(''); + const [forced, setForced] = useState(false); + const wrong = locked.some((l) => l.reason === 'passwordWrong'); + const calm = optional && !forced; + + return ( +
+
    + {locked.map((l) => ( +
  • + {t(calm ? 'files.locked.optional' : 'files.locked.message', { path: l.path })} +
  • + ))} +
+ {calm ? ( + + ) : ( +
{ + e.preventDefault(); + if (value !== '') onUnlock(value); + }} + > + +
+ + {wrong && entry.password !== '' && ( + + {t('files.locked.wrong')} + + )} +
+

{t('files.locked.note')}

+ + )} +
+ ); } /** Was in einer Datei (bei ZIP: unter einem Pfad im ZIP) erkannt oder uebersprungen wurde. */ @@ -113,6 +192,14 @@ export function FilesTab({ workspace }: FilesTabProps) { return [...groups.values()].sort((a, b) => a.path.localeCompare(b.path)); }; + const lockedOf = (entry: WorkingEntry): LockedEntry[] => + (analysis?.locked ?? []).filter((l) => analysisIds[l.file] === entry.id); + + // Liegt schon ein Serverzertifikat samt passendem Schluessel vor, ist eine gesperrte PFX entbehrlich. + const haveCertWithKey = (analysis?.items ?? []).some( + (i) => i.kind === 'certificate' && i.role === 'end-entity' && i.keyId !== null, + ); + return (

{t(`analyze.explain.${cert.role}`)}

diff --git a/apps/web/src/app/(portal)/modules/cert-manager/components/MergeTab.test.tsx b/apps/web/src/app/(portal)/modules/cert-manager/components/MergeTab.test.tsx index c4804e6..30d435d 100644 --- a/apps/web/src/app/(portal)/modules/cert-manager/components/MergeTab.test.tsx +++ b/apps/web/src/app/(portal)/modules/cert-manager/components/MergeTab.test.tsx @@ -78,6 +78,7 @@ function workspace(items: CertItem[], chains: ChainInfo[]): CertWorkspace { errorKey: null, addFiles: () => [], addText: () => null, + setPassword: () => {}, remove: () => {}, clear: () => {}, retry: () => {}, diff --git a/apps/web/src/app/(portal)/modules/cert-manager/components/PasswordInput.tsx b/apps/web/src/app/(portal)/modules/cert-manager/components/PasswordInput.tsx new file mode 100644 index 0000000..461982e --- /dev/null +++ b/apps/web/src/app/(portal)/modules/cert-manager/components/PasswordInput.tsx @@ -0,0 +1,44 @@ +'use client'; + +import { useTranslations } from 'next-intl'; +import { useId, useState } from 'react'; + +interface PasswordInputProps { + /** Sichtbare Beschriftung des Feldes */ + label: string; + value: string; + onChange: (value: string) => void; +} + +/** + * Passwortfeld mit Knopf zum Anzeigen und Verbergen (D-11). Das Passwort wird nie gespeichert: + * kein Autovervollstaendigen, kein Schreiben in den Speicher des Browsers. + */ +export function PasswordInput({ label, value, onChange }: PasswordInputProps) { + const t = useTranslations('certManager'); + const id = useId(); + const [visible, setVisible] = useState(false); + return ( +
+ + onChange(e.target.value)} + autoComplete="off" + spellCheck={false} + className="min-w-48 flex-1 rounded border border-border bg-background px-3 py-1.5 text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring" + /> + +
+ ); +} diff --git a/apps/web/src/app/(portal)/modules/cert-manager/components/SplitTab.test.tsx b/apps/web/src/app/(portal)/modules/cert-manager/components/SplitTab.test.tsx index 04bf704..ccd2f1a 100644 --- a/apps/web/src/app/(portal)/modules/cert-manager/components/SplitTab.test.tsx +++ b/apps/web/src/app/(portal)/modules/cert-manager/components/SplitTab.test.tsx @@ -71,6 +71,7 @@ function workspace(items: AnyItem[] | null): CertWorkspace { errorKey: null, addFiles: () => [], addText: () => null, + setPassword: () => {}, remove: () => {}, clear: () => {}, retry: () => {}, diff --git a/apps/web/src/app/(portal)/modules/cert-manager/use-cert-workspace.ts b/apps/web/src/app/(portal)/modules/cert-manager/use-cert-workspace.ts index c1d06bd..a9785be 100644 --- a/apps/web/src/app/(portal)/modules/cert-manager/use-cert-workspace.ts +++ b/apps/web/src/app/(portal)/modules/cert-manager/use-cert-workspace.ts @@ -7,6 +7,7 @@ import { addFiles as addToSet, type RejectedFile, removeEntry, + setEntryPassword, type WorkingEntry, } from './working-set'; @@ -25,6 +26,8 @@ export interface CertWorkspace { /** Eingefuegten PEM-Text als Eintrag anhaengen; `label` liefert die Beschriftung in der Sprache der Oberflaeche */ addText: (text: string, label?: (n: number) => string) => RejectedFile | null; remove: (id: string) => void; + /** Passwort fuer eine Datei setzen und alles neu pruefen; das Passwort lebt nur in diesem Zustand */ + setPassword: (id: string, password: string) => void; clear: () => void; retry: () => void; } @@ -110,6 +113,14 @@ export function useCertWorkspace(): CertWorkspace { [commit], ); + const setPassword = useCallback( + (id: string, password: string) => { + const next = setEntryPassword(entriesRef.current, id, password); + if (next !== entriesRef.current) commit(next); + }, + [commit], + ); + const clear = useCallback(() => { commit([]); }, [commit]); @@ -127,6 +138,7 @@ export function useCertWorkspace(): CertWorkspace { addFiles, addText, remove, + setPassword, clear, retry, }; diff --git a/apps/web/src/app/(portal)/modules/cert-manager/working-set.test.ts b/apps/web/src/app/(portal)/modules/cert-manager/working-set.test.ts index fa2d038..1b140f4 100644 --- a/apps/web/src/app/(portal)/modules/cert-manager/working-set.test.ts +++ b/apps/web/src/app/(portal)/modules/cert-manager/working-set.test.ts @@ -6,6 +6,7 @@ import { MAX_FILE_BYTES, MAX_PASTE_CHARS, removeEntry, + setEntryPassword, toFormData, type WorkingEntry, } from './working-set'; @@ -143,3 +144,30 @@ describe('toFormData', () => { expect(names).toEqual(['b.pem', 'a.pem']); }); }); + +describe('Passwoerter', () => { + it('setEntryPassword aendert nur den einen Eintrag, ohne Aenderung bleibt die Liste gleich', () => { + const { entries } = addFiles(NONE, [makeFile('a.pem'), makeFile('b.pfx')], nextId); + const next = setEntryPassword(entries, entries[1].id, 'geheim'); + expect(next.map((e) => e.password)).toEqual(['', 'geheim']); + expect(next[0]).toBe(entries[0]); + expect(setEntryPassword(next, entries[1].id, 'geheim')).toBe(next); + expect(setEntryPassword(next, 'gibt-es-nicht', 'x')).toBe(next); + }); + + it('toFormData sendet `passwords` nur, wenn ein Passwort gesetzt ist, im Takt der Dateien', () => { + const { entries } = addFiles(NONE, [makeFile('a.pem'), makeFile('b.pfx')], nextId); + expect(toFormData(entries).has('passwords')).toBe(false); + const withPassword = setEntryPassword(entries, entries[1].id, 'geheim'); + const form = toFormData(withPassword); + expect(JSON.parse(form.get('passwords') as string)).toEqual(['', 'geheim']); + expect(form.getAll('files')).toHaveLength(2); + }); + + it('das Passwort steht in keinem Dateinamen und keiner Beschriftung', () => { + const { entries } = addFiles(NONE, [makeFile('b.pfx')], nextId); + const [entry] = setEntryPassword(entries, entries[0].id, 'geheim'); + expect(entry.label).not.toContain('geheim'); + expect(entry.file.name).not.toContain('geheim'); + }); +}); diff --git a/apps/web/src/app/(portal)/modules/cert-manager/working-set.ts b/apps/web/src/app/(portal)/modules/cert-manager/working-set.ts index 0025427..abfe279 100644 --- a/apps/web/src/app/(portal)/modules/cert-manager/working-set.ts +++ b/apps/web/src/app/(portal)/modules/cert-manager/working-set.ts @@ -21,7 +21,7 @@ export interface WorkingEntry { origin: EntryOrigin; /** Nur fuer nachgeladene Zertifikate: der Server, von dem sie kamen */ host: string | null; - /** Passwort fuer diese Datei (ab Task 4); bleibt im Arbeitsspeicher */ + /** Passwort fuer diese Datei; bleibt im Arbeitsspeicher, wird nie gespeichert */ password: string; } @@ -127,10 +127,27 @@ export function removeEntry(entries: WorkingEntry[], id: string): WorkingEntry[] return entries.filter((e) => e.id !== id); } -/** Multipart-Koerper fuer die Analyse: `files` in der Reihenfolge der Eintraege. */ +/** Setzt das Passwort eines Eintrags; ohne Aenderung kommt dieselbe Liste zurueck. */ +export function setEntryPassword( + entries: WorkingEntry[], + id: string, + password: string, +): WorkingEntry[] { + if (!entries.some((e) => e.id === id && e.password !== password)) return entries; + return entries.map((e) => (e.id === id ? { ...e, password } : e)); +} + +/** + * Multipart-Koerper fuer die Analyse: `files` in der Reihenfolge der Eintraege und, nur wenn + * mindestens ein Passwort eingegeben wurde, `passwords` als JSON-Liste im Takt der Dateien. + * Passwoerter stehen nur in diesem Koerper, nie in einer Adresse. + */ export function toFormData(entries: WorkingEntry[]): FormData { const form = new FormData(); for (const entry of entries) form.append('files', entry.file, entry.file.name); + if (entries.some((e) => e.password !== '')) { + form.append('passwords', JSON.stringify(entries.map((e) => e.password))); + } return form; } diff --git a/apps/web/src/messages/de.json b/apps/web/src/messages/de.json index 71a5107..c18264f 100644 --- a/apps/web/src/messages/de.json +++ b/apps/web/src/messages/de.json @@ -1303,7 +1303,17 @@ "pasteLabel": "Eingefügter PEM-Text", "pasteAdd": "Hinzufügen", "pastedLabel": "Eingefügter Text {n}", - "originPaste": "Eingefügter Text" + "originPaste": "Eingefügter Text", + "locked": { + "message": "„{path}“ ist mit einem Passwort geschützt.", + "optional": "„{path}“ ist mit einem Passwort geschützt. Sie brauchen die Datei nicht, solange Zertifikat und Schlüssel schon vorliegen.", + "passwordLabel": "Passwort für „{name}“", + "unlock": "Entsperren", + "unlockAnyway": "Trotzdem entsperren", + "wrong": "Das Passwort passt nicht.", + "note": "Das Passwort bleibt in diesem Browserfenster und wird nur zum Öffnen der Datei übertragen." + }, + "keyWasEncrypted": "war verschlüsselt" }, "errors": { "generic": "Die Dateien konnten nicht geprüft werden. Bitte versuchen Sie es erneut.", @@ -1351,7 +1361,25 @@ "end-entity": "Das eigentliche Zertifikat für Ihre Domain. Das gehört auf den Webserver.", "intermediate": "Bestätigt Ihr Serverzertifikat gegenüber dem Browser. Wird zusammen mit dem Serverzertifikat eingespielt (Kette).", "root": "Oberste Zertifizierungsstelle. Ist in Browsern und Betriebssystemen meist schon vorhanden." - } + }, + "matchingKey": "Passender Schlüssel vorhanden", + "keysTitle": "Private Schlüssel", + "csrsTitle": "Zertifikatsanfragen", + "lockedTitle": "Geschützte Dateien", + "lockedNeeded": "braucht ein Passwort", + "lockedWrong": "Das Passwort passt nicht", + "lockedHint": "Geben Sie das Passwort im Reiter „Dateien“ ein.", + "wasEncrypted": "War verschlüsselt", + "belongsTo": "Gehört zu", + "noCertificateYet": "Dazu liegt noch kein Zertifikat vor.", + "subject": "Inhaber", + "matchedKey": "Passender Schlüssel", + "matchedCertificates": "Passendes Zertifikat", + "matchedCsr": "Passende Anfrage", + "present": "vorhanden", + "absent": "nicht vorhanden", + "explainKey": "Gehört zu Ihrem Serverzertifikat und bleibt geheim. Geben Sie ihn nie weiter.", + "explainCsr": "Die Anfrage, mit der ein Zertifikat bei der Zertifizierungsstelle bestellt wird." }, "split": { "intro": "Jedes erkannte Teil können Sie einzeln in seinem natürlichen Format herunterladen, oder alle zusammen als ZIP.", @@ -1379,6 +1407,10 @@ "gapAfterLeaf": "Zwischenzertifikat fehlt: „{name}“", "gapAfterLeafHint": "Ohne dieses Zertifikat vertrauen manche Geräte dem Server nicht. Fügen Sie es im Reiter „Dateien“ hinzu.", "gapAfterCa": "Darüber fehlt noch ein Zertifikat, meist das Stammzertifikat „{name}“. Für eine Fullchain ohne Stammzertifikat wird es nicht gebraucht." + }, + "passwordInput": { + "show": "Passwort anzeigen", + "hide": "Passwort verbergen" } }, "tenderRadar": { diff --git a/apps/web/src/messages/en.json b/apps/web/src/messages/en.json index 52536b8..fe884a6 100644 --- a/apps/web/src/messages/en.json +++ b/apps/web/src/messages/en.json @@ -1303,7 +1303,17 @@ "pasteLabel": "Pasted PEM text", "pasteAdd": "Add", "pastedLabel": "Pasted text {n}", - "originPaste": "Pasted text" + "originPaste": "Pasted text", + "locked": { + "message": "\"{path}\" is password protected.", + "optional": "\"{path}\" is password protected. You do not need the file as long as the certificate and the key are already there.", + "passwordLabel": "Password for \"{name}\"", + "unlock": "Unlock", + "unlockAnyway": "Unlock anyway", + "wrong": "The password does not match.", + "note": "The password stays in this browser window and is only sent to open the file." + }, + "keyWasEncrypted": "was encrypted" }, "errors": { "generic": "The files could not be checked. Please try again.", @@ -1351,7 +1361,25 @@ "end-entity": "The actual certificate for your domain. This one belongs on the web server.", "intermediate": "Vouches for your server certificate towards the browser. It is installed together with the server certificate (chain).", "root": "The top certificate authority. Browsers and operating systems usually have it already." - } + }, + "matchingKey": "Matching key present", + "keysTitle": "Private keys", + "csrsTitle": "Certificate requests", + "lockedTitle": "Protected files", + "lockedNeeded": "needs a password", + "lockedWrong": "The password does not match", + "lockedHint": "Enter the password in the \"Files\" tab.", + "wasEncrypted": "Was encrypted", + "belongsTo": "Belongs to", + "noCertificateYet": "There is no certificate for it yet.", + "subject": "Owner", + "matchedKey": "Matching key", + "matchedCertificates": "Matching certificate", + "matchedCsr": "Matching request", + "present": "present", + "absent": "not present", + "explainKey": "Belongs to your server certificate and stays secret. Never pass it on.", + "explainCsr": "The request used to order a certificate from the certification authority." }, "split": { "intro": "You can download every recognised part on its own in its natural format, or all of them together as a ZIP.", @@ -1379,6 +1407,10 @@ "gapAfterLeaf": "Intermediate certificate missing: “{name}”", "gapAfterLeafHint": "Without this certificate some devices will not trust the server. Add it in the “Files” tab.", "gapAfterCa": "A certificate above this one is missing, usually the root certificate “{name}”. A fullchain without the root does not need it." + }, + "passwordInput": { + "show": "Show password", + "hide": "Hide password" } }, "tenderRadar": { diff --git a/apps/web/src/messages/umlaut-dictionary.ts b/apps/web/src/messages/umlaut-dictionary.ts index 3119b97..dd93280 100644 --- a/apps/web/src/messages/umlaut-dictionary.ts +++ b/apps/web/src/messages/umlaut-dictionary.ts @@ -250,4 +250,8 @@ export const UMLAUT_ALLOWLIST: readonly string[] = [ 'passendes', 'Ausstellers', 'vertrauen', + // quick-261009-ikt Task 4: Schlüssel und Anfragen zuordnen + 'Passender', + 'Passendes', + 'Passende', ];