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

40 KiB
Raw Blame History

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
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:

// 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

// 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)

Tertiary (LOW)

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