Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
40 KiB
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) ANDC.verify(I.publicKey)(signature). Both are needed: without AKI on the cert, a same-name different-key CA passescheckIssuedbut failsverify(probed with two "Test Inter" CAs:checkIssued falsewhen AKI present; keepverifyas 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" (existingcertRolebehaviour). - 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.tschain walk matches bysubject.hash === issuer.hashonly (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'})equalscert.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 printedski=undefined) - only needed for display; get via forgeasn1.fromDer(x.raw)extension walk if wanted, not for logic. - CSR (no lib supports EC CSR):
forge.asn1.fromDerthe DER, walkCertificationRequestInfo-> subject (forge.pki.RDNAttributesAsArray), SPKI ->createPublicKey({key: spkiDer, format:'der', type:'spki'}), optional SAN from theextensionRequestattribute, optional self-signature check withcrypto.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:
// 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 -inforeads 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 fromnew X509Certificate(pem).infoAccess['CA Issuers - URI'](probed:{"CA Issuers - URI":["http://example.test/inter.cer"]}), keeps onlyhttp:/https:, ignoresldap:. Never accept a URL from the client. - Reuse
isPublicHttpUrl(url: URL)fromapps/api/src/common/public-url-guard.ts:80and copy the loop offetchLogoImageinnextcloud-status/nextcloud-logo-fetch.ts(guard before first request and before every hop,redirect:'manual', max 3 redirects,http/httpsonly on each hop, singleAbortControllertimeout 8 s +Promise.raceon abort,content-lengthpre-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 wheretarget.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 withsources: ['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):
isPrivateIpv6only 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), NAT6464:ff9b::/96or2002::/16would be classified public. Literal bracketed IPv6 URLs fail closed (hostname keeps brackets ->isIP0 -> lookup fails). Also DNS rebinding window is documented as accepted (nextcloud-logo-fetch.tsheader). Recommend (optional, low-cost): add those ranges to the guard + an undiciconnect.lookupthat 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;
buildre-validates order with the samebuildChains. - 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
- 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 withcrypto.generateKeyPairSync+X509? Node cannot create certs - commit small fixtures). - Name-only chain walk picks wrong CA for re-issued/cross-signed roots - use verify.
- Binary through utf-8: DER must go via
Buffer/'binary'(existing Pitfall 1); base64 results via Buffer. - Shared password:
analyzetakes ONE password for all files; with several PFX/keys, try per-file entry (passwordsmap by filename) or retry each locked file with the one entered password; UI keepsLockedNoticeper file. Never log passwords; existing pattern: catch -> generic message. - Unencrypted keys leave the server in responses (
analyzereturns key PEM in JSON,OverviewTabholds it in state). Keep; do not log request bodies (checkcommon/request-log.tslogs path/status only - confirm during planning), and don't put keys in URLs. - JSON 100 kB limit on
build; NPM 1 MiB default upload limit; mention in guide. - 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. - Biome: array-index keys need a
biome-ignorewith reason (seeOverviewTab); a11y rule on nested buttons (seeDropZonecomment). - PFX friendlyName from user input -> sanitize (
safeBaseName). - 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
// 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 inCHANGELOG.md. Latest release## 1.10.1 – 2026-10-06(CHANGELOG.md:34); top cert-manager entry1.1.0dated2026-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 needde+en, real umlauts, "Sie", no "fuer/Aenderung", no Mandanten/Lizenz words, 1-2 sentences each, kindsnew|changed|fixed. Seed version comes only fromlatestVersion(CERT_MANAGER_CHANGELOG); guard specmodule-registry/module-changelog.spec.tsfails otherwise. Optional follow-up the guide mentions: Tessera-levelCHANGELOG.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:192already documents the NPMclient_max_body_size >= 10mrequirement (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 certonly anwender). - i18n:
apps/web/src/messages/de.json/en.json, namespacecertManager(keys: title, description, tabs, dropZone, paste, password, or, actions, certRole, emptyState, error, overview{...}).umlaut-guard.spec.tsfails on substitute spellings (ae/oe/ue/ss tokens not allow-listed inumlaut-dictionary.ts) -> use real umlauts, extendUMLAUT_ALLOWLISTfor legit words. No cert-manager parity spec exists (only tenderRadar), keep de/en keys identical by hand.MergeTab/ConvertTabcontain hard-coded German strings (e.g. "Ausgabeformat", "PEM-Kette") - new UI must uset(). - 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.tsxreferences cert-manager layout (unchanged). - Seed/Marketplace:
cert-manager.seed.tsdescription 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
- PFX AES option: ship only 3DES (zero risk) or both profiles? Recommendation: both, default 3DES, AES labelled "nur für neuere Systeme".
- Remove old endpoints (
parse/split/merge/convert) in this task? Recommendation: yes once tabs migrate, with their specs, to avoid two parsers. - 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-managerpage.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
- Let's Encrypt community: Apache directives / SSLCertificateChainFile deprecated 2.4.8
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