feat(cert-manager): alle Ausgabeformate, Konvertieren, Bundle und PFX
- build liefert jeden Inhalt in jedem Format: Einzelzertifikat (PEM, DER, PKCS#7 .p7b/.p7c), Fullchain und Nur Kette auch als PKCS#7, Zertifikat und Schlüssel in einer PEM-Datei, PFX, Schlüssel (PKCS#8, klassisch, DER, optional mit Passwort) und CSR (PEM, DER) - PKCS#7 von Hand aus ASN.1 gebaut, jedes Zertifikat mit seinen eigenen Bytes (RSA und EC) - PFX schreiben für RSA und EC über einen begrenzten, im finally zurückgesetzten forge-Austausch; Kompatibel (3DES/SHA-1, Vorgabe) oder Modern (AES-256) - Schlüssel und Passwörter nur in der Anfrage, nie in Antwort, Fehlertext oder Log; falscher Schlüssel: keyMismatch, kein Schlüssel: keyMissing, ohne Passwort: passwordRequired - Reiter Konvertieren für Zertifikat, Schlüssel und Anfrage; Zusammenführen mit Kettenformat, Zertifikat und Schlüssel sowie PFX (Passwort doppelt, Verschlüsselung wählbar) Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,25 +1,50 @@
|
||||
import { X509Certificate } from 'node:crypto';
|
||||
import { createPrivateKey, type KeyObject, X509Certificate } from 'node:crypto';
|
||||
import * as forge from 'node-forge';
|
||||
import { buildChains } from './cert-chain';
|
||||
import { csrItemFromDer } from './cert-csr';
|
||||
import { exportKey, type KeyExportFormat } from './cert-keys';
|
||||
import { certItemFromDer } from './cert-model';
|
||||
import { safeBaseName } from './cert-names';
|
||||
import { writePkcs12 } from './cert-pkcs12';
|
||||
import {
|
||||
type BuildContent,
|
||||
type BuildFile,
|
||||
type BuildInput,
|
||||
type BuildResult,
|
||||
type CertItem,
|
||||
type CsrItem,
|
||||
certError,
|
||||
} from './cert-types';
|
||||
|
||||
/**
|
||||
* Ausgabe-Bausteine des Zertifikat-Managers (quick-261009-ikt, D-19).
|
||||
* Task 2: Fullchain und Nur Kette als PEM. Die weiteren Inhalte und Formate (Task 5) kommen in
|
||||
* dieselbe Funktion. Die Reihenfolge baut die API immer selbst aus den gesendeten Zertifikaten
|
||||
* (buildChains); eine vom Browser mitgeschickte Reihenfolge wird nie uebernommen.
|
||||
* Jeder Inhalt in jedem Format laeuft durch die eine Funktion buildOutput. Die Reihenfolge baut die API
|
||||
* immer selbst aus den gesendeten Zertifikaten (buildChains); eine vom Browser mitgeschickte Reihenfolge
|
||||
* wird nie uebernommen. Schluessel und Passwoerter werden nur verarbeitet, nie gespeichert oder
|
||||
* protokolliert, und erscheinen in keiner Fehlermeldung.
|
||||
*/
|
||||
|
||||
export { safeBaseName };
|
||||
|
||||
const PEM_MIME = 'application/x-pem-file';
|
||||
const MIME = {
|
||||
pem: 'application/x-pem-file',
|
||||
der: 'application/pkix-cert',
|
||||
pkcs7: 'application/x-pkcs7-certificates',
|
||||
pfx: 'application/x-pkcs12',
|
||||
binary: 'application/octet-stream',
|
||||
csr: 'application/pkcs10',
|
||||
} as const;
|
||||
|
||||
/** Erlaubte Formate je Inhalt; das erste ist die Vorgabe. */
|
||||
const FORMATS: Record<BuildContent, readonly string[]> = {
|
||||
leaf: ['pem', 'der', 'p7b', 'p7c'],
|
||||
fullchain: ['pem', 'p7b', 'p7c'],
|
||||
chain: ['pem', 'p7b', 'p7c'],
|
||||
leafKey: ['pem'],
|
||||
pfx: ['pfx'],
|
||||
key: ['pkcs8', 'traditional', 'pkcs8-der'],
|
||||
csr: ['pem', 'der'],
|
||||
};
|
||||
|
||||
function parseCertificate(pem: unknown): CertItem {
|
||||
if (typeof pem !== 'string' || pem.trim() === '') {
|
||||
@@ -32,21 +57,104 @@ function parseCertificate(pem: unknown): CertItem {
|
||||
}
|
||||
}
|
||||
|
||||
function parseKey(pem: string | undefined): KeyObject {
|
||||
if (typeof pem !== 'string' || pem.trim() === '') {
|
||||
certError('keyMissing', 400, 'A private key is required');
|
||||
}
|
||||
try {
|
||||
return createPrivateKey(pem);
|
||||
} catch {
|
||||
return certError('invalidInput', 400, 'keyPem is not a readable private key');
|
||||
}
|
||||
}
|
||||
|
||||
const CSR_BLOCK =
|
||||
/-----BEGIN (?:NEW )?CERTIFICATE REQUEST-----([\s\S]*?)-----END (?:NEW )?CERTIFICATE REQUEST-----/;
|
||||
|
||||
function parseCsr(pem: string | undefined): { der: Buffer; item: CsrItem } {
|
||||
const body = typeof pem === 'string' ? CSR_BLOCK.exec(pem)?.[1] : undefined;
|
||||
if (!body) certError('invalidInput', 400, 'csrPem is not a certificate request');
|
||||
try {
|
||||
const der = Buffer.from(body.replace(/\s+/g, ''), 'base64');
|
||||
return { der, item: csrItemFromDer(der, { file: 0, path: '' }) };
|
||||
} catch {
|
||||
return certError('invalidInput', 400, 'csrPem is not a certificate request');
|
||||
}
|
||||
}
|
||||
|
||||
function pemBlock(label: string, der: Buffer): string {
|
||||
const lines = der.toString('base64').match(/.{1,64}/g) ?? [];
|
||||
return `-----BEGIN ${label}-----\n${lines.join('\n')}\n-----END ${label}-----\n`;
|
||||
}
|
||||
|
||||
/** PEM-Bloecke in Kettenreihenfolge, jeder genau einmal mit abschliessendem Zeilenumbruch. */
|
||||
function joinPem(certs: CertItem[]): string {
|
||||
return certs.map((c) => `${c.pem.trim()}\n`).join('');
|
||||
}
|
||||
|
||||
function pemFile(filename: string, certs: CertItem[]): BuildFile {
|
||||
return {
|
||||
filename,
|
||||
content: Buffer.from(joinPem(certs), 'utf8').toString('base64'),
|
||||
mimeType: PEM_MIME,
|
||||
};
|
||||
function derOf(cert: CertItem): Buffer {
|
||||
return new X509Certificate(cert.pem).raw;
|
||||
}
|
||||
|
||||
const { asn1 } = forge;
|
||||
|
||||
function sequence(value: forge.asn1.Asn1[]): forge.asn1.Asn1 {
|
||||
return asn1.create(asn1.Class.UNIVERSAL, asn1.Type.SEQUENCE, true, value);
|
||||
}
|
||||
|
||||
function oid(value: string): forge.asn1.Asn1 {
|
||||
return asn1.create(asn1.Class.UNIVERSAL, asn1.Type.OID, false, asn1.oidToDer(value).getBytes());
|
||||
}
|
||||
|
||||
/**
|
||||
* PKCS#7 „certs only“ (signedData ohne Inhalt und ohne Unterschriften, wie .p7b/.p7c von CAs), von Hand
|
||||
* aus ASN.1 gebaut: ContentInfo { signedData, [0] { version 1, leere Verfahrensliste, data, [0] Zertifikate,
|
||||
* leere Unterschriftenliste } }. Jedes Zertifikat geht mit seinen eigenen Bytes hinein; forges
|
||||
* Zertifikatobjekte (nur RSA) kommen nie vor.
|
||||
*/
|
||||
function pkcs7Der(certs: CertItem[]): Buffer {
|
||||
const options = { decodeBitStrings: false } as unknown as boolean;
|
||||
const certAsn1 = certs.map((c) =>
|
||||
asn1.fromDer(forge.util.createBuffer(derOf(c).toString('binary')), options),
|
||||
);
|
||||
const signedData = sequence([
|
||||
asn1.create(asn1.Class.UNIVERSAL, asn1.Type.INTEGER, false, asn1.integerToDer(1).getBytes()),
|
||||
asn1.create(asn1.Class.UNIVERSAL, asn1.Type.SET, true, []),
|
||||
sequence([oid('1.2.840.113549.1.7.1')]),
|
||||
asn1.create(asn1.Class.CONTEXT_SPECIFIC, 0, true, certAsn1),
|
||||
asn1.create(asn1.Class.UNIVERSAL, asn1.Type.SET, true, []),
|
||||
]);
|
||||
const contentInfo = sequence([
|
||||
oid('1.2.840.113549.1.7.2'),
|
||||
asn1.create(asn1.Class.CONTEXT_SPECIFIC, 0, true, [signedData]),
|
||||
]);
|
||||
return Buffer.from(asn1.toDer(contentInfo).getBytes(), 'binary');
|
||||
}
|
||||
|
||||
function file(filename: string, data: Buffer | string, mimeType: string): BuildFile {
|
||||
const bytes = typeof data === 'string' ? Buffer.from(data, 'utf8') : data;
|
||||
return { filename, content: bytes.toString('base64'), mimeType };
|
||||
}
|
||||
|
||||
/** Zertifikatsliste als PEM, PKCS#7 PEM (.p7b) oder PKCS#7 DER (.p7c) unter dem Namen `<stem>.<endung>`. */
|
||||
function certListFile(stem: string, pemName: string, format: string, certs: CertItem[]): BuildFile {
|
||||
if (format === 'p7b') {
|
||||
return file(`${stem}.p7b`, pemBlock('PKCS7', pkcs7Der(certs)), MIME.pkcs7);
|
||||
}
|
||||
if (format === 'p7c') return file(`${stem}.p7c`, pkcs7Der(certs), MIME.pkcs7);
|
||||
return file(pemName, joinPem(certs), MIME.pem);
|
||||
}
|
||||
|
||||
/** Baut die gewuenschte Ausgabe. Wirft Nest-Ausnahmen mit Code (D-24); nie mit Passwort oder Schluessel im Text. */
|
||||
export function buildOutput(input: BuildInput): BuildResult {
|
||||
const allowed = FORMATS[input.content];
|
||||
if (!allowed) certError('invalidInput', 400, 'Unknown content');
|
||||
const format = input.format ?? allowed[0];
|
||||
if (!allowed.includes(format)) certError('invalidInput', 400, 'Format is not available here');
|
||||
|
||||
if (input.content === 'key') return buildKey(input, format as KeyExportFormat);
|
||||
if (input.content === 'csr') return buildCsr(input, format);
|
||||
|
||||
if (!input.certPem) certError('invalidInput', 400, 'certPem is required');
|
||||
const head = parseCertificate(input.certPem);
|
||||
const pool = (input.poolPems ?? []).map(parseCertificate);
|
||||
@@ -60,18 +168,48 @@ export function buildOutput(input: BuildInput): BuildResult {
|
||||
const path = chain.path.map((id) => byId.get(id) as CertItem);
|
||||
const includeRoot = input.includeRoot === true;
|
||||
const withoutRoot = path.filter((c) => !(c.role === 'root' && c.id !== head.id));
|
||||
const shown = includeRoot ? path : withoutRoot;
|
||||
const base = safeBaseName(input.baseName ?? '', head.baseName);
|
||||
|
||||
let files: BuildFile[];
|
||||
if (input.content === 'fullchain') {
|
||||
files = [pemFile(`${base}-fullchain.pem`, includeRoot ? path : withoutRoot)];
|
||||
} else if (input.content === 'chain') {
|
||||
const issuers = (includeRoot ? path : withoutRoot).filter((c) => c.id !== head.id);
|
||||
if (issuers.length === 0)
|
||||
certError('noChain', 400, 'No intermediate or root certificate available');
|
||||
files = [pemFile(`${base}-chain.pem`, issuers)];
|
||||
} else {
|
||||
return certError('formatNotPossible', 400, 'Output not available');
|
||||
switch (input.content) {
|
||||
case 'leaf':
|
||||
if (format === 'der') files = [file(`${base}.cer`, derOf(head), MIME.der)];
|
||||
else files = [certListFile(base, `${base}.crt`, format, [head])];
|
||||
break;
|
||||
case 'fullchain':
|
||||
files = [certListFile(`${base}-fullchain`, `${base}-fullchain.pem`, format, shown)];
|
||||
break;
|
||||
case 'chain': {
|
||||
const issuers = shown.filter((c) => c.id !== head.id);
|
||||
if (issuers.length === 0) {
|
||||
certError('noChain', 400, 'No intermediate or root certificate available');
|
||||
}
|
||||
files = [certListFile(`${base}-chain`, `${base}-chain.pem`, format, issuers)];
|
||||
break;
|
||||
}
|
||||
case 'leafKey': {
|
||||
const key = matchingKey(head, input.keyPem);
|
||||
const certs = input.includeChain === false ? [head] : shown;
|
||||
const keyPem = key.export({ type: 'pkcs8', format: 'pem' }) as string;
|
||||
files = [file(`${base}-bundle.pem`, joinPem(certs) + keyPem, MIME.pem)];
|
||||
break;
|
||||
}
|
||||
case 'pfx': {
|
||||
if (!input.password) certError('passwordRequired', 400, 'A password is required');
|
||||
const key = input.keyPem ? matchingKey(head, input.keyPem) : null;
|
||||
const der = writePkcs12({
|
||||
keyObject: key,
|
||||
certDers: shown.map(derOf),
|
||||
password: input.password,
|
||||
profile: input.pfxEncryption === 'modern' ? 'modern' : 'compat',
|
||||
friendlyName: base,
|
||||
});
|
||||
files = [file(`${base}.pfx`, der, MIME.pfx)];
|
||||
break;
|
||||
}
|
||||
default:
|
||||
return certError('invalidInput', 400, 'Unknown content');
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -80,3 +218,38 @@ export function buildOutput(input: BuildInput): BuildResult {
|
||||
missingIssuerCn: chain.gap?.missingIssuerCn ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
/** Liest den Schluessel und prueft, dass er zum Serverzertifikat gehoert (sonst keyMismatch). */
|
||||
function matchingKey(head: CertItem, keyPem: string | undefined): KeyObject {
|
||||
const key = parseKey(keyPem);
|
||||
if (!new X509Certificate(head.pem).checkPrivateKey(key)) {
|
||||
certError('keyMismatch', 400, 'The key does not belong to the certificate');
|
||||
}
|
||||
return key;
|
||||
}
|
||||
|
||||
function buildKey(input: BuildInput, format: KeyExportFormat): BuildResult {
|
||||
const key = parseKey(input.keyPem);
|
||||
const base = safeBaseName(input.baseName ?? '', 'schluessel');
|
||||
const data = exportKey(key, format, input.password || undefined);
|
||||
let filename: string;
|
||||
let mime: string = MIME.pem;
|
||||
if (format === 'pkcs8') filename = `${base}.key`;
|
||||
else if (format === 'traditional') {
|
||||
filename = key.asymmetricKeyType === 'rsa' ? `${base}.rsa.key` : `${base}.ec.key`;
|
||||
} else {
|
||||
filename = `${base}.key.der`;
|
||||
mime = MIME.binary;
|
||||
}
|
||||
return { files: [file(filename, data, mime)], chainComplete: true, missingIssuerCn: null };
|
||||
}
|
||||
|
||||
function buildCsr(input: BuildInput, format: string): BuildResult {
|
||||
const { der, item } = parseCsr(input.csrPem);
|
||||
const base = safeBaseName(input.baseName ?? '', item.baseName);
|
||||
const out =
|
||||
format === 'der'
|
||||
? file(`${base}.csr.der`, der, MIME.csr)
|
||||
: file(`${base}.csr`, item.pem, MIME.csr);
|
||||
return { files: [out], chainComplete: true, missingIssuerCn: null };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user