c07b0cfaf0
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>
656 lines
22 KiB
TypeScript
656 lines
22 KiB
TypeScript
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}`);
|
|
}
|