feat(cert-manager): Zertifikatspaket hochladen, erkennen und in jedem Format herunterladen

Neuer erster Reiter Übersicht: mehrere Dateien oder die ZIP vom Aussteller
auf einmal ablegen. Der Server erkennt jedes Teil (Server-, Zwischen-,
Stammzertifikat, privater Schlüssel, CSR, auch aus PFX/P7B), fasst
Duplikate zusammen, ordnet Schlüssel/CSR dem Zertifikat zu und baut die
Kette. Unter jedem Teil stehen Download-Knöpfe für alle passenden Formate
(crt, cer, Fullchain, p7b, pfx mit Schlüssel und Kette; key PKCS#8/PKCS#1/
DER; csr PEM/DER). Geschützte PFX lassen sich mit Passwort entsperren.

Neue Endpunkte POST analyze (Dateien/ZIP, Begrenzung Anzahl und Größe vor
dem Entpacken) und POST export (JSON, zustandslos). Modultexte siezen jetzt
durchgehend; Gültigkeit in UTC wie im Zertifikat.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-10-01 15:23:34 +02:00
parent 645c5e5887
commit c07b0cfaf0
11 changed files with 1811 additions and 52 deletions
@@ -0,0 +1,268 @@
import AdmZip from 'adm-zip';
import * as forge from 'node-forge';
import { beforeAll, describe, expect, it } from 'vitest';
import { analyzeBundle, exportBundleItem, safeBaseName } from './cert-bundle';
/**
* cert-bundle.spec (quick-261001-l4q) — Zertifikatspaket wie vom Aussteller:
* Stamm -> Zwischen -> Server, dazu Schluessel, CSR und PFX, als ZIP.
* Alles hier erzeugt (keine echten Kundendaten im Repo).
*/
interface Pki {
rootPem: string;
interPem: string;
leafPem: string;
keyPem: string;
csrPem: string;
pfx: Buffer;
leafModulus: string;
}
let pki: Pki;
function makeCert(
subjectCn: string,
pub: forge.pki.PublicKey,
signer: forge.pki.PrivateKey,
issuer: forge.pki.CertificateField[] | null,
ca: boolean,
serial: string,
): forge.pki.Certificate {
const cert = forge.pki.createCertificate();
cert.publicKey = pub;
cert.serialNumber = serial;
cert.validity.notBefore = new Date(Date.now() - 86_400_000);
cert.validity.notAfter = new Date(Date.now() + 90 * 86_400_000);
const subject = [{ name: 'commonName', value: subjectCn }];
cert.setSubject(subject);
cert.setIssuer(issuer ?? subject);
const ext: object[] = [{ name: 'basicConstraints', cA: ca }];
if (!ca) ext.push({ name: 'subjectAltName', altNames: [{ type: 2, value: subjectCn }] });
cert.setExtensions(ext);
cert.sign(signer as forge.pki.rsa.PrivateKey, forge.md.sha256.create());
return cert;
}
beforeAll(() => {
const rootKeys = forge.pki.rsa.generateKeyPair(1024);
const interKeys = forge.pki.rsa.generateKeyPair(1024);
const leafKeys = forge.pki.rsa.generateKeyPair(1024);
const root = makeCert('Test Root CA', rootKeys.publicKey, rootKeys.privateKey, null, true, '01');
const inter = makeCert(
'Test Intermediate CA',
interKeys.publicKey,
rootKeys.privateKey,
root.subject.attributes,
true,
'02',
);
const leaf = makeCert(
'www.example.test',
leafKeys.publicKey,
interKeys.privateKey,
inter.subject.attributes,
false,
'03',
);
const csr = forge.pki.createCertificationRequest();
csr.publicKey = leafKeys.publicKey;
csr.setSubject([{ name: 'commonName', value: 'www.example.test' }]);
csr.sign(leafKeys.privateKey, forge.md.sha256.create());
const p12 = forge.pkcs12.toPkcs12Asn1(leafKeys.privateKey, [leaf, inter], 'geheim', {
algorithm: '3des',
});
pki = {
rootPem: forge.pki.certificateToPem(root),
interPem: forge.pki.certificateToPem(inter),
leafPem: forge.pki.certificateToPem(leaf),
keyPem: forge.pki.privateKeyInfoToPem(
forge.pki.wrapRsaPrivateKey(forge.pki.privateKeyToAsn1(leafKeys.privateKey)),
),
csrPem: forge.pki.certificationRequestToPem(csr),
pfx: Buffer.from(forge.asn1.toDer(p12).getBytes(), 'binary'),
leafModulus: leafKeys.publicKey.n.toString(16),
};
}, 60_000);
function issuerZip(): Buffer {
const zip = new AdmZip();
zip.addFile('www.example.test/www.example.test.pem', Buffer.from(pki.leafPem + pki.interPem));
zip.addFile('www.example.test/www.example.test.key', Buffer.from(pki.keyPem));
zip.addFile('www.example.test/www.example.test.csr', Buffer.from(pki.csrPem));
zip.addFile('www.example.test/www.example.test.pfx', pki.pfx);
zip.addFile('www.example.test/.dnstxtrecord', Buffer.from('_dnsauth abc123'));
return zip.toBuffer();
}
describe('analyzeBundle', () => {
it('ZIP vom Aussteller: erkennt jedes Teil, fasst PEM/PFX zusammen, ordnet Schluessel und Kette zu', () => {
const r = analyzeBundle([{ originalname: 'paket.zip', buffer: issuerZip() }], 'geheim');
const kinds = r.items.map((i) => (i.kind === 'certificate' ? i.role : i.kind));
expect(kinds).toEqual(['end-entity', 'intermediate', 'privateKey', 'csr']);
const [leaf, inter, key, csr] = r.items;
expect(leaf.cn).toBe('www.example.test');
expect(leaf.san).toEqual(['www.example.test']);
// in PEM UND PFX enthalten -> ein Eintrag mit beiden Quellen
expect(leaf.sources.sort()).toEqual(['www.example.test.pem', 'www.example.test.pfx']);
expect(leaf.chainIds).toEqual([inter.id]);
expect(leaf.matchId).toBe(key.id);
expect(key.matchId).toBe(leaf.id);
expect(key.sources.sort()).toEqual(['www.example.test.key', 'www.example.test.pfx']);
expect(csr.matchId).toBe(leaf.id);
expect(csr.cn).toBe('www.example.test');
expect(leaf.baseName).toBe('www.example.test');
expect(r.locked).toEqual([]);
expect(r.ignored).toEqual(['.dnstxtrecord']);
});
it('PFX ohne passendes Passwort wird als gesperrt gemeldet, der Rest trotzdem erkannt', () => {
const r = analyzeBundle([{ originalname: 'paket.zip', buffer: issuerZip() }], 'falsch');
expect(r.locked).toEqual(['www.example.test.pfx']);
expect(r.items.filter((i) => i.kind === 'certificate')).toHaveLength(2);
});
it('Stammzertifikat wird als root erkannt und an die Kette gehaengt', () => {
const r = analyzeBundle(
[
{
originalname: 'chain.pem',
buffer: Buffer.from(pki.leafPem + pki.interPem + pki.rootPem),
},
],
'',
);
expect(r.items.map((i) => i.role)).toEqual(['end-entity', 'intermediate', 'root']);
expect(r.items[0].chainIds).toHaveLength(2);
});
it('ohne Dateien -> 400', () => {
expect(() => analyzeBundle([], '')).toThrow(/No files/);
});
it('ZIP mit zu vielen Dateien -> 400', () => {
const zip = new AdmZip();
for (let i = 0; i < 101; i++) zip.addFile(`f${i}.txt`, Buffer.from('x'));
expect(() =>
analyzeBundle([{ originalname: 'gross.zip', buffer: zip.toBuffer() }], ''),
).toThrow(/too many files/);
});
});
describe('exportBundleItem', () => {
function bundle() {
const r = analyzeBundle([{ originalname: 'paket.zip', buffer: issuerZip() }], 'geheim');
const byId = Object.fromEntries(r.items.map((i) => [i.id, i]));
return { items: r.items, byId };
}
const decode = (b64: string) => Buffer.from(b64, 'base64');
it('Zertifikat in jedem Format liest sich wieder ein', () => {
const { items, byId } = bundle();
const leaf = items[0];
const chain = leaf.chainIds.map((id) => byId[id].pem);
const keyPem = byId[leaf.matchId!].pem;
const base = { kind: leaf.kind, pem: leaf.pem, baseName: leaf.baseName, chain, keyPem };
const crt = exportBundleItem({ ...base, format: 'crt' });
expect(crt.filename).toBe('www.example.test.crt');
expect(
forge.pki.certificateFromPem(decode(crt.content).toString()).subject.getField('CN').value,
).toBe('www.example.test');
const cer = exportBundleItem({ ...base, format: 'cer' });
expect(cer.filename).toBe('www.example.test.cer');
forge.pki.certificateFromAsn1(forge.asn1.fromDer(decode(cer.content).toString('binary')));
const full = exportBundleItem({ ...base, format: 'fullchain' });
expect(
decode(full.content)
.toString()
.match(/BEGIN CERTIFICATE/g),
).toHaveLength(2);
const p7b = exportBundleItem({ ...base, format: 'p7b' });
const p7 = forge.pkcs7.messageFromPem(decode(p7b.content).toString());
expect('certificates' in p7 ? p7.certificates : []).toHaveLength(2);
const pfx = exportBundleItem({ ...base, format: 'pfx', password: 'neu' });
expect(pfx.filename).toBe('www.example.test.pfx');
const p12 = forge.pkcs12.pkcs12FromAsn1(
forge.asn1.fromDer(decode(pfx.content).toString('binary')),
'neu',
);
expect(p12.getBags({ bagType: forge.pki.oids.certBag })[forge.pki.oids.certBag]).toHaveLength(
2,
);
const keyBag = p12.getBags({ bagType: forge.pki.oids.pkcs8ShroudedKeyBag })[
forge.pki.oids.pkcs8ShroudedKeyBag
]![0];
expect((keyBag.key as forge.pki.rsa.PrivateKey).n.toString(16)).toBe(pki.leafModulus);
});
it('PFX ohne Passwort -> 400', () => {
const { items } = bundle();
expect(() =>
exportBundleItem({ kind: 'certificate', pem: items[0].pem, format: 'pfx' }),
).toThrow(/password is required/);
});
it('Schluessel als PKCS#8, PKCS#1 und DER', () => {
const { items } = bundle();
const key = items.find((i) => i.kind === 'privateKey')!;
const base = { kind: key.kind, pem: key.pem, baseName: key.baseName };
expect(decode(exportBundleItem({ ...base, format: 'key' }).content).toString()).toContain(
'BEGIN PRIVATE KEY',
);
const rsa = exportBundleItem({ ...base, format: 'key-rsa' });
expect(rsa.filename).toBe('www.example.test.rsa.key');
expect(decode(rsa.content).toString()).toContain('BEGIN RSA PRIVATE KEY');
const der = exportBundleItem({ ...base, format: 'key-der' });
const info = forge.asn1.fromDer(decode(der.content).toString('binary'));
expect((forge.pki.privateKeyFromAsn1(info) as forge.pki.rsa.PrivateKey).n.toString(16)).toBe(
pki.leafModulus,
);
});
it('CSR als PEM und DER', () => {
const { items } = bundle();
const csr = items.find((i) => i.kind === 'csr')!;
const der = exportBundleItem({
kind: 'csr',
pem: csr.pem,
baseName: csr.baseName,
format: 'csr-der',
});
expect(der.filename).toBe('www.example.test.csr.der');
forge.pki.certificationRequestFromAsn1(
forge.asn1.fromDer(decode(der.content).toString('binary')),
);
});
it('unpassendes Format -> 400', () => {
const { items } = bundle();
expect(() =>
exportBundleItem({ kind: 'csr', pem: items[3].pem, format: 'pfx', password: 'x' }),
).toThrow();
expect(() =>
exportBundleItem({ kind: 'privateKey', pem: 'kein pem', format: 'key-rsa' }),
).toThrow(/Failed to export/);
});
});
describe('safeBaseName', () => {
it('Platzhalter, Leerzeichen und Pfadteile werden entschaerft', () => {
expect(safeBaseName('*.example.de', 'x')).toBe('wildcard.example.de');
expect(safeBaseName('Encryption Everywhere DV TLS CA - G1', 'x')).toBe(
'Encryption_Everywhere_DV_TLS_CA_-_G1',
);
expect(safeBaseName('../../etc/passwd', 'x')).toBe('etc_passwd');
expect(safeBaseName('', 'fallback')).toBe('fallback');
});
});
+655
View File
@@ -0,0 +1,655 @@
import { BadRequestException } from '@nestjs/common';
import AdmZip from 'adm-zip';
import * as forge from 'node-forge';
import type { UploadedFileLike } from '../auth/types/auth-user';
/**
* Zertifikatspaket (quick-261001-l4q): alles, was ein Aussteller liefert —
* Zertifikat mit Kette (.pem/.crt/.cer/.p7b), privater Schluessel (.key),
* Zertifikatsanfrage (.csr), PFX/P12, gern als ZIP — auf einmal hochladen,
* erkennen, was was ist, und jedes Teil in jedem passenden Format
* herunterladen.
*
* Zustandslos wie der Rest des Moduls: `analyzeBundle` gibt je Teil den
* kanonischen PEM-Text zurueck, `exportBundleItem` baut daraus die Datei.
* Nichts wird gespeichert, Passwoerter werden nie protokolliert.
*/
type BundleFile = Pick<UploadedFileLike, 'buffer' | 'originalname'>;
export type BundleCertRole = 'end-entity' | 'intermediate' | 'root';
export type BundleItemKind = 'certificate' | 'privateKey' | 'csr';
export interface BundleItem {
id: string;
kind: BundleItemKind;
/** Nur bei Zertifikaten. */
role?: BundleCertRole;
/** Dateien, in denen dieses Teil gefunden wurde (Duplikate zusammengefasst). */
sources: string[];
/** Kanonischer PEM-Text — Grundlage fuer jeden Export. */
pem: string;
/** Vorschlag fuer den Dateinamen ohne Endung, aus dem CN abgeleitet. */
baseName: string;
cn: string;
organization: string;
issuerCn: string;
notBefore: string | null;
notAfter: string | null;
isExpired: boolean | null;
daysLeft: number | null;
san: string[];
keyType: string;
keyBits: number;
serialNumber: string;
sha256: string;
/** Zertifikat: id des passenden Schluessels; Schluessel/CSR: id des passenden Zertifikats. */
matchId: string | null;
/** Zertifikat: ids der Kette darueber (Aussteller, dessen Aussteller ...). */
chainIds: string[];
/** Formate, die `exportBundleItem` fuer dieses Teil liefern kann. */
formats: BundleExportFormat[];
}
export interface BundleAnalysis {
items: BundleItem[];
/** PFX/P12 oder verschluesselte Schluessel, die ohne (richtiges) Passwort nicht lesbar sind. */
locked: string[];
/** Dateien ohne erkennbares Zertifikat, Schluessel oder CSR. */
ignored: string[];
}
export type BundleExportFormat =
| 'crt'
| 'cer'
| 'fullchain'
| 'p7b'
| 'pfx'
| 'key'
| 'key-rsa'
| 'key-der'
| 'csr'
| 'csr-der';
export interface BundleExportInput {
kind: BundleItemKind;
pem: string;
format: BundleExportFormat;
baseName?: string;
/** Zertifikat: PEMs der Kette darueber (fuer Fullchain/P7B/PFX). */
chain?: string[];
/** Zertifikat: PEM des passenden privaten Schluessels (fuer PFX). */
keyPem?: string;
/** PFX: Passwort fuer die neue Datei. */
password?: string;
}
export interface BundleExportFile {
filename: string;
/** Base64 */
content: string;
mimeType: string;
}
const MAX_ZIP_ENTRIES = 100;
const MAX_ENTRY_BYTES = 5 * 1024 * 1024;
const PEM_BLOCK = /-----BEGIN ([A-Z0-9 ]+)-----[\s\S]+?-----END \1-----/g;
// ---------------------------------------------------------------------------
// Hilfen
// ---------------------------------------------------------------------------
function binary(buffer: Buffer): forge.util.ByteStringBuffer {
return forge.util.createBuffer(buffer.toString('binary'));
}
function bytesToBase64(bytes: string): string {
return Buffer.from(forge.util.bytesToHex(bytes), 'hex').toString('base64');
}
function textToBase64(text: string): string {
return Buffer.from(text, 'utf-8').toString('base64');
}
function sha256Of(cert: forge.pki.Certificate): string {
const md = forge.md.sha256.create();
md.update(forge.asn1.toDer(forge.pki.certificateToAsn1(cert)).getBytes());
return (md.digest().toHex().match(/.{2}/g) ?? []).join(':').toUpperCase();
}
function field(name: forge.pki.Certificate['subject'], short: string): string {
return (name.getField(short)?.value as string | undefined) ?? '';
}
/** Dateiname ohne Pfad und ohne gefaehrliche Zeichen, z. B. „*.example.de“ -> „wildcard.example.de“. */
export function safeBaseName(raw: string, fallback: string): string {
const cleaned = raw
.replace(/^\*\./, 'wildcard.')
.replace(/[^A-Za-z0-9._-]+/g, '_')
.replace(/^[._]+/, '')
.slice(0, 80);
return cleaned || fallback;
}
function certRole(cert: forge.pki.Certificate): BundleCertRole {
const bc = cert.getExtension('basicConstraints') as { cA?: boolean } | null;
if (!bc?.cA) return 'end-entity';
return cert.subject.hash === cert.issuer.hash ? 'root' : 'intermediate';
}
function publicKeyInfo(key: unknown): { keyType: string; keyBits: number; modulus: string } {
// node-forge liefert RSA-Schluessel mit `n`; EC-Schluessel kennt es nur
// eingeschraenkt (siehe Kommentar in CertManagerService.parseCert).
const k = key as { n?: forge.jsbn.BigInteger };
if (k?.n) return { keyType: 'RSA', keyBits: k.n.bitLength(), modulus: k.n.toString(16) };
return { keyType: 'EC', keyBits: 0, modulus: '' };
}
function sanOf(extensions: unknown[] | undefined): string[] {
const ext = (extensions ?? []).find((e) => (e as { name?: string }).name === 'subjectAltName') as
| { altNames?: { type: number; value?: string; ip?: string }[] }
| undefined;
return (ext?.altNames ?? []).map((n) =>
n.type === 2 ? (n.value ?? '') : `IP:${n.ip ?? n.value ?? ''}`,
);
}
// ---------------------------------------------------------------------------
// Einsammeln: Dateien (inkl. ZIP) -> rohe Teile
// ---------------------------------------------------------------------------
interface RawKey {
source: string;
pem: string;
modulus: string;
keyType: string;
keyBits: number;
}
interface RawCsr {
source: string;
pem: string;
}
interface Collected {
certs: { source: string; cert: forge.pki.Certificate }[];
keys: RawKey[];
csrs: RawCsr[];
locked: string[];
ignored: string[];
}
function expandZips(files: BundleFile[]): { name: string; buffer: Buffer }[] {
const out: { name: string; buffer: Buffer }[] = [];
for (const file of files) {
if (!file.originalname.toLowerCase().endsWith('.zip')) {
out.push({ name: file.originalname, buffer: file.buffer });
continue;
}
let zip: AdmZip;
try {
zip = new AdmZip(file.buffer);
} catch {
throw new BadRequestException(`"${file.originalname}" is not a readable ZIP archive`);
}
const entries = zip
.getEntries()
.filter((e) => !e.isDirectory && !e.entryName.startsWith('__MACOSX/'));
if (entries.length > MAX_ZIP_ENTRIES) {
throw new BadRequestException(`"${file.originalname}" contains too many files`);
}
for (const entry of entries) {
// Groesse aus dem Kopf pruefen, BEVOR entpackt wird (Zip-Bombe).
if (entry.header.size > MAX_ENTRY_BYTES) {
throw new BadRequestException(
`"${entry.entryName}" in "${file.originalname}" is too large`,
);
}
const name = entry.entryName.split('/').pop() ?? entry.entryName;
out.push({ name, buffer: entry.getData() });
}
}
return out;
}
function addKey(c: Collected, source: string, privateKey: forge.pki.rsa.PrivateKey): void {
const info = publicKeyInfo(privateKey);
const pem = forge.pki.privateKeyInfoToPem(
forge.pki.wrapRsaPrivateKey(forge.pki.privateKeyToAsn1(privateKey)),
);
c.keys.push({ source, pem, ...info });
}
function collectPemText(c: Collected, source: string, text: string, password: string): boolean {
let found = false;
for (const match of text.matchAll(PEM_BLOCK)) {
const [block, type] = match;
try {
if (type === 'CERTIFICATE' || type === 'TRUSTED CERTIFICATE') {
c.certs.push({ source, cert: forge.pki.certificateFromPem(block) });
found = true;
} else if (type === 'PRIVATE KEY' || type === 'RSA PRIVATE KEY') {
const key = forge.pki.privateKeyFromPem(block) as forge.pki.rsa.PrivateKey;
addKey(c, source, key);
found = true;
} else if (type === 'ENCRYPTED PRIVATE KEY') {
const key = password ? forge.pki.decryptRsaPrivateKey(block, password) : null;
if (key) addKey(c, source, key as forge.pki.rsa.PrivateKey);
else c.locked.push(source);
found = true;
} else if (type === 'EC PRIVATE KEY') {
// node-forge kann EC nicht umrechnen — Teil bleibt im Original erhalten.
c.keys.push({ source, pem: block, modulus: '', keyType: 'EC', keyBits: 0 });
found = true;
} else if (type === 'CERTIFICATE REQUEST' || type === 'NEW CERTIFICATE REQUEST') {
c.csrs.push({
source,
pem: block.replace(/NEW CERTIFICATE REQUEST/g, 'CERTIFICATE REQUEST'),
});
found = true;
} else if (type === 'PKCS7') {
const p7 = forge.pkcs7.messageFromPem(block);
for (const cert of 'certificates' in p7 ? p7.certificates : [])
c.certs.push({ source, cert });
found = true;
}
} catch {
// PKCS#8 mit EC-Schluessel o. ae.: node-forge kann ihn nicht lesen —
// im Original behalten statt zu verwerfen.
if (type === 'PRIVATE KEY') {
c.keys.push({ source, pem: block, modulus: '', keyType: 'EC', keyBits: 0 });
found = true;
}
}
}
return found;
}
function collectPfx(c: Collected, source: string, buffer: Buffer, password: string): void {
let p12: forge.pkcs12.Pkcs12Pfx | null = null;
for (const candidate of password ? [password, ''] : ['']) {
try {
p12 = forge.pkcs12.pkcs12FromAsn1(forge.asn1.fromDer(binary(buffer)), candidate);
break;
} catch {
// naechstes Passwort versuchen
}
}
if (!p12) {
c.locked.push(source);
return;
}
for (const bag of p12.getBags({ bagType: forge.pki.oids.certBag })[forge.pki.oids.certBag] ??
[]) {
if (bag.cert) c.certs.push({ source, cert: bag.cert });
}
for (const oid of [forge.pki.oids.pkcs8ShroudedKeyBag, forge.pki.oids.keyBag]) {
for (const bag of p12.getBags({ bagType: oid })[oid] ?? []) {
if (bag.key) addKey(c, source, bag.key as forge.pki.rsa.PrivateKey);
}
}
}
function collectDer(c: Collected, source: string, buffer: Buffer): boolean {
try {
const asn1 = forge.asn1.fromDer(binary(buffer));
try {
c.certs.push({ source, cert: forge.pki.certificateFromAsn1(asn1) });
return true;
} catch {
/* kein einzelnes Zertifikat */
}
try {
const p7 = forge.pkcs7.messageFromAsn1(asn1);
const certs = 'certificates' in p7 ? p7.certificates : [];
for (const cert of certs) c.certs.push({ source, cert });
if (certs.length > 0) return true;
} catch {
/* kein PKCS#7 */
}
try {
forge.pki.certificationRequestFromAsn1(asn1);
const body = forge.asn1.toDer(asn1).getBytes();
c.csrs.push({ source, pem: forge.pem.encode({ type: 'CERTIFICATE REQUEST', body }) });
return true;
} catch {
/* keine CSR */
}
} catch {
/* kein DER */
}
return false;
}
function collect(files: BundleFile[], password: string): Collected {
const c: Collected = { certs: [], keys: [], csrs: [], locked: [], ignored: [] };
for (const { name, buffer } of expandZips(files)) {
const ext = name.split('.').pop()?.toLowerCase() ?? '';
if (ext === 'pfx' || ext === 'p12') {
collectPfx(c, name, buffer, password);
continue;
}
const head = buffer.subarray(0, 4096).toString('latin1');
const found = head.includes('-----BEGIN')
? collectPemText(c, name, buffer.toString('utf-8'), password)
: collectDer(c, name, buffer);
if (!found) c.ignored.push(name);
}
return c;
}
// ---------------------------------------------------------------------------
// analyzeBundle
// ---------------------------------------------------------------------------
const CERT_FORMATS: BundleExportFormat[] = ['crt', 'cer', 'fullchain', 'p7b', 'pfx'];
export function analyzeBundle(files: BundleFile[], password = ''): BundleAnalysis {
if (files.length === 0) throw new BadRequestException('No files provided');
const c = collect(files, password);
// Zertifikate nach Fingerabdruck zusammenfassen (PEM und PFX enthalten oft dieselben).
const certMap = new Map<string, { cert: forge.pki.Certificate; sources: Set<string> }>();
for (const { source, cert } of c.certs) {
const fp = sha256Of(cert);
const entry = certMap.get(fp) ?? { cert, sources: new Set<string>() };
entry.sources.add(source);
certMap.set(fp, entry);
}
const now = Date.now();
const certItems: BundleItem[] = [...certMap.entries()].map(([fp, { cert, sources }]) => {
const info = publicKeyInfo(cert.publicKey);
const cn = field(cert.subject, 'CN');
const notAfter = cert.validity.notAfter;
const role = certRole(cert);
return {
id: `cert-${fp.replace(/:/g, '').slice(0, 16).toLowerCase()}`,
kind: 'certificate',
role,
sources: [...sources],
pem: forge.pki.certificateToPem(cert),
baseName: safeBaseName(cn, role === 'end-entity' ? 'zertifikat' : 'ca'),
cn,
organization: field(cert.subject, 'O'),
issuerCn: field(cert.issuer, 'CN'),
notBefore: cert.validity.notBefore.toISOString(),
notAfter: notAfter.toISOString(),
isExpired: notAfter.getTime() < now,
daysLeft: Math.ceil((notAfter.getTime() - now) / 86_400_000),
san: sanOf(cert.extensions),
keyType: info.keyType,
keyBits: info.keyBits,
serialNumber: cert.serialNumber,
sha256: fp,
matchId: null,
chainIds: [],
formats: CERT_FORMATS,
// nur intern fuer Kette/Zuordnung, wird unten entfernt
_cert: cert,
_modulus: info.modulus,
} as BundleItem & { _cert: forge.pki.Certificate; _modulus: string };
});
// Kette: zu jedem Zertifikat den Aussteller im Paket suchen.
type Internal = BundleItem & { _cert: forge.pki.Certificate; _modulus: string };
const internals = certItems as Internal[];
for (const item of internals) {
let current = item._cert;
const seen = new Set<string>([item.id]);
for (let depth = 0; depth < 10; depth++) {
if (current.subject.hash === current.issuer.hash) break;
const issuer = internals.find(
(o) => !seen.has(o.id) && o._cert.subject.hash === current.issuer.hash,
);
if (!issuer) break;
item.chainIds.push(issuer.id);
seen.add(issuer.id);
current = issuer._cert;
}
}
// Schluessel: Duplikate zusammenfassen, dem Zertifikat zuordnen.
const keyMap = new Map<string, { key: RawKey; sources: Set<string> }>();
for (const key of c.keys) {
const id = key.modulus || key.pem;
const entry = keyMap.get(id) ?? { key, sources: new Set<string>() };
entry.sources.add(key.source);
keyMap.set(id, entry);
}
const keyItems: BundleItem[] = [...keyMap.values()].map(({ key, sources }, i) => {
const cert = key.modulus ? internals.find((o) => o._modulus === key.modulus) : undefined;
const id = `key-${i + 1}`;
if (cert) cert.matchId = id;
const isRsa = key.keyType === 'RSA';
return {
id,
kind: 'privateKey',
sources: [...sources],
pem: key.pem,
baseName:
cert?.baseName ??
safeBaseName(
sources
.values()
.next()
.value?.replace(/\.[^.]+$/, '') ?? '',
'schluessel',
),
cn: cert?.cn ?? '',
organization: '',
issuerCn: '',
notBefore: null,
notAfter: null,
isExpired: null,
daysLeft: null,
san: [],
keyType: key.keyType,
keyBits: key.keyBits,
serialNumber: '',
sha256: '',
matchId: cert?.id ?? null,
chainIds: [],
formats: isRsa ? ['key', 'key-rsa', 'key-der'] : ['key'],
};
});
// CSRs: Duplikate zusammenfassen, Details lesen, dem Zertifikat zuordnen.
const csrMap = new Map<string, { pem: string; sources: Set<string> }>();
for (const csr of c.csrs) {
const norm = csr.pem.replace(/\s+/g, '');
const entry = csrMap.get(norm) ?? { pem: csr.pem, sources: new Set<string>() };
entry.sources.add(csr.source);
csrMap.set(norm, entry);
}
const csrItems: BundleItem[] = [...csrMap.values()].map(({ pem, sources }, i) => {
let cn = '';
let organization = '';
let info = { keyType: '', keyBits: 0, modulus: '' };
let san: string[] = [];
try {
const csr = forge.pki.certificationRequestFromPem(pem);
cn = field(csr.subject as forge.pki.Certificate['subject'], 'CN');
organization = field(csr.subject as forge.pki.Certificate['subject'], 'O');
info = publicKeyInfo(csr.publicKey);
const ext = csr.getAttribute({ name: 'extensionRequest' }) as {
extensions?: unknown[];
} | null;
san = sanOf(ext?.extensions);
} catch {
// EC-CSR: node-forge liest sie nicht — Teil bleibt trotzdem herunterladbar.
}
const cert = info.modulus ? internals.find((o) => o._modulus === info.modulus) : undefined;
return {
id: `csr-${i + 1}`,
kind: 'csr',
sources: [...sources],
pem,
baseName: cert?.baseName ?? safeBaseName(cn, 'anfrage'),
cn,
organization,
issuerCn: '',
notBefore: null,
notAfter: null,
isExpired: null,
daysLeft: null,
san,
keyType: info.keyType,
keyBits: info.keyBits,
serialNumber: '',
sha256: '',
matchId: cert?.id ?? null,
chainIds: [],
formats: ['csr', 'csr-der'],
};
});
// Reihenfolge: Serverzertifikat(e), Zwischen-, Stammzertifikate, Schluessel, CSR.
const roleOrder: Record<BundleCertRole, number> = { 'end-entity': 0, intermediate: 1, root: 2 };
internals.sort(
(a, b) =>
roleOrder[a.role ?? 'end-entity'] - roleOrder[b.role ?? 'end-entity'] ||
b.chainIds.length - a.chainIds.length,
);
const certsClean: BundleItem[] = internals.map(({ _cert, _modulus, ...rest }) => rest);
return {
items: [...certsClean, ...keyItems, ...csrItems],
locked: [...new Set(c.locked)],
ignored: [...new Set(c.ignored)],
};
}
// ---------------------------------------------------------------------------
// exportBundleItem
// ---------------------------------------------------------------------------
const MIME = {
pem: 'application/x-pem-file',
der: 'application/x-x509-ca-cert',
p7b: 'application/x-pkcs7-certificates',
pfx: 'application/x-pkcs12',
key: 'application/x-pem-file',
octet: 'application/octet-stream',
} as const;
function pemBody(pem: string): string {
const [msg] = forge.pem.decode(pem);
if (!msg) throw new Error('no PEM block');
return msg.body;
}
export function exportBundleItem(input: BundleExportInput): BundleExportFile {
const { kind, pem, format, chain = [], keyPem, password } = input;
const base = safeBaseName(
input.baseName ?? '',
kind === 'csr' ? 'anfrage' : kind === 'privateKey' ? 'schluessel' : 'zertifikat',
);
if (!pem || typeof pem !== 'string') throw new BadRequestException('No PEM provided');
if (format === 'pfx' && (!password || password.trim() === '')) {
throw new BadRequestException('A password is required for PFX output');
}
try {
if (kind === 'certificate') {
const cert = forge.pki.certificateFromPem(pem);
const chainCerts = chain.map((p) => forge.pki.certificateFromPem(p));
switch (format) {
case 'crt':
return {
filename: `${base}.crt`,
content: textToBase64(forge.pki.certificateToPem(cert)),
mimeType: MIME.pem,
};
case 'cer':
return {
filename: `${base}.cer`,
content: bytesToBase64(forge.asn1.toDer(forge.pki.certificateToAsn1(cert)).getBytes()),
mimeType: MIME.der,
};
case 'fullchain': {
const text = [cert, ...chainCerts].map((x) => forge.pki.certificateToPem(x)).join('');
return {
filename: `${base}-fullchain.pem`,
content: textToBase64(text),
mimeType: MIME.pem,
};
}
case 'p7b': {
const p7 = forge.pkcs7.createSignedData();
for (const x of [cert, ...chainCerts]) p7.addCertificate(x);
const text = forge.pem.encode({
type: 'PKCS7',
body: forge.asn1.toDer(p7.toAsn1()).getBytes(),
});
return { filename: `${base}.p7b`, content: textToBase64(text), mimeType: MIME.p7b };
}
case 'pfx': {
const key = keyPem
? (forge.pki.privateKeyFromPem(keyPem) as forge.pki.rsa.PrivateKey)
: null;
const p12 = forge.pkcs12.toPkcs12Asn1(
// null = reines Zertifikatsbuendel ohne Schluessel (siehe
// CertManagerService.mergeCerts).
key,
[cert, ...chainCerts],
password as string, // oben geprueft: PFX verlangt ein Passwort
{ algorithm: '3des', friendlyName: input.baseName || undefined },
);
return {
filename: `${base}.pfx`,
content: bytesToBase64(forge.asn1.toDer(p12).getBytes()),
mimeType: MIME.pfx,
};
}
}
} else if (kind === 'privateKey') {
switch (format) {
case 'key':
return {
filename: `${base}.key`,
content: textToBase64(`${pem.trim()}\n`),
mimeType: MIME.key,
};
case 'key-rsa': {
const key = forge.pki.privateKeyFromPem(pem);
return {
filename: `${base}.rsa.key`,
content: textToBase64(forge.pki.privateKeyToPem(key)),
mimeType: MIME.key,
};
}
case 'key-der': {
const key = forge.pki.privateKeyFromPem(pem);
const info = forge.pki.wrapRsaPrivateKey(forge.pki.privateKeyToAsn1(key));
return {
filename: `${base}.key.der`,
content: bytesToBase64(forge.asn1.toDer(info).getBytes()),
mimeType: MIME.octet,
};
}
}
} else if (kind === 'csr') {
switch (format) {
case 'csr':
return {
filename: `${base}.csr`,
content: textToBase64(`${pem.trim()}\n`),
mimeType: MIME.pem,
};
case 'csr-der':
return {
filename: `${base}.csr.der`,
content: bytesToBase64(pemBody(pem)),
mimeType: MIME.octet,
};
}
}
} catch {
// Passwort und Schluessel nie protokollieren oder zurueckgeben.
throw new BadRequestException(`Failed to export ${kind} as ${format}`);
}
throw new BadRequestException(`Unsupported format "${format}" for ${kind}`);
}
@@ -10,6 +10,12 @@ import {
import { FileInterceptor, FilesInterceptor } from '@nestjs/platform-express';
import { UseModule } from '../module-registry/module.guard';
import type { UploadedFileLike } from '../auth/types/auth-user';
import {
analyzeBundle,
type BundleExportFormat,
type BundleItemKind,
exportBundleItem,
} from './cert-bundle';
import { CertManagerService } from './cert-manager.service';
/**
@@ -118,4 +124,50 @@ export class CertManagerController {
}
return this.certManagerService.convertCert({ file, pemText, targetFormat, password });
}
/**
* POST /modules/cert-manager/analyze (quick-261001-l4q)
* Zertifikatspaket: mehrere Dateien oder ZIP hochladen, jedes Teil erkennen
* (Server-/Zwischen-/Stammzertifikat, privater Schluessel, CSR), Duplikate
* zusammenfassen, Schluessel und Kette zuordnen.
*
* T-09-03: 20 Dateien, je 5 MB; ZIP-Inhalt zusaetzlich begrenzt (cert-bundle.ts).
* T-09-02: password is never passed to the logger
*/
@Post('analyze')
@UseInterceptors(
FilesInterceptor('files', 20, {
limits: { fileSize: 5 * 1024 * 1024 },
}),
)
async analyze(
@UploadedFiles() files: UploadedFileLike[] | undefined,
@Body('password') password?: string,
) {
return analyzeBundle(files ?? [], password ?? '');
}
/**
* POST /modules/cert-manager/export (quick-261001-l4q)
* Ein Teil aus `analyze` (PEM) in das gewuenschte Format bringen.
* JSON-Body; PFX verlangt ein Passwort fuer die neue Datei.
*/
@Post('export')
async export(
@Body('kind') kind: BundleItemKind,
@Body('pem') pem: string,
@Body('format') format: BundleExportFormat,
@Body('baseName') baseName?: string,
@Body('chain') chain?: string[],
@Body('keyPem') keyPem?: string,
@Body('password') password?: string,
) {
if (
chain !== undefined &&
(!Array.isArray(chain) || chain.some((c) => typeof c !== 'string'))
) {
throw new BadRequestException('chain must be a list of PEM strings');
}
return exportBundleItem({ kind, pem, format, baseName, chain, keyPem, password });
}
}