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,8 +1,8 @@
|
|||||||
import { createPrivateKey, createPublicKey } from 'node:crypto';
|
import { createPrivateKey, createPublicKey, generateKeyPairSync } from 'node:crypto';
|
||||||
import { readFileSync } from 'node:fs';
|
import { readFileSync } from 'node:fs';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { candidatePasswords, keyIdOf, MAX_PASSWORDS } from './cert-keys';
|
import { candidatePasswords, exportKey, keyIdOf, MAX_PASSWORDS } from './cert-keys';
|
||||||
import { detectBlob } from './cert-model';
|
import { detectBlob } from './cert-model';
|
||||||
import type { KeyItem } from './cert-types';
|
import type { KeyItem } from './cert-types';
|
||||||
|
|
||||||
@@ -167,3 +167,75 @@ describe('candidatePasswords', () => {
|
|||||||
expect(out[0]).toBe('eigen');
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,6 +1,12 @@
|
|||||||
import { createHash, createPrivateKey, createPublicKey, type KeyObject } from 'node:crypto';
|
import { createHash, createPrivateKey, createPublicKey, type KeyObject } from 'node:crypto';
|
||||||
import * as forge from 'node-forge';
|
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).
|
* 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;
|
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 'reflect-metadata';
|
||||||
import { readFileSync } from 'node:fs';
|
import { readFileSync } from 'node:fs';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import { RequestMethod } from '@nestjs/common';
|
import { RequestMethod, ValidationPipe } from '@nestjs/common';
|
||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { MODULE_SLUG_KEY } from '../module-registry/module.guard';
|
import { MODULE_SLUG_KEY } from '../module-registry/module.guard';
|
||||||
import { CertManagerController, parsePasswords, repairFileName } from './cert-manager.controller';
|
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 fx = (name: string) => readFileSync(join(__dirname, '__fixtures__', name));
|
||||||
const upload = (name: string, buffer: Buffer = fx(name)) => ({
|
const upload = (name: string, buffer: Buffer = fx(name)) => ({
|
||||||
@@ -140,3 +141,55 @@ describe('repairFileName', () => {
|
|||||||
expect(repairFileName('M\xfcller.pem')).toBe('M\xfcller.pem');
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
import { X509Certificate } from 'node:crypto';
|
import { createPrivateKey, generateKeyPairSync, X509Certificate } from 'node:crypto';
|
||||||
import { readFileSync } from 'node:fs';
|
import { readFileSync } from 'node:fs';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { detectBlob } from './cert-model';
|
||||||
import { buildOutput, safeBaseName } from './cert-output';
|
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');
|
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');
|
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' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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 { buildChains } from './cert-chain';
|
||||||
|
import { csrItemFromDer } from './cert-csr';
|
||||||
|
import { exportKey, type KeyExportFormat } from './cert-keys';
|
||||||
import { certItemFromDer } from './cert-model';
|
import { certItemFromDer } from './cert-model';
|
||||||
import { safeBaseName } from './cert-names';
|
import { safeBaseName } from './cert-names';
|
||||||
|
import { writePkcs12 } from './cert-pkcs12';
|
||||||
import {
|
import {
|
||||||
|
type BuildContent,
|
||||||
type BuildFile,
|
type BuildFile,
|
||||||
type BuildInput,
|
type BuildInput,
|
||||||
type BuildResult,
|
type BuildResult,
|
||||||
type CertItem,
|
type CertItem,
|
||||||
|
type CsrItem,
|
||||||
certError,
|
certError,
|
||||||
} from './cert-types';
|
} from './cert-types';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ausgabe-Bausteine des Zertifikat-Managers (quick-261009-ikt, D-19).
|
* 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
|
* Jeder Inhalt in jedem Format laeuft durch die eine Funktion buildOutput. Die Reihenfolge baut die API
|
||||||
* dieselbe Funktion. Die Reihenfolge baut die API immer selbst aus den gesendeten Zertifikaten
|
* immer selbst aus den gesendeten Zertifikaten (buildChains); eine vom Browser mitgeschickte Reihenfolge
|
||||||
* (buildChains); eine vom Browser mitgeschickte Reihenfolge wird nie uebernommen.
|
* wird nie uebernommen. Schluessel und Passwoerter werden nur verarbeitet, nie gespeichert oder
|
||||||
|
* protokolliert, und erscheinen in keiner Fehlermeldung.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export { safeBaseName };
|
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 {
|
function parseCertificate(pem: unknown): CertItem {
|
||||||
if (typeof pem !== 'string' || pem.trim() === '') {
|
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. */
|
/** PEM-Bloecke in Kettenreihenfolge, jeder genau einmal mit abschliessendem Zeilenumbruch. */
|
||||||
function joinPem(certs: CertItem[]): string {
|
function joinPem(certs: CertItem[]): string {
|
||||||
return certs.map((c) => `${c.pem.trim()}\n`).join('');
|
return certs.map((c) => `${c.pem.trim()}\n`).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
function pemFile(filename: string, certs: CertItem[]): BuildFile {
|
function derOf(cert: CertItem): Buffer {
|
||||||
return {
|
return new X509Certificate(cert.pem).raw;
|
||||||
filename,
|
}
|
||||||
content: Buffer.from(joinPem(certs), 'utf8').toString('base64'),
|
|
||||||
mimeType: PEM_MIME,
|
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. */
|
/** Baut die gewuenschte Ausgabe. Wirft Nest-Ausnahmen mit Code (D-24); nie mit Passwort oder Schluessel im Text. */
|
||||||
export function buildOutput(input: BuildInput): BuildResult {
|
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');
|
if (!input.certPem) certError('invalidInput', 400, 'certPem is required');
|
||||||
const head = parseCertificate(input.certPem);
|
const head = parseCertificate(input.certPem);
|
||||||
const pool = (input.poolPems ?? []).map(parseCertificate);
|
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 path = chain.path.map((id) => byId.get(id) as CertItem);
|
||||||
const includeRoot = input.includeRoot === true;
|
const includeRoot = input.includeRoot === true;
|
||||||
const withoutRoot = path.filter((c) => !(c.role === 'root' && c.id !== head.id));
|
const withoutRoot = path.filter((c) => !(c.role === 'root' && c.id !== head.id));
|
||||||
|
const shown = includeRoot ? path : withoutRoot;
|
||||||
const base = safeBaseName(input.baseName ?? '', head.baseName);
|
const base = safeBaseName(input.baseName ?? '', head.baseName);
|
||||||
|
|
||||||
let files: BuildFile[];
|
let files: BuildFile[];
|
||||||
if (input.content === 'fullchain') {
|
switch (input.content) {
|
||||||
files = [pemFile(`${base}-fullchain.pem`, includeRoot ? path : withoutRoot)];
|
case 'leaf':
|
||||||
} else if (input.content === 'chain') {
|
if (format === 'der') files = [file(`${base}.cer`, derOf(head), MIME.der)];
|
||||||
const issuers = (includeRoot ? path : withoutRoot).filter((c) => c.id !== head.id);
|
else files = [certListFile(base, `${base}.crt`, format, [head])];
|
||||||
if (issuers.length === 0)
|
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');
|
certError('noChain', 400, 'No intermediate or root certificate available');
|
||||||
files = [pemFile(`${base}-chain.pem`, issuers)];
|
}
|
||||||
} else {
|
files = [certListFile(`${base}-chain`, `${base}-chain.pem`, format, issuers)];
|
||||||
return certError('formatNotPossible', 400, 'Output not available');
|
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 {
|
return {
|
||||||
@@ -80,3 +218,38 @@ export function buildOutput(input: BuildInput): BuildResult {
|
|||||||
missingIssuerCn: chain.gap?.missingIssuerCn ?? null,
|
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 };
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { X509Certificate } from 'node:crypto';
|
import { createPrivateKey, X509Certificate } from 'node:crypto';
|
||||||
import { readFileSync } from 'node:fs';
|
import { readFileSync } from 'node:fs';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
import { describe, expect, it } from 'vitest';
|
import * as forge from 'node-forge';
|
||||||
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
import { candidatePasswords, keyIdOf } from './cert-keys';
|
import { candidatePasswords, keyIdOf } from './cert-keys';
|
||||||
import { detectBlob } from './cert-model';
|
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';
|
import type { CertItem, KeyItem } from './cert-types';
|
||||||
|
|
||||||
const fx = (name: string) => readFileSync(join(__dirname, '__fixtures__', name));
|
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);
|
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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -122,3 +122,75 @@ export function readPkcs12(der: Buffer, passwords: string[], ownPassword = ''):
|
|||||||
if (!passwordProblem) throw lastError;
|
if (!passwordProblem) throw lastError;
|
||||||
return { ok: false, reason: ownPassword ? 'passwordWrong' : 'passwordNeeded' };
|
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 %)
|
* + password 256 + baseName 120 + JSON-Maskierung der Zeilenumbrueche (etwa +1,6 %)
|
||||||
* = rund 384 kB, also deutlich unter dem Grenzwert von 512 KiB.
|
* = 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
|
* Stand Task 5: alle Inhalte und Formate aus D-19; die Vorlage (template) folgt in Task 6.
|
||||||
* (Schluessel, Passwort, Vorlage) folgen in Task 5 und 6.
|
|
||||||
*/
|
*/
|
||||||
export const CERT_PEM_MAX = 16_384;
|
export const CERT_PEM_MAX = 16_384;
|
||||||
export const CERT_POOL_MAX = 20;
|
export const CERT_POOL_MAX = 20;
|
||||||
export const CERT_PASSWORD_MAX = 256;
|
export const CERT_PASSWORD_MAX = 256;
|
||||||
export const CERT_BASENAME_MAX = 120;
|
export const CERT_BASENAME_MAX = 120;
|
||||||
|
|
||||||
export const BUILD_CONTENTS = ['fullchain', 'chain'] as const;
|
export const BUILD_CONTENTS = [
|
||||||
export const BUILD_FORMATS = ['pem'] as const;
|
'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 {
|
export class BuildOutputDto {
|
||||||
@IsIn(BUILD_CONTENTS)
|
@IsIn(BUILD_CONTENTS)
|
||||||
@@ -47,10 +64,33 @@ export class BuildOutputDto {
|
|||||||
@MaxLength(CERT_PEM_MAX, { each: true })
|
@MaxLength(CERT_PEM_MAX, { each: true })
|
||||||
poolPems?: string[];
|
poolPems?: string[];
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(CERT_PEM_MAX)
|
||||||
|
keyPem?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(CERT_PEM_MAX)
|
||||||
|
csrPem?: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
includeRoot?: boolean;
|
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()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
@MaxLength(CERT_BASENAME_MAX)
|
@MaxLength(CERT_BASENAME_MAX)
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ beforeEach(() => {
|
|||||||
afterEach(cleanup);
|
afterEach(cleanup);
|
||||||
|
|
||||||
describe('CertManagerPage', () => {
|
describe('CertManagerPage', () => {
|
||||||
it('zeigt Titel und die Reiter in der Reihenfolge Dateien, Analysieren, Aufteilen, Zusammenführen', () => {
|
it('zeigt Titel und die Reiter in der Reihenfolge Dateien, Analysieren, Aufteilen, Zusammenführen, Konvertieren', () => {
|
||||||
render(<CertManagerPage />);
|
render(<CertManagerPage />);
|
||||||
expect(screen.getByRole('heading', { name: 'Zertifikat-Manager' })).toBeInTheDocument();
|
expect(screen.getByRole('heading', { name: 'Zertifikat-Manager' })).toBeInTheDocument();
|
||||||
const nav = screen.getByRole('navigation');
|
const nav = screen.getByRole('navigation');
|
||||||
@@ -53,6 +53,7 @@ describe('CertManagerPage', () => {
|
|||||||
'Analysieren',
|
'Analysieren',
|
||||||
'Aufteilen',
|
'Aufteilen',
|
||||||
'Zusammenführen',
|
'Zusammenführen',
|
||||||
|
'Konvertieren',
|
||||||
]);
|
]);
|
||||||
expect(tabs[0]).toHaveAttribute('aria-current', 'page');
|
expect(tabs[0]).toHaveAttribute('aria-current', 'page');
|
||||||
for (const tab of tabs.slice(1)) expect(tab).not.toHaveAttribute('aria-current');
|
for (const tab of tabs.slice(1)) expect(tab).not.toHaveAttribute('aria-current');
|
||||||
@@ -92,6 +93,7 @@ describe('CertManagerPage', () => {
|
|||||||
it.each([
|
it.each([
|
||||||
'Analysieren',
|
'Analysieren',
|
||||||
'Aufteilen',
|
'Aufteilen',
|
||||||
|
'Konvertieren',
|
||||||
])('%s mit leerem Arbeitsbereich: Hinweis auf den Reiter „Dateien“', (tab) => {
|
])('%s mit leerem Arbeitsbereich: Hinweis auf den Reiter „Dateien“', (tab) => {
|
||||||
render(<CertManagerPage />);
|
render(<CertManagerPage />);
|
||||||
fireEvent.click(screen.getByRole('button', { name: tab }));
|
fireEvent.click(screen.getByRole('button', { name: tab }));
|
||||||
|
|||||||
@@ -0,0 +1,212 @@
|
|||||||
|
import { cleanup, fireEvent, render as rtlRender, screen, waitFor } from '@testing-library/react';
|
||||||
|
import { NextIntlClientProvider } from 'next-intl';
|
||||||
|
import type { ReactElement } from 'react';
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import de from '@/messages/de.json';
|
||||||
|
import type { AnalysisResult, AnyItem, CertItem, CsrItem, KeyItem } from '../actions';
|
||||||
|
import { CertManagerRequestError } from '../actions';
|
||||||
|
import type { CertWorkspace } from '../use-cert-workspace';
|
||||||
|
import { ConvertTab } from './ConvertTab';
|
||||||
|
|
||||||
|
const mockBuild = vi.fn();
|
||||||
|
const mockDownload = vi.fn();
|
||||||
|
|
||||||
|
vi.mock('../actions', async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import('../actions')>();
|
||||||
|
return {
|
||||||
|
...actual,
|
||||||
|
buildOutput: (...args: unknown[]) => mockBuild(...args),
|
||||||
|
downloadBase64: (...args: unknown[]) => mockDownload(...args),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
function render(ui: ReactElement) {
|
||||||
|
return rtlRender(
|
||||||
|
<NextIntlClientProvider locale="de" messages={de} timeZone="Europe/Berlin">
|
||||||
|
{ui}
|
||||||
|
</NextIntlClientProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const leaf: CertItem = {
|
||||||
|
id: 'c-leaf',
|
||||||
|
kind: 'certificate',
|
||||||
|
role: 'end-entity',
|
||||||
|
sources: [{ file: 0, path: 'leaf.pem' }],
|
||||||
|
pem: 'PEM-LEAF',
|
||||||
|
baseName: 'www.example.test',
|
||||||
|
cn: 'www.example.test',
|
||||||
|
organization: '',
|
||||||
|
issuerCn: 'Test Inter',
|
||||||
|
issuerOrganization: '',
|
||||||
|
notBefore: '2026-01-01T00:00:00.000Z',
|
||||||
|
notAfter: '2126-01-01T00:00:00.000Z',
|
||||||
|
isExpired: false,
|
||||||
|
daysLeft: 36000,
|
||||||
|
san: [],
|
||||||
|
keyType: 'RSA',
|
||||||
|
keyBits: 2048,
|
||||||
|
curve: null,
|
||||||
|
serialNumber: '01',
|
||||||
|
sha256: '',
|
||||||
|
sha1: '',
|
||||||
|
isCa: false,
|
||||||
|
selfSigned: false,
|
||||||
|
aiaIssuerUrls: [],
|
||||||
|
keyId: 'k-1',
|
||||||
|
csrIds: [],
|
||||||
|
};
|
||||||
|
const key: KeyItem = {
|
||||||
|
id: 'k-1',
|
||||||
|
kind: 'privateKey',
|
||||||
|
sources: [{ file: 1, path: 'leaf-key.pem' }],
|
||||||
|
pem: 'PEM-KEY',
|
||||||
|
baseName: 'schluessel',
|
||||||
|
keyType: 'RSA',
|
||||||
|
keyBits: 2048,
|
||||||
|
curve: null,
|
||||||
|
wasEncrypted: false,
|
||||||
|
certIds: ['c-leaf'],
|
||||||
|
};
|
||||||
|
const csr: CsrItem = {
|
||||||
|
id: 'r-1',
|
||||||
|
kind: 'csr',
|
||||||
|
sources: [{ file: 2, path: 'leaf.csr' }],
|
||||||
|
pem: 'PEM-CSR',
|
||||||
|
baseName: 'anfrage-www',
|
||||||
|
cn: 'www.example.test',
|
||||||
|
organization: '',
|
||||||
|
san: [],
|
||||||
|
keyType: 'RSA',
|
||||||
|
keyBits: 2048,
|
||||||
|
curve: null,
|
||||||
|
keyId: 'k-1',
|
||||||
|
certIds: ['c-leaf'],
|
||||||
|
};
|
||||||
|
|
||||||
|
function workspace(items: AnyItem[]): CertWorkspace {
|
||||||
|
const analysis: AnalysisResult = { items, chains: [], locked: [], ignored: [] };
|
||||||
|
return {
|
||||||
|
entries: [],
|
||||||
|
analysis,
|
||||||
|
analysisIds: [],
|
||||||
|
status: 'idle',
|
||||||
|
errorKey: null,
|
||||||
|
addFiles: () => [],
|
||||||
|
addText: () => null,
|
||||||
|
setPassword: () => {},
|
||||||
|
remove: () => {},
|
||||||
|
clear: () => {},
|
||||||
|
retry: () => {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockBuild.mockReset();
|
||||||
|
mockDownload.mockReset();
|
||||||
|
mockBuild.mockResolvedValue({
|
||||||
|
files: [{ filename: 'out.cer', content: 'QQ==', mimeType: 'application/pkix-cert' }],
|
||||||
|
chainComplete: true,
|
||||||
|
missingIssuerCn: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
const select = (name: string) => screen.getByRole('combobox', { name });
|
||||||
|
const optionNames = (name: string) =>
|
||||||
|
Array.from(select(name).querySelectorAll('option')).map((o) => o.textContent);
|
||||||
|
|
||||||
|
describe('ConvertTab', () => {
|
||||||
|
it('fasst die Teile nach Art zusammen: Zertifikate, Schluessel, Anfragen', () => {
|
||||||
|
render(<ConvertTab workspace={workspace([leaf, key, csr])} />);
|
||||||
|
const groups = Array.from(
|
||||||
|
select('Was soll umgewandelt werden?').querySelectorAll('optgroup'),
|
||||||
|
).map((g) => g.label);
|
||||||
|
expect(groups).toEqual(['Zertifikate', 'Private Schlüssel', 'Zertifikatsanfragen']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Zertifikat: vier Formate, Download mit genau Inhalt, Format und PEM', async () => {
|
||||||
|
render(<ConvertTab workspace={workspace([leaf, key, csr])} />);
|
||||||
|
expect(optionNames('Zielformat')).toEqual([
|
||||||
|
'PEM (.crt)',
|
||||||
|
'DER (.cer)',
|
||||||
|
'PKCS#7 (.p7b)',
|
||||||
|
'PKCS#7, binär (.p7c)',
|
||||||
|
]);
|
||||||
|
fireEvent.change(select('Zielformat'), { target: { value: 'der' } });
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Herunterladen' }));
|
||||||
|
await waitFor(() => expect(mockDownload).toHaveBeenCalledTimes(1));
|
||||||
|
expect(mockBuild).toHaveBeenCalledWith({ content: 'leaf', format: 'der', certPem: 'PEM-LEAF' });
|
||||||
|
expect(mockDownload).toHaveBeenCalledWith('out.cer', 'QQ==', 'application/pkix-cert');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Schluessel: drei Formate, kein Passwort ohne Haken, Dateiname nach dem Zertifikat', async () => {
|
||||||
|
render(<ConvertTab workspace={workspace([leaf, key])} />);
|
||||||
|
fireEvent.change(select('Was soll umgewandelt werden?'), { target: { value: 'k-1' } });
|
||||||
|
expect(optionNames('Zielformat')).toEqual([
|
||||||
|
'PKCS#8 (.key)',
|
||||||
|
'Traditionell (PKCS#1 für RSA, SEC1 für EC)',
|
||||||
|
'PKCS#8, binär (.key.der)',
|
||||||
|
]);
|
||||||
|
fireEvent.change(select('Zielformat'), { target: { value: 'traditional' } });
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Herunterladen' }));
|
||||||
|
await waitFor(() => expect(mockBuild).toHaveBeenCalled());
|
||||||
|
expect(mockBuild).toHaveBeenCalledWith({
|
||||||
|
content: 'key',
|
||||||
|
format: 'traditional',
|
||||||
|
keyPem: 'PEM-KEY',
|
||||||
|
baseName: 'www.example.test',
|
||||||
|
});
|
||||||
|
expect(mockBuild.mock.calls[0][0]).not.toHaveProperty('password');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Schluessel mit Passwort schuetzen: ohne Passwort gesperrt, mit Passwort gesendet', async () => {
|
||||||
|
render(<ConvertTab workspace={workspace([key])} />);
|
||||||
|
fireEvent.click(screen.getByRole('checkbox', { name: 'Schlüssel mit Passwort schützen' }));
|
||||||
|
const button = screen.getByRole('button', { name: 'Herunterladen' });
|
||||||
|
expect(button).toBeDisabled();
|
||||||
|
fireEvent.change(screen.getByLabelText('Passwort für den Schlüssel'), {
|
||||||
|
target: { value: 'Neu-Pass-2026' },
|
||||||
|
});
|
||||||
|
expect(button).toBeEnabled();
|
||||||
|
fireEvent.click(button);
|
||||||
|
await waitFor(() => expect(mockBuild).toHaveBeenCalled());
|
||||||
|
expect(mockBuild).toHaveBeenCalledWith({
|
||||||
|
content: 'key',
|
||||||
|
format: 'pkcs8',
|
||||||
|
keyPem: 'PEM-KEY',
|
||||||
|
baseName: undefined,
|
||||||
|
password: 'Neu-Pass-2026',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Anfrage: PEM und DER, die Anfrage geht in csrPem', async () => {
|
||||||
|
render(<ConvertTab workspace={workspace([csr])} />);
|
||||||
|
expect(optionNames('Zielformat')).toEqual(['PEM (.csr)', 'DER (.csr.der)']);
|
||||||
|
fireEvent.change(select('Zielformat'), { target: { value: 'der' } });
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Herunterladen' }));
|
||||||
|
await waitFor(() => expect(mockBuild).toHaveBeenCalled());
|
||||||
|
expect(mockBuild).toHaveBeenCalledWith({
|
||||||
|
content: 'csr',
|
||||||
|
format: 'der',
|
||||||
|
csrPem: 'PEM-CSR',
|
||||||
|
baseName: 'anfrage-www',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ein Fehlercode der API zeigt den deutschen Text und laedt nichts herunter', async () => {
|
||||||
|
mockBuild.mockRejectedValueOnce(new CertManagerRequestError(400, 'formatNotPossible'));
|
||||||
|
render(<ConvertTab workspace={workspace([key])} />);
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Herunterladen' }));
|
||||||
|
expect(await screen.findByRole('alert')).toHaveTextContent(
|
||||||
|
'Dieses Format ist für den Schlüsseltyp nicht möglich',
|
||||||
|
);
|
||||||
|
expect(mockDownload).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ohne erkannte Teile steht ein Hinweis', () => {
|
||||||
|
render(<ConvertTab workspace={workspace([])} />);
|
||||||
|
expect(screen.getByText(/noch nichts erkannt/)).toBeInTheDocument();
|
||||||
|
expect(screen.queryByRole('button', { name: 'Herunterladen' })).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import {
|
||||||
|
type AnyItem,
|
||||||
|
buildOutput,
|
||||||
|
type CertItem,
|
||||||
|
type CsrItem,
|
||||||
|
certErrorKey,
|
||||||
|
downloadBase64,
|
||||||
|
type KeyItem,
|
||||||
|
} from '../actions';
|
||||||
|
import type { CertWorkspace } from '../use-cert-workspace';
|
||||||
|
import { PasswordInput } from './PasswordInput';
|
||||||
|
|
||||||
|
interface ConvertTabProps {
|
||||||
|
workspace: CertWorkspace;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Formate je Art; die Kennung geht unveraendert an die API, der Text steht unter convert.formats.<labelKey>. */
|
||||||
|
const FORMATS = {
|
||||||
|
certificate: [
|
||||||
|
{ id: 'pem', labelKey: 'certPem' },
|
||||||
|
{ id: 'der', labelKey: 'certDer' },
|
||||||
|
{ id: 'p7b', labelKey: 'p7b' },
|
||||||
|
{ id: 'p7c', labelKey: 'p7c' },
|
||||||
|
],
|
||||||
|
privateKey: [
|
||||||
|
{ id: 'pkcs8', labelKey: 'pkcs8' },
|
||||||
|
{ id: 'traditional', labelKey: 'traditional' },
|
||||||
|
{ id: 'pkcs8-der', labelKey: 'pkcs8Der' },
|
||||||
|
],
|
||||||
|
csr: [
|
||||||
|
{ id: 'pem', labelKey: 'csrPem' },
|
||||||
|
{ id: 'der', labelKey: 'csrDer' },
|
||||||
|
],
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
function nameOf(item: AnyItem): string {
|
||||||
|
return item.kind === 'privateKey' ? item.baseName : item.cn || item.baseName;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reiter „Konvertieren“: ein einzelnes Teil des Arbeitsbereichs (Zertifikat, privater Schluessel oder
|
||||||
|
* Zertifikatsanfrage) in ein anderes Format umwandeln. Die API baut die Datei; der Schluessel wird nur
|
||||||
|
* fuer diese Anfrage gesendet und nirgends gespeichert. Das Passwort wird nur gesendet, wenn der
|
||||||
|
* Schluessel damit geschuetzt werden soll.
|
||||||
|
*/
|
||||||
|
export function ConvertTab({ workspace }: ConvertTabProps) {
|
||||||
|
const t = useTranslations('certManager');
|
||||||
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||||
|
const [formats, setFormats] = useState<Record<string, string>>({});
|
||||||
|
const [protect, setProtect] = useState(false);
|
||||||
|
const [keyPassword, setKeyPassword] = useState('');
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const [errorKey, setErrorKey] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const items = workspace.analysis?.items ?? [];
|
||||||
|
const certs = items.filter((i): i is CertItem => i.kind === 'certificate');
|
||||||
|
const keys = items.filter((i): i is KeyItem => i.kind === 'privateKey');
|
||||||
|
const csrs = items.filter((i): i is CsrItem => i.kind === 'csr');
|
||||||
|
|
||||||
|
if (!workspace.analysis) {
|
||||||
|
return workspace.status === 'analyzing' ? (
|
||||||
|
<p role="status" className="text-sm text-muted-foreground">
|
||||||
|
{t('files.analyzing')}
|
||||||
|
</p>
|
||||||
|
) : null;
|
||||||
|
}
|
||||||
|
if (items.length === 0) {
|
||||||
|
return <p className="text-sm text-muted-foreground">{t('convert.nothingFound')}</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const item = items.find((i) => i.id === selectedId) ?? items[0];
|
||||||
|
const options = FORMATS[item.kind];
|
||||||
|
const format = options.find((o) => o.id === formats[item.id])?.id ?? options[0].id;
|
||||||
|
const isKey = item.kind === 'privateKey';
|
||||||
|
const needsPassword = isKey && protect;
|
||||||
|
const ready = !needsPassword || keyPassword !== '';
|
||||||
|
|
||||||
|
const keyDescription = (key: KeyItem) =>
|
||||||
|
key.keyType === 'RSA'
|
||||||
|
? t('analyze.keyRsa', { bits: key.keyBits ?? 0 })
|
||||||
|
: key.keyType === 'EC'
|
||||||
|
? t('analyze.keyEc', { curve: key.curve ?? '' })
|
||||||
|
: key.keyType;
|
||||||
|
|
||||||
|
const convert = async () => {
|
||||||
|
setBusy(true);
|
||||||
|
setErrorKey(null);
|
||||||
|
try {
|
||||||
|
let request: Parameters<typeof buildOutput>[0];
|
||||||
|
if (item.kind === 'certificate') {
|
||||||
|
request = { content: 'leaf', format, certPem: item.pem };
|
||||||
|
} else if (item.kind === 'csr') {
|
||||||
|
request = { content: 'csr', format, csrPem: item.pem, baseName: item.baseName };
|
||||||
|
} else {
|
||||||
|
// Dateiname nach dem zugehoerigen Zertifikat, wenn es eines gibt
|
||||||
|
const owner = certs.find((c) => item.certIds.includes(c.id));
|
||||||
|
request = {
|
||||||
|
content: 'key',
|
||||||
|
format,
|
||||||
|
keyPem: item.pem,
|
||||||
|
...(owner ? { baseName: owner.baseName } : {}),
|
||||||
|
...(protect ? { password: keyPassword } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const result = await buildOutput(request);
|
||||||
|
for (const file of result.files) downloadBase64(file.filename, file.content, file.mimeType);
|
||||||
|
} catch (error) {
|
||||||
|
setErrorKey(certErrorKey(error));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-5">
|
||||||
|
<p className="text-sm text-muted-foreground">{t('convert.intro')}</p>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<label htmlFor="cert-convert-item" className="text-sm text-foreground">
|
||||||
|
{t('convert.item')}
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="cert-convert-item"
|
||||||
|
value={item.id}
|
||||||
|
onChange={(e) => setSelectedId(e.target.value)}
|
||||||
|
className="min-w-64 max-w-full rounded border border-border bg-background px-3 py-1.5 text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
>
|
||||||
|
{certs.length > 0 && (
|
||||||
|
<optgroup label={t('convert.groupCertificates')}>
|
||||||
|
{certs.map((c) => (
|
||||||
|
<option key={c.id} value={c.id}>
|
||||||
|
{`${nameOf(c)} (${t(`roles.${c.role}`)})`}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</optgroup>
|
||||||
|
)}
|
||||||
|
{keys.length > 0 && (
|
||||||
|
<optgroup label={t('convert.groupKeys')}>
|
||||||
|
{keys.map((k) => (
|
||||||
|
<option key={k.id} value={k.id}>
|
||||||
|
{`${t('roles.privateKey')} (${keyDescription(k)})`}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</optgroup>
|
||||||
|
)}
|
||||||
|
{csrs.length > 0 && (
|
||||||
|
<optgroup label={t('convert.groupCsrs')}>
|
||||||
|
{csrs.map((r) => (
|
||||||
|
<option key={r.id} value={r.id}>
|
||||||
|
{`${nameOf(r)} (${t('roles.csr')})`}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</optgroup>
|
||||||
|
)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<label htmlFor="cert-convert-format" className="text-sm text-foreground">
|
||||||
|
{t('convert.format')}
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="cert-convert-format"
|
||||||
|
value={format}
|
||||||
|
onChange={(e) => setFormats((f) => ({ ...f, [item.id]: e.target.value }))}
|
||||||
|
className="rounded border border-border bg-background px-3 py-1.5 text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
>
|
||||||
|
{options.map((o) => (
|
||||||
|
<option key={o.id} value={o.id}>
|
||||||
|
{t(`convert.formats.${o.labelKey}`)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isKey && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<p className="text-xs text-muted-foreground">{t('convert.keyNote')}</p>
|
||||||
|
<label className="flex items-center gap-2 text-sm text-foreground">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={protect}
|
||||||
|
onChange={(e) => {
|
||||||
|
setProtect(e.target.checked);
|
||||||
|
if (!e.target.checked) setKeyPassword('');
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{t('convert.protectKey')}
|
||||||
|
</label>
|
||||||
|
{protect && (
|
||||||
|
<PasswordInput
|
||||||
|
label={t('convert.keyPassword')}
|
||||||
|
value={keyPassword}
|
||||||
|
onChange={setKeyPassword}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button type="button" disabled={busy || !ready} onClick={convert} className="btn btn-primary">
|
||||||
|
{busy ? t('convert.busy') : t('convert.download')}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{errorKey && (
|
||||||
|
<p
|
||||||
|
role="alert"
|
||||||
|
className="rounded-lg border border-border bg-muted p-3 text-sm text-foreground"
|
||||||
|
>
|
||||||
|
{t(`errors.${errorKey}`)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@ import { NextIntlClientProvider } from 'next-intl';
|
|||||||
import type { ReactElement } from 'react';
|
import type { ReactElement } from 'react';
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
import de from '@/messages/de.json';
|
import de from '@/messages/de.json';
|
||||||
import type { AnalysisResult, CertItem, ChainInfo } from '../actions';
|
import type { AnalysisResult, CertItem, ChainInfo, KeyItem } from '../actions';
|
||||||
import { CertManagerRequestError } from '../actions';
|
import { CertManagerRequestError } from '../actions';
|
||||||
import type { CertWorkspace } from '../use-cert-workspace';
|
import type { CertWorkspace } from '../use-cert-workspace';
|
||||||
import { MergeTab } from './MergeTab';
|
import { MergeTab } from './MergeTab';
|
||||||
@@ -68,7 +68,7 @@ function chain(over: Partial<ChainInfo> & Pick<ChainInfo, 'headId' | 'path'>): C
|
|||||||
return { rootId: null, complete: false, gap: null, alternatives: 0, ...over };
|
return { rootId: null, complete: false, gap: null, alternatives: 0, ...over };
|
||||||
}
|
}
|
||||||
|
|
||||||
function workspace(items: CertItem[], chains: ChainInfo[]): CertWorkspace {
|
function workspace(items: (CertItem | KeyItem)[], chains: ChainInfo[]): CertWorkspace {
|
||||||
const analysis: AnalysisResult = { items, chains, locked: [], ignored: [] };
|
const analysis: AnalysisResult = { items, chains, locked: [], ignored: [] };
|
||||||
return {
|
return {
|
||||||
entries: [],
|
entries: [],
|
||||||
@@ -218,4 +218,127 @@ describe('MergeTab', () => {
|
|||||||
expect(screen.getByText(/noch kein Zertifikat erkannt/)).toBeInTheDocument();
|
expect(screen.getByText(/noch kein Zertifikat erkannt/)).toBeInTheDocument();
|
||||||
expect(screen.queryByRole('button', { name: 'Fullchain herunterladen' })).toBeNull();
|
expect(screen.queryByRole('button', { name: 'Fullchain herunterladen' })).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('Format der Kette: PEM, PKCS#7 (.p7b) und PKCS#7 binaer (.p7c)', async () => {
|
||||||
|
render(<MergeTab workspace={workspace([leaf, inter, root], [completeChain])} />);
|
||||||
|
const select = screen.getByRole('combobox', { name: 'Format der Kette' });
|
||||||
|
expect(Array.from(select.querySelectorAll('option')).map((o) => o.textContent)).toEqual([
|
||||||
|
'PEM (.pem)',
|
||||||
|
'PKCS#7 (.p7b)',
|
||||||
|
'PKCS#7, binär (.p7c)',
|
||||||
|
]);
|
||||||
|
fireEvent.change(select, { target: { value: 'p7c' } });
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Nur Kette herunterladen' }));
|
||||||
|
await waitFor(() => expect(mockBuild).toHaveBeenCalled());
|
||||||
|
expect(mockBuild.mock.calls[0][0]).toMatchObject({ content: 'chain', format: 'p7c' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('MergeTab: Zertifikat und Schluessel, PFX', () => {
|
||||||
|
const key: KeyItem = {
|
||||||
|
id: 'k-leaf',
|
||||||
|
kind: 'privateKey',
|
||||||
|
sources: [{ file: 1, path: 'key.pem' }],
|
||||||
|
pem: 'PEM-KEY',
|
||||||
|
baseName: 'schluessel',
|
||||||
|
keyType: 'RSA',
|
||||||
|
keyBits: 2048,
|
||||||
|
curve: null,
|
||||||
|
wasEncrypted: false,
|
||||||
|
certIds: ['c-leaf'],
|
||||||
|
};
|
||||||
|
const keyed = { ...leaf, keyId: 'k-leaf' };
|
||||||
|
|
||||||
|
it('ohne passenden Schluessel ist der Bundle-Knopf gesperrt und der Grund steht da', () => {
|
||||||
|
render(<MergeTab workspace={workspace([leaf, inter, root], [completeChain])} />);
|
||||||
|
expect(
|
||||||
|
screen.getByRole('button', { name: 'Zertifikat und Schlüssel (eine PEM-Datei)' }),
|
||||||
|
).toBeDisabled();
|
||||||
|
expect(screen.getByText(/kein passender privater Schlüssel/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('mit Schluessel: ein Aufruf mit leafKey und dem Schluessel', async () => {
|
||||||
|
render(<MergeTab workspace={workspace([keyed, inter, root, key], [completeChain])} />);
|
||||||
|
const button = screen.getByRole('button', {
|
||||||
|
name: 'Zertifikat und Schlüssel (eine PEM-Datei)',
|
||||||
|
});
|
||||||
|
expect(button).toBeEnabled();
|
||||||
|
fireEvent.click(button);
|
||||||
|
await waitFor(() => expect(mockBuild).toHaveBeenCalled());
|
||||||
|
expect(mockBuild).toHaveBeenCalledWith({
|
||||||
|
content: 'leafKey',
|
||||||
|
format: 'pem',
|
||||||
|
certPem: 'PEM-c-leaf',
|
||||||
|
poolPems: ['PEM-c-inter', 'PEM-c-root'],
|
||||||
|
includeRoot: false,
|
||||||
|
baseName: 'www.example.test',
|
||||||
|
keyPem: 'PEM-KEY',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('PFX: Standard ist Kompatibel, ohne Passwort gesperrt, bei ungleicher Wiederholung gesperrt', () => {
|
||||||
|
render(<MergeTab workspace={workspace([keyed, inter, root, key], [completeChain])} />);
|
||||||
|
const encryption = screen.getByRole('combobox', { name: 'Verschlüsselung' });
|
||||||
|
expect((encryption as HTMLSelectElement).value).toBe('compat');
|
||||||
|
expect(Array.from(encryption.querySelectorAll('option')).map((o) => o.textContent)).toEqual([
|
||||||
|
'Kompatibel (auch ältere Windows-Server)',
|
||||||
|
'Modern (AES-256)',
|
||||||
|
]);
|
||||||
|
expect(screen.getByText(/Windows Server 2016/)).toBeInTheDocument();
|
||||||
|
const download = screen.getByRole('button', { name: 'PFX-Datei herunterladen' });
|
||||||
|
expect(download).toBeDisabled();
|
||||||
|
fireEvent.change(screen.getByLabelText('PFX-Passwort'), { target: { value: 'Neu-Pass-2026' } });
|
||||||
|
fireEvent.change(screen.getByLabelText('Passwort wiederholen'), {
|
||||||
|
target: { value: 'anders' },
|
||||||
|
});
|
||||||
|
expect(screen.getByText('Die beiden Passwörter stimmen nicht überein.')).toBeInTheDocument();
|
||||||
|
expect(download).toBeDisabled();
|
||||||
|
fireEvent.change(screen.getByLabelText('Passwort wiederholen'), {
|
||||||
|
target: { value: 'Neu-Pass-2026' },
|
||||||
|
});
|
||||||
|
expect(download).toBeEnabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('PFX herunterladen: Inhalt pfx mit Schluessel, Passwort und gewaehlter Verschluesselung', async () => {
|
||||||
|
render(<MergeTab workspace={workspace([keyed, inter, root, key], [completeChain])} />);
|
||||||
|
fireEvent.change(screen.getByRole('combobox', { name: 'Verschlüsselung' }), {
|
||||||
|
target: { value: 'modern' },
|
||||||
|
});
|
||||||
|
fireEvent.change(screen.getByLabelText('PFX-Passwort'), { target: { value: 'Neu-Pass-2026' } });
|
||||||
|
fireEvent.change(screen.getByLabelText('Passwort wiederholen'), {
|
||||||
|
target: { value: 'Neu-Pass-2026' },
|
||||||
|
});
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'PFX-Datei herunterladen' }));
|
||||||
|
await waitFor(() => expect(mockBuild).toHaveBeenCalled());
|
||||||
|
expect(mockBuild).toHaveBeenCalledWith({
|
||||||
|
content: 'pfx',
|
||||||
|
certPem: 'PEM-c-leaf',
|
||||||
|
poolPems: ['PEM-c-inter', 'PEM-c-root'],
|
||||||
|
includeRoot: false,
|
||||||
|
baseName: 'www.example.test',
|
||||||
|
keyPem: 'PEM-KEY',
|
||||||
|
password: 'Neu-Pass-2026',
|
||||||
|
pfxEncryption: 'modern',
|
||||||
|
});
|
||||||
|
expect(mockDownload).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('PFX ohne Schluessel: der Hinweis steht da und keyPem fehlt in der Anfrage', async () => {
|
||||||
|
render(<MergeTab workspace={workspace([leaf, inter, root], [completeChain])} />);
|
||||||
|
expect(screen.getByText(/enthält die PFX-Datei nur die Zertifikate/)).toBeInTheDocument();
|
||||||
|
fireEvent.change(screen.getByLabelText('PFX-Passwort'), { target: { value: 'pw' } });
|
||||||
|
fireEvent.change(screen.getByLabelText('Passwort wiederholen'), { target: { value: 'pw' } });
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'PFX-Datei herunterladen' }));
|
||||||
|
await waitFor(() => expect(mockBuild).toHaveBeenCalled());
|
||||||
|
expect(mockBuild.mock.calls[0][0]).not.toHaveProperty('keyPem');
|
||||||
|
expect(mockBuild.mock.calls[0][0]).toMatchObject({ content: 'pfx', pfxEncryption: 'compat' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('das Passwort erscheint nirgends im dargestellten Text', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<MergeTab workspace={workspace([keyed, inter, root, key], [completeChain])} />,
|
||||||
|
);
|
||||||
|
fireEvent.change(screen.getByLabelText('PFX-Passwort'), { target: { value: 'Neu-Pass-2026' } });
|
||||||
|
expect(container.textContent).not.toContain('Neu-Pass-2026');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,9 +8,14 @@ import {
|
|||||||
type CertItem,
|
type CertItem,
|
||||||
certErrorKey,
|
certErrorKey,
|
||||||
downloadBase64,
|
downloadBase64,
|
||||||
|
type KeyItem,
|
||||||
} from '../actions';
|
} from '../actions';
|
||||||
import type { CertWorkspace } from '../use-cert-workspace';
|
import type { CertWorkspace } from '../use-cert-workspace';
|
||||||
import { ChainView } from './ChainView';
|
import { ChainView } from './ChainView';
|
||||||
|
import { type PfxEncryption, PfxOptions, pfxPasswordReady } from './PfxOptions';
|
||||||
|
|
||||||
|
const CHAIN_FORMATS = ['pem', 'p7b', 'p7c'] as const;
|
||||||
|
type ChainFormat = (typeof CHAIN_FORMATS)[number];
|
||||||
|
|
||||||
interface MergeTabProps {
|
interface MergeTabProps {
|
||||||
workspace: CertWorkspace;
|
workspace: CertWorkspace;
|
||||||
@@ -18,19 +23,28 @@ interface MergeTabProps {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Reiter „Zusammenführen“: Tessera ordnet Serverzertifikat, Zwischenzertifikate und Wurzel selbst
|
* Reiter „Zusammenführen“: Tessera ordnet Serverzertifikat, Zwischenzertifikate und Wurzel selbst
|
||||||
* (die API prueft Aussteller und Signatur) und liefert Fullchain oder Nur Kette. Das
|
* (die API prueft Aussteller und Signatur) und liefert Fullchain oder Nur Kette (PEM, .p7b, .p7c),
|
||||||
* Stammzertifikat kommt nur mit, wenn es angehakt ist (D-02, Vorgabe aus).
|
* Zertifikat und Schluessel in einer PEM-Datei oder eine PFX-Datei. Das Stammzertifikat kommt nur
|
||||||
|
* mit, wenn es angehakt ist (D-02, Vorgabe aus). Die PFX-Passwoerter bleiben im Zustand dieses
|
||||||
|
* Reiters und gehen nur in die Anfrage.
|
||||||
*/
|
*/
|
||||||
export function MergeTab({ workspace }: MergeTabProps) {
|
export function MergeTab({ workspace }: MergeTabProps) {
|
||||||
const t = useTranslations('certManager');
|
const t = useTranslations('certManager');
|
||||||
const [selectedHead, setSelectedHead] = useState<string | null>(null);
|
const [selectedHead, setSelectedHead] = useState<string | null>(null);
|
||||||
const [includeRoot, setIncludeRoot] = useState(false);
|
const [includeRoot, setIncludeRoot] = useState(false);
|
||||||
|
const [format, setFormat] = useState<ChainFormat>('pem');
|
||||||
|
const [pfxPassword, setPfxPassword] = useState('');
|
||||||
|
const [pfxRepeat, setPfxRepeat] = useState('');
|
||||||
|
const [pfxEncryption, setPfxEncryption] = useState<PfxEncryption>('compat');
|
||||||
const [busy, setBusy] = useState<BuildContent | null>(null);
|
const [busy, setBusy] = useState<BuildContent | null>(null);
|
||||||
const [errorKey, setErrorKey] = useState<string | null>(null);
|
const [errorKey, setErrorKey] = useState<string | null>(null);
|
||||||
|
|
||||||
const certs = (workspace.analysis?.items ?? []).filter(
|
const certs = (workspace.analysis?.items ?? []).filter(
|
||||||
(i): i is CertItem => i.kind === 'certificate',
|
(i): i is CertItem => i.kind === 'certificate',
|
||||||
);
|
);
|
||||||
|
const keys = (workspace.analysis?.items ?? []).filter(
|
||||||
|
(i): i is KeyItem => i.kind === 'privateKey',
|
||||||
|
);
|
||||||
const chains = workspace.analysis?.chains ?? [];
|
const chains = workspace.analysis?.chains ?? [];
|
||||||
|
|
||||||
if (workspace.status === 'analyzing' && !workspace.analysis) {
|
if (workspace.status === 'analyzing' && !workspace.analysis) {
|
||||||
@@ -54,19 +68,32 @@ export function MergeTab({ workspace }: MergeTabProps) {
|
|||||||
(c) => c.id !== chain.headId && (includeRoot || c.id !== chain.rootId),
|
(c) => c.id !== chain.headId && (includeRoot || c.id !== chain.rootId),
|
||||||
);
|
);
|
||||||
|
|
||||||
const download = async (content: 'fullchain' | 'chain') => {
|
const key = head?.keyId ? keys.find((k) => k.id === head.keyId) : undefined;
|
||||||
|
|
||||||
|
const download = async (content: BuildContent) => {
|
||||||
if (!head) return;
|
if (!head) return;
|
||||||
setBusy(content);
|
setBusy(content);
|
||||||
setErrorKey(null);
|
setErrorKey(null);
|
||||||
try {
|
try {
|
||||||
const result = await buildOutput({
|
const base = {
|
||||||
content,
|
|
||||||
format: 'pem',
|
|
||||||
certPem: head.pem,
|
certPem: head.pem,
|
||||||
poolPems: members.filter((c) => c.id !== head.id).map((c) => c.pem),
|
poolPems: members.filter((c) => c.id !== head.id).map((c) => c.pem),
|
||||||
includeRoot,
|
includeRoot,
|
||||||
baseName: head.baseName,
|
baseName: head.baseName,
|
||||||
});
|
};
|
||||||
|
const result = await buildOutput(
|
||||||
|
content === 'leafKey'
|
||||||
|
? { content, format: 'pem', ...base, keyPem: key?.pem }
|
||||||
|
: content === 'pfx'
|
||||||
|
? {
|
||||||
|
content,
|
||||||
|
...base,
|
||||||
|
...(key ? { keyPem: key.pem } : {}),
|
||||||
|
password: pfxPassword,
|
||||||
|
pfxEncryption,
|
||||||
|
}
|
||||||
|
: { content, format, ...base },
|
||||||
|
);
|
||||||
for (const file of result.files) downloadBase64(file.filename, file.content, file.mimeType);
|
for (const file of result.files) downloadBase64(file.filename, file.content, file.mimeType);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setErrorKey(certErrorKey(error));
|
setErrorKey(certErrorKey(error));
|
||||||
@@ -116,6 +143,24 @@ export function MergeTab({ workspace }: MergeTabProps) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<label htmlFor="cert-merge-format" className="text-sm text-foreground">
|
||||||
|
{t('merge.formatLabel')}
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="cert-merge-format"
|
||||||
|
value={format}
|
||||||
|
onChange={(e) => setFormat(e.target.value as ChainFormat)}
|
||||||
|
className="rounded border border-border bg-background px-3 py-1.5 text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
>
|
||||||
|
{CHAIN_FORMATS.map((f) => (
|
||||||
|
<option key={f} value={f}>
|
||||||
|
{t(`merge.formats.${f}`)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-3">
|
<div className="flex flex-wrap gap-3">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -138,6 +183,42 @@ export function MergeTab({ workspace }: MergeTabProps) {
|
|||||||
<p className="text-xs text-muted-foreground">{t('merge.chainNeedsIssuer')}</p>
|
<p className="text-xs text-muted-foreground">{t('merge.chainNeedsIssuer')}</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<section className="space-y-2 border-t border-border pt-4">
|
||||||
|
<h3 className="text-sm font-semibold text-foreground">{t('merge.bundleTitle')}</h3>
|
||||||
|
<p className="text-xs text-muted-foreground">{t('merge.bundleHint')}</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={busy !== null || !key}
|
||||||
|
onClick={() => download('leafKey')}
|
||||||
|
className="btn btn-secondary"
|
||||||
|
>
|
||||||
|
{busy === 'leafKey' ? t('merge.busy') : t('merge.downloadBundle')}
|
||||||
|
</button>
|
||||||
|
{!key && <p className="text-xs text-muted-foreground">{t('merge.bundleNeedsKey')}</p>}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="space-y-3 border-t border-border pt-4">
|
||||||
|
<h3 className="text-sm font-semibold text-foreground">{t('merge.pfxTitle')}</h3>
|
||||||
|
<p className="text-xs text-muted-foreground">{t('merge.pfxIntro')}</p>
|
||||||
|
<PfxOptions
|
||||||
|
password={pfxPassword}
|
||||||
|
repeat={pfxRepeat}
|
||||||
|
encryption={pfxEncryption}
|
||||||
|
onPassword={setPfxPassword}
|
||||||
|
onRepeat={setPfxRepeat}
|
||||||
|
onEncryption={setPfxEncryption}
|
||||||
|
/>
|
||||||
|
{!key && <p className="text-xs text-muted-foreground">{t('merge.pfxNoKey')}</p>}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={busy !== null || !pfxPasswordReady(pfxPassword, pfxRepeat)}
|
||||||
|
onClick={() => download('pfx')}
|
||||||
|
className="btn btn-secondary"
|
||||||
|
>
|
||||||
|
{busy === 'pfx' ? t('merge.busy') : t('merge.downloadPfx')}
|
||||||
|
</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
{errorKey && (
|
{errorKey && (
|
||||||
<p
|
<p
|
||||||
role="alert"
|
role="alert"
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
import { useId } from 'react';
|
||||||
|
import { PasswordInput } from './PasswordInput';
|
||||||
|
|
||||||
|
export type PfxEncryption = 'compat' | 'modern';
|
||||||
|
|
||||||
|
interface PfxOptionsProps {
|
||||||
|
password: string;
|
||||||
|
repeat: string;
|
||||||
|
encryption: PfxEncryption;
|
||||||
|
onPassword: (value: string) => void;
|
||||||
|
onRepeat: (value: string) => void;
|
||||||
|
onEncryption: (value: PfxEncryption) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Die PFX-Datei darf erst erzeugt werden, wenn ein Passwort gesetzt ist und beide Eingaben gleich sind. */
|
||||||
|
export function pfxPasswordReady(password: string, repeat: string): boolean {
|
||||||
|
return password !== '' && password === repeat;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Passwort (zweimal) und Verschluesselung fuer PFX-Dateien (D-07). Vorgabe ist „Kompatibel“
|
||||||
|
* (3DES/SHA-1): Windows Server 2016 und aeltere koennen AES-256 oft nicht oeffnen.
|
||||||
|
* Das Passwort bleibt im Zustand des Reiters, wird nirgends gespeichert und nur in der Anfrage gesendet.
|
||||||
|
*/
|
||||||
|
export function PfxOptions({
|
||||||
|
password,
|
||||||
|
repeat,
|
||||||
|
encryption,
|
||||||
|
onPassword,
|
||||||
|
onRepeat,
|
||||||
|
onEncryption,
|
||||||
|
}: PfxOptionsProps) {
|
||||||
|
const t = useTranslations('certManager');
|
||||||
|
const selectId = useId();
|
||||||
|
const mismatch = repeat !== '' && password !== repeat;
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<PasswordInput label={t('pfx.password')} value={password} onChange={onPassword} />
|
||||||
|
<PasswordInput label={t('pfx.repeat')} value={repeat} onChange={onRepeat} />
|
||||||
|
{mismatch && (
|
||||||
|
<p role="alert" className="text-sm text-foreground">
|
||||||
|
{t('pfx.mismatch')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<label htmlFor={selectId} className="text-sm text-foreground">
|
||||||
|
{t('pfx.encryption')}
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id={selectId}
|
||||||
|
value={encryption}
|
||||||
|
onChange={(e) => onEncryption(e.target.value as PfxEncryption)}
|
||||||
|
className="rounded border border-border bg-background px-3 py-1.5 text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
>
|
||||||
|
<option value="compat">{t('pfx.compat')}</option>
|
||||||
|
<option value="modern">{t('pfx.modern')}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">{t('pfx.modernHint')}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,13 +5,14 @@ import { useState } from 'react';
|
|||||||
import { TabBar } from '@/components/accounting/tab-bar';
|
import { TabBar } from '@/components/accounting/tab-bar';
|
||||||
import { PageHeader } from '@/components/layout/page-header';
|
import { PageHeader } from '@/components/layout/page-header';
|
||||||
import { AnalyzeTab } from './components/AnalyzeTab';
|
import { AnalyzeTab } from './components/AnalyzeTab';
|
||||||
|
import { ConvertTab } from './components/ConvertTab';
|
||||||
import { EmptyWorkspace } from './components/EmptyWorkspace';
|
import { EmptyWorkspace } from './components/EmptyWorkspace';
|
||||||
import { FilesTab } from './components/FilesTab';
|
import { FilesTab } from './components/FilesTab';
|
||||||
import { MergeTab } from './components/MergeTab';
|
import { MergeTab } from './components/MergeTab';
|
||||||
import { SplitTab } from './components/SplitTab';
|
import { SplitTab } from './components/SplitTab';
|
||||||
import { useCertWorkspace } from './use-cert-workspace';
|
import { useCertWorkspace } from './use-cert-workspace';
|
||||||
|
|
||||||
type TabId = 'files' | 'analyze' | 'split' | 'merge';
|
type TabId = 'files' | 'analyze' | 'split' | 'merge' | 'convert';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* CertManagerPage: Zertifikat-Manager rund um einen gemeinsamen Arbeitsbereich (quick-261009-ikt).
|
* CertManagerPage: Zertifikat-Manager rund um einen gemeinsamen Arbeitsbereich (quick-261009-ikt).
|
||||||
@@ -32,6 +33,7 @@ export default function CertManagerPage() {
|
|||||||
{ id: 'analyze', label: t('tabs.analyze') },
|
{ id: 'analyze', label: t('tabs.analyze') },
|
||||||
{ id: 'split', label: t('tabs.split') },
|
{ id: 'split', label: t('tabs.split') },
|
||||||
{ id: 'merge', label: t('tabs.merge') },
|
{ id: 'merge', label: t('tabs.merge') },
|
||||||
|
{ id: 'convert', label: t('tabs.convert') },
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -49,6 +51,7 @@ export default function CertManagerPage() {
|
|||||||
{activeTab === 'analyze' && <AnalyzeTab workspace={workspace} />}
|
{activeTab === 'analyze' && <AnalyzeTab workspace={workspace} />}
|
||||||
{activeTab === 'split' && <SplitTab workspace={workspace} />}
|
{activeTab === 'split' && <SplitTab workspace={workspace} />}
|
||||||
{activeTab === 'merge' && <MergeTab workspace={workspace} />}
|
{activeTab === 'merge' && <MergeTab workspace={workspace} />}
|
||||||
|
{activeTab === 'convert' && <ConvertTab workspace={workspace} />}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1256,7 +1256,8 @@
|
|||||||
"filesWithCount": "Dateien ({count})",
|
"filesWithCount": "Dateien ({count})",
|
||||||
"analyze": "Analysieren",
|
"analyze": "Analysieren",
|
||||||
"split": "Aufteilen",
|
"split": "Aufteilen",
|
||||||
"merge": "Zusammenführen"
|
"merge": "Zusammenführen",
|
||||||
|
"convert": "Konvertieren"
|
||||||
},
|
},
|
||||||
"roles": {
|
"roles": {
|
||||||
"end-entity": "Serverzertifikat",
|
"end-entity": "Serverzertifikat",
|
||||||
@@ -1398,7 +1399,21 @@
|
|||||||
"downloadFullchain": "Fullchain herunterladen",
|
"downloadFullchain": "Fullchain herunterladen",
|
||||||
"downloadChain": "Nur Kette herunterladen",
|
"downloadChain": "Nur Kette herunterladen",
|
||||||
"busy": "Wird erstellt …",
|
"busy": "Wird erstellt …",
|
||||||
"chainNeedsIssuer": "Für „Nur Kette“ fehlt ein Zwischenzertifikat. Fügen Sie es im Reiter „Dateien“ hinzu."
|
"chainNeedsIssuer": "Für „Nur Kette“ fehlt ein Zwischenzertifikat. Fügen Sie es im Reiter „Dateien“ hinzu.",
|
||||||
|
"formatLabel": "Format der Kette",
|
||||||
|
"formats": {
|
||||||
|
"pem": "PEM (.pem)",
|
||||||
|
"p7b": "PKCS#7 (.p7b)",
|
||||||
|
"p7c": "PKCS#7, binär (.p7c)"
|
||||||
|
},
|
||||||
|
"bundleTitle": "Zertifikat und Schlüssel",
|
||||||
|
"bundleHint": "Eine einzige PEM-Datei mit Serverzertifikat, Zwischenzertifikaten und dem privaten Schlüssel. Bewahren Sie sie sicher auf.",
|
||||||
|
"downloadBundle": "Zertifikat und Schlüssel (eine PEM-Datei)",
|
||||||
|
"bundleNeedsKey": "Dazu liegt noch kein passender privater Schlüssel vor. Fügen Sie ihn im Reiter „Dateien“ hinzu.",
|
||||||
|
"pfxTitle": "PFX-Datei",
|
||||||
|
"pfxIntro": "Eine passwortgeschützte Datei mit Zertifikaten und Schlüssel, wie sie Windows, IIS und viele Geräte erwarten.",
|
||||||
|
"pfxNoKey": "Ohne passenden Schlüssel enthält die PFX-Datei nur die Zertifikate.",
|
||||||
|
"downloadPfx": "PFX-Datei herunterladen"
|
||||||
},
|
},
|
||||||
"chain": {
|
"chain": {
|
||||||
"issuedBy": "Ausgestellt von {name}",
|
"issuedBy": "Ausgestellt von {name}",
|
||||||
@@ -1411,6 +1426,40 @@
|
|||||||
"passwordInput": {
|
"passwordInput": {
|
||||||
"show": "Passwort anzeigen",
|
"show": "Passwort anzeigen",
|
||||||
"hide": "Passwort verbergen"
|
"hide": "Passwort verbergen"
|
||||||
|
},
|
||||||
|
"pfx": {
|
||||||
|
"password": "PFX-Passwort",
|
||||||
|
"repeat": "Passwort wiederholen",
|
||||||
|
"mismatch": "Die beiden Passwörter stimmen nicht überein.",
|
||||||
|
"encryption": "Verschlüsselung",
|
||||||
|
"compat": "Kompatibel (auch ältere Windows-Server)",
|
||||||
|
"modern": "Modern (AES-256)",
|
||||||
|
"modernHint": "Ältere Windows-Server, etwa Windows Server 2016, können die moderne Verschlüsselung oft nicht öffnen. Im Zweifel wählen Sie „Kompatibel“."
|
||||||
|
},
|
||||||
|
"convert": {
|
||||||
|
"intro": "Wandeln Sie ein einzelnes Teil Ihrer Dateien in ein anderes Format um.",
|
||||||
|
"nothingFound": "Es wurde noch nichts erkannt, das sich umwandeln ließe.",
|
||||||
|
"item": "Was soll umgewandelt werden?",
|
||||||
|
"format": "Zielformat",
|
||||||
|
"groupCertificates": "Zertifikate",
|
||||||
|
"groupKeys": "Private Schlüssel",
|
||||||
|
"groupCsrs": "Zertifikatsanfragen",
|
||||||
|
"formats": {
|
||||||
|
"certPem": "PEM (.crt)",
|
||||||
|
"certDer": "DER (.cer)",
|
||||||
|
"p7b": "PKCS#7 (.p7b)",
|
||||||
|
"p7c": "PKCS#7, binär (.p7c)",
|
||||||
|
"pkcs8": "PKCS#8 (.key)",
|
||||||
|
"traditional": "Traditionell (PKCS#1 für RSA, SEC1 für EC)",
|
||||||
|
"pkcs8Der": "PKCS#8, binär (.key.der)",
|
||||||
|
"csrPem": "PEM (.csr)",
|
||||||
|
"csrDer": "DER (.csr.der)"
|
||||||
|
},
|
||||||
|
"keyNote": "Der Schlüssel wird nur für die Umwandlung an Tessera gesendet und nirgends gespeichert.",
|
||||||
|
"protectKey": "Schlüssel mit Passwort schützen",
|
||||||
|
"keyPassword": "Passwort für den Schlüssel",
|
||||||
|
"download": "Herunterladen",
|
||||||
|
"busy": "Wird erstellt …"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"tenderRadar": {
|
"tenderRadar": {
|
||||||
|
|||||||
@@ -1256,7 +1256,8 @@
|
|||||||
"filesWithCount": "Files ({count})",
|
"filesWithCount": "Files ({count})",
|
||||||
"analyze": "Analyze",
|
"analyze": "Analyze",
|
||||||
"split": "Split",
|
"split": "Split",
|
||||||
"merge": "Merge"
|
"merge": "Merge",
|
||||||
|
"convert": "Convert"
|
||||||
},
|
},
|
||||||
"roles": {
|
"roles": {
|
||||||
"end-entity": "Server certificate",
|
"end-entity": "Server certificate",
|
||||||
@@ -1398,7 +1399,21 @@
|
|||||||
"downloadFullchain": "Download fullchain",
|
"downloadFullchain": "Download fullchain",
|
||||||
"downloadChain": "Download chain only",
|
"downloadChain": "Download chain only",
|
||||||
"busy": "Creating …",
|
"busy": "Creating …",
|
||||||
"chainNeedsIssuer": "An intermediate certificate is missing for “chain only”. Add it in the “Files” tab."
|
"chainNeedsIssuer": "An intermediate certificate is missing for “chain only”. Add it in the “Files” tab.",
|
||||||
|
"formatLabel": "Chain format",
|
||||||
|
"formats": {
|
||||||
|
"pem": "PEM (.pem)",
|
||||||
|
"p7b": "PKCS#7 (.p7b)",
|
||||||
|
"p7c": "PKCS#7, binary (.p7c)"
|
||||||
|
},
|
||||||
|
"bundleTitle": "Certificate and key",
|
||||||
|
"bundleHint": "A single PEM file with the server certificate, the intermediate certificates and the private key. Keep it safe.",
|
||||||
|
"downloadBundle": "Certificate and key (one PEM file)",
|
||||||
|
"bundleNeedsKey": "There is no matching private key yet. Add it in the “Files” tab.",
|
||||||
|
"pfxTitle": "PFX file",
|
||||||
|
"pfxIntro": "A password-protected file with certificates and key, as Windows, IIS and many devices expect it.",
|
||||||
|
"pfxNoKey": "Without a matching key the PFX file contains only the certificates.",
|
||||||
|
"downloadPfx": "Download PFX file"
|
||||||
},
|
},
|
||||||
"chain": {
|
"chain": {
|
||||||
"issuedBy": "Issued by {name}",
|
"issuedBy": "Issued by {name}",
|
||||||
@@ -1411,6 +1426,40 @@
|
|||||||
"passwordInput": {
|
"passwordInput": {
|
||||||
"show": "Show password",
|
"show": "Show password",
|
||||||
"hide": "Hide password"
|
"hide": "Hide password"
|
||||||
|
},
|
||||||
|
"pfx": {
|
||||||
|
"password": "PFX password",
|
||||||
|
"repeat": "Repeat password",
|
||||||
|
"mismatch": "The two passwords do not match.",
|
||||||
|
"encryption": "Encryption",
|
||||||
|
"compat": "Compatible (also older Windows servers)",
|
||||||
|
"modern": "Modern (AES-256)",
|
||||||
|
"modernHint": "Older Windows servers, such as Windows Server 2016, often cannot open the modern encryption. When in doubt, choose “Compatible”."
|
||||||
|
},
|
||||||
|
"convert": {
|
||||||
|
"intro": "Convert a single item of your files into another format.",
|
||||||
|
"nothingFound": "Nothing has been recognised yet that could be converted.",
|
||||||
|
"item": "What should be converted?",
|
||||||
|
"format": "Target format",
|
||||||
|
"groupCertificates": "Certificates",
|
||||||
|
"groupKeys": "Private keys",
|
||||||
|
"groupCsrs": "Certificate requests",
|
||||||
|
"formats": {
|
||||||
|
"certPem": "PEM (.crt)",
|
||||||
|
"certDer": "DER (.cer)",
|
||||||
|
"p7b": "PKCS#7 (.p7b)",
|
||||||
|
"p7c": "PKCS#7, binary (.p7c)",
|
||||||
|
"pkcs8": "PKCS#8 (.key)",
|
||||||
|
"traditional": "Traditional (PKCS#1 for RSA, SEC1 for EC)",
|
||||||
|
"pkcs8Der": "PKCS#8, binary (.key.der)",
|
||||||
|
"csrPem": "PEM (.csr)",
|
||||||
|
"csrDer": "DER (.csr.der)"
|
||||||
|
},
|
||||||
|
"keyNote": "The key is sent to Tessera only for the conversion and is not stored anywhere.",
|
||||||
|
"protectKey": "Protect the key with a password",
|
||||||
|
"keyPassword": "Password for the key",
|
||||||
|
"download": "Download",
|
||||||
|
"busy": "Creating …"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"tenderRadar": {
|
"tenderRadar": {
|
||||||
|
|||||||
@@ -254,4 +254,7 @@ export const UMLAUT_ALLOWLIST: readonly string[] = [
|
|||||||
'Passender',
|
'Passender',
|
||||||
'Passendes',
|
'Passendes',
|
||||||
'Passende',
|
'Passende',
|
||||||
|
// quick-261009-ikt Task 5: PFX und Konvertieren
|
||||||
|
'passwortgeschützte',
|
||||||
|
'AES',
|
||||||
];
|
];
|
||||||
|
|||||||
Reference in New Issue
Block a user