feat(cert-manager): Zertifikatspaket hochladen, erkennen und in jedem Format herunterladen
Neuer erster Reiter Übersicht: mehrere Dateien oder die ZIP vom Aussteller auf einmal ablegen. Der Server erkennt jedes Teil (Server-, Zwischen-, Stammzertifikat, privater Schlüssel, CSR, auch aus PFX/P7B), fasst Duplikate zusammen, ordnet Schlüssel/CSR dem Zertifikat zu und baut die Kette. Unter jedem Teil stehen Download-Knöpfe für alle passenden Formate (crt, cer, Fullchain, p7b, pfx mit Schlüssel und Kette; key PKCS#8/PKCS#1/ DER; csr PEM/DER). Geschützte PFX lassen sich mit Passwort entsperren. Neue Endpunkte POST analyze (Dateien/ZIP, Begrenzung Anzahl und Größe vor dem Entpacken) und POST export (JSON, zustandslos). Modultexte siezen jetzt durchgehend; Gültigkeit in UTC wie im Zertifikat. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,268 @@
|
|||||||
|
import AdmZip from 'adm-zip';
|
||||||
|
import * as forge from 'node-forge';
|
||||||
|
import { beforeAll, describe, expect, it } from 'vitest';
|
||||||
|
import { analyzeBundle, exportBundleItem, safeBaseName } from './cert-bundle';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* cert-bundle.spec (quick-261001-l4q) — Zertifikatspaket wie vom Aussteller:
|
||||||
|
* Stamm -> Zwischen -> Server, dazu Schluessel, CSR und PFX, als ZIP.
|
||||||
|
* Alles hier erzeugt (keine echten Kundendaten im Repo).
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface Pki {
|
||||||
|
rootPem: string;
|
||||||
|
interPem: string;
|
||||||
|
leafPem: string;
|
||||||
|
keyPem: string;
|
||||||
|
csrPem: string;
|
||||||
|
pfx: Buffer;
|
||||||
|
leafModulus: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
let pki: Pki;
|
||||||
|
|
||||||
|
function makeCert(
|
||||||
|
subjectCn: string,
|
||||||
|
pub: forge.pki.PublicKey,
|
||||||
|
signer: forge.pki.PrivateKey,
|
||||||
|
issuer: forge.pki.CertificateField[] | null,
|
||||||
|
ca: boolean,
|
||||||
|
serial: string,
|
||||||
|
): forge.pki.Certificate {
|
||||||
|
const cert = forge.pki.createCertificate();
|
||||||
|
cert.publicKey = pub;
|
||||||
|
cert.serialNumber = serial;
|
||||||
|
cert.validity.notBefore = new Date(Date.now() - 86_400_000);
|
||||||
|
cert.validity.notAfter = new Date(Date.now() + 90 * 86_400_000);
|
||||||
|
const subject = [{ name: 'commonName', value: subjectCn }];
|
||||||
|
cert.setSubject(subject);
|
||||||
|
cert.setIssuer(issuer ?? subject);
|
||||||
|
const ext: object[] = [{ name: 'basicConstraints', cA: ca }];
|
||||||
|
if (!ca) ext.push({ name: 'subjectAltName', altNames: [{ type: 2, value: subjectCn }] });
|
||||||
|
cert.setExtensions(ext);
|
||||||
|
cert.sign(signer as forge.pki.rsa.PrivateKey, forge.md.sha256.create());
|
||||||
|
return cert;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeAll(() => {
|
||||||
|
const rootKeys = forge.pki.rsa.generateKeyPair(1024);
|
||||||
|
const interKeys = forge.pki.rsa.generateKeyPair(1024);
|
||||||
|
const leafKeys = forge.pki.rsa.generateKeyPair(1024);
|
||||||
|
const root = makeCert('Test Root CA', rootKeys.publicKey, rootKeys.privateKey, null, true, '01');
|
||||||
|
const inter = makeCert(
|
||||||
|
'Test Intermediate CA',
|
||||||
|
interKeys.publicKey,
|
||||||
|
rootKeys.privateKey,
|
||||||
|
root.subject.attributes,
|
||||||
|
true,
|
||||||
|
'02',
|
||||||
|
);
|
||||||
|
const leaf = makeCert(
|
||||||
|
'www.example.test',
|
||||||
|
leafKeys.publicKey,
|
||||||
|
interKeys.privateKey,
|
||||||
|
inter.subject.attributes,
|
||||||
|
false,
|
||||||
|
'03',
|
||||||
|
);
|
||||||
|
|
||||||
|
const csr = forge.pki.createCertificationRequest();
|
||||||
|
csr.publicKey = leafKeys.publicKey;
|
||||||
|
csr.setSubject([{ name: 'commonName', value: 'www.example.test' }]);
|
||||||
|
csr.sign(leafKeys.privateKey, forge.md.sha256.create());
|
||||||
|
|
||||||
|
const p12 = forge.pkcs12.toPkcs12Asn1(leafKeys.privateKey, [leaf, inter], 'geheim', {
|
||||||
|
algorithm: '3des',
|
||||||
|
});
|
||||||
|
|
||||||
|
pki = {
|
||||||
|
rootPem: forge.pki.certificateToPem(root),
|
||||||
|
interPem: forge.pki.certificateToPem(inter),
|
||||||
|
leafPem: forge.pki.certificateToPem(leaf),
|
||||||
|
keyPem: forge.pki.privateKeyInfoToPem(
|
||||||
|
forge.pki.wrapRsaPrivateKey(forge.pki.privateKeyToAsn1(leafKeys.privateKey)),
|
||||||
|
),
|
||||||
|
csrPem: forge.pki.certificationRequestToPem(csr),
|
||||||
|
pfx: Buffer.from(forge.asn1.toDer(p12).getBytes(), 'binary'),
|
||||||
|
leafModulus: leafKeys.publicKey.n.toString(16),
|
||||||
|
};
|
||||||
|
}, 60_000);
|
||||||
|
|
||||||
|
function issuerZip(): Buffer {
|
||||||
|
const zip = new AdmZip();
|
||||||
|
zip.addFile('www.example.test/www.example.test.pem', Buffer.from(pki.leafPem + pki.interPem));
|
||||||
|
zip.addFile('www.example.test/www.example.test.key', Buffer.from(pki.keyPem));
|
||||||
|
zip.addFile('www.example.test/www.example.test.csr', Buffer.from(pki.csrPem));
|
||||||
|
zip.addFile('www.example.test/www.example.test.pfx', pki.pfx);
|
||||||
|
zip.addFile('www.example.test/.dnstxtrecord', Buffer.from('_dnsauth abc123'));
|
||||||
|
return zip.toBuffer();
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('analyzeBundle', () => {
|
||||||
|
it('ZIP vom Aussteller: erkennt jedes Teil, fasst PEM/PFX zusammen, ordnet Schluessel und Kette zu', () => {
|
||||||
|
const r = analyzeBundle([{ originalname: 'paket.zip', buffer: issuerZip() }], 'geheim');
|
||||||
|
|
||||||
|
const kinds = r.items.map((i) => (i.kind === 'certificate' ? i.role : i.kind));
|
||||||
|
expect(kinds).toEqual(['end-entity', 'intermediate', 'privateKey', 'csr']);
|
||||||
|
|
||||||
|
const [leaf, inter, key, csr] = r.items;
|
||||||
|
expect(leaf.cn).toBe('www.example.test');
|
||||||
|
expect(leaf.san).toEqual(['www.example.test']);
|
||||||
|
// in PEM UND PFX enthalten -> ein Eintrag mit beiden Quellen
|
||||||
|
expect(leaf.sources.sort()).toEqual(['www.example.test.pem', 'www.example.test.pfx']);
|
||||||
|
expect(leaf.chainIds).toEqual([inter.id]);
|
||||||
|
expect(leaf.matchId).toBe(key.id);
|
||||||
|
expect(key.matchId).toBe(leaf.id);
|
||||||
|
expect(key.sources.sort()).toEqual(['www.example.test.key', 'www.example.test.pfx']);
|
||||||
|
expect(csr.matchId).toBe(leaf.id);
|
||||||
|
expect(csr.cn).toBe('www.example.test');
|
||||||
|
expect(leaf.baseName).toBe('www.example.test');
|
||||||
|
|
||||||
|
expect(r.locked).toEqual([]);
|
||||||
|
expect(r.ignored).toEqual(['.dnstxtrecord']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('PFX ohne passendes Passwort wird als gesperrt gemeldet, der Rest trotzdem erkannt', () => {
|
||||||
|
const r = analyzeBundle([{ originalname: 'paket.zip', buffer: issuerZip() }], 'falsch');
|
||||||
|
expect(r.locked).toEqual(['www.example.test.pfx']);
|
||||||
|
expect(r.items.filter((i) => i.kind === 'certificate')).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Stammzertifikat wird als root erkannt und an die Kette gehaengt', () => {
|
||||||
|
const r = analyzeBundle(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
originalname: 'chain.pem',
|
||||||
|
buffer: Buffer.from(pki.leafPem + pki.interPem + pki.rootPem),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'',
|
||||||
|
);
|
||||||
|
expect(r.items.map((i) => i.role)).toEqual(['end-entity', 'intermediate', 'root']);
|
||||||
|
expect(r.items[0].chainIds).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ohne Dateien -> 400', () => {
|
||||||
|
expect(() => analyzeBundle([], '')).toThrow(/No files/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ZIP mit zu vielen Dateien -> 400', () => {
|
||||||
|
const zip = new AdmZip();
|
||||||
|
for (let i = 0; i < 101; i++) zip.addFile(`f${i}.txt`, Buffer.from('x'));
|
||||||
|
expect(() =>
|
||||||
|
analyzeBundle([{ originalname: 'gross.zip', buffer: zip.toBuffer() }], ''),
|
||||||
|
).toThrow(/too many files/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('exportBundleItem', () => {
|
||||||
|
function bundle() {
|
||||||
|
const r = analyzeBundle([{ originalname: 'paket.zip', buffer: issuerZip() }], 'geheim');
|
||||||
|
const byId = Object.fromEntries(r.items.map((i) => [i.id, i]));
|
||||||
|
return { items: r.items, byId };
|
||||||
|
}
|
||||||
|
const decode = (b64: string) => Buffer.from(b64, 'base64');
|
||||||
|
|
||||||
|
it('Zertifikat in jedem Format liest sich wieder ein', () => {
|
||||||
|
const { items, byId } = bundle();
|
||||||
|
const leaf = items[0];
|
||||||
|
const chain = leaf.chainIds.map((id) => byId[id].pem);
|
||||||
|
const keyPem = byId[leaf.matchId!].pem;
|
||||||
|
const base = { kind: leaf.kind, pem: leaf.pem, baseName: leaf.baseName, chain, keyPem };
|
||||||
|
|
||||||
|
const crt = exportBundleItem({ ...base, format: 'crt' });
|
||||||
|
expect(crt.filename).toBe('www.example.test.crt');
|
||||||
|
expect(
|
||||||
|
forge.pki.certificateFromPem(decode(crt.content).toString()).subject.getField('CN').value,
|
||||||
|
).toBe('www.example.test');
|
||||||
|
|
||||||
|
const cer = exportBundleItem({ ...base, format: 'cer' });
|
||||||
|
expect(cer.filename).toBe('www.example.test.cer');
|
||||||
|
forge.pki.certificateFromAsn1(forge.asn1.fromDer(decode(cer.content).toString('binary')));
|
||||||
|
|
||||||
|
const full = exportBundleItem({ ...base, format: 'fullchain' });
|
||||||
|
expect(
|
||||||
|
decode(full.content)
|
||||||
|
.toString()
|
||||||
|
.match(/BEGIN CERTIFICATE/g),
|
||||||
|
).toHaveLength(2);
|
||||||
|
|
||||||
|
const p7b = exportBundleItem({ ...base, format: 'p7b' });
|
||||||
|
const p7 = forge.pkcs7.messageFromPem(decode(p7b.content).toString());
|
||||||
|
expect('certificates' in p7 ? p7.certificates : []).toHaveLength(2);
|
||||||
|
|
||||||
|
const pfx = exportBundleItem({ ...base, format: 'pfx', password: 'neu' });
|
||||||
|
expect(pfx.filename).toBe('www.example.test.pfx');
|
||||||
|
const p12 = forge.pkcs12.pkcs12FromAsn1(
|
||||||
|
forge.asn1.fromDer(decode(pfx.content).toString('binary')),
|
||||||
|
'neu',
|
||||||
|
);
|
||||||
|
expect(p12.getBags({ bagType: forge.pki.oids.certBag })[forge.pki.oids.certBag]).toHaveLength(
|
||||||
|
2,
|
||||||
|
);
|
||||||
|
const keyBag = p12.getBags({ bagType: forge.pki.oids.pkcs8ShroudedKeyBag })[
|
||||||
|
forge.pki.oids.pkcs8ShroudedKeyBag
|
||||||
|
]![0];
|
||||||
|
expect((keyBag.key as forge.pki.rsa.PrivateKey).n.toString(16)).toBe(pki.leafModulus);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('PFX ohne Passwort -> 400', () => {
|
||||||
|
const { items } = bundle();
|
||||||
|
expect(() =>
|
||||||
|
exportBundleItem({ kind: 'certificate', pem: items[0].pem, format: 'pfx' }),
|
||||||
|
).toThrow(/password is required/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Schluessel als PKCS#8, PKCS#1 und DER', () => {
|
||||||
|
const { items } = bundle();
|
||||||
|
const key = items.find((i) => i.kind === 'privateKey')!;
|
||||||
|
const base = { kind: key.kind, pem: key.pem, baseName: key.baseName };
|
||||||
|
expect(decode(exportBundleItem({ ...base, format: 'key' }).content).toString()).toContain(
|
||||||
|
'BEGIN PRIVATE KEY',
|
||||||
|
);
|
||||||
|
const rsa = exportBundleItem({ ...base, format: 'key-rsa' });
|
||||||
|
expect(rsa.filename).toBe('www.example.test.rsa.key');
|
||||||
|
expect(decode(rsa.content).toString()).toContain('BEGIN RSA PRIVATE KEY');
|
||||||
|
const der = exportBundleItem({ ...base, format: 'key-der' });
|
||||||
|
const info = forge.asn1.fromDer(decode(der.content).toString('binary'));
|
||||||
|
expect((forge.pki.privateKeyFromAsn1(info) as forge.pki.rsa.PrivateKey).n.toString(16)).toBe(
|
||||||
|
pki.leafModulus,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('CSR als PEM und DER', () => {
|
||||||
|
const { items } = bundle();
|
||||||
|
const csr = items.find((i) => i.kind === 'csr')!;
|
||||||
|
const der = exportBundleItem({
|
||||||
|
kind: 'csr',
|
||||||
|
pem: csr.pem,
|
||||||
|
baseName: csr.baseName,
|
||||||
|
format: 'csr-der',
|
||||||
|
});
|
||||||
|
expect(der.filename).toBe('www.example.test.csr.der');
|
||||||
|
forge.pki.certificationRequestFromAsn1(
|
||||||
|
forge.asn1.fromDer(decode(der.content).toString('binary')),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('unpassendes Format -> 400', () => {
|
||||||
|
const { items } = bundle();
|
||||||
|
expect(() =>
|
||||||
|
exportBundleItem({ kind: 'csr', pem: items[3].pem, format: 'pfx', password: 'x' }),
|
||||||
|
).toThrow();
|
||||||
|
expect(() =>
|
||||||
|
exportBundleItem({ kind: 'privateKey', pem: 'kein pem', format: 'key-rsa' }),
|
||||||
|
).toThrow(/Failed to export/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('safeBaseName', () => {
|
||||||
|
it('Platzhalter, Leerzeichen und Pfadteile werden entschaerft', () => {
|
||||||
|
expect(safeBaseName('*.example.de', 'x')).toBe('wildcard.example.de');
|
||||||
|
expect(safeBaseName('Encryption Everywhere DV TLS CA - G1', 'x')).toBe(
|
||||||
|
'Encryption_Everywhere_DV_TLS_CA_-_G1',
|
||||||
|
);
|
||||||
|
expect(safeBaseName('../../etc/passwd', 'x')).toBe('etc_passwd');
|
||||||
|
expect(safeBaseName('', 'fallback')).toBe('fallback');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,655 @@
|
|||||||
|
import { BadRequestException } from '@nestjs/common';
|
||||||
|
import AdmZip from 'adm-zip';
|
||||||
|
import * as forge from 'node-forge';
|
||||||
|
import type { UploadedFileLike } from '../auth/types/auth-user';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zertifikatspaket (quick-261001-l4q): alles, was ein Aussteller liefert —
|
||||||
|
* Zertifikat mit Kette (.pem/.crt/.cer/.p7b), privater Schluessel (.key),
|
||||||
|
* Zertifikatsanfrage (.csr), PFX/P12, gern als ZIP — auf einmal hochladen,
|
||||||
|
* erkennen, was was ist, und jedes Teil in jedem passenden Format
|
||||||
|
* herunterladen.
|
||||||
|
*
|
||||||
|
* Zustandslos wie der Rest des Moduls: `analyzeBundle` gibt je Teil den
|
||||||
|
* kanonischen PEM-Text zurueck, `exportBundleItem` baut daraus die Datei.
|
||||||
|
* Nichts wird gespeichert, Passwoerter werden nie protokolliert.
|
||||||
|
*/
|
||||||
|
|
||||||
|
type BundleFile = Pick<UploadedFileLike, 'buffer' | 'originalname'>;
|
||||||
|
|
||||||
|
export type BundleCertRole = 'end-entity' | 'intermediate' | 'root';
|
||||||
|
export type BundleItemKind = 'certificate' | 'privateKey' | 'csr';
|
||||||
|
|
||||||
|
export interface BundleItem {
|
||||||
|
id: string;
|
||||||
|
kind: BundleItemKind;
|
||||||
|
/** Nur bei Zertifikaten. */
|
||||||
|
role?: BundleCertRole;
|
||||||
|
/** Dateien, in denen dieses Teil gefunden wurde (Duplikate zusammengefasst). */
|
||||||
|
sources: string[];
|
||||||
|
/** Kanonischer PEM-Text — Grundlage fuer jeden Export. */
|
||||||
|
pem: string;
|
||||||
|
/** Vorschlag fuer den Dateinamen ohne Endung, aus dem CN abgeleitet. */
|
||||||
|
baseName: string;
|
||||||
|
cn: string;
|
||||||
|
organization: string;
|
||||||
|
issuerCn: string;
|
||||||
|
notBefore: string | null;
|
||||||
|
notAfter: string | null;
|
||||||
|
isExpired: boolean | null;
|
||||||
|
daysLeft: number | null;
|
||||||
|
san: string[];
|
||||||
|
keyType: string;
|
||||||
|
keyBits: number;
|
||||||
|
serialNumber: string;
|
||||||
|
sha256: string;
|
||||||
|
/** Zertifikat: id des passenden Schluessels; Schluessel/CSR: id des passenden Zertifikats. */
|
||||||
|
matchId: string | null;
|
||||||
|
/** Zertifikat: ids der Kette darueber (Aussteller, dessen Aussteller ...). */
|
||||||
|
chainIds: string[];
|
||||||
|
/** Formate, die `exportBundleItem` fuer dieses Teil liefern kann. */
|
||||||
|
formats: BundleExportFormat[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BundleAnalysis {
|
||||||
|
items: BundleItem[];
|
||||||
|
/** PFX/P12 oder verschluesselte Schluessel, die ohne (richtiges) Passwort nicht lesbar sind. */
|
||||||
|
locked: string[];
|
||||||
|
/** Dateien ohne erkennbares Zertifikat, Schluessel oder CSR. */
|
||||||
|
ignored: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BundleExportFormat =
|
||||||
|
| 'crt'
|
||||||
|
| 'cer'
|
||||||
|
| 'fullchain'
|
||||||
|
| 'p7b'
|
||||||
|
| 'pfx'
|
||||||
|
| 'key'
|
||||||
|
| 'key-rsa'
|
||||||
|
| 'key-der'
|
||||||
|
| 'csr'
|
||||||
|
| 'csr-der';
|
||||||
|
|
||||||
|
export interface BundleExportInput {
|
||||||
|
kind: BundleItemKind;
|
||||||
|
pem: string;
|
||||||
|
format: BundleExportFormat;
|
||||||
|
baseName?: string;
|
||||||
|
/** Zertifikat: PEMs der Kette darueber (fuer Fullchain/P7B/PFX). */
|
||||||
|
chain?: string[];
|
||||||
|
/** Zertifikat: PEM des passenden privaten Schluessels (fuer PFX). */
|
||||||
|
keyPem?: string;
|
||||||
|
/** PFX: Passwort fuer die neue Datei. */
|
||||||
|
password?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BundleExportFile {
|
||||||
|
filename: string;
|
||||||
|
/** Base64 */
|
||||||
|
content: string;
|
||||||
|
mimeType: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_ZIP_ENTRIES = 100;
|
||||||
|
const MAX_ENTRY_BYTES = 5 * 1024 * 1024;
|
||||||
|
|
||||||
|
const PEM_BLOCK = /-----BEGIN ([A-Z0-9 ]+)-----[\s\S]+?-----END \1-----/g;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Hilfen
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function binary(buffer: Buffer): forge.util.ByteStringBuffer {
|
||||||
|
return forge.util.createBuffer(buffer.toString('binary'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function bytesToBase64(bytes: string): string {
|
||||||
|
return Buffer.from(forge.util.bytesToHex(bytes), 'hex').toString('base64');
|
||||||
|
}
|
||||||
|
|
||||||
|
function textToBase64(text: string): string {
|
||||||
|
return Buffer.from(text, 'utf-8').toString('base64');
|
||||||
|
}
|
||||||
|
|
||||||
|
function sha256Of(cert: forge.pki.Certificate): string {
|
||||||
|
const md = forge.md.sha256.create();
|
||||||
|
md.update(forge.asn1.toDer(forge.pki.certificateToAsn1(cert)).getBytes());
|
||||||
|
return (md.digest().toHex().match(/.{2}/g) ?? []).join(':').toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function field(name: forge.pki.Certificate['subject'], short: string): string {
|
||||||
|
return (name.getField(short)?.value as string | undefined) ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Dateiname ohne Pfad und ohne gefaehrliche Zeichen, z. B. „*.example.de“ -> „wildcard.example.de“. */
|
||||||
|
export function safeBaseName(raw: string, fallback: string): string {
|
||||||
|
const cleaned = raw
|
||||||
|
.replace(/^\*\./, 'wildcard.')
|
||||||
|
.replace(/[^A-Za-z0-9._-]+/g, '_')
|
||||||
|
.replace(/^[._]+/, '')
|
||||||
|
.slice(0, 80);
|
||||||
|
return cleaned || fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function certRole(cert: forge.pki.Certificate): BundleCertRole {
|
||||||
|
const bc = cert.getExtension('basicConstraints') as { cA?: boolean } | null;
|
||||||
|
if (!bc?.cA) return 'end-entity';
|
||||||
|
return cert.subject.hash === cert.issuer.hash ? 'root' : 'intermediate';
|
||||||
|
}
|
||||||
|
|
||||||
|
function publicKeyInfo(key: unknown): { keyType: string; keyBits: number; modulus: string } {
|
||||||
|
// node-forge liefert RSA-Schluessel mit `n`; EC-Schluessel kennt es nur
|
||||||
|
// eingeschraenkt (siehe Kommentar in CertManagerService.parseCert).
|
||||||
|
const k = key as { n?: forge.jsbn.BigInteger };
|
||||||
|
if (k?.n) return { keyType: 'RSA', keyBits: k.n.bitLength(), modulus: k.n.toString(16) };
|
||||||
|
return { keyType: 'EC', keyBits: 0, modulus: '' };
|
||||||
|
}
|
||||||
|
|
||||||
|
function sanOf(extensions: unknown[] | undefined): string[] {
|
||||||
|
const ext = (extensions ?? []).find((e) => (e as { name?: string }).name === 'subjectAltName') as
|
||||||
|
| { altNames?: { type: number; value?: string; ip?: string }[] }
|
||||||
|
| undefined;
|
||||||
|
return (ext?.altNames ?? []).map((n) =>
|
||||||
|
n.type === 2 ? (n.value ?? '') : `IP:${n.ip ?? n.value ?? ''}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Einsammeln: Dateien (inkl. ZIP) -> rohe Teile
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
interface RawKey {
|
||||||
|
source: string;
|
||||||
|
pem: string;
|
||||||
|
modulus: string;
|
||||||
|
keyType: string;
|
||||||
|
keyBits: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RawCsr {
|
||||||
|
source: string;
|
||||||
|
pem: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Collected {
|
||||||
|
certs: { source: string; cert: forge.pki.Certificate }[];
|
||||||
|
keys: RawKey[];
|
||||||
|
csrs: RawCsr[];
|
||||||
|
locked: string[];
|
||||||
|
ignored: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function expandZips(files: BundleFile[]): { name: string; buffer: Buffer }[] {
|
||||||
|
const out: { name: string; buffer: Buffer }[] = [];
|
||||||
|
for (const file of files) {
|
||||||
|
if (!file.originalname.toLowerCase().endsWith('.zip')) {
|
||||||
|
out.push({ name: file.originalname, buffer: file.buffer });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let zip: AdmZip;
|
||||||
|
try {
|
||||||
|
zip = new AdmZip(file.buffer);
|
||||||
|
} catch {
|
||||||
|
throw new BadRequestException(`"${file.originalname}" is not a readable ZIP archive`);
|
||||||
|
}
|
||||||
|
const entries = zip
|
||||||
|
.getEntries()
|
||||||
|
.filter((e) => !e.isDirectory && !e.entryName.startsWith('__MACOSX/'));
|
||||||
|
if (entries.length > MAX_ZIP_ENTRIES) {
|
||||||
|
throw new BadRequestException(`"${file.originalname}" contains too many files`);
|
||||||
|
}
|
||||||
|
for (const entry of entries) {
|
||||||
|
// Groesse aus dem Kopf pruefen, BEVOR entpackt wird (Zip-Bombe).
|
||||||
|
if (entry.header.size > MAX_ENTRY_BYTES) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`"${entry.entryName}" in "${file.originalname}" is too large`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const name = entry.entryName.split('/').pop() ?? entry.entryName;
|
||||||
|
out.push({ name, buffer: entry.getData() });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function addKey(c: Collected, source: string, privateKey: forge.pki.rsa.PrivateKey): void {
|
||||||
|
const info = publicKeyInfo(privateKey);
|
||||||
|
const pem = forge.pki.privateKeyInfoToPem(
|
||||||
|
forge.pki.wrapRsaPrivateKey(forge.pki.privateKeyToAsn1(privateKey)),
|
||||||
|
);
|
||||||
|
c.keys.push({ source, pem, ...info });
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectPemText(c: Collected, source: string, text: string, password: string): boolean {
|
||||||
|
let found = false;
|
||||||
|
for (const match of text.matchAll(PEM_BLOCK)) {
|
||||||
|
const [block, type] = match;
|
||||||
|
try {
|
||||||
|
if (type === 'CERTIFICATE' || type === 'TRUSTED CERTIFICATE') {
|
||||||
|
c.certs.push({ source, cert: forge.pki.certificateFromPem(block) });
|
||||||
|
found = true;
|
||||||
|
} else if (type === 'PRIVATE KEY' || type === 'RSA PRIVATE KEY') {
|
||||||
|
const key = forge.pki.privateKeyFromPem(block) as forge.pki.rsa.PrivateKey;
|
||||||
|
addKey(c, source, key);
|
||||||
|
found = true;
|
||||||
|
} else if (type === 'ENCRYPTED PRIVATE KEY') {
|
||||||
|
const key = password ? forge.pki.decryptRsaPrivateKey(block, password) : null;
|
||||||
|
if (key) addKey(c, source, key as forge.pki.rsa.PrivateKey);
|
||||||
|
else c.locked.push(source);
|
||||||
|
found = true;
|
||||||
|
} else if (type === 'EC PRIVATE KEY') {
|
||||||
|
// node-forge kann EC nicht umrechnen — Teil bleibt im Original erhalten.
|
||||||
|
c.keys.push({ source, pem: block, modulus: '', keyType: 'EC', keyBits: 0 });
|
||||||
|
found = true;
|
||||||
|
} else if (type === 'CERTIFICATE REQUEST' || type === 'NEW CERTIFICATE REQUEST') {
|
||||||
|
c.csrs.push({
|
||||||
|
source,
|
||||||
|
pem: block.replace(/NEW CERTIFICATE REQUEST/g, 'CERTIFICATE REQUEST'),
|
||||||
|
});
|
||||||
|
found = true;
|
||||||
|
} else if (type === 'PKCS7') {
|
||||||
|
const p7 = forge.pkcs7.messageFromPem(block);
|
||||||
|
for (const cert of 'certificates' in p7 ? p7.certificates : [])
|
||||||
|
c.certs.push({ source, cert });
|
||||||
|
found = true;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// PKCS#8 mit EC-Schluessel o. ae.: node-forge kann ihn nicht lesen —
|
||||||
|
// im Original behalten statt zu verwerfen.
|
||||||
|
if (type === 'PRIVATE KEY') {
|
||||||
|
c.keys.push({ source, pem: block, modulus: '', keyType: 'EC', keyBits: 0 });
|
||||||
|
found = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectPfx(c: Collected, source: string, buffer: Buffer, password: string): void {
|
||||||
|
let p12: forge.pkcs12.Pkcs12Pfx | null = null;
|
||||||
|
for (const candidate of password ? [password, ''] : ['']) {
|
||||||
|
try {
|
||||||
|
p12 = forge.pkcs12.pkcs12FromAsn1(forge.asn1.fromDer(binary(buffer)), candidate);
|
||||||
|
break;
|
||||||
|
} catch {
|
||||||
|
// naechstes Passwort versuchen
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!p12) {
|
||||||
|
c.locked.push(source);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const bag of p12.getBags({ bagType: forge.pki.oids.certBag })[forge.pki.oids.certBag] ??
|
||||||
|
[]) {
|
||||||
|
if (bag.cert) c.certs.push({ source, cert: bag.cert });
|
||||||
|
}
|
||||||
|
for (const oid of [forge.pki.oids.pkcs8ShroudedKeyBag, forge.pki.oids.keyBag]) {
|
||||||
|
for (const bag of p12.getBags({ bagType: oid })[oid] ?? []) {
|
||||||
|
if (bag.key) addKey(c, source, bag.key as forge.pki.rsa.PrivateKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectDer(c: Collected, source: string, buffer: Buffer): boolean {
|
||||||
|
try {
|
||||||
|
const asn1 = forge.asn1.fromDer(binary(buffer));
|
||||||
|
try {
|
||||||
|
c.certs.push({ source, cert: forge.pki.certificateFromAsn1(asn1) });
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
/* kein einzelnes Zertifikat */
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const p7 = forge.pkcs7.messageFromAsn1(asn1);
|
||||||
|
const certs = 'certificates' in p7 ? p7.certificates : [];
|
||||||
|
for (const cert of certs) c.certs.push({ source, cert });
|
||||||
|
if (certs.length > 0) return true;
|
||||||
|
} catch {
|
||||||
|
/* kein PKCS#7 */
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
forge.pki.certificationRequestFromAsn1(asn1);
|
||||||
|
const body = forge.asn1.toDer(asn1).getBytes();
|
||||||
|
c.csrs.push({ source, pem: forge.pem.encode({ type: 'CERTIFICATE REQUEST', body }) });
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
/* keine CSR */
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* kein DER */
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function collect(files: BundleFile[], password: string): Collected {
|
||||||
|
const c: Collected = { certs: [], keys: [], csrs: [], locked: [], ignored: [] };
|
||||||
|
for (const { name, buffer } of expandZips(files)) {
|
||||||
|
const ext = name.split('.').pop()?.toLowerCase() ?? '';
|
||||||
|
if (ext === 'pfx' || ext === 'p12') {
|
||||||
|
collectPfx(c, name, buffer, password);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const head = buffer.subarray(0, 4096).toString('latin1');
|
||||||
|
const found = head.includes('-----BEGIN')
|
||||||
|
? collectPemText(c, name, buffer.toString('utf-8'), password)
|
||||||
|
: collectDer(c, name, buffer);
|
||||||
|
if (!found) c.ignored.push(name);
|
||||||
|
}
|
||||||
|
return c;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// analyzeBundle
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const CERT_FORMATS: BundleExportFormat[] = ['crt', 'cer', 'fullchain', 'p7b', 'pfx'];
|
||||||
|
|
||||||
|
export function analyzeBundle(files: BundleFile[], password = ''): BundleAnalysis {
|
||||||
|
if (files.length === 0) throw new BadRequestException('No files provided');
|
||||||
|
const c = collect(files, password);
|
||||||
|
|
||||||
|
// Zertifikate nach Fingerabdruck zusammenfassen (PEM und PFX enthalten oft dieselben).
|
||||||
|
const certMap = new Map<string, { cert: forge.pki.Certificate; sources: Set<string> }>();
|
||||||
|
for (const { source, cert } of c.certs) {
|
||||||
|
const fp = sha256Of(cert);
|
||||||
|
const entry = certMap.get(fp) ?? { cert, sources: new Set<string>() };
|
||||||
|
entry.sources.add(source);
|
||||||
|
certMap.set(fp, entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
const certItems: BundleItem[] = [...certMap.entries()].map(([fp, { cert, sources }]) => {
|
||||||
|
const info = publicKeyInfo(cert.publicKey);
|
||||||
|
const cn = field(cert.subject, 'CN');
|
||||||
|
const notAfter = cert.validity.notAfter;
|
||||||
|
const role = certRole(cert);
|
||||||
|
return {
|
||||||
|
id: `cert-${fp.replace(/:/g, '').slice(0, 16).toLowerCase()}`,
|
||||||
|
kind: 'certificate',
|
||||||
|
role,
|
||||||
|
sources: [...sources],
|
||||||
|
pem: forge.pki.certificateToPem(cert),
|
||||||
|
baseName: safeBaseName(cn, role === 'end-entity' ? 'zertifikat' : 'ca'),
|
||||||
|
cn,
|
||||||
|
organization: field(cert.subject, 'O'),
|
||||||
|
issuerCn: field(cert.issuer, 'CN'),
|
||||||
|
notBefore: cert.validity.notBefore.toISOString(),
|
||||||
|
notAfter: notAfter.toISOString(),
|
||||||
|
isExpired: notAfter.getTime() < now,
|
||||||
|
daysLeft: Math.ceil((notAfter.getTime() - now) / 86_400_000),
|
||||||
|
san: sanOf(cert.extensions),
|
||||||
|
keyType: info.keyType,
|
||||||
|
keyBits: info.keyBits,
|
||||||
|
serialNumber: cert.serialNumber,
|
||||||
|
sha256: fp,
|
||||||
|
matchId: null,
|
||||||
|
chainIds: [],
|
||||||
|
formats: CERT_FORMATS,
|
||||||
|
// nur intern fuer Kette/Zuordnung, wird unten entfernt
|
||||||
|
_cert: cert,
|
||||||
|
_modulus: info.modulus,
|
||||||
|
} as BundleItem & { _cert: forge.pki.Certificate; _modulus: string };
|
||||||
|
});
|
||||||
|
|
||||||
|
// Kette: zu jedem Zertifikat den Aussteller im Paket suchen.
|
||||||
|
type Internal = BundleItem & { _cert: forge.pki.Certificate; _modulus: string };
|
||||||
|
const internals = certItems as Internal[];
|
||||||
|
for (const item of internals) {
|
||||||
|
let current = item._cert;
|
||||||
|
const seen = new Set<string>([item.id]);
|
||||||
|
for (let depth = 0; depth < 10; depth++) {
|
||||||
|
if (current.subject.hash === current.issuer.hash) break;
|
||||||
|
const issuer = internals.find(
|
||||||
|
(o) => !seen.has(o.id) && o._cert.subject.hash === current.issuer.hash,
|
||||||
|
);
|
||||||
|
if (!issuer) break;
|
||||||
|
item.chainIds.push(issuer.id);
|
||||||
|
seen.add(issuer.id);
|
||||||
|
current = issuer._cert;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Schluessel: Duplikate zusammenfassen, dem Zertifikat zuordnen.
|
||||||
|
const keyMap = new Map<string, { key: RawKey; sources: Set<string> }>();
|
||||||
|
for (const key of c.keys) {
|
||||||
|
const id = key.modulus || key.pem;
|
||||||
|
const entry = keyMap.get(id) ?? { key, sources: new Set<string>() };
|
||||||
|
entry.sources.add(key.source);
|
||||||
|
keyMap.set(id, entry);
|
||||||
|
}
|
||||||
|
const keyItems: BundleItem[] = [...keyMap.values()].map(({ key, sources }, i) => {
|
||||||
|
const cert = key.modulus ? internals.find((o) => o._modulus === key.modulus) : undefined;
|
||||||
|
const id = `key-${i + 1}`;
|
||||||
|
if (cert) cert.matchId = id;
|
||||||
|
const isRsa = key.keyType === 'RSA';
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
kind: 'privateKey',
|
||||||
|
sources: [...sources],
|
||||||
|
pem: key.pem,
|
||||||
|
baseName:
|
||||||
|
cert?.baseName ??
|
||||||
|
safeBaseName(
|
||||||
|
sources
|
||||||
|
.values()
|
||||||
|
.next()
|
||||||
|
.value?.replace(/\.[^.]+$/, '') ?? '',
|
||||||
|
'schluessel',
|
||||||
|
),
|
||||||
|
cn: cert?.cn ?? '',
|
||||||
|
organization: '',
|
||||||
|
issuerCn: '',
|
||||||
|
notBefore: null,
|
||||||
|
notAfter: null,
|
||||||
|
isExpired: null,
|
||||||
|
daysLeft: null,
|
||||||
|
san: [],
|
||||||
|
keyType: key.keyType,
|
||||||
|
keyBits: key.keyBits,
|
||||||
|
serialNumber: '',
|
||||||
|
sha256: '',
|
||||||
|
matchId: cert?.id ?? null,
|
||||||
|
chainIds: [],
|
||||||
|
formats: isRsa ? ['key', 'key-rsa', 'key-der'] : ['key'],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// CSRs: Duplikate zusammenfassen, Details lesen, dem Zertifikat zuordnen.
|
||||||
|
const csrMap = new Map<string, { pem: string; sources: Set<string> }>();
|
||||||
|
for (const csr of c.csrs) {
|
||||||
|
const norm = csr.pem.replace(/\s+/g, '');
|
||||||
|
const entry = csrMap.get(norm) ?? { pem: csr.pem, sources: new Set<string>() };
|
||||||
|
entry.sources.add(csr.source);
|
||||||
|
csrMap.set(norm, entry);
|
||||||
|
}
|
||||||
|
const csrItems: BundleItem[] = [...csrMap.values()].map(({ pem, sources }, i) => {
|
||||||
|
let cn = '';
|
||||||
|
let organization = '';
|
||||||
|
let info = { keyType: '', keyBits: 0, modulus: '' };
|
||||||
|
let san: string[] = [];
|
||||||
|
try {
|
||||||
|
const csr = forge.pki.certificationRequestFromPem(pem);
|
||||||
|
cn = field(csr.subject as forge.pki.Certificate['subject'], 'CN');
|
||||||
|
organization = field(csr.subject as forge.pki.Certificate['subject'], 'O');
|
||||||
|
info = publicKeyInfo(csr.publicKey);
|
||||||
|
const ext = csr.getAttribute({ name: 'extensionRequest' }) as {
|
||||||
|
extensions?: unknown[];
|
||||||
|
} | null;
|
||||||
|
san = sanOf(ext?.extensions);
|
||||||
|
} catch {
|
||||||
|
// EC-CSR: node-forge liest sie nicht — Teil bleibt trotzdem herunterladbar.
|
||||||
|
}
|
||||||
|
const cert = info.modulus ? internals.find((o) => o._modulus === info.modulus) : undefined;
|
||||||
|
return {
|
||||||
|
id: `csr-${i + 1}`,
|
||||||
|
kind: 'csr',
|
||||||
|
sources: [...sources],
|
||||||
|
pem,
|
||||||
|
baseName: cert?.baseName ?? safeBaseName(cn, 'anfrage'),
|
||||||
|
cn,
|
||||||
|
organization,
|
||||||
|
issuerCn: '',
|
||||||
|
notBefore: null,
|
||||||
|
notAfter: null,
|
||||||
|
isExpired: null,
|
||||||
|
daysLeft: null,
|
||||||
|
san,
|
||||||
|
keyType: info.keyType,
|
||||||
|
keyBits: info.keyBits,
|
||||||
|
serialNumber: '',
|
||||||
|
sha256: '',
|
||||||
|
matchId: cert?.id ?? null,
|
||||||
|
chainIds: [],
|
||||||
|
formats: ['csr', 'csr-der'],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Reihenfolge: Serverzertifikat(e), Zwischen-, Stammzertifikate, Schluessel, CSR.
|
||||||
|
const roleOrder: Record<BundleCertRole, number> = { 'end-entity': 0, intermediate: 1, root: 2 };
|
||||||
|
internals.sort(
|
||||||
|
(a, b) =>
|
||||||
|
roleOrder[a.role ?? 'end-entity'] - roleOrder[b.role ?? 'end-entity'] ||
|
||||||
|
b.chainIds.length - a.chainIds.length,
|
||||||
|
);
|
||||||
|
const certsClean: BundleItem[] = internals.map(({ _cert, _modulus, ...rest }) => rest);
|
||||||
|
|
||||||
|
return {
|
||||||
|
items: [...certsClean, ...keyItems, ...csrItems],
|
||||||
|
locked: [...new Set(c.locked)],
|
||||||
|
ignored: [...new Set(c.ignored)],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// exportBundleItem
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const MIME = {
|
||||||
|
pem: 'application/x-pem-file',
|
||||||
|
der: 'application/x-x509-ca-cert',
|
||||||
|
p7b: 'application/x-pkcs7-certificates',
|
||||||
|
pfx: 'application/x-pkcs12',
|
||||||
|
key: 'application/x-pem-file',
|
||||||
|
octet: 'application/octet-stream',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
function pemBody(pem: string): string {
|
||||||
|
const [msg] = forge.pem.decode(pem);
|
||||||
|
if (!msg) throw new Error('no PEM block');
|
||||||
|
return msg.body;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function exportBundleItem(input: BundleExportInput): BundleExportFile {
|
||||||
|
const { kind, pem, format, chain = [], keyPem, password } = input;
|
||||||
|
const base = safeBaseName(
|
||||||
|
input.baseName ?? '',
|
||||||
|
kind === 'csr' ? 'anfrage' : kind === 'privateKey' ? 'schluessel' : 'zertifikat',
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!pem || typeof pem !== 'string') throw new BadRequestException('No PEM provided');
|
||||||
|
if (format === 'pfx' && (!password || password.trim() === '')) {
|
||||||
|
throw new BadRequestException('A password is required for PFX output');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (kind === 'certificate') {
|
||||||
|
const cert = forge.pki.certificateFromPem(pem);
|
||||||
|
const chainCerts = chain.map((p) => forge.pki.certificateFromPem(p));
|
||||||
|
switch (format) {
|
||||||
|
case 'crt':
|
||||||
|
return {
|
||||||
|
filename: `${base}.crt`,
|
||||||
|
content: textToBase64(forge.pki.certificateToPem(cert)),
|
||||||
|
mimeType: MIME.pem,
|
||||||
|
};
|
||||||
|
case 'cer':
|
||||||
|
return {
|
||||||
|
filename: `${base}.cer`,
|
||||||
|
content: bytesToBase64(forge.asn1.toDer(forge.pki.certificateToAsn1(cert)).getBytes()),
|
||||||
|
mimeType: MIME.der,
|
||||||
|
};
|
||||||
|
case 'fullchain': {
|
||||||
|
const text = [cert, ...chainCerts].map((x) => forge.pki.certificateToPem(x)).join('');
|
||||||
|
return {
|
||||||
|
filename: `${base}-fullchain.pem`,
|
||||||
|
content: textToBase64(text),
|
||||||
|
mimeType: MIME.pem,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case 'p7b': {
|
||||||
|
const p7 = forge.pkcs7.createSignedData();
|
||||||
|
for (const x of [cert, ...chainCerts]) p7.addCertificate(x);
|
||||||
|
const text = forge.pem.encode({
|
||||||
|
type: 'PKCS7',
|
||||||
|
body: forge.asn1.toDer(p7.toAsn1()).getBytes(),
|
||||||
|
});
|
||||||
|
return { filename: `${base}.p7b`, content: textToBase64(text), mimeType: MIME.p7b };
|
||||||
|
}
|
||||||
|
case 'pfx': {
|
||||||
|
const key = keyPem
|
||||||
|
? (forge.pki.privateKeyFromPem(keyPem) as forge.pki.rsa.PrivateKey)
|
||||||
|
: null;
|
||||||
|
const p12 = forge.pkcs12.toPkcs12Asn1(
|
||||||
|
// null = reines Zertifikatsbuendel ohne Schluessel (siehe
|
||||||
|
// CertManagerService.mergeCerts).
|
||||||
|
key,
|
||||||
|
[cert, ...chainCerts],
|
||||||
|
password as string, // oben geprueft: PFX verlangt ein Passwort
|
||||||
|
{ algorithm: '3des', friendlyName: input.baseName || undefined },
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
filename: `${base}.pfx`,
|
||||||
|
content: bytesToBase64(forge.asn1.toDer(p12).getBytes()),
|
||||||
|
mimeType: MIME.pfx,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (kind === 'privateKey') {
|
||||||
|
switch (format) {
|
||||||
|
case 'key':
|
||||||
|
return {
|
||||||
|
filename: `${base}.key`,
|
||||||
|
content: textToBase64(`${pem.trim()}\n`),
|
||||||
|
mimeType: MIME.key,
|
||||||
|
};
|
||||||
|
case 'key-rsa': {
|
||||||
|
const key = forge.pki.privateKeyFromPem(pem);
|
||||||
|
return {
|
||||||
|
filename: `${base}.rsa.key`,
|
||||||
|
content: textToBase64(forge.pki.privateKeyToPem(key)),
|
||||||
|
mimeType: MIME.key,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
case 'key-der': {
|
||||||
|
const key = forge.pki.privateKeyFromPem(pem);
|
||||||
|
const info = forge.pki.wrapRsaPrivateKey(forge.pki.privateKeyToAsn1(key));
|
||||||
|
return {
|
||||||
|
filename: `${base}.key.der`,
|
||||||
|
content: bytesToBase64(forge.asn1.toDer(info).getBytes()),
|
||||||
|
mimeType: MIME.octet,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else if (kind === 'csr') {
|
||||||
|
switch (format) {
|
||||||
|
case 'csr':
|
||||||
|
return {
|
||||||
|
filename: `${base}.csr`,
|
||||||
|
content: textToBase64(`${pem.trim()}\n`),
|
||||||
|
mimeType: MIME.pem,
|
||||||
|
};
|
||||||
|
case 'csr-der':
|
||||||
|
return {
|
||||||
|
filename: `${base}.csr.der`,
|
||||||
|
content: bytesToBase64(pemBody(pem)),
|
||||||
|
mimeType: MIME.octet,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Passwort und Schluessel nie protokollieren oder zurueckgeben.
|
||||||
|
throw new BadRequestException(`Failed to export ${kind} as ${format}`);
|
||||||
|
}
|
||||||
|
throw new BadRequestException(`Unsupported format "${format}" for ${kind}`);
|
||||||
|
}
|
||||||
@@ -10,6 +10,12 @@ import {
|
|||||||
import { FileInterceptor, FilesInterceptor } from '@nestjs/platform-express';
|
import { FileInterceptor, FilesInterceptor } from '@nestjs/platform-express';
|
||||||
import { UseModule } from '../module-registry/module.guard';
|
import { UseModule } from '../module-registry/module.guard';
|
||||||
import type { UploadedFileLike } from '../auth/types/auth-user';
|
import type { UploadedFileLike } from '../auth/types/auth-user';
|
||||||
|
import {
|
||||||
|
analyzeBundle,
|
||||||
|
type BundleExportFormat,
|
||||||
|
type BundleItemKind,
|
||||||
|
exportBundleItem,
|
||||||
|
} from './cert-bundle';
|
||||||
import { CertManagerService } from './cert-manager.service';
|
import { CertManagerService } from './cert-manager.service';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -118,4 +124,50 @@ export class CertManagerController {
|
|||||||
}
|
}
|
||||||
return this.certManagerService.convertCert({ file, pemText, targetFormat, password });
|
return this.certManagerService.convertCert({ file, pemText, targetFormat, password });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /modules/cert-manager/analyze (quick-261001-l4q)
|
||||||
|
* Zertifikatspaket: mehrere Dateien oder ZIP hochladen, jedes Teil erkennen
|
||||||
|
* (Server-/Zwischen-/Stammzertifikat, privater Schluessel, CSR), Duplikate
|
||||||
|
* zusammenfassen, Schluessel und Kette zuordnen.
|
||||||
|
*
|
||||||
|
* T-09-03: 20 Dateien, je 5 MB; ZIP-Inhalt zusaetzlich begrenzt (cert-bundle.ts).
|
||||||
|
* T-09-02: password is never passed to the logger
|
||||||
|
*/
|
||||||
|
@Post('analyze')
|
||||||
|
@UseInterceptors(
|
||||||
|
FilesInterceptor('files', 20, {
|
||||||
|
limits: { fileSize: 5 * 1024 * 1024 },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
async analyze(
|
||||||
|
@UploadedFiles() files: UploadedFileLike[] | undefined,
|
||||||
|
@Body('password') password?: string,
|
||||||
|
) {
|
||||||
|
return analyzeBundle(files ?? [], password ?? '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /modules/cert-manager/export (quick-261001-l4q)
|
||||||
|
* Ein Teil aus `analyze` (PEM) in das gewuenschte Format bringen.
|
||||||
|
* JSON-Body; PFX verlangt ein Passwort fuer die neue Datei.
|
||||||
|
*/
|
||||||
|
@Post('export')
|
||||||
|
async export(
|
||||||
|
@Body('kind') kind: BundleItemKind,
|
||||||
|
@Body('pem') pem: string,
|
||||||
|
@Body('format') format: BundleExportFormat,
|
||||||
|
@Body('baseName') baseName?: string,
|
||||||
|
@Body('chain') chain?: string[],
|
||||||
|
@Body('keyPem') keyPem?: string,
|
||||||
|
@Body('password') password?: string,
|
||||||
|
) {
|
||||||
|
if (
|
||||||
|
chain !== undefined &&
|
||||||
|
(!Array.isArray(chain) || chain.some((c) => typeof c !== 'string'))
|
||||||
|
) {
|
||||||
|
throw new BadRequestException('chain must be a list of PEM strings');
|
||||||
|
}
|
||||||
|
return exportBundleItem({ kind, pem, format, baseName, chain, keyPem, password });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -208,3 +208,81 @@ export async function postForm(
|
|||||||
|
|
||||||
return response.json();
|
return response.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Zertifikatspaket (quick-261001-l4q) — spiegelt apps/api/src/cert-manager/cert-bundle.ts
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export type BundleItemKind = 'certificate' | 'privateKey' | 'csr';
|
||||||
|
export type BundleExportFormat =
|
||||||
|
| 'crt'
|
||||||
|
| 'cer'
|
||||||
|
| 'fullchain'
|
||||||
|
| 'p7b'
|
||||||
|
| 'pfx'
|
||||||
|
| 'key'
|
||||||
|
| 'key-rsa'
|
||||||
|
| 'key-der'
|
||||||
|
| 'csr'
|
||||||
|
| 'csr-der';
|
||||||
|
|
||||||
|
export interface BundleItem {
|
||||||
|
id: string;
|
||||||
|
kind: BundleItemKind;
|
||||||
|
role?: CertRole;
|
||||||
|
sources: string[];
|
||||||
|
pem: string;
|
||||||
|
baseName: string;
|
||||||
|
cn: string;
|
||||||
|
organization: string;
|
||||||
|
issuerCn: string;
|
||||||
|
notBefore: string | null;
|
||||||
|
notAfter: string | null;
|
||||||
|
isExpired: boolean | null;
|
||||||
|
daysLeft: number | null;
|
||||||
|
san: string[];
|
||||||
|
keyType: string;
|
||||||
|
keyBits: number;
|
||||||
|
serialNumber: string;
|
||||||
|
sha256: string;
|
||||||
|
matchId: string | null;
|
||||||
|
chainIds: string[];
|
||||||
|
formats: BundleExportFormat[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BundleAnalysis {
|
||||||
|
items: BundleItem[];
|
||||||
|
locked: string[];
|
||||||
|
ignored: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Mehrere Dateien (auch ZIP) analysieren — POST /modules/cert-manager/analyze. */
|
||||||
|
export async function analyzeBundleAction(files: File[], password?: string): Promise<BundleAnalysis> {
|
||||||
|
const form = new FormData();
|
||||||
|
for (const file of files) form.append('files', file);
|
||||||
|
if (password) form.append('password', password);
|
||||||
|
return postForm('analyze', form) as Promise<BundleAnalysis>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ein Teil des Pakets in ein Format bringen — POST /modules/cert-manager/export (JSON). */
|
||||||
|
export async function exportBundleItemAction(input: {
|
||||||
|
kind: BundleItemKind;
|
||||||
|
pem: string;
|
||||||
|
format: BundleExportFormat;
|
||||||
|
baseName: string;
|
||||||
|
chain?: string[];
|
||||||
|
keyPem?: string;
|
||||||
|
password?: string;
|
||||||
|
}): Promise<FileResponse> {
|
||||||
|
const response = await fetch(`${API_URL}/modules/cert-manager/export`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
if (!response.ok) {
|
||||||
|
const body = await response.text().catch(() => '');
|
||||||
|
throw new Error(`${response.status} ${body}`.trim());
|
||||||
|
}
|
||||||
|
return response.json() as Promise<FileResponse>;
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ vi.mock('next-intl', () => ({
|
|||||||
const certManager: Record<string, string> = {
|
const certManager: Record<string, string> = {
|
||||||
'title': 'Zertifikat-Manager',
|
'title': 'Zertifikat-Manager',
|
||||||
'description': 'Zertifikate analysieren, aufteilen, zusammenfuehren und konvertieren.',
|
'description': 'Zertifikate analysieren, aufteilen, zusammenfuehren und konvertieren.',
|
||||||
|
'tabs.overview': 'Uebersicht',
|
||||||
'tabs.inspect': 'Analysieren',
|
'tabs.inspect': 'Analysieren',
|
||||||
'tabs.split': 'Aufteilen',
|
'tabs.split': 'Aufteilen',
|
||||||
'tabs.merge': 'Zusammenfuehren',
|
'tabs.merge': 'Zusammenfuehren',
|
||||||
@@ -76,8 +77,15 @@ describe('CertManagerPage shell', () => {
|
|||||||
expect(screen.queryByText('Passwort (PFX/P12)')).not.toBeInTheDocument();
|
expect(screen.queryByText('Passwort (PFX/P12)')).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows Inspect tab empty state on initial render', () => {
|
it('startet mit der Uebersicht ohne Einzeldatei-Eingabe (quick-261001-l4q)', () => {
|
||||||
render(<CertManagerPage />);
|
render(<CertManagerPage />);
|
||||||
|
expect(screen.getByText('dropTitle')).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText('Datei hierher ziehen oder klicken')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows Inspect tab empty state when Inspect tab is active', () => {
|
||||||
|
render(<CertManagerPage />);
|
||||||
|
fireEvent.click(screen.getAllByText('Analysieren')[0]);
|
||||||
expect(screen.getByText('Kein Zertifikat geladen.')).toBeInTheDocument();
|
expect(screen.getByText('Kein Zertifikat geladen.')).toBeInTheDocument();
|
||||||
expect(screen.getByText('Lade eine Datei hoch oder fuege PEM-Text ein.')).toBeInTheDocument();
|
expect(screen.getByText('Lade eine Datei hoch oder fuege PEM-Text ein.')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,202 @@
|
|||||||
|
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
vi.mock('next-intl', () => ({
|
||||||
|
useTranslations: () => (key: string, values?: Record<string, unknown>) =>
|
||||||
|
values ? `${key} ${JSON.stringify(values)}` : key,
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../actions', () => ({
|
||||||
|
analyzeBundleAction: vi.fn(),
|
||||||
|
exportBundleItemAction: vi.fn(),
|
||||||
|
downloadBase64: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import {
|
||||||
|
analyzeBundleAction,
|
||||||
|
type BundleItem,
|
||||||
|
downloadBase64,
|
||||||
|
exportBundleItemAction,
|
||||||
|
} from '../actions';
|
||||||
|
import { OverviewTab } from './OverviewTab';
|
||||||
|
|
||||||
|
const mockAnalyze = analyzeBundleAction as ReturnType<typeof vi.fn>;
|
||||||
|
const mockExport = exportBundleItemAction as ReturnType<typeof vi.fn>;
|
||||||
|
const mockDownload = downloadBase64 as ReturnType<typeof vi.fn>;
|
||||||
|
|
||||||
|
function item(over: Partial<BundleItem>): BundleItem {
|
||||||
|
return {
|
||||||
|
id: 'x',
|
||||||
|
kind: 'certificate',
|
||||||
|
sources: ['a.pem'],
|
||||||
|
pem: 'PEM',
|
||||||
|
baseName: 'www.example.test',
|
||||||
|
cn: 'www.example.test',
|
||||||
|
organization: '',
|
||||||
|
issuerCn: '',
|
||||||
|
notBefore: null,
|
||||||
|
notAfter: null,
|
||||||
|
isExpired: null,
|
||||||
|
daysLeft: null,
|
||||||
|
san: [],
|
||||||
|
keyType: 'RSA',
|
||||||
|
keyBits: 2048,
|
||||||
|
serialNumber: '',
|
||||||
|
sha256: '',
|
||||||
|
matchId: null,
|
||||||
|
chainIds: [],
|
||||||
|
formats: [],
|
||||||
|
...over,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const LEAF = item({
|
||||||
|
id: 'leaf',
|
||||||
|
role: 'end-entity',
|
||||||
|
pem: 'LEAF-PEM',
|
||||||
|
issuerCn: 'Test Intermediate CA',
|
||||||
|
notBefore: '2026-01-01T00:00:00.000Z',
|
||||||
|
notAfter: '2027-01-01T00:00:00.000Z',
|
||||||
|
isExpired: false,
|
||||||
|
daysLeft: 200,
|
||||||
|
san: ['www.example.test', 'example.test'],
|
||||||
|
matchId: 'key',
|
||||||
|
chainIds: ['inter'],
|
||||||
|
formats: ['crt', 'cer', 'fullchain', 'p7b', 'pfx'],
|
||||||
|
});
|
||||||
|
const INTER = item({
|
||||||
|
id: 'inter',
|
||||||
|
role: 'intermediate',
|
||||||
|
pem: 'INTER-PEM',
|
||||||
|
cn: 'Test Intermediate CA',
|
||||||
|
isExpired: false,
|
||||||
|
daysLeft: 900,
|
||||||
|
formats: ['crt', 'cer', 'fullchain', 'p7b', 'pfx'],
|
||||||
|
});
|
||||||
|
const KEY = item({
|
||||||
|
id: 'key',
|
||||||
|
kind: 'privateKey',
|
||||||
|
pem: 'KEY-PEM',
|
||||||
|
matchId: 'leaf',
|
||||||
|
formats: ['key', 'key-rsa', 'key-der'],
|
||||||
|
});
|
||||||
|
const CSR = item({
|
||||||
|
id: 'csr',
|
||||||
|
kind: 'csr',
|
||||||
|
pem: 'CSR-PEM',
|
||||||
|
matchId: 'leaf',
|
||||||
|
formats: ['csr', 'csr-der'],
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function upload(files: File[]) {
|
||||||
|
render(<OverviewTab />);
|
||||||
|
fireEvent.change(screen.getByTestId('overview-file-input'), { target: { files } });
|
||||||
|
await waitFor(() => expect(mockAnalyze).toHaveBeenCalled());
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('OverviewTab', () => {
|
||||||
|
it('analysiert die abgelegten Dateien und zeigt je Teil Typ und Download-Knoepfe', async () => {
|
||||||
|
mockAnalyze.mockResolvedValue({
|
||||||
|
items: [LEAF, INTER, KEY, CSR],
|
||||||
|
locked: [],
|
||||||
|
ignored: ['.dnstxtrecord'],
|
||||||
|
});
|
||||||
|
const zip = new File(['zip'], 'paket.zip');
|
||||||
|
await upload([zip]);
|
||||||
|
|
||||||
|
expect(mockAnalyze).toHaveBeenCalledWith([zip], undefined);
|
||||||
|
const cards = await screen.findAllByTestId('bundle-item');
|
||||||
|
expect(cards).toHaveLength(4);
|
||||||
|
expect(within(cards[0]).getByText('type.end-entity')).toBeInTheDocument();
|
||||||
|
expect(within(cards[1]).getByText('type.intermediate')).toBeInTheDocument();
|
||||||
|
expect(within(cards[2]).getByText('type.privateKey')).toBeInTheDocument();
|
||||||
|
expect(within(cards[3]).getByText('type.csr')).toBeInTheDocument();
|
||||||
|
// Zuordnung und Gueltigkeit
|
||||||
|
expect(within(cards[0]).getByText('www.example.test, example.test')).toBeInTheDocument();
|
||||||
|
expect(within(cards[0]).getByText('valid')).toBeInTheDocument();
|
||||||
|
expect(within(cards[2]).getByText(/type\.end-entity/)).toBeInTheDocument();
|
||||||
|
// alle Formate als Knoepfe
|
||||||
|
expect(
|
||||||
|
within(cards[0])
|
||||||
|
.getAllByRole('button')
|
||||||
|
.map((b) => b.textContent),
|
||||||
|
).toEqual(['format.crt', 'format.cer', 'format.fullchain', 'format.p7b', 'format.pfx']);
|
||||||
|
expect(within(cards[2]).getAllByRole('button')).toHaveLength(3);
|
||||||
|
expect(screen.getByText(/ignored/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Download schickt Kette und Schluessel mit und loest die Datei aus', async () => {
|
||||||
|
mockAnalyze.mockResolvedValue({ items: [LEAF, INTER, KEY], locked: [], ignored: [] });
|
||||||
|
mockExport.mockResolvedValue({
|
||||||
|
filename: 'www.example.test-fullchain.pem',
|
||||||
|
content: 'Zm9v',
|
||||||
|
mimeType: 'x',
|
||||||
|
});
|
||||||
|
await upload([new File(['x'], 'a.pem')]);
|
||||||
|
const [leafCard] = await screen.findAllByTestId('bundle-item');
|
||||||
|
|
||||||
|
fireEvent.click(within(leafCard).getByText('format.fullchain'));
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(mockDownload).toHaveBeenCalledWith('www.example.test-fullchain.pem', 'Zm9v', 'x'),
|
||||||
|
);
|
||||||
|
expect(mockExport).toHaveBeenCalledWith({
|
||||||
|
kind: 'certificate',
|
||||||
|
pem: 'LEAF-PEM',
|
||||||
|
format: 'fullchain',
|
||||||
|
baseName: 'www.example.test',
|
||||||
|
chain: ['INTER-PEM'],
|
||||||
|
keyPem: 'KEY-PEM',
|
||||||
|
password: undefined,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('PFX fragt erst ein Passwort ab', async () => {
|
||||||
|
mockAnalyze.mockResolvedValue({ items: [LEAF, INTER, KEY], locked: [], ignored: [] });
|
||||||
|
mockExport.mockResolvedValue({
|
||||||
|
filename: 'www.example.test.pfx',
|
||||||
|
content: 'Zm9v',
|
||||||
|
mimeType: 'x',
|
||||||
|
});
|
||||||
|
await upload([new File(['x'], 'a.pem')]);
|
||||||
|
const [leafCard] = await screen.findAllByTestId('bundle-item');
|
||||||
|
|
||||||
|
fireEvent.click(within(leafCard).getByText('format.pfx'));
|
||||||
|
expect(mockExport).not.toHaveBeenCalled();
|
||||||
|
expect(within(leafCard).getByText('pfxWithKey')).toBeInTheDocument();
|
||||||
|
const download = within(leafCard).getByText('pfxDownload');
|
||||||
|
expect(download).toBeDisabled();
|
||||||
|
|
||||||
|
fireEvent.change(within(leafCard).getByLabelText('pfxPassword'), {
|
||||||
|
target: { value: 'geheim' },
|
||||||
|
});
|
||||||
|
fireEvent.click(download);
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(mockExport).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ format: 'pfx', password: 'geheim' }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('geschuetzte PFX: Passwort eingeben und erneut pruefen', async () => {
|
||||||
|
mockAnalyze.mockResolvedValueOnce({ items: [LEAF], locked: ['a.pfx'], ignored: [] });
|
||||||
|
const pfx = new File(['x'], 'a.pfx');
|
||||||
|
await upload([pfx]);
|
||||||
|
|
||||||
|
fireEvent.change(await screen.findByLabelText('lockedPassword'), { target: { value: 'pw' } });
|
||||||
|
mockAnalyze.mockResolvedValueOnce({ items: [LEAF, KEY], locked: [], ignored: [] });
|
||||||
|
fireEvent.click(screen.getByText('unlock'));
|
||||||
|
await waitFor(() => expect(mockAnalyze).toHaveBeenLastCalledWith([pfx], 'pw'));
|
||||||
|
expect(await screen.findAllByTestId('bundle-item')).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Fehler beim Pruefen wird angezeigt', async () => {
|
||||||
|
mockAnalyze.mockRejectedValue(new Error('400'));
|
||||||
|
await upload([new File(['x'], 'a.txt')]);
|
||||||
|
expect(await screen.findByText('error')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,355 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
import { useRef, useState } from 'react';
|
||||||
|
import {
|
||||||
|
analyzeBundleAction,
|
||||||
|
type BundleAnalysis,
|
||||||
|
type BundleExportFormat,
|
||||||
|
type BundleItem,
|
||||||
|
downloadBase64,
|
||||||
|
exportBundleItemAction,
|
||||||
|
} from '../actions';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* OverviewTab (quick-261001-l4q) — Zertifikatspaket vom Aussteller auf einmal
|
||||||
|
* hochladen (mehrere Dateien oder ZIP), sehen, welche Datei was ist, und jedes
|
||||||
|
* Teil in jedem passenden Format herunterladen. Die Erkennung macht der Server
|
||||||
|
* (POST analyze), der Export ebenfalls (POST export) — beides zustandslos.
|
||||||
|
*
|
||||||
|
* T-09-02: Passwoerter leben nur im lokalen Zustand, nie in URL oder Log.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const ACCEPT = '.zip,.pem,.crt,.cer,.der,.pfx,.p12,.p7b,.p7c,.key,.csr';
|
||||||
|
|
||||||
|
const ROLE_STYLES: Record<string, string> = {
|
||||||
|
'end-entity': 'bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300',
|
||||||
|
intermediate: 'bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300',
|
||||||
|
root: 'bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-300',
|
||||||
|
privateKey: 'bg-violet-100 text-violet-800 dark:bg-violet-900/40 dark:text-violet-300',
|
||||||
|
csr: 'bg-muted text-muted-foreground',
|
||||||
|
};
|
||||||
|
|
||||||
|
function typeKey(item: BundleItem): string {
|
||||||
|
return item.kind === 'certificate' ? (item.role ?? 'end-entity') : item.kind;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** In UTC wie im Zertifikat: „bis 23:59:59 UTC“ ist bei uns schon der Folgetag — der Aussteller nennt aber dieses Datum. */
|
||||||
|
function formatDate(iso: string | null): string {
|
||||||
|
if (!iso) return '';
|
||||||
|
return new Date(iso).toLocaleDateString('de-DE', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: 'numeric',
|
||||||
|
timeZone: 'UTC',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OverviewTab() {
|
||||||
|
const t = useTranslations('certManager.overview');
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [files, setFiles] = useState<File[]>([]);
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [isDragOver, setIsDragOver] = useState(false);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [result, setResult] = useState<BundleAnalysis | null>(null);
|
||||||
|
|
||||||
|
async function analyze(next: File[], pw: string) {
|
||||||
|
if (next.length === 0) {
|
||||||
|
setResult(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
setResult(await analyzeBundleAction(next, pw || undefined));
|
||||||
|
} catch {
|
||||||
|
setResult(null);
|
||||||
|
setError(t('error'));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function addFiles(list: FileList | null) {
|
||||||
|
const added = Array.from(list ?? []);
|
||||||
|
if (added.length === 0) return;
|
||||||
|
const next = [...files, ...added];
|
||||||
|
setFiles(next);
|
||||||
|
void analyze(next, password);
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeFile(index: number) {
|
||||||
|
const next = files.filter((_, i) => i !== index);
|
||||||
|
setFiles(next);
|
||||||
|
void analyze(next, password);
|
||||||
|
}
|
||||||
|
|
||||||
|
function reset() {
|
||||||
|
setFiles([]);
|
||||||
|
setPassword('');
|
||||||
|
setResult(null);
|
||||||
|
setError(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
const byId = Object.fromEntries((result?.items ?? []).map((i) => [i.id, i]));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="file"
|
||||||
|
multiple
|
||||||
|
accept={ACCEPT}
|
||||||
|
className="hidden"
|
||||||
|
data-testid="overview-file-input"
|
||||||
|
onChange={(e) => {
|
||||||
|
addFiles(e.target.files);
|
||||||
|
e.target.value = '';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => inputRef.current?.click()}
|
||||||
|
onDragOver={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsDragOver(true);
|
||||||
|
}}
|
||||||
|
onDragLeave={() => setIsDragOver(false)}
|
||||||
|
onDrop={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setIsDragOver(false);
|
||||||
|
addFiles(e.dataTransfer.files);
|
||||||
|
}}
|
||||||
|
className={`block w-full cursor-pointer rounded-lg border-2 border-dashed p-8 text-center transition-colors ${
|
||||||
|
isDragOver ? 'border-primary bg-primary/5' : 'border-border hover:border-primary/50'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<p className="text-sm text-foreground">{t('dropTitle')}</p>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">{t('dropHint')}</p>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{files.length > 0 && (
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
{files.map((file, i) => (
|
||||||
|
<span
|
||||||
|
// biome-ignore lint/suspicious/noArrayIndexKey: dieselbe Datei darf zweimal in der Liste stehen; die Liste aendert sich nur durch Anhaengen/Entfernen
|
||||||
|
key={`${file.name}-${i}`}
|
||||||
|
className="inline-flex items-center gap-1.5 rounded border border-border px-2 py-1 text-xs"
|
||||||
|
>
|
||||||
|
{file.name}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-label={t('removeFile', { name: file.name })}
|
||||||
|
onClick={() => removeFile(i)}
|
||||||
|
className="text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
<button type="button" onClick={reset} className="text-xs text-muted-foreground underline">
|
||||||
|
{t('reset')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading && <p className="text-sm text-muted-foreground">{t('analyzing')}</p>}
|
||||||
|
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||||
|
|
||||||
|
{result && result.locked.length > 0 && (
|
||||||
|
<div className="space-y-2 rounded-lg border border-status-warn/50 bg-status-warn/10 p-4">
|
||||||
|
<p className="text-sm text-foreground">
|
||||||
|
{t('locked', { files: result.locked.join(', ') })}
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
placeholder={t('lockedPassword')}
|
||||||
|
aria-label={t('lockedPassword')}
|
||||||
|
autoComplete="off"
|
||||||
|
className="w-64 rounded border border-border bg-background px-3 py-1.5 text-sm"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary"
|
||||||
|
disabled={!password || loading}
|
||||||
|
onClick={() => void analyze(files, password)}
|
||||||
|
>
|
||||||
|
{t('unlock')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{result && result.items.length === 0 && !loading && (
|
||||||
|
<p className="text-sm text-muted-foreground">{t('nothingFound')}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{result && result.items.length > 0 && (
|
||||||
|
<ul className="space-y-4">
|
||||||
|
{result.items.map((item) => (
|
||||||
|
<BundleItemCard key={item.id} item={item} byId={byId} />
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{result && result.ignored.length > 0 && (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t('ignored', { files: result.ignored.join(', ') })}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function BundleItemCard({ item, byId }: { item: BundleItem; byId: Record<string, BundleItem> }) {
|
||||||
|
const t = useTranslations('certManager.overview');
|
||||||
|
const [pfxOpen, setPfxOpen] = useState(false);
|
||||||
|
const [pfxPassword, setPfxPassword] = useState('');
|
||||||
|
const [busy, setBusy] = useState<BundleExportFormat | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const match = item.matchId ? byId[item.matchId] : undefined;
|
||||||
|
const chain = item.chainIds.map((id) => byId[id]).filter(Boolean);
|
||||||
|
|
||||||
|
async function download(format: BundleExportFormat, password?: string) {
|
||||||
|
setBusy(format);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const file = await exportBundleItemAction({
|
||||||
|
kind: item.kind,
|
||||||
|
pem: item.pem,
|
||||||
|
format,
|
||||||
|
baseName: item.baseName,
|
||||||
|
chain: item.kind === 'certificate' ? chain.map((c) => c.pem) : undefined,
|
||||||
|
keyPem: item.kind === 'certificate' && match ? match.pem : undefined,
|
||||||
|
password,
|
||||||
|
});
|
||||||
|
downloadBase64(file.filename, file.content, file.mimeType);
|
||||||
|
if (format === 'pfx') {
|
||||||
|
setPfxOpen(false);
|
||||||
|
setPfxPassword('');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setError(t('exportError'));
|
||||||
|
} finally {
|
||||||
|
setBusy(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const status =
|
||||||
|
item.isExpired === null
|
||||||
|
? null
|
||||||
|
: item.isExpired
|
||||||
|
? { cls: 'bg-status-down/15 text-status-down-fg', text: t('expired') }
|
||||||
|
: (item.daysLeft ?? 0) <= 30
|
||||||
|
? {
|
||||||
|
cls: 'bg-status-warn/15 text-status-warn-fg',
|
||||||
|
text: t('expiresSoon', { days: item.daysLeft ?? 0 }),
|
||||||
|
}
|
||||||
|
: { cls: 'bg-status-ok/15 text-status-ok-fg', text: t('valid') };
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li className="rounded-lg border border-border p-4" data-testid="bundle-item">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<span className={`rounded px-2 py-0.5 text-xs font-semibold ${ROLE_STYLES[typeKey(item)]}`}>
|
||||||
|
{t(`type.${typeKey(item)}`)}
|
||||||
|
</span>
|
||||||
|
<span className="font-medium break-all">{item.cn || item.baseName}</span>
|
||||||
|
{status && (
|
||||||
|
<span className={`rounded px-2 py-0.5 text-xs font-medium ${status.cls}`}>
|
||||||
|
{status.text}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">{t(`explain.${typeKey(item)}`)}</p>
|
||||||
|
|
||||||
|
<dl className="mt-3 grid grid-cols-[max-content_1fr] gap-x-4 gap-y-1 text-sm">
|
||||||
|
{item.kind === 'certificate' && (
|
||||||
|
<>
|
||||||
|
<dt className="text-muted-foreground">{t('issuer')}</dt>
|
||||||
|
<dd className="break-all">{item.issuerCn}</dd>
|
||||||
|
<dt className="text-muted-foreground">{t('validity')}</dt>
|
||||||
|
<dd>
|
||||||
|
{formatDate(item.notBefore)} – {formatDate(item.notAfter)}
|
||||||
|
</dd>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{item.san.length > 0 && (
|
||||||
|
<>
|
||||||
|
<dt className="text-muted-foreground">{t('names')}</dt>
|
||||||
|
<dd className="break-all">{item.san.join(', ')}</dd>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{item.keyType && (
|
||||||
|
<>
|
||||||
|
<dt className="text-muted-foreground">{t('key')}</dt>
|
||||||
|
<dd>{item.keyBits > 0 ? `${item.keyType} ${item.keyBits} Bit` : item.keyType}</dd>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{match && (
|
||||||
|
<>
|
||||||
|
<dt className="text-muted-foreground">{t('belongsTo')}</dt>
|
||||||
|
<dd className="break-all">
|
||||||
|
{t(`type.${typeKey(match)}`)} {match.cn}
|
||||||
|
</dd>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<dt className="text-muted-foreground">{t('source')}</dt>
|
||||||
|
<dd className="break-all">{item.sources.join(', ')}</dd>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
{item.kind === 'privateKey' && (
|
||||||
|
<p className="mt-2 text-xs text-status-warn-fg">{t('keySecret')}</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-4 flex flex-wrap gap-2">
|
||||||
|
{item.formats.map((format) => (
|
||||||
|
<button
|
||||||
|
key={format}
|
||||||
|
type="button"
|
||||||
|
disabled={busy !== null}
|
||||||
|
onClick={() => (format === 'pfx' ? setPfxOpen((o) => !o) : void download(format))}
|
||||||
|
className="rounded border border-border px-3 py-1.5 text-xs font-medium transition-colors hover:bg-secondary disabled:opacity-50"
|
||||||
|
title={t(`formatHint.${format}`)}
|
||||||
|
>
|
||||||
|
{busy === format ? t('downloading') : t(`format.${format}`)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{pfxOpen && (
|
||||||
|
<div className="mt-3 space-y-2 rounded border border-border p-3">
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{match ? t('pfxWithKey') : t('pfxWithoutKey')}
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={pfxPassword}
|
||||||
|
onChange={(e) => setPfxPassword(e.target.value)}
|
||||||
|
placeholder={t('pfxPassword')}
|
||||||
|
aria-label={t('pfxPassword')}
|
||||||
|
autoComplete="new-password"
|
||||||
|
className="w-64 rounded border border-border bg-background px-3 py-1.5 text-sm"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary"
|
||||||
|
disabled={!pfxPassword || busy !== null}
|
||||||
|
onClick={() => void download('pfx', pfxPassword)}
|
||||||
|
>
|
||||||
|
{busy === 'pfx' ? t('downloading') : t('pfxDownload')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && <p className="mt-2 text-sm text-destructive">{error}</p>}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -9,10 +9,11 @@ import { InspectTab } from './components/InspectTab';
|
|||||||
import { SplitTab } from './components/SplitTab';
|
import { SplitTab } from './components/SplitTab';
|
||||||
import { MergeTab } from './components/MergeTab';
|
import { MergeTab } from './components/MergeTab';
|
||||||
import { ConvertTab } from './components/ConvertTab';
|
import { ConvertTab } from './components/ConvertTab';
|
||||||
|
import { OverviewTab } from './components/OverviewTab';
|
||||||
|
|
||||||
type TabId = 'inspect' | 'split' | 'merge' | 'convert';
|
type TabId = 'overview' | 'inspect' | 'split' | 'merge' | 'convert';
|
||||||
|
|
||||||
const TABS: TabId[] = ['inspect', 'split', 'merge', 'convert'];
|
const TABS: TabId[] = ['overview', 'inspect', 'split', 'merge', 'convert'];
|
||||||
|
|
||||||
/** Returns true if the selected file is a PFX/P12 (requires decryption password) */
|
/** Returns true if the selected file is a PFX/P12 (requires decryption password) */
|
||||||
function isPfxFile(file: File | null): boolean {
|
function isPfxFile(file: File | null): boolean {
|
||||||
@@ -23,6 +24,7 @@ function isPfxFile(file: File | null): boolean {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* CertManagerPage — tab-based shell for certificate operations.
|
* CertManagerPage — tab-based shell for certificate operations.
|
||||||
|
* quick-261001-l4q: erster Reiter „Übersicht“ fuer ganze Zertifikatspakete.
|
||||||
* Layout per UI-SPEC: max-w-4xl, shared input card, tab nav, tab content card.
|
* Layout per UI-SPEC: max-w-4xl, shared input card, tab nav, tab content card.
|
||||||
* T-09-02: password lives in local React state only; never logged or placed in URLs.
|
* T-09-02: password lives in local React state only; never logged or placed in URLs.
|
||||||
* T-09-04: all API calls via postForm() which sends credentials:'include'.
|
* T-09-04: all API calls via postForm() which sends credentials:'include'.
|
||||||
@@ -34,7 +36,7 @@ function isPfxFile(file: File | null): boolean {
|
|||||||
export default function CertManagerPage() {
|
export default function CertManagerPage() {
|
||||||
const t = useTranslations('certManager');
|
const t = useTranslations('certManager');
|
||||||
|
|
||||||
const [activeTab, setActiveTab] = useState<TabId>('inspect');
|
const [activeTab, setActiveTab] = useState<TabId>('overview');
|
||||||
const [file, setFile] = useState<File | null>(null);
|
const [file, setFile] = useState<File | null>(null);
|
||||||
const [pemText, setPemText] = useState('');
|
const [pemText, setPemText] = useState('');
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
@@ -75,17 +77,14 @@ export default function CertManagerPage() {
|
|||||||
|
|
||||||
const renderActiveTab = () => {
|
const renderActiveTab = () => {
|
||||||
switch (activeTab) {
|
switch (activeTab) {
|
||||||
|
case 'overview':
|
||||||
|
return <OverviewTab />;
|
||||||
case 'inspect':
|
case 'inspect':
|
||||||
return <InspectTab file={file} pemText={pemText} password={password} />;
|
return <InspectTab file={file} pemText={pemText} password={password} />;
|
||||||
case 'split':
|
case 'split':
|
||||||
return <SplitTab file={file} pemText={pemText} password={password} />;
|
return <SplitTab file={file} pemText={pemText} password={password} />;
|
||||||
case 'merge':
|
case 'merge':
|
||||||
return (
|
return <MergeTab password={password} onOutputFormatChange={setMergeOutputFormat} />;
|
||||||
<MergeTab
|
|
||||||
password={password}
|
|
||||||
onOutputFormatChange={setMergeOutputFormat}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
case 'convert':
|
case 'convert':
|
||||||
return (
|
return (
|
||||||
<ConvertTab
|
<ConvertTab
|
||||||
@@ -102,42 +101,8 @@ export default function CertManagerPage() {
|
|||||||
<div className="mx-auto max-w-4xl space-y-6 p-3 sm:p-6">
|
<div className="mx-auto max-w-4xl space-y-6 p-3 sm:p-6">
|
||||||
<PageHeader moduleSlug="cert-manager" title={t('title')} description={t('description')} />
|
<PageHeader moduleSlug="cert-manager" title={t('title')} description={t('description')} />
|
||||||
|
|
||||||
{/* Shared input card */}
|
|
||||||
<div className="rounded-lg bg-card dark:border dark:border-border p-6 shadow-sm space-y-4">
|
|
||||||
{/* DropZone */}
|
|
||||||
<DropZone
|
|
||||||
onFile={handleFile}
|
|
||||||
accept=".pem,.crt,.cer,.der,.pfx,.p12,.p7b,.p7c"
|
|
||||||
currentFile={file}
|
|
||||||
onClear={handleClearFile}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* ODER divider */}
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<hr className="flex-1 border-border" />
|
|
||||||
<span className="text-xs text-muted-foreground">{t('or')}</span>
|
|
||||||
<hr className="flex-1 border-border" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* PEM textarea */}
|
|
||||||
<textarea
|
|
||||||
value={pemText}
|
|
||||||
onChange={(e) => handlePemChange(e.target.value)}
|
|
||||||
placeholder={t('paste.placeholder')}
|
|
||||||
rows={4}
|
|
||||||
className="w-full rounded border border-border bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring resize-y"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Conditional password field (T-09-02) */}
|
|
||||||
<PasswordField
|
|
||||||
value={password}
|
|
||||||
onChange={setPassword}
|
|
||||||
show={showPassword}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Tab navigation */}
|
{/* Tab navigation */}
|
||||||
<nav className="border-b border-border flex gap-6">
|
<nav className="flex gap-6 overflow-x-auto border-b border-border">
|
||||||
{TABS.map((tab) => (
|
{TABS.map((tab) => (
|
||||||
<button
|
<button
|
||||||
key={tab}
|
key={tab}
|
||||||
@@ -154,6 +119,39 @@ export default function CertManagerPage() {
|
|||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
{/* Shared input card — nur fuer die Einzeldatei-Werkzeuge; die Uebersicht
|
||||||
|
(quick-261001-l4q) hat ihre eigene Mehrfach-Ablage. */}
|
||||||
|
{activeTab !== 'overview' && (
|
||||||
|
<div className="rounded-lg bg-card dark:border dark:border-border p-6 shadow-sm space-y-4">
|
||||||
|
{/* DropZone */}
|
||||||
|
<DropZone
|
||||||
|
onFile={handleFile}
|
||||||
|
accept=".pem,.crt,.cer,.der,.pfx,.p12,.p7b,.p7c"
|
||||||
|
currentFile={file}
|
||||||
|
onClear={handleClearFile}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* ODER divider */}
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<hr className="flex-1 border-border" />
|
||||||
|
<span className="text-xs text-muted-foreground">{t('or')}</span>
|
||||||
|
<hr className="flex-1 border-border" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* PEM textarea */}
|
||||||
|
<textarea
|
||||||
|
value={pemText}
|
||||||
|
onChange={(e) => handlePemChange(e.target.value)}
|
||||||
|
placeholder={t('paste.placeholder')}
|
||||||
|
rows={4}
|
||||||
|
className="w-full rounded border border-border bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring resize-y"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Conditional password field (T-09-02) */}
|
||||||
|
<PasswordField value={password} onChange={setPassword} show={showPassword} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Tab content card */}
|
{/* Tab content card */}
|
||||||
<div className="rounded-lg bg-card dark:border dark:border-border p-6 shadow-sm">
|
<div className="rounded-lg bg-card dark:border dark:border-border p-6 shadow-sm">
|
||||||
{renderActiveTab()}
|
{renderActiveTab()}
|
||||||
|
|||||||
@@ -1182,8 +1182,9 @@
|
|||||||
},
|
},
|
||||||
"certManager": {
|
"certManager": {
|
||||||
"title": "Zertifikat-Manager",
|
"title": "Zertifikat-Manager",
|
||||||
"description": "Zertifikate analysieren, aufteilen, zusammenführen und konvertieren.",
|
"description": "Zertifikatspakete prüfen und in jedes Format bringen; einzelne Zertifikate analysieren, aufteilen, zusammenführen und konvertieren.",
|
||||||
"tabs": {
|
"tabs": {
|
||||||
|
"overview": "Übersicht",
|
||||||
"inspect": "Analysieren",
|
"inspect": "Analysieren",
|
||||||
"split": "Aufteilen",
|
"split": "Aufteilen",
|
||||||
"merge": "Zusammenführen",
|
"merge": "Zusammenführen",
|
||||||
@@ -1217,18 +1218,85 @@
|
|||||||
},
|
},
|
||||||
"emptyState": {
|
"emptyState": {
|
||||||
"inspect": "Kein Zertifikat geladen.",
|
"inspect": "Kein Zertifikat geladen.",
|
||||||
"inspectBody": "Lade eine Datei hoch oder füge PEM-Text ein.",
|
"inspectBody": "Laden Sie eine Datei hoch oder fügen Sie PEM-Text ein.",
|
||||||
"split": "Keine Datei geladen.",
|
"split": "Keine Datei geladen.",
|
||||||
"splitBody": "Lade eine Fullchain- oder P7B-Datei hoch.",
|
"splitBody": "Laden Sie eine Fullchain- oder P7B-Datei hoch.",
|
||||||
"merge": "Keine Zertifikate ausgewählt.",
|
"merge": "Keine Zertifikate ausgewählt.",
|
||||||
"mergeBody": "Lade mindestens zwei Dateien hoch.",
|
"mergeBody": "Laden Sie mindestens zwei Dateien hoch.",
|
||||||
"convert": "Keine Datei geladen.",
|
"convert": "Keine Datei geladen.",
|
||||||
"convertBody": "Lade eine Datei hoch und wähle ein Ausgabeformat."
|
"convertBody": "Laden Sie eine Datei hoch und wählen Sie ein Ausgabeformat."
|
||||||
},
|
},
|
||||||
"error": {
|
"error": {
|
||||||
"generic": "Verarbeitung fehlgeschlagen. Prüfe das Dateiformat oder das Passwort.",
|
"generic": "Verarbeitung fehlgeschlagen. Prüfen Sie das Dateiformat oder das Passwort.",
|
||||||
"wrongPassword": "Falsches Passwort. PFX/P12-Datei konnte nicht entschlüsselt werden.",
|
"wrongPassword": "Falsches Passwort. PFX/P12-Datei konnte nicht entschlüsselt werden.",
|
||||||
"unknownFormat": "Unbekanntes Format. Die Datei konnte nicht als Zertifikat erkannt werden."
|
"unknownFormat": "Unbekanntes Format. Die Datei konnte nicht als Zertifikat erkannt werden."
|
||||||
|
},
|
||||||
|
"overview": {
|
||||||
|
"dropTitle": "Zertifikatsdateien oder ZIP hierher ziehen oder klicken",
|
||||||
|
"dropHint": "Alles auf einmal, so wie es vom Aussteller kommt: .zip, .pem, .crt, .cer, .key, .csr, .pfx, .p12, .p7b",
|
||||||
|
"removeFile": "{name} entfernen",
|
||||||
|
"reset": "Alle entfernen",
|
||||||
|
"analyzing": "Dateien werden geprüft …",
|
||||||
|
"error": "Die Dateien konnten nicht geprüft werden. Bitte prüfen Sie, ob es Zertifikatsdateien sind.",
|
||||||
|
"locked": "Geschützt: {files}. Geben Sie das Passwort ein, um auch diesen Inhalt zu lesen.",
|
||||||
|
"lockedPassword": "Passwort der geschützten Datei",
|
||||||
|
"unlock": "Entsperren",
|
||||||
|
"nothingFound": "In den Dateien wurde kein Zertifikat, Schlüssel und keine Zertifikatsanfrage gefunden.",
|
||||||
|
"ignored": "Nicht verwendet (kein Zertifikat erkannt): {files}",
|
||||||
|
"type": {
|
||||||
|
"end-entity": "Serverzertifikat",
|
||||||
|
"intermediate": "Zwischenzertifikat",
|
||||||
|
"root": "Stammzertifikat",
|
||||||
|
"privateKey": "Privater Schlüssel",
|
||||||
|
"csr": "Zertifikatsanfrage (CSR)"
|
||||||
|
},
|
||||||
|
"explain": {
|
||||||
|
"end-entity": "Das eigentliche Zertifikat für Ihre Domain – das gehört auf den Webserver.",
|
||||||
|
"intermediate": "Bestätigt Ihr Serverzertifikat gegenüber dem Browser. Wird zusammen mit dem Serverzertifikat eingespielt (Kette).",
|
||||||
|
"root": "Oberste Zertifizierungsstelle. Ist in Browsern und Betriebssystemen meist schon vorhanden.",
|
||||||
|
"privateKey": "Der geheime Schlüssel zum Serverzertifikat. Wird auf dem Server gebraucht, darf aber nie weitergegeben werden.",
|
||||||
|
"csr": "Die Anfrage, mit der das Zertifikat beim Aussteller bestellt wurde. Wird nur für eine Neuausstellung gebraucht."
|
||||||
|
},
|
||||||
|
"issuer": "Ausgestellt von",
|
||||||
|
"validity": "Gültig",
|
||||||
|
"names": "Gilt für",
|
||||||
|
"key": "Schlüssel",
|
||||||
|
"belongsTo": "Gehört zu",
|
||||||
|
"source": "Gefunden in",
|
||||||
|
"valid": "Gültig",
|
||||||
|
"expired": "Abgelaufen",
|
||||||
|
"expiresSoon": "Läuft in {days} Tagen ab",
|
||||||
|
"keySecret": "Geheim halten: Wer diesen Schlüssel hat, kann sich als Ihre Website ausgeben.",
|
||||||
|
"downloading": "Wird erstellt …",
|
||||||
|
"exportError": "Diese Datei konnte nicht erstellt werden.",
|
||||||
|
"format": {
|
||||||
|
"crt": "PEM (.crt)",
|
||||||
|
"cer": "DER (.cer)",
|
||||||
|
"fullchain": "Mit Kette (.pem)",
|
||||||
|
"p7b": "PKCS#7 (.p7b)",
|
||||||
|
"pfx": "PFX (.pfx)",
|
||||||
|
"key": "PEM (.key)",
|
||||||
|
"key-rsa": "RSA-PEM (.rsa.key)",
|
||||||
|
"key-der": "DER (.key.der)",
|
||||||
|
"csr": "PEM (.csr)",
|
||||||
|
"csr-der": "DER (.csr.der)"
|
||||||
|
},
|
||||||
|
"formatHint": {
|
||||||
|
"crt": "Textformat, z. B. für Apache, Nginx und die meisten Geräte",
|
||||||
|
"cer": "Binärformat, z. B. für Windows und Java",
|
||||||
|
"fullchain": "Zertifikat und Kette in einer Datei, z. B. für Nginx",
|
||||||
|
"p7b": "Zertifikat und Kette ohne Schlüssel, z. B. für Windows/IIS",
|
||||||
|
"pfx": "Zertifikat, Kette und Schlüssel in einer passwortgeschützten Datei, z. B. für Windows/IIS und Exchange",
|
||||||
|
"key": "Schlüssel im Standardformat (PKCS#8)",
|
||||||
|
"key-rsa": "Schlüssel im älteren RSA-Format (PKCS#1), für ältere Software",
|
||||||
|
"key-der": "Schlüssel als Binärdatei",
|
||||||
|
"csr": "Zertifikatsanfrage als Text",
|
||||||
|
"csr-der": "Zertifikatsanfrage als Binärdatei"
|
||||||
|
},
|
||||||
|
"pfxWithKey": "Die PFX-Datei enthält Zertifikat, Kette und privaten Schlüssel. Legen Sie ein Passwort fest – es wird beim Einspielen abgefragt.",
|
||||||
|
"pfxWithoutKey": "Zu diesem Zertifikat liegt kein Schlüssel vor – die PFX-Datei enthält nur Zertifikat und Kette. Legen Sie ein Passwort fest.",
|
||||||
|
"pfxPassword": "Passwort für die PFX-Datei",
|
||||||
|
"pfxDownload": "PFX herunterladen"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"tenderRadar": {
|
"tenderRadar": {
|
||||||
|
|||||||
@@ -1182,8 +1182,9 @@
|
|||||||
},
|
},
|
||||||
"certManager": {
|
"certManager": {
|
||||||
"title": "Certificate Manager",
|
"title": "Certificate Manager",
|
||||||
"description": "Inspect, split, merge and convert certificates.",
|
"description": "Check certificate bundles and download them in any format; inspect, split, merge and convert single certificates.",
|
||||||
"tabs": {
|
"tabs": {
|
||||||
|
"overview": "Overview",
|
||||||
"inspect": "Inspect",
|
"inspect": "Inspect",
|
||||||
"split": "Split",
|
"split": "Split",
|
||||||
"merge": "Merge",
|
"merge": "Merge",
|
||||||
@@ -1229,6 +1230,73 @@
|
|||||||
"generic": "Processing failed. Check the file format or password.",
|
"generic": "Processing failed. Check the file format or password.",
|
||||||
"wrongPassword": "Wrong password. Could not decrypt the PFX/P12 file.",
|
"wrongPassword": "Wrong password. Could not decrypt the PFX/P12 file.",
|
||||||
"unknownFormat": "Unknown format. The file could not be recognized as a certificate."
|
"unknownFormat": "Unknown format. The file could not be recognized as a certificate."
|
||||||
|
},
|
||||||
|
"overview": {
|
||||||
|
"dropTitle": "Drop certificate files or a ZIP here, or click",
|
||||||
|
"dropHint": "Everything at once, as delivered by the issuer: .zip, .pem, .crt, .cer, .key, .csr, .pfx, .p12, .p7b",
|
||||||
|
"removeFile": "Remove {name}",
|
||||||
|
"reset": "Remove all",
|
||||||
|
"analyzing": "Checking files …",
|
||||||
|
"error": "The files could not be checked. Please make sure they are certificate files.",
|
||||||
|
"locked": "Protected: {files}. Enter the password to read this content as well.",
|
||||||
|
"lockedPassword": "Password of the protected file",
|
||||||
|
"unlock": "Unlock",
|
||||||
|
"nothingFound": "No certificate, key or certificate request was found in the files.",
|
||||||
|
"ignored": "Not used (no certificate detected): {files}",
|
||||||
|
"type": {
|
||||||
|
"end-entity": "Server certificate",
|
||||||
|
"intermediate": "Intermediate certificate",
|
||||||
|
"root": "Root certificate",
|
||||||
|
"privateKey": "Private key",
|
||||||
|
"csr": "Certificate request (CSR)"
|
||||||
|
},
|
||||||
|
"explain": {
|
||||||
|
"end-entity": "The actual certificate for your domain – it goes on the web server.",
|
||||||
|
"intermediate": "Vouches for your server certificate towards the browser. Install it together with the server certificate (chain).",
|
||||||
|
"root": "Top-level certificate authority. Usually already present in browsers and operating systems.",
|
||||||
|
"privateKey": "The secret key for the server certificate. Needed on the server, but must never be shared.",
|
||||||
|
"csr": "The request used to order the certificate from the issuer. Only needed for a reissue."
|
||||||
|
},
|
||||||
|
"issuer": "Issued by",
|
||||||
|
"validity": "Valid",
|
||||||
|
"names": "Valid for",
|
||||||
|
"key": "Key",
|
||||||
|
"belongsTo": "Belongs to",
|
||||||
|
"source": "Found in",
|
||||||
|
"valid": "Valid",
|
||||||
|
"expired": "Expired",
|
||||||
|
"expiresSoon": "Expires in {days} days",
|
||||||
|
"keySecret": "Keep secret: whoever has this key can impersonate your website.",
|
||||||
|
"downloading": "Creating …",
|
||||||
|
"exportError": "This file could not be created.",
|
||||||
|
"format": {
|
||||||
|
"crt": "PEM (.crt)",
|
||||||
|
"cer": "DER (.cer)",
|
||||||
|
"fullchain": "With chain (.pem)",
|
||||||
|
"p7b": "PKCS#7 (.p7b)",
|
||||||
|
"pfx": "PFX (.pfx)",
|
||||||
|
"key": "PEM (.key)",
|
||||||
|
"key-rsa": "RSA PEM (.rsa.key)",
|
||||||
|
"key-der": "DER (.key.der)",
|
||||||
|
"csr": "PEM (.csr)",
|
||||||
|
"csr-der": "DER (.csr.der)"
|
||||||
|
},
|
||||||
|
"formatHint": {
|
||||||
|
"crt": "Text format, e.g. for Apache, Nginx and most devices",
|
||||||
|
"cer": "Binary format, e.g. for Windows and Java",
|
||||||
|
"fullchain": "Certificate and chain in one file, e.g. for Nginx",
|
||||||
|
"p7b": "Certificate and chain without key, e.g. for Windows/IIS",
|
||||||
|
"pfx": "Certificate, chain and key in one password-protected file, e.g. for Windows/IIS and Exchange",
|
||||||
|
"key": "Key in standard format (PKCS#8)",
|
||||||
|
"key-rsa": "Key in older RSA format (PKCS#1), for older software",
|
||||||
|
"key-der": "Key as binary file",
|
||||||
|
"csr": "Certificate request as text",
|
||||||
|
"csr-der": "Certificate request as binary file"
|
||||||
|
},
|
||||||
|
"pfxWithKey": "The PFX file contains certificate, chain and private key. Set a password – it is asked for when importing.",
|
||||||
|
"pfxWithoutKey": "No key is available for this certificate – the PFX file contains only certificate and chain. Set a password.",
|
||||||
|
"pfxPassword": "Password for the PFX file",
|
||||||
|
"pfxDownload": "Download PFX"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"tenderRadar": {
|
"tenderRadar": {
|
||||||
|
|||||||
@@ -103,6 +103,13 @@ export const UMLAUT_REPLACEMENTS: Record<string, string> = {
|
|||||||
* and must never be touched by the replacement or flagged by the guard.
|
* and must never be touched by the replacement or flagged by the guard.
|
||||||
*/
|
*/
|
||||||
export const UMLAUT_ALLOWLIST: readonly string[] = [
|
export const UMLAUT_ALLOWLIST: readonly string[] = [
|
||||||
|
// quick-261001-l4q: Zertifikatsmodul, Übersicht (korrektes Deutsch)
|
||||||
|
'Aussteller',
|
||||||
|
'Betriebssystemen',
|
||||||
|
'Neuausstellung',
|
||||||
|
'passwortgeschützten',
|
||||||
|
'Schlüssel',
|
||||||
|
'Zertifizierungsstelle',
|
||||||
// quick-260929-if2: „lässt“ (Erinnerung „lässt sich nicht mehr bearbeiten“)
|
// quick-260929-if2: „lässt“ (Erinnerung „lässt sich nicht mehr bearbeiten“)
|
||||||
'lässt',
|
'lässt',
|
||||||
'manuell',
|
'manuell',
|
||||||
|
|||||||
Reference in New Issue
Block a user