feat(cert-manager): Schlüssel, PFX und CSR erkennen, Passwort je Datei
- Private Schlüssel (PKCS#1, PKCS#8, SEC1; PEM und DER; unverschlüsselt, verschlüsselt, klassisch verschlüsselt) für RSA und EC über node:crypto - PKCS#12 lesen (OpenSSL 3, kompatibel, RC2; EC-Zertifikate und -Schlüssel), auch ohne Endung und im ZIP - Zertifikatsanfragen (CSR) als PEM und DER mit Inhaber, SAN und Schlüssel - Zuordnung von Schlüssel und Anfrage zum Zertifikat (checkPrivateKey, SPKI-Vergleich) - Feld passwords je Datei, gesperrte Dateien fragen nach dem Passwort; kein Passwort in Antwort oder Log - Oberfläche: Passwortfeld mit Anzeigen/Verbergen, Schlüssel- und Anfragekarten im Reiter Analysieren Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -3,7 +3,7 @@ import { join } from 'node:path';
|
|||||||
import AdmZip from 'adm-zip';
|
import AdmZip from 'adm-zip';
|
||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { analyzeWorkingSet, cleanSourcePath } from './cert-analyze';
|
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 fx = (name: string) => readFileSync(join(__dirname, '__fixtures__', name));
|
||||||
const file = (name: string, as = name) => ({ originalname: as, buffer: fx(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);
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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 { detectBlob } from './cert-model';
|
||||||
import { cleanSourcePath } from './cert-names';
|
import { cleanSourcePath } from './cert-names';
|
||||||
import type {
|
import type {
|
||||||
AnalysisResult,
|
AnalysisResult,
|
||||||
AnyItem,
|
AnyItem,
|
||||||
CertItem,
|
CertItem,
|
||||||
|
CsrItem,
|
||||||
IgnoredEntry,
|
IgnoredEntry,
|
||||||
ItemSource,
|
ItemSource,
|
||||||
|
KeyItem,
|
||||||
LockedEntry,
|
LockedEntry,
|
||||||
} from './cert-types';
|
} from './cert-types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fassade der Analyse (quick-261009-ikt, D-15): alle hochgeladenen Dateien erkennen,
|
* Fassade der Analyse (quick-261009-ikt, D-15): alle hochgeladenen Dateien erkennen,
|
||||||
* gleiche Teile zusammenfassen und ordnen. Zustandslos; nichts wird gespeichert.
|
* 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 };
|
export { cleanSourcePath };
|
||||||
@@ -46,10 +50,17 @@ export function analyzeWorkingSet(files: AnalyzeFile[], passwords: string[] = []
|
|||||||
const byId = new Map<string, AnyItem>();
|
const byId = new Map<string, AnyItem>();
|
||||||
const ignored: IgnoredEntry[] = [];
|
const ignored: IgnoredEntry[] = [];
|
||||||
const locked: LockedEntry[] = [];
|
const locked: LockedEntry[] = [];
|
||||||
|
const lockedSeen = new Set<string>();
|
||||||
|
|
||||||
files.forEach((f, index) => {
|
files.forEach((f, index) => {
|
||||||
const path = cleanSourcePath(f.originalname);
|
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) {
|
for (const item of result.items) {
|
||||||
const known = byId.get(item.id);
|
const known = byId.get(item.id);
|
||||||
if (!known) {
|
if (!known) {
|
||||||
@@ -61,10 +72,20 @@ export function analyzeWorkingSet(files: AnalyzeFile[], passwords: string[] = []
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
ignored.push(...result.ignored);
|
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 items = orderItems([...byId.values()]);
|
||||||
const certs = items.filter((i): i is CertItem => i.kind === 'certificate');
|
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 };
|
return { items, chains: buildChains(certs).chains, locked, ignored };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { readFileSync } from 'node:fs';
|
import { readFileSync } from 'node:fs';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { buildChains } from './cert-chain';
|
import { buildChains, matchKeys } from './cert-chain';
|
||||||
import { detectBlob } from './cert-model';
|
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));
|
const fx = (name: string) => readFileSync(join(__dirname, '__fixtures__', name));
|
||||||
|
|
||||||
@@ -150,3 +150,75 @@ describe('buildChains', () => {
|
|||||||
expect(buildChains([])).toEqual({ chains: [] });
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { X509Certificate } from 'node:crypto';
|
import { createPrivateKey, createPublicKey, X509Certificate } from 'node:crypto';
|
||||||
import type { CertItem, ChainGap, ChainInfo } from './cert-types';
|
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.
|
* 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();
|
const now = Date.now();
|
||||||
return { chains: heads.map((h) => chainOf(nodes, h, 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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) };
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<string, { name: string; bits: number }> = {
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@ import { join } from 'node:path';
|
|||||||
import { RequestMethod } from '@nestjs/common';
|
import { RequestMethod } from '@nestjs/common';
|
||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { MODULE_SLUG_KEY } from '../module-registry/module.guard';
|
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 fx = (name: string) => readFileSync(join(__dirname, '__fixtures__', name));
|
||||||
const upload = (name: string, buffer: Buffer = fx(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', () => {
|
describe('repairFileName', () => {
|
||||||
it('gewinnt UTF-8-Namen zurueck, die als Latin-1 ankamen', () => {
|
it('gewinnt UTF-8-Namen zurueck, die als Latin-1 ankamen', () => {
|
||||||
const asLatin1 = Buffer.from('Zertifikat-Müller.pem', 'utf8').toString('latin1');
|
const asLatin1 = Buffer.from('Zertifikat-Müller.pem', 'utf8').toString('latin1');
|
||||||
|
|||||||
@@ -12,6 +12,35 @@ export const CERT_MAX_FILES = 30;
|
|||||||
export const CERT_MAX_FILE_BYTES = 5 * 1024 * 1024;
|
export const CERT_MAX_FILE_BYTES = 5 * 1024 * 1024;
|
||||||
export const CERT_MAX_TOTAL_BYTES = 20 * 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;
|
* 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.
|
* 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').
|
* Geschuetzt durch den globalen JwtAuthGuard, den TenantGuard und @UseModule('cert-manager').
|
||||||
*
|
*
|
||||||
* Routen (alle POST, 200):
|
* 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
|
* - 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
|
* - fetch-issuer Task 7 fehlendes Zwischenzertifikat nur auf Knopfdruck holen
|
||||||
*/
|
*/
|
||||||
@@ -41,7 +70,10 @@ export class CertManagerController {
|
|||||||
@UseInterceptors(
|
@UseInterceptors(
|
||||||
FilesInterceptor('files', CERT_MAX_FILES, { limits: { fileSize: CERT_MAX_FILE_BYTES } }),
|
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) {
|
if (!files || files.length === 0) {
|
||||||
certError('invalidInput', 400, 'No files provided');
|
certError('invalidInput', 400, 'No files provided');
|
||||||
}
|
}
|
||||||
@@ -49,8 +81,10 @@ export class CertManagerController {
|
|||||||
if (total > CERT_MAX_TOTAL_BYTES) {
|
if (total > CERT_MAX_TOTAL_BYTES) {
|
||||||
certError('tooLarge', 413, 'Files together exceed 20 MiB');
|
certError('tooLarge', 413, 'Files together exceed 20 MiB');
|
||||||
}
|
}
|
||||||
|
const passwords = parsePasswords(rawPasswords);
|
||||||
return analyzeWorkingSet(
|
return analyzeWorkingSet(
|
||||||
files.map((f) => ({ originalname: repairFileName(f.originalname), buffer: f.buffer })),
|
files.map((f) => ({ originalname: repairFileName(f.originalname), buffer: f.buffer })),
|
||||||
|
passwords,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ describe('detectBlob: Zertifikate', () => {
|
|||||||
expect(c.sources).toEqual([{ file: 0, path: 'rsa-leaf.pem' }]);
|
expect(c.sources).toEqual([{ file: 0, path: 'rsa-leaf.pem' }]);
|
||||||
expect(c.pem.startsWith('-----BEGIN CERTIFICATE-----')).toBe(true);
|
expect(c.pem.startsWith('-----BEGIN CERTIFICATE-----')).toBe(true);
|
||||||
expect(c.baseName).toBe('www.example.test');
|
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.isExpired).toBe(false);
|
||||||
expect(c.daysLeft).toBeGreaterThan(30000);
|
expect(c.daysLeft).toBeGreaterThan(30000);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,16 @@
|
|||||||
import { createHash, type KeyObject, X509Certificate } from 'node:crypto';
|
import { X509Certificate } from 'node:crypto';
|
||||||
import * as forge from 'node-forge';
|
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 { safeBaseName } from './cert-names';
|
||||||
|
import { isPkcs12Der, readPkcs12 } from './cert-pkcs12';
|
||||||
import type {
|
import type {
|
||||||
AnyItem,
|
AnyItem,
|
||||||
CertItem,
|
CertItem,
|
||||||
@@ -11,6 +21,9 @@ import type {
|
|||||||
} from './cert-types';
|
} from './cert-types';
|
||||||
import { expandZip, isZip } from './zip-expand';
|
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).
|
* 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
|
* 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.
|
* 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
|
* 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) und ZIP (eine Ebene, Grenzen in zip-expand.ts).
|
* (auch fuer EC, ueber den ASN.1-Lauf von forge), ZIP (eine Ebene, Grenzen in zip-expand.ts),
|
||||||
* Schluessel, PKCS#12 und CSR (Task 4) sind benannte, noch leere Stufen.
|
* private Schluessel (cert-keys.ts), PKCS#12 (cert-pkcs12.ts) und Zertifikatsanfragen (cert-csr.ts).
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export interface DetectContext {
|
export interface DetectContext {
|
||||||
@@ -30,8 +43,10 @@ export interface DetectContext {
|
|||||||
file: number;
|
file: number;
|
||||||
/** Dateiname, bei ZIP-Inhalt "zip/eintrag" */
|
/** Dateiname, bei ZIP-Inhalt "zip/eintrag" */
|
||||||
path: string;
|
path: string;
|
||||||
/** Passwoerter fuer geschuetzte Container (ab Task 4) */
|
/** Kandidaten fuer geschuetzte Container, das Passwort der Datei zuerst (hoechstens 10) */
|
||||||
passwords: string[];
|
passwords: string[];
|
||||||
|
/** Das fuer genau diese Datei eingegebene Passwort; leer, wenn keines. Nur fuer „gesperrt: Passwort falsch“. */
|
||||||
|
ownPassword?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DetectResult {
|
export interface DetectResult {
|
||||||
@@ -42,50 +57,10 @@ export interface DetectResult {
|
|||||||
|
|
||||||
const CERT_LABELS = new Set(['CERTIFICATE', 'X509 CERTIFICATE', 'TRUSTED CERTIFICATE']);
|
const CERT_LABELS = new Set(['CERTIFICATE', 'X509 CERTIFICATE', 'TRUSTED CERTIFICATE']);
|
||||||
const PKCS7_LABELS = new Set(['PKCS7', 'CMS']);
|
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 PEM_BLOCK = /-----BEGIN ([A-Z0-9 ]+)-----([\s\S]*?)-----END \1-----/g;
|
||||||
const BASE64_BODY = /^[A-Za-z0-9+/]+={0,2}$/;
|
const BASE64_BODY = /^[A-Za-z0-9+/]+={0,2}$/;
|
||||||
|
|
||||||
const CURVE_NAMES: Record<string, { name: string; bits: number }> = {
|
|
||||||
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 {
|
function firstValue(v: unknown): string {
|
||||||
if (Array.isArray(v)) return typeof v[0] === 'string' ? v[0] : '';
|
if (Array.isArray(v)) return typeof v[0] === 'string' ? v[0] : '';
|
||||||
return typeof v === 'string' ? v : '';
|
return typeof v === 'string' ? v : '';
|
||||||
@@ -181,7 +156,7 @@ export function certItemFromDer(der: Buffer, source: ItemSource): CertItem {
|
|||||||
isCa,
|
isCa,
|
||||||
selfSigned,
|
selfSigned,
|
||||||
aiaIssuerUrls: aiaUrls(legacy.infoAccess),
|
aiaIssuerUrls: aiaUrls(legacy.infoAccess),
|
||||||
keyId: keyIdOf(x.publicKey),
|
keyId: null,
|
||||||
csrIds: [],
|
csrIds: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -285,6 +260,8 @@ function pkcs7Items(der: Buffer, ctx: DetectContext): CertItem[] {
|
|||||||
|
|
||||||
interface PemBlock {
|
interface PemBlock {
|
||||||
label: string;
|
label: string;
|
||||||
|
/** der Block samt BEGIN/END-Zeilen, unveraendert (fuer verschluesselte Schluessel) */
|
||||||
|
text: string;
|
||||||
der: Buffer | null;
|
der: Buffer | null;
|
||||||
encrypted: boolean;
|
encrypted: boolean;
|
||||||
}
|
}
|
||||||
@@ -301,7 +278,7 @@ function pemBlocks(text: string): PemBlock[] {
|
|||||||
.join('')
|
.join('')
|
||||||
.replace(/\s+/g, '');
|
.replace(/\s+/g, '');
|
||||||
const valid = BASE64_BODY.test(body) && body.length % 4 === 0;
|
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;
|
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. */
|
/** Text mit -----BEGIN-Bloecken: jeder Block nach seinem Etikett. */
|
||||||
function detectPem(blob: Buffer, ctx: DetectContext): DetectResult | null {
|
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;
|
if (!text.includes('-----BEGIN ')) return null;
|
||||||
const result = emptyResult();
|
const result = emptyResult();
|
||||||
for (const block of pemBlocks(text)) {
|
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 (!block.der) continue;
|
||||||
if (CERT_LABELS.has(block.label)) {
|
if (CERT_LABELS.has(block.label)) {
|
||||||
const item = certFromDer(block.der, ctx);
|
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
|
// kaputter PKCS#7-Block: die anderen Bloecke der Datei bleiben gueltig
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// PRIVATE KEY, RSA/EC PRIVATE KEY, ENCRYPTED PRIVATE KEY und CERTIFICATE REQUEST
|
if (CSR_LABELS.has(block.label)) {
|
||||||
// (Task 4) folgen in dieser Schleife.
|
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). */
|
/** Einzelnes DER-Zertifikat (.cer/.crt/.der). */
|
||||||
@@ -347,9 +350,38 @@ function detectDerCertificate(blob: Buffer, ctx: DetectContext): DetectResult |
|
|||||||
return { items: [item], ignored: [], locked: [] };
|
return { items: [item], ignored: [], locked: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** PKCS#12 (Task 4). */
|
function keyContext(ctx: DetectContext) {
|
||||||
function detectPkcs12(_blob: Buffer, _ctx: DetectContext): DetectResult | null {
|
return {
|
||||||
return null;
|
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. */
|
/** 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;
|
return items.length > 0 ? { items, ignored: [], locked: [] } : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Privater Schluessel als DER (Task 4). */
|
/** Privater Schluessel als DER (PKCS#8, PKCS#1, SEC1, verschluesseltes PKCS#8). */
|
||||||
function detectPrivateKey(_blob: Buffer, _ctx: DetectContext): DetectResult | null {
|
function detectPrivateKey(blob: Buffer, ctx: DetectContext): DetectResult | null {
|
||||||
return null;
|
return keyFromDer(blob, keyContext(ctx));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Zertifikatsanfrage als DER (Task 4). */
|
/** Zertifikatsanfrage als DER. */
|
||||||
function detectCsr(_blob: Buffer, _ctx: DetectContext): DetectResult | null {
|
function detectCsr(blob: Buffer, ctx: DetectContext): DetectResult | null {
|
||||||
return null;
|
return {
|
||||||
|
items: [csrItemFromDer(blob, { file: ctx.file, path: ctx.path })],
|
||||||
|
ignored: [],
|
||||||
|
locked: [],
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const STAGES: ((blob: Buffer, ctx: DetectContext) => DetectResult | null)[] = [
|
const STAGES: ((blob: Buffer, ctx: DetectContext) => DetectResult | null)[] = [
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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' };
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@ import { NextIntlClientProvider } from 'next-intl';
|
|||||||
import type { ReactElement } from 'react';
|
import type { ReactElement } from 'react';
|
||||||
import { afterEach, describe, expect, it } from 'vitest';
|
import { afterEach, describe, expect, it } from 'vitest';
|
||||||
import de from '@/messages/de.json';
|
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 { CertWorkspace } from '../use-cert-workspace';
|
||||||
import type { WorkingEntry } from '../working-set';
|
import type { WorkingEntry } from '../working-set';
|
||||||
import { AnalyzeTab } from './AnalyzeTab';
|
import { AnalyzeTab } from './AnalyzeTab';
|
||||||
@@ -69,6 +69,7 @@ function workspace(
|
|||||||
errorKey: null,
|
errorKey: null,
|
||||||
addFiles: () => [],
|
addFiles: () => [],
|
||||||
addText: () => null,
|
addText: () => null,
|
||||||
|
setPassword: () => {},
|
||||||
remove: () => {},
|
remove: () => {},
|
||||||
clear: () => {},
|
clear: () => {},
|
||||||
retry: () => {},
|
retry: () => {},
|
||||||
@@ -229,3 +230,134 @@ describe('AnalyzeTab', () => {
|
|||||||
expect(container.innerHTML).not.toContain('uppercase');
|
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(<AnalyzeTab workspace={workspace(analysis, entries, ['e1', 'e2'])} />);
|
||||||
|
const [card] = screen.getAllByTestId('cert-card');
|
||||||
|
expect(within(card).getByText('Passender Schlüssel vorhanden')).toBeInTheDocument();
|
||||||
|
cleanup();
|
||||||
|
render(
|
||||||
|
<AnalyzeTab
|
||||||
|
workspace={workspace(
|
||||||
|
{
|
||||||
|
items: [cert({ id: 'c-n', cn: 'ohne.example.test', role: 'end-entity' })],
|
||||||
|
chains: [],
|
||||||
|
locked: [],
|
||||||
|
ignored: [],
|
||||||
|
},
|
||||||
|
entries,
|
||||||
|
['e1'],
|
||||||
|
)}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.queryByText('Passender Schlüssel vorhanden')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Schluesselkarte: Art, Groesse, „War verschlüsselt“, zu welchem Zertifikat er gehoert', () => {
|
||||||
|
render(<AnalyzeTab workspace={workspace(analysis, entries, ['e1', 'e2'])} />);
|
||||||
|
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(<AnalyzeTab workspace={workspace(lonely, entries, ['e1'])} />);
|
||||||
|
expect(screen.getByText('Dazu liegt noch kein Zertifikat vor.')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Anfragekarte: Inhaber, Namen, Schluessel, Zuordnung', () => {
|
||||||
|
render(<AnalyzeTab workspace={workspace(analysis, entries, ['e1', 'e2'])} />);
|
||||||
|
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(<AnalyzeTab workspace={workspace(analysis, entries, ['e1', 'e2'])} />);
|
||||||
|
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(
|
||||||
|
<AnalyzeTab
|
||||||
|
workspace={workspace({ items: [], chains: [], locked: analysis.locked, ignored: [] })}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
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(
|
||||||
|
<AnalyzeTab workspace={workspace(analysis, entries, ['e1', 'e2'])} />,
|
||||||
|
);
|
||||||
|
expect(container.textContent).not.toContain('PEM-KEY');
|
||||||
|
expect(container.textContent).not.toMatch(/[→←·]/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useTranslations } from 'next-intl';
|
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 type { CertWorkspace } from '../use-cert-workspace';
|
||||||
import { ChainView } from './ChainView';
|
import { ChainView } from './ChainView';
|
||||||
import { ItemCard } from './ItemCard';
|
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 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 <p className="text-sm text-muted-foreground">{t('analyze.nothingFound')}</p>;
|
return <p className="text-sm text-muted-foreground">{t('analyze.nothingFound')}</p>;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,12 +58,69 @@ export function AnalyzeTab({ workspace }: AnalyzeTabProps) {
|
|||||||
</h2>
|
</h2>
|
||||||
<ul className="space-y-4">
|
<ul className="space-y-4">
|
||||||
{certs.map((cert) => (
|
{certs.map((cert) => (
|
||||||
<ItemCard key={cert.id} cert={cert} entries={entries} analysisIds={analysisIds} />
|
<ItemCard
|
||||||
|
key={cert.id}
|
||||||
|
item={cert}
|
||||||
|
items={analysis.items}
|
||||||
|
entries={entries}
|
||||||
|
analysisIds={analysisIds}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{keys.length > 0 && (
|
||||||
|
<section className="space-y-3" aria-label={t('analyze.keysTitle')}>
|
||||||
|
<h2 className="text-sm font-semibold text-foreground">{t('analyze.keysTitle')}</h2>
|
||||||
|
<ul className="space-y-4">
|
||||||
|
{keys.map((key) => (
|
||||||
|
<ItemCard
|
||||||
|
key={key.id}
|
||||||
|
item={key}
|
||||||
|
items={analysis.items}
|
||||||
|
entries={entries}
|
||||||
|
analysisIds={analysisIds}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{csrs.length > 0 && (
|
||||||
|
<section className="space-y-3" aria-label={t('analyze.csrsTitle')}>
|
||||||
|
<h2 className="text-sm font-semibold text-foreground">{t('analyze.csrsTitle')}</h2>
|
||||||
|
<ul className="space-y-4">
|
||||||
|
{csrs.map((csr) => (
|
||||||
|
<ItemCard
|
||||||
|
key={csr.id}
|
||||||
|
item={csr}
|
||||||
|
items={analysis.items}
|
||||||
|
entries={entries}
|
||||||
|
analysisIds={analysisIds}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{analysis.locked.length > 0 && (
|
||||||
|
<section className="space-y-2" aria-label={t('analyze.lockedTitle')}>
|
||||||
|
<h2 className="text-sm font-semibold text-foreground">{t('analyze.lockedTitle')}</h2>
|
||||||
|
<ul className="space-y-1 text-sm text-muted-foreground">
|
||||||
|
{analysis.locked.map((l) => (
|
||||||
|
<li key={`${l.file}-${l.path}-${l.container}`}>
|
||||||
|
<span className="break-all text-foreground">{l.path}</span>{' '}
|
||||||
|
{l.reason === 'passwordWrong'
|
||||||
|
? t('analyze.lockedWrong')
|
||||||
|
: t('analyze.lockedNeeded')}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<p className="text-xs text-muted-foreground">{t('analyze.lockedHint')}</p>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
{analysis.ignored.length > 0 && (
|
{analysis.ignored.length > 0 && (
|
||||||
<section className="space-y-2" aria-label={t('analyze.ignoredTitle')}>
|
<section className="space-y-2" aria-label={t('analyze.ignoredTitle')}>
|
||||||
<h2 className="text-sm font-semibold text-foreground">{t('analyze.ignoredTitle')}</h2>
|
<h2 className="text-sm font-semibold text-foreground">{t('analyze.ignoredTitle')}</h2>
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { NextIntlClientProvider } from 'next-intl';
|
|||||||
import type { ReactElement } from 'react';
|
import type { ReactElement } from 'react';
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import de from '@/messages/de.json';
|
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 { CertManagerRequestError } from '../actions';
|
||||||
import { useCertWorkspace } from '../use-cert-workspace';
|
import { useCertWorkspace } from '../use-cert-workspace';
|
||||||
import { FilesTab } from './FilesTab';
|
import { FilesTab } from './FilesTab';
|
||||||
@@ -290,4 +290,180 @@ describe('FilesTab', () => {
|
|||||||
expect(screen.getByRole('alert')).toHaveTextContent('länger als');
|
expect(screen.getByRole('alert')).toHaveTextContent('länger als');
|
||||||
expect(mockAnalyze).not.toHaveBeenCalled();
|
expect(mockAnalyze).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('geschuetzte Dateien', () => {
|
||||||
|
const locked = (over: Partial<AnalysisResult['locked'][number]> = {}) => ({
|
||||||
|
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(<Harness />);
|
||||||
|
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(<Harness />);
|
||||||
|
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(<Harness />);
|
||||||
|
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(<Harness />);
|
||||||
|
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(<Harness />);
|
||||||
|
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(<Harness />);
|
||||||
|
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(<Harness />);
|
||||||
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
import { useRef, useState } from 'react';
|
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 type { CertWorkspace } from '../use-cert-workspace';
|
||||||
import {
|
import {
|
||||||
formatBytes,
|
formatBytes,
|
||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
type RejectedFile,
|
type RejectedFile,
|
||||||
type WorkingEntry,
|
type WorkingEntry,
|
||||||
} from '../working-set';
|
} from '../working-set';
|
||||||
|
import { PasswordInput } from './PasswordInput';
|
||||||
|
|
||||||
/** Farben der Rollenmarken (wie in der frueheren Uebersicht). */
|
/** Farben der Rollenmarken (wie in der frueheren Uebersicht). */
|
||||||
export const ROLE_STYLES: Record<string, string> = {
|
export const ROLE_STYLES: Record<string, string> = {
|
||||||
@@ -48,8 +49,86 @@ function roleKey(item: AnyItem): string {
|
|||||||
return item.kind === 'certificate' ? item.role : item.kind;
|
return item.kind === 'certificate' ? item.role : item.kind;
|
||||||
}
|
}
|
||||||
|
|
||||||
function itemName(item: AnyItem): string {
|
function keyDescription(
|
||||||
return item.kind === 'privateKey' ? item.baseName : item.cn || item.baseName;
|
item: Extract<AnyItem, { kind: 'privateKey' }>,
|
||||||
|
t: ReturnType<typeof useTranslations>,
|
||||||
|
): 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<typeof useTranslations>): 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 (
|
||||||
|
<div
|
||||||
|
className={`mt-2 rounded-lg p-3 text-sm ${calm ? 'bg-muted text-muted-foreground' : 'border border-border bg-muted'}`}
|
||||||
|
data-testid="cert-locked"
|
||||||
|
>
|
||||||
|
<ul className="space-y-0.5">
|
||||||
|
{locked.map((l) => (
|
||||||
|
<li key={`${l.path}-${l.container}`} className={calm ? '' : 'text-foreground'}>
|
||||||
|
{t(calm ? 'files.locked.optional' : 'files.locked.message', { path: l.path })}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
{calm ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setForced(true)}
|
||||||
|
className="mt-2 rounded border border-border px-3 py-1 text-foreground hover:bg-card focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
>
|
||||||
|
{t('files.locked.unlockAnyway')}
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<form
|
||||||
|
className="mt-2 space-y-2"
|
||||||
|
onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (value !== '') onUnlock(value);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<PasswordInput
|
||||||
|
label={t('files.locked.passwordLabel', { name: entry.label })}
|
||||||
|
value={value}
|
||||||
|
onChange={setValue}
|
||||||
|
/>
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<button type="submit" disabled={value === ''} className="btn btn-secondary">
|
||||||
|
{t('files.locked.unlock')}
|
||||||
|
</button>
|
||||||
|
{wrong && entry.password !== '' && (
|
||||||
|
<span className="text-sm text-status-down-fg" aria-live="polite">
|
||||||
|
{t('files.locked.wrong')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">{t('files.locked.note')}</p>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Was in einer Datei (bei ZIP: unter einem Pfad im ZIP) erkannt oder uebersprungen wurde. */
|
/** 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));
|
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 (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<button
|
<button
|
||||||
@@ -251,6 +338,10 @@ export function FilesTab({ workspace }: FilesTabProps) {
|
|||||||
<ul className="space-y-2">
|
<ul className="space-y-2">
|
||||||
{entries.map((entry) => {
|
{entries.map((entry) => {
|
||||||
const groups = groupsOf(entry);
|
const groups = groupsOf(entry);
|
||||||
|
const lockedHere = lockedOf(entry);
|
||||||
|
const optional =
|
||||||
|
haveCertWithKey &&
|
||||||
|
lockedHere.every((l) => l.container === 'pkcs12' && l.reason === 'passwordNeeded');
|
||||||
const analysed = analysisIds.includes(entry.id);
|
const analysed = analysisIds.includes(entry.id);
|
||||||
return (
|
return (
|
||||||
<li key={entry.id} className="rounded-lg border border-border p-3">
|
<li key={entry.id} className="rounded-lg border border-border p-3">
|
||||||
@@ -285,7 +376,12 @@ export function FilesTab({ workspace }: FilesTabProps) {
|
|||||||
>
|
>
|
||||||
{t(`roles.${roleKey(item)}`)}
|
{t(`roles.${roleKey(item)}`)}
|
||||||
</span>
|
</span>
|
||||||
<span className="break-all text-foreground">{itemName(item)}</span>
|
<span className="break-all text-foreground">{itemName(item, t)}</span>
|
||||||
|
{item.kind === 'privateKey' && item.wasEncrypted && (
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{t('files.keyWasEncrypted')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
{item.kind === 'certificate' && item.isExpired && (
|
{item.kind === 'certificate' && item.isExpired && (
|
||||||
<span className="rounded bg-red-100 px-2 py-0.5 text-xs font-medium text-red-800 dark:bg-red-900/40 dark:text-red-300">
|
<span className="rounded bg-red-100 px-2 py-0.5 text-xs font-medium text-red-800 dark:bg-red-900/40 dark:text-red-300">
|
||||||
{t('files.expired')}
|
{t('files.expired')}
|
||||||
@@ -308,6 +404,14 @@ export function FilesTab({ workspace }: FilesTabProps) {
|
|||||||
<li className="text-sm text-muted-foreground">{t('files.checking')}</li>
|
<li className="text-sm text-muted-foreground">{t('files.checking')}</li>
|
||||||
)}
|
)}
|
||||||
</ul>
|
</ul>
|
||||||
|
{lockedHere.length > 0 && (
|
||||||
|
<UnlockPanel
|
||||||
|
entry={entry}
|
||||||
|
locked={lockedHere}
|
||||||
|
optional={optional}
|
||||||
|
onUnlock={(password) => workspace.setPassword(entry.id, password)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { useFormatter, useTranslations } from 'next-intl';
|
import { useFormatter, useTranslations } from 'next-intl';
|
||||||
import type { CertItem, ItemSource } from '../actions';
|
import type { AnyItem, CertItem, CsrItem, ItemSource, KeyItem } from '../actions';
|
||||||
import type { WorkingEntry } from '../working-set';
|
import type { WorkingEntry } from '../working-set';
|
||||||
import { ROLE_STYLES } from './FilesTab';
|
import { ROLE_STYLES } from './FilesTab';
|
||||||
|
|
||||||
interface ItemCardProps {
|
interface ItemCardProps {
|
||||||
cert: CertItem;
|
item: AnyItem;
|
||||||
|
/** Alle erkannten Teile: Schluessel und Anfragen nennen damit ihre Zertifikate */
|
||||||
|
items: AnyItem[];
|
||||||
/** Eintraege des Arbeitsbereichs und ihre Kennungen zum Analysezeitpunkt, fuer die Namen der Quellen */
|
/** Eintraege des Arbeitsbereichs und ihre Kennungen zum Analysezeitpunkt, fuer die Namen der Quellen */
|
||||||
entries: WorkingEntry[];
|
entries: WorkingEntry[];
|
||||||
analysisIds: string[];
|
analysisIds: string[];
|
||||||
@@ -19,12 +21,129 @@ function sourceLabel(source: ItemSource, entries: WorkingEntry[], analysisIds: s
|
|||||||
return source.path;
|
return source.path;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type Translate = ReturnType<typeof useTranslations>;
|
||||||
|
|
||||||
|
function keyDescription(
|
||||||
|
item: { keyType: string; keyBits: number | null; curve: string | null },
|
||||||
|
t: Translate,
|
||||||
|
): 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Eine Karte je erkanntem Teil im Reiter „Analysieren“: Zertifikat, privater Schluessel oder Anfrage. */
|
||||||
|
export function ItemCard({ item, items, entries, analysisIds }: ItemCardProps) {
|
||||||
|
if (item.kind === 'certificate') {
|
||||||
|
return <CertCard cert={item} entries={entries} analysisIds={analysisIds} />;
|
||||||
|
}
|
||||||
|
if (item.kind === 'privateKey') {
|
||||||
|
return <KeyCard item={item} items={items} entries={entries} analysisIds={analysisIds} />;
|
||||||
|
}
|
||||||
|
return <CsrCard item={item} items={items} entries={entries} analysisIds={analysisIds} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function certNames(ids: string[], items: AnyItem[]): string[] {
|
||||||
|
return ids.flatMap((id) => {
|
||||||
|
const found = items.find((i): i is CertItem => i.kind === 'certificate' && i.id === id);
|
||||||
|
return found ? [found.cn || found.baseName] : [];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PartCardProps<T> {
|
||||||
|
item: T;
|
||||||
|
items: AnyItem[];
|
||||||
|
entries: WorkingEntry[];
|
||||||
|
analysisIds: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function KeyCard({ item, items, entries, analysisIds }: PartCardProps<KeyItem>) {
|
||||||
|
const t = useTranslations('certManager');
|
||||||
|
const owners = certNames(item.certIds, items);
|
||||||
|
const sources = item.sources.map((s) => sourceLabel(s, entries, analysisIds));
|
||||||
|
return (
|
||||||
|
<li className="rounded-lg border border-border p-4" data-testid="key-card">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<span className={`rounded px-2 py-0.5 text-xs font-semibold ${ROLE_STYLES.privateKey}`}>
|
||||||
|
{t('roles.privateKey')}
|
||||||
|
</span>
|
||||||
|
<span className="break-all font-medium text-foreground">{keyDescription(item, t)}</span>
|
||||||
|
{item.wasEncrypted && (
|
||||||
|
<span className="rounded bg-muted px-2 py-0.5 text-xs font-medium text-muted-foreground">
|
||||||
|
{t('analyze.wasEncrypted')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">{t('analyze.explainKey')}</p>
|
||||||
|
<dl className="mt-3 grid grid-cols-[max-content_1fr] gap-x-4 gap-y-1 text-sm">
|
||||||
|
<dt className="text-muted-foreground">{t('analyze.belongsTo')}</dt>
|
||||||
|
<dd className="break-all text-foreground">
|
||||||
|
{owners.length > 0 ? owners.join(', ') : t('analyze.noCertificateYet')}
|
||||||
|
</dd>
|
||||||
|
<dt className="text-muted-foreground">{t('analyze.source')}</dt>
|
||||||
|
<dd className="break-all text-foreground">{sources.join(', ')}</dd>
|
||||||
|
</dl>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CsrCard({ item, items, entries, analysisIds }: PartCardProps<CsrItem>) {
|
||||||
|
const t = useTranslations('certManager');
|
||||||
|
const owners = certNames(item.certIds, items);
|
||||||
|
const sources = item.sources.map((s) => sourceLabel(s, entries, analysisIds));
|
||||||
|
return (
|
||||||
|
<li className="rounded-lg border border-border p-4" data-testid="csr-card">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<span className={`rounded px-2 py-0.5 text-xs font-semibold ${ROLE_STYLES.csr}`}>
|
||||||
|
{t('roles.csr')}
|
||||||
|
</span>
|
||||||
|
<span className="break-all font-medium text-foreground">{item.cn || item.baseName}</span>
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">{t('analyze.explainCsr')}</p>
|
||||||
|
<dl className="mt-3 grid grid-cols-[max-content_1fr] gap-x-4 gap-y-1 text-sm">
|
||||||
|
{item.organization && (
|
||||||
|
<>
|
||||||
|
<dt className="text-muted-foreground">{t('analyze.subject')}</dt>
|
||||||
|
<dd className="break-all text-foreground">{item.organization}</dd>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{item.san.length > 0 && (
|
||||||
|
<>
|
||||||
|
<dt className="text-muted-foreground">{t('analyze.names')}</dt>
|
||||||
|
<dd className="break-all text-foreground">{item.san.join(', ')}</dd>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<dt className="text-muted-foreground">{t('analyze.key')}</dt>
|
||||||
|
<dd className="text-foreground">{keyDescription(item, t)}</dd>
|
||||||
|
<dt className="text-muted-foreground">{t('analyze.matchedKey')}</dt>
|
||||||
|
<dd className="text-foreground">
|
||||||
|
{item.keyId ? t('analyze.present') : t('analyze.absent')}
|
||||||
|
</dd>
|
||||||
|
<dt className="text-muted-foreground">{t('analyze.matchedCertificates')}</dt>
|
||||||
|
<dd className="break-all text-foreground">
|
||||||
|
{owners.length > 0 ? owners.join(', ') : t('analyze.absent')}
|
||||||
|
</dd>
|
||||||
|
<dt className="text-muted-foreground">{t('analyze.source')}</dt>
|
||||||
|
<dd className="break-all text-foreground">{sources.join(', ')}</dd>
|
||||||
|
</dl>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Eine Karte je Zertifikat im Reiter „Analysieren“ (D-23): Rolle, Name, Gueltigkeit, Namen,
|
* Karte eines Zertifikats (D-23): Rolle, Name, Gueltigkeit, Namen, Schluessel, Seriennummer,
|
||||||
* Schluessel, Seriennummer, Fingerabdruecke und woher das Zertifikat kam.
|
* Fingerabdruecke und woher das Zertifikat kam; mit Hinweis, wenn der passende Schluessel vorliegt.
|
||||||
* Daten kommen in UTC, wie im Zertifikat; der Aussteller nennt dasselbe Datum.
|
* Daten kommen in UTC, wie im Zertifikat; der Aussteller nennt dasselbe Datum.
|
||||||
*/
|
*/
|
||||||
export function ItemCard({ cert, entries, analysisIds }: ItemCardProps) {
|
function CertCard({
|
||||||
|
cert,
|
||||||
|
entries,
|
||||||
|
analysisIds,
|
||||||
|
}: {
|
||||||
|
cert: CertItem;
|
||||||
|
entries: WorkingEntry[];
|
||||||
|
analysisIds: string[];
|
||||||
|
}) {
|
||||||
const t = useTranslations('certManager');
|
const t = useTranslations('certManager');
|
||||||
const format = useFormatter();
|
const format = useFormatter();
|
||||||
const date = (iso: string) =>
|
const date = (iso: string) =>
|
||||||
@@ -39,12 +158,7 @@ export function ItemCard({ cert, entries, analysisIds }: ItemCardProps) {
|
|||||||
}
|
}
|
||||||
: { cls: 'bg-status-ok/15 text-status-ok-fg', text: t('analyze.valid') };
|
: { cls: 'bg-status-ok/15 text-status-ok-fg', text: t('analyze.valid') };
|
||||||
|
|
||||||
const keyText =
|
const keyText = keyDescription(cert, t);
|
||||||
cert.keyType === 'RSA'
|
|
||||||
? t('analyze.keyRsa', { bits: cert.keyBits ?? 0 })
|
|
||||||
: cert.keyType === 'EC'
|
|
||||||
? t('analyze.keyEc', { curve: cert.curve ?? '' })
|
|
||||||
: cert.keyType;
|
|
||||||
|
|
||||||
const sources = cert.sources.map((s) => sourceLabel(s, entries, analysisIds));
|
const sources = cert.sources.map((s) => sourceLabel(s, entries, analysisIds));
|
||||||
|
|
||||||
@@ -58,6 +172,11 @@ export function ItemCard({ cert, entries, analysisIds }: ItemCardProps) {
|
|||||||
<span className={`rounded px-2 py-0.5 text-xs font-medium ${status.cls}`}>
|
<span className={`rounded px-2 py-0.5 text-xs font-medium ${status.cls}`}>
|
||||||
{status.text}
|
{status.text}
|
||||||
</span>
|
</span>
|
||||||
|
{cert.keyId && (
|
||||||
|
<span className="rounded bg-status-ok/15 px-2 py-0.5 text-xs font-medium text-status-ok-fg">
|
||||||
|
{t('analyze.matchingKey')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-1 text-xs text-muted-foreground">{t(`analyze.explain.${cert.role}`)}</p>
|
<p className="mt-1 text-xs text-muted-foreground">{t(`analyze.explain.${cert.role}`)}</p>
|
||||||
|
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ function workspace(items: CertItem[], chains: ChainInfo[]): CertWorkspace {
|
|||||||
errorKey: null,
|
errorKey: null,
|
||||||
addFiles: () => [],
|
addFiles: () => [],
|
||||||
addText: () => null,
|
addText: () => null,
|
||||||
|
setPassword: () => {},
|
||||||
remove: () => {},
|
remove: () => {},
|
||||||
clear: () => {},
|
clear: () => {},
|
||||||
retry: () => {},
|
retry: () => {},
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<label htmlFor={id} className="text-sm text-foreground">
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id={id}
|
||||||
|
type={visible ? 'text' : 'password'}
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => 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"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setVisible((v) => !v)}
|
||||||
|
className="rounded border border-border px-2 py-1 text-xs text-foreground hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
>
|
||||||
|
{visible ? t('passwordInput.hide') : t('passwordInput.show')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -71,6 +71,7 @@ function workspace(items: AnyItem[] | null): CertWorkspace {
|
|||||||
errorKey: null,
|
errorKey: null,
|
||||||
addFiles: () => [],
|
addFiles: () => [],
|
||||||
addText: () => null,
|
addText: () => null,
|
||||||
|
setPassword: () => {},
|
||||||
remove: () => {},
|
remove: () => {},
|
||||||
clear: () => {},
|
clear: () => {},
|
||||||
retry: () => {},
|
retry: () => {},
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
addFiles as addToSet,
|
addFiles as addToSet,
|
||||||
type RejectedFile,
|
type RejectedFile,
|
||||||
removeEntry,
|
removeEntry,
|
||||||
|
setEntryPassword,
|
||||||
type WorkingEntry,
|
type WorkingEntry,
|
||||||
} from './working-set';
|
} 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 */
|
/** Eingefuegten PEM-Text als Eintrag anhaengen; `label` liefert die Beschriftung in der Sprache der Oberflaeche */
|
||||||
addText: (text: string, label?: (n: number) => string) => RejectedFile | null;
|
addText: (text: string, label?: (n: number) => string) => RejectedFile | null;
|
||||||
remove: (id: string) => void;
|
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;
|
clear: () => void;
|
||||||
retry: () => void;
|
retry: () => void;
|
||||||
}
|
}
|
||||||
@@ -110,6 +113,14 @@ export function useCertWorkspace(): CertWorkspace {
|
|||||||
[commit],
|
[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(() => {
|
const clear = useCallback(() => {
|
||||||
commit([]);
|
commit([]);
|
||||||
}, [commit]);
|
}, [commit]);
|
||||||
@@ -127,6 +138,7 @@ export function useCertWorkspace(): CertWorkspace {
|
|||||||
addFiles,
|
addFiles,
|
||||||
addText,
|
addText,
|
||||||
remove,
|
remove,
|
||||||
|
setPassword,
|
||||||
clear,
|
clear,
|
||||||
retry,
|
retry,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
MAX_FILE_BYTES,
|
MAX_FILE_BYTES,
|
||||||
MAX_PASTE_CHARS,
|
MAX_PASTE_CHARS,
|
||||||
removeEntry,
|
removeEntry,
|
||||||
|
setEntryPassword,
|
||||||
toFormData,
|
toFormData,
|
||||||
type WorkingEntry,
|
type WorkingEntry,
|
||||||
} from './working-set';
|
} from './working-set';
|
||||||
@@ -143,3 +144,30 @@ describe('toFormData', () => {
|
|||||||
expect(names).toEqual(['b.pem', 'a.pem']);
|
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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ export interface WorkingEntry {
|
|||||||
origin: EntryOrigin;
|
origin: EntryOrigin;
|
||||||
/** Nur fuer nachgeladene Zertifikate: der Server, von dem sie kamen */
|
/** Nur fuer nachgeladene Zertifikate: der Server, von dem sie kamen */
|
||||||
host: string | null;
|
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;
|
password: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,10 +127,27 @@ export function removeEntry(entries: WorkingEntry[], id: string): WorkingEntry[]
|
|||||||
return entries.filter((e) => e.id !== id);
|
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 {
|
export function toFormData(entries: WorkingEntry[]): FormData {
|
||||||
const form = new FormData();
|
const form = new FormData();
|
||||||
for (const entry of entries) form.append('files', entry.file, entry.file.name);
|
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;
|
return form;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1303,7 +1303,17 @@
|
|||||||
"pasteLabel": "Eingefügter PEM-Text",
|
"pasteLabel": "Eingefügter PEM-Text",
|
||||||
"pasteAdd": "Hinzufügen",
|
"pasteAdd": "Hinzufügen",
|
||||||
"pastedLabel": "Eingefügter Text {n}",
|
"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": {
|
"errors": {
|
||||||
"generic": "Die Dateien konnten nicht geprüft werden. Bitte versuchen Sie es erneut.",
|
"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.",
|
"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).",
|
"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."
|
"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": {
|
"split": {
|
||||||
"intro": "Jedes erkannte Teil können Sie einzeln in seinem natürlichen Format herunterladen, oder alle zusammen als ZIP.",
|
"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}“",
|
"gapAfterLeaf": "Zwischenzertifikat fehlt: „{name}“",
|
||||||
"gapAfterLeafHint": "Ohne dieses Zertifikat vertrauen manche Geräte dem Server nicht. Fügen Sie es im Reiter „Dateien“ hinzu.",
|
"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."
|
"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": {
|
"tenderRadar": {
|
||||||
|
|||||||
@@ -1303,7 +1303,17 @@
|
|||||||
"pasteLabel": "Pasted PEM text",
|
"pasteLabel": "Pasted PEM text",
|
||||||
"pasteAdd": "Add",
|
"pasteAdd": "Add",
|
||||||
"pastedLabel": "Pasted text {n}",
|
"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": {
|
"errors": {
|
||||||
"generic": "The files could not be checked. Please try again.",
|
"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.",
|
"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).",
|
"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."
|
"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": {
|
"split": {
|
||||||
"intro": "You can download every recognised part on its own in its natural format, or all of them together as a ZIP.",
|
"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}”",
|
"gapAfterLeaf": "Intermediate certificate missing: “{name}”",
|
||||||
"gapAfterLeafHint": "Without this certificate some devices will not trust the server. Add it in the “Files” tab.",
|
"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."
|
"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": {
|
"tenderRadar": {
|
||||||
|
|||||||
@@ -250,4 +250,8 @@ export const UMLAUT_ALLOWLIST: readonly string[] = [
|
|||||||
'passendes',
|
'passendes',
|
||||||
'Ausstellers',
|
'Ausstellers',
|
||||||
'vertrauen',
|
'vertrauen',
|
||||||
|
// quick-261009-ikt Task 4: Schlüssel und Anfragen zuordnen
|
||||||
|
'Passender',
|
||||||
|
'Passendes',
|
||||||
|
'Passende',
|
||||||
];
|
];
|
||||||
|
|||||||
Reference in New Issue
Block a user