76a30458fc
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
341 lines
40 KiB
Markdown
341 lines
40 KiB
Markdown
# 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
|