Files
tessera-ctl/.planning/quick/261009-ikt-cert-manager-umbau-mehrere-dateien-zip-f/261009-ikt-RESEARCH.md
T
schalli 76a30458fc docs(quick-261009-ikt): Cert-Manager-Umbau
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-09 17:12:10 +02:00

341 lines
40 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Quick 261009-ikt: Cert Manager Umbau - Research
**Researched:** 2026-10-09
**Domain:** X.509 / PKCS container handling in NestJS 11 (Node 24) + Next.js 15 working-set UI
**Confidence:** HIGH for code findings and library capabilities (all probed on this machine, Node v24.16.0, OpenSSL 3.5.7); MEDIUM for target-system templates (docs/forum based)
<user_constraints>
## User Constraints (from CONTEXT.md)
### Locked Decisions
- ONE upload tab (first tab): drop/select multiple files and/or ZIPs (and may paste PEM text). Everything uploaded forms a shared working set (list of files with remove buttons and what was recognised in each). The other tabs (Analysieren, Aufteilen, Zusammenführen/Fullchain, Konvertieren, Vorlagen) work on that shared set instead of having their own upload fields.
- Root certificate in Fullchain: selectable, default WITHOUT root ("Root-Zertifikat mitnehmen" checkbox).
- Missing intermediate: Tessera reports the gap clearly ("Zwischenzertifikat fehlt") and offers a button "Fehlendes Zertifikat holen" which fetches it from the certificate's AIA caIssuers URL - ONLY on button press, never automatically. Fetch must be SSRF-safe (http/https only, public addresses only - reuse the project's existing `isPublicHttpUrl`/SSRF guard pattern, size and time limits, no redirects to private targets) and the result marked as "nachgeladen".
- Templates for target systems: one-click templates, e.g. Nginx, Apache, Windows/IIS (PFX), Nginx Proxy Manager (and other common ones at Claude's discretion, e.g. HAProxy combined PEM, Java keystore only if feasible without native tools - otherwise skip). Free selection of content + format remains available.
- All common formats as input AND output: PEM/CRT/CER, DER, PKCS#7 (.p7b/.p7c), PKCS#12 (.pfx/.p12 with password), private keys (PKCS#1, PKCS#8, encrypted/unencrypted, RSA + EC), CSR; outputs: Fullchain, chain only (intermediates), single certificate, certificate + key (PEM bundle), PFX with chosen password. Module version + module changelog + guides are part of the task.
### Claude's Discretion
- Where the working set lives (prefer browser memory only, never persisted server-side; private keys and passwords never stored or logged); ZIP limits (size, file count, nesting, zip-bomb ratio); key-to-certificate matching display; how CSRs are shown; file naming of downloads; whether the server or browser does the crypto (follow the existing module architecture); exact tab names.
### Deferred Ideas (OUT OF SCOPE)
- None listed. (Memory rules apply: no licensing/multi-tenant talk, no password-leak warnings, AD read-only, user-facing texts formal "Sie", answers to user in German with "du".)
</user_constraints>
## Project Constraints (from CLAUDE.md)
- Stack is fixed: NestJS 11 / Express 5 / Prisma 6, Next.js 15.5 / React 19, Vitest (api 3.2.6, web 4.1.9), Biome 2.5 (`biome lint .`). No Redis, no TanStack Query, no shadcn - do not introduce them.
- Work only through a GSD command (this is /gsd-quick). Everything built by Claude -> keep code maintainable and plain.
- Memory rules: module change = bump module version in changelog + entry (feedback-modulversion-changelog); user-facing app text uses "Sie"; real umlauts in de.json; no customer-specific defaults; no password-leak warnings.
## Summary
The module is **node-forge 1.4.0 server-side** (`apps/api/src/cert-manager/`), stateless (upload -> process -> base64 JSON back). `cert-bundle.ts` (analyze/export, added 2026-10-02) already does multi-file + ZIP (adm-zip) and is what the "Übersicht" tab uses; the four older tabs (Analysieren/Aufteilen/Zusammenführen/Konvertieren) use the older single-file endpoints in `cert-manager.service.ts`. **The biggest finding is not the UI bug but that node-forge cannot read ANY EC certificate** (`Cannot read public key. OID is not RSA.` - probed). So today every ECDSA certificate (Let's Encrypt default, most modern CAs) fails to parse in `parse/split/merge/convert`, is silently dropped from `analyze`, and EC leaf certs inside a PFX are silently dropped (bag.cert is `null`). "Analyse funktioniert richtig" is true for RSA only.
**Bug root cause (2nd file overwrites 1st):** `page.tsx:124` renders the shared single-file `DropZone` card for every tab except `overview` - including `merge`. `DropZone.tsx:23/43` reads only `files?.[0]` and `page.tsx:54-55` does `setFile(selected)` (replace). `MergeTab` ignores that shared `file` completely and has its own native `<input multiple>` (which does append, `MergeTab.tsx` `setFiles(prev => [...prev, ...selected])`). The user used the prominent drop zone -> each drop replaced the previous one and nothing reached the merge. The redesign (one shared multi-file set) removes the whole class of bug.
**Primary recommendation:** Replace forge for X.509/keys with Node's built-in `node:crypto` (`X509Certificate`, `createPrivateKey`, `KeyObject.export`, `checkIssued`, `verify`, `checkPrivateKey`) - handles RSA+EC, PEM+DER, encrypted keys, chain-verification natively. Keep node-forge only for what Node cannot do: PKCS#12 read/write, PKCS#7 read/write, and (via forge's generic `asn1`) CSR/AKI/SKI walking. Add NO new npm package. One stateless `analyze` over the whole working set, one `build` for outputs/templates, one `fetch-issuer` for AIA. Working set = `File[]` in React state in the browser; server re-receives it per analyze call (this is how `OverviewTab` already works).
## Architectural Responsibility Map
| Capability | Primary Tier | Secondary Tier | Rationale |
|------------|-------------|----------------|-----------|
| Working set (files list, passwords, selection) | Browser / Client (React state) | - | Never persisted; private keys/passwords must not be stored (CONTEXT) |
| Parse/identify/chain/key-match | API / Backend (stateless) | - | Existing architecture; Node crypto + forge exist only server-side (web has no crypto lib except fflate) |
| ZIP expansion (input) | API / Backend | - | Existing; limits enforced server-side (adm-zip) |
| Output building (fullchain, PFX, P7B, key re-encoding) | API / Backend | - | Needs forge PKCS#12 / node KeyObject export |
| Template bundles as ZIP + config snippet text | Browser (fflate `zipSync`, already used in `SplitTab.tsx`) | API builds each file | Keeps API returning single files; ZIP is cheap client-side |
| AIA caIssuers fetch | API / Backend | - | SSRF-guarded; URL is read from the cert **on the server**, never taken from the client |
| Downloads | Browser (`downloadBase64`) | - | Existing helper |
## Standard Stack
### Core (all already installed - no new packages)
| Library | Version | Purpose | Why |
|---------|---------|---------|-----|
| `node:crypto` (Node 24 `node:24-alpine`) | 24.16.0 here | X509Certificate parse/DER/PEM, `checkIssued`, `verify`, `checkPrivateKey`, `createPrivateKey` (PKCS#1/PKCS#8/SEC1, enc/unenc, PEM/DER, RSA+EC), `KeyObject.export` (pkcs1/pkcs8/sec1, optional cipher+passphrase), `infoAccess` (AIA) | [VERIFIED: probe scripts this session, see "Probe results"] |
| `node-forge` | 1.4.0 (`apps/api/package.json:41` `"node-forge": "^1.4.0"`) | PKCS#12 read (incl. OpenSSL-3 AES/SHA-256 PFX) and write, PKCS#7 read/write, generic `asn1` for CSR/extension walking | Only lockfile option for PKCS#12/#7 [VERIFIED: pnpm-lock `node-forge@1.4.0`] |
| `adm-zip` | 0.6.0 (`apps/api/package.json:28`) | ZIP read | Already used; its inflater passes `maxOutputLength: expectedLength` (header size) so a lying header cannot out-inflate its declared size, and it CRC-checks [VERIFIED: `node_modules/.pnpm/adm-zip@0.6.0/.../methods/inflater.js:4-5`, `zipEntry.js:31-48`] |
| `undici` | 7.28.0 (`apps/api/package.json:52`) | AIA HTTP fetch (same as favorites / nextcloud-status) | Existing SSRF fetch pattern |
| `fflate` | ^0.8.3 (web) | ZIP creation for template download in browser | Already used (`SplitTab.tsx:5`) |
| multer 2.1.1 (via `@nestjs/platform-express`) | 2.1.1 | multipart limits (`fileSize`, `files`) | Existing |
### Alternatives Considered
| Instead of | Could Use | Tradeoff |
|------------|-----------|----------|
| node:crypto + forge asn1 helpers | `@peculiar/x509` (npm legitimacy: OK, 13.8M wk, repo PeculiarVentures/x509) | Gives AKI/SKI/CSR classes and a chain builder, but is NOT in the lockfile (needs install + reflect-metadata/tsyringe/pvtsutils chain) and still has no PKCS#12. Not worth it: Node covers cert+key+chain; only CSR display + AKI/SKI need ~60 lines of forge-asn1 walking. Use only if CSR parsing proves painful (then gate behind `checkpoint:human-verify`). |
| Monkeypatch forge to write EC PFX | write own PFX assembler | See Pattern 3; patch approach probed working, assembler is 100+ lines |
**Installation:** none.
## Package Legitimacy Audit
| Package | Registry | Age | Downloads | Source Repo | Verdict | Disposition |
|---------|----------|-----|-----------|-------------|---------|-------------|
| node-forge | npm | yrs | ~39.7M/wk | github.com/digitalbazaar/forge | OK | Already installed (1.4.0) |
| adm-zip | npm | yrs | ~23M/wk | github.com/cthackers/adm-zip | SUS (`too-new`: latest version published 2026-09-11) | Already installed (0.6.0) and in production use; no change, no install - no checkpoint needed |
| @peculiar/x509 | npm | yrs | ~13.8M/wk | github.com/PeculiarVentures/x509 | OK | NOT recommended / not installed (alternative only) |
**Packages removed (SLOP):** none. **Flagged SUS:** adm-zip (already present; not a new install).
## Current Architecture (facts)
| Item | Finding |
|------|---------|
| Crypto lib | node-forge 1.4.0, server only. `cert-bundle.ts` (655 lines) + `cert-manager.service.ts` (793 lines). Web has no crypto lib. |
| Endpoints (`cert-manager.controller.ts`) | `parse` (1 file/pemText), `split` (1 file), `merge` (`FilesInterceptor('files',20)`, >=2 files, out pem/pfx), `convert` (1 file/pemText -> pem/der/p7b/pfx, first cert only), `analyze` (20 files x 5 MB, optional ZIP, one password for all), `export` (JSON: kind, pem, format, chain[], keyPem, password) |
| Web | `page.tsx` tab shell (overview, inspect, split, merge, convert); shared single-file `DropZone` + paste textarea + `PasswordField` for every tab except overview; `OverviewTab.tsx` is the only multi-file UI (re-uploads the whole `File[]` to `analyze` on every add/remove). Calls go to `API_URL` = `/api-proxy` in prod (`apps/web/Dockerfile:28`, rewrite in `next.config.ts:40`). |
| Tests | api: `cert-bundle.spec.ts` (268 lines), `cert-manager.service.spec.ts` (778); web: `cert-manager.test.tsx`, `MergeTab.test.tsx`, `OverviewTab.test.tsx`, `zip-filename.test.ts`. **No EC test anywhere** (grep). |
### Format coverage vs required matrix (today)
| Input format | Today | Gap (probed unless noted) |
|---|---|---|
| Cert PEM/CRT/CER (RSA) | yes | - |
| Cert PEM/DER (EC) | **NO** | forge throws "OID is not RSA"; `parse/split/merge/convert` 400, `analyze` ignores file |
| Cert DER (`.der`/`.cer`) RSA | yes (content sniff) | - |
| PKCS#7 PEM/DER | yes (RSA only) | one EC cert inside -> whole file fails |
| PKCS#12 | RSA key + RSA certs; ext-sniff only (`.pfx/.p12`) | EC key: `bag.key` false -> **silently skipped**; EC cert: `bag.cert === null` -> **silently skipped** (`collectPfx` `if (bag.cert)`); OpenSSL-3 AES/SHA-256 PFX **is readable** by forge (probed) |
| Key PKCS#8 RSA, PKCS#1 RSA | yes | - |
| Key PKCS#8 EC / SEC1 EC | kept as raw PEM, `modulus:''` | never matched to a certificate; no key details |
| Key encrypted PKCS#8 | RSA only (`decryptRsaPrivateKey`) | EC encrypted: reported "locked" even with right password |
| Key traditional-encrypted (`Proc-Type: 4,ENCRYPTED`) | **dropped** to "ignored" | forge throws; catch only handles `PRIVATE KEY` |
| Key DER (pkcs1/pkcs8/sec1) | **not detected** | `collectDer` tries cert/p7/csr only |
| CSR PEM RSA | yes | - |
| CSR EC (PEM/DER) | PEM: item without details; DER: ignored | forge `certificationRequestFromAsn1` needs RSA |
| ZIP | yes (adm-zip, 100 entries, 5 MB/entry header size) | no total budget, no ratio check, no nested/encrypted handling, `.zip` ext only |
| Output format | Today | Gap |
|---|---|---|
| Cert PEM (.crt), DER (.cer) | yes (RSA) | EC fails |
| Fullchain | yes, always **includes root** | no root toggle; chain order = name-hash walk |
| Chain only (intermediates) | **no** | add |
| P7B | PEM-wrapped only (forge `createSignedData`, probed readable by `openssl pkcs7`) | add DER `.p7b`/`.p7c`; with/without root |
| PFX | RSA key + 3DES only; cert-only PFX allowed | EC key; AES profile; password check |
| Cert + key PEM bundle | **no** | add (also HAProxy/NPM variants) |
| Key out | RSA: PKCS#8, PKCS#1, DER; EC: PEM as-is only | EC SEC1/PKCS#8/DER; encrypted PKCS#8 (AES-256) + traditional-encrypted |
| CSR out | PEM, DER | fine (no re-encode needed) |
## Architecture Patterns
### System flow
```
Browser: working set File[] (+ pasted PEM as File, + fetched certs as items)
| POST /analyze (multipart, all files + passwords) POST /fetch-issuer (JSON {pem})
v |
API analyze: expandZips(limits) -> detect each blob -> items v server reads AIA from cert, SSRF-guarded GET,
(cert via X509Certificate | key via createPrivateKey | csr via parse DER/P7C/PEM, verify it really issued the cert
asn1 | pfx/p7 via forge) -> dedupe by sha256 -> buildChains -> -> returns items marked source "nachgeladen"
matchKeys -> {items, chains, gaps, locked, ignored}
v
Browser: tabs render the same result (Analysieren=list, Aufteilen=per-item downloads,
Fullchain/Zusammenführen=choose leaf + root toggle + format, Konvertieren=any item -> any format,
Vorlagen=one-click bundle) --POST /build (JSON, only needed PEMs)--> API builds file(s) -> base64 -> downloadBase64 / fflate ZIP
```
### Recommended structure (api)
```
apps/api/src/cert-manager/
cert-model.ts # parse blobs -> RawCert/RawKey/RawCsr (node:crypto + forge containers)
cert-chain.ts # buildChains(), matchKeys() (pure functions, easy to unit-test)
zip-expand.ts # limits + expansion
cert-aia.ts # fetchIssuer() with SSRF guard
cert-templates.ts # template id -> file list builder
cert-bundle.ts # becomes thin analyze/build facade (keep exported names for existing specs)
```
Old single-file endpoints/service (parse/split/merge/convert) become unused once the tabs run on the working set; remove with their specs in the same plan (planner decision) - do not leave two parallel parsers.
### Pattern 1: Detect blob type (replaces `collect()`)
Order: ZIP magic `PK\x03\x04` (not extension) -> text with `-----BEGIN` -> PEM blocks by type -> otherwise DER: try `new X509Certificate(buf)`; try `createPrivateKey({key, format:'der', type})` for `pkcs8`, `pkcs1`, `sec1`; try forge `pkcs12FromAsn1` (needs password path); try forge `pkcs7.messageFromAsn1`; try CSR walk. PEM types: `CERTIFICATE`, `TRUSTED CERTIFICATE`, `PKCS7`, `CMS`, `PRIVATE KEY`, `RSA PRIVATE KEY`, `EC PRIVATE KEY`, `ENCRYPTED PRIVATE KEY`, `CERTIFICATE REQUEST`, `NEW CERTIFICATE REQUEST`. Also treat `Proc-Type: 4,ENCRYPTED` PEM as locked. [VERIFIED by reading `cert-bundle.ts:131` `PEM_BLOCK` and `collectPemText`]
Key lock detection (probed): missing passphrase on PEM -> `err.code === 'ERR_OSSL_CRYPTO_INTERRUPTED_OR_CANCELLED'`; wrong passphrase -> `'ERR_OSSL_BAD_DECRYPT'`; DER without passphrase -> `'ERR_MISSING_PASSPHRASE'`. Report as locked (existing UI `LockedNotice` pattern). `PBE-SHA1-3DES` PKCS#8 and AES-256 PKCS#8 decrypt fine in Node 24; RC2-based legacy PBE was not probed (assume unsupported without OpenSSL legacy provider -> report as "locked/unreadable").
PFX (probed): forge reads OpenSSL-3 default (AES-256 + SHA-256 MAC) and `-legacy` PFX. For EC items inside: cert DER = `bag.asn1 ? forge.asn1.toDer(bag.asn1) : certificateToAsn1(bag.cert)` (EC cert bag: `bag.cert===null` but `bag.asn1` holds the cert; RSA cert bag: `bag.asn1` undefined); key = `createPrivateKey({key: toDer(bag.asn1), format:'der', type:'pkcs8'})` for shrouded EC key bag (probed OK). Sniff PFX by trying `pkcs12FromAsn1` on any DER that is a top-level SEQUENCE with INTEGER 3, not only by extension.
### Pattern 2: Chain building + key matching (pure, no network)
- Candidate issuers of cert C: every other cert I in the set with `C.checkIssued(I)` (OpenSSL X509_check_issued: subject==issuer DN **and** AKI/SKI/serial match when present, keyUsage) **AND** `C.verify(I.publicKey)` (signature). Both are needed: without AKI on the cert, a same-name different-key CA passes `checkIssued` but fails `verify` (probed with two "Test Inter" CAs: `checkIssued false` when AKI present; keep `verify` as the authority).
- Self-signed/root: `C.checkIssued(C) && C.verify(C.publicKey)` (probed true for root). Role: leaf = not CA; root = CA + self-signed; else intermediate. A self-signed non-CA stays "end-entity" (existing `certRole` behaviour).
- Path for a leaf: DFS over verified issuer candidates, depth <= 10, visited-set (cross-signing/cycles), prefer candidates that lead to a self-signed root present in the set, then currently valid, then latest `notAfter`. If several full paths exist (cross-signed roots) expose the primary and list alternatives; default output uses the primary.
- Gap: path ends at a non-self-signed cert whose issuer is absent -> `gap: { certId, missingIssuerCn, aiaUrls }` -> UI "Zwischenzertifikat fehlt" + "Fehlendes Zertifikat holen" (only if AIA exists).
- Existing `cert-bundle.ts` chain walk matches by `subject.hash === issuer.hash` only (name, no AKI, no signature) - wrong with re-issued/cross-signed same-name CAs.
- Key match: `cert.checkPrivateKey(keyObj)` (probed true, RSA+EC). For key/CSR -> cert: compare SPKI DER (`createPublicKey(key).export({type:'spki',format:'der'})` equals `cert.publicKey.export(...)`; probed true). Replaces modulus-string match that cannot work for EC.
- Display data from Node: `x.toLegacyObject().subject` ({CN,O,...}), `.infoAccess`, `x.subjectAltName`, `x.validFromDate/validToDate`, `x.publicKey.asymmetricKeyType/asymmetricKeyDetails` (`modulusLength` | `namedCurve`), `x.fingerprint256`, `x.ca`. Node 24 does **not** expose AKI/SKI (probe printed `ski=undefined`) - only needed for display; get via forge `asn1.fromDer(x.raw)` extension walk if wanted, not for logic.
- CSR (no lib supports EC CSR): `forge.asn1.fromDer` the DER, walk `CertificationRequestInfo` -> subject (`forge.pki.RDNAttributesAsArray`), SPKI -> `createPublicKey({key: spkiDer, format:'der', type:'spki'})`, optional SAN from the `extensionRequest` attribute, optional self-signature check with `crypto.verify`. forge's own CSR parser works for RSA only (probed).
### Pattern 3: PFX writing with EC keys/certs (forge cannot natively)
`forge.pkcs12.toPkcs12Asn1(key, certs, pw, opts)` internally calls `pki.privateKeyToAsn1`, `pki.wrapRsaPrivateKey`, `pki.certificateToAsn1` (RSA-only objects). Probed workaround (scoped, synchronous, restored in `finally`) that produced PFX files `openssl pkcs12 -info` accepts with RSA and EC:
```ts
// Source: probe in this session (forge 1.4.0, openssl 3.5.7 read both outputs)
import * as forge from 'node-forge';
const pki = forge.pki;
export function buildPfx(keyPkcs8Der: Buffer | null, certDers: Buffer[], password: string,
algorithm: '3des' | 'aes256', friendlyName?: string): Buffer {
const keyAsn1 = keyPkcs8Der && forge.asn1.fromDer(keyPkcs8Der.toString('binary'));
const certs = certDers.map((d) => ({ asn1: forge.asn1.fromDer(d.toString('binary')) }));
const o = { k: pki.privateKeyToAsn1, w: pki.wrapRsaPrivateKey, c: pki.certificateToAsn1 };
const p = pki as any;
p.privateKeyToAsn1 = (x: any) => x.asn1; p.wrapRsaPrivateKey = (a: any) => a; p.certificateToAsn1 = (c: any) => c.asn1;
try {
const p12 = forge.pkcs12.toPkcs12Asn1(keyAsn1 ? ({ asn1: keyAsn1 } as any) : (null as any),
certs as any, password, { algorithm, friendlyName });
return Buffer.from(forge.asn1.toDer(p12).getBytes(), 'binary');
} finally { p.privateKeyToAsn1 = o.k; p.wrapRsaPrivateKey = o.w; p.certificateToAsn1 = o.c; }
}
```
Key DER comes from `createPrivateKey(...).export({type:'pkcs8', format:'der'})` (works for any key type). Cover with a spec that round-trips the output through forge `pkcs12FromAsn1` and checks cert count + key presence for RSA **and** EC. If the planner dislikes patching a library, the alternative is a self-written PFX assembler; do not hand-roll PBE/MAC.
**PFX encryption profile (user choice, default compatible):**
- `3des` = `pbeWithSHA1And3-KeyTripleDES-CBC` + HMAC-SHA1 MAC (current behaviour; probed: `openssl pkcs12 -info` reads it). Readable by Windows Server 2012R2/2016/IIS, Java, macOS, old appliances.
- `aes256` = PBES2/PBKDF2(HMAC-SHA1)/AES-256-CBC key bag, MAC still SHA-1 (forge always uses SHA-1 MAC; `pkcs12.js:827,1007`). **Not** the same as OpenSSL-3's default (AES-256 + SHA-256 MAC). Which Windows versions accept forge's AES variant is NOT tested here [ASSUMED]. Microsoft Q&A states Windows Server 2012R2/2016 will never support AES256-SHA256 PFX ("The password you entered is incorrect") and the workaround is TripleDES-SHA1 [CITED: learn.microsoft.com/en-us/answers/questions/1054881]. Therefore: default **3DES (kompatibel)**, label the AES option "nur für neuere Systeme (Windows Server 2019 und neuer, nicht Windows Server 2016)" and mark that wording `[ASSUMED]` -> planner: accept as-is or drop the AES option (smallest, safest: ship 3DES only + AES as secondary).
### Pattern 4: AIA caIssuers fetch (button only)
- Endpoint takes `{ pem }` (the cert lacking its issuer), server reads URLs from `new X509Certificate(pem).infoAccess['CA Issuers - URI']` (probed: `{"CA Issuers - URI":["http://example.test/inter.cer"]}`), keeps only `http:`/`https:`, ignores `ldap:`. **Never accept a URL from the client.**
- Reuse `isPublicHttpUrl(url: URL)` from `apps/api/src/common/public-url-guard.ts:80` and copy the loop of `fetchLogoImage` in `nextcloud-status/nextcloud-logo-fetch.ts` (guard before first request and before every hop, `redirect:'manual'`, max 3 redirects, `http/https` only on each hop, single `AbortController` timeout 8 s + `Promise.race` on abort, `content-length` pre-check then streamed byte cap, `discard(response)` on non-final). Differences: byte cap 256 KiB (certs are ~1-3 KB, P7C a few KB), no content-type trust, no User-Agent spoofing, no cookies, `maxResponseSize`/cap in the reader loop.
- Response parse: try X509 DER; else forge PKCS#7 DER (`.p7c`, RFC 5280 "certs-only"); else PEM text. Accept ONLY certs where `target.checkIssued(c) && target.verify(c.publicKey)`; otherwise reject (prevents injecting unrelated certs). One hop per click; after adding, re-run chain building client-side/server-side so the new gap (if any) shows its own button. Return items with `sources: ['nachgeladen: <host>']`.
- Allow default ports only (80/443) - cheap extra SSRF/port-scan hardening [ASSUMED nice-to-have].
- Hardening candidate for the shared guard (read, not changed): `isPrivateIpv6` only recognises IPv4-mapped in dotted form (`/^::ffff:(\d+\.\d+\.\d+\.\d+)$/`, `public-url-guard.ts:60`); a DNS name resolving to AAAA `::ffff:7f00:1` (hex form), NAT64 `64:ff9b::/96` or `2002::/16` would be classified public. Literal bracketed IPv6 URLs fail closed (hostname keeps brackets -> `isIP` 0 -> lookup fails). Also DNS rebinding window is documented as accepted (`nextcloud-logo-fetch.ts` header). Recommend (optional, low-cost): add those ranges to the guard + an undici `connect.lookup` that re-checks the connected IP. Note any change to the shared guard affects favorites/nextcloud-status specs.
### Pattern 5: Templates (`cert-templates.ts`, id -> list of `{filename, content}` + `readme` snippet)
All content comes from the same chain/key; "without root" is default, root only when the checkbox is on (never for IIS/NPM where chain needs are explicit - see table).
| Template | Files produced | Notes |
|---|---|---|
| Nginx | `fullchain.pem` (leaf+intermediates) + `privkey.pem` (unencrypted PKCS#8 PEM) | `ssl_certificate fullchain.pem; ssl_certificate_key privkey.pem;` [ASSUMED: standard nginx docs, not re-fetched] |
| Apache >= 2.4.8 | `fullchain.pem` + `privkey.pem` | `SSLCertificateFile` = fullchain, `SSLCertificateKeyFile` = key; `SSLCertificateChainFile` deprecated since 2.4.8 [CITED: community.letsencrypt.org/t/apache-directives/5879] |
| Apache < 2.4.8 | `cert.pem` + `chain.pem` + `privkey.pem` | `SSLCertificateFile`=cert, `SSLCertificateChainFile`=chain [CITED: same] |
| IIS / Windows | `<name>.pfx` incl. chain + key, 3DES default | import into Local Computer\Personal; friendly name = base name |
| Nginx Proxy Manager (custom cert) | `certificate.pem` (leaf), `privkey.pem`, `intermediate.pem` (intermediates only) | NPM "Add certificate -> Custom" has fields Certificate Key / Certificate / Intermediate Certificate [ASSUMED: from training + community hints; could not fetch an authoritative NPM doc this session]. Community reports NPM rejecting `BEGIN PRIVATE KEY` for RSA and wanting `BEGIN RSA PRIVATE KEY` [CITED via WebSearch summary, unverified] -> offer the NPM key as PKCS#1 for RSA (`type:'pkcs1'`) and SEC1 for EC; make it a selectable detail, test on the real NPM host. |
| HAProxy | single `<name>.pem` = leaf + intermediates + unencrypted key (one concatenated file) | HAProxy `crt` file may contain cert, intermediates, key; blank-line/newline between blocks [CITED: discourse.haproxy.org threads via WebSearch; order variations reported, cert-first works] |
| Tomcat / Java | `<name>.p12` (PKCS#12 with chain; `friendlyName` = alias, 3DES) | PKCS#12 keystore type works in Java 9+/Tomcat `certificateKeystoreType="PKCS12"` [ASSUMED]. **Skip JKS** (needs keytool/native). |
| Frei | any item x any format | "Konvertieren" tab |
### Anti-patterns
- Do not keep two parsers (forge cert parser + node) in parallel; forge parse must not decide anything about certs again.
- Do not trust the client for chain order or AIA URLs; `build` re-validates order with the same `buildChains`.
- Do not put the ZIP expansion of nested archives or encrypted entries on the happy path.
## Don't Hand-Roll
| Problem | Don't Build | Use Instead | Why |
|---------|-------------|-------------|-----|
| X.509 parse (RSA+EC) | ASN.1 certificate decoder | `crypto.X509Certificate` | handles all curves/algs, DER+PEM |
| Issuer relationship | DN/AKI string matching | `checkIssued()` + `verify(pubkey)` | OpenSSL semantics (AKI/SKI/keyUsage) + real signature check |
| Key <-> cert match | modulus compare | `cert.checkPrivateKey(key)` / SPKI DER compare | works for EC |
| Key decrypt/encrypt/re-encode | PBES/PEM crypt | `createPrivateKey` / `KeyObject.export` | probed: pkcs1/pkcs8/sec1, PEM+DER, AES-256 encrypted pkcs8 and traditional |
| PKCS#12 PBE/MAC | own PBKDF/MAC | forge `pkcs12` | only lockfile option |
| ZIP | manual inflate | adm-zip + limits | CRC + bounded inflate built in |
| SSRF check | new regex | `isPublicHttpUrl` (+ optional hardening above) | one shared guard |
| ZIP creation in browser | own writer | `fflate.zipSync` | already used |
## Limits and sizes (Claude's discretion, concrete recommendation)
Current: multipart `FilesInterceptor('files', 20, {limits:{fileSize:5 MiB}})` (`cert-manager.controller.ts`), memory storage (multer default) -> theoretical 100 MiB RAM per request; ZIP: 100 entries, `entry.header.size <= 5 MiB` each, **no total cap, no ratio, no nested/encrypted handling** [VERIFIED: `cert-bundle.ts:104-105,184-205`]. Nest JSON body limit is Express default **100 kB** (`main.ts` has no body-parser options - read) - the `export`/`build` JSON must stay small: send only needed PEMs (typ. 5-20 kB).
Recommend: `files: 30`, `fileSize: 5 MiB`, client-side total <= 10 MiB (matches Betriebsanleitung "client_max_body_size mindestens 10m", `docs/anleitung-betrieb.md:192`; NPM default 1 MiB would 413 larger uploads - cert uploads are normally <1 MiB so fine, but state it in the guide). ZIP: single level only (a nested `.zip` -> listed as ignored with hint), sum of `header.size` of all kept entries <= 20 MiB checked BEFORE any `getData()`, per-entry uncompressed <= 5 MiB but treat >1 MiB as suspicious/ignored for non-PFX, ratio `header.size / max(header.compressedSize,1) <= 100`, entry count <= 100, skip `__MACOSX/`, dotfiles, `Thumbs.db`, directories; encrypted entry (general-purpose flag bit 0, `entry.header.encripted` in adm-zip) -> reported as locked/ignored; use basename only (never written to disk); detect ZIP by magic bytes, not only `.zip`. Dedupe identical blobs (existing sha256 dedupe stays).
## Common Pitfalls
1. **Forge silently drops EC** (cert, key, CSR): any "no items found" message hides this. Add EC fixtures to specs (generate with `openssl ecparam`; commit PEM fixtures or generate with `crypto.generateKeyPairSync` + `X509`? Node cannot create certs - commit small fixtures).
2. **Name-only chain walk** picks wrong CA for re-issued/cross-signed roots - use verify.
3. **Binary through utf-8**: DER must go via `Buffer`/`'binary'` (existing Pitfall 1); base64 results via Buffer.
4. **Shared password**: `analyze` takes ONE password for all files; with several PFX/keys, try per-file entry (`passwords` map by filename) or retry each locked file with the one entered password; UI keeps `LockedNotice` per file. Never log passwords; existing pattern: catch -> generic message.
5. **Unencrypted keys leave the server in responses** (`analyze` returns key PEM in JSON, `OverviewTab` holds it in state). Keep; do not log request bodies (check `common/request-log.ts` logs path/status only - confirm during planning), and don't put keys in URLs.
6. **JSON 100 kB limit** on `build`; **NPM 1 MiB default** upload limit; mention in guide.
7. **Route order** (`project_nest_route_order`): static routes before `@Get(':id')` - all new routes are POST with static names, fine; keep them declared before any param route.
8. **Biome**: array-index keys need a `biome-ignore` with reason (see `OverviewTab`); a11y rule on nested buttons (see `DropZone` comment).
9. **PFX friendlyName** from user input -> sanitize (`safeBaseName`).
10. **Fullchain order** must be leaf -> issuer -> ... ; root only on checkbox; with no key never produce a "cert+key" output (disable the button, say why).
## Code Examples
```ts
// Source: probes this session (Node 24.16)
import { X509Certificate, createPrivateKey, createPublicKey } from 'node:crypto';
const leaf = new X509Certificate(pemOrDer);
leaf.checkIssued(issuer) && leaf.verify(issuer.publicKey); // issued by
leaf.checkPrivateKey(createPrivateKey({ key, passphrase })); // key matches (RSA+EC)
leaf.infoAccess; // { 'CA Issuers - URI': ['http://...'] }
key.export({ type: 'pkcs8', format: 'pem', cipher: 'aes-256-cbc', passphrase: 'pw' }); // -----BEGIN ENCRYPTED PRIVATE KEY-----
key.export({ type: 'sec1', format: 'pem' }); // -----BEGIN EC PRIVATE KEY-----
key.export({ type: 'pkcs1', format: 'pem' }); // -----BEGIN RSA PRIVATE KEY-----
```
Use `ec.export({type:'pkcs1'})` only for RSA keys (EC -> `sec1`).
## Probe results (this session)
Forge: EC cert/CSR parse FAIL "Cannot read public key. OID is not RSA."; RSA CSR OK; PKCS#7 PEM+DER (openssl-made) OK, forge-made `.p7b` PEM and DER read by `openssl pkcs7 -print_certs` OK; PFX modern+legacy RSA/EC read OK (EC leaf `bag.cert===null`, EC key `bag.key===false` with `bag.asn1` set). Node: createPrivateKey OK for RSA PKCS#1/PKCS#8, traditional AES-256 encrypted RSA+EC, PKCS#8 AES-256 and PBE-SHA1-3DES (EC), DER pkcs1/pkcs8/sec1; X509Certificate from DER, CRLF PEM; `checkIssued`/`verify`/`checkPrivateKey` true for matching, false for wrong issuer. EC PFX written with the Pattern-3 patch: `openssl pkcs12 -info` lists 2 cert bags + shrouded key bag (3DES: `pbeWithSHA1And3-KeyTripleDES-CBC`; AES: `PBES2, PBKDF2, AES-256-CBC, PRF hmacWithSHA1`; MAC sha1).
## Integration points
- **Module version rule** (`docs/anleitung-entwicklung.md:299-340`): an entry is "unveröffentlicht" only while its date is after the date of the latest Tessera release in `CHANGELOG.md`. Latest release `## 1.10.1 – 2026-10-06` (`CHANGELOG.md:34`); top cert-manager entry `1.1.0` dated `2026-10-02` (`cert-manager.changelog.ts`) is **already released** -> add a **new** top entry dated 2026-10-09 (not extending 1.1.0). It has "new" items -> at least minor: **1.2.0**; the redesign could justify **2.0.0** ("grundlegender Umbau") - planner/user call, recommend 1.2.0 to follow the written rule conservatively. Items need `de` + `en`, real umlauts, "Sie", no "fuer/Aenderung", no Mandanten/Lizenz words, 1-2 sentences each, kinds `new|changed|fixed`. Seed version comes only from `latestVersion(CERT_MANAGER_CHANGELOG)`; guard spec `module-registry/module-changelog.spec.ts` fails otherwise. Optional follow-up the guide mentions: Tessera-level `CHANGELOG.md` "Unveröffentlicht" section also gets a user-visible line (project practice, e.g. Domains/Dateien entries there).
- **Guides:** `docs/anleitung-anwender.md:150-161` "Zertifikat-Manager" still says "vier Reitern" and does not mention the Übersicht tab - rewrite for the new tab list, ZIP/multi-file, fullchain/root toggle, "Fehlendes Zertifikat holen", templates, PFX compatibility choice. `docs/anleitung-betrieb.md:192` already documents the NPM `client_max_body_size >= 10m` requirement (link from the cert section; also note the API container makes outbound http requests for AIA - egress must be allowed). `docs/anleitung-administration.md`: no cert-manager text found (`grep -n cert` only anwender).
- **i18n:** `apps/web/src/messages/de.json` / `en.json`, namespace `certManager` (keys: title, description, tabs, dropZone, paste, password, or, actions, certRole, emptyState, error, overview{...}). `umlaut-guard.spec.ts` fails on substitute spellings (ae/oe/ue/ss tokens not allow-listed in `umlaut-dictionary.ts`) -> use real umlauts, extend `UMLAUT_ALLOWLIST` for legit words. No cert-manager parity spec exists (only tenderRadar), keep de/en keys identical by hand. `MergeTab`/`ConvertTab` contain hard-coded German strings (e.g. "Ausgabeformat", "PEM-Kette") - new UI must use `t()`.
- **Web tests to rewrite:** `cert-manager.test.tsx`, `MergeTab.test.tsx`, `OverviewTab.test.tsx` (MergeTab tests assert the old per-tab list). `module-layouts.test.tsx` references cert-manager layout (unchanged).
- **Seed/Marketplace:** `cert-manager.seed.ts` description unchanged; version flows from changelog.
## Environment Availability
| Dependency | Required By | Available | Version | Fallback |
|------------|------------|-----------|---------|----------|
| Node (host) / `node:24-alpine` (prod images) | node:crypto features | yes | 24.16.0 host; images `node:24-alpine` (CLAUDE.md) | - |
| openssl CLI | generating test fixtures only (not at runtime) | yes (host 3.5.7) | - | commit fixtures to repo; runtime must not call openssl |
| keytool | JKS | n/a | - | skip JKS (per CONTEXT) |
| Outbound HTTP from api container | AIA fetch | unknown (env-specific) | - | feature degrades to "nicht erreichbar" message; document in Betriebsanleitung |
## Validation Architecture
| Property | Value |
|----------|-------|
| Framework | Vitest 3.2.6 (api, `apps/api/vitest.config.ts`, `src/**/*.spec.ts`), Vitest 4.1.9 + Testing Library (web) |
| Quick run | `cd apps/api && pnpm vitest run src/cert-manager` ; `cd apps/web && pnpm vitest run "src/app/(portal)/modules/cert-manager"` |
| Full suite | `pnpm test` at root (turbo) / `pnpm --filter api test`, `--filter web test` |
| Lint | `biome lint .` (api/web) |
| Behavior | Test | File |
|----------|------|------|
| EC + RSA cert/key/CSR/PFX/P7B/DER analyzed, EC no longer ignored | unit | new `cert-model.spec.ts` (fixtures: RSA+EC chain, enc keys, PFX modern+legacy) |
| Chain: leaf->inter->root, root toggle, cross-signed/same-name decoy, gap reported | unit | new `cert-chain.spec.ts` |
| Key match RSA+EC; CSR match | unit | same |
| ZIP limits (count, total, ratio, nested, encrypted, lying header) | unit | new `zip-expand.spec.ts` |
| PFX round-trip RSA+EC, 3des+aes256, wrong pw | unit | `cert-bundle.spec.ts` (extend) |
| AIA: private/redirect-to-private/oversize/timeout/non-issuer rejected | unit with injected `fetchImpl`/`isPublic` like `nextcloud-logo-fetch` spec | new `cert-aia.spec.ts` |
| Templates produce expected filenames/contents | unit | new `cert-templates.spec.ts` |
| Multi-drop appends (2 files stay 2), remove, ZIP in list, tabs read shared set | web | rewritten `cert-manager.test.tsx` |
| Module changelog guard | existing | `module-registry/module-changelog.spec.ts` |
Wave 0: commit fixture files (generated with openssl commands used in this research: RSA root/inter/leaf, EC leaf, enc keys, PFX, P7B, CSR).
## Security Domain
ASVS: V5 input validation (ZIP/ASN.1 untrusted - size/ratio limits, try/catch -> generic 400); V6 cryptography (use node:crypto/forge, no custom crypto; PFX password required, not logged; encrypted-key export uses AES-256); V12 files/resources (ZIP bomb, nested, path ignored); V13/SSRF (AIA: server-derived URL, `isPublicHttpUrl` per hop, no redirects to private, size/time caps, only 80/443, issuer-verification of the response); V4 access (existing global JwtAuthGuard + `@UseModule('cert-manager')` on all routes - new routes inherit by being in the same controller); V8 data protection (working set browser-only; private keys never persisted server-side; keep response bodies out of logs).
| Threat | STRIDE | Mitigation |
|--------|--------|------------|
| Zip bomb / many entries | DoS | caps above, check sizes before `getData()` |
| SSRF via AIA URL in malicious cert | Tampering/InfoDisc | server-side URL derivation + guard + hardening |
| Malicious ASN.1 crashing parser | DoS | try/catch per blob, size caps |
| Key/password leakage via logs | InfoDisc | never log bodies; generic errors (existing T-09-02 pattern) |
| Fetched cert injected as "issuer" | Spoofing | accept only if `checkIssued` + `verify` pass |
## Assumptions Log
| # | Claim | Section | Risk if Wrong |
|---|-------|---------|---------------|
| A1 | forge-written AES-256 PFX (PBES2 + SHA-1 MAC) is accepted by newer Windows; which versions is untested | PFX profile | AES option unusable on Windows; keep 3DES default |
| A2 | NPM custom cert has fields Certificate Key / Certificate / Intermediate Certificate and may want `BEGIN RSA PRIVATE KEY` | Templates | NPM template wrong; verify on the real NPM host (user does deploys; do not deploy to test server) |
| A3 | HAProxy single PEM cert->intermediates->key order works (forum-sourced) | Templates | wrong order only if HAProxy rejects; low risk |
| A4 | Java 9+/Tomcat reads 3DES PKCS#12 | Templates | Tomcat template needs different profile |
| A5 | Nginx directive names as listed (standard) | Templates | negligible |
| A6 | Legacy RC2-based encrypted PKCS#8 not decryptable by Node w/o legacy provider (not probed) | Pattern 1 | such keys show as locked; acceptable |
| A7 | Version bump 1.2.0 vs 2.0.0 | Integration | purely naming; user/planner choose |
| A8 | Restricting AIA to ports 80/443 will not break real-world AIA URLs | Pattern 4 | rare non-standard ports blocked; acceptable |
## Open Questions
1. **PFX AES option:** ship only 3DES (zero risk) or both profiles? Recommendation: both, default 3DES, AES labelled "nur für neuere Systeme".
2. **Remove old endpoints** (`parse/split/merge/convert`) in this task? Recommendation: yes once tabs migrate, with their specs, to avoid two parsers.
3. **Version 1.2.0 vs 2.0.0** (A7).
## Sources
### Primary (HIGH)
- Code read this session: `apps/api/src/cert-manager/*`, `apps/api/src/common/public-url-guard.ts`, `apps/api/src/nextcloud-status/nextcloud-logo-fetch.ts`, `apps/api/src/favorites/icon-discovery.service.ts`, `apps/api/src/main.ts`, web cert-manager `page.tsx`, `DropZone.tsx`, `MergeTab.tsx`, `ConvertTab.tsx`, `OverviewTab.tsx`, `actions.ts`, `docs/anleitung-entwicklung.md`, `CHANGELOG.md`, `pnpm-lock.yaml`.
- Local probes (Node 24.16.0, OpenSSL 3.5.7, node-forge 1.4.0, adm-zip 0.6.0 source) - scripts in session scratchpad.
### Secondary (MEDIUM)
- [Microsoft Q&A: Windows Server 2016/2012R2 AES256-SHA256 PFX](https://learn.microsoft.com/en-us/answers/questions/1054881/windows-server-2016-2012r2-how-to-add-support-for)
- [Let's Encrypt community: Apache directives / SSLCertificateChainFile deprecated 2.4.8](https://community.letsencrypt.org/t/apache-directives/5879)
### Tertiary (LOW)
- HAProxy discourse threads on PEM composition (https://discourse.haproxy.org/t/strange-cert-chain-with-all-certs-in-one-file/7478); NPM custom-certificate field names from training + community snippets (no authoritative doc fetched).
## Metadata
**Confidence:** stack/capabilities HIGH (probed); architecture HIGH; templates MEDIUM/LOW for NPM and HAProxy.
**Research date:** 2026-10-09 | **Valid until:** ~2026-11-08