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:
2026-10-09 15:46:43 +02:00
parent fcac0a3bfd
commit 65a1dca80f
18 changed files with 1877 additions and 47 deletions
+74 -2
View File
@@ -1,8 +1,8 @@
import { createPrivateKey, createPublicKey } from 'node:crypto';
import { createPrivateKey, createPublicKey, generateKeyPairSync } 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 { candidatePasswords, exportKey, keyIdOf, MAX_PASSWORDS } from './cert-keys';
import { detectBlob } from './cert-model';
import type { KeyItem } from './cert-types';
@@ -167,3 +167,75 @@ describe('candidatePasswords', () => {
expect(out[0]).toBe('eigen');
});
});
describe('exportKey', () => {
const rsa = () => createPrivateKey(fx('rsa-leaf-key.pem'));
const ec = () => createPrivateKey(fx('ec-leaf-key.pem'));
const text = (b: Buffer) => b.toString('utf8');
const sameKey = (a: ReturnType<typeof rsa>, b: ReturnType<typeof rsa>) =>
a.export({ type: 'pkcs8', format: 'der' }).equals(b.export({ type: 'pkcs8', format: 'der' }));
it('PKCS#8 ohne Passwort: PRIVATE KEY, gleicher Schluessel', () => {
const out = text(exportKey(rsa(), 'pkcs8'));
expect(out).toContain('-----BEGIN PRIVATE KEY-----');
expect(sameKey(createPrivateKey(out), rsa())).toBe(true);
expect(text(exportKey(ec(), 'pkcs8'))).toContain('-----BEGIN PRIVATE KEY-----');
});
it('PKCS#8 mit Passwort: ENCRYPTED PRIVATE KEY, nur mit dem Passwort zu oeffnen', () => {
for (const key of [rsa(), ec()]) {
const out = text(exportKey(key, 'pkcs8', PASSWORD));
expect(out).toContain('-----BEGIN ENCRYPTED PRIVATE KEY-----');
expect(sameKey(createPrivateKey({ key: out, passphrase: PASSWORD }), key)).toBe(true);
expect(() => createPrivateKey({ key: out, passphrase: 'falsch' })).toThrow();
}
});
it('klassisch: RSA als RSA PRIVATE KEY, EC als EC PRIVATE KEY', () => {
expect(text(exportKey(rsa(), 'traditional'))).toContain('-----BEGIN RSA PRIVATE KEY-----');
expect(text(exportKey(ec(), 'traditional'))).toContain('-----BEGIN EC PRIVATE KEY-----');
});
it('klassisch mit Passwort: Proc-Type 4,ENCRYPTED, mit dem Passwort zu oeffnen', () => {
for (const key of [rsa(), ec()]) {
const out = text(exportKey(key, 'traditional', PASSWORD));
expect(out).toContain('Proc-Type: 4,ENCRYPTED');
expect(sameKey(createPrivateKey({ key: out, passphrase: PASSWORD }), key)).toBe(true);
}
});
it('PKCS#8 als DER; mit Passwort verschluesselt und nur damit zu oeffnen', () => {
const plain = exportKey(ec(), 'pkcs8-der');
expect(sameKey(createPrivateKey({ key: plain, format: 'der', type: 'pkcs8' }), ec())).toBe(
true,
);
const enc = exportKey(ec(), 'pkcs8-der', PASSWORD);
expect(
sameKey(
createPrivateKey({ key: enc, format: 'der', type: 'pkcs8', passphrase: PASSWORD }),
ec(),
),
).toBe(true);
expect(() =>
createPrivateKey({ key: enc, format: 'der', type: 'pkcs8', passphrase: 'falsch' }),
).toThrow();
});
it('klassisch fuer einen Ed25519-Schluessel: formatNotPossible', () => {
const { privateKey } = generateKeyPairSync('ed25519');
expect(text(exportKey(privateKey, 'pkcs8'))).toContain('PRIVATE KEY');
try {
exportKey(privateKey, 'traditional');
throw new Error('expected a throw');
} catch (error) {
const e = error as { getStatus(): number; getResponse(): { code: string } };
expect(e.getStatus()).toBe(400);
expect(e.getResponse().code).toBe('formatNotPossible');
}
});
it('das Passwort steht nie in der Ausgabe', () => {
expect(text(exportKey(rsa(), 'pkcs8', PASSWORD))).not.toContain(PASSWORD);
expect(text(exportKey(rsa(), 'traditional', PASSWORD))).not.toContain(PASSWORD);
});
});
+44 -1
View File
@@ -1,6 +1,12 @@
import { createHash, createPrivateKey, createPublicKey, type KeyObject } from 'node:crypto';
import * as forge from 'node-forge';
import type { IgnoredEntry, ItemSource, KeyItem, LockedEntry } from './cert-types';
import {
certError,
type IgnoredEntry,
type ItemSource,
type KeyItem,
type LockedEntry,
} from './cert-types';
/**
* Private Schluessel des Zertifikat-Managers (quick-261009-ikt, D-05, D-11, D-16).
@@ -224,3 +230,40 @@ export function keyFromDer(der: Buffer, ctx: KeyContext): KeyDetectResult | null
}
return null;
}
export type KeyExportFormat = 'pkcs8' | 'traditional' | 'pkcs8-der';
const EXPORT_CIPHER = 'aes-256-cbc';
/**
* Gibt einen privaten Schluessel in dem gewuenschten Format aus (D-19): PKCS#8 als PEM, klassisch
* (PKCS#1 fuer RSA, SEC1 fuer EC) als PEM oder PKCS#8 als DER. Mit Passwort wird mit AES-256-CBC
* verschluesselt (PKCS#8: ENCRYPTED PRIVATE KEY, klassisch: Proc-Type 4,ENCRYPTED). Die Ausgabe ist
* PEM-Text (UTF-8) oder DER. Klassisch gibt es nur fuer RSA und EC: sonst formatNotPossible.
* Weder der Schluessel noch das Passwort erscheinen in einem Fehlertext.
*/
export function exportKey(key: KeyObject, format: KeyExportFormat, password?: string): Buffer {
const cipher = password ? { cipher: EXPORT_CIPHER, passphrase: password } : {};
try {
if (format === 'pkcs8') {
return Buffer.from(key.export({ type: 'pkcs8', format: 'pem', ...cipher }) as string, 'utf8');
}
if (format === 'pkcs8-der') {
return key.export({ type: 'pkcs8', format: 'der', ...cipher }) as Buffer;
}
} catch {
return certError('formatNotPossible', 400, 'Key cannot be exported in this format');
}
const type = key.asymmetricKeyType;
if (type !== 'rsa' && type !== 'ec') {
return certError('formatNotPossible', 400, 'Traditional format needs an RSA or EC key');
}
try {
return Buffer.from(
key.export({ type: type === 'rsa' ? 'pkcs1' : 'sec1', format: 'pem', ...cipher }) as string,
'utf8',
);
} catch {
return certError('formatNotPossible', 400, 'Key cannot be exported in this format');
}
}
@@ -1,10 +1,11 @@
import 'reflect-metadata';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { RequestMethod } from '@nestjs/common';
import { RequestMethod, ValidationPipe } from '@nestjs/common';
import { describe, expect, it } from 'vitest';
import { MODULE_SLUG_KEY } from '../module-registry/module.guard';
import { CertManagerController, parsePasswords, repairFileName } from './cert-manager.controller';
import { BuildOutputDto, CERT_PEM_MAX } from './dto/cert-build.dto';
const fx = (name: string) => readFileSync(join(__dirname, '__fixtures__', name));
const upload = (name: string, buffer: Buffer = fx(name)) => ({
@@ -140,3 +141,55 @@ describe('repairFileName', () => {
expect(repairFileName('M\xfcller.pem')).toBe('M\xfcller.pem');
});
});
describe('build: Anfrage und Pruefung', () => {
const controller = new CertManagerController();
const pipe = new ValidationPipe({ whitelist: true, transform: true });
const validate = (body: object) =>
pipe.transform(body, { type: 'body', metatype: BuildOutputDto }) as Promise<BuildOutputDto>;
const text = (n: string) => fx(n).toString('utf8');
it('PFX mit Schluessel, Passwort und Verschluesselung geht durch die Pruefung und liefert eine Datei', async () => {
const dto = await validate({
content: 'pfx',
certPem: text('ec-leaf.pem'),
poolPems: [text('ec-inter.pem')],
keyPem: text('ec-leaf-key.pem'),
password: 'Neu-Pass-2026',
pfxEncryption: 'modern',
includeChain: true,
baseName: 'server',
});
const result = controller.build(dto);
expect(result.files[0].filename).toBe('server.pfx');
expect(JSON.stringify(result)).not.toContain('Neu-Pass-2026');
});
it('Schluessel- und Anfrage-Ausgabe brauchen kein Zertifikat', async () => {
const key = controller.build(
await validate({ content: 'key', format: 'traditional', keyPem: text('rsa-leaf-key.pem') }),
);
expect(key.files[0].filename).toBe('schluessel.rsa.key');
const csr = controller.build(
await validate({ content: 'csr', format: 'der', csrPem: text('rsa-leaf.csr') }),
);
expect(csr.files[0].filename.endsWith('.csr.der')).toBe(true);
});
it.each([
['unbekannter Inhalt', { content: 'zip' }],
['unbekanntes Format', { content: 'leaf', format: 'p12' }],
['unbekannte Verschluesselung', { content: 'pfx', pfxEncryption: 'rc2' }],
['includeChain keine Wahrheitsangabe', { content: 'leaf', includeChain: 'ja' }],
['zu langer Schluessel', { content: 'key', keyPem: 'k'.repeat(CERT_PEM_MAX + 1) }],
['zu lange Anfrage', { content: 'csr', csrPem: 'r'.repeat(CERT_PEM_MAX + 1) }],
['zu langes Passwort', { content: 'pfx', password: 'p'.repeat(257) }],
])('%s wird von der Pruefung abgewiesen', async (_name, body) => {
await expect(validate(body)).rejects.toMatchObject({ status: 400 });
});
it('unbekannte Felder (etwa eine Adresse) werden entfernt', async () => {
const dto = await validate({ content: 'leaf', url: 'http://10.0.0.1/', certPem: 'x' });
expect((dto as unknown as Record<string, unknown>).url).toBeUndefined();
});
});
+391 -1
View File
@@ -1,8 +1,11 @@
import { X509Certificate } from 'node:crypto';
import { createPrivateKey, generateKeyPairSync, X509Certificate } from 'node:crypto';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { describe, expect, it } from 'vitest';
import { detectBlob } from './cert-model';
import { buildOutput, safeBaseName } from './cert-output';
import { readPkcs12 } from './cert-pkcs12';
import type { CertItem, CsrItem } from './cert-types';
const fxText = (name: string) => readFileSync(join(__dirname, '__fixtures__', name), 'utf8');
@@ -140,3 +143,390 @@ describe('buildOutput fullchain und chain', () => {
expect(r.files[0].filename).toBe('mein_Server-fullchain.pem');
});
});
const bytes = (file: { content: string }) => Buffer.from(file.content, 'base64');
const NEW_PASSWORD = 'Neu-Pass-2026';
const PASSWORD = 'Test-Pass-123';
/** Zertifikate aus PKCS#7-Bytes (ueber den Parser der Anwendung, also derselbe Weg wie beim Hochladen). */
function p7Subjects(data: Buffer): string[] {
const r = detectBlob(data, { file: 0, path: 'x.p7', passwords: [] });
return r.items.filter((i): i is CertItem => i.kind === 'certificate').map((c) => c.cn);
}
describe('buildOutput: einzelnes Zertifikat', () => {
const rsaLeaf = fxText('rsa-leaf.pem');
it('PEM: <base>.crt mit genau dem Zertifikat', () => {
const r = buildOutput({ content: 'leaf', format: 'pem', certPem: rsaLeaf });
expect(r.files[0].filename).toBe('www.example.test.crt');
expect(r.files[0].mimeType).toBe('application/x-pem-file');
expect(subjects(decode(r.files[0]))).toEqual(['www.example.test']);
});
it('DER: <base>.cer, von X509Certificate gelesen gleich dem Eingang', () => {
const r = buildOutput({ content: 'leaf', format: 'der', certPem: rsaLeaf });
expect(r.files[0].filename).toBe('www.example.test.cer');
expect(r.files[0].mimeType).toBe('application/pkix-cert');
expect(new X509Certificate(bytes(r.files[0])).fingerprint256).toBe(
new X509Certificate(rsaLeaf).fingerprint256,
);
});
it.each([
['p7b', 'www.example.test.p7b', '-----BEGIN PKCS7-----'],
['p7c', 'www.example.test.p7c', null],
])('PKCS#7 %s: nur das Serverzertifikat', (format, filename, header) => {
const r = buildOutput({ content: 'leaf', format, certPem: rsaLeaf });
expect(r.files[0].filename).toBe(filename);
expect(r.files[0].mimeType).toBe('application/x-pkcs7-certificates');
if (header) expect(decode(r.files[0])).toContain(header);
else expect(bytes(r.files[0])[0]).toBe(0x30);
expect(p7Subjects(bytes(r.files[0]))).toEqual(['www.example.test']);
});
it('ohne format gilt PEM', () => {
expect(buildOutput({ content: 'leaf', certPem: rsaLeaf }).files[0].filename).toBe(
'www.example.test.crt',
);
});
});
describe('buildOutput: Fullchain und Kette als PKCS#7', () => {
const cases = [
[
'EC',
'ec-leaf.pem',
['ec-root.pem', 'ec-inter.pem'],
['Tessera Test Inter EC', 'Tessera Test Root EC'],
'ec.example.test',
],
[
'RSA',
'rsa-leaf.pem',
['rsa-root.pem', 'rsa-inter.pem'],
['Tessera Test Inter RSA', 'Tessera Test Root RSA'],
'www.example.test',
],
] as const;
it.each(
cases,
)('%s: Fullchain p7b/p7c in Kettenreihenfolge, Wurzel nur mit includeRoot', (_t, leaf, pool, issuers, cn) => {
const common = { certPem: fxText(leaf), poolPems: pool.map(fxText) };
for (const format of ['p7b', 'p7c']) {
const without = buildOutput({ content: 'fullchain', format, ...common });
expect(without.files[0].filename).toBe(`${cn}-fullchain.${format}`);
expect(p7Subjects(bytes(without.files[0]))).toEqual([cn, issuers[0]]);
const withRoot = buildOutput({ content: 'fullchain', format, includeRoot: true, ...common });
expect(p7Subjects(bytes(withRoot.files[0]))).toEqual([cn, ...issuers]);
}
});
it.each(cases)('%s: Nur Kette p7b/p7c ohne Serverzertifikat', (_t, leaf, pool, issuers, cn) => {
const common = { certPem: fxText(leaf), poolPems: pool.map(fxText) };
for (const format of ['p7b', 'p7c']) {
const only = buildOutput({ content: 'chain', format, ...common });
expect(only.files[0].filename).toBe(`${cn}-chain.${format}`);
expect(p7Subjects(bytes(only.files[0]))).toEqual([issuers[0]]);
const withRoot = buildOutput({ content: 'chain', format, includeRoot: true, ...common });
expect(p7Subjects(bytes(withRoot.files[0]))).toEqual(issuers);
}
});
it('die Zertifikate im PKCS#7 sind byte-genau die Originale', () => {
const r = buildOutput({
content: 'fullchain',
format: 'p7c',
certPem: fxText('ec-leaf.pem'),
poolPems: [fxText('ec-inter.pem')],
});
const found = detectBlob(bytes(r.files[0]), { file: 0, path: 'x', passwords: [] }).items.map(
(i) => (i as CertItem).sha256,
);
expect(found).toEqual([
new X509Certificate(fxText('ec-leaf.pem')).fingerprint256,
new X509Certificate(fxText('ec-inter.pem')).fingerprint256,
]);
});
});
describe('buildOutput: Zertifikat und Schluessel (leafKey)', () => {
const chain = {
certPem: fxText('ec-leaf.pem'),
poolPems: [fxText('ec-inter.pem'), fxText('ec-root.pem')],
keyPem: fxText('ec-leaf-key.pem'),
};
it('Server, Zwischenzertifikat, Schluessel zuletzt; ohne Wurzel', () => {
const r = buildOutput({ content: 'leafKey', ...chain });
expect(r.files[0].filename).toBe('ec.example.test-bundle.pem');
const text = decode(r.files[0]);
expect(subjects(text)).toEqual(['ec.example.test', 'Tessera Test Inter EC']);
expect(text.trimEnd().endsWith('-----END PRIVATE KEY-----')).toBe(true);
expect(text.indexOf('BEGIN PRIVATE KEY')).toBeGreaterThan(text.lastIndexOf('END CERTIFICATE'));
expect(
new X509Certificate(fxText('ec-leaf.pem')).checkPrivateKey(
createPrivateKey(text.slice(text.indexOf('-----BEGIN PRIVATE KEY'))),
),
).toBe(true);
});
it('mit includeRoot kommt die Wurzel vor dem Schluessel', () => {
const r = buildOutput({ content: 'leafKey', includeRoot: true, ...chain });
expect(subjects(decode(r.files[0]))).toEqual([
'ec.example.test',
'Tessera Test Inter EC',
'Tessera Test Root EC',
]);
});
it('includeChain false: nur Serverzertifikat und Schluessel', () => {
const r = buildOutput({ content: 'leafKey', includeChain: false, ...chain });
expect(subjects(decode(r.files[0]))).toEqual(['ec.example.test']);
});
it('Schluessel eines anderen Zertifikats: 400 keyMismatch', () => {
expect(
codeOf(() =>
buildOutput({ content: 'leafKey', ...chain, keyPem: fxText('rsa-leaf-key.pem') }),
),
).toEqual({ status: 400, code: 'keyMismatch' });
});
it('ohne Schluessel: 400 keyMissing', () => {
expect(codeOf(() => buildOutput({ content: 'leafKey', certPem: chain.certPem }))).toEqual({
status: 400,
code: 'keyMissing',
});
});
it('kein lesbarer Schluessel: 400 invalidInput, ohne Schluesseltext in der Meldung', () => {
try {
buildOutput({ content: 'leafKey', certPem: chain.certPem, keyPem: 'GEHEIM-KEIN-SCHLUESSEL' });
throw new Error('expected a throw');
} catch (error) {
const e = error as { getStatus(): number; getResponse(): { code: string; message: string } };
expect(e.getStatus()).toBe(400);
expect(e.getResponse().code).toBe('invalidInput');
expect(JSON.stringify(e.getResponse())).not.toContain('GEHEIM');
}
});
});
describe('buildOutput: PFX', () => {
const rsa = {
certPem: fxText('rsa-leaf.pem'),
poolPems: [fxText('rsa-inter.pem'), fxText('rsa-root.pem')],
keyPem: fxText('rsa-leaf-key.pem'),
};
const ec = {
certPem: fxText('ec-leaf.pem'),
poolPems: [fxText('ec-inter.pem'), fxText('ec-root.pem')],
keyPem: fxText('ec-leaf-key.pem'),
};
it('ohne Passwort: 400 passwordRequired', () => {
expect(codeOf(() => buildOutput({ content: 'pfx', ...rsa }))).toEqual({
status: 400,
code: 'passwordRequired',
});
expect(codeOf(() => buildOutput({ content: 'pfx', password: '', ...rsa }))).toEqual({
status: 400,
code: 'passwordRequired',
});
});
it.each([
['RSA', rsa, 'www.example.test'],
['EC', ec, 'ec.example.test'],
])('%s: modern, <base>.pfx, mit dem Passwort wieder lesbar (Server, Zwischen, Schluessel)', (_t, set, cn) => {
const r = buildOutput({
content: 'pfx',
password: NEW_PASSWORD,
pfxEncryption: 'modern',
...set,
});
expect(r.files[0].filename).toBe(`${cn}.pfx`);
expect(r.files[0].mimeType).toBe('application/x-pkcs12');
const read = readPkcs12(bytes(r.files[0]), [], NEW_PASSWORD);
expect(read.ok).toBe(true);
if (!read.ok) return;
const cns = read.contents.certDers.map((d) =>
String(new X509Certificate(d).toLegacyObject().subject.CN),
);
expect(cns).toHaveLength(2); // ohne Wurzel
expect(cns[0]).toBe(cn);
expect(new X509Certificate(set.certPem).checkPrivateKey(read.contents.keys[0].key)).toBe(true);
});
it('mit includeRoot kommt die Wurzel dazu; Vorgabe ist kompatibel', () => {
const r = buildOutput({ content: 'pfx', password: NEW_PASSWORD, includeRoot: true, ...rsa });
const read = readPkcs12(bytes(r.files[0]), [], NEW_PASSWORD);
expect(read.ok && read.contents.certDers).toHaveLength(3);
// 3DES-OID im Container (Kennung als DER-Bytes)
const oid3des = Buffer.from('2a864886f70d010c0103', 'hex');
expect(bytes(r.files[0]).includes(oid3des)).toBe(true);
});
it('ohne Schluessel: nur Zertifikate', () => {
const r = buildOutput({
content: 'pfx',
password: NEW_PASSWORD,
certPem: rsa.certPem,
poolPems: rsa.poolPems,
});
const read = readPkcs12(bytes(r.files[0]), [], NEW_PASSWORD);
expect(read.ok && read.contents.keys).toHaveLength(0);
expect(read.ok && read.contents.certDers).toHaveLength(2);
});
it('Schluessel eines anderen Zertifikats: 400 keyMismatch', () => {
expect(
codeOf(() =>
buildOutput({ content: 'pfx', password: NEW_PASSWORD, ...rsa, keyPem: ec.keyPem }),
),
).toEqual({ status: 400, code: 'keyMismatch' });
});
it('das Passwort steht nirgends in der Antwort', () => {
const r = buildOutput({ content: 'pfx', password: NEW_PASSWORD, ...rsa });
expect(JSON.stringify(r)).not.toContain(NEW_PASSWORD);
});
});
describe('buildOutput: Schluessel und Anfrage exportieren', () => {
const rsaKey = fxText('rsa-leaf-key.pem');
const ecKey = fxText('ec-leaf-key.pem');
it('PKCS#8: <base>.key, mit Passwort ENCRYPTED PRIVATE KEY', () => {
const plain = buildOutput({ content: 'key', format: 'pkcs8', keyPem: rsaKey });
expect(plain.files[0].filename).toBe('schluessel.key');
expect(decode(plain.files[0])).toContain('-----BEGIN PRIVATE KEY-----');
const enc = buildOutput({
content: 'key',
format: 'pkcs8',
keyPem: rsaKey,
password: PASSWORD,
});
expect(decode(enc.files[0])).toContain('-----BEGIN ENCRYPTED PRIVATE KEY-----');
expect(createPrivateKey({ key: decode(enc.files[0]), passphrase: PASSWORD })).toBeTruthy();
expect(JSON.stringify(enc)).not.toContain(PASSWORD);
});
it('klassisch: <base>.rsa.key und <base>.ec.key', () => {
const rsa = buildOutput({
content: 'key',
format: 'traditional',
keyPem: rsaKey,
baseName: 'srv',
});
expect(rsa.files[0].filename).toBe('srv.rsa.key');
expect(decode(rsa.files[0])).toContain('BEGIN RSA PRIVATE KEY');
const ec = buildOutput({
content: 'key',
format: 'traditional',
keyPem: ecKey,
baseName: 'srv',
});
expect(ec.files[0].filename).toBe('srv.ec.key');
expect(decode(ec.files[0])).toContain('BEGIN EC PRIVATE KEY');
const enc = buildOutput({
content: 'key',
format: 'traditional',
keyPem: ecKey,
password: PASSWORD,
});
expect(decode(enc.files[0])).toContain('Proc-Type: 4,ENCRYPTED');
});
it('DER: <base>.key.der, mit Passwort verschluesselt', () => {
const plain = buildOutput({ content: 'key', format: 'pkcs8-der', keyPem: ecKey });
expect(plain.files[0].filename).toBe('schluessel.key.der');
expect(plain.files[0].mimeType).toBe('application/octet-stream');
expect(
createPrivateKey({ key: bytes(plain.files[0]), format: 'der', type: 'pkcs8' }),
).toBeTruthy();
const enc = buildOutput({
content: 'key',
format: 'pkcs8-der',
keyPem: ecKey,
password: PASSWORD,
});
expect(() =>
createPrivateKey({ key: bytes(enc.files[0]), format: 'der', type: 'pkcs8' }),
).toThrow();
expect(
createPrivateKey({
key: bytes(enc.files[0]),
format: 'der',
type: 'pkcs8',
passphrase: PASSWORD,
}),
).toBeTruthy();
});
it('ohne Schluessel: 400 keyMissing; klassisch fuer Ed25519: formatNotPossible', () => {
expect(codeOf(() => buildOutput({ content: 'key', format: 'pkcs8' }))).toEqual({
status: 400,
code: 'keyMissing',
});
const ed = generateKeyPairSync('ed25519').privateKey.export({
type: 'pkcs8',
format: 'pem',
}) as string;
expect(
codeOf(() => buildOutput({ content: 'key', format: 'traditional', keyPem: ed })),
).toEqual({
status: 400,
code: 'formatNotPossible',
});
});
it('CSR: PEM <base>.csr und DER <base>.csr.der', () => {
const csrPem = fxText('ec-leaf.csr');
const pem = buildOutput({ content: 'csr', format: 'pem', csrPem });
expect(pem.files[0].filename).toBe('ec.example.test.csr');
expect(pem.files[0].mimeType).toBe('application/pkcs10');
expect(decode(pem.files[0])).toContain('-----BEGIN CERTIFICATE REQUEST-----');
const der = buildOutput({ content: 'csr', format: 'der', csrPem, baseName: 'srv' });
expect(der.files[0].filename).toBe('srv.csr.der');
const read = detectBlob(bytes(der.files[0]), { file: 0, path: 'x', passwords: [] });
expect(read.items[0].kind).toBe('csr');
expect((read.items[0] as CsrItem).keyType).toBe('EC');
});
it('CSR ohne csrPem: 400 invalidInput; Text, der keine Anfrage ist: 400 invalidInput', () => {
expect(codeOf(() => buildOutput({ content: 'csr', format: 'pem' }))).toEqual({
status: 400,
code: 'invalidInput',
});
expect(codeOf(() => buildOutput({ content: 'csr', format: 'pem', csrPem: 'x' }))).toEqual({
status: 400,
code: 'invalidInput',
});
});
});
describe('buildOutput: ungueltige Paare', () => {
it.each([
['leaf', 'p12'],
['fullchain', 'der'],
['key', 'pem'],
['csr', 'p7b'],
['leafKey', 'der'],
['unbekannt', 'pem'],
])('%s mit Format %s: 400 invalidInput', (content, format) => {
expect(
codeOf(() =>
buildOutput({
content: content as never,
format,
certPem: fxText('rsa-leaf.pem'),
keyPem: fxText('rsa-leaf-key.pem'),
csrPem: fxText('rsa-leaf.csr'),
}),
),
).toEqual({ status: 400, code: 'invalidInput' });
});
});
+193 -20
View File
@@ -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 };
}
+185 -3
View File
@@ -1,10 +1,11 @@
import { X509Certificate } from 'node:crypto';
import { createPrivateKey, X509Certificate } from 'node:crypto';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { describe, expect, it } from 'vitest';
import * as forge from 'node-forge';
import { describe, expect, it, vi } from 'vitest';
import { candidatePasswords, keyIdOf } from './cert-keys';
import { detectBlob } from './cert-model';
import { isPkcs12Der, readPkcs12 } from './cert-pkcs12';
import { isPkcs12Der, readPkcs12, writePkcs12 } from './cert-pkcs12';
import type { CertItem, KeyItem } from './cert-types';
const fx = (name: string) => readFileSync(join(__dirname, '__fixtures__', name));
@@ -110,3 +111,184 @@ describe('detectBlob: PKCS#12', () => {
expect(JSON.stringify(detect('rsa-modern.pfx', PASSWORD))).not.toContain(PASSWORD);
});
});
describe('writePkcs12', () => {
const NEW_PASSWORD = 'Neu-Pass-2026';
const OID_3DES = '1.2.840.113549.1.12.1.3';
const OID_PBES2 = '1.2.840.113549.1.5.13';
const OID_AES256 = '2.16.840.1.101.3.4.1.42';
const OID_LOCAL_KEY_ID = '1.2.840.113549.1.9.21';
const asn1Of = (der: Buffer) =>
forge.asn1.fromDer(forge.util.createBuffer(der.toString('binary')), {
strict: false,
} as unknown as boolean);
const kids = (n: forge.asn1.Asn1) => n.value as forge.asn1.Asn1[];
const oidOf = (n: forge.asn1.Asn1) => forge.asn1.derToOid(n.value as string);
/** Alle SafeBags im Container (Zertifikate unverschluesselt, Schluessel im selben Aufbau). */
function safeBags(der: Buffer): forge.asn1.Asn1[] {
const authSafe = kids(kids(asn1Of(der))[1])[1]; // ContentInfo -> [0] explicit
const inner = kids(authSafe)[0]; // OCTET STRING mit AuthenticatedSafe
const contentInfos = kids(forge.asn1.fromDer(forge.util.createBuffer(inner.value as string)));
const bags: forge.asn1.Asn1[] = [];
for (const ci of contentInfos) {
const octets = kids(kids(ci)[1])[0].value as string;
bags.push(...kids(forge.asn1.fromDer(forge.util.createBuffer(octets))));
}
return bags;
}
const keyBagOf = (der: Buffer) =>
safeBags(der).find(
(b) => oidOf(kids(b)[0]) === forge.pki.oids.pkcs8ShroudedKeyBag,
) as forge.asn1.Asn1;
function localKeyId(bag: forge.asn1.Asn1): string {
const attrs = kids(bag)[2];
for (const attr of kids(attrs)) {
if (oidOf(kids(attr)[0]) === OID_LOCAL_KEY_ID) {
return Buffer.from(kids(kids(attr)[1])[0].value as string, 'binary').toString('hex');
}
}
return '';
}
const certDers = (names: string[]) => names.map((n) => new X509Certificate(fx(n)).raw);
it.each([
['rsa', 'rsa-leaf-key.pem', ['rsa-leaf.pem', 'rsa-inter.pem']],
['ec', 'ec-leaf-key.pem', ['ec-leaf.pem', 'ec-inter.pem']],
] as const)('%s: Round-Trip ueber readPkcs12 in beiden Profilen', (_type, keyFile, certFiles) => {
for (const profile of ['compat', 'modern'] as const) {
const der = writePkcs12({
keyObject: createPrivateKey(fx(keyFile)),
certDers: certDers([...certFiles]),
password: NEW_PASSWORD,
profile,
friendlyName: 'server',
});
expect(isPkcs12Der(der)).toBe(true);
const read = readPkcs12(der, [], NEW_PASSWORD);
expect(read.ok).toBe(true);
if (!read.ok) return;
const fingerprints = read.contents.certDers.map((d) => new X509Certificate(d).fingerprint256);
expect(fingerprints).toEqual(certFiles.map(sha)); // Serverzertifikat zuerst
expect(read.contents.keys).toHaveLength(1);
expect(new X509Certificate(fx(certFiles[0])).checkPrivateKey(read.contents.keys[0].key)).toBe(
true,
);
// falsches Passwort oeffnet nicht
expect(readPkcs12(der, [], 'falsch')).toMatchObject({ ok: false, reason: 'passwordWrong' });
}
});
it('kompatibel: Schluesselbeutel mit pbeWithSHA1And3-KeyTripleDES-CBC', () => {
const der = writePkcs12({
keyObject: createPrivateKey(fx('ec-leaf-key.pem')),
certDers: certDers(['ec-leaf.pem']),
password: NEW_PASSWORD,
profile: 'compat',
friendlyName: 'server',
});
const algorithm = kids(kids(kids(keyBagOf(der))[1])[0])[0];
expect(oidOf(kids(algorithm)[0])).toBe(OID_3DES);
});
it('modern: Schluesselbeutel mit PBES2 und AES-256-CBC', () => {
const der = writePkcs12({
keyObject: createPrivateKey(fx('rsa-leaf-key.pem')),
certDers: certDers(['rsa-leaf.pem']),
password: NEW_PASSWORD,
profile: 'modern',
friendlyName: 'server',
});
const algorithm = kids(kids(kids(keyBagOf(der))[1])[0])[0];
expect(oidOf(kids(algorithm)[0])).toBe(OID_PBES2);
// PBES2-Parameter: { keyDerivation, encryptionScheme { AES-256-CBC, iv } }
const encryptionScheme = kids(kids(algorithm)[1])[1];
expect(oidOf(kids(encryptionScheme)[0])).toBe(OID_AES256);
});
it('Schluesselbeutel und Serverzertifikat tragen dieselbe localKeyId', () => {
const der = writePkcs12({
keyObject: createPrivateKey(fx('ec-leaf-key.pem')),
certDers: certDers(['ec-leaf.pem', 'ec-inter.pem']),
password: NEW_PASSWORD,
profile: 'compat',
friendlyName: 'server',
});
const bags = safeBags(der);
const certBags = bags.filter((b) => oidOf(kids(b)[0]) === forge.pki.oids.certBag);
expect(certBags).toHaveLength(2);
const keyId = localKeyId(keyBagOf(der));
expect(keyId).toMatch(/^[0-9a-f]{40}$/);
expect(localKeyId(certBags[0])).toBe(keyId);
});
it('ohne Schluessel: nur Zertifikate', () => {
const der = writePkcs12({
keyObject: null,
certDers: certDers(['rsa-leaf.pem', 'rsa-inter.pem']),
password: NEW_PASSWORD,
profile: 'compat',
friendlyName: 'server',
});
const read = readPkcs12(der, [], NEW_PASSWORD);
expect(read.ok && read.contents.certDers).toHaveLength(2);
expect(read.ok && read.contents.keys).toHaveLength(0);
});
it('forge ist danach unveraendert (nach Erfolg und nach einem Fehler mittendrin)', () => {
const pki = forge.pki as unknown as Record<string, unknown>;
const before = {
privateKeyToAsn1: pki.privateKeyToAsn1,
wrapRsaPrivateKey: pki.wrapRsaPrivateKey,
certificateToAsn1: pki.certificateToAsn1,
};
writePkcs12({
keyObject: createPrivateKey(fx('rsa-leaf-key.pem')),
certDers: certDers(['rsa-leaf.pem']),
password: NEW_PASSWORD,
profile: 'compat',
friendlyName: 'server',
});
expect(pki.privateKeyToAsn1).toBe(before.privateKeyToAsn1);
expect(pki.wrapRsaPrivateKey).toBe(before.wrapRsaPrivateKey);
expect(pki.certificateToAsn1).toBe(before.certificateToAsn1);
// forge scheitert mitten im Schreiben, waehrend die Durchreicher eingesetzt sind
let seenDuring: unknown;
const spy = vi.spyOn(forge.pkcs12, 'toPkcs12Asn1').mockImplementation(() => {
seenDuring = pki.certificateToAsn1;
throw new Error('boom');
});
try {
expect(() =>
writePkcs12({
keyObject: null,
certDers: certDers(['rsa-leaf.pem']),
password: NEW_PASSWORD,
profile: 'compat',
friendlyName: 'server',
}),
).toThrow('boom');
} finally {
spy.mockRestore();
}
expect(seenDuring).not.toBe(before.certificateToAsn1); // der Durchreicher war wirklich eingesetzt
expect(pki.privateKeyToAsn1).toBe(before.privateKeyToAsn1);
expect(pki.wrapRsaPrivateKey).toBe(before.wrapRsaPrivateKey);
expect(pki.certificateToAsn1).toBe(before.certificateToAsn1);
});
it('das Passwort steht nicht im Container', () => {
const der = writePkcs12({
keyObject: createPrivateKey(fx('rsa-leaf-key.pem')),
certDers: certDers(['rsa-leaf.pem']),
password: NEW_PASSWORD,
profile: 'modern',
friendlyName: 'server',
});
expect(der.includes(Buffer.from(NEW_PASSWORD))).toBe(false);
});
});
+72
View File
@@ -122,3 +122,75 @@ export function readPkcs12(der: Buffer, passwords: string[], ownPassword = ''):
if (!passwordProblem) throw lastError;
return { ok: false, reason: ownPassword ? 'passwordWrong' : 'passwordNeeded' };
}
export type Pkcs12Profile = 'compat' | 'modern';
export interface WritePkcs12Options {
/** Schluessel zum Serverzertifikat; ohne Schluessel entsteht ein Container nur mit Zertifikaten */
keyObject: KeyObject | null;
/** Zertifikate als DER, Serverzertifikat zuerst */
certDers: Buffer[];
password: string;
/** compat = 3DES/SHA-1 (auch aeltere Windows-Server), modern = AES-256 */
profile: Pkcs12Profile;
friendlyName: string;
}
/** Fuer BMPString (UTF-16, big endian) als forge-Bytefolge. */
function bmpString(text: string): string {
return Buffer.from(text, 'utf16le').swap16().toString('binary');
}
/**
* Schreibt einen PKCS#12-Container (D-07, D-20).
*
* forge schreibt von sich aus nur RSA: `toPkcs12Asn1` ruft `pki.privateKeyToAsn1`,
* `pki.wrapRsaPrivateKey` und `pki.certificateToAsn1` auf RSA-Objekten auf. Damit auch EC-Zertifikate
* und -Schluessel hinein koennen, werden diese drei Funktionen fuer die Dauer EINES synchronen
* Aufrufs durch Durchreicher ersetzt: sie geben das vorbereitete ASN.1 (Zertifikat aus dem DER,
* Schluessel als PKCS#8 aus node:crypto) unveraendert zurueck. Die Verschluesselung (PBE, MAC)
* macht weiterhin forge selbst. Die Originale stehen im `finally` wieder an ihrem Platz; weil Node
* einfaedig ist und der Aufruf synchron laeuft, kann kein anderer Aufrufer den Austausch sehen.
*
* compat = `3des` (pbeWithSHA1And3-KeyTripleDES-CBC, HMAC-SHA1), Windows Server 2016 und aelter lesen es.
* modern = `aes256` (PBES2/PBKDF2/AES-256-CBC fuer den Schluessel; forge bleibt beim SHA-1-MAC).
*/
export function writePkcs12(options: WritePkcs12Options): Buffer {
if (!options.password) throw new Error('A password is required');
const parse = (der: Buffer) =>
forge.asn1.fromDer(forge.util.createBuffer(der.toString('binary')), {
decodeBitStrings: false,
} as unknown as boolean);
const keyAsn1 = options.keyObject
? parse(options.keyObject.export({ type: 'pkcs8', format: 'der' }))
: null;
const certs = options.certDers.map((der) => ({ asn1: parse(der) }));
// `forge.pki` ist ein gewoehnliches, beschreibbares Objekt; die Typen kennen die Zuweisung nicht.
const pki = forge.pki as unknown as Record<string, unknown>;
const original = {
privateKeyToAsn1: pki.privateKeyToAsn1,
wrapRsaPrivateKey: pki.wrapRsaPrivateKey,
certificateToAsn1: pki.certificateToAsn1,
};
try {
pki.privateKeyToAsn1 = (wrapped: { asn1: forge.asn1.Asn1 }) => wrapped.asn1;
pki.wrapRsaPrivateKey = (asn1: forge.asn1.Asn1) => asn1;
pki.certificateToAsn1 = (wrapped: { asn1: forge.asn1.Asn1 }) => wrapped.asn1;
const p12 = forge.pkcs12.toPkcs12Asn1(
(keyAsn1 ? { asn1: keyAsn1 } : null) as unknown as forge.pki.rsa.PrivateKey | null,
certs as unknown as forge.pki.Certificate[],
options.password,
{
algorithm: options.profile === 'modern' ? 'aes256' : '3des',
friendlyName: bmpString(options.friendlyName),
generateLocalKeyId: true,
},
);
return toBuffer(p12);
} finally {
pki.privateKeyToAsn1 = original.privateKeyToAsn1;
pki.wrapRsaPrivateKey = original.wrapRsaPrivateKey;
pki.certificateToAsn1 = original.certificateToAsn1;
}
}
@@ -16,16 +16,33 @@ import {
* + password 256 + baseName 120 + JSON-Maskierung der Zeilenumbrueche (etwa +1,6 %)
* = rund 384 kB, also deutlich unter dem Grenzwert von 512 KiB.
*
* Task 2 kennt nur Fullchain und Nur Kette als PEM; weitere Inhalte, Formate und Felder
* (Schluessel, Passwort, Vorlage) folgen in Task 5 und 6.
* Stand Task 5: alle Inhalte und Formate aus D-19; die Vorlage (template) folgt in Task 6.
*/
export const CERT_PEM_MAX = 16_384;
export const CERT_POOL_MAX = 20;
export const CERT_PASSWORD_MAX = 256;
export const CERT_BASENAME_MAX = 120;
export const BUILD_CONTENTS = ['fullchain', 'chain'] as const;
export const BUILD_FORMATS = ['pem'] as const;
export const BUILD_CONTENTS = [
'leaf',
'fullchain',
'chain',
'leafKey',
'pfx',
'key',
'csr',
] as const;
export const BUILD_FORMATS = [
'pem',
'der',
'p7b',
'p7c',
'pfx',
'pkcs8',
'traditional',
'pkcs8-der',
] as const;
export const BUILD_PFX_ENCRYPTIONS = ['compat', 'modern'] as const;
export class BuildOutputDto {
@IsIn(BUILD_CONTENTS)
@@ -47,10 +64,33 @@ export class BuildOutputDto {
@MaxLength(CERT_PEM_MAX, { each: true })
poolPems?: string[];
@IsOptional()
@IsString()
@MaxLength(CERT_PEM_MAX)
keyPem?: string;
@IsOptional()
@IsString()
@MaxLength(CERT_PEM_MAX)
csrPem?: string;
@IsOptional()
@IsBoolean()
includeRoot?: boolean;
@IsOptional()
@IsBoolean()
includeChain?: boolean;
@IsOptional()
@IsString()
@MaxLength(CERT_PASSWORD_MAX)
password?: string;
@IsOptional()
@IsIn(BUILD_PFX_ENCRYPTIONS)
pfxEncryption?: (typeof BUILD_PFX_ENCRYPTIONS)[number];
@IsOptional()
@IsString()
@MaxLength(CERT_BASENAME_MAX)