feat(cert-manager): Hersteller-ZIP, PKCS#7, eingefügter Text, Analysieren und Aufteilen
- ZIP wird an den Anfangsbytes erkannt und mit Grenzen geöffnet (eine Ebene, Verhältnis, Gesamtgröße, verschlüsselte Einträge) - PKCS#7 als PEM und DER, auch für EC, über den ASN.1-Lauf - Eingefügter PEM-Text als eigener Eintrag im Reiter Dateien - Neue Reiter Analysieren und Aufteilen auf dem gemeinsamen Arbeitsbereich Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,6 @@
|
|||||||
import { readFileSync } from 'node:fs';
|
import { readFileSync } from 'node:fs';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
|
import AdmZip from 'adm-zip';
|
||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { analyzeWorkingSet, cleanSourcePath } from './cert-analyze';
|
import { analyzeWorkingSet, cleanSourcePath } from './cert-analyze';
|
||||||
import type { CertItem } from './cert-types';
|
import type { CertItem } from './cert-types';
|
||||||
@@ -71,6 +72,65 @@ describe('analyzeWorkingSet', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('analyzeWorkingSet: Hersteller-ZIP', () => {
|
||||||
|
const vendor = new AdmZip();
|
||||||
|
vendor.addFile('ec-leaf.crt', fx('ec-leaf.pem'));
|
||||||
|
vendor.addFile('Zwischen/ec-inter.crt', fx('ec-inter.pem'));
|
||||||
|
vendor.addFile('ec-root.crt', fx('ec-root.pem'));
|
||||||
|
vendor.addFile('kopie/rsa-leaf.pem', fx('rsa-leaf.pem'));
|
||||||
|
vendor.addFile('__MACOSX/._ec-leaf.crt', Buffer.from('mac'));
|
||||||
|
vendor.addFile('inner.zip', new AdmZip().toBuffer());
|
||||||
|
vendor.addFile('readme.txt', Buffer.from('Bitte lesen'));
|
||||||
|
|
||||||
|
const result = analyzeWorkingSet([
|
||||||
|
file('rsa-leaf.pem'),
|
||||||
|
file('rsa-inter.pem'),
|
||||||
|
{ originalname: 'vendor.zip', buffer: vendor.toBuffer() },
|
||||||
|
]);
|
||||||
|
const certs = result.items.filter((i): i is CertItem => i.kind === 'certificate');
|
||||||
|
|
||||||
|
it('rsa-leaf ist EIN Eintrag mit zwei Quellen (Datei 0 und Pfad im ZIP)', () => {
|
||||||
|
const leaf = certs.filter((c) => c.cn === 'www.example.test');
|
||||||
|
expect(leaf).toHaveLength(1);
|
||||||
|
expect(leaf[0].sources).toEqual([
|
||||||
|
{ file: 0, path: 'rsa-leaf.pem' },
|
||||||
|
{ file: 2, path: 'vendor.zip/kopie/rsa-leaf.pem' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ordnet Serverzertifikate, Zwischenzertifikate, Wurzel', () => {
|
||||||
|
expect(certs.map((c) => c.role)).toEqual([
|
||||||
|
'end-entity',
|
||||||
|
'end-entity',
|
||||||
|
'intermediate',
|
||||||
|
'intermediate',
|
||||||
|
'root',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('zwei Ketten: RSA unvollstaendig (afterCa), EC vollstaendig mit Wurzel', () => {
|
||||||
|
expect(result.chains).toHaveLength(2);
|
||||||
|
const rsa = result.chains.find(
|
||||||
|
(c) => certs.find((x) => x.id === c.headId)?.cn === 'www.example.test',
|
||||||
|
);
|
||||||
|
expect(rsa?.gap?.kind).toBe('afterCa');
|
||||||
|
const ec = result.chains.find(
|
||||||
|
(c) => certs.find((x) => x.id === c.headId)?.cn === 'ec.example.test',
|
||||||
|
);
|
||||||
|
expect(ec?.complete).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('meldet verschachteltes ZIP und unbekannte Datei mit Dateiindex und Pfad', () => {
|
||||||
|
expect(result.ignored).toEqual(
|
||||||
|
expect.arrayContaining([
|
||||||
|
{ file: 2, path: 'vendor.zip/inner.zip', reason: 'nestedZip' },
|
||||||
|
{ file: 2, path: 'vendor.zip/readme.txt', reason: 'unknown' },
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
expect(JSON.stringify(result)).not.toContain('MACOSX');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('cleanSourcePath', () => {
|
describe('cleanSourcePath', () => {
|
||||||
it('entfernt Steuerzeichen und kuerzt auf 255', () => {
|
it('entfernt Steuerzeichen und kuerzt auf 255', () => {
|
||||||
expect(cleanSourcePath('a\u0000b\u001fc.pem')).toBe('abc.pem');
|
expect(cleanSourcePath('a\u0000b\u001fc.pem')).toBe('abc.pem');
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { buildChains } from './cert-chain';
|
import { buildChains } from './cert-chain';
|
||||||
import { detectBlob } from './cert-model';
|
import { detectBlob } from './cert-model';
|
||||||
|
import { cleanSourcePath } from './cert-names';
|
||||||
import type {
|
import type {
|
||||||
AnalysisResult,
|
AnalysisResult,
|
||||||
AnyItem,
|
AnyItem,
|
||||||
@@ -15,22 +16,13 @@ import type {
|
|||||||
* Ketten ab Task 2; Schluessel/CSR-Zuordnung und gesperrte Container (Task 4) folgen.
|
* Ketten ab Task 2; Schluessel/CSR-Zuordnung und gesperrte Container (Task 4) folgen.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
export { cleanSourcePath };
|
||||||
|
|
||||||
export interface AnalyzeFile {
|
export interface AnalyzeFile {
|
||||||
originalname: string;
|
originalname: string;
|
||||||
buffer: Buffer;
|
buffer: Buffer;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Anzeigename einer Quelle: ohne Steuerzeichen, hoechstens 255 Zeichen. Nur Anzeige, nie ein Dateipfad. */
|
|
||||||
export function cleanSourcePath(raw: string): string {
|
|
||||||
let out = '';
|
|
||||||
for (const ch of raw) {
|
|
||||||
const code = ch.codePointAt(0) ?? 0;
|
|
||||||
if (code < 0x20 || code === 0x7f) continue;
|
|
||||||
out += ch;
|
|
||||||
}
|
|
||||||
return out.slice(0, 255);
|
|
||||||
}
|
|
||||||
|
|
||||||
const ROLE_RANK: Record<CertItem['role'], number> = { 'end-entity': 0, intermediate: 1, root: 2 };
|
const ROLE_RANK: Record<CertItem['role'], number> = { 'end-entity': 0, intermediate: 1, root: 2 };
|
||||||
|
|
||||||
function sameSource(a: ItemSource, b: ItemSource): boolean {
|
function sameSource(a: ItemSource, b: ItemSource): boolean {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { X509Certificate } from 'node:crypto';
|
import { 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 AdmZip from 'adm-zip';
|
||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { certItemFromDer, detectBlob } from './cert-model';
|
import { certItemFromDer, detectBlob } from './cert-model';
|
||||||
import type { CertItem } from './cert-types';
|
import type { CertItem } from './cert-types';
|
||||||
@@ -129,3 +130,82 @@ describe('detectBlob: Zertifikate', () => {
|
|||||||
expect(item.id).toMatch(/^c-[0-9a-f]{16}$/);
|
expect(item.id).toMatch(/^c-[0-9a-f]{16}$/);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('detectBlob: PKCS#7', () => {
|
||||||
|
it.each([
|
||||||
|
'rsa-chain.p7b',
|
||||||
|
'rsa-chain.p7c',
|
||||||
|
'ec-chain.p7b',
|
||||||
|
])('%s liefert drei Zertifikate mit unveraenderten Fingerabdruecken', (name) => {
|
||||||
|
const r = detectBlob(fx(name), ctx(name));
|
||||||
|
expect(r.ignored).toEqual([]);
|
||||||
|
expect(r.items).toHaveLength(3);
|
||||||
|
const prefix = name.startsWith('rsa') ? 'rsa' : 'ec';
|
||||||
|
const expected = ['leaf', 'inter', 'root'].map((p) => certs(`${prefix}-${p}.pem`)[0].id);
|
||||||
|
expect(r.items.map((i) => i.id).sort()).toEqual([...expected].sort());
|
||||||
|
expect((r.items[0] as CertItem).sources).toEqual([{ file: 0, path: name }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('PKCS#7 als DER ohne Endung wird erkannt (Inhalt, nicht Name)', () => {
|
||||||
|
const r = detectBlob(fx('rsa-chain.p7c'), ctx('irgendwas.dat'));
|
||||||
|
expect(r.items).toHaveLength(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('abgeschnittenes PKCS#7 ergibt unbekannt, keinen Fehler', () => {
|
||||||
|
const r = detectBlob(fx('rsa-chain.p7c').subarray(0, 400), ctx('halb.p7c'));
|
||||||
|
expect(r.items).toEqual([]);
|
||||||
|
expect(r.ignored).toEqual([{ file: 0, path: 'halb.p7c', reason: 'unknown' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('PKCS#7-Block neben einem Zertifikat im selben Text', () => {
|
||||||
|
const both = Buffer.concat([fx('rsa-chain.p7b'), Buffer.from('\n'), fx('ec-leaf.pem')]);
|
||||||
|
const r = detectBlob(both, ctx('beides.pem'));
|
||||||
|
expect(r.items).toHaveLength(4);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('detectBlob: ZIP', () => {
|
||||||
|
function zip(entries: Record<string, Buffer>): Buffer {
|
||||||
|
const z = new AdmZip();
|
||||||
|
for (const [name, data] of Object.entries(entries)) z.addFile(name, data);
|
||||||
|
return z.toBuffer();
|
||||||
|
}
|
||||||
|
|
||||||
|
it('oeffnet ein ZIP an den Anfangsbytes und gibt jedem Teil den Pfad "zip/eintrag"', () => {
|
||||||
|
const blob = zip({
|
||||||
|
'ServerCertificate.crt': fx('ec-leaf.pem'),
|
||||||
|
'Intermediate/CA.crt': fx('ec-inter.pem'),
|
||||||
|
'chain.p7b': fx('rsa-chain.p7b'),
|
||||||
|
'readme.txt': Buffer.from('Bitte lesen'),
|
||||||
|
'__MACOSX/._x': Buffer.from('mac'),
|
||||||
|
});
|
||||||
|
const r = detectBlob(blob, { file: 2, path: 'bundle.dat', passwords: [] });
|
||||||
|
const ec = r.items.find((i) => (i as CertItem).cn === 'ec.example.test');
|
||||||
|
expect(ec?.sources).toEqual([{ file: 2, path: 'bundle.dat/ServerCertificate.crt' }]);
|
||||||
|
expect(r.items).toHaveLength(5);
|
||||||
|
expect(r.ignored).toEqual([{ file: 2, path: 'bundle.dat/readme.txt', reason: 'unknown' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ein ZIP im ZIP: nestedZip mit Pfad, der Rest wird gelesen', () => {
|
||||||
|
const inner = zip({ 'x.pem': fx('rsa-root.pem') });
|
||||||
|
const blob = zip({ 'a.pem': fx('rsa-leaf.pem'), 'inner.zip': inner });
|
||||||
|
const r = detectBlob(blob, ctx('v.zip'));
|
||||||
|
expect(r.items).toHaveLength(1);
|
||||||
|
expect(r.ignored).toEqual([{ file: 0, path: 'v.zip/inner.zip', reason: 'nestedZip' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('kaputtes ZIP und verschluesseltes ZIP werden gemeldet', () => {
|
||||||
|
const broken = Buffer.concat([Buffer.from('PK\x03\x04', 'binary'), Buffer.alloc(100, 9)]);
|
||||||
|
expect(detectBlob(broken, ctx('b.zip')).ignored).toEqual([
|
||||||
|
{ file: 0, path: 'b.zip', reason: 'brokenZip' },
|
||||||
|
]);
|
||||||
|
expect(detectBlob(fx('encrypted-entry.zip'), ctx('e.zip')).ignored).toEqual([
|
||||||
|
{ file: 0, path: 'e.zip/rsa-leaf.pem', reason: 'encryptedZip' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ein ZIP ganz ohne lesbare Teile ergibt einen Eintrag unbekannt fuer das ZIP', () => {
|
||||||
|
const r = detectBlob(new AdmZip().toBuffer(), ctx('leer.zip'));
|
||||||
|
expect(r.ignored).toEqual([{ file: 0, path: 'leer.zip', reason: 'unknown' }]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { createHash, type KeyObject, X509Certificate } from 'node:crypto';
|
import { createHash, type KeyObject, X509Certificate } from 'node:crypto';
|
||||||
|
import * as forge from 'node-forge';
|
||||||
import { safeBaseName } from './cert-names';
|
import { safeBaseName } from './cert-names';
|
||||||
import type {
|
import type {
|
||||||
AnyItem,
|
AnyItem,
|
||||||
@@ -8,6 +9,7 @@ import type {
|
|||||||
ItemSource,
|
ItemSource,
|
||||||
LockedEntry,
|
LockedEntry,
|
||||||
} from './cert-types';
|
} from './cert-types';
|
||||||
|
import { expandZip, isZip } from './zip-expand';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Der eine Parser des Zertifikat-Managers (quick-261009-ikt, D-08, D-16).
|
* Der eine Parser des Zertifikat-Managers (quick-261009-ikt, D-08, D-16).
|
||||||
@@ -18,8 +20,9 @@ import type {
|
|||||||
*
|
*
|
||||||
* Erkennung nach Inhalt, nie nach Dateiendung. Jede Stufe steht in try/catch: eine kaputte
|
* Erkennung nach Inhalt, nie nach Dateiendung. Jede Stufe steht in try/catch: eine kaputte
|
||||||
* Datei ergibt hoechstens einen Eintrag „unbekannt“, nie einen Fehler fuer die ganze Anfrage.
|
* Datei ergibt hoechstens einen Eintrag „unbekannt“, nie einen Fehler fuer die ganze Anfrage.
|
||||||
* Stand Task 1: Zertifikate als PEM (auch TRUSTED CERTIFICATE) und als DER. ZIP und PKCS#7
|
* Stand Task 3: Zertifikate als PEM (auch TRUSTED CERTIFICATE) und als DER, PKCS#7 als PEM und DER
|
||||||
* (Task 3) sowie Schluessel, PKCS#12 und CSR (Task 4) sind benannte, noch leere Stufen.
|
* (auch fuer EC, ueber den ASN.1-Lauf von forge) und ZIP (eine Ebene, Grenzen in zip-expand.ts).
|
||||||
|
* Schluessel, PKCS#12 und CSR (Task 4) sind benannte, noch leere Stufen.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export interface DetectContext {
|
export interface DetectContext {
|
||||||
@@ -38,6 +41,7 @@ export interface DetectResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const CERT_LABELS = new Set(['CERTIFICATE', 'X509 CERTIFICATE', 'TRUSTED CERTIFICATE']);
|
const CERT_LABELS = new Set(['CERTIFICATE', 'X509 CERTIFICATE', 'TRUSTED CERTIFICATE']);
|
||||||
|
const PKCS7_LABELS = new Set(['PKCS7', 'CMS']);
|
||||||
const PEM_BLOCK = /-----BEGIN ([A-Z0-9 ]+)-----([\s\S]*?)-----END \1-----/g;
|
const PEM_BLOCK = /-----BEGIN ([A-Z0-9 ]+)-----([\s\S]*?)-----END \1-----/g;
|
||||||
const BASE64_BODY = /^[A-Za-z0-9+/]+={0,2}$/;
|
const BASE64_BODY = /^[A-Za-z0-9+/]+={0,2}$/;
|
||||||
|
|
||||||
@@ -211,9 +215,72 @@ function emptyResult(): DetectResult {
|
|||||||
// Stufen. Jede liefert null, wenn sie den Inhalt nicht als „ihren“ erkennt.
|
// Stufen. Jede liefert null, wenn sie den Inhalt nicht als „ihren“ erkennt.
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/** ZIP (Task 3): erkannt an den Anfangsbytes, nicht an der Endung. */
|
/**
|
||||||
function detectZip(_blob: Buffer, _ctx: DetectContext): DetectResult | null {
|
* ZIP: erkannt an den Anfangsbytes, nicht an der Endung. Jeder Eintrag laeuft durch dieselbe
|
||||||
return null;
|
* Erkennung wie eine hochgeladene Datei; sein Pfad ist "zipname/eintrag". Ein ZIP im ZIP wird
|
||||||
|
* von expandZip schon als nestedZip gemeldet und nicht geoeffnet.
|
||||||
|
*/
|
||||||
|
function detectZip(blob: Buffer, ctx: DetectContext): DetectResult | null {
|
||||||
|
if (!isZip(blob)) return null;
|
||||||
|
const expansion = expandZip(blob, ctx.path, ctx.file);
|
||||||
|
const result: DetectResult = { items: [], ignored: [...expansion.ignored], locked: [] };
|
||||||
|
for (const entry of expansion.blobs) {
|
||||||
|
const inner = detectBlob(entry.buffer, { ...ctx, path: entry.path });
|
||||||
|
result.items.push(...inner.items);
|
||||||
|
result.ignored.push(...inner.ignored);
|
||||||
|
result.locked.push(...inner.locked);
|
||||||
|
}
|
||||||
|
if (result.items.length === 0 && result.ignored.length === 0 && result.locked.length === 0) {
|
||||||
|
result.ignored.push({ file: ctx.file, path: ctx.path, reason: 'unknown' });
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
const OID_SIGNED_DATA = '1.2.840.113549.1.7.2';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Zertifikate aus einem PKCS#7-/CMS-signedData-Block (DER, auch BER mit unbestimmter Laenge).
|
||||||
|
* Reiner ASN.1-Lauf: ContentInfo -> [0] SignedData -> [0] certificates. Jedes Zertifikat wird
|
||||||
|
* als DER an node:crypto gegeben; die RSA-only-Zertifikatsleser von forge kommen nie vor.
|
||||||
|
*/
|
||||||
|
function pkcs7Certificates(der: Buffer): Buffer[] {
|
||||||
|
// Die Typdefinition kennt nur `strict: boolean`; forge nimmt zur Laufzeit ein Optionsobjekt.
|
||||||
|
// decodeBitStrings aus: Bitfolgen bleiben unveraendert, damit die Zertifikats-Bytes beim
|
||||||
|
// erneuten Schreiben mit den Originalen uebereinstimmen (gleicher Fingerabdruck).
|
||||||
|
const options = { decodeBitStrings: false } as unknown as boolean;
|
||||||
|
const root = forge.asn1.fromDer(forge.util.createBuffer(der.toString('binary')), options);
|
||||||
|
const children = root.value as forge.asn1.Asn1[];
|
||||||
|
if (!Array.isArray(children) || children.length < 2) return [];
|
||||||
|
const [contentType, content] = children;
|
||||||
|
if (
|
||||||
|
contentType.type !== forge.asn1.Type.OID ||
|
||||||
|
forge.asn1.derToOid(contentType.value as string) !== OID_SIGNED_DATA
|
||||||
|
) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const signedData = (content.value as forge.asn1.Asn1[])?.[0];
|
||||||
|
const parts = signedData?.value as forge.asn1.Asn1[] | undefined;
|
||||||
|
if (!Array.isArray(parts)) return [];
|
||||||
|
const certificates = parts.find(
|
||||||
|
(p) => p.tagClass === forge.asn1.Class.CONTEXT_SPECIFIC && p.type === 0 && p.constructed,
|
||||||
|
);
|
||||||
|
if (!certificates) return [];
|
||||||
|
const out: Buffer[] = [];
|
||||||
|
for (const cert of certificates.value as forge.asn1.Asn1[]) {
|
||||||
|
if (cert.type !== forge.asn1.Type.SEQUENCE) continue; // andere Zertifikatsformen (Attributzertifikate) ueberspringen
|
||||||
|
out.push(Buffer.from(forge.asn1.toDer(cert).getBytes(), 'binary'));
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Zertifikate aus PKCS#7-DER als Eintraege; Elemente, die keine X.509-Zertifikate sind, fallen weg. */
|
||||||
|
function pkcs7Items(der: Buffer, ctx: DetectContext): CertItem[] {
|
||||||
|
const items: CertItem[] = [];
|
||||||
|
for (const certDer of pkcs7Certificates(der)) {
|
||||||
|
const item = certFromDer(certDer, ctx);
|
||||||
|
if (item) items.push(item);
|
||||||
|
}
|
||||||
|
return items;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface PemBlock {
|
interface PemBlock {
|
||||||
@@ -260,8 +327,15 @@ function detectPem(blob: Buffer, ctx: DetectContext): DetectResult | null {
|
|||||||
const item = certFromDer(block.der, ctx);
|
const item = certFromDer(block.der, ctx);
|
||||||
if (item) result.items.push(item);
|
if (item) result.items.push(item);
|
||||||
}
|
}
|
||||||
// PKCS7/CMS (Task 3); PRIVATE KEY, RSA/EC PRIVATE KEY, ENCRYPTED PRIVATE KEY und
|
if (PKCS7_LABELS.has(block.label)) {
|
||||||
// CERTIFICATE REQUEST (Task 4) folgen in dieser Schleife.
|
try {
|
||||||
|
result.items.push(...pkcs7Items(block.der, ctx));
|
||||||
|
} catch {
|
||||||
|
// kaputter PKCS#7-Block: die anderen Bloecke der Datei bleiben gueltig
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// PRIVATE KEY, RSA/EC PRIVATE KEY, ENCRYPTED PRIVATE KEY und CERTIFICATE REQUEST
|
||||||
|
// (Task 4) folgen in dieser Schleife.
|
||||||
}
|
}
|
||||||
return result.items.length > 0 ? result : null;
|
return result.items.length > 0 ? result : null;
|
||||||
}
|
}
|
||||||
@@ -278,9 +352,10 @@ function detectPkcs12(_blob: Buffer, _ctx: DetectContext): DetectResult | null {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** PKCS#7 signedData als DER (Task 3). */
|
/** PKCS#7 signedData als DER. */
|
||||||
function detectPkcs7(_blob: Buffer, _ctx: DetectContext): DetectResult | null {
|
function detectPkcs7(blob: Buffer, ctx: DetectContext): DetectResult | null {
|
||||||
return null;
|
const items = pkcs7Items(blob, ctx);
|
||||||
|
return items.length > 0 ? { items, ignored: [], locked: [] } : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Privater Schluessel als DER (Task 4). */
|
/** Privater Schluessel als DER (Task 4). */
|
||||||
|
|||||||
@@ -12,3 +12,14 @@ export function safeBaseName(raw: string, fallback: string): string {
|
|||||||
.slice(0, 80);
|
.slice(0, 80);
|
||||||
return cleaned || fallback;
|
return cleaned || fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Anzeigename einer Quelle: ohne Steuerzeichen, hoechstens 255 Zeichen. Nur Anzeige, nie ein Dateipfad. */
|
||||||
|
export function cleanSourcePath(raw: string): string {
|
||||||
|
let out = '';
|
||||||
|
for (const ch of raw) {
|
||||||
|
const code = ch.codePointAt(0) ?? 0;
|
||||||
|
if (code < 0x20 || code === 0x7f) continue;
|
||||||
|
out += ch;
|
||||||
|
}
|
||||||
|
return out.slice(0, 255);
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,156 @@
|
|||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import AdmZip from 'adm-zip';
|
||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { expandZip, isZip, ZIP_LIMITS } from './zip-expand';
|
||||||
|
|
||||||
|
const fx = (name: string) => readFileSync(join(__dirname, '__fixtures__', name));
|
||||||
|
|
||||||
|
function zipOf(entries: Record<string, Buffer | string>): Buffer {
|
||||||
|
const zip = new AdmZip();
|
||||||
|
for (const [name, content] of Object.entries(entries)) {
|
||||||
|
zip.addFile(name, Buffer.isBuffer(content) ? content : Buffer.from(content));
|
||||||
|
}
|
||||||
|
return zip.toBuffer();
|
||||||
|
}
|
||||||
|
|
||||||
|
function vendorZip(): Buffer {
|
||||||
|
const inner = zipOf({ 'x.txt': 'innen' });
|
||||||
|
const zip = new AdmZip();
|
||||||
|
zip.addFile('ServerCertificate.crt', fx('rsa-leaf.pem'));
|
||||||
|
zip.addFile('Intermediate/CA.crt', fx('rsa-inter.pem'));
|
||||||
|
zip.addFile('__MACOSX/._ServerCertificate.crt', Buffer.from('mac'));
|
||||||
|
zip.addFile('.DS_Store', Buffer.from('ds'));
|
||||||
|
zip.addFile('Thumbs.db', Buffer.from('thumbs'));
|
||||||
|
zip.addFile('Intermediate/', Buffer.alloc(0));
|
||||||
|
zip.addFile('readme.txt', Buffer.from('Bitte lesen'));
|
||||||
|
zip.addFile('inner.zip', inner);
|
||||||
|
return zip.toBuffer();
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('isZip', () => {
|
||||||
|
it('erkennt ZIP an den Anfangsbytes, nicht am Namen', () => {
|
||||||
|
expect(isZip(vendorZip())).toBe(true);
|
||||||
|
expect(isZip(Buffer.from('PK\x05\x06rest', 'binary'))).toBe(true);
|
||||||
|
expect(isZip(fx('rsa-leaf.pem'))).toBe(false);
|
||||||
|
expect(isZip(Buffer.alloc(0))).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('expandZip: Hersteller-ZIP', () => {
|
||||||
|
const result = expandZip(vendorZip(), 'vendor.zip', 3);
|
||||||
|
|
||||||
|
it('liefert die Zertifikate und die Textdatei mit Pfad "zip/eintrag"', () => {
|
||||||
|
expect(result.blobs.map((b) => b.path).sort()).toEqual([
|
||||||
|
'vendor.zip/Intermediate/CA.crt',
|
||||||
|
'vendor.zip/ServerCertificate.crt',
|
||||||
|
'vendor.zip/readme.txt',
|
||||||
|
]);
|
||||||
|
const server = result.blobs.find((b) => b.path === 'vendor.zip/ServerCertificate.crt');
|
||||||
|
expect(server?.buffer.equals(fx('rsa-leaf.pem'))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Muell (MACOSX, Punktdateien, Thumbs.db, Ordner) erzeugt nichts', () => {
|
||||||
|
const all = JSON.stringify(result);
|
||||||
|
expect(all).not.toContain('MACOSX');
|
||||||
|
expect(all).not.toContain('DS_Store');
|
||||||
|
expect(all).not.toContain('Thumbs');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ein ZIP im ZIP wird mit Grund gemeldet und nicht geoeffnet', () => {
|
||||||
|
expect(result.ignored).toEqual([
|
||||||
|
{ file: 3, path: 'vendor.zip/inner.zip', reason: 'nestedZip' },
|
||||||
|
]);
|
||||||
|
expect(result.blobs.some((b) => b.path.endsWith('inner.zip'))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dasselbe ZIP wird auch unter anderem Namen geoeffnet (Anfangsbytes)', () => {
|
||||||
|
const renamed = expandZip(vendorZip(), 'bundle.dat', 0);
|
||||||
|
expect(renamed.blobs).toHaveLength(3);
|
||||||
|
expect(renamed.blobs[0].path.startsWith('bundle.dat/')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('expandZip: Grenzen und Fehler', () => {
|
||||||
|
it('Zufallsbytes mit ZIP-Anfang ergeben brokenZip', () => {
|
||||||
|
const junk = Buffer.concat([Buffer.from('PK\x03\x04', 'binary'), Buffer.alloc(200, 7)]);
|
||||||
|
const r = expandZip(junk, 'kaputt.zip', 1);
|
||||||
|
expect(r.blobs).toEqual([]);
|
||||||
|
expect(r.ignored).toEqual([{ file: 1, path: 'kaputt.zip', reason: 'brokenZip' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ein verschluesselter Eintrag wird mit Grund gemeldet', () => {
|
||||||
|
const r = expandZip(fx('encrypted-entry.zip'), 'enc.zip', 0);
|
||||||
|
expect(r.blobs).toEqual([]);
|
||||||
|
expect(r.ignored).toEqual([{ file: 0, path: 'enc.zip/rsa-leaf.pem', reason: 'encryptedZip' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('mehr Eintraege als erlaubt: ein tooManyEntries fuer das ganze ZIP, keine Teile', () => {
|
||||||
|
const zip = zipOf({ 'a.pem': 'a', 'b.pem': 'b', 'c.pem': 'c' });
|
||||||
|
const r = expandZip(zip, 'viele.zip', 2, { ...ZIP_LIMITS, maxEntries: 2 });
|
||||||
|
expect(r.blobs).toEqual([]);
|
||||||
|
expect(r.ignored).toEqual([{ file: 2, path: 'viele.zip', reason: 'tooManyEntries' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Muell zaehlt nicht zu den erlaubten Eintraegen', () => {
|
||||||
|
const zip = zipOf({ 'a.pem': 'a', '.hidden': 'x', '__MACOSX/b': 'y', 'Thumbs.db': 'z' });
|
||||||
|
const r = expandZip(zip, 'z.zip', 0, { ...ZIP_LIMITS, maxEntries: 1 });
|
||||||
|
expect(r.blobs).toHaveLength(1);
|
||||||
|
expect(r.ignored).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ein zu grosser Eintrag wird mit tooLarge uebersprungen, der Rest bleibt', () => {
|
||||||
|
const zip = zipOf({ 'gross.pem': Buffer.from('ab'.repeat(400)), 'klein.pem': 'k' });
|
||||||
|
const r = expandZip(zip, 'g.zip', 0, { ...ZIP_LIMITS, maxEntryBytes: 500 });
|
||||||
|
expect(r.ignored).toEqual([{ file: 0, path: 'g.zip/gross.pem', reason: 'tooLarge' }]);
|
||||||
|
expect(r.blobs.map((b) => b.path)).toEqual(['g.zip/klein.pem']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('600 kB Nullbytes (Verhaeltnis ueber 100) ergeben suspicious', () => {
|
||||||
|
const zip = zipOf({ 'null.bin': Buffer.alloc(600 * 1024) });
|
||||||
|
const r = expandZip(zip, 'bombe.zip', 0);
|
||||||
|
expect(r.blobs).toEqual([]);
|
||||||
|
expect(r.ignored).toEqual([{ file: 0, path: 'bombe.zip/null.bin', reason: 'suspicious' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('behaltene Eintraege ueber der Gesamtgrenze: ein zipTooLarge, keine Teile', () => {
|
||||||
|
const zip = zipOf({ 'a.pem': Buffer.from('1234567890'), 'b.pem': Buffer.from('1234567890') });
|
||||||
|
const r = expandZip(zip, 'summe.zip', 4, { ...ZIP_LIMITS, maxTotalBytes: 15 });
|
||||||
|
expect(r.blobs).toEqual([]);
|
||||||
|
expect(r.ignored).toEqual([{ file: 4, path: 'summe.zip', reason: 'zipTooLarge' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('prueft die Grenzen vor dem Entpacken (kein Entpacken bei zipTooLarge)', () => {
|
||||||
|
const zip = zipOf({ 'a.pem': Buffer.from('1234567890'), 'b.pem': Buffer.from('1234567890') });
|
||||||
|
const original = AdmZip.prototype.getEntries;
|
||||||
|
let inflated = 0;
|
||||||
|
AdmZip.prototype.getEntries = function patched(this: AdmZip) {
|
||||||
|
const entries = original.call(this);
|
||||||
|
for (const e of entries) {
|
||||||
|
const get = e.getData.bind(e);
|
||||||
|
e.getData = () => {
|
||||||
|
inflated++;
|
||||||
|
return get();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return entries;
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
expandZip(zip, 's.zip', 0, { ...ZIP_LIMITS, maxTotalBytes: 15 });
|
||||||
|
} finally {
|
||||||
|
AdmZip.prototype.getEntries = original;
|
||||||
|
}
|
||||||
|
expect(inflated).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Anzeigepfad ohne Steuerzeichen', () => {
|
||||||
|
const zip = zipOf({ 'a\u0001b.pem': 'x' });
|
||||||
|
const r = expandZip(zip, 'c.zip', 0);
|
||||||
|
expect(r.blobs[0].path).toBe('c.zip/ab.pem');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leeres ZIP ergibt keine Teile und keinen Fehler', () => {
|
||||||
|
const r = expandZip(new AdmZip().toBuffer(), 'leer.zip', 0);
|
||||||
|
expect(r.blobs).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
import AdmZip from 'adm-zip';
|
||||||
|
import { cleanSourcePath } from './cert-names';
|
||||||
|
import type { IgnoredEntry } from './cert-types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ZIP-Erkennung und -Entpacken des Zertifikat-Managers (quick-261009-ikt, D-17).
|
||||||
|
*
|
||||||
|
* Regeln:
|
||||||
|
* - Ein ZIP erkennt man an den Anfangsbytes (PK\x03\x04 oder PK\x05\x06), nie an der Endung.
|
||||||
|
* - Nur eine Ebene: ein Eintrag, der selbst ein ZIP ist, wird mit Grund `nestedZip` gemeldet.
|
||||||
|
* - Muell wird still uebersprungen: Ordner, `__MACOSX/`, Namen mit fuehrendem Punkt, Thumbs.db,
|
||||||
|
* desktop.ini. Er zaehlt auch nicht zur Eintragsgrenze.
|
||||||
|
* - Mehr als `maxEntries` Eintraege: das ganze ZIP wird mit `tooManyEntries` abgelehnt.
|
||||||
|
* - Verschluesselter Eintrag (Flag Bit 0): `encryptedZip`. Deklarierte Groesse ueber `maxEntryBytes`:
|
||||||
|
* `tooLarge`. Deklarierte Groesse / max(gepackt, 1) ueber `maxRatio`: `suspicious`.
|
||||||
|
* - Summe der deklarierten Groessen der behaltenen Eintraege ueber `maxTotalBytes`: das ganze ZIP
|
||||||
|
* wird mit `zipTooLarge` abgelehnt.
|
||||||
|
* - Alle diese Pruefungen laufen auf den Kopfdaten und damit VOR dem ersten Entpacken.
|
||||||
|
* adm-zip entpackt hoechstens die deklarierte Groesse und prueft die CRC; zusaetzlich wird das
|
||||||
|
* Ergebnis noch einmal gegen die Grenze geprueft.
|
||||||
|
* - Eintragsnamen dienen nur der Anzeige (Steuerzeichen entfernt, hoechstens 255 Zeichen) und
|
||||||
|
* werden nie als Dateipfad benutzt.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface ZipLimits {
|
||||||
|
maxEntries: number;
|
||||||
|
maxEntryBytes: number;
|
||||||
|
maxRatio: number;
|
||||||
|
maxTotalBytes: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ZIP_LIMITS: ZipLimits = {
|
||||||
|
maxEntries: 100,
|
||||||
|
maxEntryBytes: 1024 * 1024,
|
||||||
|
maxRatio: 100,
|
||||||
|
maxTotalBytes: 20 * 1024 * 1024,
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface ZipBlob {
|
||||||
|
/** Anzeigepfad: "zipname/eintrag" */
|
||||||
|
path: string;
|
||||||
|
buffer: Buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ZipExpansion {
|
||||||
|
blobs: ZipBlob[];
|
||||||
|
ignored: IgnoredEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ZIP-Anfangsbytes: lokaler Dateikopf (PK 03 04) oder leeres Archiv (PK 05 06). */
|
||||||
|
export function isZip(buffer: Buffer): boolean {
|
||||||
|
return (
|
||||||
|
buffer.length >= 4 &&
|
||||||
|
buffer[0] === 0x50 &&
|
||||||
|
buffer[1] === 0x4b &&
|
||||||
|
((buffer[2] === 0x03 && buffer[3] === 0x04) || (buffer[2] === 0x05 && buffer[3] === 0x06))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const JUNK_FILES = new Set(['thumbs.db', 'desktop.ini']);
|
||||||
|
|
||||||
|
function isJunk(entryName: string, isDirectory: boolean): boolean {
|
||||||
|
if (isDirectory) return true;
|
||||||
|
const segments = entryName.split(/[\\/]/).filter(Boolean);
|
||||||
|
if (segments.length === 0) return true;
|
||||||
|
if (segments.some((s) => s === '__MACOSX' || s.startsWith('.'))) return true;
|
||||||
|
return JUNK_FILES.has(segments[segments.length - 1].toLowerCase());
|
||||||
|
}
|
||||||
|
|
||||||
|
function displayPath(zipName: string, entryName: string): string {
|
||||||
|
return cleanSourcePath(`${zipName}/${entryName.replace(/^[\\/]+/, '')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function expandZip(
|
||||||
|
buffer: Buffer,
|
||||||
|
zipName: string,
|
||||||
|
file: number,
|
||||||
|
limits: ZipLimits = ZIP_LIMITS,
|
||||||
|
): ZipExpansion {
|
||||||
|
const blobs: ZipBlob[] = [];
|
||||||
|
const ignored: IgnoredEntry[] = [];
|
||||||
|
const whole = (reason: IgnoredEntry['reason']): ZipExpansion => ({
|
||||||
|
blobs: [],
|
||||||
|
ignored: [{ file, path: cleanSourcePath(zipName), reason }],
|
||||||
|
});
|
||||||
|
|
||||||
|
let entries: ReturnType<AdmZip['getEntries']>;
|
||||||
|
try {
|
||||||
|
entries = new AdmZip(buffer).getEntries();
|
||||||
|
} catch {
|
||||||
|
return whole('brokenZip');
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidates = entries.filter((e) => !isJunk(e.entryName, e.isDirectory));
|
||||||
|
if (candidates.length > limits.maxEntries) return whole('tooManyEntries');
|
||||||
|
|
||||||
|
// Kopfdaten pruefen, bevor irgendein Eintrag entpackt wird.
|
||||||
|
const kept: typeof candidates = [];
|
||||||
|
let total = 0;
|
||||||
|
for (const entry of candidates) {
|
||||||
|
const path = displayPath(zipName, entry.entryName);
|
||||||
|
const size = entry.header.size;
|
||||||
|
if (entry.header.encrypted) {
|
||||||
|
ignored.push({ file, path, reason: 'encryptedZip' });
|
||||||
|
} else if (entry.entryName.toLowerCase().endsWith('.zip')) {
|
||||||
|
ignored.push({ file, path, reason: 'nestedZip' });
|
||||||
|
} else if (size > limits.maxEntryBytes) {
|
||||||
|
ignored.push({ file, path, reason: 'tooLarge' });
|
||||||
|
} else if (size / Math.max(entry.header.compressedSize, 1) > limits.maxRatio) {
|
||||||
|
ignored.push({ file, path, reason: 'suspicious' });
|
||||||
|
} else {
|
||||||
|
kept.push(entry);
|
||||||
|
total += size;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (total > limits.maxTotalBytes) return whole('zipTooLarge');
|
||||||
|
|
||||||
|
for (const entry of kept) {
|
||||||
|
const path = displayPath(zipName, entry.entryName);
|
||||||
|
let data: Buffer;
|
||||||
|
try {
|
||||||
|
data = entry.getData();
|
||||||
|
} catch {
|
||||||
|
ignored.push({ file, path, reason: 'brokenZip' });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (data.length > limits.maxEntryBytes) {
|
||||||
|
ignored.push({ file, path, reason: 'tooLarge' });
|
||||||
|
} else if (isZip(data)) {
|
||||||
|
ignored.push({ file, path, reason: 'nestedZip' });
|
||||||
|
} else {
|
||||||
|
blobs.push({ path, buffer: data });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { blobs, ignored };
|
||||||
|
}
|
||||||
@@ -43,14 +43,19 @@ beforeEach(() => {
|
|||||||
afterEach(cleanup);
|
afterEach(cleanup);
|
||||||
|
|
||||||
describe('CertManagerPage', () => {
|
describe('CertManagerPage', () => {
|
||||||
it('zeigt Titel und die Reiter „Dateien“ und „Zusammenführen“, „Dateien“ ist aktiv', () => {
|
it('zeigt Titel und die Reiter in der Reihenfolge Dateien, Analysieren, Aufteilen, Zusammenführen', () => {
|
||||||
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');
|
||||||
const tabs = within(nav).getAllByRole('button');
|
const tabs = within(nav).getAllByRole('button');
|
||||||
expect(tabs.map((b) => b.textContent)).toEqual(['Dateien', 'Zusammenführen']);
|
expect(tabs.map((b) => b.textContent)).toEqual([
|
||||||
|
'Dateien',
|
||||||
|
'Analysieren',
|
||||||
|
'Aufteilen',
|
||||||
|
'Zusammenführen',
|
||||||
|
]);
|
||||||
expect(tabs[0]).toHaveAttribute('aria-current', 'page');
|
expect(tabs[0]).toHaveAttribute('aria-current', 'page');
|
||||||
expect(tabs[1]).not.toHaveAttribute('aria-current');
|
for (const tab of tabs.slice(1)) expect(tab).not.toHaveAttribute('aria-current');
|
||||||
// Startansicht ist der Dateien-Reiter
|
// Startansicht ist der Dateien-Reiter
|
||||||
expect(screen.getByText(/Dateien hierher ziehen/)).toBeInTheDocument();
|
expect(screen.getByText(/Dateien hierher ziehen/)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
@@ -84,6 +89,16 @@ describe('CertManagerPage', () => {
|
|||||||
expect(screen.getByText(/Dateien hierher ziehen/)).toBeInTheDocument();
|
expect(screen.getByText(/Dateien hierher ziehen/)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
'Analysieren',
|
||||||
|
'Aufteilen',
|
||||||
|
])('%s mit leerem Arbeitsbereich: Hinweis auf den Reiter „Dateien“', (tab) => {
|
||||||
|
render(<CertManagerPage />);
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: tab }));
|
||||||
|
expect(screen.getByText(/Noch keine Dateien\. Laden Sie Ihre Zertifikate/)).toBeInTheDocument();
|
||||||
|
expect(screen.queryByTestId('cert-file-input')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
it('mit Dateien zeigt Zusammenführen die Grundlage, und die Liste bleibt beim Reiterwechsel', async () => {
|
it('mit Dateien zeigt Zusammenführen die Grundlage, und die Liste bleibt beim Reiterwechsel', async () => {
|
||||||
render(<CertManagerPage />);
|
render(<CertManagerPage />);
|
||||||
fireEvent.change(screen.getByTestId('cert-file-input'), {
|
fireEvent.change(screen.getByTestId('cert-file-input'), {
|
||||||
|
|||||||
@@ -0,0 +1,231 @@
|
|||||||
|
import { cleanup, render as rtlRender, screen, within } from '@testing-library/react';
|
||||||
|
import { NextIntlClientProvider } from 'next-intl';
|
||||||
|
import type { ReactElement } from 'react';
|
||||||
|
import { afterEach, describe, expect, it } from 'vitest';
|
||||||
|
import de from '@/messages/de.json';
|
||||||
|
import type { AnalysisResult, CertItem, ChainInfo } from '../actions';
|
||||||
|
import type { CertWorkspace } from '../use-cert-workspace';
|
||||||
|
import type { WorkingEntry } from '../working-set';
|
||||||
|
import { AnalyzeTab } from './AnalyzeTab';
|
||||||
|
|
||||||
|
function render(ui: ReactElement) {
|
||||||
|
return rtlRender(
|
||||||
|
<NextIntlClientProvider locale="de" messages={de} timeZone="Europe/Berlin">
|
||||||
|
{ui}
|
||||||
|
</NextIntlClientProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function cert(over: Partial<CertItem> & Pick<CertItem, 'id' | 'cn' | 'role'>): CertItem {
|
||||||
|
return {
|
||||||
|
kind: 'certificate',
|
||||||
|
sources: [{ file: 0, path: 'a.pem' }],
|
||||||
|
pem: `PEM-${over.id}`,
|
||||||
|
baseName: over.cn,
|
||||||
|
organization: '',
|
||||||
|
issuerCn: 'Test Inter',
|
||||||
|
issuerOrganization: '',
|
||||||
|
notBefore: '2026-01-01T00:00:00.000Z',
|
||||||
|
notAfter: '2126-03-15T23:59:59.000Z',
|
||||||
|
isExpired: false,
|
||||||
|
daysLeft: 36000,
|
||||||
|
san: ['www.example.test', 'example.test'],
|
||||||
|
keyType: 'RSA',
|
||||||
|
keyBits: 2048,
|
||||||
|
curve: null,
|
||||||
|
serialNumber: 'AB12',
|
||||||
|
sha256: 'AA:BB:CC',
|
||||||
|
sha1: '11:22:33',
|
||||||
|
isCa: over.role !== 'end-entity',
|
||||||
|
selfSigned: over.role === 'root',
|
||||||
|
aiaIssuerUrls: [],
|
||||||
|
keyId: null,
|
||||||
|
csrIds: [],
|
||||||
|
...over,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function entry(id: string, name: string, label = name): WorkingEntry {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
file: new File(['x'], name),
|
||||||
|
label,
|
||||||
|
origin: 'upload',
|
||||||
|
host: null,
|
||||||
|
password: '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function workspace(
|
||||||
|
analysis: AnalysisResult | null,
|
||||||
|
entries: WorkingEntry[] = [],
|
||||||
|
analysisIds: string[] = [],
|
||||||
|
): CertWorkspace {
|
||||||
|
return {
|
||||||
|
entries,
|
||||||
|
analysis,
|
||||||
|
analysisIds,
|
||||||
|
status: 'idle',
|
||||||
|
errorKey: null,
|
||||||
|
addFiles: () => [],
|
||||||
|
addText: () => null,
|
||||||
|
remove: () => {},
|
||||||
|
clear: () => {},
|
||||||
|
retry: () => {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const leaf = cert({
|
||||||
|
id: 'c-leaf',
|
||||||
|
cn: 'www.example.test',
|
||||||
|
role: 'end-entity',
|
||||||
|
sources: [
|
||||||
|
{ file: 0, path: 'a.pem' },
|
||||||
|
{ file: 1, path: 'vendor.zip/kopie/a.pem' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const inter = cert({
|
||||||
|
id: 'c-inter',
|
||||||
|
cn: 'Test Inter',
|
||||||
|
role: 'intermediate',
|
||||||
|
issuerCn: 'Test Root',
|
||||||
|
sources: [{ file: 1, path: 'vendor.zip/Intermediate/CA.crt' }],
|
||||||
|
});
|
||||||
|
const ec = cert({
|
||||||
|
id: 'c-ec',
|
||||||
|
cn: 'ec.example.test',
|
||||||
|
role: 'end-entity',
|
||||||
|
keyType: 'EC',
|
||||||
|
keyBits: 256,
|
||||||
|
curve: 'P-256',
|
||||||
|
isExpired: true,
|
||||||
|
daysLeft: -4,
|
||||||
|
notAfter: '2026-10-05T10:00:00.000Z',
|
||||||
|
});
|
||||||
|
const soon = cert({ id: 'c-soon', cn: 'bald.example.test', role: 'end-entity', daysLeft: 12 });
|
||||||
|
|
||||||
|
const chains: ChainInfo[] = [
|
||||||
|
{
|
||||||
|
headId: 'c-leaf',
|
||||||
|
path: ['c-leaf', 'c-inter'],
|
||||||
|
rootId: null,
|
||||||
|
complete: false,
|
||||||
|
alternatives: 0,
|
||||||
|
gap: { certId: 'c-inter', kind: 'afterCa', missingIssuerCn: 'Test Root', aiaUrls: [] },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
headId: 'c-ec',
|
||||||
|
path: ['c-ec'],
|
||||||
|
rootId: null,
|
||||||
|
complete: false,
|
||||||
|
alternatives: 0,
|
||||||
|
gap: { certId: 'c-ec', kind: 'afterLeaf', missingIssuerCn: 'Inter EC', aiaUrls: [] },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
describe('AnalyzeTab', () => {
|
||||||
|
const analysis: AnalysisResult = {
|
||||||
|
items: [leaf, ec, soon, inter],
|
||||||
|
chains,
|
||||||
|
locked: [],
|
||||||
|
ignored: [
|
||||||
|
{ file: 1, path: 'vendor.zip/inner.zip', reason: 'nestedZip' },
|
||||||
|
{ file: 1, path: 'vendor.zip/readme.txt', reason: 'unknown' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const entries = [entry('e1', 'a.pem'), entry('e2', 'vendor.zip')];
|
||||||
|
|
||||||
|
it('zeigt zuerst die Ketten, dann die Zertifikate, dann Nicht verwendetes', () => {
|
||||||
|
render(<AnalyzeTab workspace={workspace(analysis, entries, ['e1', 'e2'])} />);
|
||||||
|
const headings = screen.getAllByRole('heading').map((h) => h.textContent);
|
||||||
|
expect(headings).toEqual(['Ketten', 'Zertifikate', 'Nicht verwendet']);
|
||||||
|
const chainsRegion = screen.getByRole('region', { name: 'Ketten' });
|
||||||
|
expect(within(chainsRegion).getByText(/Zwischenzertifikat fehlt/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('eine Karte je Zertifikat mit Rolle, Name, Aussteller, Gueltigkeit in UTC, Namen, Schluessel', () => {
|
||||||
|
render(<AnalyzeTab workspace={workspace(analysis, entries, ['e1', 'e2'])} />);
|
||||||
|
const cards = screen.getAllByTestId('cert-card');
|
||||||
|
expect(cards).toHaveLength(4);
|
||||||
|
const first = cards[0];
|
||||||
|
expect(within(first).getByText('Serverzertifikat')).toBeInTheDocument();
|
||||||
|
expect(within(first).getByText('www.example.test')).toBeInTheDocument();
|
||||||
|
expect(within(first).getByText('Test Inter')).toBeInTheDocument();
|
||||||
|
expect(within(first).getByText('www.example.test, example.test')).toBeInTheDocument();
|
||||||
|
expect(within(first).getByText('RSA, 2048 Bit')).toBeInTheDocument();
|
||||||
|
expect(within(first).getByText('AB12')).toBeInTheDocument();
|
||||||
|
expect(within(first).getByText('AA:BB:CC')).toBeInTheDocument();
|
||||||
|
expect(within(first).getByText('11:22:33')).toBeInTheDocument();
|
||||||
|
// 23:59:59 UTC bleibt der 15. Maerz (nicht der Folgetag in Berlin)
|
||||||
|
expect(first).toHaveTextContent('15.03.2126');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('EC-Zertifikat nennt die Kurve, abgelaufen und bald ablaufend sind unterscheidbar', () => {
|
||||||
|
render(<AnalyzeTab workspace={workspace(analysis, entries, ['e1', 'e2'])} />);
|
||||||
|
const ecCard = screen
|
||||||
|
.getAllByTestId('cert-card')
|
||||||
|
.find((c) => c.textContent?.includes('ec.example.test')) as HTMLElement;
|
||||||
|
expect(within(ecCard).getByText('EC, Kurve P-256')).toBeInTheDocument();
|
||||||
|
expect(within(ecCard).getByText('Abgelaufen')).toBeInTheDocument();
|
||||||
|
const soonCard = screen
|
||||||
|
.getAllByTestId('cert-card')
|
||||||
|
.find((c) => c.textContent?.includes('bald.example.test')) as HTMLElement;
|
||||||
|
expect(within(soonCard).getByText('Läuft in 12 Tagen ab')).toBeInTheDocument();
|
||||||
|
const okCard = screen.getAllByTestId('cert-card')[0];
|
||||||
|
expect(within(okCard).getByText('Gültig', { selector: 'span' })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('nennt die Quellen mit Datei und ZIP-Pfad', () => {
|
||||||
|
render(<AnalyzeTab workspace={workspace(analysis, entries, ['e1', 'e2'])} />);
|
||||||
|
const card = screen.getAllByTestId('cert-card')[0];
|
||||||
|
expect(within(card).getByText('a.pem, vendor.zip/kopie/a.pem')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('eingefuegter Text erscheint mit seiner Beschriftung als Quelle', () => {
|
||||||
|
const pasted = entry('e9', 'pasted-1.pem', 'Eingefügter Text 1');
|
||||||
|
const a: AnalysisResult = {
|
||||||
|
items: [
|
||||||
|
cert({
|
||||||
|
id: 'c-p',
|
||||||
|
cn: 'p.example.test',
|
||||||
|
role: 'end-entity',
|
||||||
|
sources: [{ file: 0, path: 'pasted-1.pem' }],
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
chains: [],
|
||||||
|
locked: [],
|
||||||
|
ignored: [],
|
||||||
|
};
|
||||||
|
render(<AnalyzeTab workspace={workspace(a, [pasted], ['e9'])} />);
|
||||||
|
expect(screen.getByText('Eingefügter Text 1')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('listet uebersprungene Eintraege mit Pfad und Grund', () => {
|
||||||
|
render(<AnalyzeTab workspace={workspace(analysis, entries, ['e1', 'e2'])} />);
|
||||||
|
const region = screen.getByRole('region', { name: 'Nicht verwendet' });
|
||||||
|
const items = within(region).getAllByRole('listitem');
|
||||||
|
expect(items).toHaveLength(2);
|
||||||
|
expect(items[0]).toHaveTextContent('vendor.zip/inner.zip');
|
||||||
|
expect(items[0]).toHaveTextContent('Ein ZIP in einem ZIP wird nicht geöffnet.');
|
||||||
|
expect(items[1]).toHaveTextContent('Darin wurde kein Zertifikat erkannt.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ohne erkanntes Zertifikat und ohne Ausgelassenes: ruhiger Hinweis', () => {
|
||||||
|
render(
|
||||||
|
<AnalyzeTab workspace={workspace({ items: [], chains: [], locked: [], ignored: [] })} />,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
screen.getByText('In den Dateien wurde noch kein Zertifikat erkannt.'),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('enthaelt keine Pfeilzeichen und keine ALL-CAPS-Beschriftungen', () => {
|
||||||
|
const { container } = render(
|
||||||
|
<AnalyzeTab workspace={workspace(analysis, entries, ['e1', 'e2'])} />,
|
||||||
|
);
|
||||||
|
expect(container.textContent).not.toMatch(/[→←·]/);
|
||||||
|
expect(container.innerHTML).not.toContain('uppercase');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
import type { CertItem } from '../actions';
|
||||||
|
import type { CertWorkspace } from '../use-cert-workspace';
|
||||||
|
import { ChainView } from './ChainView';
|
||||||
|
import { ItemCard } from './ItemCard';
|
||||||
|
|
||||||
|
interface AnalyzeTabProps {
|
||||||
|
workspace: CertWorkspace;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reiter „Analysieren“: arbeitet auf dem gemeinsamen Arbeitsbereich (D-01). Zuerst die Ketten
|
||||||
|
* (eine je Serverzertifikat), dann jedes Zertifikat im Einzelnen, zuletzt, was uebersprungen wurde.
|
||||||
|
*/
|
||||||
|
export function AnalyzeTab({ workspace }: AnalyzeTabProps) {
|
||||||
|
const t = useTranslations('certManager');
|
||||||
|
const { analysis, entries, analysisIds, status } = workspace;
|
||||||
|
|
||||||
|
if (!analysis) {
|
||||||
|
return status === 'analyzing' ? (
|
||||||
|
<p role="status" className="text-sm text-muted-foreground">
|
||||||
|
{t('files.analyzing')}
|
||||||
|
</p>
|
||||||
|
) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const certs = analysis.items.filter((i): i is CertItem => i.kind === 'certificate');
|
||||||
|
|
||||||
|
if (certs.length === 0 && analysis.ignored.length === 0) {
|
||||||
|
return <p className="text-sm text-muted-foreground">{t('analyze.nothingFound')}</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<p className="text-sm text-muted-foreground">{t('analyze.intro')}</p>
|
||||||
|
|
||||||
|
{analysis.chains.length > 0 && (
|
||||||
|
<section className="space-y-3" aria-label={t('analyze.chainsTitle')}>
|
||||||
|
<h2 className="text-sm font-semibold text-foreground">{t('analyze.chainsTitle')}</h2>
|
||||||
|
{analysis.chains.map((chain) => (
|
||||||
|
<ChainView key={chain.headId} chain={chain} certs={certs} />
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{certs.length > 0 && (
|
||||||
|
<section className="space-y-3" aria-label={t('analyze.certificatesTitle')}>
|
||||||
|
<h2 className="text-sm font-semibold text-foreground">
|
||||||
|
{t('analyze.certificatesTitle')}
|
||||||
|
</h2>
|
||||||
|
<ul className="space-y-4">
|
||||||
|
{certs.map((cert) => (
|
||||||
|
<ItemCard key={cert.id} cert={cert} entries={entries} analysisIds={analysisIds} />
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{analysis.ignored.length > 0 && (
|
||||||
|
<section className="space-y-2" aria-label={t('analyze.ignoredTitle')}>
|
||||||
|
<h2 className="text-sm font-semibold text-foreground">{t('analyze.ignoredTitle')}</h2>
|
||||||
|
<ul className="space-y-1 text-sm text-muted-foreground">
|
||||||
|
{analysis.ignored.map((i) => (
|
||||||
|
<li key={`${i.file}-${i.path}-${i.reason}`}>
|
||||||
|
<span className="break-all text-foreground">{i.path}</span>{' '}
|
||||||
|
{t(`files.ignored.${i.reason}`)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -215,4 +215,79 @@ describe('FilesTab', () => {
|
|||||||
expect(screen.getByText('Test Inter')).toBeInTheDocument();
|
expect(screen.getByText('Test Inter')).toBeInTheDocument();
|
||||||
expect(screen.queryByText('Darin wurde kein Zertifikat erkannt.')).not.toBeInTheDocument();
|
expect(screen.queryByText('Darin wurde kein Zertifikat erkannt.')).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
it('ein ZIP zeigt die enthaltenen Teile nach Pfad mit Rolle und Name', async () => {
|
||||||
|
mockAnalyze.mockResolvedValueOnce({
|
||||||
|
items: [
|
||||||
|
cert('c-srv', 'www.example.test', 'end-entity', [0], ['vendor.zip/ServerCertificate.crt']),
|
||||||
|
cert('c-ca', 'Test Inter', 'intermediate', [0], ['vendor.zip/Intermediate/CA.crt']),
|
||||||
|
],
|
||||||
|
chains: [],
|
||||||
|
locked: [],
|
||||||
|
ignored: [{ file: 0, path: 'vendor.zip/inner.zip', reason: 'nestedZip' }],
|
||||||
|
} satisfies AnalysisResult);
|
||||||
|
render(<Harness />);
|
||||||
|
selectFiles([makeFile('vendor.zip')]);
|
||||||
|
const srvPath = await screen.findByText('vendor.zip/ServerCertificate.crt');
|
||||||
|
const group = srvPath.closest('li') as HTMLElement;
|
||||||
|
expect(within(group).getByText('www.example.test')).toBeInTheDocument();
|
||||||
|
expect(within(group).getByText('Serverzertifikat')).toBeInTheDocument();
|
||||||
|
const caGroup = screen.getByText('vendor.zip/Intermediate/CA.crt').closest('li') as HTMLElement;
|
||||||
|
expect(within(caGroup).getByText('Zwischenzertifikat')).toBeInTheDocument();
|
||||||
|
const nested = screen.getByText('vendor.zip/inner.zip').closest('li') as HTMLElement;
|
||||||
|
expect(
|
||||||
|
within(nested).getByText('Ein ZIP in einem ZIP wird nicht geöffnet.'),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
['nestedZip', 'Ein ZIP in einem ZIP wird nicht geöffnet.'],
|
||||||
|
['encryptedZip', 'Der ZIP-Inhalt ist mit einem Passwort geschützt und wird übersprungen.'],
|
||||||
|
['tooLarge', 'Die Datei ist zu groß und wird übersprungen.'],
|
||||||
|
['suspicious', 'Die Datei ist ungewöhnlich stark gepackt und wird übersprungen.'],
|
||||||
|
['zipTooLarge', 'Das ZIP enthält zu viele Daten und wird nicht geöffnet.'],
|
||||||
|
['tooManyEntries', 'Das ZIP enthält zu viele Dateien und wird nicht geöffnet.'],
|
||||||
|
['brokenZip', 'Das ZIP lässt sich nicht lesen.'],
|
||||||
|
] as const)('Grund %s wird als Text gezeigt', async (reason, text) => {
|
||||||
|
mockAnalyze.mockResolvedValueOnce({
|
||||||
|
items: [],
|
||||||
|
chains: [],
|
||||||
|
locked: [],
|
||||||
|
ignored: [{ file: 0, path: 'v.zip/x', reason }],
|
||||||
|
} satisfies AnalysisResult);
|
||||||
|
render(<Harness />);
|
||||||
|
selectFiles([makeFile('v.zip')]);
|
||||||
|
expect(await screen.findByText(text)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('der eingeklappte Bereich „PEM-Text einfügen“ fügt „Eingefügter Text 1“ als Texteintrag hinzu', async () => {
|
||||||
|
render(<Harness />);
|
||||||
|
expect(screen.queryByLabelText('Eingefügter PEM-Text')).not.toBeInTheDocument();
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'PEM-Text einfügen' }));
|
||||||
|
const area = screen.getByLabelText('Eingefügter PEM-Text');
|
||||||
|
const add = screen.getByRole('button', { name: 'Hinzufügen' });
|
||||||
|
expect(add).toBeDisabled();
|
||||||
|
fireEvent.change(area, { target: { value: '-----BEGIN CERTIFICATE-----\nabc\n' } });
|
||||||
|
fireEvent.click(add);
|
||||||
|
expect(await screen.findByText('Eingefügter Text 1')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Eingefügter Text')).toBeInTheDocument();
|
||||||
|
const sent = mockAnalyze.mock.calls.at(-1)?.[0] as { file: File }[];
|
||||||
|
expect(sent.map((e) => e.file.name)).toEqual(['pasted-1.pem']);
|
||||||
|
// Bereich ist wieder zu, der naechste Text wird Nummer 2
|
||||||
|
expect(screen.queryByLabelText('Eingefügter PEM-Text')).not.toBeInTheDocument();
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'PEM-Text einfügen' }));
|
||||||
|
fireEvent.change(screen.getByLabelText('Eingefügter PEM-Text'), { target: { value: 'zwei' } });
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Hinzufügen' }));
|
||||||
|
expect(await screen.findByText('Eingefügter Text 2')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('zu langer eingefügter Text wird mit Grund abgelehnt', () => {
|
||||||
|
render(<Harness />);
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'PEM-Text einfügen' }));
|
||||||
|
fireEvent.change(screen.getByLabelText('Eingefügter PEM-Text'), {
|
||||||
|
target: { value: 'a'.repeat(256_001) },
|
||||||
|
});
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Hinzufügen' }));
|
||||||
|
expect(screen.getByRole('alert')).toHaveTextContent('länger als');
|
||||||
|
expect(mockAnalyze).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,12 +2,13 @@
|
|||||||
|
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
import { useRef, useState } from 'react';
|
import { useRef, useState } from 'react';
|
||||||
import type { AnyItem } from '../actions';
|
import type { AnyItem, IgnoredEntry } from '../actions';
|
||||||
import type { CertWorkspace } from '../use-cert-workspace';
|
import type { CertWorkspace } from '../use-cert-workspace';
|
||||||
import {
|
import {
|
||||||
formatBytes,
|
formatBytes,
|
||||||
MAX_ENTRIES,
|
MAX_ENTRIES,
|
||||||
MAX_FILE_BYTES,
|
MAX_FILE_BYTES,
|
||||||
|
MAX_PASTE_CHARS,
|
||||||
MAX_TOTAL_BYTES,
|
MAX_TOTAL_BYTES,
|
||||||
type RejectedFile,
|
type RejectedFile,
|
||||||
type WorkingEntry,
|
type WorkingEntry,
|
||||||
@@ -51,6 +52,13 @@ function itemName(item: AnyItem): string {
|
|||||||
return item.kind === 'privateKey' ? item.baseName : item.cn || item.baseName;
|
return item.kind === 'privateKey' ? item.baseName : item.cn || item.baseName;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Was in einer Datei (bei ZIP: unter einem Pfad im ZIP) erkannt oder uebersprungen wurde. */
|
||||||
|
interface PartGroup {
|
||||||
|
path: string;
|
||||||
|
items: AnyItem[];
|
||||||
|
ignored: IgnoredEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
interface FilesTabProps {
|
interface FilesTabProps {
|
||||||
workspace: CertWorkspace;
|
workspace: CertWorkspace;
|
||||||
}
|
}
|
||||||
@@ -64,6 +72,8 @@ export function FilesTab({ workspace }: FilesTabProps) {
|
|||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
const [dragging, setDragging] = useState(false);
|
const [dragging, setDragging] = useState(false);
|
||||||
const [rejected, setRejected] = useState<RejectedFile[]>([]);
|
const [rejected, setRejected] = useState<RejectedFile[]>([]);
|
||||||
|
const [pasteOpen, setPasteOpen] = useState(false);
|
||||||
|
const [pasteText, setPasteText] = useState('');
|
||||||
const { entries, analysis, analysisIds, status, errorKey } = workspace;
|
const { entries, analysis, analysisIds, status, errorKey } = workspace;
|
||||||
|
|
||||||
const add = (list: FileList | File[] | null | undefined) => {
|
const add = (list: FileList | File[] | null | undefined) => {
|
||||||
@@ -72,12 +82,36 @@ export function FilesTab({ workspace }: FilesTabProps) {
|
|||||||
setRejected(workspace.addFiles(files));
|
setRejected(workspace.addFiles(files));
|
||||||
};
|
};
|
||||||
|
|
||||||
const itemsOf = (entry: WorkingEntry): AnyItem[] =>
|
const addPasted = () => {
|
||||||
(analysis?.items ?? []).filter((item) =>
|
const problem = workspace.addText(pasteText, (n) => t('files.pastedLabel', { n }));
|
||||||
item.sources.some((s) => analysisIds[s.file] === entry.id),
|
setRejected(problem ? [problem] : []);
|
||||||
);
|
if (!problem) {
|
||||||
const ignoredOf = (entry: WorkingEntry) =>
|
setPasteText('');
|
||||||
(analysis?.ignored ?? []).filter((i) => analysisIds[i.file] === entry.id);
|
setPasteOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Teile eines Eintrags nach Pfad gruppiert: bei einem ZIP je enthaltene Datei, sonst eine Gruppe. */
|
||||||
|
const groupsOf = (entry: WorkingEntry): PartGroup[] => {
|
||||||
|
const groups = new Map<string, PartGroup>();
|
||||||
|
const group = (path: string): PartGroup => {
|
||||||
|
let g = groups.get(path);
|
||||||
|
if (!g) {
|
||||||
|
g = { path, items: [], ignored: [] };
|
||||||
|
groups.set(path, g);
|
||||||
|
}
|
||||||
|
return g;
|
||||||
|
};
|
||||||
|
for (const item of analysis?.items ?? []) {
|
||||||
|
for (const source of item.sources) {
|
||||||
|
if (analysisIds[source.file] === entry.id) group(source.path).items.push(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const ignored of analysis?.ignored ?? []) {
|
||||||
|
if (analysisIds[ignored.file] === entry.id) group(ignored.path).ignored.push(ignored);
|
||||||
|
}
|
||||||
|
return [...groups.values()].sort((a, b) => a.path.localeCompare(b.path));
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
@@ -123,6 +157,40 @@ export function FilesTab({ workspace }: FilesTabProps) {
|
|||||||
})}
|
})}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-expanded={pasteOpen}
|
||||||
|
aria-controls="cert-paste-area"
|
||||||
|
onClick={() => setPasteOpen((open) => !open)}
|
||||||
|
className="rounded border border-border px-3 py-1 text-sm text-foreground hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
>
|
||||||
|
{t('files.pasteToggle')}
|
||||||
|
</button>
|
||||||
|
{pasteOpen && (
|
||||||
|
<div id="cert-paste-area" className="space-y-2">
|
||||||
|
<p className="text-xs text-muted-foreground">{t('files.pasteHint')}</p>
|
||||||
|
<textarea
|
||||||
|
value={pasteText}
|
||||||
|
onChange={(e) => setPasteText(e.target.value)}
|
||||||
|
aria-label={t('files.pasteLabel')}
|
||||||
|
rows={6}
|
||||||
|
spellCheck={false}
|
||||||
|
autoComplete="off"
|
||||||
|
className="w-full rounded border border-border bg-background px-3 py-2 font-mono text-xs text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={addPasted}
|
||||||
|
disabled={pasteText.trim() === ''}
|
||||||
|
className="btn btn-secondary"
|
||||||
|
>
|
||||||
|
{t('files.pasteAdd')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{rejected.length > 0 && (
|
{rejected.length > 0 && (
|
||||||
<div role="alert" className="rounded-lg border border-border bg-muted p-3 text-sm">
|
<div role="alert" className="rounded-lg border border-border bg-muted p-3 text-sm">
|
||||||
<p className="font-medium text-foreground">{t('files.rejectedTitle')}</p>
|
<p className="font-medium text-foreground">{t('files.rejectedTitle')}</p>
|
||||||
@@ -133,6 +201,7 @@ export function FilesTab({ workspace }: FilesTabProps) {
|
|||||||
name: r.name,
|
name: r.name,
|
||||||
max: MAX_ENTRIES,
|
max: MAX_ENTRIES,
|
||||||
size: (r.reason === 'tooLarge' ? MAX_FILE_BYTES : MAX_TOTAL_BYTES) / MB,
|
size: (r.reason === 'tooLarge' ? MAX_FILE_BYTES : MAX_TOTAL_BYTES) / MB,
|
||||||
|
chars: MAX_PASTE_CHARS,
|
||||||
})}
|
})}
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
@@ -181,16 +250,16 @@ export function FilesTab({ workspace }: FilesTabProps) {
|
|||||||
|
|
||||||
<ul className="space-y-2">
|
<ul className="space-y-2">
|
||||||
{entries.map((entry) => {
|
{entries.map((entry) => {
|
||||||
const items = itemsOf(entry);
|
const groups = groupsOf(entry);
|
||||||
const ignored = ignoredOf(entry);
|
|
||||||
const analysed = analysisIds.includes(entry.id);
|
const analysed = analysisIds.includes(entry.id);
|
||||||
return (
|
return (
|
||||||
<li key={entry.id} className="rounded-lg border border-border p-3">
|
<li key={entry.id} className="rounded-lg border border-border p-3">
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<p className="break-all text-sm font-medium text-foreground">{entry.label}</p>
|
<p className="break-all text-sm font-medium text-foreground">{entry.label}</p>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="flex flex-wrap gap-x-3 text-xs text-muted-foreground">
|
||||||
{formatBytes(entry.file.size)}
|
<span>{formatBytes(entry.file.size)}</span>
|
||||||
|
{entry.origin === 'paste' && <span>{t('files.originPaste')}</span>}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@@ -202,25 +271,37 @@ export function FilesTab({ workspace }: FilesTabProps) {
|
|||||||
{t('files.removeShort')}
|
{t('files.removeShort')}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<ul className="mt-2 space-y-1">
|
<ul className="mt-2 space-y-2">
|
||||||
{items.map((item) => (
|
{groups.map((g) => (
|
||||||
<li key={item.id} className="flex flex-wrap items-center gap-2 text-sm">
|
<li key={g.path}>
|
||||||
<span
|
{g.path !== entry.file.name && (
|
||||||
className={`rounded px-2 py-0.5 text-xs font-semibold ${ROLE_STYLES[roleKey(item)]}`}
|
<p className="break-all text-xs text-muted-foreground">{g.path}</p>
|
||||||
>
|
|
||||||
{t(`roles.${roleKey(item)}`)}
|
|
||||||
</span>
|
|
||||||
<span className="break-all text-foreground">{itemName(item)}</span>
|
|
||||||
{item.kind === 'certificate' && item.isExpired && (
|
|
||||||
<span className="rounded bg-red-100 px-2 py-0.5 text-xs font-medium text-red-800 dark:bg-red-900/40 dark:text-red-300">
|
|
||||||
{t('files.expired')}
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
</li>
|
<ul className="space-y-1">
|
||||||
))}
|
{g.items.map((item) => (
|
||||||
{ignored.map((i) => (
|
<li key={item.id} className="flex flex-wrap items-center gap-2 text-sm">
|
||||||
<li key={`${i.path}-${i.reason}`} className="text-sm text-muted-foreground">
|
<span
|
||||||
{t(`files.ignored.${i.reason}`)}
|
className={`rounded px-2 py-0.5 text-xs font-semibold ${ROLE_STYLES[roleKey(item)]}`}
|
||||||
|
>
|
||||||
|
{t(`roles.${roleKey(item)}`)}
|
||||||
|
</span>
|
||||||
|
<span className="break-all text-foreground">{itemName(item)}</span>
|
||||||
|
{item.kind === 'certificate' && item.isExpired && (
|
||||||
|
<span className="rounded bg-red-100 px-2 py-0.5 text-xs font-medium text-red-800 dark:bg-red-900/40 dark:text-red-300">
|
||||||
|
{t('files.expired')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
{g.ignored.map((i) => (
|
||||||
|
<li
|
||||||
|
key={`${i.path}-${i.reason}`}
|
||||||
|
className="text-sm text-muted-foreground"
|
||||||
|
>
|
||||||
|
{t(`files.ignored.${i.reason}`)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
{!analysed && status === 'analyzing' && (
|
{!analysed && status === 'analyzing' && (
|
||||||
|
|||||||
@@ -0,0 +1,90 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useFormatter, useTranslations } from 'next-intl';
|
||||||
|
import type { CertItem, ItemSource } from '../actions';
|
||||||
|
import type { WorkingEntry } from '../working-set';
|
||||||
|
import { ROLE_STYLES } from './FilesTab';
|
||||||
|
|
||||||
|
interface ItemCardProps {
|
||||||
|
cert: CertItem;
|
||||||
|
/** Eintraege des Arbeitsbereichs und ihre Kennungen zum Analysezeitpunkt, fuer die Namen der Quellen */
|
||||||
|
entries: WorkingEntry[];
|
||||||
|
analysisIds: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Anzeigename einer Quelle: Pfad (bei ZIP "zip/eintrag"), bei eingefuegtem Text die Beschriftung. */
|
||||||
|
function sourceLabel(source: ItemSource, entries: WorkingEntry[], analysisIds: string[]): string {
|
||||||
|
const entry = entries.find((e) => e.id === analysisIds[source.file]);
|
||||||
|
if (entry && source.path === entry.file.name) return entry.label;
|
||||||
|
return source.path;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Eine Karte je Zertifikat im Reiter „Analysieren“ (D-23): Rolle, Name, Gueltigkeit, Namen,
|
||||||
|
* Schluessel, Seriennummer, Fingerabdruecke und woher das Zertifikat kam.
|
||||||
|
* Daten kommen in UTC, wie im Zertifikat; der Aussteller nennt dasselbe Datum.
|
||||||
|
*/
|
||||||
|
export function ItemCard({ cert, entries, analysisIds }: ItemCardProps) {
|
||||||
|
const t = useTranslations('certManager');
|
||||||
|
const format = useFormatter();
|
||||||
|
const date = (iso: string) =>
|
||||||
|
format.dateTime(new Date(iso), { dateStyle: 'medium', timeZone: 'UTC' });
|
||||||
|
|
||||||
|
const status = cert.isExpired
|
||||||
|
? { cls: 'bg-status-down/15 text-status-down-fg', text: t('analyze.expired') }
|
||||||
|
: cert.daysLeft <= 30
|
||||||
|
? {
|
||||||
|
cls: 'bg-status-warn/15 text-status-warn-fg',
|
||||||
|
text: t('analyze.expiresSoon', { days: cert.daysLeft }),
|
||||||
|
}
|
||||||
|
: { cls: 'bg-status-ok/15 text-status-ok-fg', text: t('analyze.valid') };
|
||||||
|
|
||||||
|
const keyText =
|
||||||
|
cert.keyType === 'RSA'
|
||||||
|
? t('analyze.keyRsa', { bits: cert.keyBits ?? 0 })
|
||||||
|
: cert.keyType === 'EC'
|
||||||
|
? t('analyze.keyEc', { curve: cert.curve ?? '' })
|
||||||
|
: cert.keyType;
|
||||||
|
|
||||||
|
const sources = cert.sources.map((s) => sourceLabel(s, entries, analysisIds));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li className="rounded-lg border border-border p-4" data-testid="cert-card">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<span className={`rounded px-2 py-0.5 text-xs font-semibold ${ROLE_STYLES[cert.role]}`}>
|
||||||
|
{t(`roles.${cert.role}`)}
|
||||||
|
</span>
|
||||||
|
<span className="break-all font-medium text-foreground">{cert.cn || cert.baseName}</span>
|
||||||
|
<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(`analyze.explain.${cert.role}`)}</p>
|
||||||
|
|
||||||
|
<dl className="mt-3 grid grid-cols-[max-content_1fr] gap-x-4 gap-y-1 text-sm">
|
||||||
|
<dt className="text-muted-foreground">{t('analyze.issuer')}</dt>
|
||||||
|
<dd className="break-all text-foreground">{cert.issuerCn || t('chain.unknownIssuer')}</dd>
|
||||||
|
<dt className="text-muted-foreground">{t('analyze.validity')}</dt>
|
||||||
|
<dd className="text-foreground">
|
||||||
|
{t('analyze.validRange', { from: date(cert.notBefore), to: date(cert.notAfter) })}
|
||||||
|
</dd>
|
||||||
|
{cert.san.length > 0 && (
|
||||||
|
<>
|
||||||
|
<dt className="text-muted-foreground">{t('analyze.names')}</dt>
|
||||||
|
<dd className="break-all text-foreground">{cert.san.join(', ')}</dd>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<dt className="text-muted-foreground">{t('analyze.key')}</dt>
|
||||||
|
<dd className="text-foreground">{keyText}</dd>
|
||||||
|
<dt className="text-muted-foreground">{t('analyze.serial')}</dt>
|
||||||
|
<dd className="break-all font-mono text-xs text-foreground">{cert.serialNumber}</dd>
|
||||||
|
<dt className="text-muted-foreground">{t('analyze.sha256')}</dt>
|
||||||
|
<dd className="break-all font-mono text-xs text-foreground">{cert.sha256}</dd>
|
||||||
|
<dt className="text-muted-foreground">{t('analyze.sha1')}</dt>
|
||||||
|
<dd className="break-all font-mono text-xs text-foreground">{cert.sha1}</dd>
|
||||||
|
<dt className="text-muted-foreground">{t('analyze.source')}</dt>
|
||||||
|
<dd className="break-all text-foreground">{sources.join(', ')}</dd>
|
||||||
|
</dl>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -77,6 +77,7 @@ function workspace(items: CertItem[], chains: ChainInfo[]): CertWorkspace {
|
|||||||
status: 'idle',
|
status: 'idle',
|
||||||
errorKey: null,
|
errorKey: null,
|
||||||
addFiles: () => [],
|
addFiles: () => [],
|
||||||
|
addText: () => null,
|
||||||
remove: () => {},
|
remove: () => {},
|
||||||
clear: () => {},
|
clear: () => {},
|
||||||
retry: () => {},
|
retry: () => {},
|
||||||
|
|||||||
@@ -0,0 +1,207 @@
|
|||||||
|
import { cleanup, fireEvent, render as rtlRender, screen, within } 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 } from '../actions';
|
||||||
|
import type { CertWorkspace } from '../use-cert-workspace';
|
||||||
|
import { dedupeNames, SplitTab } from './SplitTab';
|
||||||
|
|
||||||
|
const mockDownload = vi.fn();
|
||||||
|
const mockFetch = vi.fn();
|
||||||
|
|
||||||
|
vi.mock('../actions', async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import('../actions')>();
|
||||||
|
return { ...actual, downloadBase64: (...args: unknown[]) => mockDownload(...args) };
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock('fflate', async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import('fflate')>();
|
||||||
|
return { ...actual, zipSync: vi.fn(actual.zipSync) };
|
||||||
|
});
|
||||||
|
|
||||||
|
function render(ui: ReactElement) {
|
||||||
|
return rtlRender(
|
||||||
|
<NextIntlClientProvider locale="de" messages={de} timeZone="Europe/Berlin">
|
||||||
|
{ui}
|
||||||
|
</NextIntlClientProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function cert(id: string, cn: string, role: CertItem['role'], baseName = cn): CertItem {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
kind: 'certificate',
|
||||||
|
role,
|
||||||
|
sources: [{ file: 0, path: `${id}.pem` }],
|
||||||
|
pem: `-----BEGIN CERTIFICATE-----\n${id}\n-----END CERTIFICATE-----\n`,
|
||||||
|
baseName,
|
||||||
|
cn,
|
||||||
|
organization: '',
|
||||||
|
issuerCn: '',
|
||||||
|
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: role !== 'end-entity',
|
||||||
|
selfSigned: role === 'root',
|
||||||
|
aiaIssuerUrls: [],
|
||||||
|
keyId: null,
|
||||||
|
csrIds: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function workspace(items: AnyItem[] | null): CertWorkspace {
|
||||||
|
const analysis: AnalysisResult | null = items
|
||||||
|
? { items, chains: [], locked: [], ignored: [] }
|
||||||
|
: null;
|
||||||
|
return {
|
||||||
|
entries: [],
|
||||||
|
analysis,
|
||||||
|
analysisIds: [],
|
||||||
|
status: 'idle',
|
||||||
|
errorKey: null,
|
||||||
|
addFiles: () => [],
|
||||||
|
addText: () => null,
|
||||||
|
remove: () => {},
|
||||||
|
clear: () => {},
|
||||||
|
retry: () => {},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockDownload.mockReset();
|
||||||
|
mockFetch.mockReset();
|
||||||
|
vi.stubGlobal('fetch', mockFetch);
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
const items: AnyItem[] = [
|
||||||
|
cert('c-leaf', 'www.example.test', 'end-entity'),
|
||||||
|
cert('c-inter', 'Test Inter', 'intermediate', 'Test_Inter'),
|
||||||
|
];
|
||||||
|
|
||||||
|
describe('SplitTab', () => {
|
||||||
|
it('jedes Teil als Zeile mit Rolle, Name und Dateiname', () => {
|
||||||
|
render(<SplitTab workspace={workspace(items)} />);
|
||||||
|
const rows = screen.getAllByRole('listitem');
|
||||||
|
expect(rows).toHaveLength(2);
|
||||||
|
expect(within(rows[0]).getByText('Serverzertifikat')).toBeInTheDocument();
|
||||||
|
expect(within(rows[0]).getByText('www.example.test.crt')).toBeInTheDocument();
|
||||||
|
expect(within(rows[1]).getByText('Zwischenzertifikat')).toBeInTheDocument();
|
||||||
|
expect(within(rows[1]).getByText('Test_Inter.crt')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Herunterladen gibt das PEM des Teils als Base64 an downloadBase64, ohne Aufruf der API', () => {
|
||||||
|
render(<SplitTab workspace={workspace(items)} />);
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '„www.example.test.crt“ herunterladen' }));
|
||||||
|
expect(mockDownload).toHaveBeenCalledTimes(1);
|
||||||
|
const [name, content, mime] = mockDownload.mock.calls[0];
|
||||||
|
expect(name).toBe('www.example.test.crt');
|
||||||
|
expect(atob(content)).toBe(items[0].pem);
|
||||||
|
expect(mime).toBe('application/x-pem-file');
|
||||||
|
expect(mockFetch).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Schluessel und Anfragen bekommen .key und .csr (Zeilen sind unabhaengig von der Art)', () => {
|
||||||
|
const key: AnyItem = {
|
||||||
|
id: 'k-1',
|
||||||
|
kind: 'privateKey',
|
||||||
|
sources: [],
|
||||||
|
pem: 'KEY',
|
||||||
|
baseName: 'schluessel',
|
||||||
|
keyType: 'RSA',
|
||||||
|
keyBits: 2048,
|
||||||
|
curve: null,
|
||||||
|
wasEncrypted: false,
|
||||||
|
certIds: [],
|
||||||
|
};
|
||||||
|
const csr: AnyItem = {
|
||||||
|
id: 'r-1',
|
||||||
|
kind: 'csr',
|
||||||
|
sources: [],
|
||||||
|
pem: 'CSR',
|
||||||
|
baseName: 'anfrage',
|
||||||
|
cn: 'www.example.test',
|
||||||
|
organization: '',
|
||||||
|
san: [],
|
||||||
|
keyType: 'RSA',
|
||||||
|
keyBits: 2048,
|
||||||
|
curve: null,
|
||||||
|
keyId: null,
|
||||||
|
certIds: [],
|
||||||
|
};
|
||||||
|
render(<SplitTab workspace={workspace([items[0], key, csr])} />);
|
||||||
|
expect(screen.getByText('schluessel.key')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('anfrage.csr')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('„Alle als ZIP herunterladen“ packt alle Teile mit durchnummerierten gleichen Namen', async () => {
|
||||||
|
const fflate = await import('fflate');
|
||||||
|
const click = vi.fn();
|
||||||
|
const createObjectURL = vi.fn(() => 'blob:x');
|
||||||
|
const revoke = vi.fn();
|
||||||
|
vi.stubGlobal('URL', { ...URL, createObjectURL, revokeObjectURL: revoke });
|
||||||
|
const realCreate = document.createElement.bind(document);
|
||||||
|
let anchor: HTMLAnchorElement | null = null;
|
||||||
|
vi.spyOn(document, 'createElement').mockImplementation((tag: string) => {
|
||||||
|
const el = realCreate(tag);
|
||||||
|
if (tag === 'a') {
|
||||||
|
anchor = el as HTMLAnchorElement;
|
||||||
|
el.click = click;
|
||||||
|
}
|
||||||
|
return el;
|
||||||
|
});
|
||||||
|
const dup = [
|
||||||
|
cert('c-1', 'x', 'end-entity', 'gleich'),
|
||||||
|
cert('c-2', 'y', 'end-entity', 'gleich'),
|
||||||
|
cert('c-3', 'z', 'root', 'Wurzel'),
|
||||||
|
];
|
||||||
|
render(<SplitTab workspace={workspace(dup)} />);
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Alle als ZIP herunterladen' }));
|
||||||
|
expect(fflate.zipSync).toHaveBeenCalledTimes(1);
|
||||||
|
const files = (fflate.zipSync as unknown as { mock: { calls: [Record<string, Uint8Array>][] } })
|
||||||
|
.mock.calls[0][0];
|
||||||
|
expect(Object.keys(files)).toEqual(['gleich.crt', 'gleich-2.crt', 'Wurzel.crt']);
|
||||||
|
expect(new TextDecoder().decode(files['gleich-2.crt'])).toBe(dup[1].pem);
|
||||||
|
expect(click).toHaveBeenCalled();
|
||||||
|
expect((anchor as HTMLAnchorElement | null)?.download).toBe('Zertifikate.zip');
|
||||||
|
expect(revoke).toHaveBeenCalledWith('blob:x');
|
||||||
|
expect(mockFetch).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('bei nur einem Teil gibt es keinen ZIP-Knopf', () => {
|
||||||
|
render(<SplitTab workspace={workspace([items[0]])} />);
|
||||||
|
expect(
|
||||||
|
screen.queryByRole('button', { name: 'Alle als ZIP herunterladen' }),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ohne erkannte Teile: ruhiger Hinweis', () => {
|
||||||
|
render(<SplitTab workspace={workspace([])} />);
|
||||||
|
expect(screen.getByText(/noch nichts erkannt/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('dedupeNames', () => {
|
||||||
|
it('nummeriert wiederholte Namen vor der Endung', () => {
|
||||||
|
expect(dedupeNames(['a.crt', 'a.crt', 'b.crt', 'a.crt'])).toEqual([
|
||||||
|
'a.crt',
|
||||||
|
'a-2.crt',
|
||||||
|
'b.crt',
|
||||||
|
'a-3.crt',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { zipSync } from 'fflate';
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
import { type AnyItem, downloadBase64 } from '../actions';
|
||||||
|
import type { CertWorkspace } from '../use-cert-workspace';
|
||||||
|
import { sanitizeZipFilename } from '../zip-filename';
|
||||||
|
import { ROLE_STYLES } from './FilesTab';
|
||||||
|
|
||||||
|
interface SplitTabProps {
|
||||||
|
workspace: CertWorkspace;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MIME_PEM = 'application/x-pem-file';
|
||||||
|
|
||||||
|
/** Dateiname im natuerlichen Format des Teils; Zertifikat .crt, Schluessel .key, Anfrage .csr. */
|
||||||
|
function fileNameOf(item: AnyItem): string {
|
||||||
|
const ext = item.kind === 'certificate' ? 'crt' : item.kind === 'privateKey' ? 'key' : 'csr';
|
||||||
|
return `${item.baseName}.${ext}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function roleKey(item: AnyItem): string {
|
||||||
|
return item.kind === 'certificate' ? item.role : item.kind;
|
||||||
|
}
|
||||||
|
|
||||||
|
function itemName(item: AnyItem): string {
|
||||||
|
return item.kind === 'privateKey' ? item.baseName : item.cn || item.baseName;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toBase64(text: string): string {
|
||||||
|
return btoa(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Gleiche Namen bekommen -2, -3 vor der Endung (fflate ueberschriebe sie sonst stillschweigend). */
|
||||||
|
export function dedupeNames(names: string[]): string[] {
|
||||||
|
const used = new Set<string>();
|
||||||
|
return names.map((name) => {
|
||||||
|
let candidate = name;
|
||||||
|
let n = 1;
|
||||||
|
const dot = name.lastIndexOf('.');
|
||||||
|
const base = dot > 0 ? name.slice(0, dot) : name;
|
||||||
|
const ext = dot > 0 ? name.slice(dot) : '';
|
||||||
|
while (used.has(candidate)) candidate = `${base}-${++n}${ext}`;
|
||||||
|
used.add(candidate);
|
||||||
|
return candidate;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function downloadZip(items: AnyItem[], zipFilename: string) {
|
||||||
|
const names = dedupeNames(items.map(fileNameOf));
|
||||||
|
const files: Record<string, Uint8Array> = {};
|
||||||
|
items.forEach((item, i) => {
|
||||||
|
files[names[i]] = new TextEncoder().encode(item.pem);
|
||||||
|
});
|
||||||
|
const blob = new Blob([zipSync(files) as BlobPart], { type: 'application/zip' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const anchor = document.createElement('a');
|
||||||
|
anchor.href = url;
|
||||||
|
// Der uebersetzte Name kann Zeichen tragen, die Windows verbietet: auf das Zulaessige zurueckschneiden.
|
||||||
|
anchor.download = sanitizeZipFilename(zipFilename);
|
||||||
|
anchor.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reiter „Aufteilen“: jedes erkannte Teil einzeln in seinem natuerlichen Format herunterladen,
|
||||||
|
* oder alle zusammen als ZIP. Rein im Browser aus den Daten der Analyse; kein Aufruf der API.
|
||||||
|
*/
|
||||||
|
export function SplitTab({ workspace }: SplitTabProps) {
|
||||||
|
const t = useTranslations('certManager');
|
||||||
|
const items = workspace.analysis?.items ?? [];
|
||||||
|
|
||||||
|
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('split.nothingFound')}</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p className="text-sm text-muted-foreground">{t('split.intro')}</p>
|
||||||
|
{items.length > 1 && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => downloadZip(items, t('split.zipFilename'))}
|
||||||
|
className="btn btn-secondary"
|
||||||
|
>
|
||||||
|
{t('split.downloadAll')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{items.map((item) => (
|
||||||
|
<li
|
||||||
|
key={item.id}
|
||||||
|
className="flex items-center justify-between gap-3 rounded-lg border border-border px-3 py-2 text-sm"
|
||||||
|
>
|
||||||
|
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-2">
|
||||||
|
<span
|
||||||
|
className={`shrink-0 rounded px-2 py-0.5 text-xs font-semibold ${ROLE_STYLES[roleKey(item)]}`}
|
||||||
|
>
|
||||||
|
{t(`roles.${roleKey(item)}`)}
|
||||||
|
</span>
|
||||||
|
<span className="break-all font-medium text-foreground">{itemName(item)}</span>
|
||||||
|
<span className="break-all text-xs text-muted-foreground">{fileNameOf(item)}</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => downloadBase64(fileNameOf(item), toBase64(item.pem), MIME_PEM)}
|
||||||
|
aria-label={t('split.downloadFor', { name: fileNameOf(item) })}
|
||||||
|
className="shrink-0 rounded border border-border px-3 py-1 text-xs font-medium text-foreground hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
>
|
||||||
|
{t('split.download')}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,12 +4,14 @@ import { useTranslations } from 'next-intl';
|
|||||||
import { useState } from 'react';
|
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 { 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 { useCertWorkspace } from './use-cert-workspace';
|
import { useCertWorkspace } from './use-cert-workspace';
|
||||||
|
|
||||||
type TabId = 'files' | 'merge';
|
type TabId = 'files' | 'analyze' | 'split' | 'merge';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* CertManagerPage: Zertifikat-Manager rund um einen gemeinsamen Arbeitsbereich (quick-261009-ikt).
|
* CertManagerPage: Zertifikat-Manager rund um einen gemeinsamen Arbeitsbereich (quick-261009-ikt).
|
||||||
@@ -27,6 +29,8 @@ export default function CertManagerPage() {
|
|||||||
id: 'files',
|
id: 'files',
|
||||||
label: count > 0 ? t('tabs.filesWithCount', { count }) : t('tabs.files'),
|
label: count > 0 ? t('tabs.filesWithCount', { count }) : t('tabs.files'),
|
||||||
},
|
},
|
||||||
|
{ id: 'analyze', label: t('tabs.analyze') },
|
||||||
|
{ id: 'split', label: t('tabs.split') },
|
||||||
{ id: 'merge', label: t('tabs.merge') },
|
{ id: 'merge', label: t('tabs.merge') },
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -42,6 +46,8 @@ export default function CertManagerPage() {
|
|||||||
{activeTab !== 'files' && count > 0 && (
|
{activeTab !== 'files' && count > 0 && (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<p className="text-sm text-muted-foreground">{t('workspace.basis', { count })}</p>
|
<p className="text-sm text-muted-foreground">{t('workspace.basis', { count })}</p>
|
||||||
|
{activeTab === 'analyze' && <AnalyzeTab workspace={workspace} />}
|
||||||
|
{activeTab === 'split' && <SplitTab workspace={workspace} />}
|
||||||
{activeTab === 'merge' && <MergeTab workspace={workspace} />}
|
{activeTab === 'merge' && <MergeTab workspace={workspace} />}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useCallback, useRef, useState } from 'react';
|
import { useCallback, useRef, useState } from 'react';
|
||||||
import { type AnalysisResult, analyzeWorkingSet, certErrorKey } from './actions';
|
import { type AnalysisResult, analyzeWorkingSet, certErrorKey } from './actions';
|
||||||
import {
|
import {
|
||||||
|
addText as addTextToSet,
|
||||||
addFiles as addToSet,
|
addFiles as addToSet,
|
||||||
type RejectedFile,
|
type RejectedFile,
|
||||||
removeEntry,
|
removeEntry,
|
||||||
@@ -21,6 +22,8 @@ export interface CertWorkspace {
|
|||||||
/** Schluessel unter certManager.errors.* */
|
/** Schluessel unter certManager.errors.* */
|
||||||
errorKey: string | null;
|
errorKey: string | null;
|
||||||
addFiles: (files: File[]) => RejectedFile[];
|
addFiles: (files: File[]) => RejectedFile[];
|
||||||
|
/** Eingefuegten PEM-Text als Eintrag anhaengen; `label` liefert die Beschriftung in der Sprache der Oberflaeche */
|
||||||
|
addText: (text: string, label?: (n: number) => string) => RejectedFile | null;
|
||||||
remove: (id: string) => void;
|
remove: (id: string) => void;
|
||||||
clear: () => void;
|
clear: () => void;
|
||||||
retry: () => void;
|
retry: () => void;
|
||||||
@@ -86,6 +89,20 @@ export function useCertWorkspace(): CertWorkspace {
|
|||||||
[commit],
|
[commit],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const addText = useCallback(
|
||||||
|
(text: string, label?: (n: number) => string): RejectedFile | null => {
|
||||||
|
const result = addTextToSet(
|
||||||
|
entriesRef.current,
|
||||||
|
text,
|
||||||
|
() => `entry-${++idCounter.current}`,
|
||||||
|
label,
|
||||||
|
);
|
||||||
|
if (result.entries.length !== entriesRef.current.length) commit(result.entries);
|
||||||
|
return result.rejected;
|
||||||
|
},
|
||||||
|
[commit],
|
||||||
|
);
|
||||||
|
|
||||||
const remove = useCallback(
|
const remove = useCallback(
|
||||||
(id: string) => {
|
(id: string) => {
|
||||||
commit(removeEntry(entriesRef.current, id));
|
commit(removeEntry(entriesRef.current, id));
|
||||||
@@ -101,5 +118,16 @@ export function useCertWorkspace(): CertWorkspace {
|
|||||||
runAnalysis(entriesRef.current);
|
runAnalysis(entriesRef.current);
|
||||||
}, [runAnalysis]);
|
}, [runAnalysis]);
|
||||||
|
|
||||||
return { entries, analysis, analysisIds, status, errorKey, addFiles, remove, clear, retry };
|
return {
|
||||||
|
entries,
|
||||||
|
analysis,
|
||||||
|
analysisIds,
|
||||||
|
status,
|
||||||
|
errorKey,
|
||||||
|
addFiles,
|
||||||
|
addText,
|
||||||
|
remove,
|
||||||
|
clear,
|
||||||
|
retry,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import {
|
import {
|
||||||
addFiles,
|
addFiles,
|
||||||
|
addText,
|
||||||
MAX_ENTRIES,
|
MAX_ENTRIES,
|
||||||
MAX_FILE_BYTES,
|
MAX_FILE_BYTES,
|
||||||
|
MAX_PASTE_CHARS,
|
||||||
removeEntry,
|
removeEntry,
|
||||||
toFormData,
|
toFormData,
|
||||||
type WorkingEntry,
|
type WorkingEntry,
|
||||||
@@ -68,6 +70,59 @@ describe('addFiles', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('addText', () => {
|
||||||
|
it('trimmt, legt pasted-1.pem an und beschriftet „Eingefügter Text 1“', () => {
|
||||||
|
const r = addText(NONE, ' -----BEGIN CERTIFICATE-----\nabc\n ', nextId);
|
||||||
|
expect(r.rejected).toBeNull();
|
||||||
|
expect(r.entries).toHaveLength(1);
|
||||||
|
const [e] = r.entries;
|
||||||
|
expect(e.file.name).toBe('pasted-1.pem');
|
||||||
|
expect(e.label).toBe('Eingefügter Text 1');
|
||||||
|
expect(e.origin).toBe('paste');
|
||||||
|
expect(e.host).toBeNull();
|
||||||
|
expect(e.password).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('der Inhalt der Datei ist der getrimmte Text', async () => {
|
||||||
|
const r = addText(NONE, '\n hallo \n', nextId);
|
||||||
|
expect(await r.entries[0].file.text()).toBe('hallo');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('der naechste Text heisst pasted-2.pem, auch nach dem Entfernen des ersten', () => {
|
||||||
|
const one = addText(NONE, 'eins', nextId);
|
||||||
|
const two = addText(one.entries, 'zwei', nextId);
|
||||||
|
expect(two.entries.map((e) => e.file.name)).toEqual(['pasted-1.pem', 'pasted-2.pem']);
|
||||||
|
const without = removeEntry(two.entries, two.entries[0].id);
|
||||||
|
const three = addText(without, 'drei', nextId);
|
||||||
|
expect(three.entries.map((e) => e.file.name)).toEqual(['pasted-2.pem', 'pasted-3.pem']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('die Beschriftung kommt aus der uebergebenen Funktion (Sprache)', () => {
|
||||||
|
const r = addText(NONE, 'x', nextId, (n) => `Pasted text ${n}`);
|
||||||
|
expect(r.entries[0].label).toBe('Pasted text 1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leerer oder nur aus Leerraum bestehender Text aendert nichts', () => {
|
||||||
|
const r = addText(NONE, ' \n ', nextId);
|
||||||
|
expect(r.entries).toEqual([]);
|
||||||
|
expect(r.rejected).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('mehr als 256 000 Zeichen werden abgelehnt', () => {
|
||||||
|
const r = addText(NONE, 'a'.repeat(MAX_PASTE_CHARS + 1), nextId);
|
||||||
|
expect(r.entries).toEqual([]);
|
||||||
|
expect(r.rejected).toEqual({ name: 'Eingefügter Text 1', reason: 'pasteTooLarge' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('eingefuegte Texte zaehlen zu den 30 Eintraegen', () => {
|
||||||
|
const files = Array.from({ length: MAX_ENTRIES }, (_, i) => makeFile(`f${i}.pem`, 10, i));
|
||||||
|
const full = addFiles(NONE, files, nextId);
|
||||||
|
const r = addText(full.entries, 'noch einer', nextId);
|
||||||
|
expect(r.entries).toHaveLength(MAX_ENTRIES);
|
||||||
|
expect(r.rejected).toEqual({ name: 'Eingefügter Text 1', reason: 'tooMany' });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('removeEntry', () => {
|
describe('removeEntry', () => {
|
||||||
it('entfernt nur den genannten Eintrag und behaelt die Reihenfolge', () => {
|
it('entfernt nur den genannten Eintrag und behaelt die Reihenfolge', () => {
|
||||||
const { entries } = addFiles(
|
const { entries } = addFiles(
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export interface WorkingEntry {
|
|||||||
password: string;
|
password: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type RejectReason = 'duplicate' | 'tooMany' | 'tooLarge' | 'totalTooLarge';
|
export type RejectReason = 'duplicate' | 'tooMany' | 'tooLarge' | 'totalTooLarge' | 'pasteTooLarge';
|
||||||
|
|
||||||
export interface RejectedFile {
|
export interface RejectedFile {
|
||||||
name: string;
|
name: string;
|
||||||
@@ -75,6 +75,53 @@ export function addFiles(
|
|||||||
return { entries: next, rejected };
|
return { entries: next, rejected };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const PASTED_NAME = /^pasted-(\d+)\.pem$/;
|
||||||
|
|
||||||
|
export interface AddTextResult {
|
||||||
|
entries: WorkingEntry[];
|
||||||
|
rejected: RejectedFile | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Haengt eingefuegten PEM-Text als eigenen Eintrag an (D-23): `pasted-<n>.pem`, Beschriftung
|
||||||
|
* „Eingefuegter Text <n>“. Die Nummer zaehlt weiter, auch wenn ein frueherer Text entfernt wurde.
|
||||||
|
* Leerer Text aendert nichts. Der Text zaehlt wie eine Datei zu den 30 Eintraegen und zur Gesamtgroesse.
|
||||||
|
*/
|
||||||
|
export function addText(
|
||||||
|
entries: WorkingEntry[],
|
||||||
|
text: string,
|
||||||
|
nextId: () => string,
|
||||||
|
labelFor: (n: number) => string = (n) => `Eingefügter Text ${n}`,
|
||||||
|
): AddTextResult {
|
||||||
|
const trimmed = text.trim();
|
||||||
|
if (trimmed === '') return { entries, rejected: null };
|
||||||
|
|
||||||
|
const used = entries
|
||||||
|
.filter((e) => e.origin === 'paste')
|
||||||
|
.map((e) => Number(PASTED_NAME.exec(e.file.name)?.[1] ?? 0));
|
||||||
|
const n = Math.max(0, ...used) + 1;
|
||||||
|
const label = labelFor(n);
|
||||||
|
const file = new File([trimmed], `pasted-${n}.pem`, {
|
||||||
|
type: 'text/plain',
|
||||||
|
lastModified: Date.now(),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (trimmed.length > MAX_PASTE_CHARS) {
|
||||||
|
return { entries, rejected: { name: label, reason: 'pasteTooLarge' } };
|
||||||
|
}
|
||||||
|
if (entries.length >= MAX_ENTRIES) {
|
||||||
|
return { entries, rejected: { name: label, reason: 'tooMany' } };
|
||||||
|
}
|
||||||
|
const total = entries.reduce((sum, e) => sum + e.file.size, 0);
|
||||||
|
if (total + file.size > MAX_TOTAL_BYTES) {
|
||||||
|
return { entries, rejected: { name: label, reason: 'totalTooLarge' } };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
entries: [...entries, { id: nextId(), file, label, origin: 'paste', host: null, password: '' }],
|
||||||
|
rejected: null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
/** Entfernt einen Eintrag; die Reihenfolge der uebrigen bleibt. */
|
/** Entfernt einen Eintrag; die Reihenfolge der uebrigen bleibt. */
|
||||||
export function removeEntry(entries: WorkingEntry[], id: string): WorkingEntry[] {
|
export function removeEntry(entries: WorkingEntry[], id: string): WorkingEntry[] {
|
||||||
return entries.filter((e) => e.id !== id);
|
return entries.filter((e) => e.id !== id);
|
||||||
|
|||||||
@@ -1254,6 +1254,8 @@
|
|||||||
"tabs": {
|
"tabs": {
|
||||||
"files": "Dateien",
|
"files": "Dateien",
|
||||||
"filesWithCount": "Dateien ({count})",
|
"filesWithCount": "Dateien ({count})",
|
||||||
|
"analyze": "Analysieren",
|
||||||
|
"split": "Aufteilen",
|
||||||
"merge": "Zusammenführen"
|
"merge": "Zusammenführen"
|
||||||
},
|
},
|
||||||
"roles": {
|
"roles": {
|
||||||
@@ -1282,7 +1284,8 @@
|
|||||||
"duplicate": "„{name}“ ist schon in der Liste.",
|
"duplicate": "„{name}“ ist schon in der Liste.",
|
||||||
"tooMany": "„{name}“: die Liste ist mit {max} Dateien voll.",
|
"tooMany": "„{name}“: die Liste ist mit {max} Dateien voll.",
|
||||||
"tooLarge": "„{name}“ ist größer als {size} MB.",
|
"tooLarge": "„{name}“ ist größer als {size} MB.",
|
||||||
"totalTooLarge": "„{name}“: zusammen wären es mehr als {size} MB."
|
"totalTooLarge": "„{name}“: zusammen wären es mehr als {size} MB.",
|
||||||
|
"pasteTooLarge": "„{name}“ ist länger als {chars} Zeichen."
|
||||||
},
|
},
|
||||||
"ignored": {
|
"ignored": {
|
||||||
"unknown": "Darin wurde kein Zertifikat erkannt.",
|
"unknown": "Darin wurde kein Zertifikat erkannt.",
|
||||||
@@ -1294,7 +1297,13 @@
|
|||||||
"zipTooLarge": "Das ZIP enthält zu viele Daten und wird nicht geöffnet.",
|
"zipTooLarge": "Das ZIP enthält zu viele Daten und wird nicht geöffnet.",
|
||||||
"tooManyEntries": "Das ZIP enthält zu viele Dateien und wird nicht geöffnet.",
|
"tooManyEntries": "Das ZIP enthält zu viele Dateien und wird nicht geöffnet.",
|
||||||
"unsupportedKey": "Dieser Schlüsseltyp wird nicht unterstützt."
|
"unsupportedKey": "Dieser Schlüsseltyp wird nicht unterstützt."
|
||||||
}
|
},
|
||||||
|
"pasteToggle": "PEM-Text einfügen",
|
||||||
|
"pasteHint": "Fügen Sie Text ein, der mit „-----BEGIN“ beginnt. Mehrere Blöcke hintereinander sind möglich.",
|
||||||
|
"pasteLabel": "Eingefügter PEM-Text",
|
||||||
|
"pasteAdd": "Hinzufügen",
|
||||||
|
"pastedLabel": "Eingefügter Text {n}",
|
||||||
|
"originPaste": "Eingefügter Text"
|
||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"generic": "Die Dateien konnten nicht geprüft werden. Bitte versuchen Sie es erneut.",
|
"generic": "Die Dateien konnten nicht geprüft werden. Bitte versuchen Sie es erneut.",
|
||||||
@@ -1318,6 +1327,40 @@
|
|||||||
"goToFiles": "Zum Reiter „Dateien“",
|
"goToFiles": "Zum Reiter „Dateien“",
|
||||||
"basis": "{count, plural, one {Grundlage: 1 Datei aus dem Reiter „Dateien“.} other {Grundlage: # Dateien aus dem Reiter „Dateien“.}}"
|
"basis": "{count, plural, one {Grundlage: 1 Datei aus dem Reiter „Dateien“.} other {Grundlage: # Dateien aus dem Reiter „Dateien“.}}"
|
||||||
},
|
},
|
||||||
|
"analyze": {
|
||||||
|
"intro": "Alles, was in Ihren Dateien erkannt wurde: zuerst die Ketten, dann jedes Zertifikat im Einzelnen. Alle Zeiten stehen in UTC, wie im Zertifikat.",
|
||||||
|
"chainsTitle": "Ketten",
|
||||||
|
"certificatesTitle": "Zertifikate",
|
||||||
|
"ignoredTitle": "Nicht verwendet",
|
||||||
|
"nothingFound": "In den Dateien wurde noch kein Zertifikat erkannt.",
|
||||||
|
"issuer": "Ausgestellt von",
|
||||||
|
"validity": "Gültig",
|
||||||
|
"validRange": "{from} bis {to}",
|
||||||
|
"names": "Gilt für",
|
||||||
|
"key": "Schlüssel",
|
||||||
|
"keyRsa": "RSA, {bits} Bit",
|
||||||
|
"keyEc": "EC, Kurve {curve}",
|
||||||
|
"serial": "Seriennummer",
|
||||||
|
"sha256": "SHA-256",
|
||||||
|
"sha1": "SHA-1",
|
||||||
|
"source": "Gefunden in",
|
||||||
|
"valid": "Gültig",
|
||||||
|
"expired": "Abgelaufen",
|
||||||
|
"expiresSoon": "Läuft in {days} Tagen ab",
|
||||||
|
"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."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"split": {
|
||||||
|
"intro": "Jedes erkannte Teil können Sie einzeln in seinem natürlichen Format herunterladen, oder alle zusammen als ZIP.",
|
||||||
|
"nothingFound": "Es wurde noch nichts erkannt, das sich herunterladen ließe.",
|
||||||
|
"download": "Herunterladen",
|
||||||
|
"downloadFor": "„{name}“ herunterladen",
|
||||||
|
"downloadAll": "Alle als ZIP herunterladen",
|
||||||
|
"zipFilename": "Zertifikate.zip"
|
||||||
|
},
|
||||||
"merge": {
|
"merge": {
|
||||||
"intro": "Tessera ordnet Serverzertifikat, Zwischenzertifikate und Stammzertifikat selbst und prüft dabei, dass jedes Zertifikat wirklich vom nächsten ausgestellt wurde. Die Reihenfolge Ihrer Dateien spielt keine Rolle.",
|
"intro": "Tessera ordnet Serverzertifikat, Zwischenzertifikate und Stammzertifikat selbst und prüft dabei, dass jedes Zertifikat wirklich vom nächsten ausgestellt wurde. Die Reihenfolge Ihrer Dateien spielt keine Rolle.",
|
||||||
"noCertificates": "In den Dateien wurde noch kein Zertifikat erkannt. Fügen Sie im Reiter „Dateien“ ein Zertifikat hinzu.",
|
"noCertificates": "In den Dateien wurde noch kein Zertifikat erkannt. Fügen Sie im Reiter „Dateien“ ein Zertifikat hinzu.",
|
||||||
|
|||||||
@@ -1254,6 +1254,8 @@
|
|||||||
"tabs": {
|
"tabs": {
|
||||||
"files": "Files",
|
"files": "Files",
|
||||||
"filesWithCount": "Files ({count})",
|
"filesWithCount": "Files ({count})",
|
||||||
|
"analyze": "Analyze",
|
||||||
|
"split": "Split",
|
||||||
"merge": "Merge"
|
"merge": "Merge"
|
||||||
},
|
},
|
||||||
"roles": {
|
"roles": {
|
||||||
@@ -1282,7 +1284,8 @@
|
|||||||
"duplicate": "\"{name}\" is already in the list.",
|
"duplicate": "\"{name}\" is already in the list.",
|
||||||
"tooMany": "\"{name}\": the list is full with {max} files.",
|
"tooMany": "\"{name}\": the list is full with {max} files.",
|
||||||
"tooLarge": "\"{name}\" is larger than {size} MB.",
|
"tooLarge": "\"{name}\" is larger than {size} MB.",
|
||||||
"totalTooLarge": "\"{name}\": together this would be more than {size} MB."
|
"totalTooLarge": "\"{name}\": together this would be more than {size} MB.",
|
||||||
|
"pasteTooLarge": "\"{name}\" is longer than {chars} characters."
|
||||||
},
|
},
|
||||||
"ignored": {
|
"ignored": {
|
||||||
"unknown": "No certificate was recognised in it.",
|
"unknown": "No certificate was recognised in it.",
|
||||||
@@ -1294,7 +1297,13 @@
|
|||||||
"zipTooLarge": "The ZIP contains too much data and is not opened.",
|
"zipTooLarge": "The ZIP contains too much data and is not opened.",
|
||||||
"tooManyEntries": "The ZIP contains too many files and is not opened.",
|
"tooManyEntries": "The ZIP contains too many files and is not opened.",
|
||||||
"unsupportedKey": "This key type is not supported."
|
"unsupportedKey": "This key type is not supported."
|
||||||
}
|
},
|
||||||
|
"pasteToggle": "Paste PEM text",
|
||||||
|
"pasteHint": "Paste text that starts with \"-----BEGIN\". Several blocks in a row are fine.",
|
||||||
|
"pasteLabel": "Pasted PEM text",
|
||||||
|
"pasteAdd": "Add",
|
||||||
|
"pastedLabel": "Pasted text {n}",
|
||||||
|
"originPaste": "Pasted text"
|
||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"generic": "The files could not be checked. Please try again.",
|
"generic": "The files could not be checked. Please try again.",
|
||||||
@@ -1318,6 +1327,40 @@
|
|||||||
"goToFiles": "Go to the “Files” tab",
|
"goToFiles": "Go to the “Files” tab",
|
||||||
"basis": "{count, plural, one {Based on 1 file from the “Files” tab.} other {Based on # files from the “Files” tab.}}"
|
"basis": "{count, plural, one {Based on 1 file from the “Files” tab.} other {Based on # files from the “Files” tab.}}"
|
||||||
},
|
},
|
||||||
|
"analyze": {
|
||||||
|
"intro": "Everything recognised in your files: the chains first, then every certificate in detail. All times are shown in UTC, as in the certificate.",
|
||||||
|
"chainsTitle": "Chains",
|
||||||
|
"certificatesTitle": "Certificates",
|
||||||
|
"ignoredTitle": "Not used",
|
||||||
|
"nothingFound": "No certificate has been recognised in the files yet.",
|
||||||
|
"issuer": "Issued by",
|
||||||
|
"validity": "Valid",
|
||||||
|
"validRange": "{from} to {to}",
|
||||||
|
"names": "Valid for",
|
||||||
|
"key": "Key",
|
||||||
|
"keyRsa": "RSA, {bits} bit",
|
||||||
|
"keyEc": "EC, curve {curve}",
|
||||||
|
"serial": "Serial number",
|
||||||
|
"sha256": "SHA-256",
|
||||||
|
"sha1": "SHA-1",
|
||||||
|
"source": "Found in",
|
||||||
|
"valid": "Valid",
|
||||||
|
"expired": "Expired",
|
||||||
|
"expiresSoon": "Expires in {days} days",
|
||||||
|
"explain": {
|
||||||
|
"end-entity": "The actual certificate for your domain. This one belongs on the web server.",
|
||||||
|
"intermediate": "Vouches for your server certificate towards the browser. It is installed together with the server certificate (chain).",
|
||||||
|
"root": "The top certificate authority. Browsers and operating systems usually have it already."
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"split": {
|
||||||
|
"intro": "You can download every recognised part on its own in its natural format, or all of them together as a ZIP.",
|
||||||
|
"nothingFound": "Nothing has been recognised yet that could be downloaded.",
|
||||||
|
"download": "Download",
|
||||||
|
"downloadFor": "Download \"{name}\"",
|
||||||
|
"downloadAll": "Download all as ZIP",
|
||||||
|
"zipFilename": "Certificates.zip"
|
||||||
|
},
|
||||||
"merge": {
|
"merge": {
|
||||||
"intro": "Tessera orders the server certificate, intermediate certificates and root certificate itself and checks that each certificate was really issued by the next one. The order of your files does not matter.",
|
"intro": "Tessera orders the server certificate, intermediate certificates and root certificate itself and checks that each certificate was really issued by the next one. The order of your files does not matter.",
|
||||||
"noCertificates": "No certificate has been recognised in the files yet. Add a certificate in the “Files” tab.",
|
"noCertificates": "No certificate has been recognised in the files yet. Add a certificate in the “Files” tab.",
|
||||||
|
|||||||
Reference in New Issue
Block a user