From 75ea83fc017c345d39085c5b56a61e4da91dec18 Mon Sep 17 00:00:00 2001 From: Schalli Date: Fri, 9 Oct 2026 14:52:29 +0200 Subject: [PATCH] =?UTF-8?q?feat(cert-manager):=20Reiter=20=E2=80=9EDateien?= =?UTF-8?q?=E2=80=9C=20mit=20mehreren=20Dateien=20und=20ein=20Parser=20f?= =?UTF-8?q?=C3=BCr=20RSA=20und=20EC=20=E2=80=93=20Durchstich?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Ein gemeinsamer Arbeitsbereich im Browser: jede Auswahl hängt an, eine zweite Datei ersetzt nie die erste - Ein Parser (node:crypto) für RSA- und EC-Zertifikate als PEM, DER und TRUSTED CERTIFICATE, Rolle je Datei - Neue Route analyze (mehrere Dateien, 30 x 5 MiB, zusammen 20 MiB); alte Routen, Service und Web-Reiter entfernt - Test-PKI-Fixtures (RSA und EC, Ketten, Schlüssel, CSR, PFX) mit Erzeugungsskript Co-Authored-By: Claude Opus 5.5 (1M context) --- .../src/cert-manager/__fixtures__/README.md | 18 + .../__fixtures__/aia-private-leaf.pem | 25 + .../cert-manager/__fixtures__/ec-chain.p7b | 38 + .../cert-manager/__fixtures__/ec-compat.pfx | Bin 0 -> 2286 bytes .../__fixtures__/ec-fullchain.pem | 43 + .../cert-manager/__fixtures__/ec-inter.pem | 15 + .../__fixtures__/ec-leaf-key-enc-pkcs8.der | Bin 0 -> 180 bytes .../__fixtures__/ec-leaf-key-enc-pkcs8.pem | 6 + .../__fixtures__/ec-leaf-key-enc-trad.pem | 8 + .../__fixtures__/ec-leaf-key-sec1.der | Bin 0 -> 121 bytes .../__fixtures__/ec-leaf-key-sec1.pem | 5 + .../cert-manager/__fixtures__/ec-leaf-key.pem | 5 + .../src/cert-manager/__fixtures__/ec-leaf.cer | Bin 0 -> 594 bytes .../src/cert-manager/__fixtures__/ec-leaf.csr | 8 + .../cert-manager/__fixtures__/ec-leaf.csr.der | Bin 0 -> 286 bytes .../src/cert-manager/__fixtures__/ec-leaf.pem | 15 + .../cert-manager/__fixtures__/ec-modern.pfx | Bin 0 -> 2448 bytes .../src/cert-manager/__fixtures__/ec-root.pem | 13 + .../__fixtures__/encrypted-entry.zip | Bin 0 -> 1219 bytes .../__fixtures__/make-fixtures.sh | 184 ++++ .../cert-manager/__fixtures__/rsa-chain.p7b | 60 ++ .../cert-manager/__fixtures__/rsa-chain.p7c | Bin 0 -> 2782 bytes .../cert-manager/__fixtures__/rsa-compat.pfx | Bin 0 -> 4429 bytes .../__fixtures__/rsa-fullchain.pem | 65 ++ .../__fixtures__/rsa-inter-cross.pem | 22 + .../__fixtures__/rsa-inter-decoy.pem | 22 + .../__fixtures__/rsa-inter-expired.pem | 22 + .../cert-manager/__fixtures__/rsa-inter.pem | 22 + .../__fixtures__/rsa-leaf-key-enc-pkcs8.pem | 30 + .../__fixtures__/rsa-leaf-key-enc-trad.pem | 30 + .../__fixtures__/rsa-leaf-key-pkcs1.der | Bin 0 -> 1191 bytes .../__fixtures__/rsa-leaf-key-pkcs1.pem | 27 + .../__fixtures__/rsa-leaf-key-pkcs8.der | Bin 0 -> 1217 bytes .../__fixtures__/rsa-leaf-key.pem | 28 + .../__fixtures__/rsa-leaf-noaki.pem | 19 + .../cert-manager/__fixtures__/rsa-leaf.cer | Bin 0 -> 977 bytes .../cert-manager/__fixtures__/rsa-leaf.csr | 17 + .../__fixtures__/rsa-leaf.csr.der | Bin 0 -> 695 bytes .../cert-manager/__fixtures__/rsa-leaf.pem | 23 + .../cert-manager/__fixtures__/rsa-legacy.pfx | Bin 0 -> 4429 bytes .../cert-manager/__fixtures__/rsa-modern.bin | Bin 0 -> 4595 bytes .../cert-manager/__fixtures__/rsa-modern.pfx | Bin 0 -> 4595 bytes .../cert-manager/__fixtures__/rsa-nopass.pfx | Bin 0 -> 4595 bytes .../cert-manager/__fixtures__/rsa-root.pem | 20 + .../cert-manager/__fixtures__/rsa-root2.pem | 20 + .../cert-manager/__fixtures__/rsa-trusted.pem | 23 + .../__fixtures__/selfsigned-leaf.pem | 21 + .../api/src/cert-manager/cert-analyze.spec.ts | 67 ++ apps/api/src/cert-manager/cert-analyze.ts | 75 ++ apps/api/src/cert-manager/cert-bundle.spec.ts | 268 ------ apps/api/src/cert-manager/cert-bundle.ts | 655 --------------- .../cert-manager.controller.spec.ts | 84 ++ .../cert-manager/cert-manager.controller.ts | 197 +---- .../src/cert-manager/cert-manager.module.ts | 10 +- .../cert-manager/cert-manager.service.spec.ts | 778 ----------------- .../src/cert-manager/cert-manager.service.ts | 793 ------------------ apps/api/src/cert-manager/cert-model.spec.ts | 131 +++ apps/api/src/cert-manager/cert-model.ts | 324 +++++++ apps/api/src/cert-manager/cert-output.spec.ts | 18 + apps/api/src/cert-manager/cert-output.ts | 14 + apps/api/src/cert-manager/cert-types.ts | 183 ++++ .../src/cert-manager/dto/convert-cert.dto.ts | 8 - .../src/cert-manager/dto/merge-certs.dto.ts | 8 - .../src/cert-manager/dto/parse-cert.dto.ts | 8 - .../(portal)/modules/cert-manager/actions.ts | 440 ++++------ .../cert-manager/cert-manager.test.tsx | 478 ++--------- .../cert-manager/components/ConvertTab.tsx | 96 --- .../cert-manager/components/DropZone.tsx | 108 --- .../cert-manager/components/FilesTab.test.tsx | 218 +++++ .../cert-manager/components/FilesTab.tsx | 238 ++++++ .../cert-manager/components/InspectTab.tsx | 144 ---- .../cert-manager/components/MergeTab.test.tsx | 74 -- .../cert-manager/components/MergeTab.tsx | 159 ---- .../components/OverviewTab.test.tsx | 217 ----- .../cert-manager/components/OverviewTab.tsx | 402 --------- .../cert-manager/components/PasswordField.tsx | 79 -- .../cert-manager/components/SplitTab.tsx | 145 ---- .../(portal)/modules/cert-manager/page.tsx | 162 +--- .../cert-manager/use-cert-workspace.ts | 105 +++ .../modules/cert-manager/working-set.test.ts | 90 ++ .../modules/cert-manager/working-set.ts | 94 +++ apps/web/src/messages/de.json | 168 ++-- apps/web/src/messages/en.json | 168 ++-- apps/web/src/messages/umlaut-dictionary.ts | 6 + 84 files changed, 2906 insertions(+), 5160 deletions(-) create mode 100644 apps/api/src/cert-manager/__fixtures__/README.md create mode 100644 apps/api/src/cert-manager/__fixtures__/aia-private-leaf.pem create mode 100644 apps/api/src/cert-manager/__fixtures__/ec-chain.p7b create mode 100644 apps/api/src/cert-manager/__fixtures__/ec-compat.pfx create mode 100644 apps/api/src/cert-manager/__fixtures__/ec-fullchain.pem create mode 100644 apps/api/src/cert-manager/__fixtures__/ec-inter.pem create mode 100644 apps/api/src/cert-manager/__fixtures__/ec-leaf-key-enc-pkcs8.der create mode 100644 apps/api/src/cert-manager/__fixtures__/ec-leaf-key-enc-pkcs8.pem create mode 100644 apps/api/src/cert-manager/__fixtures__/ec-leaf-key-enc-trad.pem create mode 100644 apps/api/src/cert-manager/__fixtures__/ec-leaf-key-sec1.der create mode 100644 apps/api/src/cert-manager/__fixtures__/ec-leaf-key-sec1.pem create mode 100644 apps/api/src/cert-manager/__fixtures__/ec-leaf-key.pem create mode 100644 apps/api/src/cert-manager/__fixtures__/ec-leaf.cer create mode 100644 apps/api/src/cert-manager/__fixtures__/ec-leaf.csr create mode 100644 apps/api/src/cert-manager/__fixtures__/ec-leaf.csr.der create mode 100644 apps/api/src/cert-manager/__fixtures__/ec-leaf.pem create mode 100644 apps/api/src/cert-manager/__fixtures__/ec-modern.pfx create mode 100644 apps/api/src/cert-manager/__fixtures__/ec-root.pem create mode 100644 apps/api/src/cert-manager/__fixtures__/encrypted-entry.zip create mode 100644 apps/api/src/cert-manager/__fixtures__/make-fixtures.sh create mode 100644 apps/api/src/cert-manager/__fixtures__/rsa-chain.p7b create mode 100644 apps/api/src/cert-manager/__fixtures__/rsa-chain.p7c create mode 100644 apps/api/src/cert-manager/__fixtures__/rsa-compat.pfx create mode 100644 apps/api/src/cert-manager/__fixtures__/rsa-fullchain.pem create mode 100644 apps/api/src/cert-manager/__fixtures__/rsa-inter-cross.pem create mode 100644 apps/api/src/cert-manager/__fixtures__/rsa-inter-decoy.pem create mode 100644 apps/api/src/cert-manager/__fixtures__/rsa-inter-expired.pem create mode 100644 apps/api/src/cert-manager/__fixtures__/rsa-inter.pem create mode 100644 apps/api/src/cert-manager/__fixtures__/rsa-leaf-key-enc-pkcs8.pem create mode 100644 apps/api/src/cert-manager/__fixtures__/rsa-leaf-key-enc-trad.pem create mode 100644 apps/api/src/cert-manager/__fixtures__/rsa-leaf-key-pkcs1.der create mode 100644 apps/api/src/cert-manager/__fixtures__/rsa-leaf-key-pkcs1.pem create mode 100644 apps/api/src/cert-manager/__fixtures__/rsa-leaf-key-pkcs8.der create mode 100644 apps/api/src/cert-manager/__fixtures__/rsa-leaf-key.pem create mode 100644 apps/api/src/cert-manager/__fixtures__/rsa-leaf-noaki.pem create mode 100644 apps/api/src/cert-manager/__fixtures__/rsa-leaf.cer create mode 100644 apps/api/src/cert-manager/__fixtures__/rsa-leaf.csr create mode 100644 apps/api/src/cert-manager/__fixtures__/rsa-leaf.csr.der create mode 100644 apps/api/src/cert-manager/__fixtures__/rsa-leaf.pem create mode 100644 apps/api/src/cert-manager/__fixtures__/rsa-legacy.pfx create mode 100644 apps/api/src/cert-manager/__fixtures__/rsa-modern.bin create mode 100644 apps/api/src/cert-manager/__fixtures__/rsa-modern.pfx create mode 100644 apps/api/src/cert-manager/__fixtures__/rsa-nopass.pfx create mode 100644 apps/api/src/cert-manager/__fixtures__/rsa-root.pem create mode 100644 apps/api/src/cert-manager/__fixtures__/rsa-root2.pem create mode 100644 apps/api/src/cert-manager/__fixtures__/rsa-trusted.pem create mode 100644 apps/api/src/cert-manager/__fixtures__/selfsigned-leaf.pem create mode 100644 apps/api/src/cert-manager/cert-analyze.spec.ts create mode 100644 apps/api/src/cert-manager/cert-analyze.ts delete mode 100644 apps/api/src/cert-manager/cert-bundle.spec.ts delete mode 100644 apps/api/src/cert-manager/cert-bundle.ts create mode 100644 apps/api/src/cert-manager/cert-manager.controller.spec.ts delete mode 100644 apps/api/src/cert-manager/cert-manager.service.spec.ts delete mode 100644 apps/api/src/cert-manager/cert-manager.service.ts create mode 100644 apps/api/src/cert-manager/cert-model.spec.ts create mode 100644 apps/api/src/cert-manager/cert-model.ts create mode 100644 apps/api/src/cert-manager/cert-output.spec.ts create mode 100644 apps/api/src/cert-manager/cert-output.ts create mode 100644 apps/api/src/cert-manager/cert-types.ts delete mode 100644 apps/api/src/cert-manager/dto/convert-cert.dto.ts delete mode 100644 apps/api/src/cert-manager/dto/merge-certs.dto.ts delete mode 100644 apps/api/src/cert-manager/dto/parse-cert.dto.ts delete mode 100644 apps/web/src/app/(portal)/modules/cert-manager/components/ConvertTab.tsx delete mode 100644 apps/web/src/app/(portal)/modules/cert-manager/components/DropZone.tsx create mode 100644 apps/web/src/app/(portal)/modules/cert-manager/components/FilesTab.test.tsx create mode 100644 apps/web/src/app/(portal)/modules/cert-manager/components/FilesTab.tsx delete mode 100644 apps/web/src/app/(portal)/modules/cert-manager/components/InspectTab.tsx delete mode 100644 apps/web/src/app/(portal)/modules/cert-manager/components/MergeTab.test.tsx delete mode 100644 apps/web/src/app/(portal)/modules/cert-manager/components/MergeTab.tsx delete mode 100644 apps/web/src/app/(portal)/modules/cert-manager/components/OverviewTab.test.tsx delete mode 100644 apps/web/src/app/(portal)/modules/cert-manager/components/OverviewTab.tsx delete mode 100644 apps/web/src/app/(portal)/modules/cert-manager/components/PasswordField.tsx delete mode 100644 apps/web/src/app/(portal)/modules/cert-manager/components/SplitTab.tsx create mode 100644 apps/web/src/app/(portal)/modules/cert-manager/use-cert-workspace.ts create mode 100644 apps/web/src/app/(portal)/modules/cert-manager/working-set.test.ts create mode 100644 apps/web/src/app/(portal)/modules/cert-manager/working-set.ts diff --git a/apps/api/src/cert-manager/__fixtures__/README.md b/apps/api/src/cert-manager/__fixtures__/README.md new file mode 100644 index 0000000..b1fb2a3 --- /dev/null +++ b/apps/api/src/cert-manager/__fixtures__/README.md @@ -0,0 +1,18 @@ +# Fixtures des Zertifikat-Managers + +Nur Testdaten (quick-261009-ikt). Keine echten Zertifikate, keine echten Schluessel. + +- Eine eigene Test-PKI: RSA (Wurzel, zweite Wurzel, Zwischenzertifikat, dazu Varianten + gleichen Namens: cross-signiert, abgelaufen, Lockvogel mit anderem Schluessel) und + EC (Wurzel P-384, Zwischenzertifikat P-384, Server P-256). +- Passwort aller geschuetzten Dateien (verschluesselte Schluessel, PFX, ZIP): `Test-Pass-123`. + Die Datei `rsa-nopass.pfx` hat ein leeres Passwort. +- Die Schluessel der CAs liegen nur waehrend der Erzeugung in einem temporaeren Ordner und + werden geloescht. Committet sind ausschliesslich Schluessel der Server-Zertifikate. +- Dateinamen enden nie auf `.key` (die `.gitignore` ignoriert `*.key` wegen des Updater-Schluessels). +- Neu erzeugen: `bash apps/api/src/cert-manager/__fixtures__/make-fixtures.sh` (OpenSSL 3.4 oder neuer). + Achtung: Eine Neuerzeugung ersetzt alle Zertifikate (neue Schluessel, neue Fingerabdruecke); + die Specs lesen die Fingerabdruecke deshalb aus den Dateien und nennen keine festen Werte. +- Die Specs lesen die Dateien mit `readFileSync(join(__dirname, '__fixtures__', ...))` und rufen + OpenSSL nie auf (die CI hat es nicht). Das Pruefskript `e2e-cert.sh` nutzt OpenSSL. +- Hinweis fuer einen kuenftigen Secret-Scanner: diesen Ordner freigeben (Testschluessel). diff --git a/apps/api/src/cert-manager/__fixtures__/aia-private-leaf.pem b/apps/api/src/cert-manager/__fixtures__/aia-private-leaf.pem new file mode 100644 index 0000000..35e1856 --- /dev/null +++ b/apps/api/src/cert-manager/__fixtures__/aia-private-leaf.pem @@ -0,0 +1,25 @@ +-----BEGIN CERTIFICATE----- +MIIEIzCCAwugAwIBAgICAMswDQYJKoZIhvcNAQELBQAwODEVMBMGA1UECgwMVGVz +c2VyYSBUZXN0MR8wHQYDVQQDDBZUZXNzZXJhIFRlc3QgSW50ZXIgUlNBMCAXDTI2 +MTAwOTEyNDIwMloYDzIxMjYwOTE1MTI0MjAyWjA6MRUwEwYDVQQKDAxUZXNzZXJh +IFRlc3QxITAfBgNVBAMMGGFpYS1wcml2YXRlLmV4YW1wbGUudGVzdDCCASIwDQYJ +KoZIhvcNAQEBBQADggEPADCCAQoCggEBAKqqSQ1+lWWU/3YG2/1JjZLBm7yg1JXY +/F9tHMqmzrkrLzqp42a72fvigDTjQQOP3jGujyrIBHfeCNjylIKZuyXJ3o47z4ZL +B6eg2MNwMWEVYVYLLUydA0bKvEfuDbLxV2I8OiAFbGn9kDhAP/cniBm13HDyxjKm +Cj+ZBweXwr+eJ/amNZhYDDrcWy4r6n0qg+CtUlSuj5qXHgrdKxEEVnfwhUyPNT4h +KtTVQZWW4RoRMcHEmQLwPmf+SQ0F1NAN9XkxGbVRT17475vDiUgq2cnq/aWwbZ/1 +D1IyqLXEgZUFqMfZMr2SE4CiPuLsEqXjDzMbvJf2ZP2XKsWlYL9RD1kCAwEAAaOC +ATEwggEtMAwGA1UdEwEB/wQCMAAwDgYDVR0PAQH/BAQDAgWgMBMGA1UdJQQMMAoG +CCsGAQUFBwMBMB0GA1UdDgQWBBS9erSIVCsbwhwEHYRLbDlKknZTHzAfBgNVHSME +GDAWgBTh7bvQ9X9iHKYm2zw9/nacQORtnzAjBgNVHREEHDAaghhhaWEtcHJpdmF0 +ZS5leGFtcGxlLnRlc3QwgZIGCCsGAQUFBwEBBIGFMIGCMCYGCCsGAQUFBzAChhpo +dHRwOi8vMTI3LjAuMC4xL2ludGVyLmNlcjApBggrBgEFBQcwAoYdaHR0cDovLzE2 +OS4yNTQuMTY5LjI1NC9sYXRlc3QwLQYIKwYBBQUHMAKGIWxkYXA6Ly9sZGFwLmV4 +YW1wbGUudGVzdC9jbj1pbnRlcjANBgkqhkiG9w0BAQsFAAOCAQEAWXPmZqdtxGWU +m9uXsHlf89Nx/lfHv/gmY276Wm+k1Kjtv03kv4XS5P5yqcbwkDH5ywoYL6X7FEgP +R61iSu5lpyEIcRjOOwXIEchnDruX8RI7rbrPAsJ6ZY6i1tck+WBb1QcD36qnwar7 +vH9GQuINNKLVEDeVz2fB3/wSdxc0jEEMegWvMnHtZ4fC0YtKx84LPhsyBOEkSu6n +vXeeyb+0s4/UBkClJSbpVEaG2kIkq8JOokEQCgRZ1Np9n1PObPSSFSY2OKEYot92 +ao4UNr9Pq6QqkfQ5rthfA5H7em6LE/poUviugEXc6BURnPwmVb+FK/1ni1TDZTbo +WO7eSAZz5Q== +-----END CERTIFICATE----- diff --git a/apps/api/src/cert-manager/__fixtures__/ec-chain.p7b b/apps/api/src/cert-manager/__fixtures__/ec-chain.p7b new file mode 100644 index 0000000..0e34d1c --- /dev/null +++ b/apps/api/src/cert-manager/__fixtures__/ec-chain.p7b @@ -0,0 +1,38 @@ +-----BEGIN PKCS7----- +MIIGuQYJKoZIhvcNAQcCoIIGqjCCBqYCAQExADALBgkqhkiG9w0BBwGgggaOMIIC +TjCCAdWgAwIBAgICAZEwCgYIKoZIzj0EAwIwNzEVMBMGA1UECgwMVGVzc2VyYSBU +ZXN0MR4wHAYDVQQDDBVUZXNzZXJhIFRlc3QgSW50ZXIgRUMwIBcNMjYxMDA5MTI0 +MjAyWhgPMjEyNjA5MTUxMjQyMDJaMDExFTATBgNVBAoMDFRlc3NlcmEgVGVzdDEY +MBYGA1UEAwwPZWMuZXhhbXBsZS50ZXN0MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcD +QgAEeY7t+BxIXCNmolAyuLxdVSFmnFc1+Ipj9Xalvuhrk6b00/msgJIQ4+Dx2vci +T9lGjfPJvlZqOfNNt+VVmsV7x6OB1DCB0TAMBgNVHRMBAf8EAjAAMA4GA1UdDwEB +/wQEAwIFoDATBgNVHSUEDDAKBggrBgEFBQcDATAdBgNVHQ4EFgQUsPnAEeAumf2R +qSu3lGGuUQVDhzowHwYDVR0jBBgwFoAUGgwrSpk97+BQ9zfXSImoWjrgG90wGgYD +VR0RBBMwEYIPZWMuZXhhbXBsZS50ZXN0MEAGCCsGAQUFBwEBBDQwMjAwBggrBgEF +BQcwAoYkaHR0cDovL3BraS5leGFtcGxlLnRlc3QvZWMtaW50ZXIuY2VyMAoGCCqG +SM49BAMCA2cAMGQCMCOFzkLRLlGyCixYx9ufTb0k/5IxkRjrwDax7OFTSoN9+0lo +Jv1Hp/3mGeS65HOf1AIwc17jU4Nh+478Hoaktv8dgN/qoetQgiOx/IaOb4O5shKM +k+hRxoYYeJ/MrndtBFjUMIICRjCCAcugAwIBAgICAS0wCgYIKoZIzj0EAwIwNjEV +MBMGA1UECgwMVGVzc2VyYSBUZXN0MR0wGwYDVQQDDBRUZXNzZXJhIFRlc3QgUm9v +dCBFQzAgFw0yNjEwMDkxMjQyMDJaGA8yMTI2MDkxNTEyNDIwMlowNzEVMBMGA1UE +CgwMVGVzc2VyYSBUZXN0MR4wHAYDVQQDDBVUZXNzZXJhIFRlc3QgSW50ZXIgRUMw +djAQBgcqhkjOPQIBBgUrgQQAIgNiAASCaV+fIBVW12edBlTT95heB5iuYizTNmTP +QUgu2u/XL7MIuIiy1DCsvKEwAPM62wvKab8xP4VsURLd2bGDKQ6nKfJyCITOG/iT +UsAhTYfkWM4tFPie5Xczmj2prnq6NaKjgagwgaUwEgYDVR0TAQH/BAgwBgEB/wIB +ADAOBgNVHQ8BAf8EBAMCAQYwHQYDVR0OBBYEFBoMK0qZPe/gUPc310iJqFo64Bvd +MB8GA1UdIwQYMBaAFABpT8YziNytqiBtP8IJbB2w4h5FMD8GCCsGAQUFBwEBBDMw +MTAvBggrBgEFBQcwAoYjaHR0cDovL3BraS5leGFtcGxlLnRlc3QvZWMtcm9vdC5j +ZXIwCgYIKoZIzj0EAwIDaQAwZgIxAKvXNAcWTnuiUtQRcq5dVpttiFy1B0OuHEYS +/UjdljB6oc2Ea8xJiCIXQODu/qOtJQIxAMrvX6coL2rvAj19zNrF2jboSoRcYEgE +YxyCzo5X9+XGC1sOiBDYzlRPX+9+AovkrzCCAe4wggF1oAMCAQICFHEw11UEFUZO +zbui78XQHRBvsrHGMAoGCCqGSM49BAMCMDYxFTATBgNVBAoMDFRlc3NlcmEgVGVz +dDEdMBsGA1UEAwwUVGVzc2VyYSBUZXN0IFJvb3QgRUMwIBcNMjYxMDA5MTI0MjAy +WhgPMjEyNjA5MTUxMjQyMDJaMDYxFTATBgNVBAoMDFRlc3NlcmEgVGVzdDEdMBsG +A1UEAwwUVGVzc2VyYSBUZXN0IFJvb3QgRUMwdjAQBgcqhkjOPQIBBgUrgQQAIgNi +AARPBGScIkXNVouGImWgBFylQVaFbJCYXVpcQQQ6L6xNkfgVOZf8zmNkb3pxMWx6 +l4mCmaGhmRupJRhFimLupWs5PVvBAkqWQtok2mldEk8e+5+/tq82kxgnOSHkE7/V +10+jQjBAMA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgEGMB0GA1UdDgQW +BBQAaU/GM4jcraogbT/CCWwdsOIeRTAKBggqhkjOPQQDAgNnADBkAjAvOiOt6Ojs +gSeYeH7CnydetEZWH9kg+KzeVySa9DNTG9GpfZfcws4y6VwFVJDyaysCMFFm/wJL +rJOOowf5AZNqssvzq0gsoHvo8Je/EqDON9irSf2gBVluy+oJU7DP8yu7mTEA +-----END PKCS7----- diff --git a/apps/api/src/cert-manager/__fixtures__/ec-compat.pfx b/apps/api/src/cert-manager/__fixtures__/ec-compat.pfx new file mode 100644 index 0000000000000000000000000000000000000000..4731141c78746f17aff0912849da6c058dd69748 GIT binary patch literal 2286 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 Fe*sG7JvaaW literal 0 HcmV?d00001 diff --git a/apps/api/src/cert-manager/__fixtures__/ec-fullchain.pem b/apps/api/src/cert-manager/__fixtures__/ec-fullchain.pem new file mode 100644 index 0000000..591e095 --- /dev/null +++ b/apps/api/src/cert-manager/__fixtures__/ec-fullchain.pem @@ -0,0 +1,43 @@ +-----BEGIN CERTIFICATE----- +MIICTjCCAdWgAwIBAgICAZEwCgYIKoZIzj0EAwIwNzEVMBMGA1UECgwMVGVzc2Vy +YSBUZXN0MR4wHAYDVQQDDBVUZXNzZXJhIFRlc3QgSW50ZXIgRUMwIBcNMjYxMDA5 +MTI0MjAyWhgPMjEyNjA5MTUxMjQyMDJaMDExFTATBgNVBAoMDFRlc3NlcmEgVGVz +dDEYMBYGA1UEAwwPZWMuZXhhbXBsZS50ZXN0MFkwEwYHKoZIzj0CAQYIKoZIzj0D +AQcDQgAEeY7t+BxIXCNmolAyuLxdVSFmnFc1+Ipj9Xalvuhrk6b00/msgJIQ4+Dx +2vciT9lGjfPJvlZqOfNNt+VVmsV7x6OB1DCB0TAMBgNVHRMBAf8EAjAAMA4GA1Ud +DwEB/wQEAwIFoDATBgNVHSUEDDAKBggrBgEFBQcDATAdBgNVHQ4EFgQUsPnAEeAu +mf2RqSu3lGGuUQVDhzowHwYDVR0jBBgwFoAUGgwrSpk97+BQ9zfXSImoWjrgG90w +GgYDVR0RBBMwEYIPZWMuZXhhbXBsZS50ZXN0MEAGCCsGAQUFBwEBBDQwMjAwBggr +BgEFBQcwAoYkaHR0cDovL3BraS5leGFtcGxlLnRlc3QvZWMtaW50ZXIuY2VyMAoG +CCqGSM49BAMCA2cAMGQCMCOFzkLRLlGyCixYx9ufTb0k/5IxkRjrwDax7OFTSoN9 ++0loJv1Hp/3mGeS65HOf1AIwc17jU4Nh+478Hoaktv8dgN/qoetQgiOx/IaOb4O5 +shKMk+hRxoYYeJ/MrndtBFjU +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIICRjCCAcugAwIBAgICAS0wCgYIKoZIzj0EAwIwNjEVMBMGA1UECgwMVGVzc2Vy +YSBUZXN0MR0wGwYDVQQDDBRUZXNzZXJhIFRlc3QgUm9vdCBFQzAgFw0yNjEwMDkx +MjQyMDJaGA8yMTI2MDkxNTEyNDIwMlowNzEVMBMGA1UECgwMVGVzc2VyYSBUZXN0 +MR4wHAYDVQQDDBVUZXNzZXJhIFRlc3QgSW50ZXIgRUMwdjAQBgcqhkjOPQIBBgUr +gQQAIgNiAASCaV+fIBVW12edBlTT95heB5iuYizTNmTPQUgu2u/XL7MIuIiy1DCs +vKEwAPM62wvKab8xP4VsURLd2bGDKQ6nKfJyCITOG/iTUsAhTYfkWM4tFPie5Xcz +mj2prnq6NaKjgagwgaUwEgYDVR0TAQH/BAgwBgEB/wIBADAOBgNVHQ8BAf8EBAMC +AQYwHQYDVR0OBBYEFBoMK0qZPe/gUPc310iJqFo64BvdMB8GA1UdIwQYMBaAFABp +T8YziNytqiBtP8IJbB2w4h5FMD8GCCsGAQUFBwEBBDMwMTAvBggrBgEFBQcwAoYj +aHR0cDovL3BraS5leGFtcGxlLnRlc3QvZWMtcm9vdC5jZXIwCgYIKoZIzj0EAwID +aQAwZgIxAKvXNAcWTnuiUtQRcq5dVpttiFy1B0OuHEYS/UjdljB6oc2Ea8xJiCIX +QODu/qOtJQIxAMrvX6coL2rvAj19zNrF2jboSoRcYEgEYxyCzo5X9+XGC1sOiBDY +zlRPX+9+Aovkrw== +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIIB7jCCAXWgAwIBAgIUcTDXVQQVRk7Nu6LvxdAdEG+yscYwCgYIKoZIzj0EAwIw +NjEVMBMGA1UECgwMVGVzc2VyYSBUZXN0MR0wGwYDVQQDDBRUZXNzZXJhIFRlc3Qg +Um9vdCBFQzAgFw0yNjEwMDkxMjQyMDJaGA8yMTI2MDkxNTEyNDIwMlowNjEVMBMG +A1UECgwMVGVzc2VyYSBUZXN0MR0wGwYDVQQDDBRUZXNzZXJhIFRlc3QgUm9vdCBF +QzB2MBAGByqGSM49AgEGBSuBBAAiA2IABE8EZJwiRc1Wi4YiZaAEXKVBVoVskJhd +WlxBBDovrE2R+BU5l/zOY2RvenExbHqXiYKZoaGZG6klGEWKYu6lazk9W8ECSpZC +2iTaaV0STx77n7+2rzaTGCc5IeQTv9XXT6NCMEAwDwYDVR0TAQH/BAUwAwEB/zAO +BgNVHQ8BAf8EBAMCAQYwHQYDVR0OBBYEFABpT8YziNytqiBtP8IJbB2w4h5FMAoG +CCqGSM49BAMCA2cAMGQCMC86I63o6OyBJ5h4fsKfJ160RlYf2SD4rN5XJJr0M1Mb +0al9l9zCzjLpXAVUkPJrKwIwUWb/Akusk46jB/kBk2qyy/OrSCyge+jwl78SoM43 +2KtJ/aAFWW7L6glTsM/zK7uZ +-----END CERTIFICATE----- diff --git a/apps/api/src/cert-manager/__fixtures__/ec-inter.pem b/apps/api/src/cert-manager/__fixtures__/ec-inter.pem new file mode 100644 index 0000000..dfc07c3 --- /dev/null +++ b/apps/api/src/cert-manager/__fixtures__/ec-inter.pem @@ -0,0 +1,15 @@ +-----BEGIN CERTIFICATE----- +MIICRjCCAcugAwIBAgICAS0wCgYIKoZIzj0EAwIwNjEVMBMGA1UECgwMVGVzc2Vy +YSBUZXN0MR0wGwYDVQQDDBRUZXNzZXJhIFRlc3QgUm9vdCBFQzAgFw0yNjEwMDkx +MjQyMDJaGA8yMTI2MDkxNTEyNDIwMlowNzEVMBMGA1UECgwMVGVzc2VyYSBUZXN0 +MR4wHAYDVQQDDBVUZXNzZXJhIFRlc3QgSW50ZXIgRUMwdjAQBgcqhkjOPQIBBgUr +gQQAIgNiAASCaV+fIBVW12edBlTT95heB5iuYizTNmTPQUgu2u/XL7MIuIiy1DCs +vKEwAPM62wvKab8xP4VsURLd2bGDKQ6nKfJyCITOG/iTUsAhTYfkWM4tFPie5Xcz +mj2prnq6NaKjgagwgaUwEgYDVR0TAQH/BAgwBgEB/wIBADAOBgNVHQ8BAf8EBAMC +AQYwHQYDVR0OBBYEFBoMK0qZPe/gUPc310iJqFo64BvdMB8GA1UdIwQYMBaAFABp +T8YziNytqiBtP8IJbB2w4h5FMD8GCCsGAQUFBwEBBDMwMTAvBggrBgEFBQcwAoYj +aHR0cDovL3BraS5leGFtcGxlLnRlc3QvZWMtcm9vdC5jZXIwCgYIKoZIzj0EAwID +aQAwZgIxAKvXNAcWTnuiUtQRcq5dVpttiFy1B0OuHEYS/UjdljB6oc2Ea8xJiCIX +QODu/qOtJQIxAMrvX6coL2rvAj19zNrF2jboSoRcYEgEYxyCzo5X9+XGC1sOiBDY +zlRPX+9+Aovkrw== +-----END CERTIFICATE----- diff --git a/apps/api/src/cert-manager/__fixtures__/ec-leaf-key-enc-pkcs8.der b/apps/api/src/cert-manager/__fixtures__/ec-leaf-key-enc-pkcs8.der new file mode 100644 index 0000000000000000000000000000000000000000..38124ba093705ecf7becba96e87d8e7ddf9e99bb GIT binary patch literal 180 zcmV;l089Tcfw3?g1_~;MNQU&3|Hu0s;sC1c8t$Z}0ETr&n3^ z=YWqU*FP=Jyraxp$pP0-D!J!*YzXh1tX~c^*^IC|dd?#^t$s@;*ch6PtOEKHz(Gc=xdXv^wasQfRYg7 b;PKk`B2U>yjq}OAR%$u(O}FJ$n#Fs^=>|61 literal 0 HcmV?d00001 diff --git a/apps/api/src/cert-manager/__fixtures__/ec-leaf-key-sec1.pem b/apps/api/src/cert-manager/__fixtures__/ec-leaf-key-sec1.pem new file mode 100644 index 0000000..3144c5d --- /dev/null +++ b/apps/api/src/cert-manager/__fixtures__/ec-leaf-key-sec1.pem @@ -0,0 +1,5 @@ +-----BEGIN EC PRIVATE KEY----- +MHcCAQEEIDEFEkJwvCAUZT60LWQ1hQi/maGFIVMg5tD6f+FAXim3oAoGCCqGSM49 +AwEHoUQDQgAEeY7t+BxIXCNmolAyuLxdVSFmnFc1+Ipj9Xalvuhrk6b00/msgJIQ +4+Dx2vciT9lGjfPJvlZqOfNNt+VVmsV7xw== +-----END EC PRIVATE KEY----- diff --git a/apps/api/src/cert-manager/__fixtures__/ec-leaf-key.pem b/apps/api/src/cert-manager/__fixtures__/ec-leaf-key.pem new file mode 100644 index 0000000..5e3f26a --- /dev/null +++ b/apps/api/src/cert-manager/__fixtures__/ec-leaf-key.pem @@ -0,0 +1,5 @@ +-----BEGIN PRIVATE KEY----- +MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgMQUSQnC8IBRlPrQt +ZDWFCL+ZoYUhUyDm0Pp/4UBeKbehRANCAAR5ju34HEhcI2aiUDK4vF1VIWacVzX4 +imP1dqW+6GuTpvTT+ayAkhDj4PHa9yJP2UaN88m+Vmo580235VWaxXvH +-----END PRIVATE KEY----- diff --git a/apps/api/src/cert-manager/__fixtures__/ec-leaf.cer b/apps/api/src/cert-manager/__fixtures__/ec-leaf.cer new file mode 100644 index 0000000000000000000000000000000000000000..a2a365a8da85d69464b78cb8260b724c0ee7c1c9 GIT binary patch literal 594 zcmXqLV)8R+V!XP5nTe5!iHUKd0T&yGR-4B;TNY*}19L-B17SAiP!=v8o{-ey;?$x< z1t48wC}$u85@zNRMHW`@%qvMPQgC%PP!Q)eGBY$VurxF>F)}cUlHfNoG%^EnO`!q? zhPaHDFc5ncCg!+7@>}2W@Kk}VqmH4d;3GiBStxG zQGn5oJ+YyRX>-C&e{>~(EnB+pMfT)nUoQV#(=bWk@q>@IzAO3PbnE?ma$i`MXafKs9kRm=7F&2>xKMx2#(3|;p;!5r9Qxew&vO2e08OVdAm05s(Y7mj)(e|2Y`~E?| zck}BWohzcO9!TFckOCz(h4ro%|_@}Ke azj@~-p`OVv0*|#xRLnoKt~{3|;tBxMdBWZR literal 0 HcmV?d00001 diff --git a/apps/api/src/cert-manager/__fixtures__/ec-leaf.csr b/apps/api/src/cert-manager/__fixtures__/ec-leaf.csr new file mode 100644 index 0000000..2332116 --- /dev/null +++ b/apps/api/src/cert-manager/__fixtures__/ec-leaf.csr @@ -0,0 +1,8 @@ +-----BEGIN CERTIFICATE REQUEST----- +MIIBGjCBwAIBADAxMRUwEwYDVQQKDAxUZXNzZXJhIFRlc3QxGDAWBgNVBAMMD2Vj +LmV4YW1wbGUudGVzdDBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABHmO7fgcSFwj +ZqJQMri8XVUhZpxXNfiKY/V2pb7oa5Om9NP5rICSEOPg8dr3Ik/ZRo3zyb5Wajnz +TbflVZrFe8egLTArBgkqhkiG9w0BCQ4xHjAcMBoGA1UdEQQTMBGCD2VjLmV4YW1w +bGUudGVzdDAKBggqhkjOPQQDAgNJADBGAiEAq1WTh8ixRI26kN4p6gmIE0h1TOua +hDLUu8FA9tXK73oCIQDAGX/FC2qjMuobhP+DX0Gre5D8B18VZyAaMiuBWCC/mg== +-----END CERTIFICATE REQUEST----- diff --git a/apps/api/src/cert-manager/__fixtures__/ec-leaf.csr.der b/apps/api/src/cert-manager/__fixtures__/ec-leaf.csr.der new file mode 100644 index 0000000000000000000000000000000000000000..a11bccd3a7ca9873666b924283c265cee174f3cb GIT binary patch literal 286 zcmXqLVw5syJix@rU|?t{Y9P$U9LmDQ!xNHPT%1~zr~sr(3?&T2K*G#C{He)$sTGO2 z1v#mDB|sU2NT3FGtu~KywoHs{9AE}BBRjJb150J!+aEF>G0JI+0*rR-i49dun-gyO zqbvDq+0uP4vL`S5a{1?)hDidCAAG#^UCIBZTkq$S`@*s;Kl^Tf8anG}_3;I|2HI?# zKoi@(^D=Vs8Oj;R7)SwKAuGrtY#`W#;Sw&W^(@Rx%$^JeZcK^{t3xNZpV;WqyKBNd z%~zZq!XBkQuV=LwUDgox9*yBah6{L)` K8zU6<&jJ9Z$!JFa literal 0 HcmV?d00001 diff --git a/apps/api/src/cert-manager/__fixtures__/ec-leaf.pem b/apps/api/src/cert-manager/__fixtures__/ec-leaf.pem new file mode 100644 index 0000000..267eb9d --- /dev/null +++ b/apps/api/src/cert-manager/__fixtures__/ec-leaf.pem @@ -0,0 +1,15 @@ +-----BEGIN CERTIFICATE----- +MIICTjCCAdWgAwIBAgICAZEwCgYIKoZIzj0EAwIwNzEVMBMGA1UECgwMVGVzc2Vy +YSBUZXN0MR4wHAYDVQQDDBVUZXNzZXJhIFRlc3QgSW50ZXIgRUMwIBcNMjYxMDA5 +MTI0MjAyWhgPMjEyNjA5MTUxMjQyMDJaMDExFTATBgNVBAoMDFRlc3NlcmEgVGVz +dDEYMBYGA1UEAwwPZWMuZXhhbXBsZS50ZXN0MFkwEwYHKoZIzj0CAQYIKoZIzj0D +AQcDQgAEeY7t+BxIXCNmolAyuLxdVSFmnFc1+Ipj9Xalvuhrk6b00/msgJIQ4+Dx +2vciT9lGjfPJvlZqOfNNt+VVmsV7x6OB1DCB0TAMBgNVHRMBAf8EAjAAMA4GA1Ud +DwEB/wQEAwIFoDATBgNVHSUEDDAKBggrBgEFBQcDATAdBgNVHQ4EFgQUsPnAEeAu +mf2RqSu3lGGuUQVDhzowHwYDVR0jBBgwFoAUGgwrSpk97+BQ9zfXSImoWjrgG90w +GgYDVR0RBBMwEYIPZWMuZXhhbXBsZS50ZXN0MEAGCCsGAQUFBwEBBDQwMjAwBggr +BgEFBQcwAoYkaHR0cDovL3BraS5leGFtcGxlLnRlc3QvZWMtaW50ZXIuY2VyMAoG +CCqGSM49BAMCA2cAMGQCMCOFzkLRLlGyCixYx9ufTb0k/5IxkRjrwDax7OFTSoN9 ++0loJv1Hp/3mGeS65HOf1AIwc17jU4Nh+478Hoaktv8dgN/qoetQgiOx/IaOb4O5 +shKMk+hRxoYYeJ/MrndtBFjU +-----END CERTIFICATE----- diff --git a/apps/api/src/cert-manager/__fixtures__/ec-modern.pfx b/apps/api/src/cert-manager/__fixtures__/ec-modern.pfx new file mode 100644 index 0000000000000000000000000000000000000000..c27523df05f5d42786281dd72b087fb2a3f441b6 GIT binary patch literal 2448 zcmai$X*AS}8^>p+nPHd_Wy!vc$re95gUkr&mPW{gP#Q6n$&wPsHp)8I6r)ii>tt;s zJ7eFYa>Eq5V;hk*iRyOG|9`qK?~CU==llJ>c+Pp=e7*n`L4$zz091q}9HAa-8@q)9 z^MRREgdUWN(Eep>093y5eVkklzgEk?2^jGFEnq0X0nqtZA^|j1*bDsP zgyG{vRd{c&Q{%SBAqa#Y1R&x3|GNr7z(9a39C0qz7VNzT3RZ^-hZc(C@RmW5WfT{< zNq^B+6qU~&^v8Ad38w&uozI6nn6lF%w>657&qFMxyt2AnM>pie(Af_a9(rSPLnf^z z1N^1grJ63&?%uAd+!ynjQpY;*;x@MasmZXU0It5z$U}p;hXBWmeW5>nWRTACa8E=%PIETdI%co#v z5eFb^^o|YmLS&G-?PVoCN77{fo<3IF8Syz!h0V}7XU$0c#Rq8eQy|D^VuRSI5fHF- z=Y33G0$GnWVWwflc{@z^%M3TYr?$!3|D|{mPFH)j?LPhKiYlY@>7{i=)z3{1ixeMm|f|5!~M=sh`CA&!)p{{S08|9vjY^I*X4v!uV z{_*owK4&-T!YdG3>>0c5Y0| za|!gPUfY24$~4m9VOUi2Gmh@2pG~C*(|4dgbP;LlC354nMyQZ}oYtn~rNoA!-Q=>h z?+7$){}POB&*gK=uwDM3B0Q~<>V9Hj@u@lJQbDuw=#6Dx`5hv6#7L%BG;l| zr-~DI+HzFje#RU+9rX@FabGXoyK`ogRg0x#lb4$`s-2Pz3mtU8(um@8A8PpVb5 zI&>*t>#>7~K;BxjG5*xpqazXNB-ND;a~IsDhFY9^fVb_$i1bSw0ID}Ia;l8HZOT15 zw&rX5wEGco_ZY3|rFi>QK}r)FFLIoIK~NqpYrL6OE;d%PCV6^eA3jJ_g3a*R$DHvh(rvA%zFGFi4@Q=Fh)f&G2DNc3 zr)(D5BXTx^5>^Ga7t7tL>S`{nMY$g*1?Wgnl}QOSuYI0KRP(}7W2!E#Q(tFnTOWm<%=Xl(D~{gppI)T)S7 z%|BVe(T~Az6xKa+0B;}cnp7!!+Evj0s)gfc^A>XtV*~y!WtPR4&9RnD{!(_s21mU< z(Sm2_sF71S03DWYuZ(HN<>{xj9xtn+1y)BUB(RnjM&{?xZFTuwQC6aB=4)mj)w((o zui9C_X}+~t(no~&MTBIZt1$_ON=Xl@-Z~Sw?O<2;N;iyvL9h#}4MTOYo1AYH`#30< zSkttU_iX%WFipzx@>D^fkQ~dbYkP?-Jw&!qWot(*m|f0skhrtL3A668kk0ooosc3p z#S(XOoJ|`@f`TIwH{H9oAOj_W^?az&5z zq8|_<#;SXy;*sQfw+O1o_!v8o;g(;NiLV2q_h0VSDSH2P7M4cO6Y4i_|rUk`WK8kJm5aNBs zPf=mHQIX3S+p(1^D5(W5``m)UXeR+S?pdA(- zi2+pbvEQ8j2mD|xlnOTdW$XSLEx6$SI{{Jv3}HrZ0nr=(h503mtQC@VBAq>@E*1Q_ zgZ`hGC%rpq-?u>QTL8)A>?OY2fJUd8TzdlfwveomX5my*ek#tl`-XXU26BJpUe^@# z?rf*|6EkHGTvu>ixtj(^l-_#BED6G!)*1JTT;NL;iE$e(i~6AXR~X9VOnQ!G{B@M# zVT6K}%)1C!jdL?!03V8Ir1jF@7a3*59k1>-yH@5 zhJm08iLV91X|sc2x4}vwQEH;m`z|_WYeP-aEew+m;8zAjp!{~3Th!h BWS#&3 literal 0 HcmV?d00001 diff --git a/apps/api/src/cert-manager/__fixtures__/ec-root.pem b/apps/api/src/cert-manager/__fixtures__/ec-root.pem new file mode 100644 index 0000000..dcfc3e4 --- /dev/null +++ b/apps/api/src/cert-manager/__fixtures__/ec-root.pem @@ -0,0 +1,13 @@ +-----BEGIN CERTIFICATE----- +MIIB7jCCAXWgAwIBAgIUcTDXVQQVRk7Nu6LvxdAdEG+yscYwCgYIKoZIzj0EAwIw +NjEVMBMGA1UECgwMVGVzc2VyYSBUZXN0MR0wGwYDVQQDDBRUZXNzZXJhIFRlc3Qg +Um9vdCBFQzAgFw0yNjEwMDkxMjQyMDJaGA8yMTI2MDkxNTEyNDIwMlowNjEVMBMG +A1UECgwMVGVzc2VyYSBUZXN0MR0wGwYDVQQDDBRUZXNzZXJhIFRlc3QgUm9vdCBF +QzB2MBAGByqGSM49AgEGBSuBBAAiA2IABE8EZJwiRc1Wi4YiZaAEXKVBVoVskJhd +WlxBBDovrE2R+BU5l/zOY2RvenExbHqXiYKZoaGZG6klGEWKYu6lazk9W8ECSpZC +2iTaaV0STx77n7+2rzaTGCc5IeQTv9XXT6NCMEAwDwYDVR0TAQH/BAUwAwEB/zAO +BgNVHQ8BAf8EBAMCAQYwHQYDVR0OBBYEFABpT8YziNytqiBtP8IJbB2w4h5FMAoG +CCqGSM49BAMCA2cAMGQCMC86I63o6OyBJ5h4fsKfJ160RlYf2SD4rN5XJJr0M1Mb +0al9l9zCzjLpXAVUkPJrKwIwUWb/Akusk46jB/kBk2qyy/OrSCyge+jwl78SoM43 +2KtJ/aAFWW7L6glTsM/zK7uZ +-----END CERTIFICATE----- diff --git a/apps/api/src/cert-manager/__fixtures__/encrypted-entry.zip b/apps/api/src/cert-manager/__fixtures__/encrypted-entry.zip new file mode 100644 index 0000000000000000000000000000000000000000..c85305a07055bf2a97a07ad582908ae97458a0a0 GIT binary patch literal 1219 zcmWIWW@Zs#;AG%na4hwV{qsz=ft7`UA(@qdfrmkcp{O`fHzzeQO|Kv|H#CHkfm!n5 zi7Y7~F0J5ZU}Sm0%)kI9n0Xt|&HJ?>Nr&~$7q;?v$74Plnf^-eOVdjI@HKbs@5v6e zrK#CRi*_m{sC(2*z9jbOZmHMDzEkU8Us_`N)$1p>(czx!u8EG@)xVlAd;I2s%7Vs^ zA~IJZ7H_%C=$)Oq=eHbB-P>z2qAcD{>#iJ=e|lD9)`r4OvFvu!=5MQh_us|s>P)E`B5%0gwERNC#fh<@ zmk-MBy?E1lOUaD#y*4L4bJ$6xHAMNx=GOD?-($JP+xL{|CH74Y2#54{HPzqSSNGL5s_$!e+B-=+ zquuyNXLIb{_{w^{oVAWIWs1m zY5M=+knPM zje0ZoYdfDzdnwtrcDn&HN9N1*>!!z@nKUOX>!_!jjfm;&8Fda1XZ%q!vEz09uJnF- zr_h?vAdZK3LbmPRI8Ei!&&#b{-1GLC?ODwBg74D?@$^FrK3YtgGclXFLvNAaV;BE7 zk4kC^1DDNNzc9v?VOG1bYu>5I?P|3tR!M>@<}a|EGyoGp?;X!GDlr`m_rxr<{Bp_T+%_ z>1`W|HYH!Ip0n}y*URx%9N}^iPGT1=`=560KCn-TXZfkQk56q-JSnbfCP9mGRZOHDqJKOK!6d5w={xSs6`Dcq^LnFW&*rf*+6O; OfiMV2M+1v01_l7%fh2kW literal 0 HcmV?d00001 diff --git a/apps/api/src/cert-manager/__fixtures__/make-fixtures.sh b/apps/api/src/cert-manager/__fixtures__/make-fixtures.sh new file mode 100644 index 0000000..3581459 --- /dev/null +++ b/apps/api/src/cert-manager/__fixtures__/make-fixtures.sh @@ -0,0 +1,184 @@ +#!/usr/bin/env bash +# Test-only PKI for quick 261009-ikt (Zertifikat-Manager). +# Erzeugt alle Fixtures dieses Ordners mit OpenSSL (>= 3.4: -legacy, -not_before). +# Die Schluessel der CAs liegen nur in einem mktemp-Ordner, den ein trap loescht; +# committet werden nur Schluessel der Server-Zertifikate. +# Passwort aller geschuetzten Fixtures: Test-Pass-123 +# Aufruf: bash apps/api/src/cert-manager/__fixtures__/make-fixtures.sh +set -euo pipefail + +OUT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +T="$(mktemp -d)" +trap 'rm -rf "$T"' EXIT +PW='Test-Pass-123' +DAYS=36500 + +# Alte, erzeugte Dateien entfernen (Skript und README bleiben). +find "$OUT" -maxdepth 1 -type f ! -name make-fixtures.sh ! -name README.md -delete + +rsa_key() { openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out "$1" 2>/dev/null; } +ec_key() { openssl genpkey -algorithm EC -pkeyopt "ec_paramgen_curve:$2" -pkeyopt ec_param_enc:named_curve -out "$1" 2>/dev/null; } + +# csr +csr() { openssl req -new -key "$1" -subj "$2" -out "$3"; } + +# selfsign +selfsign() { + csr "$1" "$2" "$T/self.csr" + openssl x509 -req -in "$T/self.csr" -signkey "$1" -days "$DAYS" -extfile "$3" -out "$4" 2>/dev/null +} + +# sign [extra openssl x509 args] +sign() { + local c="$1" ic="$2" ik="$3" sn="$4" ext="$5" out="$6" + shift 6 + openssl x509 -req -in "$c" -CA "$ic" -CAkey "$ik" -set_serial "$sn" -extfile "$ext" "$@" -out "$out" 2>/dev/null +} + +ext_root() { + cat > "$1" <<'X' +basicConstraints = critical,CA:TRUE +keyUsage = critical,keyCertSign,cRLSign +subjectKeyIdentifier = hash +X +} + +ext_inter() { # [ski] + cat > "$1" < + cat > "$1" < "$T/ext-noaki" <<'X' +basicConstraints = critical,CA:FALSE +keyUsage = critical,digitalSignature,keyEncipherment +subjectKeyIdentifier = none +authorityKeyIdentifier = none +subjectAltName = DNS:noaki.example.test +X +csr "$T/rsa-leaf-noaki.key" "/O=Tessera Test/CN=noaki.example.test" "$T/noaki.csr" +sign "$T/noaki.csr" "$OUT/rsa-inter.pem" "$T/rsa-inter.key" 202 "$T/ext-noaki" "$OUT/rsa-leaf-noaki.pem" -days "$DAYS" + +# AIA-Adressen, die Tessera nie abrufen darf (Task 7) +ext_leaf "$T/ext-aia" "DNS:aia-private.example.test" "caIssuers;URI:http://127.0.0.1/inter.cer,caIssuers;URI:http://169.254.169.254/latest,caIssuers;URI:ldap://ldap.example.test/cn=inter" +csr "$T/rsa-self.key" "/O=Tessera Test/CN=aia-private.example.test" "$T/aia.csr" +sign "$T/aia.csr" "$OUT/rsa-inter.pem" "$T/rsa-inter.key" 203 "$T/ext-aia" "$OUT/aia-private-leaf.pem" -days "$DAYS" + +# selbstsigniert, keine CA +cat > "$T/ext-self" <<'X' +basicConstraints = critical,CA:FALSE +keyUsage = critical,digitalSignature,keyEncipherment +subjectKeyIdentifier = hash +subjectAltName = DNS:selfsigned.example.test +X +rsa_key "$T/rsa-self2.key" +selfsign "$T/rsa-self2.key" "/O=Tessera Test/CN=selfsigned.example.test" "$T/ext-self" "$OUT/selfsigned-leaf.pem" + +cat "$OUT/rsa-leaf.pem" "$OUT/rsa-inter.pem" "$OUT/rsa-root.pem" > "$OUT/rsa-fullchain.pem" +openssl crl2pkcs7 -nocrl -certfile "$OUT/rsa-leaf.pem" -certfile "$OUT/rsa-inter.pem" -certfile "$OUT/rsa-root.pem" -out "$OUT/rsa-chain.p7b" +openssl crl2pkcs7 -nocrl -certfile "$OUT/rsa-leaf.pem" -certfile "$OUT/rsa-inter.pem" -certfile "$OUT/rsa-root.pem" -outform DER -out "$OUT/rsa-chain.p7c" +openssl x509 -in "$OUT/rsa-leaf.pem" -trustout -addtrust serverAuth -out "$OUT/rsa-trusted.pem" + +# -------------------------------------------------------------------- EC PKI +ec_key "$T/ec-root.key" secp384r1 +ec_key "$T/ec-inter.key" secp384r1 +ec_key "$OUT/ec-leaf-key.pem" prime256v1 + +selfsign "$T/ec-root.key" "/O=Tessera Test/CN=Tessera Test Root EC" "$T/ext-root" "$OUT/ec-root.pem" +csr "$T/ec-inter.key" "/O=Tessera Test/CN=Tessera Test Inter EC" "$T/ec-inter.csr" +ext_inter "$T/ext-ec-inter" "http://pki.example.test/ec-root.cer" +sign "$T/ec-inter.csr" "$OUT/ec-root.pem" "$T/ec-root.key" 301 "$T/ext-ec-inter" "$OUT/ec-inter.pem" -days "$DAYS" +ext_leaf "$T/ext-ec-leaf" "DNS:ec.example.test" "caIssuers;URI:http://pki.example.test/ec-inter.cer" +csr "$OUT/ec-leaf-key.pem" "/O=Tessera Test/CN=ec.example.test" "$T/ec-leaf.csr" +sign "$T/ec-leaf.csr" "$OUT/ec-inter.pem" "$T/ec-inter.key" 401 "$T/ext-ec-leaf" "$OUT/ec-leaf.pem" -days "$DAYS" +openssl x509 -in "$OUT/ec-leaf.pem" -outform DER -out "$OUT/ec-leaf.cer" +cat "$OUT/ec-leaf.pem" "$OUT/ec-inter.pem" "$OUT/ec-root.pem" > "$OUT/ec-fullchain.pem" +openssl crl2pkcs7 -nocrl -certfile "$OUT/ec-leaf.pem" -certfile "$OUT/ec-inter.pem" -certfile "$OUT/ec-root.pem" -out "$OUT/ec-chain.p7b" + +# -------------------------------------------------- Schluessel in allen Formen +# RSA (rsa-leaf-key.pem ist PKCS#8, unverschluesselt) +openssl rsa -in "$OUT/rsa-leaf-key.pem" -traditional -out "$OUT/rsa-leaf-key-pkcs1.pem" 2>/dev/null +openssl pkcs8 -topk8 -in "$OUT/rsa-leaf-key.pem" -v2 aes-256-cbc -passout "pass:$PW" -out "$OUT/rsa-leaf-key-enc-pkcs8.pem" +openssl rsa -in "$OUT/rsa-leaf-key.pem" -traditional -aes256 -passout "pass:$PW" -out "$OUT/rsa-leaf-key-enc-trad.pem" 2>/dev/null +openssl pkcs8 -topk8 -nocrypt -in "$OUT/rsa-leaf-key.pem" -outform DER -out "$OUT/rsa-leaf-key-pkcs8.der" +openssl rsa -in "$OUT/rsa-leaf-key.pem" -traditional -outform DER -out "$OUT/rsa-leaf-key-pkcs1.der" 2>/dev/null +# EC (ec-leaf-key.pem ist PKCS#8, unverschluesselt) +openssl ec -in "$OUT/ec-leaf-key.pem" -out "$OUT/ec-leaf-key-sec1.pem" 2>/dev/null +openssl pkcs8 -topk8 -in "$OUT/ec-leaf-key.pem" -v1 PBE-SHA1-3DES -passout "pass:$PW" -out "$OUT/ec-leaf-key-enc-pkcs8.pem" +openssl ec -in "$OUT/ec-leaf-key.pem" -aes256 -passout "pass:$PW" -out "$OUT/ec-leaf-key-enc-trad.pem" 2>/dev/null +openssl ec -in "$OUT/ec-leaf-key.pem" -outform DER -out "$OUT/ec-leaf-key-sec1.der" 2>/dev/null +openssl pkcs8 -topk8 -in "$OUT/ec-leaf-key.pem" -v1 PBE-SHA1-3DES -passout "pass:$PW" -outform DER -out "$OUT/ec-leaf-key-enc-pkcs8.der" + +# ---------------------------------------------------------------------- CSRs +openssl req -new -key "$OUT/rsa-leaf-key.pem" -subj "/O=Tessera Test/CN=www.example.test" \ + -addext "subjectAltName=DNS:www.example.test,DNS:example.test" -out "$OUT/rsa-leaf.csr" +openssl req -in "$OUT/rsa-leaf.csr" -outform DER -out "$OUT/rsa-leaf.csr.der" +openssl req -new -key "$OUT/ec-leaf-key.pem" -subj "/O=Tessera Test/CN=ec.example.test" \ + -addext "subjectAltName=DNS:ec.example.test" -out "$OUT/ec-leaf.csr" +openssl req -in "$OUT/ec-leaf.csr" -outform DER -out "$OUT/ec-leaf.csr.der" + +# ---------------------------------------------------------------------- PFX +cat "$OUT/rsa-inter.pem" "$OUT/rsa-root.pem" > "$T/rsa-ca.pem" +cat "$OUT/ec-inter.pem" "$OUT/ec-root.pem" > "$T/ec-ca.pem" +COMPAT=(-certpbe PBE-SHA1-3DES -keypbe PBE-SHA1-3DES -macalg sha1) +openssl pkcs12 -export -inkey "$OUT/rsa-leaf-key.pem" -in "$OUT/rsa-leaf.pem" -certfile "$T/rsa-ca.pem" -passout "pass:$PW" -out "$OUT/rsa-modern.pfx" +openssl pkcs12 -export -inkey "$OUT/rsa-leaf-key.pem" -in "$OUT/rsa-leaf.pem" -certfile "$T/rsa-ca.pem" -passout "pass:$PW" "${COMPAT[@]}" -out "$OUT/rsa-compat.pfx" +openssl pkcs12 -export -legacy -inkey "$OUT/rsa-leaf-key.pem" -in "$OUT/rsa-leaf.pem" -certfile "$T/rsa-ca.pem" -passout "pass:$PW" -out "$OUT/rsa-legacy.pfx" +openssl pkcs12 -export -inkey "$OUT/rsa-leaf-key.pem" -in "$OUT/rsa-leaf.pem" -certfile "$T/rsa-ca.pem" -passout "pass:" -out "$OUT/rsa-nopass.pfx" +openssl pkcs12 -export -inkey "$OUT/ec-leaf-key.pem" -in "$OUT/ec-leaf.pem" -certfile "$T/ec-ca.pem" -passout "pass:$PW" -out "$OUT/ec-modern.pfx" +openssl pkcs12 -export -inkey "$OUT/ec-leaf-key.pem" -in "$OUT/ec-leaf.pem" -certfile "$T/ec-ca.pem" -passout "pass:$PW" "${COMPAT[@]}" -out "$OUT/ec-compat.pfx" +cp "$OUT/rsa-modern.pfx" "$OUT/rsa-modern.bin" + +# ----------------------------------------------------- ZIP mit Passwortschutz +( cd "$OUT" && zip -q -j -P "$PW" encrypted-entry.zip rsa-leaf.pem ) + +echo "fixtures ok: $(find "$OUT" -maxdepth 1 -type f | wc -l) files" diff --git a/apps/api/src/cert-manager/__fixtures__/rsa-chain.p7b b/apps/api/src/cert-manager/__fixtures__/rsa-chain.p7b new file mode 100644 index 0000000..aeb9578 --- /dev/null +++ b/apps/api/src/cert-manager/__fixtures__/rsa-chain.p7b @@ -0,0 +1,60 @@ +-----BEGIN PKCS7----- +MIIK2gYJKoZIhvcNAQcCoIIKyzCCCscCAQExADALBgkqhkiG9w0BBwGgggqvMIID +zTCCArWgAwIBAgICAMkwDQYJKoZIhvcNAQELBQAwODEVMBMGA1UECgwMVGVzc2Vy +YSBUZXN0MR8wHQYDVQQDDBZUZXNzZXJhIFRlc3QgSW50ZXIgUlNBMCAXDTI2MTAw +OTEyNDIwMVoYDzIxMjYwOTE1MTI0MjAxWjAyMRUwEwYDVQQKDAxUZXNzZXJhIFRl +c3QxGTAXBgNVBAMMEHd3dy5leGFtcGxlLnRlc3QwggEiMA0GCSqGSIb3DQEBAQUA +A4IBDwAwggEKAoIBAQChsylCqNQb5oizfylAouxYYV8zg+SyS7tgi+K4BhNxbfKK +siX3CU381IdnMxxhbZnjpO5BEsLGxXmFt9tDrleek4Cj+Jsr3bdGBhddAq1D6qyp +ovy7jbjGWzwk7RaXLtzLghWJNF+5ZPQxoNs46qFC5f6/CvsP6SWpNaQFr71Wa7sB +JTqCs4mTO6x4mhVB5938Ra817KbVBPnCWFkr15gEGr/h6b7Z4JpAUGwPX4nwx97/ +eVHUuZpuQQv9orPf7gdluGe9WMes+z2C1ffy+OpW2qXj3zeJddSsMZ/Z16gyV7gJ +7V6fkvuzIiF4ptbh2putG3VEoQHNch5XwUcjyoGPAgMBAAGjgeQwgeEwDAYDVR0T +AQH/BAIwADAOBgNVHQ8BAf8EBAMCBaAwEwYDVR0lBAwwCgYIKwYBBQUHAwEwHQYD +VR0OBBYEFJZb5QnoYaGiFQ+69CazBuUz4l8eMB8GA1UdIwQYMBaAFOHtu9D1f2Ic +pibbPD3+dpxA5G2fMCkGA1UdEQQiMCCCEHd3dy5leGFtcGxlLnRlc3SCDGV4YW1w +bGUudGVzdDBBBggrBgEFBQcBAQQ1MDMwMQYIKwYBBQUHMAKGJWh0dHA6Ly9wa2ku +ZXhhbXBsZS50ZXN0L3JzYS1pbnRlci5jZXIwDQYJKoZIhvcNAQELBQADggEBAIjy +KW27UJvII5osjVF23qEs+3VpxfLFTFGStFcOgfwyo4K7vxkLkHuI2zuKDzgJgJNq +VqA0E1iWrvVLYH98+neaHAO19Dhaf7XJeCGIIyHELKl3vunMUhzldF3sgZAptQls +9FcTmD0in5Kf+HGztndrLpuCK3Tk9nCH1/N/ijO3duZ21S6ZetgVGBxTmk9GiNaO +52ILdP/dfxRejtj+3NwofOCnoUhdu9TKn5Ynhwk//jYTNlmShpytTe3yfqKKF7Hy +CSZjsy/sp/4YQp97bOPbfKiz7GrLN11B8cG6E8xNeDkKDzjuiv2qKZ9DfzYxXAo3 +LtjpjzOkAWpOV/F5v+QwggOWMIICfqADAgECAgFlMA0GCSqGSIb3DQEBCwUAMDcx +FTATBgNVBAoMDFRlc3NlcmEgVGVzdDEeMBwGA1UEAwwVVGVzc2VyYSBUZXN0IFJv +b3QgUlNBMCAXDTI2MTAwOTEyNDIwMVoYDzIxMjYwOTE1MTI0MjAxWjA4MRUwEwYD +VQQKDAxUZXNzZXJhIFRlc3QxHzAdBgNVBAMMFlRlc3NlcmEgVGVzdCBJbnRlciBS +U0EwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDjgDk3CHFghsd3WsY8 +PVRCEd0jhvgz/AprSNB3hnloSPL6m9JXq+kyG0s/Yd37YiErARPfn3EqGkXsQizZ +m/Tf2p5/SJBNephu9cjNhAMvGwpE3svHL8YKrLC88LS/Bwqk4JGEXKSJhSwisoQf +AU3m5JacQZH3lzRzTGUnHGA2HwaS7U11oBfi8QA9ODQtT3pMg6cRpZgRzjsmTAuV +qKSPECKtO2JITwGR22AyxUeUK1aBrUTq5BPznwAE+kjsZLfe7KK8k4GjJihmEgAx +3yDow/xW07jqjMi45q4rg/g8sqV5W5ftSBi+G5WYTZCTSHRdrBCcg74zpOvEiygA +R3k9AgMBAAGjgakwgaYwEgYDVR0TAQH/BAgwBgEB/wIBADAOBgNVHQ8BAf8EBAMC +AQYwHQYDVR0OBBYEFOHtu9D1f2IcpibbPD3+dpxA5G2fMB8GA1UdIwQYMBaAFJxB +RfrmzUr809FjRO+YO/ZvZSIZMEAGCCsGAQUFBwEBBDQwMjAwBggrBgEFBQcwAoYk +aHR0cDovL3BraS5leGFtcGxlLnRlc3QvcnNhLXJvb3QuY2VyMA0GCSqGSIb3DQEB +CwUAA4IBAQAtnOFmZdM7sMOX92zrjCSv/oDWpHgu/RX4WsyiYW9u8j4sjvnXtvjb +jQ9KDVrTVKMTfTUZ9swW/+bMEfH6r4mQ6/BYPZVPdNN/pCFmNrc2SdgNo+/wUief +/8n1vz0NtYySw7wrC4ztuWqtkKL5NOPSIIH78ol/IsuF30UsAVYC+pLe0FbtfZ6M +LmbZ1sUCODwWhGNpHfYnBqwlvXkrzLnm8rORTFsWDfh14Poo02x7U2bkv4nRtbF8 +omPitJdpuxvcfxh8RvuE6AXWxpUfZpjL1sAjuTbS6DG3puvqspQOJLH5kP5zV5QU +0zOLUe4dWKR0Oq0RVgZW9+chW4oKbwpaMIIDQDCCAiigAwIBAgIUQS/6gR+OfRDy +zTjM/QZTaW68Yt0wDQYJKoZIhvcNAQELBQAwNzEVMBMGA1UECgwMVGVzc2VyYSBU +ZXN0MR4wHAYDVQQDDBVUZXNzZXJhIFRlc3QgUm9vdCBSU0EwIBcNMjYxMDA5MTI0 +MjAxWhgPMjEyNjA5MTUxMjQyMDFaMDcxFTATBgNVBAoMDFRlc3NlcmEgVGVzdDEe +MBwGA1UEAwwVVGVzc2VyYSBUZXN0IFJvb3QgUlNBMIIBIjANBgkqhkiG9w0BAQEF +AAOCAQ8AMIIBCgKCAQEArZUI8WKuFgKxnUmI9GmwrEXJc49ZnfD9JMhUFTAlMMxo +cQNoRFOa9S645qdx1pOPXLGvryoycX65bTwtqERwgNthvr5Mxrx9lVGy/KQQDR1S +5kjhaJ4Shvkuf/5G65WwLSl98oKdIjeldvq/ip5VOzvnlixbDCHkHBLV3o/RGQXm +jzM2Tp/CmdeCLhFWDQqJEwEZHsb9WMMHorPjR7374nNYnLGk2M2md5hXXpicnfnP +sCoQ54XYPc4oQ7Zk8Tf8rgEpHcmGLo54wD0V0WZN+1nT7KUrARbSl1HKC2FNp0f3 +VPFHSigkzbtsH0IPl+Hp4S9vSv2PUzlNxhLoHeblaQIDAQABo0IwQDAPBgNVHRMB +Af8EBTADAQH/MA4GA1UdDwEB/wQEAwIBBjAdBgNVHQ4EFgQUnEFF+ubNSvzT0WNE +75g79m9lIhkwDQYJKoZIhvcNAQELBQADggEBACacb52tb9EidZl9UKOCgr+03fmc +c9UFUv7U6Dh/K/d+BYyg8MW3msOd/oft8VVIA4FeK8UxJG9CMt/V08kcajYZ4rBr +tm3A2uM+O2V2KXUtymx17bOsOb4doFHgEPgWanraGXLOfp+1WCljQO4nkyT+peVb ++mX8QYh5DionRIviyJdeHUQtZ1lH+4iRS4hcs6qC64ARmE7rNnoPNYS8ANyQO/E0 +DZJmJiBCnWomMELjIdcZxCEONHvedeq9HgRtiEdd/r1GX/4leMmxAJMFkd8fsljr +8Gl9FVmsqw4f7G9Urf9nSOlQMwZGa2//179cs/FuDkz4TxVIfL/0aq8v//MxAA== +-----END PKCS7----- diff --git a/apps/api/src/cert-manager/__fixtures__/rsa-chain.p7c b/apps/api/src/cert-manager/__fixtures__/rsa-chain.p7c new file mode 100644 index 0000000000000000000000000000000000000000..7685803fa2f5a9fcf75c0a63a1e914dc92e74183 GIT binary patch literal 2782 zcmciEc{r478vyWmXDeeFOT!KQOcGMIELj@MP@O}T zNjSDnMG1YCHA2>`9VTTTXVmF@Ip66!-*x`@{(7(bexB=j-|M;W-}Ag671e{}*cnNT zT;K+_K(nc+W{`?{3BtJirucIXUNybTl;u~ogm(bT~$$egAlCPw>g+3^`of}WUf#5D2UL6nO z?1a&H9G<`qHGWTk_;0N(0&f4jHg8~HAeI#5=y}G2ge9|I3sM2;uRjq$Kwwk=4Poa| zP$~dGau`Ymx7tO=q8JnIUpSV0!i4^ehWX6=qEpV8wwqH7>Cg$jQ zeVG1EpZ`H)L-56l-aU7$ZzP4~EoNZ)DvXfZ9iT;fCJS%pu2x^EY_!*roe@gKzHX)p zMynsMIysNa?$w&iF&JglqgK!pa3WAf(8r@^ANQ{Zo3&S^d+BqoY#TVTMa znu_zlh2Yy#>_<7q9dmL8(Lc}2GmxWdL&tZ35*usCz=c7fux*1g)z3droWu&`dv$bK z0hxLuo-|NtV*(#s8kD5|NA0Lwn<}aQ<+T6+*8tT(+?N=HM#`Nglh5o{RXyYGx~crC zzJ87>t{W|ebtd_K-3aVnWj6uGbd2DEb?!@VK57}IXenhyGNzx*uFVj8pyh1Pl(v-YDBZ4ZTj zJBrf8c2C5q(E+!E)^ox2BW%zqY|!UFg9b={+9Oc&Tjq9vV(i+nJqvE~Z0_w%{vSF2 z^P}SLP!EUg(%kBEBJy#dU89b!r2$``Oyr{4D$1SsJTNl&G?BTS@zVOI3B2fm9~}Eu zoTM;-zyQr>=XZOj4U}JH%n$V3pb+Ct&!%~Oc+wgHQx!!S_BTIPZA2B8)Xd(k--4pQ zNr*T~kG`lZT^1n$n2wF4Wa=j@q^kRwkQBsD5G0U^Gp7F8+lS^Lx?1Wght8T@xXG8F z#`jEH-h?yx7Cnwvx=7oJcnC=7J%Mj9zJ{?1FEX4Q5qM97z?X^BCoB4=b8C{q^W+s> z_#wCf$?=D)R&AA&u}><;?qDt~>XhXN+o#SDh3iC<(@f)&h-8OC-pmViYV@f`mlYw# z!MeYrz72*KfczW6KB3+UBH5auz@KOX$gdRtCDN}IXX@`=9&6pd+V;ZP@Ygi$kKQC{ z5m4_7MK(=!5D$W1Vm3|L|3=f7-7VjesggP9LTb}4d6>H3F%>IY%nIwI2VvI)7wuYd z9lgDn-z#5T>bkesdj-9p+pf(rPasr7*mh;b=F*gw%+uiUYEN(Ht}H%hBJ1i%B?6qcS(KtrDXUDGnyjZd~sl}GGGN=PV9ef zH4}Ox7VGk=vjM85BNXB6D*jOcStwT_bm18%3 zI+Nm#mKGQ9#QU7D^3+keWq2m6*Rih7q_HM6*{p1p&dV)sK1LioeS<%82}@xaO(mD8 zD1|bqS<;&M0n7CLaMaA-2aigLI0FfPy zYc>zJceBolcV%s*Ij{$^UrkXwFbTqwbykI$JykAkawc10%0G&EGF{tXje=W{pmuVya zxcJzp>u0M5pdN_+Ln;DN#6I0_@|oUQMcyx@{jY}}&ZAQ6@AfTa z`gI`8S?%Lm6wJbTL~QnKLq+<-EY_u&xg$ha_%TcaPS)E1KhV+EB<4mC87gtV=Xt+p z_fDJg@{$HwZt|eP zuvC}GBPkyBbN&94wL9RRQN|9eTBGAExuB*}ND?ApK%&fMYSuMW(6;a=9*Jph%cAvD j#EHXdNF#Ue^{)D(j5#kJlf^@V#E|-Vw_?@x_c+LJ4SPXQ literal 0 HcmV?d00001 diff --git a/apps/api/src/cert-manager/__fixtures__/rsa-compat.pfx b/apps/api/src/cert-manager/__fixtures__/rsa-compat.pfx new file mode 100644 index 0000000000000000000000000000000000000000..db94f9ae77fa7ea1196403c7f0c3253b527dc3c1 GIT binary patch literal 4429 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 OB_%OIYs>$7$^QX-5Q60Z literal 0 HcmV?d00001 diff --git a/apps/api/src/cert-manager/__fixtures__/rsa-fullchain.pem b/apps/api/src/cert-manager/__fixtures__/rsa-fullchain.pem new file mode 100644 index 0000000..edf9a0b --- /dev/null +++ b/apps/api/src/cert-manager/__fixtures__/rsa-fullchain.pem @@ -0,0 +1,65 @@ +-----BEGIN CERTIFICATE----- +MIIDzTCCArWgAwIBAgICAMkwDQYJKoZIhvcNAQELBQAwODEVMBMGA1UECgwMVGVz +c2VyYSBUZXN0MR8wHQYDVQQDDBZUZXNzZXJhIFRlc3QgSW50ZXIgUlNBMCAXDTI2 +MTAwOTEyNDIwMVoYDzIxMjYwOTE1MTI0MjAxWjAyMRUwEwYDVQQKDAxUZXNzZXJh +IFRlc3QxGTAXBgNVBAMMEHd3dy5leGFtcGxlLnRlc3QwggEiMA0GCSqGSIb3DQEB +AQUAA4IBDwAwggEKAoIBAQChsylCqNQb5oizfylAouxYYV8zg+SyS7tgi+K4BhNx +bfKKsiX3CU381IdnMxxhbZnjpO5BEsLGxXmFt9tDrleek4Cj+Jsr3bdGBhddAq1D +6qypovy7jbjGWzwk7RaXLtzLghWJNF+5ZPQxoNs46qFC5f6/CvsP6SWpNaQFr71W +a7sBJTqCs4mTO6x4mhVB5938Ra817KbVBPnCWFkr15gEGr/h6b7Z4JpAUGwPX4nw +x97/eVHUuZpuQQv9orPf7gdluGe9WMes+z2C1ffy+OpW2qXj3zeJddSsMZ/Z16gy +V7gJ7V6fkvuzIiF4ptbh2putG3VEoQHNch5XwUcjyoGPAgMBAAGjgeQwgeEwDAYD +VR0TAQH/BAIwADAOBgNVHQ8BAf8EBAMCBaAwEwYDVR0lBAwwCgYIKwYBBQUHAwEw +HQYDVR0OBBYEFJZb5QnoYaGiFQ+69CazBuUz4l8eMB8GA1UdIwQYMBaAFOHtu9D1 +f2IcpibbPD3+dpxA5G2fMCkGA1UdEQQiMCCCEHd3dy5leGFtcGxlLnRlc3SCDGV4 +YW1wbGUudGVzdDBBBggrBgEFBQcBAQQ1MDMwMQYIKwYBBQUHMAKGJWh0dHA6Ly9w +a2kuZXhhbXBsZS50ZXN0L3JzYS1pbnRlci5jZXIwDQYJKoZIhvcNAQELBQADggEB +AIjyKW27UJvII5osjVF23qEs+3VpxfLFTFGStFcOgfwyo4K7vxkLkHuI2zuKDzgJ +gJNqVqA0E1iWrvVLYH98+neaHAO19Dhaf7XJeCGIIyHELKl3vunMUhzldF3sgZAp +tQls9FcTmD0in5Kf+HGztndrLpuCK3Tk9nCH1/N/ijO3duZ21S6ZetgVGBxTmk9G +iNaO52ILdP/dfxRejtj+3NwofOCnoUhdu9TKn5Ynhwk//jYTNlmShpytTe3yfqKK +F7HyCSZjsy/sp/4YQp97bOPbfKiz7GrLN11B8cG6E8xNeDkKDzjuiv2qKZ9DfzYx +XAo3LtjpjzOkAWpOV/F5v+Q= +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIIDljCCAn6gAwIBAgIBZTANBgkqhkiG9w0BAQsFADA3MRUwEwYDVQQKDAxUZXNz +ZXJhIFRlc3QxHjAcBgNVBAMMFVRlc3NlcmEgVGVzdCBSb290IFJTQTAgFw0yNjEw +MDkxMjQyMDFaGA8yMTI2MDkxNTEyNDIwMVowODEVMBMGA1UECgwMVGVzc2VyYSBU +ZXN0MR8wHQYDVQQDDBZUZXNzZXJhIFRlc3QgSW50ZXIgUlNBMIIBIjANBgkqhkiG +9w0BAQEFAAOCAQ8AMIIBCgKCAQEA44A5NwhxYIbHd1rGPD1UQhHdI4b4M/wKa0jQ +d4Z5aEjy+pvSV6vpMhtLP2Hd+2IhKwET359xKhpF7EIs2Zv039qef0iQTXqYbvXI +zYQDLxsKRN7Lxy/GCqywvPC0vwcKpOCRhFykiYUsIrKEHwFN5uSWnEGR95c0c0xl +JxxgNh8Gku1NdaAX4vEAPTg0LU96TIOnEaWYEc47JkwLlaikjxAirTtiSE8Bkdtg +MsVHlCtWga1E6uQT858ABPpI7GS33uyivJOBoyYoZhIAMd8g6MP8VtO46ozIuOau +K4P4PLKleVuX7UgYvhuVmE2Qk0h0XawQnIO+M6TrxIsoAEd5PQIDAQABo4GpMIGm +MBIGA1UdEwEB/wQIMAYBAf8CAQAwDgYDVR0PAQH/BAQDAgEGMB0GA1UdDgQWBBTh +7bvQ9X9iHKYm2zw9/nacQORtnzAfBgNVHSMEGDAWgBScQUX65s1K/NPRY0TvmDv2 +b2UiGTBABggrBgEFBQcBAQQ0MDIwMAYIKwYBBQUHMAKGJGh0dHA6Ly9wa2kuZXhh +bXBsZS50ZXN0L3JzYS1yb290LmNlcjANBgkqhkiG9w0BAQsFAAOCAQEALZzhZmXT +O7DDl/ds64wkr/6A1qR4Lv0V+FrMomFvbvI+LI7517b4240PSg1a01SjE301GfbM +Fv/mzBHx+q+JkOvwWD2VT3TTf6QhZja3NknYDaPv8FInn//J9b89DbWMksO8KwuM +7blqrZCi+TTj0iCB+/KJfyLLhd9FLAFWAvqS3tBW7X2ejC5m2dbFAjg8FoRjaR32 +JwasJb15K8y55vKzkUxbFg34deD6KNNse1Nm5L+J0bWxfKJj4rSXabsb3H8YfEb7 +hOgF1saVH2aYy9bAI7k20ugxt6br6rKUDiSx+ZD+c1eUFNMzi1HuHVikdDqtEVYG +VvfnIVuKCm8KWg== +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIIDQDCCAiigAwIBAgIUQS/6gR+OfRDyzTjM/QZTaW68Yt0wDQYJKoZIhvcNAQEL +BQAwNzEVMBMGA1UECgwMVGVzc2VyYSBUZXN0MR4wHAYDVQQDDBVUZXNzZXJhIFRl +c3QgUm9vdCBSU0EwIBcNMjYxMDA5MTI0MjAxWhgPMjEyNjA5MTUxMjQyMDFaMDcx +FTATBgNVBAoMDFRlc3NlcmEgVGVzdDEeMBwGA1UEAwwVVGVzc2VyYSBUZXN0IFJv +b3QgUlNBMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEArZUI8WKuFgKx +nUmI9GmwrEXJc49ZnfD9JMhUFTAlMMxocQNoRFOa9S645qdx1pOPXLGvryoycX65 +bTwtqERwgNthvr5Mxrx9lVGy/KQQDR1S5kjhaJ4Shvkuf/5G65WwLSl98oKdIjel +dvq/ip5VOzvnlixbDCHkHBLV3o/RGQXmjzM2Tp/CmdeCLhFWDQqJEwEZHsb9WMMH +orPjR7374nNYnLGk2M2md5hXXpicnfnPsCoQ54XYPc4oQ7Zk8Tf8rgEpHcmGLo54 +wD0V0WZN+1nT7KUrARbSl1HKC2FNp0f3VPFHSigkzbtsH0IPl+Hp4S9vSv2PUzlN +xhLoHeblaQIDAQABo0IwQDAPBgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIB +BjAdBgNVHQ4EFgQUnEFF+ubNSvzT0WNE75g79m9lIhkwDQYJKoZIhvcNAQELBQAD +ggEBACacb52tb9EidZl9UKOCgr+03fmcc9UFUv7U6Dh/K/d+BYyg8MW3msOd/oft +8VVIA4FeK8UxJG9CMt/V08kcajYZ4rBrtm3A2uM+O2V2KXUtymx17bOsOb4doFHg +EPgWanraGXLOfp+1WCljQO4nkyT+peVb+mX8QYh5DionRIviyJdeHUQtZ1lH+4iR +S4hcs6qC64ARmE7rNnoPNYS8ANyQO/E0DZJmJiBCnWomMELjIdcZxCEONHvedeq9 +HgRtiEdd/r1GX/4leMmxAJMFkd8fsljr8Gl9FVmsqw4f7G9Urf9nSOlQMwZGa2// +179cs/FuDkz4TxVIfL/0aq8v//M= +-----END CERTIFICATE----- diff --git a/apps/api/src/cert-manager/__fixtures__/rsa-inter-cross.pem b/apps/api/src/cert-manager/__fixtures__/rsa-inter-cross.pem new file mode 100644 index 0000000..9f0de9d --- /dev/null +++ b/apps/api/src/cert-manager/__fixtures__/rsa-inter-cross.pem @@ -0,0 +1,22 @@ +-----BEGIN CERTIFICATE----- +MIIDmDCCAoCgAwIBAgIBZjANBgkqhkiG9w0BAQsFADA5MRUwEwYDVQQKDAxUZXNz +ZXJhIFRlc3QxIDAeBgNVBAMMF1Rlc3NlcmEgVGVzdCBSb290IFJTQSAyMCAXDTI2 +MTAwOTEyNDIwMVoYDzIxMjYwOTE1MTI0MjAxWjA4MRUwEwYDVQQKDAxUZXNzZXJh +IFRlc3QxHzAdBgNVBAMMFlRlc3NlcmEgVGVzdCBJbnRlciBSU0EwggEiMA0GCSqG +SIb3DQEBAQUAA4IBDwAwggEKAoIBAQDjgDk3CHFghsd3WsY8PVRCEd0jhvgz/Apr +SNB3hnloSPL6m9JXq+kyG0s/Yd37YiErARPfn3EqGkXsQizZm/Tf2p5/SJBNephu +9cjNhAMvGwpE3svHL8YKrLC88LS/Bwqk4JGEXKSJhSwisoQfAU3m5JacQZH3lzRz +TGUnHGA2HwaS7U11oBfi8QA9ODQtT3pMg6cRpZgRzjsmTAuVqKSPECKtO2JITwGR +22AyxUeUK1aBrUTq5BPznwAE+kjsZLfe7KK8k4GjJihmEgAx3yDow/xW07jqjMi4 +5q4rg/g8sqV5W5ftSBi+G5WYTZCTSHRdrBCcg74zpOvEiygAR3k9AgMBAAGjgakw +gaYwEgYDVR0TAQH/BAgwBgEB/wIBADAOBgNVHQ8BAf8EBAMCAQYwHQYDVR0OBBYE +FOHtu9D1f2IcpibbPD3+dpxA5G2fMB8GA1UdIwQYMBaAFAWS7tXYkaYfZby55IDF +jWqB9JYsMEAGCCsGAQUFBwEBBDQwMjAwBggrBgEFBQcwAoYkaHR0cDovL3BraS5l +eGFtcGxlLnRlc3QvcnNhLXJvb3QuY2VyMA0GCSqGSIb3DQEBCwUAA4IBAQAgYACL +ZoSkNHNY6qb1DBF7x4tmiElh7gFQb1SMlNhRz26uk8nkuLyv/iN76WMMURb2iZ9D +LacWOFlJ6HVR5lmgJ9to10c3U8pWETbewV0Y4Y0/onw52xkdNOj18cOaNNSTYcUa +bVZf6aYiRVqzve2s2Eizk1/ERghiFYBDmibqmyoiunsWeQQlLJ3KpPoc4Os8+CRG +mDcxB5pPTzWDf4KT/O47SlSOLegRkyW0FOFRRInAPU6Xj9eATrw/p7oiApe57+Lb +95rJU7K1T4uLczv0AHkTmHdpOy5C0nkZ1+nvc6KOutStWu9h+9EPbV/iu4E1BYJC +JI5rO38ryDKo2/Lq +-----END CERTIFICATE----- diff --git a/apps/api/src/cert-manager/__fixtures__/rsa-inter-decoy.pem b/apps/api/src/cert-manager/__fixtures__/rsa-inter-decoy.pem new file mode 100644 index 0000000..3cd4a85 --- /dev/null +++ b/apps/api/src/cert-manager/__fixtures__/rsa-inter-decoy.pem @@ -0,0 +1,22 @@ +-----BEGIN CERTIFICATE----- +MIIDljCCAn6gAwIBAgIBaDANBgkqhkiG9w0BAQsFADA3MRUwEwYDVQQKDAxUZXNz +ZXJhIFRlc3QxHjAcBgNVBAMMFVRlc3NlcmEgVGVzdCBSb290IFJTQTAgFw0yNjEw +MDkxMjQyMDFaGA8yMTI2MDkxNTEyNDIwMVowODEVMBMGA1UECgwMVGVzc2VyYSBU +ZXN0MR8wHQYDVQQDDBZUZXNzZXJhIFRlc3QgSW50ZXIgUlNBMIIBIjANBgkqhkiG +9w0BAQEFAAOCAQ8AMIIBCgKCAQEA0GLXEIhbzwlL4jFZiPoG9gGlZt/wTltUoS8A +GGlqIznA5IHoOON+YOkcMw8DkU+UJHFbuQZwHAzDxiMf0hPPv7johdvyrsRrQAMG +fJ/xa3vdDVKE2O0w1g1q3ivJY7d9arfqxVqNhKpgKV3SoHqI9RUh1Li6wulxNWxv +f7OIp+NqfSab9/ocfuRkKDhZFsG11jDhUdO3hmMcqU0WgHbm04/tX1eXOU9l7cMM +E1S6m3yUYkC2qt75cdf4TE0UCaxGkiw+RKkTZ3vUzheFOFQXzI0SXcclKsaKckNf +iYkQLrUfBeXf5adGqZdRUO6kpIjPrYaSnaBayDu80Hp0DoZwXQIDAQABo4GpMIGm +MBIGA1UdEwEB/wQIMAYBAf8CAQAwDgYDVR0PAQH/BAQDAgEGMB0GA1UdDgQWBBTh +7bvQ9X9iHKYm2zw9/nacQORtnzAfBgNVHSMEGDAWgBScQUX65s1K/NPRY0TvmDv2 +b2UiGTBABggrBgEFBQcBAQQ0MDIwMAYIKwYBBQUHMAKGJGh0dHA6Ly9wa2kuZXhh +bXBsZS50ZXN0L3JzYS1yb290LmNlcjANBgkqhkiG9w0BAQsFAAOCAQEAUiEAAGEQ +08Uzdb8L4uB/llHWPe4/PuyXzNBtoO0HmG4XHhSDzXNkZIdwZDl1wqnkKGR+Nj0j +zvAu1c6DIvtN8ylsdAwgccyc1ge3edvjE11pgUimrKOblvA+l3ZjqqDvFeKoQkTn +IWHgdGa5EZY88IA96UYsMkud6QiBAZziUqqSv3a9uVvezfrvSyx3a02yEFTMXc1u +7Bxu+Sfc3/bLs/Yspobf2CqR6S9EPvQ41+WvNHv35OjU3ANtThxeLATJ4+F180Or +q3Vt2YdVighbxFId8A9/Nzh3ujRawOdV6J3P0w5LhHSpECYUyyOiwCk77uqNo4X2 +tDj3p1UEZoHeCg== +-----END CERTIFICATE----- diff --git a/apps/api/src/cert-manager/__fixtures__/rsa-inter-expired.pem b/apps/api/src/cert-manager/__fixtures__/rsa-inter-expired.pem new file mode 100644 index 0000000..5f3dcb2 --- /dev/null +++ b/apps/api/src/cert-manager/__fixtures__/rsa-inter-expired.pem @@ -0,0 +1,22 @@ +-----BEGIN CERTIFICATE----- +MIIDlDCCAnygAwIBAgIBZzANBgkqhkiG9w0BAQsFADA3MRUwEwYDVQQKDAxUZXNz +ZXJhIFRlc3QxHjAcBgNVBAMMFVRlc3NlcmEgVGVzdCBSb290IFJTQTAeFw0yMDAx +MDEwMDAwMDBaFw0yMTAxMDEwMDAwMDBaMDgxFTATBgNVBAoMDFRlc3NlcmEgVGVz +dDEfMB0GA1UEAwwWVGVzc2VyYSBUZXN0IEludGVyIFJTQTCCASIwDQYJKoZIhvcN +AQEBBQADggEPADCCAQoCggEBAOOAOTcIcWCGx3daxjw9VEIR3SOG+DP8CmtI0HeG +eWhI8vqb0ler6TIbSz9h3ftiISsBE9+fcSoaRexCLNmb9N/ann9IkE16mG71yM2E +Ay8bCkTey8cvxgqssLzwtL8HCqTgkYRcpImFLCKyhB8BTebklpxBkfeXNHNMZScc +YDYfBpLtTXWgF+LxAD04NC1PekyDpxGlmBHOOyZMC5WopI8QIq07YkhPAZHbYDLF +R5QrVoGtROrkE/OfAAT6SOxkt97soryTgaMmKGYSADHfIOjD/FbTuOqMyLjmriuD ++DyypXlbl+1IGL4blZhNkJNIdF2sEJyDvjOk68SLKABHeT0CAwEAAaOBqTCBpjAS +BgNVHRMBAf8ECDAGAQH/AgEAMA4GA1UdDwEB/wQEAwIBBjAdBgNVHQ4EFgQU4e27 +0PV/YhymJts8Pf52nEDkbZ8wHwYDVR0jBBgwFoAUnEFF+ubNSvzT0WNE75g79m9l +IhkwQAYIKwYBBQUHAQEENDAyMDAGCCsGAQUFBzAChiRodHRwOi8vcGtpLmV4YW1w +bGUudGVzdC9yc2Etcm9vdC5jZXIwDQYJKoZIhvcNAQELBQADggEBAIxVn/C/QXPn +4KJ0to2Zp1RyCF6A63+eMufEwN1E4IVkULfGn4I0hoIr3mfu8bXb+DLi2h6YZhUK +TBY1mzAYXrgAp909hOlUjwAvsQ5UIxlJJC3Ksb56JbjDvngDerdwxvahPwWfi/ct +t30fTfJ+T6acclubq8MFx0rmzxMsMv0ZyvKkwm7GZ1vJNmMq/oqLIvQlcDTeFzQw +TtrU78QlMwIoZ2fzU9H15z6ZIS+R/hzkCXCgwVUDVTlmR+tYUG45COJDgmf6VP7P +ayU6qx3yVYNku1k4fbTEiVKHnxtVUakOr4LQDTDVT32Dy4+NHH9wC0lXPqxSnuSm +Xa+kt19+8Jo= +-----END CERTIFICATE----- diff --git a/apps/api/src/cert-manager/__fixtures__/rsa-inter.pem b/apps/api/src/cert-manager/__fixtures__/rsa-inter.pem new file mode 100644 index 0000000..1eeb268 --- /dev/null +++ b/apps/api/src/cert-manager/__fixtures__/rsa-inter.pem @@ -0,0 +1,22 @@ +-----BEGIN CERTIFICATE----- +MIIDljCCAn6gAwIBAgIBZTANBgkqhkiG9w0BAQsFADA3MRUwEwYDVQQKDAxUZXNz +ZXJhIFRlc3QxHjAcBgNVBAMMFVRlc3NlcmEgVGVzdCBSb290IFJTQTAgFw0yNjEw +MDkxMjQyMDFaGA8yMTI2MDkxNTEyNDIwMVowODEVMBMGA1UECgwMVGVzc2VyYSBU +ZXN0MR8wHQYDVQQDDBZUZXNzZXJhIFRlc3QgSW50ZXIgUlNBMIIBIjANBgkqhkiG +9w0BAQEFAAOCAQ8AMIIBCgKCAQEA44A5NwhxYIbHd1rGPD1UQhHdI4b4M/wKa0jQ +d4Z5aEjy+pvSV6vpMhtLP2Hd+2IhKwET359xKhpF7EIs2Zv039qef0iQTXqYbvXI +zYQDLxsKRN7Lxy/GCqywvPC0vwcKpOCRhFykiYUsIrKEHwFN5uSWnEGR95c0c0xl +JxxgNh8Gku1NdaAX4vEAPTg0LU96TIOnEaWYEc47JkwLlaikjxAirTtiSE8Bkdtg +MsVHlCtWga1E6uQT858ABPpI7GS33uyivJOBoyYoZhIAMd8g6MP8VtO46ozIuOau +K4P4PLKleVuX7UgYvhuVmE2Qk0h0XawQnIO+M6TrxIsoAEd5PQIDAQABo4GpMIGm +MBIGA1UdEwEB/wQIMAYBAf8CAQAwDgYDVR0PAQH/BAQDAgEGMB0GA1UdDgQWBBTh +7bvQ9X9iHKYm2zw9/nacQORtnzAfBgNVHSMEGDAWgBScQUX65s1K/NPRY0TvmDv2 +b2UiGTBABggrBgEFBQcBAQQ0MDIwMAYIKwYBBQUHMAKGJGh0dHA6Ly9wa2kuZXhh +bXBsZS50ZXN0L3JzYS1yb290LmNlcjANBgkqhkiG9w0BAQsFAAOCAQEALZzhZmXT +O7DDl/ds64wkr/6A1qR4Lv0V+FrMomFvbvI+LI7517b4240PSg1a01SjE301GfbM +Fv/mzBHx+q+JkOvwWD2VT3TTf6QhZja3NknYDaPv8FInn//J9b89DbWMksO8KwuM +7blqrZCi+TTj0iCB+/KJfyLLhd9FLAFWAvqS3tBW7X2ejC5m2dbFAjg8FoRjaR32 +JwasJb15K8y55vKzkUxbFg34deD6KNNse1Nm5L+J0bWxfKJj4rSXabsb3H8YfEb7 +hOgF1saVH2aYy9bAI7k20ugxt6br6rKUDiSx+ZD+c1eUFNMzi1HuHVikdDqtEVYG +VvfnIVuKCm8KWg== +-----END CERTIFICATE----- diff --git a/apps/api/src/cert-manager/__fixtures__/rsa-leaf-key-enc-pkcs8.pem b/apps/api/src/cert-manager/__fixtures__/rsa-leaf-key-enc-pkcs8.pem new file mode 100644 index 0000000..b51f74f --- /dev/null +++ b/apps/api/src/cert-manager/__fixtures__/rsa-leaf-key-enc-pkcs8.pem @@ -0,0 +1,30 @@ +-----BEGIN ENCRYPTED PRIVATE KEY----- +MIIFNTBfBgkqhkiG9w0BBQ0wUjAxBgkqhkiG9w0BBQwwJAQQg5fXQz/YSEVEX28A +AdpmwgICCAAwDAYIKoZIhvcNAgkFADAdBglghkgBZQMEASoEEOKJk93MoScWJyo4 +N+rjX0sEggTQmNcQPcv0NB050XsfUuol1MtoaYl4w6/KfPib1U/AN8lUbcZy0E/5 +M22jc4bAj1MyodSSMzcUzzYpfzFlVXOBN2oy3fcuoUxn2b2qv4GUiNmUSudHnZIm +KFRswKxXjHrESgq/qoJdz9eZAx5zDeLX+eB7WhUB5RLYiv+g7edmarNump1gNpLb +8VSNLmh4b4dfk9snoktkP+gLZZ7oRw4FiQw33WJE0MF3oB+WSd1fvtpK2Vv+bND4 +VngNPIjM71n/jfBkIRDTNGWKkqILJekjcjDvaf9ICHbIoBKORWBnd9VbVdJM/DAW +u8hG+ynCl9HsAwkW3KH/nCrfKbmiVSA+gmzyRuEupQeVrYTET2zszZkQhLQFrCgO +FfiF38oW3+BQ0uwkEEctv+AT/qsBqBTg+9LvXQKIVuvLF0i0mhjRyIYc8I53FPyK +vycuMMEktmXP7kslY7ooYbH8E09DnzZU+V51rrEruox0WAmm/s1kF0LOPMDuB3cY +HqhsYBat8kNWDdnsMDTuwPottWj0JeB2nUALxrgYEMQUgPLkwiZgzr9ka5sS97gM +dzXfuaaFjvZU7jG9nQPriDftNtTXsspSiwyUFEgpiJF5E4QS/6bxupGwA6kTtU4k +i3q93ABKEMC7EuAO98RbeXPQe9pGY0n4F6K7fv2ORdPCUV1QvqDWbDHjxXxg/jLn +nc4VZ8OHdhcNj4oY0l3Bsttur/fzNzNBgwb1fW8vFTimJcyYSuB3eaLn3NYZi9Ff +8zM2qkmEuHz/TmzveOO3LSu5sTQ7opXPEjkXzFCVy0kqYD6KhAR7EnBHhufAzuyw +j1r1J23HUYoZBGpIPpyErm8Om+1EQGk0jmjPX8zM4jODgzpp1wyzQVnJcCJTBDfA +wHKZX1Q9twKtmFbzwvqPCZfgDWcah4aH3QvFhiV8l6fA36Bqv+cwzFPEAjW+WdbE +mQfzdgZN9LOzwNoru2zh4I3oOktRpolO0ljB+B84ZmmsKtWj2QZqv+KFnacZ1zku +KzHxiPzjZA+70xqMEtdo0aZ2KpqLR9BFXsXYWkEUaoKoIEeR71yfzzY2bHn8ghPn +e54aj2IJdaHldKku5udo88/xms9F8z8hfQ2xu3z64k7y4gl+NyMUsRz0G22D0tHe +PHFiauXP1ChsaMTVDmYuQV/QebV6/xyXmWvHYUbBJUcSdsXxVKu6ps4e0Pdr7Ax6 +DFOdqhXYjv2Ee1+9Fr0HoFLRIcdFbms2R63akZuh1PYIaomhM2enIWUA4vwIPKOz +FHweDGIbckC4uFLxrI96I7QMdwJeYwVN8JQkZWKYZSWz/ImBr4xfI3v/Ykaxn/Ho +rQVL3RPBr6UxvXesUPxqNJ0KuFml59lO3LS0X1o6JOBQMLeNoaxkxbJStzQXTovR +QNhHrcjXe/uzR6D+7d+b1x/+07YVXu35ngHhykLoFJKHNyLcsY+5x+JV5GC0nwu7 +tMv0IEQ3ghYb46QumEwZXZcSeOdR/PtKB1Z8T/hq1LwfFrdfjB9El6lykmR8cmY8 +MOj20JWjzpeT49TL8gNyoIzcgXoTx2N7N7Spk0JjXp2H9lRM9QhVtH4D+cH7ta/x +Ck91ivg9KD8yA6/ZyzXhaEWWJk/zQPpXCi05Cmi7qFgQP0N+N+X2WcM= +-----END ENCRYPTED PRIVATE KEY----- diff --git a/apps/api/src/cert-manager/__fixtures__/rsa-leaf-key-enc-trad.pem b/apps/api/src/cert-manager/__fixtures__/rsa-leaf-key-enc-trad.pem new file mode 100644 index 0000000..40582d2 --- /dev/null +++ b/apps/api/src/cert-manager/__fixtures__/rsa-leaf-key-enc-trad.pem @@ -0,0 +1,30 @@ +-----BEGIN RSA PRIVATE KEY----- +Proc-Type: 4,ENCRYPTED +DEK-Info: AES-256-CBC,442BE8C527B808926A2DF826DAF11BAC + +AKxmS2ayYesaUoL4UCf3uNtttP9epZklQaNSqC6Vvq6FeL4qxOLxw3tvWvDmCIKF +4lL+xSrmrIeZiXqraWR0Frtioy04yxNxDzZAMCA4hLkoLdR2mupmOq4OoSeP82Vn +NO4yNWAI1/qh3QvqilTLsUHINdyIgVae7r43WlfbUTrdI1iKnNe7GdKRMnUK1NDz +wFCLSeZ3Dd7lDh81BwM57u5qJ5tmY0JHj3rZYY8IHJDZNCQAKrNqqgrZiAiq7trs +t4PoaFiv4ShVuSpwP6twaGIQEqMkN56coy6h9ytGTSv7AesAORtY7fV7CQDo6BaQ +K9gaGAk8w7ehGsTyIZrz7CQX+P1UYUMX8iC+VdnFhMr0rdvgtp3T5/wlgTDn4S1g +kLqQqbfp3Ke+bjpaAiISh79Q7wRW04Q+uwL4eSqp0QLNDjsQXo0i5x9LzxN1NPhy +u5QcDXg6/TETgzDsrHC187uh/HoF2OlNRn0tX1Cft/vadq62ZZUX0k8OsuOBEGWb +sy3VDOOvirNkBeBGBpLsO8IQU4gQaOli5GsKMmrbV1tMfvzucScMUral3oHwOgoG +6Oae6U+R0Nr3a7dEPFl5+ahUGtJ8ebuzv2hoTq0eYz9PB13M2sOAOTN+DYUUmob0 +9ukPX5JgVWAfObK0KlppMPp3ZOl8n+rchU7CzW4hMVqpszkEUMJA+pipL3dl1jgD +aI5O5px3RxZA0zJYWn/hiX1d0FZ+/f/zUbXzcBm2kTjAOXxoh61d/8xiZZs47ft3 +0+VatRPqLH108tmWF/Pu2PYEqj01qlz0vVWdnhPA70J1QAh/KAEhZIFl2PYqGVaB +MKzQFdfTtec+vrtBb/V15l1oukgfdr2PUQpmPxHfp/PgGp8dta47uyOpwBFT11Ic +Ih5mik2UFZcS7jd4hVDqUb6Xt+GjUqFG7t+moWXKEsD+ACIC4gMQRKkMqSK76G/v +g1bikEOJkpdnZyoDtui24eF+5lcV1Bk0iKWoOUrQbmsYkWnPqDocdQBx4Iz3J0cZ +4+0nTeemZrSu4Rs6UoIogZkvuGFYGeM2Ao5mXH0uYbF5ZBBYzksAzxInz5fDA2I2 +Uv1Qx0Hq0SpqCSc03WZQoyo0TY43MufnYafJytgUsiaKh6W3IyQFETebKLvt7ehG +enQN22nxpLEUZJdvUuTomjC+VvoPsjGlfrgeX5qpkXHZG3Jc3meTIcSAORkHXFkf +OJcSXQqBv3cGq3vu8qKyfIR5aTVftaRXD5tXYmVzxQm6GFJST9zhuj48ptnNrEvC +3BudQK3nRXFsUEC4Bx6ZrcLdIZc0wkYyySI6h3ASZx5g/+nbT1RVi0joH//MyoYa +SOqhPtZexPxPHYmfcX8g0iDdfmumbcdHIiOG81ugBK4IDKa4I8+PTO/rJn0pV+Ky +EPKtcBFln54/HZf4sH8MMfIfqt6maREKJZpRLX1tGtFX+VQrEIUOMhILBVYIh+Sn +V6hmqSRfcUUhdpnOEYz747lNU0yOKpOguUwsg6VoKBH8rvsNU6V6RYzccU9BZSDX +aUawD9xq4bZ5DyW7TJQtKSTsUDeyZx14RZkIcMFYyAwQ9flmA438yiuqfozdatoT +-----END RSA PRIVATE KEY----- diff --git a/apps/api/src/cert-manager/__fixtures__/rsa-leaf-key-pkcs1.der b/apps/api/src/cert-manager/__fixtures__/rsa-leaf-key-pkcs1.der new file mode 100644 index 0000000000000000000000000000000000000000..2e9fa1543d322d14762e832710f959c0d6dff3ed GIT binary patch literal 1191 zcmV;Y1X%kpf&`-i0RRGm0RaG^vnfKT)EnlAvwtZ-qU=~Y+mA{=W+Q59uYTHKYZvy;f_x0VO(uvx$>C ztazFgLFe84MXxpNrqu-b!dO`=*O&wvzv1b=+2EQ$P;3uhiSWnX|9Mf=xteZ43;m+A z-|h!xxM#gs$E^E3g4Or(`07^LrQ_c>iFMShF`wDjs4`c$3GH5=lKZnFA$X?N;o6(6 z8+AmX0nKtASHVXk%7Kpp0|5X50)hbm67D>p@tE8oG5EuVPt-X2^K4>} z>1zF}iA6;VBl!9cJzc%!u8H!3Vmxx3jxZlSbN;ob1r>)$|3c3{Rt4_Ur`4B&+t_4= znx{cPFqo3Cfj)itTE%doC$4JLwyF-TQir~FEb8&#EdO)@ppzx3Xp5M#S*m%n7k4RPA znXwTk9U^y5p7=U*IW}T^JjO_ACa9=)0)c=Ow#q=}5=|Y3Sc}u{_@^TWAQy^P(U_Pl zOo?}^K!r~1aO8|rK!wwYcyG15PoC@uP2At?!n5J!Q)Eg#P|ww1v;qP+4{b|Br`3jc zoPx_t;bpZFf^7$~i7;wj@9yR~(gZdU&VOXjzaH;kpt7QGW!~KCh88j+ia*u_?P)Or zfq*f5gM~X_ac==1vc8Xo_dvZWcZWaP0w`If2_%>|-z&1F70N=35j*sZeE-C8`9(n8 zeWJ<3R+C{c+Py^+-mB0=(!l&#=~ux_eYOy}N#Y|GY2JtQwF)@8Ms)x+lWr=mXAc8R zbjZ_nvFWGT!;r{2D%8IThH9->a<8hJw?zVhfdI(Tq^P3(C|#PzE4>MwD;o-<8cpo8 z=L20C&LNQUrr!ay9qXGc{0)hbn0HL!fLa5Xm z=7_U@DL|s^SYcl?gXFSHyI_mrxCRq(ZSsn;CHDzU{M3hMGaO-Und7AHK@!5o#d(Fd z+e5Bbo|Ay1_?s);w?+mRT>`B`>a3}v{JV{~##=lj?G~3V+{=O$i8NoiWb`qh+c@f> zLgoIy3i}V~C8;%}1+TqUYr6p@I)bx_lRK<Au>>WT`=@Y3XouB zt1Zo7O1zH4s*CZM+#oUd!-h}PIQsK!Vvy--{i}&ZMGGVN`VT!_z2&Zn@`7SKa-5DZ zA3k&bwWkFYhe`iJ&p%cL?$f8$mx9~aWQLn3t*YycyE-tKlCXh3efi~Gk}>AO?>cJJ zKr)cF8Jy`eLFRQM&+cXbx=`CkklXvrBk6$-7$t6PvpX{;p9E;;V&LP{Z5yK(*LJ=L z4X+Xio!k_CIgDG_rKX^=IlX3jekeX8q*ygNnkA=(qOIpyBonEoA0_DRLQw*NfdJYr z+^=)#YhfuRvI3QL*MK)|BxAK5WZ+v0Ti3NpDn z9(b=)>nd^CH2`k=YtFVoI&=`~6W9&%aApQbTO8zRqrz?@foue`^<*r@W;DNTKNdsH zmB}rFJBpJ}EF+r|wTZI+UZk7U&8Y%`fdIX_3=ti*AAwGx7D2eOGUYF_W|<~h;?HWAK$ zWY51I?_i*^qHks1-0FrFG9rpU)&%WoF#>^rF?@rCJ795d0UxrykB0X^y()KyKiUE) zS)~aim^a@mvZfWvLW>bQ^o)G}#BupWK;3BNl1ihxD}yIJ!o405y|tDz0Y_159+t({-`wr`f}h$T}+2zX^tFtyglds++e( z0)c@5$kL>!qWvgcn#U`>37sn&3ZoiL?6c~1(y8sBj(}!D})clvMSJIr>qbC6Z$^fx% fdOfA8(y`3QJw1S^dFVHn5;2hpBuEEj&Kp1jBxzXK literal 0 HcmV?d00001 diff --git a/apps/api/src/cert-manager/__fixtures__/rsa-leaf-key.pem b/apps/api/src/cert-manager/__fixtures__/rsa-leaf-key.pem new file mode 100644 index 0000000..062859e --- /dev/null +++ b/apps/api/src/cert-manager/__fixtures__/rsa-leaf-key.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQChsylCqNQb5oiz +fylAouxYYV8zg+SyS7tgi+K4BhNxbfKKsiX3CU381IdnMxxhbZnjpO5BEsLGxXmF +t9tDrleek4Cj+Jsr3bdGBhddAq1D6qypovy7jbjGWzwk7RaXLtzLghWJNF+5ZPQx +oNs46qFC5f6/CvsP6SWpNaQFr71Wa7sBJTqCs4mTO6x4mhVB5938Ra817KbVBPnC +WFkr15gEGr/h6b7Z4JpAUGwPX4nwx97/eVHUuZpuQQv9orPf7gdluGe9WMes+z2C +1ffy+OpW2qXj3zeJddSsMZ/Z16gyV7gJ7V6fkvuzIiF4ptbh2putG3VEoQHNch5X +wUcjyoGPAgMBAAECggEAEvTQ+nqWWyr8jaZzrJss//NyWLS2o9xS/C+KBa7YqqyM +CYx2/O3/UYuM0CFiy10w9EoKkGBlqy3NYEq8jsOqi/GY3CAx+MOGT9Q4+vNsYpDp +av2riUVFCyP4+g89Xb3lronygmI8cpyOMB8+c/61pwUVh0n/Qs8/VgXu06fVl4Lb +2GSGmyetquuMuzowmJKwgT59+eVdkjHmwu86atNAMpC2GZzpM0HmdSPP7mYAulDb +R5Db+8wj6YEOGCVubbM7MyafBGjmYuDj1G0boxfXdr4IDa8SCJ3cFH05jFvYpaag +sjm9Znp+KD4jpFg1Opolp4airedZJBOpph8l6O1CUQKBgQDaLtyvc+lrYSklsgKV +ddeAN20kY7UdZOBbClvXtUoqRgRvn8W7PdfYbm4cEUgO8eoUUiwuJiAeeK9T6ypx +2TUAbvtrzrZBOnQQ6hPYDfJwZgZIWxzkaaPCbiOBbASz9WQsxmY0v20/FkPNlckt +gjuKk08sI5sStYmy/l6km9TNqQKBgQC9ugwRHbUhzry8/s5IoGehLZrkcJw/pfCK +str5R/tRfjcUGrjtx3J98e3fS9SJJWYExgU3QKNsYI/VSZBlX+6+LfS5MMdIx2K7 +Q4bpiuC9OnNxgKh6QLYXoPCP/57fy9HYeMrqj0hV3JmxEScdIndOnvg6czk2Ynw8 +xkhpJqiodwKBgBS2ykDmEk0dhliL0+74pyMHIBeKV9GYmCxMiXerQIVO7HDkjFNA +hdOIeG+1vE+e7AhN3N/swrPh5VNkSj1Qz9VgtAICOA9tS0On1YZ3nILLTOFltRKC +bQeyiTBqXu/u5jnSBDYRzn9kz78e72CgsqJvZd7c6oYWMiKKP9YE7WkxAoGAMXyD +hTtgcW8BH7K+j4b3QL0qd4c/2gIoWaUJJJg33yuyphXKQosRO/SMfP/EcflFQN19 +osnDVpNhMNq9RRPeq9BE0sD8WelXwUx9thC5SeIjFmneh/S1Cji6RnUANZNuKq5n +DwNMdMjTdbHpp9nDkMg6KtS/CYZqrVdyr6qbt0UCgYEAyNKkqKL9KF2axyu9CZ0r +GwqjGk3ss+cDXRnl8d7gSANgfwBO6Ald8yjLg0R9vfQNNgXo+pWfQWvLbziRzKNB +kQSrveY91I5AreiUwrsACL7Th1uc1PyXrFfSnNmjJwECygCxb3o9parSsczIPT2A +qHnoN5cSMZEJJEgHZM4bQAM= +-----END PRIVATE KEY----- diff --git a/apps/api/src/cert-manager/__fixtures__/rsa-leaf-noaki.pem b/apps/api/src/cert-manager/__fixtures__/rsa-leaf-noaki.pem new file mode 100644 index 0000000..da0b783 --- /dev/null +++ b/apps/api/src/cert-manager/__fixtures__/rsa-leaf-noaki.pem @@ -0,0 +1,19 @@ +-----BEGIN CERTIFICATE----- +MIIDKTCCAhGgAwIBAgICAMowDQYJKoZIhvcNAQELBQAwODEVMBMGA1UECgwMVGVz +c2VyYSBUZXN0MR8wHQYDVQQDDBZUZXNzZXJhIFRlc3QgSW50ZXIgUlNBMCAXDTI2 +MTAwOTEyNDIwMloYDzIxMjYwOTE1MTI0MjAyWjA0MRUwEwYDVQQKDAxUZXNzZXJh +IFRlc3QxGzAZBgNVBAMMEm5vYWtpLmV4YW1wbGUudGVzdDCCASIwDQYJKoZIhvcN +AQEBBQADggEPADCCAQoCggEBAKw9J4MOPalTZs02fV5vOYgmrXQe26Cqteidu222 +R+j9xXr9jT0wc6psHQK+mNy4bxX3vjp9d7YbGdwBYl30vU6Lu4hDr9499xR3sPAt +1iO4adHwnvWZ/wiMkZ2PqCoWc7F0m9cfg2o1o4Qr7OtVzgpFU3HeHA3Ny+iR4YoD +ObBfjFe/OVyBm/BFPXGmrlHCgGku7evMcXEm7xRv5q2c/EThxNidsqFOUptsSZM2 +e4TqblsuskZVchJMVQ3vp+tNomAwnRq7tf+LRjNN1fYsr7nXHmbxI0bkc0Xl/rGy +W4qZNXVUQ9X6dE1fMKD0aELsip9zKVl4LLAZTtGvwDEuIXcCAwEAAaM/MD0wDAYD +VR0TAQH/BAIwADAOBgNVHQ8BAf8EBAMCBaAwHQYDVR0RBBYwFIISbm9ha2kuZXhh +bXBsZS50ZXN0MA0GCSqGSIb3DQEBCwUAA4IBAQCaA/bLiHvN6SH7iRrS6qsn1R1y +EBTvluor+Mtzh/IluoFw8mpgBxdLWN0dlTrTKtk2QmrBQx7ci4iJ31DVlXsZBE7a +Vr+OvPsiqfPz9alocxJHBeHeDOVBTup91vatq113bmMwGt5GLUdBcOtIznbMrmgj +Uj3ILK7FO6iipudVEDh6tJknGdSUa8g71RWymHQIfIaQTfu1zbP8R5PdSpdFEHpm +mUJLqTLKfYlf3FYirbPgT4XP761TAXgXgGnqJN7PElE+wTlCrPeHeA0Hd9Eep2dq +UDG+Nq6CeHz1nnx5V929BfO8x14YZvSCBRZW0oFCpQE6btL1IU5kPfd1YbPZ +-----END CERTIFICATE----- diff --git a/apps/api/src/cert-manager/__fixtures__/rsa-leaf.cer b/apps/api/src/cert-manager/__fixtures__/rsa-leaf.cer new file mode 100644 index 0000000000000000000000000000000000000000..92d52df787596f50ae5b0832371ba1583086c105 GIT binary patch literal 977 zcmXqLVm@oo#I$t*GZP~d6BENp170>xtu~Lg@4SqR+^h@+7KWk*!fec;EL=Q1A*sd1 zsYQtjK)S?G-ar;4%*-Q(EUe&}SCU$!5ESfapdij`WM*h!U}93&B{f8cK7Z$7Hy;QR&2W7ozqRC zohI=+Q@$83xNY%jq0`fU`?-Gezf@gmx`cK8-mvW5jH*^mn>#04uc??N>iGQbAJ_G! zZ-hvWDDR|a0$IV;bR`|qO7_usLn?nvJo zaeU2h+or4EKmB+Wc5CV5`{tdcSJoKLzj=LyQTPtdw{i0){obskSh4Kd!&|f0N|(AU zWIS6W7kLfkG2T+%@r_#H1t4T1G||Qv+iI zLm1zHsZBMbq@=(~U%wzb6D9ifi;5F%-tO@`-Ji= zo!-E*dkb}bmu4RQbkrws(w1<(#y>`jn|AM)%Mv? z)YtqfpC!Y*^@~MR{nnEeiXF;|M|4(}?|XSBNakrt?3>03np-(@zJv?UuvMBrY5tGG z&D+Ye^=3C|mpu7a(0=`MeV6g}vS(#i^=4Mx5S5S#p5^b>ajoxp5_ie}yY(V*eK-Ey zxua3@VEICi*xgr7&7Y>;&T0S8OxP@PQrn!hzHdL(E$R~A_=!_3d9(hT<^Lp{=2z!D zzFo6o^P8;G=CO_+5AG5^<6B|L#c%Ph>+dSf`OfubhA~{`dN*G78!utZ@(cf1x&H|O DWHEm& literal 0 HcmV?d00001 diff --git a/apps/api/src/cert-manager/__fixtures__/rsa-leaf.csr b/apps/api/src/cert-manager/__fixtures__/rsa-leaf.csr new file mode 100644 index 0000000..66ea43f --- /dev/null +++ b/apps/api/src/cert-manager/__fixtures__/rsa-leaf.csr @@ -0,0 +1,17 @@ +-----BEGIN CERTIFICATE REQUEST----- +MIICszCCAZsCAQAwMjEVMBMGA1UECgwMVGVzc2VyYSBUZXN0MRkwFwYDVQQDDBB3 +d3cuZXhhbXBsZS50ZXN0MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA +obMpQqjUG+aIs38pQKLsWGFfM4Pksku7YIviuAYTcW3yirIl9wlN/NSHZzMcYW2Z +46TuQRLCxsV5hbfbQ65XnpOAo/ibK923RgYXXQKtQ+qsqaL8u424xls8JO0Wly7c +y4IViTRfuWT0MaDbOOqhQuX+vwr7D+klqTWkBa+9Vmu7ASU6grOJkzuseJoVQefd +/EWvNeym1QT5wlhZK9eYBBq/4em+2eCaQFBsD1+J8Mfe/3lR1LmabkEL/aKz3+4H +ZbhnvVjHrPs9gtX38vjqVtql4983iXXUrDGf2deoMle4Ce1en5L7syIheKbW4dqb +rRt1RKEBzXIeV8FHI8qBjwIDAQABoDwwOgYJKoZIhvcNAQkOMS0wKzApBgNVHREE +IjAgghB3d3cuZXhhbXBsZS50ZXN0ggxleGFtcGxlLnRlc3QwDQYJKoZIhvcNAQEL +BQADggEBAHIZISAzaPkw4XSO3KEg2Ody+RJW9+OMytWhpI5ntWqg9j/7vFHYt/eY +TgA9cIHzlTyKwTdm9WH2nr0hLwucFo7h5ospdnbsn9XTeYSTNvvls8U2Fx+IcIsh +nV2oj0M6KTC86vT7xBp4i3W+WFtrBIG8Ptdq8wiNBgaIPyUce2HO4UC1rjVmhEzH +o8Ccd+7GXkmQWP3HFqPUn5B9LjAiqg5LDwf12dEc0vIyi5MMtRDgHX5YzcAdbWgf +TXGXZaIzbHPsJveC9yRBl3rD8Fr0ydMUFu8OhrSUiCeTvEijlzOhHv2i1jA81+1Y +ajhWgXtxKNPaKM2MWRo9CNQMlGJTTWk= +-----END CERTIFICATE REQUEST----- diff --git a/apps/api/src/cert-manager/__fixtures__/rsa-leaf.csr.der b/apps/api/src/cert-manager/__fixtures__/rsa-leaf.csr.der new file mode 100644 index 0000000000000000000000000000000000000000..dbca78837989b891ad0513fe832139c17bae3ba4 GIT binary patch literal 695 zcmXqLV%lub#5kLYk-@;oP}D$}jX9Ksi-#v9wYWI7C{Y1Oml#SKh=YWgc?8PK%k@$# z5_1c3QuRuJQb4Ur2E1&XT5TR}-+37s8Ce;an;7{SfZ|+CO^l2T3pZ;zt+*ontYdS% zro*B)5sC4}%}+LY?@s7`w1Z8!F!xi}Ce`npzJIQ?ryI*8=FWV)L>uv$gMTcViQeWm@a}YR$?;e|GooI2LWA@>XoR-ksA;qMau3J5#Td%2@CF=P6?jP6nrf-&AW%+q1B2xSM z3>K;V4`1%P`CyhqKn{O==ZE9>{#OQG**PoEk^AqW&G+B2r|wAK8*zNiZ`-D;-#`6$ z6?SXsvs88PGv3R=iLb<<-t{K=|e;biy5!P5;sB!t0#@U`oDO-*!JX4Z_ GeKP@?4mUag literal 0 HcmV?d00001 diff --git a/apps/api/src/cert-manager/__fixtures__/rsa-leaf.pem b/apps/api/src/cert-manager/__fixtures__/rsa-leaf.pem new file mode 100644 index 0000000..e9029ef --- /dev/null +++ b/apps/api/src/cert-manager/__fixtures__/rsa-leaf.pem @@ -0,0 +1,23 @@ +-----BEGIN CERTIFICATE----- +MIIDzTCCArWgAwIBAgICAMkwDQYJKoZIhvcNAQELBQAwODEVMBMGA1UECgwMVGVz +c2VyYSBUZXN0MR8wHQYDVQQDDBZUZXNzZXJhIFRlc3QgSW50ZXIgUlNBMCAXDTI2 +MTAwOTEyNDIwMVoYDzIxMjYwOTE1MTI0MjAxWjAyMRUwEwYDVQQKDAxUZXNzZXJh +IFRlc3QxGTAXBgNVBAMMEHd3dy5leGFtcGxlLnRlc3QwggEiMA0GCSqGSIb3DQEB +AQUAA4IBDwAwggEKAoIBAQChsylCqNQb5oizfylAouxYYV8zg+SyS7tgi+K4BhNx +bfKKsiX3CU381IdnMxxhbZnjpO5BEsLGxXmFt9tDrleek4Cj+Jsr3bdGBhddAq1D +6qypovy7jbjGWzwk7RaXLtzLghWJNF+5ZPQxoNs46qFC5f6/CvsP6SWpNaQFr71W +a7sBJTqCs4mTO6x4mhVB5938Ra817KbVBPnCWFkr15gEGr/h6b7Z4JpAUGwPX4nw +x97/eVHUuZpuQQv9orPf7gdluGe9WMes+z2C1ffy+OpW2qXj3zeJddSsMZ/Z16gy +V7gJ7V6fkvuzIiF4ptbh2putG3VEoQHNch5XwUcjyoGPAgMBAAGjgeQwgeEwDAYD +VR0TAQH/BAIwADAOBgNVHQ8BAf8EBAMCBaAwEwYDVR0lBAwwCgYIKwYBBQUHAwEw +HQYDVR0OBBYEFJZb5QnoYaGiFQ+69CazBuUz4l8eMB8GA1UdIwQYMBaAFOHtu9D1 +f2IcpibbPD3+dpxA5G2fMCkGA1UdEQQiMCCCEHd3dy5leGFtcGxlLnRlc3SCDGV4 +YW1wbGUudGVzdDBBBggrBgEFBQcBAQQ1MDMwMQYIKwYBBQUHMAKGJWh0dHA6Ly9w +a2kuZXhhbXBsZS50ZXN0L3JzYS1pbnRlci5jZXIwDQYJKoZIhvcNAQELBQADggEB +AIjyKW27UJvII5osjVF23qEs+3VpxfLFTFGStFcOgfwyo4K7vxkLkHuI2zuKDzgJ +gJNqVqA0E1iWrvVLYH98+neaHAO19Dhaf7XJeCGIIyHELKl3vunMUhzldF3sgZAp +tQls9FcTmD0in5Kf+HGztndrLpuCK3Tk9nCH1/N/ijO3duZ21S6ZetgVGBxTmk9G +iNaO52ILdP/dfxRejtj+3NwofOCnoUhdu9TKn5Ynhwk//jYTNlmShpytTe3yfqKK +F7HyCSZjsy/sp/4YQp97bOPbfKiz7GrLN11B8cG6E8xNeDkKDzjuiv2qKZ9DfzYx +XAo3LtjpjzOkAWpOV/F5v+Q= +-----END CERTIFICATE----- diff --git a/apps/api/src/cert-manager/__fixtures__/rsa-legacy.pfx b/apps/api/src/cert-manager/__fixtures__/rsa-legacy.pfx new file mode 100644 index 0000000000000000000000000000000000000000..aaf7d2281868cdd01666c4fb58312321360925ee GIT binary patch literal 4429 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 QdNPgWfx`xC{$0uc0s&`T&;S4c literal 0 HcmV?d00001 diff --git a/apps/api/src/cert-manager/__fixtures__/rsa-modern.bin b/apps/api/src/cert-manager/__fixtures__/rsa-modern.bin new file mode 100644 index 0000000000000000000000000000000000000000..218aa587a352207011ec171d6944790b6ad09ba9 GIT binary patch literal 4595 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 literal 0 HcmV?d00001 diff --git a/apps/api/src/cert-manager/__fixtures__/rsa-root.pem b/apps/api/src/cert-manager/__fixtures__/rsa-root.pem new file mode 100644 index 0000000..bf17c64 --- /dev/null +++ b/apps/api/src/cert-manager/__fixtures__/rsa-root.pem @@ -0,0 +1,20 @@ +-----BEGIN CERTIFICATE----- +MIIDQDCCAiigAwIBAgIUQS/6gR+OfRDyzTjM/QZTaW68Yt0wDQYJKoZIhvcNAQEL +BQAwNzEVMBMGA1UECgwMVGVzc2VyYSBUZXN0MR4wHAYDVQQDDBVUZXNzZXJhIFRl +c3QgUm9vdCBSU0EwIBcNMjYxMDA5MTI0MjAxWhgPMjEyNjA5MTUxMjQyMDFaMDcx +FTATBgNVBAoMDFRlc3NlcmEgVGVzdDEeMBwGA1UEAwwVVGVzc2VyYSBUZXN0IFJv +b3QgUlNBMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEArZUI8WKuFgKx +nUmI9GmwrEXJc49ZnfD9JMhUFTAlMMxocQNoRFOa9S645qdx1pOPXLGvryoycX65 +bTwtqERwgNthvr5Mxrx9lVGy/KQQDR1S5kjhaJ4Shvkuf/5G65WwLSl98oKdIjel +dvq/ip5VOzvnlixbDCHkHBLV3o/RGQXmjzM2Tp/CmdeCLhFWDQqJEwEZHsb9WMMH +orPjR7374nNYnLGk2M2md5hXXpicnfnPsCoQ54XYPc4oQ7Zk8Tf8rgEpHcmGLo54 +wD0V0WZN+1nT7KUrARbSl1HKC2FNp0f3VPFHSigkzbtsH0IPl+Hp4S9vSv2PUzlN +xhLoHeblaQIDAQABo0IwQDAPBgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIB +BjAdBgNVHQ4EFgQUnEFF+ubNSvzT0WNE75g79m9lIhkwDQYJKoZIhvcNAQELBQAD +ggEBACacb52tb9EidZl9UKOCgr+03fmcc9UFUv7U6Dh/K/d+BYyg8MW3msOd/oft +8VVIA4FeK8UxJG9CMt/V08kcajYZ4rBrtm3A2uM+O2V2KXUtymx17bOsOb4doFHg +EPgWanraGXLOfp+1WCljQO4nkyT+peVb+mX8QYh5DionRIviyJdeHUQtZ1lH+4iR +S4hcs6qC64ARmE7rNnoPNYS8ANyQO/E0DZJmJiBCnWomMELjIdcZxCEONHvedeq9 +HgRtiEdd/r1GX/4leMmxAJMFkd8fsljr8Gl9FVmsqw4f7G9Urf9nSOlQMwZGa2// +179cs/FuDkz4TxVIfL/0aq8v//M= +-----END CERTIFICATE----- diff --git a/apps/api/src/cert-manager/__fixtures__/rsa-root2.pem b/apps/api/src/cert-manager/__fixtures__/rsa-root2.pem new file mode 100644 index 0000000..ffe40d2 --- /dev/null +++ b/apps/api/src/cert-manager/__fixtures__/rsa-root2.pem @@ -0,0 +1,20 @@ +-----BEGIN CERTIFICATE----- +MIIDRDCCAiygAwIBAgIUdyC8QeOCAMFyciFeju4zpGlnkNQwDQYJKoZIhvcNAQEL +BQAwOTEVMBMGA1UECgwMVGVzc2VyYSBUZXN0MSAwHgYDVQQDDBdUZXNzZXJhIFRl +c3QgUm9vdCBSU0EgMjAgFw0yNjEwMDkxMjQyMDFaGA8yMTI2MDkxNTEyNDIwMVow +OTEVMBMGA1UECgwMVGVzc2VyYSBUZXN0MSAwHgYDVQQDDBdUZXNzZXJhIFRlc3Qg +Um9vdCBSU0EgMjCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAOKsZ0yL +jIeKFN0WDvf0JMzCwaCKIMw6QB7sUImBKycDtitODNo9Wyq0q3FwiUgC7RTxfBDK +y8T9ZyXGSIR86lL+foKrMZAChbTQSm85b1AAKoR2ejcMUjlX9pky7h82yuFdmNj0 +m2R5iX1DRvMkRUwh187jkrWBOoN9j6E+3DXXWxEofgji7PNyPhV641bSim7haf6o +lLvTBSachZzrD5clSVznYuaxtETyVJjWdDq1+QpNS+wuDBgcgm4Hvy6XqhQRLfV9 +Msv0MntnLb/Fjsw18AVpDtf37QjyZUkPBONZb6ePSrEhT7OIbit+zf22m4EcQS5N +ct277z/Z27cVfCsCAwEAAaNCMEAwDwYDVR0TAQH/BAUwAwEB/zAOBgNVHQ8BAf8E +BAMCAQYwHQYDVR0OBBYEFAWS7tXYkaYfZby55IDFjWqB9JYsMA0GCSqGSIb3DQEB +CwUAA4IBAQCz2BSC7UW2vD8/ZGGv9uduU9LfzyF4IaPB+HZLoYhpoDqdW/PMAQqY +AAFnadKTnVkXr6oeJDxU3jKhfEsvSDyzWS6XIsd48H8VCwItkspUhbX4wZzBPdLl +8jSr7wMg9GGIEChhtzZSyhwlfFrQRetDIOF7x6lKvOWsErIaWGHadC/6qBOEKRYV +tEdhxQGUt2q14vMTPfhgx7f9YZAgYSBnQhXNrrTkDlDwprmZiqdQxujdnPPG/FBU ++0TV6PG2EKp2+lZGoEeSGp4WlVAhGLJ/1FCKt/p36/6vfXSJYxi+dQRnb12QiMD7 ++AeksQrRrPSIR57eSTsUcNJ3321Mduv7 +-----END CERTIFICATE----- diff --git a/apps/api/src/cert-manager/__fixtures__/rsa-trusted.pem b/apps/api/src/cert-manager/__fixtures__/rsa-trusted.pem new file mode 100644 index 0000000..179c674 --- /dev/null +++ b/apps/api/src/cert-manager/__fixtures__/rsa-trusted.pem @@ -0,0 +1,23 @@ +-----BEGIN TRUSTED CERTIFICATE----- +MIIDzTCCArWgAwIBAgICAMkwDQYJKoZIhvcNAQELBQAwODEVMBMGA1UECgwMVGVz +c2VyYSBUZXN0MR8wHQYDVQQDDBZUZXNzZXJhIFRlc3QgSW50ZXIgUlNBMCAXDTI2 +MTAwOTEyNDIwMVoYDzIxMjYwOTE1MTI0MjAxWjAyMRUwEwYDVQQKDAxUZXNzZXJh +IFRlc3QxGTAXBgNVBAMMEHd3dy5leGFtcGxlLnRlc3QwggEiMA0GCSqGSIb3DQEB +AQUAA4IBDwAwggEKAoIBAQChsylCqNQb5oizfylAouxYYV8zg+SyS7tgi+K4BhNx +bfKKsiX3CU381IdnMxxhbZnjpO5BEsLGxXmFt9tDrleek4Cj+Jsr3bdGBhddAq1D +6qypovy7jbjGWzwk7RaXLtzLghWJNF+5ZPQxoNs46qFC5f6/CvsP6SWpNaQFr71W +a7sBJTqCs4mTO6x4mhVB5938Ra817KbVBPnCWFkr15gEGr/h6b7Z4JpAUGwPX4nw +x97/eVHUuZpuQQv9orPf7gdluGe9WMes+z2C1ffy+OpW2qXj3zeJddSsMZ/Z16gy +V7gJ7V6fkvuzIiF4ptbh2putG3VEoQHNch5XwUcjyoGPAgMBAAGjgeQwgeEwDAYD +VR0TAQH/BAIwADAOBgNVHQ8BAf8EBAMCBaAwEwYDVR0lBAwwCgYIKwYBBQUHAwEw +HQYDVR0OBBYEFJZb5QnoYaGiFQ+69CazBuUz4l8eMB8GA1UdIwQYMBaAFOHtu9D1 +f2IcpibbPD3+dpxA5G2fMCkGA1UdEQQiMCCCEHd3dy5leGFtcGxlLnRlc3SCDGV4 +YW1wbGUudGVzdDBBBggrBgEFBQcBAQQ1MDMwMQYIKwYBBQUHMAKGJWh0dHA6Ly9w +a2kuZXhhbXBsZS50ZXN0L3JzYS1pbnRlci5jZXIwDQYJKoZIhvcNAQELBQADggEB +AIjyKW27UJvII5osjVF23qEs+3VpxfLFTFGStFcOgfwyo4K7vxkLkHuI2zuKDzgJ +gJNqVqA0E1iWrvVLYH98+neaHAO19Dhaf7XJeCGIIyHELKl3vunMUhzldF3sgZAp +tQls9FcTmD0in5Kf+HGztndrLpuCK3Tk9nCH1/N/ijO3duZ21S6ZetgVGBxTmk9G +iNaO52ILdP/dfxRejtj+3NwofOCnoUhdu9TKn5Ynhwk//jYTNlmShpytTe3yfqKK +F7HyCSZjsy/sp/4YQp97bOPbfKiz7GrLN11B8cG6E8xNeDkKDzjuiv2qKZ9DfzYx +XAo3LtjpjzOkAWpOV/F5v+QwDDAKBggrBgEFBQcDAQ== +-----END TRUSTED CERTIFICATE----- diff --git a/apps/api/src/cert-manager/__fixtures__/selfsigned-leaf.pem b/apps/api/src/cert-manager/__fixtures__/selfsigned-leaf.pem new file mode 100644 index 0000000..08d1fde --- /dev/null +++ b/apps/api/src/cert-manager/__fixtures__/selfsigned-leaf.pem @@ -0,0 +1,21 @@ +-----BEGIN CERTIFICATE----- +MIIDZTCCAk2gAwIBAgIUVZCxv3SnAxqUosl2K2FJSr6mZ68wDQYJKoZIhvcNAQEL +BQAwOTEVMBMGA1UECgwMVGVzc2VyYSBUZXN0MSAwHgYDVQQDDBdzZWxmc2lnbmVk +LmV4YW1wbGUudGVzdDAgFw0yNjEwMDkxMjQyMDJaGA8yMTI2MDkxNTEyNDIwMlow +OTEVMBMGA1UECgwMVGVzc2VyYSBUZXN0MSAwHgYDVQQDDBdzZWxmc2lnbmVkLmV4 +YW1wbGUudGVzdDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBANxM3x+C +PXuoxgA4W51JhO0usVyiZUdNya0/nDeiyPTMiAyQ0B5DDRosLVjDzVfG1+akOgxV +yOOABg+Jq9NwXGqLcShK1/EYcCcKSpR4CUP5QsSyDqHfs/iA4VT00RSB6bUjbmwN +XnLltBMa4U3kUBCzm6kszRRxiSN7ajE/X1JpNpXJuwQDCHkBkWwSDVHOZOdxy6tg +4xOEsO4wuuseVfQ+iiV33x9+tcnC/09mfR6FH7/fUHlklbLhtUJ5HMlwp+99JlIC +cjPbjDtDeJSRa0mTVXslwBeCvLTveSUAmOKqQ1JUJrmGGtKF6qxYnSkc4XgHq98s +T5RoNGabU1JXfLsCAwEAAaNjMGEwDAYDVR0TAQH/BAIwADAOBgNVHQ8BAf8EBAMC +BaAwHQYDVR0OBBYEFIQZ3IbjgP382bn1kEe9P6/AeJPpMCIGA1UdEQQbMBmCF3Nl +bGZzaWduZWQuZXhhbXBsZS50ZXN0MA0GCSqGSIb3DQEBCwUAA4IBAQCER/EnWGV0 +AnC2/6bYZyR0jlNlH2ZbNfOPCEx+UsAJAr1y54gXjsgNi8lil5vh906qyAldnfxg +UyDM0Z6UkG0KJIgu4Oh1VQFRKeRrXi/4pmTJSZIef6R8pn+9ZjcBzxILaAPYz5we +PilSTrpaBN/VJbSg8MRjdHnZKOXSvB6ZTMDbsO/mx6zV0lPskA6tG4Q7K6zaTL4p +ZGXpvnsL2OSHy2ksNLJhKRD3RGIFSaK9eim1R0WjGgiJijtuFJqcPmCIKmZYQk8C +cLS1hI38LsWKbW+b3J4mJZsIp+B5BePDlTAGKZsprE9rDE9+lxWES7WIMNLx+L3N +jY9ghAjkRQzl +-----END CERTIFICATE----- diff --git a/apps/api/src/cert-manager/cert-analyze.spec.ts b/apps/api/src/cert-manager/cert-analyze.spec.ts new file mode 100644 index 0000000..a04f092 --- /dev/null +++ b/apps/api/src/cert-manager/cert-analyze.spec.ts @@ -0,0 +1,67 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { analyzeWorkingSet, cleanSourcePath } from './cert-analyze'; +import type { CertItem } from './cert-types'; + +const fx = (name: string) => readFileSync(join(__dirname, '__fixtures__', name)); +const file = (name: string, as = name) => ({ originalname: as, buffer: fx(name) }); + +describe('analyzeWorkingSet', () => { + const result = analyzeWorkingSet([ + file('rsa-leaf.pem'), + file('rsa-inter.pem'), + file('ec-leaf.cer'), + file('ec-inter.pem'), + file('ec-root.pem'), + file('rsa-leaf.cer'), + { originalname: 'readme.txt', buffer: Buffer.from('Bitte lesen') }, + ]); + + it('fasst dasselbe Zertifikat aus zwei Dateien zu einem Eintrag zusammen', () => { + const certs = result.items.filter((i): i is CertItem => i.kind === 'certificate'); + expect(certs).toHaveLength(5); + const leaf = certs.find((c) => c.cn === 'www.example.test'); + expect(leaf?.sources).toEqual([ + { file: 0, path: 'rsa-leaf.pem' }, + { file: 5, path: 'rsa-leaf.cer' }, + ]); + }); + + it('ordnet Serverzertifikat, Zwischenzertifikat, Wurzel, dann Name', () => { + const certs = result.items.filter((i): i is CertItem => i.kind === 'certificate'); + expect(certs.map((c) => `${c.role}:${c.cn}`)).toEqual([ + 'end-entity:ec.example.test', + 'end-entity:www.example.test', + 'intermediate:Tessera Test Inter EC', + 'intermediate:Tessera Test Inter RSA', + 'root:Tessera Test Root EC', + ]); + }); + + it('meldet unbekannte Dateien mit ihrem Index', () => { + expect(result.ignored).toEqual([{ file: 6, path: 'readme.txt', reason: 'unknown' }]); + }); + + it('Ketten und gesperrte Eintraege sind in diesem Stand leer', () => { + expect(result.chains).toEqual([]); + expect(result.locked).toEqual([]); + }); + + it('JSON enthaelt weder undefined noch NaN', () => { + const json = JSON.stringify(result); + expect(json).not.toContain('undefined'); + expect(json).not.toContain('NaN'); + }); + + it('leere Anfrage ergibt leere Listen', () => { + expect(analyzeWorkingSet([])).toEqual({ items: [], chains: [], locked: [], ignored: [] }); + }); +}); + +describe('cleanSourcePath', () => { + it('entfernt Steuerzeichen und kuerzt auf 255', () => { + expect(cleanSourcePath('a\u0000b\u001fc.pem')).toBe('abc.pem'); + expect(cleanSourcePath('x'.repeat(400))).toHaveLength(255); + }); +}); diff --git a/apps/api/src/cert-manager/cert-analyze.ts b/apps/api/src/cert-manager/cert-analyze.ts new file mode 100644 index 0000000..0d8022d --- /dev/null +++ b/apps/api/src/cert-manager/cert-analyze.ts @@ -0,0 +1,75 @@ +import { detectBlob } from './cert-model'; +import type { + AnalysisResult, + AnyItem, + CertItem, + IgnoredEntry, + ItemSource, + LockedEntry, +} from './cert-types'; + +/** + * Fassade der Analyse (quick-261009-ikt, D-15): alle hochgeladenen Dateien erkennen, + * gleiche Teile zusammenfassen und ordnen. Zustandslos; nichts wird gespeichert. + * Ketten (Task 2), Schluessel/CSR-Zuordnung und gesperrte Container (Task 4) folgen. + */ + +export interface AnalyzeFile { + originalname: string; + buffer: Buffer; +} + +/** Anzeigename einer Quelle: ohne Steuerzeichen, hoechstens 255 Zeichen. Nur Anzeige, nie ein Dateipfad. */ +export function cleanSourcePath(raw: string): string { + let out = ''; + for (const ch of raw) { + const code = ch.codePointAt(0) ?? 0; + if (code < 0x20 || code === 0x7f) continue; + out += ch; + } + return out.slice(0, 255); +} + +const ROLE_RANK: Record = { 'end-entity': 0, intermediate: 1, root: 2 }; + +function sameSource(a: ItemSource, b: ItemSource): boolean { + return a.file === b.file && a.path === b.path; +} + +function orderItems(items: AnyItem[]): AnyItem[] { + const certs = items.filter((i): i is CertItem => i.kind === 'certificate'); + const keys = items.filter((i) => i.kind === 'privateKey'); + const csrs = items.filter((i) => i.kind === 'csr'); + certs.sort( + (a, b) => + ROLE_RANK[a.role] - ROLE_RANK[b.role] || + a.cn.localeCompare(b.cn) || + Date.parse(b.notAfter) - Date.parse(a.notAfter), + ); + return [...certs, ...keys, ...csrs]; +} + +export function analyzeWorkingSet(files: AnalyzeFile[], passwords: string[] = []): AnalysisResult { + const byId = new Map(); + const ignored: IgnoredEntry[] = []; + const locked: LockedEntry[] = []; + + files.forEach((f, index) => { + const path = cleanSourcePath(f.originalname); + const result = detectBlob(f.buffer, { file: index, path, passwords }); + for (const item of result.items) { + const known = byId.get(item.id); + if (!known) { + byId.set(item.id, item); + continue; + } + for (const source of item.sources) { + if (!known.sources.some((s) => sameSource(s, source))) known.sources.push(source); + } + } + ignored.push(...result.ignored); + locked.push(...result.locked); + }); + + return { items: orderItems([...byId.values()]), chains: [], locked, ignored }; +} diff --git a/apps/api/src/cert-manager/cert-bundle.spec.ts b/apps/api/src/cert-manager/cert-bundle.spec.ts deleted file mode 100644 index bcb9d46..0000000 --- a/apps/api/src/cert-manager/cert-bundle.spec.ts +++ /dev/null @@ -1,268 +0,0 @@ -import AdmZip from 'adm-zip'; -import * as forge from 'node-forge'; -import { beforeAll, describe, expect, it } from 'vitest'; -import { analyzeBundle, exportBundleItem, safeBaseName } from './cert-bundle'; - -/** - * cert-bundle.spec (quick-261001-l4q) — Zertifikatspaket wie vom Aussteller: - * Stamm -> Zwischen -> Server, dazu Schluessel, CSR und PFX, als ZIP. - * Alles hier erzeugt (keine echten Kundendaten im Repo). - */ - -interface Pki { - rootPem: string; - interPem: string; - leafPem: string; - keyPem: string; - csrPem: string; - pfx: Buffer; - leafModulus: string; -} - -let pki: Pki; - -function makeCert( - subjectCn: string, - pub: forge.pki.PublicKey, - signer: forge.pki.PrivateKey, - issuer: forge.pki.CertificateField[] | null, - ca: boolean, - serial: string, -): forge.pki.Certificate { - const cert = forge.pki.createCertificate(); - cert.publicKey = pub; - cert.serialNumber = serial; - cert.validity.notBefore = new Date(Date.now() - 86_400_000); - cert.validity.notAfter = new Date(Date.now() + 90 * 86_400_000); - const subject = [{ name: 'commonName', value: subjectCn }]; - cert.setSubject(subject); - cert.setIssuer(issuer ?? subject); - const ext: object[] = [{ name: 'basicConstraints', cA: ca }]; - if (!ca) ext.push({ name: 'subjectAltName', altNames: [{ type: 2, value: subjectCn }] }); - cert.setExtensions(ext); - cert.sign(signer as forge.pki.rsa.PrivateKey, forge.md.sha256.create()); - return cert; -} - -beforeAll(() => { - const rootKeys = forge.pki.rsa.generateKeyPair(1024); - const interKeys = forge.pki.rsa.generateKeyPair(1024); - const leafKeys = forge.pki.rsa.generateKeyPair(1024); - const root = makeCert('Test Root CA', rootKeys.publicKey, rootKeys.privateKey, null, true, '01'); - const inter = makeCert( - 'Test Intermediate CA', - interKeys.publicKey, - rootKeys.privateKey, - root.subject.attributes, - true, - '02', - ); - const leaf = makeCert( - 'www.example.test', - leafKeys.publicKey, - interKeys.privateKey, - inter.subject.attributes, - false, - '03', - ); - - const csr = forge.pki.createCertificationRequest(); - csr.publicKey = leafKeys.publicKey; - csr.setSubject([{ name: 'commonName', value: 'www.example.test' }]); - csr.sign(leafKeys.privateKey, forge.md.sha256.create()); - - const p12 = forge.pkcs12.toPkcs12Asn1(leafKeys.privateKey, [leaf, inter], 'geheim', { - algorithm: '3des', - }); - - pki = { - rootPem: forge.pki.certificateToPem(root), - interPem: forge.pki.certificateToPem(inter), - leafPem: forge.pki.certificateToPem(leaf), - keyPem: forge.pki.privateKeyInfoToPem( - forge.pki.wrapRsaPrivateKey(forge.pki.privateKeyToAsn1(leafKeys.privateKey)), - ), - csrPem: forge.pki.certificationRequestToPem(csr), - pfx: Buffer.from(forge.asn1.toDer(p12).getBytes(), 'binary'), - leafModulus: leafKeys.publicKey.n.toString(16), - }; -}, 60_000); - -function issuerZip(): Buffer { - const zip = new AdmZip(); - zip.addFile('www.example.test/www.example.test.pem', Buffer.from(pki.leafPem + pki.interPem)); - zip.addFile('www.example.test/www.example.test.key', Buffer.from(pki.keyPem)); - zip.addFile('www.example.test/www.example.test.csr', Buffer.from(pki.csrPem)); - zip.addFile('www.example.test/www.example.test.pfx', pki.pfx); - zip.addFile('www.example.test/.dnstxtrecord', Buffer.from('_dnsauth abc123')); - return zip.toBuffer(); -} - -describe('analyzeBundle', () => { - it('ZIP vom Aussteller: erkennt jedes Teil, fasst PEM/PFX zusammen, ordnet Schluessel und Kette zu', () => { - const r = analyzeBundle([{ originalname: 'paket.zip', buffer: issuerZip() }], 'geheim'); - - const kinds = r.items.map((i) => (i.kind === 'certificate' ? i.role : i.kind)); - expect(kinds).toEqual(['end-entity', 'intermediate', 'privateKey', 'csr']); - - const [leaf, inter, key, csr] = r.items; - expect(leaf.cn).toBe('www.example.test'); - expect(leaf.san).toEqual(['www.example.test']); - // in PEM UND PFX enthalten -> ein Eintrag mit beiden Quellen - expect(leaf.sources.sort()).toEqual(['www.example.test.pem', 'www.example.test.pfx']); - expect(leaf.chainIds).toEqual([inter.id]); - expect(leaf.matchId).toBe(key.id); - expect(key.matchId).toBe(leaf.id); - expect(key.sources.sort()).toEqual(['www.example.test.key', 'www.example.test.pfx']); - expect(csr.matchId).toBe(leaf.id); - expect(csr.cn).toBe('www.example.test'); - expect(leaf.baseName).toBe('www.example.test'); - - expect(r.locked).toEqual([]); - expect(r.ignored).toEqual(['.dnstxtrecord']); - }); - - it('PFX ohne passendes Passwort wird als gesperrt gemeldet, der Rest trotzdem erkannt', () => { - const r = analyzeBundle([{ originalname: 'paket.zip', buffer: issuerZip() }], 'falsch'); - expect(r.locked).toEqual(['www.example.test.pfx']); - expect(r.items.filter((i) => i.kind === 'certificate')).toHaveLength(2); - }); - - it('Stammzertifikat wird als root erkannt und an die Kette gehaengt', () => { - const r = analyzeBundle( - [ - { - originalname: 'chain.pem', - buffer: Buffer.from(pki.leafPem + pki.interPem + pki.rootPem), - }, - ], - '', - ); - expect(r.items.map((i) => i.role)).toEqual(['end-entity', 'intermediate', 'root']); - expect(r.items[0].chainIds).toHaveLength(2); - }); - - it('ohne Dateien -> 400', () => { - expect(() => analyzeBundle([], '')).toThrow(/No files/); - }); - - it('ZIP mit zu vielen Dateien -> 400', () => { - const zip = new AdmZip(); - for (let i = 0; i < 101; i++) zip.addFile(`f${i}.txt`, Buffer.from('x')); - expect(() => - analyzeBundle([{ originalname: 'gross.zip', buffer: zip.toBuffer() }], ''), - ).toThrow(/too many files/); - }); -}); - -describe('exportBundleItem', () => { - function bundle() { - const r = analyzeBundle([{ originalname: 'paket.zip', buffer: issuerZip() }], 'geheim'); - const byId = Object.fromEntries(r.items.map((i) => [i.id, i])); - return { items: r.items, byId }; - } - const decode = (b64: string) => Buffer.from(b64, 'base64'); - - it('Zertifikat in jedem Format liest sich wieder ein', () => { - const { items, byId } = bundle(); - const leaf = items[0]; - const chain = leaf.chainIds.map((id) => byId[id].pem); - const keyPem = byId[leaf.matchId!].pem; - const base = { kind: leaf.kind, pem: leaf.pem, baseName: leaf.baseName, chain, keyPem }; - - const crt = exportBundleItem({ ...base, format: 'crt' }); - expect(crt.filename).toBe('www.example.test.crt'); - expect( - forge.pki.certificateFromPem(decode(crt.content).toString()).subject.getField('CN').value, - ).toBe('www.example.test'); - - const cer = exportBundleItem({ ...base, format: 'cer' }); - expect(cer.filename).toBe('www.example.test.cer'); - forge.pki.certificateFromAsn1(forge.asn1.fromDer(decode(cer.content).toString('binary'))); - - const full = exportBundleItem({ ...base, format: 'fullchain' }); - expect( - decode(full.content) - .toString() - .match(/BEGIN CERTIFICATE/g), - ).toHaveLength(2); - - const p7b = exportBundleItem({ ...base, format: 'p7b' }); - const p7 = forge.pkcs7.messageFromPem(decode(p7b.content).toString()); - expect('certificates' in p7 ? p7.certificates : []).toHaveLength(2); - - const pfx = exportBundleItem({ ...base, format: 'pfx', password: 'neu' }); - expect(pfx.filename).toBe('www.example.test.pfx'); - const p12 = forge.pkcs12.pkcs12FromAsn1( - forge.asn1.fromDer(decode(pfx.content).toString('binary')), - 'neu', - ); - expect(p12.getBags({ bagType: forge.pki.oids.certBag })[forge.pki.oids.certBag]).toHaveLength( - 2, - ); - const keyBag = p12.getBags({ bagType: forge.pki.oids.pkcs8ShroudedKeyBag })[ - forge.pki.oids.pkcs8ShroudedKeyBag - ]![0]; - expect((keyBag.key as forge.pki.rsa.PrivateKey).n.toString(16)).toBe(pki.leafModulus); - }); - - it('PFX ohne Passwort -> 400', () => { - const { items } = bundle(); - expect(() => - exportBundleItem({ kind: 'certificate', pem: items[0].pem, format: 'pfx' }), - ).toThrow(/password is required/); - }); - - it('Schluessel als PKCS#8, PKCS#1 und DER', () => { - const { items } = bundle(); - const key = items.find((i) => i.kind === 'privateKey')!; - const base = { kind: key.kind, pem: key.pem, baseName: key.baseName }; - expect(decode(exportBundleItem({ ...base, format: 'key' }).content).toString()).toContain( - 'BEGIN PRIVATE KEY', - ); - const rsa = exportBundleItem({ ...base, format: 'key-rsa' }); - expect(rsa.filename).toBe('www.example.test.rsa.key'); - expect(decode(rsa.content).toString()).toContain('BEGIN RSA PRIVATE KEY'); - const der = exportBundleItem({ ...base, format: 'key-der' }); - const info = forge.asn1.fromDer(decode(der.content).toString('binary')); - expect((forge.pki.privateKeyFromAsn1(info) as forge.pki.rsa.PrivateKey).n.toString(16)).toBe( - pki.leafModulus, - ); - }); - - it('CSR als PEM und DER', () => { - const { items } = bundle(); - const csr = items.find((i) => i.kind === 'csr')!; - const der = exportBundleItem({ - kind: 'csr', - pem: csr.pem, - baseName: csr.baseName, - format: 'csr-der', - }); - expect(der.filename).toBe('www.example.test.csr.der'); - forge.pki.certificationRequestFromAsn1( - forge.asn1.fromDer(decode(der.content).toString('binary')), - ); - }); - - it('unpassendes Format -> 400', () => { - const { items } = bundle(); - expect(() => - exportBundleItem({ kind: 'csr', pem: items[3].pem, format: 'pfx', password: 'x' }), - ).toThrow(); - expect(() => - exportBundleItem({ kind: 'privateKey', pem: 'kein pem', format: 'key-rsa' }), - ).toThrow(/Failed to export/); - }); -}); - -describe('safeBaseName', () => { - it('Platzhalter, Leerzeichen und Pfadteile werden entschaerft', () => { - expect(safeBaseName('*.example.de', 'x')).toBe('wildcard.example.de'); - expect(safeBaseName('Encryption Everywhere DV TLS CA - G1', 'x')).toBe( - 'Encryption_Everywhere_DV_TLS_CA_-_G1', - ); - expect(safeBaseName('../../etc/passwd', 'x')).toBe('etc_passwd'); - expect(safeBaseName('', 'fallback')).toBe('fallback'); - }); -}); diff --git a/apps/api/src/cert-manager/cert-bundle.ts b/apps/api/src/cert-manager/cert-bundle.ts deleted file mode 100644 index 0362e1e..0000000 --- a/apps/api/src/cert-manager/cert-bundle.ts +++ /dev/null @@ -1,655 +0,0 @@ -import { BadRequestException } from '@nestjs/common'; -import AdmZip from 'adm-zip'; -import * as forge from 'node-forge'; -import type { UploadedFileLike } from '../auth/types/auth-user'; - -/** - * Zertifikatspaket (quick-261001-l4q): alles, was ein Aussteller liefert — - * Zertifikat mit Kette (.pem/.crt/.cer/.p7b), privater Schluessel (.key), - * Zertifikatsanfrage (.csr), PFX/P12, gern als ZIP — auf einmal hochladen, - * erkennen, was was ist, und jedes Teil in jedem passenden Format - * herunterladen. - * - * Zustandslos wie der Rest des Moduls: `analyzeBundle` gibt je Teil den - * kanonischen PEM-Text zurueck, `exportBundleItem` baut daraus die Datei. - * Nichts wird gespeichert, Passwoerter werden nie protokolliert. - */ - -type BundleFile = Pick; - -export type BundleCertRole = 'end-entity' | 'intermediate' | 'root'; -export type BundleItemKind = 'certificate' | 'privateKey' | 'csr'; - -export interface BundleItem { - id: string; - kind: BundleItemKind; - /** Nur bei Zertifikaten. */ - role?: BundleCertRole; - /** Dateien, in denen dieses Teil gefunden wurde (Duplikate zusammengefasst). */ - sources: string[]; - /** Kanonischer PEM-Text — Grundlage fuer jeden Export. */ - pem: string; - /** Vorschlag fuer den Dateinamen ohne Endung, aus dem CN abgeleitet. */ - baseName: string; - cn: string; - organization: string; - issuerCn: string; - notBefore: string | null; - notAfter: string | null; - isExpired: boolean | null; - daysLeft: number | null; - san: string[]; - keyType: string; - keyBits: number; - serialNumber: string; - sha256: string; - /** Zertifikat: id des passenden Schluessels; Schluessel/CSR: id des passenden Zertifikats. */ - matchId: string | null; - /** Zertifikat: ids der Kette darueber (Aussteller, dessen Aussteller ...). */ - chainIds: string[]; - /** Formate, die `exportBundleItem` fuer dieses Teil liefern kann. */ - formats: BundleExportFormat[]; -} - -export interface BundleAnalysis { - items: BundleItem[]; - /** PFX/P12 oder verschluesselte Schluessel, die ohne (richtiges) Passwort nicht lesbar sind. */ - locked: string[]; - /** Dateien ohne erkennbares Zertifikat, Schluessel oder CSR. */ - ignored: string[]; -} - -export type BundleExportFormat = - | 'crt' - | 'cer' - | 'fullchain' - | 'p7b' - | 'pfx' - | 'key' - | 'key-rsa' - | 'key-der' - | 'csr' - | 'csr-der'; - -export interface BundleExportInput { - kind: BundleItemKind; - pem: string; - format: BundleExportFormat; - baseName?: string; - /** Zertifikat: PEMs der Kette darueber (fuer Fullchain/P7B/PFX). */ - chain?: string[]; - /** Zertifikat: PEM des passenden privaten Schluessels (fuer PFX). */ - keyPem?: string; - /** PFX: Passwort fuer die neue Datei. */ - password?: string; -} - -export interface BundleExportFile { - filename: string; - /** Base64 */ - content: string; - mimeType: string; -} - -const MAX_ZIP_ENTRIES = 100; -const MAX_ENTRY_BYTES = 5 * 1024 * 1024; - -const PEM_BLOCK = /-----BEGIN ([A-Z0-9 ]+)-----[\s\S]+?-----END \1-----/g; - -// --------------------------------------------------------------------------- -// Hilfen -// --------------------------------------------------------------------------- - -function binary(buffer: Buffer): forge.util.ByteStringBuffer { - return forge.util.createBuffer(buffer.toString('binary')); -} - -function bytesToBase64(bytes: string): string { - return Buffer.from(forge.util.bytesToHex(bytes), 'hex').toString('base64'); -} - -function textToBase64(text: string): string { - return Buffer.from(text, 'utf-8').toString('base64'); -} - -function sha256Of(cert: forge.pki.Certificate): string { - const md = forge.md.sha256.create(); - md.update(forge.asn1.toDer(forge.pki.certificateToAsn1(cert)).getBytes()); - return (md.digest().toHex().match(/.{2}/g) ?? []).join(':').toUpperCase(); -} - -function field(name: forge.pki.Certificate['subject'], short: string): string { - return (name.getField(short)?.value as string | undefined) ?? ''; -} - -/** Dateiname ohne Pfad und ohne gefaehrliche Zeichen, z. B. „*.example.de“ -> „wildcard.example.de“. */ -export function safeBaseName(raw: string, fallback: string): string { - const cleaned = raw - .replace(/^\*\./, 'wildcard.') - .replace(/[^A-Za-z0-9._-]+/g, '_') - .replace(/^[._]+/, '') - .slice(0, 80); - return cleaned || fallback; -} - -function certRole(cert: forge.pki.Certificate): BundleCertRole { - const bc = cert.getExtension('basicConstraints') as { cA?: boolean } | null; - if (!bc?.cA) return 'end-entity'; - return cert.subject.hash === cert.issuer.hash ? 'root' : 'intermediate'; -} - -function publicKeyInfo(key: unknown): { keyType: string; keyBits: number; modulus: string } { - // node-forge liefert RSA-Schluessel mit `n`; EC-Schluessel kennt es nur - // eingeschraenkt (siehe Kommentar in CertManagerService.parseCert). - const k = key as { n?: forge.jsbn.BigInteger }; - if (k?.n) return { keyType: 'RSA', keyBits: k.n.bitLength(), modulus: k.n.toString(16) }; - return { keyType: 'EC', keyBits: 0, modulus: '' }; -} - -function sanOf(extensions: unknown[] | undefined): string[] { - const ext = (extensions ?? []).find((e) => (e as { name?: string }).name === 'subjectAltName') as - | { altNames?: { type: number; value?: string; ip?: string }[] } - | undefined; - return (ext?.altNames ?? []).map((n) => - n.type === 2 ? (n.value ?? '') : `IP:${n.ip ?? n.value ?? ''}`, - ); -} - -// --------------------------------------------------------------------------- -// Einsammeln: Dateien (inkl. ZIP) -> rohe Teile -// --------------------------------------------------------------------------- - -interface RawKey { - source: string; - pem: string; - modulus: string; - keyType: string; - keyBits: number; -} - -interface RawCsr { - source: string; - pem: string; -} - -interface Collected { - certs: { source: string; cert: forge.pki.Certificate }[]; - keys: RawKey[]; - csrs: RawCsr[]; - locked: string[]; - ignored: string[]; -} - -function expandZips(files: BundleFile[]): { name: string; buffer: Buffer }[] { - const out: { name: string; buffer: Buffer }[] = []; - for (const file of files) { - if (!file.originalname.toLowerCase().endsWith('.zip')) { - out.push({ name: file.originalname, buffer: file.buffer }); - continue; - } - let zip: AdmZip; - try { - zip = new AdmZip(file.buffer); - } catch { - throw new BadRequestException(`"${file.originalname}" is not a readable ZIP archive`); - } - const entries = zip - .getEntries() - .filter((e) => !e.isDirectory && !e.entryName.startsWith('__MACOSX/')); - if (entries.length > MAX_ZIP_ENTRIES) { - throw new BadRequestException(`"${file.originalname}" contains too many files`); - } - for (const entry of entries) { - // Groesse aus dem Kopf pruefen, BEVOR entpackt wird (Zip-Bombe). - if (entry.header.size > MAX_ENTRY_BYTES) { - throw new BadRequestException( - `"${entry.entryName}" in "${file.originalname}" is too large`, - ); - } - const name = entry.entryName.split('/').pop() ?? entry.entryName; - out.push({ name, buffer: entry.getData() }); - } - } - return out; -} - -function addKey(c: Collected, source: string, privateKey: forge.pki.rsa.PrivateKey): void { - const info = publicKeyInfo(privateKey); - const pem = forge.pki.privateKeyInfoToPem( - forge.pki.wrapRsaPrivateKey(forge.pki.privateKeyToAsn1(privateKey)), - ); - c.keys.push({ source, pem, ...info }); -} - -function collectPemText(c: Collected, source: string, text: string, password: string): boolean { - let found = false; - for (const match of text.matchAll(PEM_BLOCK)) { - const [block, type] = match; - try { - if (type === 'CERTIFICATE' || type === 'TRUSTED CERTIFICATE') { - c.certs.push({ source, cert: forge.pki.certificateFromPem(block) }); - found = true; - } else if (type === 'PRIVATE KEY' || type === 'RSA PRIVATE KEY') { - const key = forge.pki.privateKeyFromPem(block) as forge.pki.rsa.PrivateKey; - addKey(c, source, key); - found = true; - } else if (type === 'ENCRYPTED PRIVATE KEY') { - const key = password ? forge.pki.decryptRsaPrivateKey(block, password) : null; - if (key) addKey(c, source, key as forge.pki.rsa.PrivateKey); - else c.locked.push(source); - found = true; - } else if (type === 'EC PRIVATE KEY') { - // node-forge kann EC nicht umrechnen — Teil bleibt im Original erhalten. - c.keys.push({ source, pem: block, modulus: '', keyType: 'EC', keyBits: 0 }); - found = true; - } else if (type === 'CERTIFICATE REQUEST' || type === 'NEW CERTIFICATE REQUEST') { - c.csrs.push({ - source, - pem: block.replace(/NEW CERTIFICATE REQUEST/g, 'CERTIFICATE REQUEST'), - }); - found = true; - } else if (type === 'PKCS7') { - const p7 = forge.pkcs7.messageFromPem(block); - for (const cert of 'certificates' in p7 ? p7.certificates : []) - c.certs.push({ source, cert }); - found = true; - } - } catch { - // PKCS#8 mit EC-Schluessel o. ae.: node-forge kann ihn nicht lesen — - // im Original behalten statt zu verwerfen. - if (type === 'PRIVATE KEY') { - c.keys.push({ source, pem: block, modulus: '', keyType: 'EC', keyBits: 0 }); - found = true; - } - } - } - return found; -} - -function collectPfx(c: Collected, source: string, buffer: Buffer, password: string): void { - let p12: forge.pkcs12.Pkcs12Pfx | null = null; - for (const candidate of password ? [password, ''] : ['']) { - try { - p12 = forge.pkcs12.pkcs12FromAsn1(forge.asn1.fromDer(binary(buffer)), candidate); - break; - } catch { - // naechstes Passwort versuchen - } - } - if (!p12) { - c.locked.push(source); - return; - } - for (const bag of p12.getBags({ bagType: forge.pki.oids.certBag })[forge.pki.oids.certBag] ?? - []) { - if (bag.cert) c.certs.push({ source, cert: bag.cert }); - } - for (const oid of [forge.pki.oids.pkcs8ShroudedKeyBag, forge.pki.oids.keyBag]) { - for (const bag of p12.getBags({ bagType: oid })[oid] ?? []) { - if (bag.key) addKey(c, source, bag.key as forge.pki.rsa.PrivateKey); - } - } -} - -function collectDer(c: Collected, source: string, buffer: Buffer): boolean { - try { - const asn1 = forge.asn1.fromDer(binary(buffer)); - try { - c.certs.push({ source, cert: forge.pki.certificateFromAsn1(asn1) }); - return true; - } catch { - /* kein einzelnes Zertifikat */ - } - try { - const p7 = forge.pkcs7.messageFromAsn1(asn1); - const certs = 'certificates' in p7 ? p7.certificates : []; - for (const cert of certs) c.certs.push({ source, cert }); - if (certs.length > 0) return true; - } catch { - /* kein PKCS#7 */ - } - try { - forge.pki.certificationRequestFromAsn1(asn1); - const body = forge.asn1.toDer(asn1).getBytes(); - c.csrs.push({ source, pem: forge.pem.encode({ type: 'CERTIFICATE REQUEST', body }) }); - return true; - } catch { - /* keine CSR */ - } - } catch { - /* kein DER */ - } - return false; -} - -function collect(files: BundleFile[], password: string): Collected { - const c: Collected = { certs: [], keys: [], csrs: [], locked: [], ignored: [] }; - for (const { name, buffer } of expandZips(files)) { - const ext = name.split('.').pop()?.toLowerCase() ?? ''; - if (ext === 'pfx' || ext === 'p12') { - collectPfx(c, name, buffer, password); - continue; - } - const head = buffer.subarray(0, 4096).toString('latin1'); - const found = head.includes('-----BEGIN') - ? collectPemText(c, name, buffer.toString('utf-8'), password) - : collectDer(c, name, buffer); - if (!found) c.ignored.push(name); - } - return c; -} - -// --------------------------------------------------------------------------- -// analyzeBundle -// --------------------------------------------------------------------------- - -const CERT_FORMATS: BundleExportFormat[] = ['crt', 'cer', 'fullchain', 'p7b', 'pfx']; - -export function analyzeBundle(files: BundleFile[], password = ''): BundleAnalysis { - if (files.length === 0) throw new BadRequestException('No files provided'); - const c = collect(files, password); - - // Zertifikate nach Fingerabdruck zusammenfassen (PEM und PFX enthalten oft dieselben). - const certMap = new Map }>(); - for (const { source, cert } of c.certs) { - const fp = sha256Of(cert); - const entry = certMap.get(fp) ?? { cert, sources: new Set() }; - entry.sources.add(source); - certMap.set(fp, entry); - } - - const now = Date.now(); - const certItems: BundleItem[] = [...certMap.entries()].map(([fp, { cert, sources }]) => { - const info = publicKeyInfo(cert.publicKey); - const cn = field(cert.subject, 'CN'); - const notAfter = cert.validity.notAfter; - const role = certRole(cert); - return { - id: `cert-${fp.replace(/:/g, '').slice(0, 16).toLowerCase()}`, - kind: 'certificate', - role, - sources: [...sources], - pem: forge.pki.certificateToPem(cert), - baseName: safeBaseName(cn, role === 'end-entity' ? 'zertifikat' : 'ca'), - cn, - organization: field(cert.subject, 'O'), - issuerCn: field(cert.issuer, 'CN'), - notBefore: cert.validity.notBefore.toISOString(), - notAfter: notAfter.toISOString(), - isExpired: notAfter.getTime() < now, - daysLeft: Math.ceil((notAfter.getTime() - now) / 86_400_000), - san: sanOf(cert.extensions), - keyType: info.keyType, - keyBits: info.keyBits, - serialNumber: cert.serialNumber, - sha256: fp, - matchId: null, - chainIds: [], - formats: CERT_FORMATS, - // nur intern fuer Kette/Zuordnung, wird unten entfernt - _cert: cert, - _modulus: info.modulus, - } as BundleItem & { _cert: forge.pki.Certificate; _modulus: string }; - }); - - // Kette: zu jedem Zertifikat den Aussteller im Paket suchen. - type Internal = BundleItem & { _cert: forge.pki.Certificate; _modulus: string }; - const internals = certItems as Internal[]; - for (const item of internals) { - let current = item._cert; - const seen = new Set([item.id]); - for (let depth = 0; depth < 10; depth++) { - if (current.subject.hash === current.issuer.hash) break; - const issuer = internals.find( - (o) => !seen.has(o.id) && o._cert.subject.hash === current.issuer.hash, - ); - if (!issuer) break; - item.chainIds.push(issuer.id); - seen.add(issuer.id); - current = issuer._cert; - } - } - - // Schluessel: Duplikate zusammenfassen, dem Zertifikat zuordnen. - const keyMap = new Map }>(); - for (const key of c.keys) { - const id = key.modulus || key.pem; - const entry = keyMap.get(id) ?? { key, sources: new Set() }; - entry.sources.add(key.source); - keyMap.set(id, entry); - } - const keyItems: BundleItem[] = [...keyMap.values()].map(({ key, sources }, i) => { - const cert = key.modulus ? internals.find((o) => o._modulus === key.modulus) : undefined; - const id = `key-${i + 1}`; - if (cert) cert.matchId = id; - const isRsa = key.keyType === 'RSA'; - return { - id, - kind: 'privateKey', - sources: [...sources], - pem: key.pem, - baseName: - cert?.baseName ?? - safeBaseName( - sources - .values() - .next() - .value?.replace(/\.[^.]+$/, '') ?? '', - 'schluessel', - ), - cn: cert?.cn ?? '', - organization: '', - issuerCn: '', - notBefore: null, - notAfter: null, - isExpired: null, - daysLeft: null, - san: [], - keyType: key.keyType, - keyBits: key.keyBits, - serialNumber: '', - sha256: '', - matchId: cert?.id ?? null, - chainIds: [], - formats: isRsa ? ['key', 'key-rsa', 'key-der'] : ['key'], - }; - }); - - // CSRs: Duplikate zusammenfassen, Details lesen, dem Zertifikat zuordnen. - const csrMap = new Map }>(); - for (const csr of c.csrs) { - const norm = csr.pem.replace(/\s+/g, ''); - const entry = csrMap.get(norm) ?? { pem: csr.pem, sources: new Set() }; - entry.sources.add(csr.source); - csrMap.set(norm, entry); - } - const csrItems: BundleItem[] = [...csrMap.values()].map(({ pem, sources }, i) => { - let cn = ''; - let organization = ''; - let info = { keyType: '', keyBits: 0, modulus: '' }; - let san: string[] = []; - try { - const csr = forge.pki.certificationRequestFromPem(pem); - cn = field(csr.subject as forge.pki.Certificate['subject'], 'CN'); - organization = field(csr.subject as forge.pki.Certificate['subject'], 'O'); - info = publicKeyInfo(csr.publicKey); - const ext = csr.getAttribute({ name: 'extensionRequest' }) as { - extensions?: unknown[]; - } | null; - san = sanOf(ext?.extensions); - } catch { - // EC-CSR: node-forge liest sie nicht — Teil bleibt trotzdem herunterladbar. - } - const cert = info.modulus ? internals.find((o) => o._modulus === info.modulus) : undefined; - return { - id: `csr-${i + 1}`, - kind: 'csr', - sources: [...sources], - pem, - baseName: cert?.baseName ?? safeBaseName(cn, 'anfrage'), - cn, - organization, - issuerCn: '', - notBefore: null, - notAfter: null, - isExpired: null, - daysLeft: null, - san, - keyType: info.keyType, - keyBits: info.keyBits, - serialNumber: '', - sha256: '', - matchId: cert?.id ?? null, - chainIds: [], - formats: ['csr', 'csr-der'], - }; - }); - - // Reihenfolge: Serverzertifikat(e), Zwischen-, Stammzertifikate, Schluessel, CSR. - const roleOrder: Record = { 'end-entity': 0, intermediate: 1, root: 2 }; - internals.sort( - (a, b) => - roleOrder[a.role ?? 'end-entity'] - roleOrder[b.role ?? 'end-entity'] || - b.chainIds.length - a.chainIds.length, - ); - const certsClean: BundleItem[] = internals.map(({ _cert, _modulus, ...rest }) => rest); - - return { - items: [...certsClean, ...keyItems, ...csrItems], - locked: [...new Set(c.locked)], - ignored: [...new Set(c.ignored)], - }; -} - -// --------------------------------------------------------------------------- -// exportBundleItem -// --------------------------------------------------------------------------- - -const MIME = { - pem: 'application/x-pem-file', - der: 'application/x-x509-ca-cert', - p7b: 'application/x-pkcs7-certificates', - pfx: 'application/x-pkcs12', - key: 'application/x-pem-file', - octet: 'application/octet-stream', -} as const; - -function pemBody(pem: string): string { - const [msg] = forge.pem.decode(pem); - if (!msg) throw new Error('no PEM block'); - return msg.body; -} - -export function exportBundleItem(input: BundleExportInput): BundleExportFile { - const { kind, pem, format, chain = [], keyPem, password } = input; - const base = safeBaseName( - input.baseName ?? '', - kind === 'csr' ? 'anfrage' : kind === 'privateKey' ? 'schluessel' : 'zertifikat', - ); - - if (!pem || typeof pem !== 'string') throw new BadRequestException('No PEM provided'); - if (format === 'pfx' && (!password || password.trim() === '')) { - throw new BadRequestException('A password is required for PFX output'); - } - - try { - if (kind === 'certificate') { - const cert = forge.pki.certificateFromPem(pem); - const chainCerts = chain.map((p) => forge.pki.certificateFromPem(p)); - switch (format) { - case 'crt': - return { - filename: `${base}.crt`, - content: textToBase64(forge.pki.certificateToPem(cert)), - mimeType: MIME.pem, - }; - case 'cer': - return { - filename: `${base}.cer`, - content: bytesToBase64(forge.asn1.toDer(forge.pki.certificateToAsn1(cert)).getBytes()), - mimeType: MIME.der, - }; - case 'fullchain': { - const text = [cert, ...chainCerts].map((x) => forge.pki.certificateToPem(x)).join(''); - return { - filename: `${base}-fullchain.pem`, - content: textToBase64(text), - mimeType: MIME.pem, - }; - } - case 'p7b': { - const p7 = forge.pkcs7.createSignedData(); - for (const x of [cert, ...chainCerts]) p7.addCertificate(x); - const text = forge.pem.encode({ - type: 'PKCS7', - body: forge.asn1.toDer(p7.toAsn1()).getBytes(), - }); - return { filename: `${base}.p7b`, content: textToBase64(text), mimeType: MIME.p7b }; - } - case 'pfx': { - const key = keyPem - ? (forge.pki.privateKeyFromPem(keyPem) as forge.pki.rsa.PrivateKey) - : null; - const p12 = forge.pkcs12.toPkcs12Asn1( - // null = reines Zertifikatsbuendel ohne Schluessel (siehe - // CertManagerService.mergeCerts). - key, - [cert, ...chainCerts], - password as string, // oben geprueft: PFX verlangt ein Passwort - { algorithm: '3des', friendlyName: input.baseName || undefined }, - ); - return { - filename: `${base}.pfx`, - content: bytesToBase64(forge.asn1.toDer(p12).getBytes()), - mimeType: MIME.pfx, - }; - } - } - } else if (kind === 'privateKey') { - switch (format) { - case 'key': - return { - filename: `${base}.key`, - content: textToBase64(`${pem.trim()}\n`), - mimeType: MIME.key, - }; - case 'key-rsa': { - const key = forge.pki.privateKeyFromPem(pem); - return { - filename: `${base}.rsa.key`, - content: textToBase64(forge.pki.privateKeyToPem(key)), - mimeType: MIME.key, - }; - } - case 'key-der': { - const key = forge.pki.privateKeyFromPem(pem); - const info = forge.pki.wrapRsaPrivateKey(forge.pki.privateKeyToAsn1(key)); - return { - filename: `${base}.key.der`, - content: bytesToBase64(forge.asn1.toDer(info).getBytes()), - mimeType: MIME.octet, - }; - } - } - } else if (kind === 'csr') { - switch (format) { - case 'csr': - return { - filename: `${base}.csr`, - content: textToBase64(`${pem.trim()}\n`), - mimeType: MIME.pem, - }; - case 'csr-der': - return { - filename: `${base}.csr.der`, - content: bytesToBase64(pemBody(pem)), - mimeType: MIME.octet, - }; - } - } - } catch { - // Passwort und Schluessel nie protokollieren oder zurueckgeben. - throw new BadRequestException(`Failed to export ${kind} as ${format}`); - } - throw new BadRequestException(`Unsupported format "${format}" for ${kind}`); -} diff --git a/apps/api/src/cert-manager/cert-manager.controller.spec.ts b/apps/api/src/cert-manager/cert-manager.controller.spec.ts new file mode 100644 index 0000000..f969e24 --- /dev/null +++ b/apps/api/src/cert-manager/cert-manager.controller.spec.ts @@ -0,0 +1,84 @@ +import 'reflect-metadata'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { RequestMethod } from '@nestjs/common'; +import { describe, expect, it } from 'vitest'; +import { MODULE_SLUG_KEY } from '../module-registry/module.guard'; +import { CertManagerController, repairFileName } from './cert-manager.controller'; + +const fx = (name: string) => readFileSync(join(__dirname, '__fixtures__', name)); +const upload = (name: string, buffer: Buffer = fx(name)) => ({ + originalname: name, + mimetype: 'application/octet-stream', + size: buffer.length, + buffer, +}); + +function codeOf(fn: () => unknown): { status: number; code: string } { + try { + fn(); + } catch (error) { + const e = error as { getStatus(): number; getResponse(): { code: string } }; + return { status: e.getStatus(), code: e.getResponse().code }; + } + throw new Error('expected a throw'); +} + +describe('CertManagerController', () => { + const controller = new CertManagerController(); + + it('liegt unter modules/cert-manager und gehoert zum Modul cert-manager', () => { + expect(Reflect.getMetadata('path', CertManagerController)).toBe('modules/cert-manager'); + expect(Reflect.getMetadata(MODULE_SLUG_KEY, CertManagerController)).toBe('cert-manager'); + }); + + it('bietet in diesem Stand genau den Handler analyze (POST analyze, Code 200)', () => { + const handlers = Object.getOwnPropertyNames(CertManagerController.prototype).filter( + (n) => n !== 'constructor', + ); + expect(handlers).toEqual(['analyze']); + const handler = CertManagerController.prototype.analyze; + expect(Reflect.getMetadata('path', handler)).toBe('analyze'); + expect(Reflect.getMetadata('method', handler)).toBe(RequestMethod.POST); + expect(Reflect.getMetadata('__httpCode__', handler)).toBe(200); + }); + + it('ohne Dateien: 400 invalidInput', () => { + expect(codeOf(() => controller.analyze(undefined))).toEqual({ + status: 400, + code: 'invalidInput', + }); + expect(codeOf(() => controller.analyze([]))).toEqual({ status: 400, code: 'invalidInput' }); + }); + + it('Dateien ueber 20 MiB zusammen: 413 tooLarge, ohne Analyse', () => { + const big = Buffer.alloc(5 * 1024 * 1024, 1); + const files = [1, 2, 3, 4, 5].map((i) => upload(`big-${i}.bin`, big)); + expect(codeOf(() => controller.analyze(files))).toEqual({ status: 413, code: 'tooLarge' }); + }); + + it('reicht die Dateien in Anfrage-Reihenfolge an die Analyse', () => { + const result = controller.analyze([ + upload('ec-leaf.pem'), + upload('rsa-leaf.pem'), + upload('x.txt', Buffer.from('x')), + ]); + const sources = result.items.flatMap((i) => i.sources); + expect(sources).toEqual([ + { file: 0, path: 'ec-leaf.pem' }, + { file: 1, path: 'rsa-leaf.pem' }, + ]); + expect(result.ignored).toEqual([{ file: 2, path: 'x.txt', reason: 'unknown' }]); + }); +}); + +describe('repairFileName', () => { + it('gewinnt UTF-8-Namen zurueck, die als Latin-1 ankamen', () => { + const asLatin1 = Buffer.from('Zertifikat-Müller.pem', 'utf8').toString('latin1'); + expect(repairFileName(asLatin1)).toBe('Zertifikat-Müller.pem'); + }); + it('lässt Namen ohne Umlaute und echtes Latin-1 unverändert', () => { + expect(repairFileName('a.pem')).toBe('a.pem'); + expect(repairFileName('M\xfcller.pem')).toBe('M\xfcller.pem'); + }); +}); diff --git a/apps/api/src/cert-manager/cert-manager.controller.ts b/apps/api/src/cert-manager/cert-manager.controller.ts index e9d37e4..51e3213 100644 --- a/apps/api/src/cert-manager/cert-manager.controller.ts +++ b/apps/api/src/cert-manager/cert-manager.controller.ts @@ -1,173 +1,54 @@ -import { - BadRequestException, - Body, - Controller, - Post, - UploadedFile, - UploadedFiles, - UseInterceptors, -} from '@nestjs/common'; -import { FileInterceptor, FilesInterceptor } from '@nestjs/platform-express'; -import { UseModule } from '../module-registry/module.guard'; +import { Controller, HttpCode, Post, UploadedFiles, UseInterceptors } from '@nestjs/common'; +import { FilesInterceptor } from '@nestjs/platform-express'; import type { UploadedFileLike } from '../auth/types/auth-user'; -import { - analyzeBundle, - type BundleExportFormat, - type BundleItemKind, - exportBundleItem, -} from './cert-bundle'; -import { CertManagerService } from './cert-manager.service'; +import { UseModule } from '../module-registry/module.guard'; +import { analyzeWorkingSet } from './cert-analyze'; +import { type AnalysisResult, certError } from './cert-types'; + +/** Obergrenzen (D-17): je Datei 5 MiB, alle Dateien zusammen 20 MiB, hoechstens 30 Dateien. */ +export const CERT_MAX_FILES = 30; +export const CERT_MAX_FILE_BYTES = 5 * 1024 * 1024; +export const CERT_MAX_TOTAL_BYTES = 20 * 1024 * 1024; /** - * CertManagerController — 4 POST endpoints for certificate operations. + * multer liest Dateinamen als Latin-1. Waren es UTF-8-Bytes (Umlaute), den Namen zurueckgewinnen; + * ist das Ergebnis kein gueltiges UTF-8, bleibt der Name wie er ist. + */ +export function repairFileName(name: string): string { + const utf8 = Buffer.from(name, 'latin1').toString('utf8'); + return utf8.includes('�') ? name : utf8; +} + +/** + * CertManagerController: Zertifikat-Manager (quick-261009-ikt, D-14). * - * All routes are protected by: - * - Global JwtAuthGuard (authentication) - * - Global TenantGuard (tenant context) - * - @UseModule('cert-manager') ModuleGuard (module activation check) + * Zustandslos: nichts wird gespeichert, Passwoerter und Schluessel kommen nie in ein Log + * (kein Logger-Aufruf mit Anfrageinhalt; das Request-Log kennt nur Pfad und Status). + * Geschuetzt durch den globalen JwtAuthGuard, den TenantGuard und @UseModule('cert-manager'). * - * File size limit: 5 MB per file (T-09-03 — DoS mitigation). - * Password parameter is never passed to a logger (T-09-02 — InfoDisc mitigation). + * Routen (alle POST, 200): + * - analyze Task 1 mehrere Dateien (multipart) erkennen und zusammenfassen + * - build Task 2 Ausgabe bauen (JSON), ab Task 5/6 erweitert + * - fetch-issuer Task 7 fehlendes Zwischenzertifikat nur auf Knopfdruck holen */ @Controller('modules/cert-manager') @UseModule('cert-manager') export class CertManagerController { - constructor(private readonly certManagerService: CertManagerService) {} - - /** - * POST /modules/cert-manager/parse - * Inspect a single certificate: subject, issuer, validity, SANs, fingerprints. - * Accepts multipart file upload OR JSON body with pemText. - */ - @Post('parse') - @UseInterceptors( - FileInterceptor('file', { - limits: { fileSize: 5 * 1024 * 1024 }, - }), - ) - async parseCert( - @UploadedFile() file: UploadedFileLike | undefined, - @Body('password') password?: string, - @Body('pemText') pemText?: string, - ) { - if (!file && !pemText) { - throw new BadRequestException('No file or PEM text provided'); - } - return this.certManagerService.parseCert({ file, pemText, password }); - } - - /** - * POST /modules/cert-manager/split - * Split a fullchain.pem or P7B bundle into individual certificates. - */ - @Post('split') - @UseInterceptors( - FileInterceptor('file', { - limits: { fileSize: 5 * 1024 * 1024 }, - }), - ) - async splitCerts( - @UploadedFile() file: UploadedFileLike | undefined, - @Body('password') password?: string, - ) { - if (!file) { - throw new BadRequestException('No file provided'); - } - return this.certManagerService.splitCerts({ file, password }); - } - - /** - * POST /modules/cert-manager/merge - * Merge multiple certificates into a PEM chain or PFX bundle. - * Uses FilesInterceptor (plural) to accept multiple files with field name "files". - */ - @Post('merge') - @UseInterceptors( - FilesInterceptor('files', 20, { - limits: { fileSize: 5 * 1024 * 1024 }, - }), - ) - async mergeCerts( - @UploadedFiles() files: UploadedFileLike[], - @Body('outputFormat') outputFormat: string, - @Body('password') password?: string, - ) { - if (!files || files.length < 2) { - throw new BadRequestException('At least 2 files required for merge'); - } - return this.certManagerService.mergeCerts({ files, outputFormat, password }); - } - - /** - * POST /modules/cert-manager/convert - * Convert a certificate between PEM, DER, and P7B formats. - * Accepts a multipart file upload OR a pemText body field. - * - * T-09-03: fileSize limit 5 MB (DoS mitigation) - * T-09-04: global JwtAuthGuard + @UseModule('cert-manager') ModuleGuard - * T-09-02: password is never passed to the logger - */ - @Post('convert') - @UseInterceptors( - FileInterceptor('file', { - limits: { fileSize: 5 * 1024 * 1024 }, - }), - ) - async convertCert( - @UploadedFile() file: UploadedFileLike | undefined, - @Body('targetFormat') targetFormat: string, - @Body('password') password?: string, - @Body('pemText') pemText?: string, - ) { - if (!file && !pemText) { - throw new BadRequestException('No file or PEM text provided'); - } - return this.certManagerService.convertCert({ file, pemText, targetFormat, password }); - } - - /** - * POST /modules/cert-manager/analyze (quick-261001-l4q) - * Zertifikatspaket: mehrere Dateien oder ZIP hochladen, jedes Teil erkennen - * (Server-/Zwischen-/Stammzertifikat, privater Schluessel, CSR), Duplikate - * zusammenfassen, Schluessel und Kette zuordnen. - * - * T-09-03: 20 Dateien, je 5 MB; ZIP-Inhalt zusaetzlich begrenzt (cert-bundle.ts). - * T-09-02: password is never passed to the logger - */ @Post('analyze') + @HttpCode(200) @UseInterceptors( - FilesInterceptor('files', 20, { - limits: { fileSize: 5 * 1024 * 1024 }, - }), + FilesInterceptor('files', CERT_MAX_FILES, { limits: { fileSize: CERT_MAX_FILE_BYTES } }), ) - async analyze( - @UploadedFiles() files: UploadedFileLike[] | undefined, - @Body('password') password?: string, - ) { - return analyzeBundle(files ?? [], password ?? ''); - } - - /** - * POST /modules/cert-manager/export (quick-261001-l4q) - * Ein Teil aus `analyze` (PEM) in das gewuenschte Format bringen. - * JSON-Body; PFX verlangt ein Passwort fuer die neue Datei. - */ - @Post('export') - async export( - @Body('kind') kind: BundleItemKind, - @Body('pem') pem: string, - @Body('format') format: BundleExportFormat, - @Body('baseName') baseName?: string, - @Body('chain') chain?: string[], - @Body('keyPem') keyPem?: string, - @Body('password') password?: string, - ) { - if ( - chain !== undefined && - (!Array.isArray(chain) || chain.some((c) => typeof c !== 'string')) - ) { - throw new BadRequestException('chain must be a list of PEM strings'); + analyze(@UploadedFiles() files: UploadedFileLike[] | undefined): AnalysisResult { + if (!files || files.length === 0) { + certError('invalidInput', 400, 'No files provided'); } - return exportBundleItem({ kind, pem, format, baseName, chain, keyPem, password }); + const total = files.reduce((sum, f) => sum + f.buffer.length, 0); + if (total > CERT_MAX_TOTAL_BYTES) { + certError('tooLarge', 413, 'Files together exceed 20 MiB'); + } + return analyzeWorkingSet( + files.map((f) => ({ originalname: repairFileName(f.originalname), buffer: f.buffer })), + ); } } diff --git a/apps/api/src/cert-manager/cert-manager.module.ts b/apps/api/src/cert-manager/cert-manager.module.ts index f793e4a..e2a3618 100644 --- a/apps/api/src/cert-manager/cert-manager.module.ts +++ b/apps/api/src/cert-manager/cert-manager.module.ts @@ -3,13 +3,12 @@ import { ModuleRegistryModule } from '../module-registry/module-registry.module' import { ModuleRegistryService } from '../module-registry/module-registry.service'; import { CertManagerController } from './cert-manager.controller'; import { seedCertManagerModule } from './cert-manager.seed'; -import { CertManagerService } from './cert-manager.service'; /** * NestJS module for the Cert Manager feature. * - * Provides server-side certificate inspection, splitting, merging, - * and format conversion using node-forge (pure JS, no native bindings). + * Zustandslose Zertifikat-Verarbeitung (node:crypto, node-forge nur fuer PKCS#12 und ASN.1); + * keine Provider, die Logik steht in reinen Funktionen (cert-model, cert-analyze, ...). * * Seeds itself into the module registry on application startup via * OnModuleInit lifecycle hook (CERT-06). @@ -21,14 +20,11 @@ import { CertManagerService } from './cert-manager.service'; @Module({ imports: [ModuleRegistryModule], controllers: [CertManagerController], - providers: [CertManagerService], }) export class CertManagerModule implements OnModuleInit { private readonly logger = new Logger(CertManagerModule.name); - constructor( - private readonly moduleRegistryService: ModuleRegistryService, - ) {} + constructor(private readonly moduleRegistryService: ModuleRegistryService) {} async onModuleInit(): Promise { try { diff --git a/apps/api/src/cert-manager/cert-manager.service.spec.ts b/apps/api/src/cert-manager/cert-manager.service.spec.ts deleted file mode 100644 index c087a4c..0000000 --- a/apps/api/src/cert-manager/cert-manager.service.spec.ts +++ /dev/null @@ -1,778 +0,0 @@ -import { BadRequestException } from '@nestjs/common'; -import * as forge from 'node-forge'; -import { beforeAll, describe, expect, it, vi } from 'vitest'; -import { seedCertManagerModule } from './cert-manager.seed'; -import { CertManagerService } from './cert-manager.service'; - -// --------------------------------------------------------------------------- -// Test helpers -// --------------------------------------------------------------------------- - -/** Generate a self-signed X.509 cert via node-forge (RSA 1024 — fast for tests) */ -function generateSelfSignedCert(): forge.pki.Certificate { - const keys = forge.pki.rsa.generateKeyPair(1024); - const cert = forge.pki.createCertificate(); - cert.publicKey = keys.publicKey; - cert.serialNumber = '01'; - cert.validity.notBefore = new Date(); - cert.validity.notAfter = new Date(); - cert.validity.notAfter.setFullYear(cert.validity.notBefore.getFullYear() + 1); - - const attrs = [{ name: 'commonName', value: 'test.example.com' }]; - cert.setSubject(attrs); - cert.setIssuer(attrs); - cert.sign(keys.privateKey, forge.md.sha256.create()); - return cert; -} - -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - -describe('seedCertManagerModule', () => { - it('calls moduleRegistryService.seedModule once with cert-manager slug', async () => { - const mockSeedModule = vi.fn().mockResolvedValue(undefined); - const mockModuleRegistryService = { seedModule: mockSeedModule } as any; - - await seedCertManagerModule(mockModuleRegistryService); - - expect(mockSeedModule).toHaveBeenCalledTimes(1); - const arg = mockSeedModule.mock.calls[0][0]; - expect(arg.slug).toBe('cert-manager'); - expect(arg.category).toBe('security-tools'); - expect(arg.isSystem).toBe(true); - }); -}); - -describe('CertManagerService helpers', () => { - let service: CertManagerService; - let testCert: forge.pki.Certificate; - - beforeAll(() => { - service = new CertManagerService(); - testCert = generateSelfSignedCert(); - }); - - // ------------------------------------------------------------------------- - // detectFormat - // ------------------------------------------------------------------------- - - describe('detectFormat', () => { - it('returns "pfx" for .pfx extension', () => { - const buf = Buffer.from([0x30, 0x82]); // arbitrary binary - expect(service.detectFormat('cert.pfx', buf)).toBe('pfx'); - }); - - it('returns "p7b" for .p7b extension', () => { - const buf = Buffer.from([0x30, 0x82]); - expect(service.detectFormat('cert.p7b', buf)).toBe('p7b'); - }); - - it('returns "der" for .der extension', () => { - const buf = Buffer.from([0x30, 0x82]); - expect(service.detectFormat('cert.der', buf)).toBe('der'); - }); - - it('returns "pem" for .pem extension', () => { - const buf = Buffer.from('-----BEGIN CERTIFICATE-----\nfake\n-----END CERTIFICATE-----'); - expect(service.detectFormat('cert.pem', buf)).toBe('pem'); - }); - - it('returns "pem" for .cer extension with PEM content', () => { - const buf = Buffer.from('-----BEGIN CERTIFICATE-----\nfake\n-----END CERTIFICATE-----'); - expect(service.detectFormat('cert.cer', buf)).toBe('pem'); - }); - - it('returns "der" for .cer extension with binary (non-PEM) content', () => { - const buf = Buffer.from([0x30, 0x82, 0x01, 0x00]); - expect(service.detectFormat('cert.cer', buf)).toBe('der'); - }); - }); - - // ------------------------------------------------------------------------- - // getFingerprint - // ------------------------------------------------------------------------- - - describe('getFingerprint', () => { - it('returns uppercase colon-separated hex for sha256', () => { - const fp = service.getFingerprint(testCert, 'sha256'); - // e.g. "AA:BB:CC:..." - expect(fp).toMatch(/^[0-9A-F]{2}(:[0-9A-F]{2})+$/); - }); - - it('returns uppercase colon-separated hex for sha1', () => { - const fp = service.getFingerprint(testCert, 'sha1'); - expect(fp).toMatch(/^[0-9A-F]{2}(:[0-9A-F]{2})+$/); - }); - }); - - // ------------------------------------------------------------------------- - // parsePemChain - // ------------------------------------------------------------------------- - - describe('parsePemChain', () => { - it('returns array of length 2 for two concatenated cert PEMs', () => { - const cert1 = generateSelfSignedCert(); - const cert2 = generateSelfSignedCert(); - const pem1 = forge.pki.certificateToPem(cert1); - const pem2 = forge.pki.certificateToPem(cert2); - const chain = `${pem1}\n${pem2}`; - - const parsed = service.parsePemChain(chain); - expect(parsed).toHaveLength(2); - }); - - it('returns array of length 1 for a single PEM', () => { - const pem = forge.pki.certificateToPem(testCert); - const parsed = service.parsePemChain(pem); - expect(parsed).toHaveLength(1); - }); - }); -}); - -// --------------------------------------------------------------------------- -// parseCert — RED tests (CERT-01, CERT-05 read half) -// --------------------------------------------------------------------------- - -describe('parseCert', () => { - let service: CertManagerService; - let certPem: string; - let certDerBuffer: Buffer; - let pfxBuffer: Buffer; - const CN = 'parsecert.example.com'; - - beforeAll(() => { - service = new CertManagerService(); - - // Create a cert+key pair for all fixtures (RSA-1024 for speed) - const keys = forge.pki.rsa.generateKeyPair(1024); - const cert = forge.pki.createCertificate(); - cert.publicKey = keys.publicKey; - cert.serialNumber = '01'; - cert.validity.notBefore = new Date(); - cert.validity.notAfter = new Date(); - cert.validity.notAfter.setFullYear(cert.validity.notBefore.getFullYear() + 1); - - const attrs = [{ name: 'commonName', value: CN }]; - cert.setSubject(attrs); - cert.setIssuer(attrs); - cert.sign(keys.privateKey, forge.md.sha256.create()); - - // PEM fixture - certPem = forge.pki.certificateToPem(cert); - - // DER fixture — binary encoding required (Pitfall 1) - const derBytes = forge.asn1.toDer(forge.pki.certificateToAsn1(cert)).getBytes(); - certDerBuffer = Buffer.from(derBytes, 'binary'); - - // PFX fixture with password 'secret' - const p12Asn1 = forge.pkcs12.toPkcs12Asn1( - keys.privateKey, - [cert], - 'secret', - { algorithm: '3des' }, - ); - const p12DerBytes = forge.asn1.toDer(p12Asn1).getBytes(); - pfxBuffer = Buffer.from(p12DerBytes, 'binary'); - }, 15000); // 15s timeout — RSA keygen can be slow in pure JS - - it('returns CertDetails with subject.cn, fingerprint.sha256, keyType RSA, keyBits, isExpired false for PEM input', async () => { - // RED: parseCert currently throws NotImplementedException — this test will FAIL - const result = await (service.parseCert({ pemText: certPem }) as Promise); - expect(result.subject.cn).toBe(CN); - expect(result.fingerprint.sha256).toMatch(/^[0-9A-F]{2}(:[0-9A-F]{2})+$/); - expect(result.keyType).toBe('RSA'); - expect(result.keyBits).toBe(1024); - expect(result.validity.isExpired).toBe(false); - }); - - it('returns CertDetails with matching subject.cn for DER input', async () => { - // RED: parseCert throws NotImplementedException — this test will FAIL - const result = await (service.parseCert({ - file: { originalname: 'c.der', buffer: certDerBuffer }, - }) as Promise); - expect(result.subject.cn).toBe(CN); - }); - - it('returns CertDetails for PFX with correct password', async () => { - // RED: parseCert throws NotImplementedException — this test will FAIL - const result = await (service.parseCert({ - file: { originalname: 'c.pfx', buffer: pfxBuffer }, - password: 'secret', - }) as Promise); - expect(result.subject.cn).toBe(CN); - }); - - it('throws BadRequestException for PFX with wrong password', async () => { - // RED: currently throws NotImplementedException (not BadRequestException) — FAIL - await expect( - service.parseCert({ - file: { originalname: 'c.pfx', buffer: pfxBuffer }, - password: 'wrong', - }), - ).rejects.toThrow(BadRequestException); - }); - - it('throws BadRequestException for malformed PEM input', async () => { - // RED: currently throws NotImplementedException (not BadRequestException) — FAIL - await expect( - service.parseCert({ pemText: 'not a cert' }), - ).rejects.toThrow(BadRequestException); - }); -}); - -// --------------------------------------------------------------------------- -// splitCerts — RED tests (CERT-02) -// --------------------------------------------------------------------------- - -describe('splitCerts', () => { - let service: CertManagerService; - let cert1Pem: string; - let cert2Pem: string; - let fullchainBuffer: Buffer; - let p7bBuffer: Buffer; - const CN1 = 'split1.example.com'; - const CN2 = 'split2.example.com'; - - beforeAll(() => { - service = new CertManagerService(); - - // Build cert 1 (RSA-1024 for speed) - const keys1 = forge.pki.rsa.generateKeyPair(1024); - const c1 = forge.pki.createCertificate(); - c1.publicKey = keys1.publicKey; - c1.serialNumber = '01'; - c1.validity.notBefore = new Date(); - c1.validity.notAfter = new Date(); - c1.validity.notAfter.setFullYear(c1.validity.notBefore.getFullYear() + 1); - const attrs1 = [{ name: 'commonName', value: CN1 }]; - c1.setSubject(attrs1); - c1.setIssuer(attrs1); - c1.sign(keys1.privateKey, forge.md.sha256.create()); - cert1Pem = forge.pki.certificateToPem(c1); - - // Build cert 2 - const keys2 = forge.pki.rsa.generateKeyPair(1024); - const c2 = forge.pki.createCertificate(); - c2.publicKey = keys2.publicKey; - c2.serialNumber = '02'; - c2.validity.notBefore = new Date(); - c2.validity.notAfter = new Date(); - c2.validity.notAfter.setFullYear(c2.validity.notBefore.getFullYear() + 1); - const attrs2 = [{ name: 'commonName', value: CN2 }]; - c2.setSubject(attrs2); - c2.setIssuer(attrs2); - c2.sign(keys2.privateKey, forge.md.sha256.create()); - cert2Pem = forge.pki.certificateToPem(c2); - - // Fullchain fixture: two PEMs concatenated - const fullchainPem = `${cert1Pem}\n${cert2Pem}`; - fullchainBuffer = Buffer.from(fullchainPem, 'utf-8'); - - // P7B fixture: PEM-wrapped PKCS7 SignedData bundle with both certs - const p7 = forge.pkcs7.createSignedData(); - p7.addCertificate(c1); - p7.addCertificate(c2); - const p7DerBytes = forge.asn1.toDer(p7.toAsn1()).getBytes(); - const p7PemStr = forge.pem.encode({ type: 'PKCS7', body: p7DerBytes }); - p7bBuffer = Buffer.from(p7PemStr, 'utf-8'); - }, 30000); // 30s — two RSA-1024 keygens - - it('returns count 2 and two certs each with a single PEM block and correct CN for fullchain PEM', async () => { - // RED: splitCerts throws NotImplementedException — FAIL - const result = await (service.splitCerts({ - file: { originalname: 'fullchain.pem', buffer: fullchainBuffer }, - }) as Promise); - expect(result.count).toBe(2); - expect(result.certs).toHaveLength(2); - - // Each cert content decodes to exactly one BEGIN CERTIFICATE block - for (const entry of result.certs) { - const decoded = Buffer.from(entry.content, 'base64').toString('utf-8'); - const matches = decoded.match(/-----BEGIN CERTIFICATE-----/g); - expect(matches).toHaveLength(1); - } - - // CN values are present - const cns = result.certs.map((c: any) => c.subject.cn); - expect(cns).toContain(CN1); - expect(cns).toContain(CN2); - - // validity.notAfter is present and looks like ISO 8601 - for (const entry of result.certs) { - expect(entry.validity.notAfter).toMatch(/^\d{4}-\d{2}-\d{2}T/); - } - }); - - it('returns enclosed certs from a P7B PEM bundle', async () => { - // RED: splitCerts throws NotImplementedException — FAIL - const result = await (service.splitCerts({ - file: { originalname: 'bundle.p7b', buffer: p7bBuffer }, - }) as Promise); - expect(result.count).toBeGreaterThanOrEqual(1); - expect(result.certs.length).toBeGreaterThanOrEqual(1); - }); - - it('throws BadRequestException for malformed input', async () => { - // RED: splitCerts throws NotImplementedException (not BadRequestException) — FAIL - await expect( - service.splitCerts({ - file: { originalname: 'bad.pem', buffer: Buffer.from('this is garbage') }, - }), - ).rejects.toThrow(BadRequestException); - }); -}); - -// --------------------------------------------------------------------------- -// mergeCerts — RED tests (CERT-03, CERT-05 write half) -// --------------------------------------------------------------------------- - -describe('mergeCerts', () => { - let service: CertManagerService; - let certAPem: string; - let certBPem: string; - const CNA = 'merge-a.example.com'; - const CNB = 'merge-b.example.com'; - - beforeAll(() => { - service = new CertManagerService(); - - // Cert A (RSA-1024 for speed) - const keysA = forge.pki.rsa.generateKeyPair(1024); - const certA = forge.pki.createCertificate(); - certA.publicKey = keysA.publicKey; - certA.serialNumber = '01'; - certA.validity.notBefore = new Date(); - certA.validity.notAfter = new Date(); - certA.validity.notAfter.setFullYear(certA.validity.notBefore.getFullYear() + 1); - const attrsA = [{ name: 'commonName', value: CNA }]; - certA.setSubject(attrsA); - certA.setIssuer(attrsA); - certA.sign(keysA.privateKey, forge.md.sha256.create()); - certAPem = forge.pki.certificateToPem(certA); - - // Cert B - const keysB = forge.pki.rsa.generateKeyPair(1024); - const certB = forge.pki.createCertificate(); - certB.publicKey = keysB.publicKey; - certB.serialNumber = '02'; - certB.validity.notBefore = new Date(); - certB.validity.notAfter = new Date(); - certB.validity.notAfter.setFullYear(certB.validity.notBefore.getFullYear() + 1); - const attrsB = [{ name: 'commonName', value: CNB }]; - certB.setSubject(attrsB); - certB.setIssuer(attrsB); - certB.sign(keysB.privateKey, forge.md.sha256.create()); - certBPem = forge.pki.certificateToPem(certB); - }, 30000); // 30s — two RSA-1024 keygens - - it('returns PEM chain with 2 BEGIN CERTIFICATE blocks when merging 2 PEM files', async () => { - // RED: mergeCerts throws NotImplementedException — FAIL - const result = await (service.mergeCerts({ - files: [ - { originalname: 'certA.pem', buffer: Buffer.from(certAPem, 'utf-8') }, - { originalname: 'certB.pem', buffer: Buffer.from(certBPem, 'utf-8') }, - ], - outputFormat: 'pem', - }) as Promise); - - expect(result.mimeType).toBe('application/x-pem-file'); - expect(result.filename).toContain('chain'); - - // Decoded content must have exactly 2 cert blocks - const decoded = Buffer.from(result.content as string, 'base64').toString('utf-8'); - const blocks = decoded.match(/-----BEGIN CERTIFICATE-----/g); - expect(blocks).toHaveLength(2); - }); - - it('returns password-protected PFX that round-trips with the correct password', async () => { - // RED: mergeCerts throws NotImplementedException — FAIL - // Note: 2-file minimum is enforced at the controller level; service accepts 1 file for PFX - const result = await (service.mergeCerts({ - files: [ - { originalname: 'certA.pem', buffer: Buffer.from(certAPem, 'utf-8') }, - ], - outputFormat: 'pfx', - password: 'secret', - }) as Promise); - - expect(result.mimeType).toBe('application/x-pkcs12'); - expect(result.filename).toBe('bundle.pfx'); - - // Round-trip: decode base64 PFX → re-parse with password 'secret' → verify cert bag present - const pfxBuf = Buffer.from(result.content as string, 'base64'); - const p12Asn1 = forge.asn1.fromDer( - forge.util.createBuffer(pfxBuf.toString('binary')), - ); - // Should NOT throw with the correct password (Open Question 1 resolution) - const p12 = forge.pkcs12.pkcs12FromAsn1(p12Asn1, 'secret'); - const certBags = p12.getBags({ bagType: forge.pki.oids.certBag }); - const bags = certBags[forge.pki.oids.certBag] ?? []; - expect(bags.length).toBeGreaterThanOrEqual(1); - }); - - it('throws BadRequestException when PFX output is requested without a password', async () => { - // RED: mergeCerts throws NotImplementedException (not BadRequestException) — FAIL - await expect( - service.mergeCerts({ - files: [ - { originalname: 'certA.pem', buffer: Buffer.from(certAPem, 'utf-8') }, - { originalname: 'certB.pem', buffer: Buffer.from(certBPem, 'utf-8') }, - ], - outputFormat: 'pfx', - // no password — should be rejected - }), - ).rejects.toThrow(BadRequestException); - }); - - it('throws BadRequestException for malformed (garbage) input files', async () => { - // RED: mergeCerts throws NotImplementedException (not BadRequestException) — FAIL - await expect( - service.mergeCerts({ - files: [ - { originalname: 'bad.pem', buffer: Buffer.from('this is garbage') }, - { originalname: 'bad2.pem', buffer: Buffer.from('also garbage') }, - ], - outputFormat: 'pem', - }), - ).rejects.toThrow(BadRequestException); - }); -}); - -// --------------------------------------------------------------------------- -// convertCert — RED tests (CERT-04) -// --------------------------------------------------------------------------- - -describe('convertCert', () => { - let service: CertManagerService; - let certPem: string; - let certDerBuffer: Buffer; - const CN = 'convert.example.com'; - - beforeAll(() => { - service = new CertManagerService(); - - // RSA-1024 self-signed cert (fast for tests) - const keys = forge.pki.rsa.generateKeyPair(1024); - const cert = forge.pki.createCertificate(); - cert.publicKey = keys.publicKey; - cert.serialNumber = '01'; - cert.validity.notBefore = new Date(); - cert.validity.notAfter = new Date(); - cert.validity.notAfter.setFullYear(cert.validity.notBefore.getFullYear() + 1); - - const attrs = [{ name: 'commonName', value: CN }]; - cert.setSubject(attrs); - cert.setIssuer(attrs); - cert.sign(keys.privateKey, forge.md.sha256.create()); - - // PEM fixture - certPem = forge.pki.certificateToPem(cert); - - // DER fixture — binary encoding required (Pitfall 1) - const derBytes = forge.asn1.toDer(forge.pki.certificateToAsn1(cert)).getBytes(); - certDerBuffer = Buffer.from(derBytes, 'binary'); - }, 15000); - - it('PEM→DER: returns FileResponse with correct mimeType and round-trip identity', async () => { - // RED: convertCert throws NotImplementedException — this test will FAIL - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const result = await (service.convertCert({ pemText: certPem, targetFormat: 'der' } as any) as Promise); - expect(result.mimeType).toBe('application/x-x509-ca-cert'); - expect(result.filename).toContain('converted'); - expect(result.content).toBeTruthy(); - - // Round-trip identity: decode base64 DER → re-parse → verify CN matches original - const derBuf = Buffer.from(result.content as string, 'base64'); - const asn1 = forge.asn1.fromDer(forge.util.createBuffer(derBuf.toString('binary'))); - const reparsed = forge.pki.certificateFromAsn1(asn1); - expect(reparsed.subject.getField('CN')?.value).toBe(CN); - }); - - it('DER→PEM: returned PEM parses to cert with same CN (round-trip identity)', async () => { - // RED: convertCert throws NotImplementedException — this test will FAIL - const result = await (service.convertCert({ - file: { originalname: 'c.der', buffer: certDerBuffer }, - targetFormat: 'pem', - // eslint-disable-next-line @typescript-eslint/no-explicit-any - } as any) as Promise); - expect(result.mimeType).toBe('application/x-pem-file'); - - // Decode base64 PEM content → parse → verify CN matches original - const pemStr = Buffer.from(result.content as string, 'base64').toString('utf-8'); - const reparsed = forge.pki.certificateFromPem(pemStr); - expect(reparsed.subject.getField('CN')?.value).toBe(CN); - }); - - it('PEM→P7B: returned P7B contains the original certificate', async () => { - // RED: convertCert throws NotImplementedException — this test will FAIL - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const result = await (service.convertCert({ pemText: certPem, targetFormat: 'p7b' } as any) as Promise); - expect(result.mimeType).toBe('application/x-pkcs7-certificates'); - expect(result.filename).toContain('converted'); - - // Decode base64 P7B (PEM-wrapped PKCS7) and verify at least 1 cert enclosed - const p7bContent = Buffer.from(result.content as string, 'base64').toString('utf-8'); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const p7 = forge.pkcs7.messageFromPem(p7bContent) as any; - expect((p7.certificates as forge.pki.Certificate[]).length).toBeGreaterThanOrEqual(1); - }); - - it('throws BadRequestException for malformed PEM input', async () => { - // RED: convertCert throws NotImplementedException (not BadRequestException) — FAIL - await expect( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - service.convertCert({ pemText: 'garbage input not a cert', targetFormat: 'der' } as any), - ).rejects.toThrow(BadRequestException); - }); -}); - -// --------------------------------------------------------------------------- -// PFX mit unlesbarem Zertifikats-Bag — bag.cert = null (quick-260921-iwr, D-01/D-04) -// -// node-forge setzt bag.cert bei einem wohlgeformten, aber nicht als X.509 -// lesbaren Zertifikats-Bag auf null (lib/pkcs12.js Zeile 703-709: der Fehler -// aus certificateFromAsn1 wird abgefangen und durch bag.cert = null ersetzt). -// Die drei betroffenen Zusicherungen (parseCert Zeile 207, mergeCerts Zeile -// 469, convertCert Zeile 602) behaupten also etwas, das node-forge selbst -// widerlegt. Alle vier Pfade antworteten schon vorher mit 400 statt 500 — die -// hier gepruefte Aenderung ist die Meldung, nicht der Statuscode (D-02). -// --------------------------------------------------------------------------- - -describe('PFX mit unlesbarem Zertifikats-Bag (bag.cert = null)', () => { - let malformedPfxBuffer: Buffer; - - beforeAll(() => { - // Handgebaute ~83-Byte-PFX-Datei mit forge.asn1: wohlgeformte DER-Struktur - // von aussen nach innen (PFX -> ContentInfo -> AuthenticatedSafe -> - // ContentInfo -> SafeContents -> SafeBag -> CertBag), deren - // Zertifikats-Bag-Inhalt aber nur "SEQUENCE { INTEGER 1 }" ist — kein - // X.509-Zertifikat. Passwort ist die leere Zeichenkette. - const fakeCertAsn1 = forge.asn1.create( - forge.asn1.Class.UNIVERSAL, - forge.asn1.Type.SEQUENCE, - true, - [forge.asn1.create(forge.asn1.Class.UNIVERSAL, forge.asn1.Type.INTEGER, false, String.fromCharCode(1))], - ); - const fakeCertDer = forge.asn1.toDer(fakeCertAsn1).getBytes(); - - const certBagAsn1 = forge.asn1.create( - forge.asn1.Class.UNIVERSAL, - forge.asn1.Type.SEQUENCE, - true, - [ - forge.asn1.create( - forge.asn1.Class.UNIVERSAL, - forge.asn1.Type.OID, - false, - forge.asn1.oidToDer(forge.pki.oids.x509Certificate).getBytes(), - ), - forge.asn1.create(forge.asn1.Class.CONTEXT_SPECIFIC, 0, true, [ - forge.asn1.create(forge.asn1.Class.UNIVERSAL, forge.asn1.Type.OCTETSTRING, false, fakeCertDer), - ]), - ], - ); - - const safeBagAsn1 = forge.asn1.create( - forge.asn1.Class.UNIVERSAL, - forge.asn1.Type.SEQUENCE, - true, - [ - forge.asn1.create( - forge.asn1.Class.UNIVERSAL, - forge.asn1.Type.OID, - false, - forge.asn1.oidToDer(forge.pki.oids.certBag).getBytes(), - ), - forge.asn1.create(forge.asn1.Class.CONTEXT_SPECIFIC, 0, true, [certBagAsn1]), - ], - ); - - const safeContentsAsn1 = forge.asn1.create( - forge.asn1.Class.UNIVERSAL, - forge.asn1.Type.SEQUENCE, - true, - [safeBagAsn1], - ); - const safeContentsDer = forge.asn1.toDer(safeContentsAsn1).getBytes(); - - const innerContentInfoAsn1 = forge.asn1.create( - forge.asn1.Class.UNIVERSAL, - forge.asn1.Type.SEQUENCE, - true, - [ - forge.asn1.create( - forge.asn1.Class.UNIVERSAL, - forge.asn1.Type.OID, - false, - forge.asn1.oidToDer(forge.pki.oids.data).getBytes(), - ), - forge.asn1.create(forge.asn1.Class.CONTEXT_SPECIFIC, 0, true, [ - forge.asn1.create(forge.asn1.Class.UNIVERSAL, forge.asn1.Type.OCTETSTRING, false, safeContentsDer), - ]), - ], - ); - - const authSafeAsn1 = forge.asn1.create( - forge.asn1.Class.UNIVERSAL, - forge.asn1.Type.SEQUENCE, - true, - [innerContentInfoAsn1], - ); - const authSafeDer = forge.asn1.toDer(authSafeAsn1).getBytes(); - - const outerContentInfoAsn1 = forge.asn1.create( - forge.asn1.Class.UNIVERSAL, - forge.asn1.Type.SEQUENCE, - true, - [ - forge.asn1.create( - forge.asn1.Class.UNIVERSAL, - forge.asn1.Type.OID, - false, - forge.asn1.oidToDer(forge.pki.oids.data).getBytes(), - ), - forge.asn1.create(forge.asn1.Class.CONTEXT_SPECIFIC, 0, true, [ - forge.asn1.create(forge.asn1.Class.UNIVERSAL, forge.asn1.Type.OCTETSTRING, false, authSafeDer), - ]), - ], - ); - - const pfxAsn1 = forge.asn1.create( - forge.asn1.Class.UNIVERSAL, - forge.asn1.Type.SEQUENCE, - true, - [ - forge.asn1.create(forge.asn1.Class.UNIVERSAL, forge.asn1.Type.INTEGER, false, String.fromCharCode(3)), - outerContentInfoAsn1, - ], - ); - - const pfxDer = forge.asn1.toDer(pfxAsn1).getBytes(); - malformedPfxBuffer = Buffer.from(pfxDer, 'binary'); - }); - - it('Vorbedingung: forge liefert genau einen Bag mit cert=null fuer diese Datei (belegt, dass der Waechter den gemeinten Fall trifft)', () => { - expect(malformedPfxBuffer.length).toBe(83); - - const p12Asn1 = forge.asn1.fromDer( - forge.util.createBuffer(malformedPfxBuffer.toString('binary')), - ); - const p12 = forge.pkcs12.pkcs12FromAsn1(p12Asn1, ''); - const certBags = p12.getBags({ bagType: forge.pki.oids.certBag }); - const bags = certBags[forge.pki.oids.certBag] ?? []; - - expect(bags).toHaveLength(1); - expect(bags[0].cert).toBeNull(); - }); - - it('parseCert: wirft 400 mit einer Meldung, die den Zertifikats-Bag benennt, statt der irrefuehrenden "Failed to extract certificate details"', async () => { - const service = new CertManagerService(); - - await expect( - service.parseCert({ - file: { originalname: 'bad.pfx', buffer: malformedPfxBuffer }, - password: '', - }), - ).rejects.toThrow(BadRequestException); - - await expect( - service.parseCert({ - file: { originalname: 'bad.pfx', buffer: malformedPfxBuffer }, - password: '', - }), - ).rejects.toThrow(/certificate bag/i); - }); - - it('mergeCerts (outputFormat pem): wirft 400 mit derselben praezisen Aussage statt "Failed to create merged certificate output"', async () => { - const service = new CertManagerService(); - - await expect( - service.mergeCerts({ - files: [{ originalname: 'bad.pfx', buffer: malformedPfxBuffer }], - outputFormat: 'pem', - password: '', - }), - ).rejects.toThrow(BadRequestException); - - await expect( - service.mergeCerts({ - files: [{ originalname: 'bad.pfx', buffer: malformedPfxBuffer }], - outputFormat: 'pem', - password: '', - }), - ).rejects.toThrow(/certificate bag/i); - }); - - it('mergeCerts (outputFormat pfx): wirft 400 mit derselben praezisen Aussage statt "Failed to create merged certificate output"', async () => { - const service = new CertManagerService(); - - await expect( - service.mergeCerts({ - files: [{ originalname: 'bad.pfx', buffer: malformedPfxBuffer }], - outputFormat: 'pfx', - password: 'secret', - }), - ).rejects.toThrow(BadRequestException); - - await expect( - service.mergeCerts({ - files: [{ originalname: 'bad.pfx', buffer: malformedPfxBuffer }], - outputFormat: 'pfx', - password: 'secret', - }), - ).rejects.toThrow(/certificate bag/i); - }); - - it('convertCert: wirft 400 mit einer Meldung, die den Zertifikats-Bag benennt, statt der irrefuehrenden "Failed to convert certificate to pem: serialization error"', async () => { - const service = new CertManagerService(); - - await expect( - // eslint-disable-next-line @typescript-eslint/no-explicit-any - service.convertCert({ - file: { originalname: 'bad.pfx', buffer: malformedPfxBuffer }, - password: '', - targetFormat: 'pem', - // eslint-disable-next-line @typescript-eslint/no-explicit-any - } as any), - ).rejects.toThrow(BadRequestException); - - await expect( - service.convertCert({ - file: { originalname: 'bad.pfx', buffer: malformedPfxBuffer }, - password: '', - targetFormat: 'pem', - // eslint-disable-next-line @typescript-eslint/no-explicit-any - } as any), - ).rejects.toThrow(/certificate bag/i); - }); - - it('eine gueltige PFX-Datei verhaelt sich unveraendert: parseCert liefert weiterhin die CertDetails', async () => { - const service = new CertManagerService(); - - const keys = forge.pki.rsa.generateKeyPair(1024); - const cert = forge.pki.createCertificate(); - cert.publicKey = keys.publicKey; - cert.serialNumber = '01'; - cert.validity.notBefore = new Date(); - cert.validity.notAfter = new Date(); - cert.validity.notAfter.setFullYear(cert.validity.notBefore.getFullYear() + 1); - const attrs = [{ name: 'commonName', value: 'valid-pfx.example.com' }]; - cert.setSubject(attrs); - cert.setIssuer(attrs); - cert.sign(keys.privateKey, forge.md.sha256.create()); - - const p12Asn1 = forge.pkcs12.toPkcs12Asn1(keys.privateKey, [cert], 'secret', { - algorithm: '3des', - }); - const validPfxBuffer = Buffer.from(forge.asn1.toDer(p12Asn1).getBytes(), 'binary'); - - const result = await (service.parseCert({ - file: { originalname: 'valid.pfx', buffer: validPfxBuffer }, - password: 'secret', - // eslint-disable-next-line @typescript-eslint/no-explicit-any - }) as Promise); - - expect(result.subject.cn).toBe('valid-pfx.example.com'); - }, 15000); -}); diff --git a/apps/api/src/cert-manager/cert-manager.service.ts b/apps/api/src/cert-manager/cert-manager.service.ts deleted file mode 100644 index 76d95e4..0000000 --- a/apps/api/src/cert-manager/cert-manager.service.ts +++ /dev/null @@ -1,793 +0,0 @@ -import { BadRequestException, Injectable, Logger } from '@nestjs/common'; -import * as forge from 'node-forge'; - -/** - * Was `forge.pkcs7.messageFromPem()` bzw. `messageFromAsn1()` zurueckgeben — - * der mitgelieferte Typ aus `@types/node-forge`, nicht ein eigener. - * - * Nur die signierte Form traegt `certificates`; die Lesestellen grenzen - * deshalb mit `'certificates' in p7` ein. Das ist verhaltensgleich zum - * bisherigen `p7.certificates ?? []`: bei einer enveloped-Nachricht fehlt - * das Feld, und beide Schreibweisen liefern dann die leere Liste. - */ -type P7Message = forge.pkcs7.Captured< - forge.pkcs7.PkcsEnvelopedData | forge.pkcs7.PkcsSignedData ->; -import type { UploadedFileLike } from '../auth/types/auth-user'; - -/** - * Eine hochgeladene Zertifikatsdatei, so weit dieser Dienst sie liest: - * Inhalt und eingereichter Name (der Name geht ausschliesslich in - * `detectFormat` und in Fehlermeldungen). Abgeleitet aus `UploadedFileLike` - * statt daneben erfunden (quick-260921-m34); `mimetype` und `size` bleiben - * bewusst draussen, weil kein Zweig dieses Dienstes sie liest. - */ -type CertFileLike = Pick; - -// --------------------------------------------------------------------------- -// CertDetails — the structured result returned by parseCert -// --------------------------------------------------------------------------- - -export interface CertDetails { - subject: { cn: string; o: string; ou: string; c: string }; - issuer: { cn: string; o: string; c: string }; - validity: { notBefore: string; notAfter: string; isExpired: boolean; daysLeft: number }; - san: string[]; - keyType: string; // "RSA" | "EC" - keyBits: number; // 2048, 4096, 256, ... - serialNumber: string; - signatureAlgorithm: string; // "sha256WithRSAEncryption", etc. - fingerprint: { sha1: string; sha256: string }; - pemPreview: string; -} - -// --------------------------------------------------------------------------- -// SplitResponse — the structured result returned by splitCerts -// --------------------------------------------------------------------------- - -export type CertRole = 'root' | 'intermediate' | 'end-entity'; - -export interface SplitEntry { - index: number; - filename: string; - /** PEM content base64-encoded (one BEGIN CERTIFICATE block per entry) */ - content: string; - subject: { cn: string }; - validity: { notAfter: string }; - certRole: CertRole; -} - -export interface SplitResponse { - count: number; - certs: SplitEntry[]; -} - -// --------------------------------------------------------------------------- -// FileResponse — the structured result returned by convertCert / mergeCerts -// --------------------------------------------------------------------------- - -export interface FileResponse { - /** Suggested download filename, e.g. "converted.der" */ - filename: string; - /** Base64-encoded file content */ - content: string; - /** MIME type for the download */ - mimeType: string; -} - -/** Map from target format key to MIME type */ -const FORMAT_MIME: Record = { - pem: 'application/x-pem-file', - der: 'application/x-x509-ca-cert', - p7b: 'application/x-pkcs7-certificates', - pfx: 'application/x-pkcs12', -}; - -// --------------------------------------------------------------------------- -// Reverse OID map (OID string -> human-readable algorithm name) -// Built once at module load — node-forge's pki.oids is name->OID -// --------------------------------------------------------------------------- -function buildReverseOids(): Record { - const result: Record = {}; - for (const [name, oid] of Object.entries(forge.pki.oids as Record)) { - result[oid] = name; - } - return result; -} -const REVERSE_OIDS = buildReverseOids(); - -/** - * CertManagerService — server-side certificate operations. - * - * All cryptographic processing is ephemeral (upload → process → return). - * No data is persisted to disk or database. - * - * SECURITY NOTES: - * - Binary buffers MUST use toString('binary') for forge (never 'utf-8' — Pitfall 1) - * - Password parameters are never passed to the logger - * - All forge operations wrapped in try/catch → BadRequestException - */ -@Injectable() -export class CertManagerService { - private readonly logger = new Logger(CertManagerService.name); - - // --------------------------------------------------------------------------- - // Shared helpers (used by all operation methods) - // --------------------------------------------------------------------------- - - /** - * Detect the format of a certificate file from extension + content sniff. - * .cer is ambiguous — resolved by inspecting the first bytes of the buffer. - */ - detectFormat( - filename: string, - buffer: Buffer, - ): 'pem' | 'der' | 'pfx' | 'p7b' { - const ext = filename.split('.').pop()?.toLowerCase() ?? ''; - const isPemContent = buffer.slice(0, 27).toString('ascii').includes('-----BEGIN'); - - if (ext === 'pfx' || ext === 'p12') return 'pfx'; - if (ext === 'p7b' || ext === 'p7c') return 'p7b'; - if (ext === 'der') return 'der'; - if (ext === 'pem' || ext === 'crt') return 'pem'; - if (ext === 'cer') return isPemContent ? 'pem' : 'der'; // .cer is ambiguous - // Fallback: sniff content - return isPemContent ? 'pem' : 'der'; - } - - /** - * Convert a Node.js Buffer to a forge ByteStringBuffer using 'binary' encoding. - * - * CRITICAL: Always use 'binary' encoding — UTF-8 corrupts DER/PFX/P7B binary data. - * See RESEARCH.md Pitfall 1. - */ - toForgeBuffer(buffer: Buffer): forge.util.ByteStringBuffer { - return forge.util.createBuffer(buffer.toString('binary')); - } - - /** - * Compute SHA-1 or SHA-256 fingerprint of a certificate. - * Hash is computed over the DER-encoded bytes, returned as uppercase colon-joined hex. - */ - getFingerprint(cert: forge.pki.Certificate, algorithm: 'sha1' | 'sha256'): string { - const md = algorithm === 'sha1' ? forge.md.sha1.create() : forge.md.sha256.create(); - const der = forge.asn1.toDer(forge.pki.certificateToAsn1(cert)).getBytes(); - md.update(der); - return md.digest().toHex().match(/.{2}/g)!.join(':').toUpperCase(); - } - - /** - * Split a PEM string containing one or more concatenated certificates. - * Returns an array of parsed forge Certificate objects. - */ - parsePemChain(pem: string): forge.pki.Certificate[] { - const blocks = - pem.match(/-----BEGIN CERTIFICATE-----[\s\S]+?-----END CERTIFICATE-----/g) ?? []; - return blocks.map((b) => forge.pki.certificateFromPem(b)); - } - - private detectCertRole(cert: forge.pki.Certificate): CertRole { - const bc = cert.getExtension('basicConstraints') as { cA?: boolean } | null; - if (!bc?.cA) return 'end-entity'; - // Self-signed = subject hash matches issuer hash → Root CA - return cert.subject.hash === cert.issuer.hash ? 'root' : 'intermediate'; - } - - // --------------------------------------------------------------------------- - // parseCert — CERT-01 + CERT-05 (read half) - // --------------------------------------------------------------------------- - - /** - * Parse a certificate from PEM text or an uploaded file (PEM/DER/PFX/P7B). - * - * Security contract (T-09-01, T-09-02): - * - All forge calls wrapped in try/catch → BadRequestException (never an unhandled 500) - * - Wrong PFX password → generic 400 message (password value never logged or echoed) - */ - async parseCert(input: { - file?: CertFileLike; - pemText?: string; - password?: string; - }): Promise { - const { file, pemText, password } = input; - - let cert: forge.pki.Certificate; - - try { - if (pemText) { - // ── PEM text input ────────────────────────────────────────────────── - const certs = this.parsePemChain(pemText); - if (certs.length === 0) { - throw new Error('No certificate block found in PEM text'); - } - cert = certs[0]; - } else if (file) { - const format = this.detectFormat(file.originalname, file.buffer); - - if (format === 'pem') { - // ── PEM file ─────────────────────────────────────────────────────── - const pemStr = file.buffer.toString('utf-8'); - const certs = this.parsePemChain(pemStr); - if (certs.length === 0) { - throw new Error('No certificate block found in PEM file'); - } - cert = certs[0]; - } else if (format === 'der') { - // ── DER binary file ──────────────────────────────────────────────── - // CRITICAL: binary encoding, never utf-8 (RESEARCH Pitfall 1) - const asn1 = forge.asn1.fromDer(this.toForgeBuffer(file.buffer)); - cert = forge.pki.certificateFromAsn1(asn1); - } else if (format === 'pfx') { - // ── PFX/PKCS12 file ─────────────────────────────────────────────── - // wrong password → forge throws → caught below → BadRequestException (T-09-02) - const p12Asn1 = forge.asn1.fromDer(this.toForgeBuffer(file.buffer)); - const p12 = forge.pkcs12.pkcs12FromAsn1(p12Asn1, password ?? ''); - const certBags = p12.getBags({ bagType: forge.pki.oids.certBag }); - const bags = certBags[forge.pki.oids.certBag] ?? []; - if (bags.length === 0) { - throw new Error('No certificate bag found in PFX/PKCS12'); - } - // node-forge sets bag.cert to null when the bag's content parses as - // valid DER but is not a readable X.509 certificate (lib/pkcs12.js - // certBag decoder). An explicit guard here — not an assertion — so - // the 400 names the real cause (quick-260921-iwr, D-01/D-04). - const parsedCert = bags[0].cert; - if (!parsedCert) { - throw new BadRequestException( - 'Certificate bag in PFX/PKCS12 does not contain a readable X.509 certificate', - ); - } - cert = parsedCert; - } else { - // ── P7B/PKCS7 file — PEM-wrapped or binary DER (Pitfall 4) ──────── - const isPemP7b = file.buffer - .slice(0, 27) - .toString('ascii') - .includes('-----BEGIN'); - // siehe P7Message oben — mitgelieferter Typ, keine Behauptung. - let p7: P7Message; - if (isPemP7b) { - p7 = forge.pkcs7.messageFromPem(file.buffer.toString('utf-8')); - } else { - const p7Asn1 = forge.asn1.fromDer(this.toForgeBuffer(file.buffer)); - p7 = forge.pkcs7.messageFromAsn1(p7Asn1); - } - const p7Certs: forge.pki.Certificate[] = - 'certificates' in p7 ? p7.certificates : []; - if (p7Certs.length === 0) { - throw new Error('No certificate found in P7B/PKCS7'); - } - cert = p7Certs[0]; - } - } else { - // Neither file nor pemText — controller should have rejected this already, - // but guard here too (BadRequestException is NOT caught by the outer try/catch below) - throw new BadRequestException('No file or PEM text provided'); - } - } catch (err) { - // Re-throw BadRequestException as-is; convert everything else to 400 - if (err instanceof BadRequestException) throw err; - // Do NOT log the password (T-09-02) - this.logger.warn('parseCert: failed to parse certificate (format/password error)'); - throw new BadRequestException( - 'Failed to parse certificate: invalid format or wrong password', - ); - } - - // ── Build CertDetails ────────────────────────────────────────────────── - try { - const notBefore = cert.validity.notBefore; - const notAfter = cert.validity.notAfter; - const now = new Date(); - const isExpired = notAfter < now; - const daysLeft = Math.ceil( - (notAfter.getTime() - now.getTime()) / (1000 * 60 * 60 * 24), - ); - - // Key type and size - // BLEIBT als any, mit Begruendung (260921-m34, Aufgabe 3c, D-01/D-02): - // @types/node-forge kennt nur `PublicKey = rsa.PublicKey | ed25519.Key` - // (index.d.ts:232). Der EC-Zweig unten liest `curve` und - // `params.curve.q.bitLength()` — Felder, die node-forge zur Laufzeit - // liefert, die der mitgelieferte Typ aber GAR NICHT kennt. Eine - // Umdeutung ueber zwei Stufen wuerde dieselbe Luecke verdecken und - // zusaetzlich so aussehen, als sei sie geprueft. Ein ehrliches any mit - // dieser Zeile ist hier das bessere Ergebnis. - const pubKey = cert.publicKey as any; - let keyType = 'RSA'; - let keyBits = 0; - if (pubKey.n) { - keyType = 'RSA'; - keyBits = pubKey.n.bitLength(); - } else if (pubKey.curve) { - keyType = 'EC'; - // EC key size from curve params — estimate from key length - keyBits = pubKey.params?.curve?.q?.bitLength() ?? 0; - } - - // Subject Alternative Names - // - // BLEIBEN als any, mit Begruendung (260921-m34, Aufgabe 3c, D-01/D-02): - // @types/node-forge deklariert `Certificate.extensions` als `any[]` - // (index.d.ts:435) und sagt damit ueber den Inhalt einer Erweiterung - // NICHTS aus. Jede Schnittstelle, die wir hier selbst fuer `altNames` - // schrieben, waere unbelegt — der Compiler koennte sie an keiner - // Stelle gegen etwas pruefen, sie saehe aber geprueft aus. Die drei - // any-Stellen dieses Blocks bleiben deshalb sichtbar stehen, statt - // gegen eine Behauptung getauscht zu werden. - const sanExt = cert.extensions?.find((e: any) => e.name === 'subjectAltName'); - const san: string[] = ((sanExt as any)?.altNames ?? []).map((n: any) => - n.type === 2 ? (n.value as string) : `IP:${(n.ip ?? n.value) as string}`, - ); - - // Signature algorithm — OID → human-readable name - // @types/node-forge deklariert siginfo.algorithmOid als string — die - // Zusicherung war ueberfluessig. Das ?. bleibt woertlich erhalten. - const sigOid = cert.siginfo?.algorithmOid ?? ''; - const signatureAlgorithm = REVERSE_OIDS[sigOid] ?? sigOid; - - // Fingerprints - const sha1 = this.getFingerprint(cert, 'sha1'); - const sha256 = this.getFingerprint(cert, 'sha256'); - - return { - subject: { - cn: cert.subject.getField('CN')?.value ?? '', - o: cert.subject.getField('O')?.value ?? '', - ou: cert.subject.getField('OU')?.value ?? '', - c: cert.subject.getField('C')?.value ?? '', - }, - issuer: { - cn: cert.issuer.getField('CN')?.value ?? '', - o: cert.issuer.getField('O')?.value ?? '', - c: cert.issuer.getField('C')?.value ?? '', - }, - validity: { - notBefore: notBefore.toISOString(), - notAfter: notAfter.toISOString(), - isExpired, - daysLeft, - }, - san, - keyType, - keyBits, - serialNumber: cert.serialNumber, - signatureAlgorithm, - fingerprint: { sha1, sha256 }, - pemPreview: forge.pki.certificateToPem(cert), - }; - } catch { - this.logger.warn('parseCert: failed to extract CertDetails fields'); - throw new BadRequestException('Failed to extract certificate details'); - } - } - - // --------------------------------------------------------------------------- - // Remaining operation stubs (implemented in later plan slices) - // --------------------------------------------------------------------------- - - /** - * Split a fullchain PEM or P7B/PKCS7 bundle into individual certificates. - * - * Security contract (T-09-01): - * - All forge calls wrapped in try/catch → BadRequestException on malformed input - * - * Security contract (T-09-03): - * - File size limit 5 MB enforced by FileInterceptor in the controller - */ - async splitCerts(input: { - file?: CertFileLike; - password?: string; - }): Promise { - const { file } = input; - - if (!file) { - throw new BadRequestException('No file provided'); - } - - let certs: forge.pki.Certificate[]; - - try { - const format = this.detectFormat(file.originalname, file.buffer); - - if (format === 'pem') { - // ── PEM chain (fullchain.pem, .crt — both map to 'pem' in detectFormat) ─ - const pemStr = file.buffer.toString('utf-8'); - certs = this.parsePemChain(pemStr); - if (certs.length === 0) { - throw new Error('No certificate blocks found in PEM file'); - } - } else if (format === 'p7b') { - // ── P7B/PKCS7 bundle — PEM-wrapped or binary DER (Pitfall 4) ───────── - const isPemP7b = file.buffer - .slice(0, 27) - .toString('ascii') - .includes('-----BEGIN'); - // Der mitgelieferte Typ traegt hier: messageFromPem/messageFromAsn1 - // liefern beide Captured. - let p7: P7Message; - if (isPemP7b) { - // PEM-wrapped PKCS7 (e.g. -----BEGIN PKCS7-----) - p7 = forge.pkcs7.messageFromPem(file.buffer.toString('utf-8')); - } else { - // Binary DER PKCS7 - const p7Asn1 = forge.asn1.fromDer(this.toForgeBuffer(file.buffer)); - p7 = forge.pkcs7.messageFromAsn1(p7Asn1); - } - certs = 'certificates' in p7 ? p7.certificates : []; - if (certs.length === 0) { - throw new Error('No certificates found in P7B/PKCS7 bundle'); - } - } else { - // DER / PFX — not a valid chain/bundle format for splitting - throw new BadRequestException( - 'Only PEM chains (.pem, .crt) and P7B bundles (.p7b) can be split', - ); - } - } catch (err) { - if (err instanceof BadRequestException) throw err; - this.logger.warn('splitCerts: failed to parse bundle'); - throw new BadRequestException('Failed to split certificates: invalid format or corrupted file'); - } - - // ── Determine cert roles ─────────────────────────────────────────────── - const roles: CertRole[] = certs.map((cert) => this.detectCertRole(cert)); - - // Build counters for filename disambiguation - const roleCounters: Record = { root: 0, intermediate: 0, 'end-entity': 0 }; - const roleFilename = (role: CertRole): string => { - roleCounters[role]++; - const n = roleCounters[role]; - if (role === 'root') return n === 1 ? 'root-ca.pem' : `root-ca-${n}.pem`; - if (role === 'intermediate') return `intermediate-${n}.pem`; - return n === 1 ? 'cert.pem' : `cert-${n}.pem`; - }; - - // ── Build SplitResponse ──────────────────────────────────────────────── - const certEntries: SplitEntry[] = certs.map((cert, index) => { - const pemStr = forge.pki.certificateToPem(cert); - const content = Buffer.from(pemStr, 'utf-8').toString('base64'); - const cn: string = cert.subject.getField('CN')?.value ?? ''; - const notAfter: string = cert.validity.notAfter.toISOString(); - const certRole = roles[index]; - - return { - index, - filename: roleFilename(certRole), - content, - subject: { cn }, - validity: { notAfter }, - certRole, - }; - }); - - return { - count: certEntries.length, - certs: certEntries, - }; - } - - /** - * Merge multiple certificate files into a PEM chain or a password-protected PFX/PKCS12 bundle. - * - * Security contract (T-09-01, T-09-02, T-09-03): - * - All forge calls wrapped in try/catch → BadRequestException on malformed input - * - Password required for PFX output; never logged or echoed - * - File size limit enforced by FilesInterceptor (controller level) - * - * Open Question 1 resolution: `forge.pkcs12.toPkcs12Asn1(null, certs, password)` was tested - * at implementation time — node-forge 1.4.0 accepts null as the private key for cert-only PFX. - * No fallback to lower-level certBag construction was needed. - */ - async mergeCerts(input: { - files?: CertFileLike[]; - outputFormat: string; - password?: string; - }): Promise { - const { files, outputFormat, password } = input; - - if (!files || files.length === 0) { - throw new BadRequestException('No files provided'); - } - - // PFX output requires a non-empty password (T-09-02) - if (outputFormat === 'pfx' && (!password || password.trim() === '')) { - throw new BadRequestException('A password is required for PFX output'); - } - - // ── Parse all input files to forge Certificate objects ──────────────────── - let certs: forge.pki.Certificate[]; - - try { - certs = files.flatMap((file) => { - const format = this.detectFormat(file.originalname, file.buffer); - - if (format === 'pem') { - const pemStr = file.buffer.toString('utf-8'); - const parsed = this.parsePemChain(pemStr); - if (parsed.length === 0) { - throw new Error(`No certificate block found in ${file.originalname}`); - } - return parsed; - } else if (format === 'der') { - // CRITICAL: binary encoding, never utf-8 (RESEARCH Pitfall 1) - const asn1 = forge.asn1.fromDer(this.toForgeBuffer(file.buffer)); - return [forge.pki.certificateFromAsn1(asn1)]; - } else if (format === 'pfx') { - // Extract all certs from the PFX bag - const p12Asn1 = forge.asn1.fromDer(this.toForgeBuffer(file.buffer)); - const p12 = forge.pkcs12.pkcs12FromAsn1(p12Asn1, password ?? ''); - const certBags = p12.getBags({ bagType: forge.pki.oids.certBag }); - const bags = certBags[forge.pki.oids.certBag] ?? []; - // node-forge sets bag.cert to null when a bag's content parses as - // valid DER but is not a readable X.509 certificate (lib/pkcs12.js - // certBag decoder). No entry may be silently dropped (D-04) — check - // every bag and reject the whole file, naming it, before returning. - const bagCerts = bags.map((bag) => bag.cert); - // @types/node-forge declares Bag.cert as `Certificate | undefined`, - // but node-forge's own runtime sets it to `null` for an unreadable - // bag (lib/pkcs12.js certBag decoder) — check both, not just `=== - // undefined`, so the guard actually catches what the library does. - const missingCertIndex = bagCerts.findIndex((c) => c === undefined || c === null); - if (missingCertIndex !== -1) { - throw new BadRequestException( - `Certificate bag in "${file.originalname}" does not contain a readable X.509 certificate`, - ); - } - return bagCerts.filter((c): c is forge.pki.Certificate => c !== undefined && c !== null); - } else { - // P7B/PKCS7 — PEM-wrapped or binary DER (Pitfall 4) - const isPemP7b = file.buffer - .slice(0, 27) - .toString('ascii') - .includes('-----BEGIN'); - // siehe P7Message oben — mitgelieferter Typ, keine Behauptung. - let p7: P7Message; - if (isPemP7b) { - p7 = forge.pkcs7.messageFromPem(file.buffer.toString('utf-8')); - } else { - const p7Asn1 = forge.asn1.fromDer(this.toForgeBuffer(file.buffer)); - p7 = forge.pkcs7.messageFromAsn1(p7Asn1); - } - return 'certificates' in p7 ? p7.certificates : []; - } - }); - } catch (err) { - if (err instanceof BadRequestException) throw err; - // Password never logged (T-09-02) - this.logger.warn('mergeCerts: failed to parse one or more input files'); - throw new BadRequestException( - 'Failed to parse certificate files: invalid format or corrupted input', - ); - } - - if (certs.length === 0) { - throw new BadRequestException('No valid certificates found in uploaded files'); - } - - // ── Serialize to requested output format ────────────────────────────────── - try { - if (outputFormat === 'pem') { - // PEM chain: concatenate all certs - const chain = certs.map((cert) => forge.pki.certificateToPem(cert)).join('\n'); - const content = Buffer.from(chain, 'utf-8').toString('base64'); - return { - filename: 'chain.pem', - content, - mimeType: FORMAT_MIME.pem, - }; - } else if (outputFormat === 'pfx') { - // Open Question 1 resolution: toPkcs12Asn1(null, certs, password) works in node-forge 1.4.0 - // null as the private key produces a cert-only PKCS12 bundle (no key bag — cert bag only) - const p12Asn1 = forge.pkcs12.toPkcs12Asn1( - // BLEIBT (260921-m34, Aufgabe 3c): node-forge 1.4.0 nimmt hier einen - // fehlenden Schluessel an und erzeugt ein reines - // Zertifikatsbuendel; @types/node-forge schliesst null aus. Die - // mitgelieferten Typen beschreiben die Bibliothek an dieser Stelle - // also nachweislich falsch — ein erzwungener Typ waere eine - // Behauptung ueber etwas, das nicht stimmt. - null as any, // cert-only PFX — null key accepted by node-forge 1.4.0 - certs, - password!, - { algorithm: '3des' }, - ); - // CRITICAL: bytesToHex → Buffer.from(hex,'hex') — avoids utf-8 corruption (Pitfall 1) - const p12Hex = forge.util.bytesToHex(forge.asn1.toDer(p12Asn1).getBytes()); - const pfxBuffer = Buffer.from(p12Hex, 'hex'); - const content = pfxBuffer.toString('base64'); - return { - filename: 'bundle.pfx', - content, - mimeType: FORMAT_MIME.pfx, - }; - } else { - throw new BadRequestException( - `Unsupported output format: "${outputFormat}". Supported: pem, pfx`, - ); - } - } catch (err) { - if (err instanceof BadRequestException) throw err; - this.logger.warn('mergeCerts: failed to serialize merged output'); - throw new BadRequestException('Failed to create merged certificate output'); - } - } - - /** - * Convert a certificate between PEM, DER, and P7B formats. - * - * Security contract (T-09-01, T-09-06): - * - All forge calls wrapped in try/catch → BadRequestException on malformed input - * - DER output built via bytesToHex → Buffer.from(hex, 'hex') → base64 (never utf-8 round-trip) - * - Password is never passed to the logger (T-09-02) - */ - async convertCert(input: { - file?: CertFileLike; - pemText?: string; - targetFormat: string; - password?: string; - }): Promise { - const { file, pemText, targetFormat, password } = input; - - // ── Validate targetFormat ────────────────────────────────────────────── - if (!FORMAT_MIME[targetFormat]) { - throw new BadRequestException( - `Unsupported target format: "${targetFormat}". Supported: pem, der, p7b, pfx`, - ); - } - - // PFX output requires a non-empty password (T-09-02) - if (targetFormat === 'pfx' && (!password || password.trim() === '')) { - throw new BadRequestException('A password is required for PFX output'); - } - - let cert: forge.pki.Certificate; - - try { - // ── Resolve input to a forge Certificate ──────────────────────────── - if (pemText) { - // PEM text pasted by the user - const certs = this.parsePemChain(pemText); - if (certs.length === 0) { - throw new Error('No certificate block found in PEM text'); - } - cert = certs[0]; - } else if (file) { - const format = this.detectFormat(file.originalname, file.buffer); - - if (format === 'pem') { - const pemStr = file.buffer.toString('utf-8'); - const certs = this.parsePemChain(pemStr); - if (certs.length === 0) { - throw new Error('No certificate block found in PEM file'); - } - cert = certs[0]; - } else if (format === 'der') { - // CRITICAL: binary encoding, never utf-8 (RESEARCH Pitfall 1) - const asn1 = forge.asn1.fromDer(this.toForgeBuffer(file.buffer)); - cert = forge.pki.certificateFromAsn1(asn1); - } else if (format === 'pfx') { - // PFX/PKCS12 — extract first cert bag (wrong password → BadRequestException) - const p12Asn1 = forge.asn1.fromDer(this.toForgeBuffer(file.buffer)); - const p12 = forge.pkcs12.pkcs12FromAsn1(p12Asn1, password ?? ''); - const certBags = p12.getBags({ bagType: forge.pki.oids.certBag }); - const bags = certBags[forge.pki.oids.certBag] ?? []; - if (bags.length === 0) { - throw new Error('No certificate bag found in PFX/PKCS12'); - } - // node-forge sets bag.cert to null when the bag's content parses as - // valid DER but is not a readable X.509 certificate (lib/pkcs12.js - // certBag decoder). An explicit guard here — not an assertion — so - // the 400 names the real cause (quick-260921-iwr, D-01/D-04). - const parsedCert = bags[0].cert; - if (!parsedCert) { - throw new BadRequestException( - 'Certificate bag in PFX/PKCS12 does not contain a readable X.509 certificate', - ); - } - cert = parsedCert; - } else { - // P7B — extract first cert - const isPemP7b = file.buffer - .slice(0, 27) - .toString('ascii') - .includes('-----BEGIN'); - // siehe P7Message oben — mitgelieferter Typ, keine Behauptung. - let p7: P7Message; - if (isPemP7b) { - p7 = forge.pkcs7.messageFromPem(file.buffer.toString('utf-8')); - } else { - const p7Asn1 = forge.asn1.fromDer(this.toForgeBuffer(file.buffer)); - p7 = forge.pkcs7.messageFromAsn1(p7Asn1); - } - const p7Certs: forge.pki.Certificate[] = - 'certificates' in p7 ? p7.certificates : []; - if (p7Certs.length === 0) { - throw new Error('No certificate found in P7B/PKCS7'); - } - cert = p7Certs[0]; - } - } else { - throw new BadRequestException('No file or PEM text provided'); - } - } catch (err) { - if (err instanceof BadRequestException) throw err; - // Password never logged (T-09-02) - this.logger.warn('convertCert: failed to parse input certificate'); - throw new BadRequestException( - 'Failed to parse certificate: invalid format or wrong password', - ); - } - - // ── Serialize to targetFormat ───────────────────────────────────────── - try { - let content: string; - - if (targetFormat === 'pem') { - // PEM text → base64 via utf-8 - const pemOut = forge.pki.certificateToPem(cert); - content = Buffer.from(pemOut, 'utf-8').toString('base64'); - } else if (targetFormat === 'der') { - // DER binary — CRITICAL: bytesToHex → Buffer.from(hex, 'hex') → base64 - // Avoids utf-8 round-trip corruption (RESEARCH Pitfall 1 / T-09-06) - const derHex = forge.util.bytesToHex( - forge.asn1.toDer(forge.pki.certificateToAsn1(cert)).getBytes(), - ); - content = Buffer.from(derHex, 'hex').toString('base64'); - } else if (targetFormat === 'p7b') { - // P7B — PEM-wrapped PKCS7 SignedData containing the certificate - const p7 = forge.pkcs7.createSignedData(); - p7.addCertificate(cert); - const p7DerBytes = forge.asn1.toDer(p7.toAsn1()).getBytes(); - const p7PemStr = forge.pem.encode({ type: 'PKCS7', body: p7DerBytes }); - content = Buffer.from(p7PemStr, 'utf-8').toString('base64'); - } else { - // PFX — cert-only PKCS12 bundle (Open Question 1: null key works in node-forge 1.4.0) - const p12Asn1 = forge.pkcs12.toPkcs12Asn1( - // BLEIBT (260921-m34, Aufgabe 3c): node-forge 1.4.0 nimmt hier einen - // fehlenden Schluessel an und erzeugt ein reines - // Zertifikatsbuendel; @types/node-forge schliesst null aus. Die - // mitgelieferten Typen beschreiben die Bibliothek an dieser Stelle - // also nachweislich falsch — ein erzwungener Typ waere eine - // Behauptung ueber etwas, das nicht stimmt. - null as any, // cert-only PFX — null key accepted by node-forge 1.4.0 - [cert], - password!, - { algorithm: '3des' }, - ); - // CRITICAL: bytesToHex → Buffer.from(hex,'hex') — avoids utf-8 corruption (Pitfall 1) - const p12Hex = forge.util.bytesToHex(forge.asn1.toDer(p12Asn1).getBytes()); - content = Buffer.from(p12Hex, 'hex').toString('base64'); - return { - filename: 'converted.pfx', - content, - mimeType: FORMAT_MIME.pfx, - }; - } - - return { - filename: `converted.${targetFormat}`, - content, - mimeType: FORMAT_MIME[targetFormat], - }; - } catch { - this.logger.warn('convertCert: failed to serialize to target format'); - throw new BadRequestException( - `Failed to convert certificate to ${targetFormat}: serialization error`, - ); - } - } - - // --------------------------------------------------------------------------- - // Internal helpers for later slices - // --------------------------------------------------------------------------- - - /** Wrap a node-forge operation and re-throw as BadRequestException on failure */ - protected _parseOrThrow(fn: () => T, errorMsg: string): T { - try { - return fn(); - } catch (_err) { - this.logger.warn(`Cert parse failed: ${errorMsg}`); - throw new BadRequestException(errorMsg); - } - } -} diff --git a/apps/api/src/cert-manager/cert-model.spec.ts b/apps/api/src/cert-manager/cert-model.spec.ts new file mode 100644 index 0000000..1e97b58 --- /dev/null +++ b/apps/api/src/cert-manager/cert-model.spec.ts @@ -0,0 +1,131 @@ +import { X509Certificate } from 'node:crypto'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { certItemFromDer, detectBlob } from './cert-model'; +import type { CertItem } from './cert-types'; + +const fx = (name: string) => readFileSync(join(__dirname, '__fixtures__', name)); +const ctx = (path: string) => ({ file: 0, path, passwords: [] as string[] }); + +function certs(name: string): CertItem[] { + const r = detectBlob(fx(name), ctx(name)); + return r.items.filter((i): i is CertItem => i.kind === 'certificate'); +} + +describe('detectBlob: Zertifikate', () => { + it('RSA-Serverzertifikat: alle Felder aus node:crypto', () => { + const [c] = certs('rsa-leaf.pem'); + const x = new X509Certificate(fx('rsa-leaf.pem')); + expect(c.cn).toBe('www.example.test'); + expect(c.san).toEqual(['www.example.test', 'example.test']); + expect(c.issuerCn).toBe('Tessera Test Inter RSA'); + expect(c.role).toBe('end-entity'); + expect(c.keyType).toBe('RSA'); + expect(c.keyBits).toBe(2048); + expect(c.curve).toBeNull(); + expect(c.isCa).toBe(false); + expect(c.selfSigned).toBe(false); + expect(c.aiaIssuerUrls).toEqual(['http://pki.example.test/rsa-inter.cer']); + expect(c.sha256).toBe(x.fingerprint256); + expect(c.sha1).toBe(x.fingerprint); + expect(c.id).toBe(`c-${x.fingerprint256.replace(/:/g, '').slice(0, 16).toLowerCase()}`); + expect(c.sources).toEqual([{ file: 0, path: 'rsa-leaf.pem' }]); + expect(c.pem.startsWith('-----BEGIN CERTIFICATE-----')).toBe(true); + expect(c.baseName).toBe('www.example.test'); + expect(c.keyId).toMatch(/^k-[0-9a-f]{16}$/); + expect(c.isExpired).toBe(false); + expect(c.daysLeft).toBeGreaterThan(30000); + }); + + it('Zwischenzertifikat und Stammzertifikat bekommen ihre Rolle', () => { + expect(certs('rsa-inter.pem')[0].role).toBe('intermediate'); + expect(certs('rsa-inter.pem')[0].baseName).toBe('Tessera_Test_Inter_RSA'); + const root = certs('rsa-root.pem')[0]; + expect(root.role).toBe('root'); + expect(root.selfSigned).toBe(true); + expect(root.isCa).toBe(true); + }); + + it('EC-Zertifikate: Schluesseltyp und Kurve', () => { + const leaf = certs('ec-leaf.pem')[0]; + expect(leaf.keyType).toBe('EC'); + expect(leaf.curve).toBe('P-256'); + expect(leaf.keyBits).toBe(256); + const root = certs('ec-root.pem')[0]; + expect(root.curve).toBe('P-384'); + expect(root.keyBits).toBe(384); + expect(root.isCa).toBe(true); + expect(root.selfSigned).toBe(true); + expect(root.role).toBe('root'); + }); + + it('selbstsigniert, aber keine CA: bleibt Serverzertifikat', () => { + const c = certs('selfsigned-leaf.pem')[0]; + expect(c.selfSigned).toBe(true); + expect(c.isCa).toBe(false); + expect(c.role).toBe('end-entity'); + }); + + it('abgelaufenes Zwischenzertifikat wird als abgelaufen gemeldet', () => { + const c = certs('rsa-inter-expired.pem')[0]; + expect(c.isExpired).toBe(true); + expect(c.daysLeft).toBeLessThan(0); + }); + + it('DER ergibt dieselbe Kennung wie PEM', () => { + expect(certs('ec-leaf.cer')[0].id).toBe(certs('ec-leaf.pem')[0].id); + expect(certs('rsa-leaf.cer')[0].id).toBe(certs('rsa-leaf.pem')[0].id); + }); + + it('Fullchain mit BOM, CRLF und Text drumherum: drei Zertifikate', () => { + const body = fx('ec-fullchain.pem').toString('utf8').replace(/\n/g, '\r\n'); + const messy = Buffer.concat([ + Buffer.from([0xef, 0xbb, 0xbf]), + Buffer.from(`Bag Attributes\r\n friendlyName: x\r\n${body}\r\nEnde der Datei\r\n`, 'utf8'), + ]); + const r = detectBlob(messy, ctx('messy.pem')); + expect(r.items).toHaveLength(3); + expect(r.ignored).toEqual([]); + expect(r.items.map((i) => (i as CertItem).role).sort()).toEqual([ + 'end-entity', + 'intermediate', + 'root', + ]); + }); + + it('TRUSTED CERTIFICATE liefert das Zertifikat', () => { + const [c] = certs('rsa-trusted.pem'); + expect(c.id).toBe(certs('rsa-leaf.pem')[0].id); + }); + + it('kaputte Eingaben werden gemeldet, ohne zu werfen', () => { + const half = fx('rsa-leaf.cer').subarray(0, 300); + const brokenBase64 = Buffer.from( + '-----BEGIN CERTIFICATE-----\nMIIDzTCC@@@@@@@@@!!!\n-----END CERTIFICATE-----\n', + ); + const random = Buffer.from(Array.from({ length: 512 }, (_, i) => (i * 37 + 11) % 256)); + for (const blob of [random, Buffer.alloc(0), half, brokenBase64, Buffer.from('hallo welt')]) { + const r = detectBlob(blob, ctx('x.bin')); + expect(r.items).toEqual([]); + expect(r.ignored).toEqual([{ file: 0, path: 'x.bin', reason: 'unknown' }]); + } + }); + + it('ein kaputter Block neben einem guten verhindert das Zertifikat nicht', () => { + const mixed = Buffer.concat([ + Buffer.from('-----BEGIN CERTIFICATE-----\n@@@@\n-----END CERTIFICATE-----\n'), + fx('rsa-leaf.pem'), + ]); + const r = detectBlob(mixed, ctx('mixed.pem')); + expect(r.items).toHaveLength(1); + expect(r.ignored).toEqual([]); + }); + + it('certItemFromDer erzeugt Kennung und Quelle', () => { + const der = fx('rsa-leaf.cer'); + const item = certItemFromDer(der, { file: 3, path: 'a/b.cer' }); + expect(item.sources).toEqual([{ file: 3, path: 'a/b.cer' }]); + expect(item.id).toMatch(/^c-[0-9a-f]{16}$/); + }); +}); diff --git a/apps/api/src/cert-manager/cert-model.ts b/apps/api/src/cert-manager/cert-model.ts new file mode 100644 index 0000000..ab578a3 --- /dev/null +++ b/apps/api/src/cert-manager/cert-model.ts @@ -0,0 +1,324 @@ +import { createHash, type KeyObject, X509Certificate } from 'node:crypto'; +import { safeBaseName } from './cert-output'; +import type { + AnyItem, + CertItem, + CertRole, + IgnoredEntry, + ItemSource, + LockedEntry, +} from './cert-types'; + +/** + * Der eine Parser des Zertifikat-Managers (quick-261009-ikt, D-08, D-16). + * + * Alles ueber Zertifikate und Schluessel entscheidet node:crypto (RSA und EC, PEM und DER). + * node-forge wird nur fuer PKCS#12 und als allgemeiner ASN.1-Leser/-Schreiber genutzt; die + * Zertifikat-, CSR- und PKCS#7-Parser von forge sind nur fuer RSA gebaut und kommen hier nie vor. + * + * Erkennung nach Inhalt, nie nach Dateiendung. Jede Stufe steht in try/catch: eine kaputte + * Datei ergibt hoechstens einen Eintrag „unbekannt“, nie einen Fehler fuer die ganze Anfrage. + * Stand Task 1: Zertifikate als PEM (auch TRUSTED CERTIFICATE) und als DER. ZIP und PKCS#7 + * (Task 3) sowie Schluessel, PKCS#12 und CSR (Task 4) sind benannte, noch leere Stufen. + */ + +export interface DetectContext { + /** Index der hochgeladenen Datei in Anfrage-Reihenfolge */ + file: number; + /** Dateiname, bei ZIP-Inhalt "zip/eintrag" */ + path: string; + /** Passwoerter fuer geschuetzte Container (ab Task 4) */ + passwords: string[]; +} + +export interface DetectResult { + items: AnyItem[]; + ignored: IgnoredEntry[]; + locked: LockedEntry[]; +} + +const CERT_LABELS = new Set(['CERTIFICATE', 'X509 CERTIFICATE', 'TRUSTED CERTIFICATE']); +const PEM_BLOCK = /-----BEGIN ([A-Z0-9 ]+)-----([\s\S]*?)-----END \1-----/g; +const BASE64_BODY = /^[A-Za-z0-9+/]+={0,2}$/; + +const CURVE_NAMES: Record = { + prime256v1: { name: 'P-256', bits: 256 }, + secp256r1: { name: 'P-256', bits: 256 }, + secp384r1: { name: 'P-384', bits: 384 }, + secp521r1: { name: 'P-521', bits: 521 }, +}; + +export function sha256Hex(data: Buffer | string): string { + return createHash('sha256').update(data).digest('hex'); +} + +/** Beschreibung eines oeffentlichen oder privaten Schluessels, fuer Zertifikat, Schluessel und CSR gleich. */ +export function describeKey(key: KeyObject): { + keyType: string; + keyBits: number | null; + curve: string | null; +} { + const type = key.asymmetricKeyType ?? 'unknown'; + const details = key.asymmetricKeyDetails ?? {}; + if (type === 'rsa') { + return { keyType: 'RSA', keyBits: details.modulusLength ?? null, curve: null }; + } + if (type === 'ec') { + const raw = details.namedCurve ?? ''; + const known = CURVE_NAMES[raw]; + return { + keyType: 'EC', + keyBits: known?.bits ?? null, + curve: known?.name ?? (raw || null), + }; + } + if (type === 'ed25519') return { keyType: 'ED25519', keyBits: 256, curve: null }; + return { keyType: type.toUpperCase(), keyBits: null, curve: null }; +} + +/** 'k-' + 16 Hex von sha256 des SPKI-DER: gleiche Kennung fuer Zertifikat, Schluessel und CSR. */ +export function keyIdOf(publicKey: KeyObject): string { + const spki = publicKey.export({ type: 'spki', format: 'der' }); + return `k-${sha256Hex(spki).slice(0, 16)}`; +} + +function firstValue(v: unknown): string { + if (Array.isArray(v)) return typeof v[0] === 'string' ? v[0] : ''; + return typeof v === 'string' ? v : ''; +} + +const SAN_SPLIT = /,\s(?=(?:DNS|IP Address|email|URI|Registered ID|othername|DirName):)/; + +function sanList(subjectAltName: string | undefined): string[] { + if (!subjectAltName) return []; + return subjectAltName + .split(SAN_SPLIT) + .map((part) => part.trim()) + .filter(Boolean) + .map((part) => { + if (part.startsWith('DNS:')) return part.slice(4); + if (part.startsWith('IP Address:')) return `IP:${part.slice('IP Address:'.length)}`; + return part; + }); +} + +function aiaUrls(legacyInfoAccess: unknown): string[] { + const raw = (legacyInfoAccess as Record | undefined)?.['CA Issuers - URI']; + const list = Array.isArray(raw) ? raw : typeof raw === 'string' ? [raw] : []; + const urls: string[] = []; + for (const entry of list) { + if (typeof entry !== 'string') continue; + try { + const u = new URL(entry); + if (u.protocol === 'http:' || u.protocol === 'https:') urls.push(entry); + } catch { + // keine gueltige Adresse: ueberspringen + } + if (urls.length >= 5) break; + } + return urls; +} + +/** + * Selbstsigniert = vom eigenen Schluessel unterschrieben. OpenSSLs checkIssued lehnt ein Zertifikat + * ab, dessen Schluesselverwendung kein Zertifikatsignieren erlaubt (typisch bei selbstsignierten + * Serverzertifikaten); darum zaehlt auch „Aussteller gleich Inhaber“ plus echte Signaturpruefung. + */ +function isSelfSigned(x: X509Certificate): boolean { + try { + if (!x.verify(x.publicKey)) return false; + return x.checkIssued(x) || x.subject === x.issuer; + } catch { + return false; + } +} + +export function roleOf(isCa: boolean, selfSigned: boolean): CertRole { + if (!isCa) return 'end-entity'; + return selfSigned ? 'root' : 'intermediate'; +} + +/** Baut aus einem DER-Zertifikat den Eintrag (alles aus node:crypto). Wirft bei ungueltigem DER. */ +export function certItemFromDer(der: Buffer, source: ItemSource): CertItem { + const x = new X509Certificate(der); + const legacy = x.toLegacyObject() as unknown as { + subject?: Record; + issuer?: Record; + infoAccess?: unknown; + }; + const cn = firstValue(legacy.subject?.CN); + const selfSigned = isSelfSigned(x); + const isCa = x.ca; + const role = roleOf(isCa, selfSigned); + const notAfter = x.validToDate; + const key = describeKey(x.publicKey); + return { + id: `c-${sha256Hex(x.raw).slice(0, 16)}`, + kind: 'certificate', + role, + sources: [{ ...source }], + pem: x.toString(), + baseName: safeBaseName(cn, role === 'end-entity' ? 'zertifikat' : 'ca'), + cn, + organization: firstValue(legacy.subject?.O), + issuerCn: firstValue(legacy.issuer?.CN), + issuerOrganization: firstValue(legacy.issuer?.O), + notBefore: x.validFromDate.toISOString(), + notAfter: notAfter.toISOString(), + isExpired: notAfter.getTime() < Date.now(), + daysLeft: Math.ceil((notAfter.getTime() - Date.now()) / 86_400_000), + san: sanList(x.subjectAltName), + keyType: key.keyType, + keyBits: key.keyBits, + curve: key.curve, + serialNumber: x.serialNumber, + sha256: x.fingerprint256, + sha1: x.fingerprint, + isCa, + selfSigned, + aiaIssuerUrls: aiaUrls(legacy.infoAccess), + keyId: keyIdOf(x.publicKey), + csrIds: [], + }; +} + +/** + * Das fuehrende ASN.1-SEQUENCE-Element eines DER-Blocks (Tag 0x30 samt Laenge). + * Nach hinten angehaengte Daten (z. B. Vertrauensangaben bei TRUSTED CERTIFICATE) fallen weg. + * Ein unvollstaendiger Block ergibt null. + */ +export function leadingDerSequence(buf: Buffer): Buffer | null { + if (buf.length < 2 || buf[0] !== 0x30) return null; + let length = buf[1]; + let headerLength = 2; + if (length & 0x80) { + const lengthBytes = length & 0x7f; + if (lengthBytes === 0 || lengthBytes > 4 || buf.length < 2 + lengthBytes) return null; + length = 0; + for (let i = 0; i < lengthBytes; i++) length = length * 256 + buf[2 + i]; + headerLength = 2 + lengthBytes; + } + const total = headerLength + length; + if (total > buf.length) return null; + return buf.subarray(0, total); +} + +function emptyResult(): DetectResult { + return { items: [], ignored: [], locked: [] }; +} + +// --------------------------------------------------------------------------- +// Stufen. Jede liefert null, wenn sie den Inhalt nicht als „ihren“ erkennt. +// --------------------------------------------------------------------------- + +/** ZIP (Task 3): erkannt an den Anfangsbytes, nicht an der Endung. */ +function detectZip(_blob: Buffer, _ctx: DetectContext): DetectResult | null { + return null; +} + +interface PemBlock { + label: string; + der: Buffer | null; + encrypted: boolean; +} + +function pemBlocks(text: string): PemBlock[] { + const blocks: PemBlock[] = []; + for (const m of text.matchAll(PEM_BLOCK)) { + const label = m[1]; + const rawBody = m[2]; + const encrypted = /Proc-Type:\s*4,\s*ENCRYPTED/i.test(rawBody); + const body = rawBody + .split(/\r?\n/) + .filter((line) => !line.includes(':')) + .join('') + .replace(/\s+/g, ''); + const valid = BASE64_BODY.test(body) && body.length % 4 === 0; + blocks.push({ label, der: valid ? Buffer.from(body, 'base64') : null, encrypted }); + } + return blocks; +} + +function certFromDer(der: Buffer, ctx: DetectContext): CertItem | null { + const seq = leadingDerSequence(der); + if (!seq) return null; + try { + return certItemFromDer(seq, { file: ctx.file, path: ctx.path }); + } catch { + return null; + } +} + +/** Text mit -----BEGIN-Bloecken: jeder Block nach seinem Etikett. */ +function detectPem(blob: Buffer, ctx: DetectContext): DetectResult | null { + const text = blob.toString('utf8').replace(/^/, ''); + if (!text.includes('-----BEGIN ')) return null; + const result = emptyResult(); + for (const block of pemBlocks(text)) { + if (!block.der) continue; + if (CERT_LABELS.has(block.label)) { + const item = certFromDer(block.der, ctx); + if (item) result.items.push(item); + } + // PKCS7/CMS (Task 3); PRIVATE KEY, RSA/EC PRIVATE KEY, ENCRYPTED PRIVATE KEY und + // CERTIFICATE REQUEST (Task 4) folgen in dieser Schleife. + } + return result.items.length > 0 ? result : null; +} + +/** Einzelnes DER-Zertifikat (.cer/.crt/.der). */ +function detectDerCertificate(blob: Buffer, ctx: DetectContext): DetectResult | null { + const item = certFromDer(blob, ctx); + if (!item) return null; + return { items: [item], ignored: [], locked: [] }; +} + +/** PKCS#12 (Task 4). */ +function detectPkcs12(_blob: Buffer, _ctx: DetectContext): DetectResult | null { + return null; +} + +/** PKCS#7 signedData als DER (Task 3). */ +function detectPkcs7(_blob: Buffer, _ctx: DetectContext): DetectResult | null { + return null; +} + +/** Privater Schluessel als DER (Task 4). */ +function detectPrivateKey(_blob: Buffer, _ctx: DetectContext): DetectResult | null { + return null; +} + +/** Zertifikatsanfrage als DER (Task 4). */ +function detectCsr(_blob: Buffer, _ctx: DetectContext): DetectResult | null { + return null; +} + +const STAGES: ((blob: Buffer, ctx: DetectContext) => DetectResult | null)[] = [ + detectZip, + detectPem, + detectDerCertificate, + detectPkcs12, + detectPkcs7, + detectPrivateKey, + detectCsr, +]; + +/** + * Erkennt, was in einer Datei steckt (D-16). Wirft nie: eine Datei, die keine Stufe + * erkennt, ergibt einen Eintrag „unbekannt“. + */ +export function detectBlob(blob: Buffer, ctx: DetectContext): DetectResult { + for (const stage of STAGES) { + try { + const result = stage(blob, ctx); + if (result) return result; + } catch { + // diese Stufe kann den Inhalt nicht lesen: naechste Stufe versuchen + } + } + return { + items: [], + ignored: [{ file: ctx.file, path: ctx.path, reason: 'unknown' }], + locked: [], + }; +} diff --git a/apps/api/src/cert-manager/cert-output.spec.ts b/apps/api/src/cert-manager/cert-output.spec.ts new file mode 100644 index 0000000..86f95c2 --- /dev/null +++ b/apps/api/src/cert-manager/cert-output.spec.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from 'vitest'; +import { safeBaseName } from './cert-output'; + +describe('safeBaseName', () => { + it('Platzhalter, Leerzeichen und Pfadteile werden entschaerft', () => { + expect(safeBaseName('*.example.de', 'x')).toBe('wildcard.example.de'); + expect(safeBaseName('Encryption Everywhere DV TLS CA - G1', 'x')).toBe( + 'Encryption_Everywhere_DV_TLS_CA_-_G1', + ); + expect(safeBaseName('../../etc/passwd', 'x')).toBe('etc_passwd'); + expect(safeBaseName('', 'fallback')).toBe('fallback'); + }); + + it('kuerzt auf 80 Zeichen und behaelt den Ersatz bei leerem Ergebnis', () => { + expect(safeBaseName('a'.repeat(200), 'x')).toHaveLength(80); + expect(safeBaseName('...', 'ersatz')).toBe('ersatz'); + }); +}); diff --git a/apps/api/src/cert-manager/cert-output.ts b/apps/api/src/cert-manager/cert-output.ts new file mode 100644 index 0000000..cf4e3c1 --- /dev/null +++ b/apps/api/src/cert-manager/cert-output.ts @@ -0,0 +1,14 @@ +/** + * Ausgabe-Bausteine des Zertifikat-Managers (quick-261009-ikt). + * Task 1: nur der Dateiname-Helfer; die Ausgabeformate (build) kommen in den folgenden Tasks. + */ + +/** Dateiname ohne Pfad und ohne gefaehrliche Zeichen, z. B. „*.example.de“ -> „wildcard.example.de“. */ +export function safeBaseName(raw: string, fallback: string): string { + const cleaned = raw + .replace(/^\*\./, 'wildcard.') + .replace(/[^A-Za-z0-9._-]+/g, '_') + .replace(/^[._]+/, '') + .slice(0, 80); + return cleaned || fallback; +} diff --git a/apps/api/src/cert-manager/cert-types.ts b/apps/api/src/cert-manager/cert-types.ts new file mode 100644 index 0000000..b5a0056 --- /dev/null +++ b/apps/api/src/cert-manager/cert-types.ts @@ -0,0 +1,183 @@ +import { HttpException } from '@nestjs/common'; + +/** + * Vertrag des Zertifikat-Managers (quick-261009-ikt, D-15, D-19, D-24). + * Die Typen werden 1:1 in apps/web/.../cert-manager/actions.ts gespiegelt. + * Alles hier ist zustandslos: Tessera speichert weder Dateien noch Schluessel noch Passwoerter. + */ + +/** Wo ein Teil herkam: Index der hochgeladenen Datei (Reihenfolge der Anfrage) und Pfad (bei ZIP: "zipname/eintrag"). */ +export interface ItemSource { + file: number; + path: string; +} + +export type CertRole = 'end-entity' | 'intermediate' | 'root'; + +export interface CertItem { + /** 'c-' + die ersten 16 Hex-Zeichen (klein) von sha256(DER) */ + id: string; + kind: 'certificate'; + role: CertRole; + sources: ItemSource[]; + pem: string; + baseName: string; + cn: string; + organization: string; + issuerCn: string; + issuerOrganization: string; + notBefore: string; + notAfter: string; + isExpired: boolean; + daysLeft: number; + /** DNS-Namen unveraendert, andere Arten mit Praefix wie 'IP:' */ + san: string[]; + keyType: string; + keyBits: number | null; + curve: string | null; + serialNumber: string; + sha256: string; + sha1: string; + isCa: boolean; + selfSigned: boolean; + /** nur http/https, hoechstens 5 */ + aiaIssuerUrls: string[]; + /** Kennung des oeffentlichen Schluessels: 'k-' + 16 Hex von sha256(SPKI-DER), passt zu KeyItem.id und CsrItem.keyId */ + keyId: string | null; + csrIds: string[]; +} + +export interface KeyItem { + /** 'k-' + 16 Hex von sha256(SPKI-DER) */ + id: string; + kind: 'privateKey'; + sources: ItemSource[]; + /** unverschluesseltes PKCS#8 */ + pem: string; + baseName: string; + keyType: string; + keyBits: number | null; + curve: string | null; + wasEncrypted: boolean; + certIds: string[]; +} + +export interface CsrItem { + /** 'r-' + 16 Hex von sha256(DER) */ + id: string; + kind: 'csr'; + sources: ItemSource[]; + pem: string; + baseName: string; + cn: string; + organization: string; + san: string[]; + keyType: string; + keyBits: number | null; + curve: string | null; + keyId: string | null; + certIds: string[]; +} + +export type AnyItem = CertItem | KeyItem | CsrItem; + +export interface ChainGap { + certId: string; + kind: 'afterLeaf' | 'afterCa'; + missingIssuerCn: string; + aiaUrls: string[]; +} + +export interface ChainInfo { + headId: string; + /** Kopf zuerst, dann jeder Aussteller, Wurzel zuletzt (falls vorhanden) */ + path: string[]; + rootId: string | null; + complete: boolean; + gap: ChainGap | null; + alternatives: number; +} + +export interface LockedEntry { + file: number; + path: string; + container: 'pkcs12' | 'privateKey'; + reason: 'passwordNeeded' | 'passwordWrong'; +} + +export type IgnoredReason = + | 'unknown' + | 'nestedZip' + | 'encryptedZip' + | 'brokenZip' + | 'tooLarge' + | 'suspicious' + | 'zipTooLarge' + | 'tooManyEntries' + | 'unsupportedKey'; + +export interface IgnoredEntry { + file: number; + path: string; + reason: IgnoredReason; +} + +export interface AnalysisResult { + items: AnyItem[]; + chains: ChainInfo[]; + locked: LockedEntry[]; + ignored: IgnoredEntry[]; +} + +export type BuildContent = 'leaf' | 'fullchain' | 'chain' | 'leafKey' | 'pfx' | 'key' | 'csr'; + +export interface BuildInput { + content: BuildContent; + format?: string; + certPem?: string; + poolPems?: string[]; + keyPem?: string; + csrPem?: string; + includeRoot?: boolean; + includeChain?: boolean; + password?: string; + pfxEncryption?: 'compat' | 'modern'; + template?: string; + baseName?: string; +} + +export interface BuildFile { + filename: string; + /** Base64 */ + content: string; + mimeType: string; +} + +export interface BuildResult { + files: BuildFile[]; + chainComplete: boolean; + missingIssuerCn: string | null; + snippet?: string | null; +} + +/** Fehlercodes (D-24); die Meldung ist Englisch fuer Entwickler, die Oberflaeche zeigt eigene Texte zum Code. */ +export type CertErrorCode = + | 'invalidInput' + | 'notACertificate' + | 'noChain' + | 'keyMissing' + | 'keyMismatch' + | 'passwordRequired' + | 'formatNotPossible' + | 'templateNeedsKey' + | 'tooLarge' + | 'aiaMissing' + | 'aiaInternal' + | 'aiaNotIssuer' + | 'aiaUnreachable' + | 'aiaTooLarge'; + +/** Wirft eine Nest-Ausnahme mit dem Koerper `{ code, message }` (nie mit Passwort oder Schluessel im Text). */ +export function certError(code: CertErrorCode, status: number, message: string): never { + throw new HttpException({ code, message }, status); +} diff --git a/apps/api/src/cert-manager/dto/convert-cert.dto.ts b/apps/api/src/cert-manager/dto/convert-cert.dto.ts deleted file mode 100644 index c157f36..0000000 --- a/apps/api/src/cert-manager/dto/convert-cert.dto.ts +++ /dev/null @@ -1,8 +0,0 @@ -/** - * DTO for the cert-manager convert endpoint body fields. - * Used alongside FileInterceptor for single-file upload. - */ -export class ConvertCertDto { - targetFormat!: 'pem' | 'der' | 'pfx' | 'p7b'; - password?: string; -} diff --git a/apps/api/src/cert-manager/dto/merge-certs.dto.ts b/apps/api/src/cert-manager/dto/merge-certs.dto.ts deleted file mode 100644 index 776ff41..0000000 --- a/apps/api/src/cert-manager/dto/merge-certs.dto.ts +++ /dev/null @@ -1,8 +0,0 @@ -/** - * DTO for the cert-manager merge endpoint body fields. - * Used alongside FilesInterceptor for multi-file upload. - */ -export class MergeCertsDto { - outputFormat!: 'pem' | 'pfx'; - password?: string; -} diff --git a/apps/api/src/cert-manager/dto/parse-cert.dto.ts b/apps/api/src/cert-manager/dto/parse-cert.dto.ts deleted file mode 100644 index 342511f..0000000 --- a/apps/api/src/cert-manager/dto/parse-cert.dto.ts +++ /dev/null @@ -1,8 +0,0 @@ -/** - * DTO for the cert-manager parse endpoint (text paste / JSON body path). - * For file uploads the body fields are extracted via @Body() in the controller. - */ -export class ParseCertDto { - pemText!: string; - password?: string; -} diff --git a/apps/web/src/app/(portal)/modules/cert-manager/actions.ts b/apps/web/src/app/(portal)/modules/cert-manager/actions.ts index 12a6029..012b455 100644 --- a/apps/web/src/app/(portal)/modules/cert-manager/actions.ts +++ b/apps/web/src/app/(portal)/modules/cert-manager/actions.ts @@ -1,110 +1,190 @@ -export const API_URL = - process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001'; - -// --------------------------------------------------------------------------- -// SplitResponse — mirrors CertManagerService.SplitResponse -// --------------------------------------------------------------------------- - -export type CertRole = 'root' | 'intermediate' | 'end-entity'; - -export interface SplitEntry { - index: number; - filename: string; - /** PEM content base64-encoded (one BEGIN CERTIFICATE block per entry) */ - content: string; - subject: { cn: string }; - validity: { notAfter: string }; - certRole: CertRole; -} - -export interface SplitResponse { - count: number; - certs: SplitEntry[]; -} - -// --------------------------------------------------------------------------- -// CertDetails — mirrors the API response shape from CertManagerService -// --------------------------------------------------------------------------- - -export interface CertDetails { - subject: { cn: string; o: string; ou: string; c: string }; - issuer: { cn: string; o: string; c: string }; - validity: { notBefore: string; notAfter: string; isExpired: boolean; daysLeft: number }; - san: string[]; - keyType: string; - keyBits: number; - serialNumber: string; - signatureAlgorithm: string; - fingerprint: { sha1: string; sha256: string }; - pemPreview: string; -} - -// --------------------------------------------------------------------------- -// inspectCertAction — calls POST /modules/cert-manager/parse -// --------------------------------------------------------------------------- +import { toFormData, type WorkingEntry } from './working-set'; /** - * Call POST /modules/cert-manager/parse. - * - If pemText is present → JSON body { pemText, password } - * - Otherwise → multipart FormData with file + optional password + * Aufrufe der Zertifikat-Manager-API (quick-261009-ikt). Die Typen spiegeln 1:1 + * apps/api/src/cert-manager/cert-types.ts. * - * T-09-02: password is never placed in URL, logged, or echoed. - * T-09-04: credentials:'include' ensures JWT cookie is sent. + * Passwoerter und Schluessel stehen nur im Koerper der Anfrage, nie in einer Adresse + * und nie in einer Konsolenausgabe. Der Browser sendet das Sitzungs-Cookie mit. */ -export async function inspectCertAction(input: { - file?: File | null; - pemText?: string; - password?: string; -}): Promise { - const { file, pemText, password } = input; - if (pemText) { - // JSON path — content-type must be application/json (not multipart) - const response = await fetch(`${API_URL}/modules/cert-manager/parse`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ pemText, password }), - credentials: 'include', - }); - if (!response.ok) { - const body = await response.text().catch(() => ''); - throw new Error(`${response.status} ${body}`.trim()); - } - return response.json() as Promise; - } else { - // Multipart path — let browser set Content-Type with boundary - const form = new FormData(); - if (file) form.append('file', file); - if (password) form.append('password', password); - return postForm('parse', form) as Promise; +export const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001'; + +export interface ItemSource { + file: number; + path: string; +} + +export type CertRole = 'end-entity' | 'intermediate' | 'root'; + +export interface CertItem { + id: string; + kind: 'certificate'; + role: CertRole; + sources: ItemSource[]; + pem: string; + baseName: string; + cn: string; + organization: string; + issuerCn: string; + issuerOrganization: string; + notBefore: string; + notAfter: string; + isExpired: boolean; + daysLeft: number; + san: string[]; + keyType: string; + keyBits: number | null; + curve: string | null; + serialNumber: string; + sha256: string; + sha1: string; + isCa: boolean; + selfSigned: boolean; + aiaIssuerUrls: string[]; + keyId: string | null; + csrIds: string[]; +} + +export interface KeyItem { + id: string; + kind: 'privateKey'; + sources: ItemSource[]; + pem: string; + baseName: string; + keyType: string; + keyBits: number | null; + curve: string | null; + wasEncrypted: boolean; + certIds: string[]; +} + +export interface CsrItem { + id: string; + kind: 'csr'; + sources: ItemSource[]; + pem: string; + baseName: string; + cn: string; + organization: string; + san: string[]; + keyType: string; + keyBits: number | null; + curve: string | null; + keyId: string | null; + certIds: string[]; +} + +export type AnyItem = CertItem | KeyItem | CsrItem; + +export interface ChainGap { + certId: string; + kind: 'afterLeaf' | 'afterCa'; + missingIssuerCn: string; + aiaUrls: string[]; +} + +export interface ChainInfo { + headId: string; + path: string[]; + rootId: string | null; + complete: boolean; + gap: ChainGap | null; + alternatives: number; +} + +export interface LockedEntry { + file: number; + path: string; + container: 'pkcs12' | 'privateKey'; + reason: 'passwordNeeded' | 'passwordWrong'; +} + +export type IgnoredReason = + | 'unknown' + | 'nestedZip' + | 'encryptedZip' + | 'brokenZip' + | 'tooLarge' + | 'suspicious' + | 'zipTooLarge' + | 'tooManyEntries' + | 'unsupportedKey'; + +export interface IgnoredEntry { + file: number; + path: string; + reason: IgnoredReason; +} + +export interface AnalysisResult { + items: AnyItem[]; + chains: ChainInfo[]; + locked: LockedEntry[]; + ignored: IgnoredEntry[]; +} + +/** Fehler der API mit Statuscode und (falls vorhanden) dem Fehlercode aus `{ code, message }`. */ +export class CertManagerRequestError extends Error { + constructor( + readonly status: number, + readonly code: string | null, + ) { + super(`cert-manager request failed (${status}${code ? ` ${code}` : ''})`); + this.name = 'CertManagerRequestError'; } } -// --------------------------------------------------------------------------- -// splitCertsAction — calls POST /modules/cert-manager/split -// --------------------------------------------------------------------------- +const KNOWN_ERROR_CODES = new Set([ + 'invalidInput', + 'notACertificate', + 'noChain', + 'keyMissing', + 'keyMismatch', + 'passwordRequired', + 'formatNotPossible', + 'templateNeedsKey', + 'tooLarge', + 'aiaMissing', + 'aiaInternal', + 'aiaNotIssuer', + 'aiaUnreachable', + 'aiaTooLarge', +]); -/** - * Upload a fullchain PEM or P7B bundle to POST /modules/cert-manager/split. - * Returns a SplitResponse with one entry per certificate in the bundle. - * - * T-09-04: credentials:'include' via postForm ensures JWT cookie is sent. - */ -export async function splitCertsAction(file: File): Promise { - const form = new FormData(); - form.append('file', file); - return postForm('split', form) as Promise; +/** Schluessel unter `certManager.errors.`: der Fehlercode, 413 ohne Code als tooLarge, sonst generic. */ +export function certErrorKey(error: unknown): string { + if (error instanceof CertManagerRequestError) { + if (error.code && KNOWN_ERROR_CODES.has(error.code)) return error.code; + if (error.status === 413) return 'tooLarge'; + } + return 'generic'; } -/** - * Download a base64-encoded file as a browser download. - * T-09-02: password is never placed in URL, console.log, or filename. - */ -export function downloadBase64( - filename: string, - content: string, - mimeType: string, -): void { +async function errorFromResponse(response: Response): Promise { + let code: string | null = null; + try { + const body = (await response.json()) as { code?: unknown }; + if (typeof body.code === 'string') code = body.code; + } catch { + // Antwort ohne JSON (z. B. 413 vom Proxy): nur der Status zaehlt + } + return new CertManagerRequestError(response.status, code); +} + +/** POST /modules/cert-manager/analyze: der ganze Arbeitsbereich in Reihenfolge, Antwort mit allen erkannten Teilen. */ +export async function analyzeWorkingSet(entries: WorkingEntry[]): Promise { + const response = await fetch(`${API_URL}/modules/cert-manager/analyze`, { + method: 'POST', + body: toFormData(entries), + credentials: 'include', + }); + if (!response.ok) throw await errorFromResponse(response); + return (await response.json()) as AnalysisResult; +} + +/** Laedt eine Base64-Datei als Browser-Download herunter. Der Dateiname enthaelt nie ein Passwort. */ +export function downloadBase64(filename: string, content: string, mimeType: string): void { const bytes = atob(content); const byteArray = new Uint8Array(bytes.length); for (let i = 0; i < bytes.length; i++) { @@ -118,171 +198,3 @@ export function downloadBase64( anchor.click(); URL.revokeObjectURL(url); } - -// --------------------------------------------------------------------------- -// FileResponse — mirrors CertManagerService.FileResponse -// --------------------------------------------------------------------------- - -export interface FileResponse { - /** Suggested download filename, e.g. "converted.der" */ - filename: string; - /** Base64-encoded file content */ - content: string; - /** MIME type for the download */ - mimeType: string; -} - -// --------------------------------------------------------------------------- -// mergeCertsAction — calls POST /modules/cert-manager/merge -// --------------------------------------------------------------------------- - -/** - * Upload multiple certificate files to POST /modules/cert-manager/merge. - * The API requires at least 2 files (enforced by controller). - * Returns a FileResponse — the caller should call downloadBase64(filename, content, mimeType). - * - * T-09-02: password is never placed in URL, logged, or echoed. - * T-09-03: maxCount 20 per FilesInterceptor (enforced server-side). - * T-09-04: credentials:'include' via postForm ensures JWT cookie is sent. - */ -export async function mergeCertsAction( - files: File[], - outputFormat: string, - password?: string, -): Promise { - const form = new FormData(); - files.forEach((file) => { - form.append('files', file); - }); - form.append('outputFormat', outputFormat); - if (password) form.append('password', password); - return postForm('merge', form) as Promise; -} - -// --------------------------------------------------------------------------- -// convertCertAction — calls POST /modules/cert-manager/convert -// --------------------------------------------------------------------------- - -/** - * Upload a certificate and convert it to the chosen target format. - * Returns a FileResponse — the caller should call downloadBase64(filename, content, mimeType). - * - * T-09-02: password is never placed in URL, logged, or echoed. - * T-09-04: credentials:'include' via postForm ensures JWT cookie is sent. - */ -export async function convertCertAction( - input: { file?: File | null; pemText?: string; password?: string }, - targetFormat: string, -): Promise { - const { file, pemText, password } = input; - const form = new FormData(); - if (file) form.append('file', file); - if (pemText) form.append('pemText', pemText); - if (password) form.append('password', password); - form.append('targetFormat', targetFormat); - return postForm('convert', form) as Promise; -} - -/** - * POST a FormData payload to a cert-manager endpoint. - * T-09-04: credentials:'include' ensures JWT cookie is sent for ModuleGuard. - * No manual Content-Type header — browser sets multipart boundary automatically. - */ -export async function postForm( - endpoint: string, - form: FormData, -): Promise { - const response = await fetch( - `${API_URL}/modules/cert-manager/${endpoint}`, - { - method: 'POST', - body: form, - credentials: 'include', - }, - ); - - if (!response.ok) { - const body = await response.text().catch(() => ''); - throw new Error(`${response.status} ${body}`.trim()); - } - - return response.json(); -} - -// --------------------------------------------------------------------------- -// Zertifikatspaket (quick-261001-l4q) — spiegelt apps/api/src/cert-manager/cert-bundle.ts -// --------------------------------------------------------------------------- - -export type BundleItemKind = 'certificate' | 'privateKey' | 'csr'; -export type BundleExportFormat = - | 'crt' - | 'cer' - | 'fullchain' - | 'p7b' - | 'pfx' - | 'key' - | 'key-rsa' - | 'key-der' - | 'csr' - | 'csr-der'; - -export interface BundleItem { - id: string; - kind: BundleItemKind; - role?: CertRole; - sources: string[]; - pem: string; - baseName: string; - cn: string; - organization: string; - issuerCn: string; - notBefore: string | null; - notAfter: string | null; - isExpired: boolean | null; - daysLeft: number | null; - san: string[]; - keyType: string; - keyBits: number; - serialNumber: string; - sha256: string; - matchId: string | null; - chainIds: string[]; - formats: BundleExportFormat[]; -} - -export interface BundleAnalysis { - items: BundleItem[]; - locked: string[]; - ignored: string[]; -} - -/** Mehrere Dateien (auch ZIP) analysieren — POST /modules/cert-manager/analyze. */ -export async function analyzeBundleAction(files: File[], password?: string): Promise { - const form = new FormData(); - for (const file of files) form.append('files', file); - if (password) form.append('password', password); - return postForm('analyze', form) as Promise; -} - -/** Ein Teil des Pakets in ein Format bringen — POST /modules/cert-manager/export (JSON). */ -export async function exportBundleItemAction(input: { - kind: BundleItemKind; - pem: string; - format: BundleExportFormat; - baseName: string; - chain?: string[]; - keyPem?: string; - password?: string; -}): Promise { - const response = await fetch(`${API_URL}/modules/cert-manager/export`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(input), - credentials: 'include', - }); - if (!response.ok) { - const body = await response.text().catch(() => ''); - throw new Error(`${response.status} ${body}`.trim()); - } - return response.json() as Promise; -} diff --git a/apps/web/src/app/(portal)/modules/cert-manager/cert-manager.test.tsx b/apps/web/src/app/(portal)/modules/cert-manager/cert-manager.test.tsx index 620a4a9..81899c1 100644 --- a/apps/web/src/app/(portal)/modules/cert-manager/cert-manager.test.tsx +++ b/apps/web/src/app/(portal)/modules/cert-manager/cert-manager.test.tsx @@ -1,438 +1,70 @@ -import { cleanup, render, screen, fireEvent, waitFor } from '@testing-library/react'; -import { afterEach, describe, expect, it, vi } from 'vitest'; +import { cleanup, fireEvent, render as rtlRender, screen, waitFor } from '@testing-library/react'; +import { NextIntlClientProvider } from 'next-intl'; +import type { ReactElement } from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import de from '@/messages/de.json'; +import CertManagerPage from './page'; -// Mock next-intl — provide certManager namespace keys -vi.mock('next-intl', () => ({ - useTranslations: (ns: string) => (key: string) => { - const certManager: Record = { - 'title': 'Zertifikat-Manager', - 'description': 'Zertifikate analysieren, aufteilen, zusammenfuehren und konvertieren.', - 'tabs.overview': 'Uebersicht', - 'tabs.inspect': 'Analysieren', - 'tabs.split': 'Aufteilen', - 'tabs.merge': 'Zusammenfuehren', - 'tabs.convert': 'Konvertieren', - 'dropZone.placeholder': 'Datei hierher ziehen oder klicken', - 'dropZone.formats': '.pem, .crt, .cer, .der, .pfx, .p12, .p7b, .p7c', - 'paste.placeholder': 'PEM-Inhalt einfuegen (-----BEGIN ...)', - 'password.label': 'Passwort (PFX/P12)', - 'or': 'oder', - 'actions.inspect': 'Analysieren', - 'actions.split': 'Aufteilen', - 'actions.merge': 'Zusammenfuehren', - 'actions.convert': 'Konvertieren', - 'actions.download': 'Herunterladen', - 'actions.processing': 'Wird verarbeitet...', - 'emptyState.inspect': 'Kein Zertifikat geladen.', - 'emptyState.inspectBody': 'Lade eine Datei hoch oder fuege PEM-Text ein.', - 'emptyState.split': 'Keine Datei geladen.', - 'emptyState.splitBody': 'Lade eine Fullchain- oder P7B-Datei hoch.', - 'emptyState.merge': 'Keine Zertifikate ausgewaehlt.', - 'emptyState.mergeBody': 'Lade mindestens zwei Dateien hoch.', - 'emptyState.convert': 'Keine Datei geladen.', - 'emptyState.convertBody': 'Lade eine Datei hoch und waehle ein Ausgabeformat.', - 'error.generic': 'Verarbeitung fehlgeschlagen. Pruefe das Dateiformat oder das Passwort.', - 'error.wrongPassword': 'Falsches Passwort. PFX/P12-Datei konnte nicht entschluesselt werden.', - 'error.unknownFormat': 'Unbekanntes Format. Die Datei konnte nicht als Zertifikat erkannt werden.', - }; - if (ns === 'certManager') { - return certManager[key] ?? key; - } - return key; - }, +const mockAnalyze = vi.fn(); + +vi.mock('./actions', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, analyzeWorkingSet: (...args: unknown[]) => mockAnalyze(...args) }; +}); + +vi.mock('@/components/layout/page-header', () => ({ + PageHeader: ({ title, description }: { title: string; description?: string }) => ( +
+

{title}

+

{description}

+
+ ), })); -import CertManagerPage from './page'; -import { InspectTab } from './components/InspectTab'; -import { SplitTab } from './components/SplitTab'; -import { MergeTab } from './components/MergeTab'; -import { ConvertTab } from './components/ConvertTab'; -import * as actions from './actions'; +function render(ui: ReactElement) { + return rtlRender( + + {ui} + , + ); +} -afterEach(() => { - cleanup(); - vi.restoreAllMocks(); +beforeEach(() => { + mockAnalyze.mockReset(); + mockAnalyze.mockResolvedValue({ items: [], chains: [], locked: [], ignored: [] }); }); +afterEach(cleanup); -describe('CertManagerPage shell', () => { - it('renders the page title and description', () => { +describe('CertManagerPage', () => { + it('zeigt Titel und den einen Reiter „Dateien“ als aktiven Reiter', () => { render(); - expect(screen.getByText('Zertifikat-Manager')).toBeInTheDocument(); - expect(screen.getByText('Zertifikate analysieren, aufteilen, zusammenfuehren und konvertieren.')).toBeInTheDocument(); + expect(screen.getByRole('heading', { name: 'Zertifikat-Manager' })).toBeInTheDocument(); + const tabs = screen.getAllByRole('button').filter((b) => b.hasAttribute('aria-current')); + expect(tabs).toHaveLength(1); + expect(tabs[0]).toHaveTextContent('Dateien'); + expect(tabs[0]).toHaveAttribute('aria-current', 'page'); + // Startansicht ist der Dateien-Reiter + expect(screen.getByText(/Dateien hierher ziehen/)).toBeInTheDocument(); }); - it('renders all four tab labels', () => { + it('der Reiter zeigt die Anzahl der Dateien', async () => { render(); - // "Analysieren" appears as both tab label (nav) and InspectTab action button - // getAllByText avoids the "multiple elements" error - expect(screen.getAllByText('Analysieren').length).toBeGreaterThanOrEqual(1); - expect(screen.getByText('Aufteilen')).toBeInTheDocument(); - expect(screen.getByText('Zusammenfuehren')).toBeInTheDocument(); - expect(screen.getByText('Konvertieren')).toBeInTheDocument(); + const input = screen.getByTestId('cert-file-input'); + fireEvent.change(input, { + target: { + files: [ + new File(['a'], 'a.pem', { lastModified: 1 }), + new File(['b'], 'b.pem', { lastModified: 2 }), + ], + }, + }); + await waitFor(() => + expect(screen.getByRole('button', { name: 'Dateien (2)' })).toBeInTheDocument(), + ); }); - it('does NOT render the password field on initial render (no PFX file selected)', () => { + it('ruft die Analyse nicht ohne Dateien auf', () => { render(); - // Password field label should not be in document when no PFX is selected - expect(screen.queryByText('Passwort (PFX/P12)')).not.toBeInTheDocument(); - }); - - it('startet mit der Uebersicht ohne Einzeldatei-Eingabe (quick-261001-l4q)', () => { - render(); - expect(screen.getByText('dropTitle')).toBeInTheDocument(); - expect(screen.queryByText('Datei hierher ziehen oder klicken')).not.toBeInTheDocument(); - }); - - it('shows Inspect tab empty state when Inspect tab is active', () => { - render(); - fireEvent.click(screen.getAllByText('Analysieren')[0]); - expect(screen.getByText('Kein Zertifikat geladen.')).toBeInTheDocument(); - expect(screen.getByText('Lade eine Datei hoch oder fuege PEM-Text ein.')).toBeInTheDocument(); - }); - - it('shows Split tab empty state when Split tab is active', () => { - render(); - // Click the "Aufteilen" tab button (second one in nav) - const tabButtons = screen.getAllByText('Aufteilen'); - // The tab nav button is the one that triggers tab change - fireEvent.click(tabButtons[0]); - expect(screen.getByText('Keine Datei geladen.')).toBeInTheDocument(); - expect(screen.getByText('Lade eine Fullchain- oder P7B-Datei hoch.')).toBeInTheDocument(); - }); - - it('shows Merge tab empty state when Merge tab is active', () => { - render(); - fireEvent.click(screen.getByText('Zusammenfuehren')); - expect(screen.getByText('Keine Zertifikate ausgewaehlt.')).toBeInTheDocument(); - expect(screen.getByText('Lade mindestens zwei Dateien hoch.')).toBeInTheDocument(); - }); - - it('shows Convert tab empty state when Convert tab is active', () => { - render(); - // "Konvertieren" appears once as tab label - fireEvent.click(screen.getByText('Konvertieren')); - expect(screen.getAllByText('Keine Datei geladen.').length).toBeGreaterThanOrEqual(1); - expect(screen.getByText('Lade eine Datei hoch und waehle ein Ausgabeformat.')).toBeInTheDocument(); - }); -}); - -// --------------------------------------------------------------------------- -// InspectTab — unit tests for the Analysieren result grid and error state -// --------------------------------------------------------------------------- - -const MOCK_CERT_DETAILS: actions.CertDetails = { - subject: { cn: 'example.com', o: 'Acme Corp', ou: 'IT', c: 'DE' }, - issuer: { cn: 'Example CA', o: 'CA Corp', c: 'US' }, - validity: { - notBefore: '2025-01-01T00:00:00.000Z', - notAfter: '2026-01-01T00:00:00.000Z', - isExpired: false, - daysLeft: 180, - }, - san: ['example.com', 'www.example.com'], - keyType: 'RSA', - keyBits: 2048, - serialNumber: '01', - signatureAlgorithm: 'sha256WithRSAEncryption', - fingerprint: { - sha1: 'AA:BB:CC:DD:EE', - sha256: 'FF:00:11:22:33:44:55:66:77:88', - }, - pemPreview: '-----BEGIN CERTIFICATE-----\nXXX\n-----END CERTIFICATE-----', -}; - -describe('InspectTab', () => { - it('renders CertDetails grid with subject CN and SHA-256 after successful inspect', async () => { - vi.spyOn(actions, 'inspectCertAction').mockResolvedValue(MOCK_CERT_DETAILS); - - render( - , - ); - - // Click the Analysieren button - fireEvent.click(screen.getByText('Analysieren')); - - // Wait for the async action to complete and result grid to render - await waitFor(() => { - expect(screen.getByText('example.com')).toBeInTheDocument(); - }); - - // SHA-256 fingerprint is rendered - expect(screen.getByText('FF:00:11:22:33:44:55:66:77:88')).toBeInTheDocument(); - - // Empty state is no longer shown - expect(screen.queryByText('Kein Zertifikat geladen.')).not.toBeInTheDocument(); - }); - - it('shows text-destructive error when inspectCertAction rejects', async () => { - vi.spyOn(actions, 'inspectCertAction').mockRejectedValue( - new Error('wrong password'), - ); - - render( - , - ); - - fireEvent.click(screen.getByText('Analysieren')); - - // Wait for the error message to appear - await waitFor(() => { - // The wrong-password error message should be rendered - expect( - screen.getByText('Falsches Passwort. PFX/P12-Datei konnte nicht entschluesselt werden.'), - ).toBeInTheDocument(); - }); - - // Error element should have the destructive class - const errorEl = screen.getByText( - 'Falsches Passwort. PFX/P12-Datei konnte nicht entschluesselt werden.', - ); - expect(errorEl.className).toContain('text-destructive'); - }); -}); - -// --------------------------------------------------------------------------- -// SplitTab — unit tests -// --------------------------------------------------------------------------- - -const MOCK_SPLIT_RESPONSE: actions.SplitResponse = { - count: 2, - certs: [ - { - index: 0, - filename: 'cert-1.pem', - content: btoa('-----BEGIN CERTIFICATE-----\nXXX\n-----END CERTIFICATE-----'), - subject: { cn: 'cert1.example.com' }, - validity: { notAfter: '2027-01-01T00:00:00.000Z' }, - }, - { - index: 1, - filename: 'cert-2.pem', - content: btoa('-----BEGIN CERTIFICATE-----\nYYY\n-----END CERTIFICATE-----'), - subject: { cn: 'cert2.example.com' }, - validity: { notAfter: '2027-06-01T00:00:00.000Z' }, - }, - ], -}; - -describe('SplitTab', () => { - it('renders two download buttons after clicking Aufteilen with mocked splitCertsAction', async () => { - vi.spyOn(actions, 'splitCertsAction').mockResolvedValue(MOCK_SPLIT_RESPONSE); - - const testFile = new File(['cert data'], 'fullchain.pem', { - type: 'application/x-pem-file', - }); - - render(); - - // Initial empty state - expect(screen.getByText('Keine Datei geladen.')).toBeInTheDocument(); - - // Click the Aufteilen button (only one in this isolated render) - fireEvent.click(screen.getByText('Aufteilen')); - - // Wait for the async action to resolve and per-cert list to appear - await waitFor(() => { - expect(screen.getAllByText('Herunterladen').length).toBe(2); - }); - - // Empty state is gone - expect(screen.queryByText('Keine Datei geladen.')).not.toBeInTheDocument(); - - // Both cert CN values are rendered - expect(screen.getByText('cert1.example.com')).toBeInTheDocument(); - expect(screen.getByText('cert2.example.com')).toBeInTheDocument(); - }); - - it('shows text-destructive error when splitCertsAction rejects', async () => { - vi.spyOn(actions, 'splitCertsAction').mockRejectedValue( - new Error('invalid format or corrupted file'), - ); - - const testFile = new File(['garbage'], 'bad.pem', { type: 'text/plain' }); - - render(); - - fireEvent.click(screen.getByText('Aufteilen')); - - await waitFor(() => { - expect( - screen.getByText('Unbekanntes Format. Die Datei konnte nicht als Zertifikat erkannt werden.'), - ).toBeInTheDocument(); - }); - - const errorEl = screen.getByText( - 'Unbekanntes Format. Die Datei konnte nicht als Zertifikat erkannt werden.', - ); - expect(errorEl.className).toContain('text-destructive'); - }); -}); - -// --------------------------------------------------------------------------- -// ConvertTab — unit tests -// --------------------------------------------------------------------------- - -const MOCK_FILE_RESPONSE: actions.FileResponse = { - filename: 'converted.der', - content: btoa('fake der bytes'), - mimeType: 'application/x-x509-ca-cert', -}; - -describe('ConvertTab', () => { - it('renders format selector with pem, der, p7b options', () => { - render(); - const select = screen.getByRole('combobox'); - const optionValues = Array.from(select.querySelectorAll('option')).map( - (o) => (o as HTMLOptionElement).value, - ); - expect(optionValues).toContain('pem'); - expect(optionValues).toContain('der'); - expect(optionValues).toContain('p7b'); - }); - - it('calls downloadBase64 after clicking Konvertieren when convertCertAction resolves', async () => { - vi.spyOn(actions, 'convertCertAction').mockResolvedValue(MOCK_FILE_RESPONSE); - const downloadSpy = vi.spyOn(actions, 'downloadBase64').mockImplementation(() => {}); - - const testFile = new File(['cert data'], 'cert.pem', { - type: 'application/x-pem-file', - }); - - render(); - - // Click the Konvertieren button - fireEvent.click(screen.getByText('Konvertieren')); - - // Wait for async action to resolve and downloadBase64 to be called - await waitFor(() => { - expect(downloadSpy).toHaveBeenCalledWith( - MOCK_FILE_RESPONSE.filename, - MOCK_FILE_RESPONSE.content, - MOCK_FILE_RESPONSE.mimeType, - ); - }); - }); - - it('shows text-destructive error when convertCertAction rejects with format error', async () => { - vi.spyOn(actions, 'convertCertAction').mockRejectedValue( - new Error('unknown format or invalid certificate'), - ); - - const testFile = new File(['garbage'], 'bad.bin', { type: 'application/octet-stream' }); - - render(); - - fireEvent.click(screen.getByText('Konvertieren')); - - await waitFor(() => { - expect( - screen.getByText( - 'Unbekanntes Format. Die Datei konnte nicht als Zertifikat erkannt werden.', - ), - ).toBeInTheDocument(); - }); - - const errorEl = screen.getByText( - 'Unbekanntes Format. Die Datei konnte nicht als Zertifikat erkannt werden.', - ); - expect(errorEl.className).toContain('text-destructive'); - }); - - it('renders pem, der, p7b, and pfx options in the format selector', () => { - render(); - const select = screen.getByRole('combobox'); - const optionValues = Array.from(select.querySelectorAll('option')).map( - (o) => (o as HTMLOptionElement).value, - ); - expect(optionValues).toContain('pem'); - expect(optionValues).toContain('der'); - expect(optionValues).toContain('p7b'); - expect(optionValues).toContain('pfx'); - }); -}); - -// --------------------------------------------------------------------------- -// MergeTab — unit tests (CERT-03, CERT-05) -// --------------------------------------------------------------------------- - -const MOCK_MERGE_PEM_RESPONSE: actions.FileResponse = { - filename: 'chain.pem', - content: btoa('-----BEGIN CERTIFICATE-----\nXXX\n-----END CERTIFICATE-----\n-----BEGIN CERTIFICATE-----\nYYY\n-----END CERTIFICATE-----'), - mimeType: 'application/x-pem-file', -}; - -describe('MergeTab', () => { - it('disables Zusammenfuehren action button when fewer than 2 files are selected', () => { - render(); - - // The action button should be disabled (no files yet) - const actionBtn = screen.getByTestId('merge-action-btn'); - expect(actionBtn).toBeDisabled(); - }); - - it('enables Zusammenfuehren button when 2 or more files are selected', async () => { - render(); - - const fileInput = screen.getByTestId('merge-file-input'); - const file1 = new File(['cert1'], 'cert1.pem', { type: 'application/x-pem-file' }); - const file2 = new File(['cert2'], 'cert2.pem', { type: 'application/x-pem-file' }); - - fireEvent.change(fileInput, { target: { files: [file1, file2] } }); - - await waitFor(() => { - expect(screen.getByTestId('merge-action-btn')).not.toBeDisabled(); - }); - }); - - it('shows password field in page.tsx when PFX output is selected in MergeTab', async () => { - // Render the full page so the shared PasswordField behaviour can be tested - render(); - - // Navigate to merge tab — only one "Zusammenfuehren" visible before tab switch - fireEvent.click(screen.getByText('Zusammenfuehren')); - - // Change format selector to pfx - const formatSelect = screen.getByDisplayValue('PEM-Kette'); - fireEvent.change(formatSelect, { target: { value: 'pfx' } }); - - // The shared PasswordField should now be visible - await waitFor(() => { - expect(screen.getByText('Passwort (PFX/P12)')).toBeInTheDocument(); - }); - }); - - it('calls downloadBase64 after successful merge when >= 2 files selected', async () => { - vi.spyOn(actions, 'mergeCertsAction').mockResolvedValue(MOCK_MERGE_PEM_RESPONSE); - const downloadSpy = vi.spyOn(actions, 'downloadBase64').mockImplementation(() => {}); - - render(); - - const fileInput = screen.getByTestId('merge-file-input'); - const file1 = new File(['cert1'], 'cert1.pem', { type: 'application/x-pem-file' }); - const file2 = new File(['cert2'], 'cert2.pem', { type: 'application/x-pem-file' }); - - fireEvent.change(fileInput, { target: { files: [file1, file2] } }); - - await waitFor(() => { - expect(screen.getByTestId('merge-action-btn')).not.toBeDisabled(); - }); - - fireEvent.click(screen.getByTestId('merge-action-btn')); - - await waitFor(() => { - expect(downloadSpy).toHaveBeenCalledWith( - MOCK_MERGE_PEM_RESPONSE.filename, - MOCK_MERGE_PEM_RESPONSE.content, - MOCK_MERGE_PEM_RESPONSE.mimeType, - ); - }); + expect(mockAnalyze).not.toHaveBeenCalled(); }); }); diff --git a/apps/web/src/app/(portal)/modules/cert-manager/components/ConvertTab.tsx b/apps/web/src/app/(portal)/modules/cert-manager/components/ConvertTab.tsx deleted file mode 100644 index fcb270f..0000000 --- a/apps/web/src/app/(portal)/modules/cert-manager/components/ConvertTab.tsx +++ /dev/null @@ -1,96 +0,0 @@ -'use client'; - -import { useState } from 'react'; -import { useTranslations } from 'next-intl'; -import { convertCertAction, downloadBase64 } from '../actions'; - -interface ConvertTabProps { - file: File | null; - pemText: string; - password: string; - /** Callback to notify page.tsx when target format changes (for shared PasswordField visibility) */ - onTargetFormatChange?: (format: string) => void; -} - -type TargetFormat = 'pem' | 'der' | 'p7b' | 'pfx'; - -export function ConvertTab({ file, pemText, password, onTargetFormatChange }: ConvertTabProps) { - const t = useTranslations('certManager'); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - const [targetFormat, setTargetFormat] = useState('pem'); - - function handleFormatChange(format: TargetFormat) { - setTargetFormat(format); - onTargetFormatChange?.(format); - } - - async function handleConvert() { - setLoading(true); - setError(null); - try { - const response = await convertCertAction({ file, pemText, password }, targetFormat); - downloadBase64(response.filename, response.content, response.mimeType); - } catch (err) { - const msg = err instanceof Error ? err.message.toLowerCase() : ''; - if (msg.includes('password') || msg.includes('passwort')) { - setError(t('error.wrongPassword')); - } else if ( - msg.includes('format') || - msg.includes('invalid') || - msg.includes('unknown') - ) { - setError(t('error.unknownFormat')); - } else { - setError(t('error.generic')); - } - } finally { - setLoading(false); - } - } - - const canConvert = Boolean(file || pemText); - - return ( -
- {/* Target format selector */} -
- - -
- - {/* Primary action button */} - - - {/* Error state */} - {error &&

{error}

} - - {/* Empty state — shown when no error */} - {!error && ( -
-

{t('emptyState.convert')}

-

{t('emptyState.convertBody')}

-
- )} -
- ); -} diff --git a/apps/web/src/app/(portal)/modules/cert-manager/components/DropZone.tsx b/apps/web/src/app/(portal)/modules/cert-manager/components/DropZone.tsx deleted file mode 100644 index 1379ab6..0000000 --- a/apps/web/src/app/(portal)/modules/cert-manager/components/DropZone.tsx +++ /dev/null @@ -1,108 +0,0 @@ -'use client'; - -import { useRef, useState } from 'react'; -import { useTranslations } from 'next-intl'; - -interface DropZoneProps { - onFile: (file: File) => void; - accept: string; - currentFile: File | null; - onClear: () => void; -} - -export function DropZone({ onFile, accept, currentFile, onClear }: DropZoneProps) { - const t = useTranslations('certManager'); - const fileInputRef = useRef(null); - const [isDragOver, setIsDragOver] = useState(false); - - const handleClick = () => { - fileInputRef.current?.click(); - }; - - const handleFileChange = (e: React.ChangeEvent) => { - const file = e.target.files?.[0]; - if (file) { - onFile(file); - } - // Allow re-selecting the same file - e.target.value = ''; - }; - - const handleDragOver = (e: React.DragEvent) => { - e.preventDefault(); - setIsDragOver(true); - }; - - const handleDragLeave = () => { - setIsDragOver(false); - }; - - const handleDrop = (e: React.DragEvent) => { - e.preventDefault(); - setIsDragOver(false); - const file = e.dataTransfer.files?.[0]; - if (file) { - onFile(file); - } - }; - - return ( -
- - {/* a11y/useSemanticElements: kein role="button"-div mehr -- die - eigentliche Flaeche ist jetzt ein echtes - {currentFile && ( - - )} -
- - ); -} diff --git a/apps/web/src/app/(portal)/modules/cert-manager/components/FilesTab.test.tsx b/apps/web/src/app/(portal)/modules/cert-manager/components/FilesTab.test.tsx new file mode 100644 index 0000000..4a46e03 --- /dev/null +++ b/apps/web/src/app/(portal)/modules/cert-manager/components/FilesTab.test.tsx @@ -0,0 +1,218 @@ +import { + cleanup, + fireEvent, + render as rtlRender, + screen, + waitFor, + within, +} from '@testing-library/react'; +import { NextIntlClientProvider } from 'next-intl'; +import type { ReactElement } from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import de from '@/messages/de.json'; +import type { AnalysisResult, CertItem } from '../actions'; +import { CertManagerRequestError } from '../actions'; +import { useCertWorkspace } from '../use-cert-workspace'; +import { FilesTab } from './FilesTab'; + +const mockAnalyze = vi.fn(); + +vi.mock('../actions', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, analyzeWorkingSet: (...args: unknown[]) => mockAnalyze(...args) }; +}); + +function render(ui: ReactElement) { + return rtlRender( + + {ui} + , + ); +} + +function Harness() { + const workspace = useCertWorkspace(); + return ; +} + +function cert(id: string, cn: string, role: CertItem['role'], files: number[], names: string[]) { + return { + id, + kind: 'certificate', + role, + sources: files.map((file, i) => ({ file, path: names[i] })), + pem: '', + baseName: cn, + cn, + organization: '', + issuerCn: '', + issuerOrganization: '', + notBefore: '2026-01-01T00:00:00.000Z', + notAfter: '2126-01-01T00:00:00.000Z', + isExpired: false, + daysLeft: 36000, + san: [], + keyType: 'RSA', + keyBits: 2048, + curve: null, + serialNumber: '01', + sha256: '', + sha1: '', + isCa: role !== 'end-entity', + selfSigned: role === 'root', + aiaIssuerUrls: [], + keyId: null, + csrIds: [], + } satisfies CertItem; +} + +/** Fake-Server: erkennt anhand des Dateinamens, was in der Datei „steckt“. */ +function fakeAnalyze(entries: { file: File }[]): AnalysisResult { + const items: CertItem[] = []; + const ignored: AnalysisResult['ignored'] = []; + entries.forEach((entry, index) => { + const name = entry.file.name; + if (name.startsWith('leaf')) + items.push(cert('c-leaf', 'www.example.test', 'end-entity', [index], [name])); + else if (name.startsWith('inter')) + items.push(cert('c-inter', 'Test Inter', 'intermediate', [index], [name])); + else ignored.push({ file: index, path: name, reason: 'unknown' }); + }); + return { items, chains: [], locked: [], ignored }; +} + +function makeFile(name: string, lastModified = 1) { + return new File(['x'], name, { lastModified }); +} + +function selectFiles(files: File[]) { + const input = screen.getByTestId('cert-file-input') as HTMLInputElement; + fireEvent.change(input, { target: { files } }); +} + +beforeEach(() => { + mockAnalyze.mockReset(); + mockAnalyze.mockImplementation(async (entries: { file: File }[]) => fakeAnalyze(entries)); +}); +afterEach(cleanup); + +describe('FilesTab', () => { + it('zwei Auswahlen nacheinander: beide bleiben, die Analyse bekommt beide (Fehlerbild des Nutzers)', async () => { + render(); + selectFiles([makeFile('leaf.pem')]); + await screen.findByText('www.example.test'); + selectFiles([makeFile('inter.pem')]); + await screen.findByText('Test Inter'); + expect(screen.getByText('leaf.pem')).toBeInTheDocument(); + expect(screen.getByText('inter.pem')).toBeInTheDocument(); + const lastCall = mockAnalyze.mock.calls.at(-1)?.[0] as { file: File }[]; + expect(lastCall.map((e) => e.file.name)).toEqual(['leaf.pem', 'inter.pem']); + }); + + it('eine Auswahl mit zwei Dateien haengt beide an', async () => { + render(); + selectFiles([makeFile('leaf.pem'), makeFile('inter.pem')]); + await screen.findByText('Test Inter'); + expect(screen.getAllByRole('listitem').length).toBeGreaterThanOrEqual(2); + expect(mockAnalyze).toHaveBeenCalledTimes(1); + }); + + it('Ablegen von Dateien haengt an', async () => { + render(); + const zone = screen.getByRole('button', { name: /Dateien hierher ziehen/ }); + fireEvent.drop(zone, { dataTransfer: { files: [makeFile('leaf.pem')] } }); + await screen.findByText('www.example.test'); + fireEvent.drop(zone, { dataTransfer: { files: [makeFile('inter.pem')] } }); + await screen.findByText('Test Inter'); + expect(screen.getByText('leaf.pem')).toBeInTheDocument(); + }); + + it('Entfernen analysiert den Rest neu, die letzte Datei leert ohne Aufruf', async () => { + render(); + selectFiles([makeFile('leaf.pem'), makeFile('inter.pem')]); + await screen.findByText('Test Inter'); + fireEvent.click(screen.getByRole('button', { name: '„leaf.pem“ entfernen' })); + await waitFor(() => expect(screen.queryByText('leaf.pem')).not.toBeInTheDocument()); + await waitFor(() => { + const last = mockAnalyze.mock.calls.at(-1)?.[0] as { file: File }[]; + expect(last.map((e) => e.file.name)).toEqual(['inter.pem']); + }); + const callsBefore = mockAnalyze.mock.calls.length; + fireEvent.click(screen.getByRole('button', { name: '„inter.pem“ entfernen' })); + await screen.findByText(/Noch keine Dateien/); + expect(mockAnalyze).toHaveBeenCalledTimes(callsBefore); + expect(screen.queryByText('Test Inter')).not.toBeInTheDocument(); + }); + + it('zeigt je Eintrag Rolle und Name der erkannten Zertifikate', async () => { + render(); + selectFiles([makeFile('leaf.pem')]); + const item = (await screen.findByText('www.example.test')).closest('li') as HTMLElement; + expect(within(item).getByText('Serverzertifikat')).toBeInTheDocument(); + }); + + it('eine nicht erkannte Datei nennt den Grund', async () => { + render(); + selectFiles([makeFile('readme.txt')]); + await screen.findByText('Darin wurde kein Zertifikat erkannt.'); + }); + + it('abgelehnte Dateien werden mit Grund aufgelistet', async () => { + render(); + selectFiles([makeFile('leaf.pem', 5)]); + await screen.findByText('www.example.test'); + selectFiles([makeFile('leaf.pem', 5)]); + expect(await screen.findByText('„leaf.pem“ ist schon in der Liste.')).toBeInTheDocument(); + }); + + it('der Hinweis, dass die Liste nur in diesem Browserfenster liegt, ist immer sichtbar', () => { + render(); + expect(screen.getByText(/nur in diesem Browserfenster/)).toBeInTheDocument(); + }); + + it('zeigt waehrend der Analyse einen Statustext', async () => { + let resolve: (value: AnalysisResult) => void = () => {}; + mockAnalyze.mockImplementationOnce( + () => + new Promise((r) => { + resolve = r; + }), + ); + render(); + selectFiles([makeFile('leaf.pem')]); + expect(await screen.findByRole('status')).toHaveTextContent('Dateien werden geprüft'); + resolve({ items: [], chains: [], locked: [], ignored: [] }); + await waitFor(() => expect(screen.queryByRole('status')).not.toBeInTheDocument()); + }); + + it('bei einem Analysefehler: Text zum Fehlercode und „Erneut versuchen“ wiederholt den Aufruf', async () => { + mockAnalyze.mockRejectedValueOnce(new CertManagerRequestError(413, 'tooLarge')); + render(); + selectFiles([makeFile('leaf.pem')]); + expect(await screen.findByText(/zusammen zu groß/)).toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Erneut versuchen' })); + await screen.findByText('www.example.test'); + expect(mockAnalyze).toHaveBeenCalledTimes(2); + }); + + it('verwirft die Antwort einer aelteren Anfrage', async () => { + const resolvers: ((value: AnalysisResult) => void)[] = []; + mockAnalyze.mockImplementation( + () => + new Promise((r) => { + resolvers.push(r); + }), + ); + render(); + selectFiles([makeFile('leaf.pem')]); + selectFiles([makeFile('inter.pem')]); + await waitFor(() => expect(resolvers).toHaveLength(2)); + const second = fakeAnalyze([{ file: makeFile('leaf.pem') }, { file: makeFile('inter.pem') }]); + resolvers[1](second); + await screen.findByText('Test Inter'); + resolvers[0](fakeAnalyze([{ file: makeFile('readme.txt') }])); + await new Promise((r) => setTimeout(r, 20)); + expect(screen.getByText('Test Inter')).toBeInTheDocument(); + expect(screen.queryByText('Darin wurde kein Zertifikat erkannt.')).not.toBeInTheDocument(); + }); +}); diff --git a/apps/web/src/app/(portal)/modules/cert-manager/components/FilesTab.tsx b/apps/web/src/app/(portal)/modules/cert-manager/components/FilesTab.tsx new file mode 100644 index 0000000..580976b --- /dev/null +++ b/apps/web/src/app/(portal)/modules/cert-manager/components/FilesTab.tsx @@ -0,0 +1,238 @@ +'use client'; + +import { useTranslations } from 'next-intl'; +import { useRef, useState } from 'react'; +import type { AnyItem } from '../actions'; +import type { CertWorkspace } from '../use-cert-workspace'; +import { + formatBytes, + MAX_ENTRIES, + MAX_FILE_BYTES, + MAX_TOTAL_BYTES, + type RejectedFile, + type WorkingEntry, +} from '../working-set'; + +/** Farben der Rollenmarken (wie in der frueheren Uebersicht). */ +export const ROLE_STYLES: Record = { + 'end-entity': 'bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300', + intermediate: 'bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300', + root: 'bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-300', + privateKey: 'bg-violet-100 text-violet-800 dark:bg-violet-900/40 dark:text-violet-300', + csr: 'bg-muted text-muted-foreground', +}; + +const ACCEPT = [ + '.zip', + '.pem', + '.crt', + '.cer', + '.cert', + '.der', + '.ca-bundle', + '.chain', + '.p7b', + '.p7c', + '.pfx', + '.p12', + '.csr', + '.req', + '.txt', + '.key', +].join(','); + +const MB = 1024 * 1024; + +function roleKey(item: AnyItem): string { + return item.kind === 'certificate' ? item.role : item.kind; +} + +function itemName(item: AnyItem): string { + return item.kind === 'privateKey' ? item.baseName : item.cn || item.baseName; +} + +interface FilesTabProps { + workspace: CertWorkspace; +} + +/** + * Reiter „Dateien“: der einzige Ort zum Hinzufuegen (D-01). Jede Auswahl haengt an die Liste an; + * eine zweite Datei ersetzt nie die erste. Pro Eintrag steht, was Tessera darin erkannt hat. + */ +export function FilesTab({ workspace }: FilesTabProps) { + const t = useTranslations('certManager'); + const inputRef = useRef(null); + const [dragging, setDragging] = useState(false); + const [rejected, setRejected] = useState([]); + const { entries, analysis, analysisIds, status, errorKey } = workspace; + + const add = (list: FileList | File[] | null | undefined) => { + const files = Array.from(list ?? []); + if (files.length === 0) return; + setRejected(workspace.addFiles(files)); + }; + + const itemsOf = (entry: WorkingEntry): AnyItem[] => + (analysis?.items ?? []).filter((item) => + item.sources.some((s) => analysisIds[s.file] === entry.id), + ); + const ignoredOf = (entry: WorkingEntry) => + (analysis?.ignored ?? []).filter((i) => analysisIds[i.file] === entry.id); + + return ( +
+ + { + add(e.target.files); + e.target.value = ''; + }} + /> + +

+ {t('files.limits', { + maxFiles: MAX_ENTRIES, + maxFile: MAX_FILE_BYTES / MB, + maxTotal: MAX_TOTAL_BYTES / MB, + })} +

+ + {rejected.length > 0 && ( +
+

{t('files.rejectedTitle')}

+
    + {rejected.map((r) => ( +
  • + {t(`files.rejected.${r.reason}`, { + name: r.name, + max: MAX_ENTRIES, + size: (r.reason === 'tooLarge' ? MAX_FILE_BYTES : MAX_TOTAL_BYTES) / MB, + })} +
  • + ))} +
+
+ )} + +

+ {t('files.memoryNote')} +

+ + {entries.length === 0 ? ( +

{t('files.empty')}

+ ) : ( +
+
+

+ {t('files.listTitle', { count: entries.length })} +

+ +
+ + {status === 'analyzing' && ( +

+ {t('files.analyzing')} +

+ )} + {status === 'error' && ( +
+

{t(`errors.${errorKey ?? 'generic'}`)}

+ +
+ )} + +
    + {entries.map((entry) => { + const items = itemsOf(entry); + const ignored = ignoredOf(entry); + const analysed = analysisIds.includes(entry.id); + return ( +
  • +
    +
    +

    {entry.label}

    +

    + {formatBytes(entry.file.size)} +

    +
    + +
    +
      + {items.map((item) => ( +
    • + + {t(`roles.${roleKey(item)}`)} + + {itemName(item)} + {item.kind === 'certificate' && item.isExpired && ( + + {t('files.expired')} + + )} +
    • + ))} + {ignored.map((i) => ( +
    • + {t(`files.ignored.${i.reason}`)} +
    • + ))} + {!analysed && status === 'analyzing' && ( +
    • {t('files.checking')}
    • + )} +
    +
  • + ); + })} +
+
+ )} +
+ ); +} diff --git a/apps/web/src/app/(portal)/modules/cert-manager/components/InspectTab.tsx b/apps/web/src/app/(portal)/modules/cert-manager/components/InspectTab.tsx deleted file mode 100644 index 6bcf5e2..0000000 --- a/apps/web/src/app/(portal)/modules/cert-manager/components/InspectTab.tsx +++ /dev/null @@ -1,144 +0,0 @@ -'use client'; - -import { useState } from 'react'; -import { useTranslations } from 'next-intl'; -import { type CertDetails, inspectCertAction } from '../actions'; - -interface InspectTabProps { - file: File | null; - pemText: string; - password: string; -} - -export function InspectTab({ file, pemText, password }: InspectTabProps) { - const t = useTranslations('certManager'); - const [loading, setLoading] = useState(false); - const [result, setResult] = useState(null); - const [error, setError] = useState(null); - - async function handleInspect() { - setLoading(true); - setError(null); - setResult(null); - try { - const details = await inspectCertAction({ file, pemText, password }); - setResult(details); - } catch (err) { - const msg = err instanceof Error ? err.message.toLowerCase() : ''; - if (msg.includes('password') || msg.includes('passwort')) { - setError(t('error.wrongPassword')); - } else if (msg.includes('unknown') || msg.includes('format') || msg.includes('invalid')) { - setError(t('error.unknownFormat')); - } else { - setError(t('error.generic')); - } - } finally { - setLoading(false); - } - } - - const canInspect = Boolean(file || pemText); - - return ( -
- {/* Primary action button */} - - - {/* Error state */} - {error && ( -

{error}

- )} - - {/* Empty state — shown when no result and no error */} - {!result && !error && ( -
-

{t('emptyState.inspect')}

-

{t('emptyState.inspectBody')}

-
- )} - - {/* Result grid — key-value layout per UI-SPEC (grid-cols-2 gap-2 text-sm) */} - {result && ( -
- Subject CN - {result.subject.cn} - - {result.subject.o && ( - <> - Subject O - {result.subject.o} - - )} - - {result.subject.ou && ( - <> - Subject OU - {result.subject.ou} - - )} - - {result.subject.c && ( - <> - Subject C - {result.subject.c} - - )} - - Issuer CN - {result.issuer.cn} - - {result.issuer.o && ( - <> - Issuer O - {result.issuer.o} - - )} - - Not Before - {result.validity.notBefore} - - Not After - {result.validity.notAfter} - - Expired - {result.validity.isExpired ? 'Yes' : 'No'} - - Days Left - {result.validity.daysLeft} - - Key Type - {result.keyType} - - Key Bits - {result.keyBits} - - Serial Number - {result.serialNumber} - - Signature Algorithm - {result.signatureAlgorithm} - - SHA-1 - {result.fingerprint.sha1} - - SHA-256 - {result.fingerprint.sha256} - - {result.san.length > 0 && ( - <> - SANs - {result.san.join(', ')} - - )} -
- )} -
- ); -} diff --git a/apps/web/src/app/(portal)/modules/cert-manager/components/MergeTab.test.tsx b/apps/web/src/app/(portal)/modules/cert-manager/components/MergeTab.test.tsx deleted file mode 100644 index bdc4e35..0000000 --- a/apps/web/src/app/(portal)/modules/cert-manager/components/MergeTab.test.tsx +++ /dev/null @@ -1,74 +0,0 @@ -import { fireEvent, render, screen } from '@testing-library/react'; -import { afterEach, describe, expect, it, vi } from 'vitest'; - -// Mock next-intl — passthrough t(key) => key (same pattern as stopwatch-widget.test.tsx) -vi.mock('next-intl', () => ({ - useTranslations: () => (key: string) => key, -})); - -// Mock ../actions — MergeTab only needs mergeCertsAction and downloadBase64 to exist; -// this test never triggers the merge action itself, only file-list management. -vi.mock('../actions', () => ({ - mergeCertsAction: vi.fn(), - downloadBase64: vi.fn(), -})); - -import { MergeTab } from './MergeTab'; - -afterEach(() => { - vi.restoreAllMocks(); -}); - -describe('MergeTab — Dateiliste', () => { - it('quick-260921-iwr: Entfernen der mittleren Datei laesst genau die erste und dritte Datei uebrig, in dieser Reihenfolge, mit ihren eigenen Namen (belegt den Positionsschluessel bei einer schrumpfenden Liste)', async () => { - render(); - - const fileInput = screen.getByTestId('merge-file-input'); - const file1 = new File(['cert1'], 'erste.pem', { type: 'application/x-pem-file' }); - const file2 = new File(['cert2'], 'mittlere.pem', { type: 'application/x-pem-file' }); - const file3 = new File(['cert3'], 'dritte.pem', { type: 'application/x-pem-file' }); - - fireEvent.change(fileInput, { target: { files: [file1, file2, file3] } }); - - // Alle drei Dateien sind zunaechst gelistet - expect(screen.getByText('erste.pem')).toBeInTheDocument(); - expect(screen.getByText('mittlere.pem')).toBeInTheDocument(); - expect(screen.getByText('dritte.pem')).toBeInTheDocument(); - - // Die mittlere Datei ueber ihren Entfernen-Knopf loeschen (aria-label enthaelt den Dateinamen) - const removeMiddleBtn = screen.getByLabelText('Remove mittlere.pem'); - fireEvent.click(removeMiddleBtn); - - // "mittlere.pem" ist verschwunden - expect(screen.queryByText('mittlere.pem')).not.toBeInTheDocument(); - - // Die erste und dritte Datei stehen weiterhin mit ihren eigenen Namen in der Liste, - // in dieser Reihenfolge — kein Verwechseln durch den Positionsschluessel. - const remainingNames = screen - .getAllByText(/\.pem$/) - .map((el) => el.textContent); - expect(remainingNames).toEqual(['erste.pem', 'dritte.pem']); - - // Die dritte Datei behaelt ihren eigenen Entfernen-Knopf (eigener Name im aria-label, - // nicht der der geloeschten mittleren Datei). - expect(screen.getByLabelText('Remove erste.pem')).toBeInTheDocument(); - expect(screen.getByLabelText('Remove dritte.pem')).toBeInTheDocument(); - expect(screen.queryByLabelText('Remove mittlere.pem')).not.toBeInTheDocument(); - }); - - it('quick-260921-iwr: Entfernen der ersten Datei laesst die zweite an ihrer eigenen Stelle mit eigenem Namen zurueck', async () => { - render(); - - const fileInput = screen.getByTestId('merge-file-input'); - const file1 = new File(['cert1'], 'a.pem', { type: 'application/x-pem-file' }); - const file2 = new File(['cert2'], 'b.pem', { type: 'application/x-pem-file' }); - - fireEvent.change(fileInput, { target: { files: [file1, file2] } }); - - fireEvent.click(screen.getByLabelText('Remove a.pem')); - - expect(screen.queryByText('a.pem')).not.toBeInTheDocument(); - expect(screen.getByText('b.pem')).toBeInTheDocument(); - expect(screen.getByLabelText('Remove b.pem')).toBeInTheDocument(); - }); -}); diff --git a/apps/web/src/app/(portal)/modules/cert-manager/components/MergeTab.tsx b/apps/web/src/app/(portal)/modules/cert-manager/components/MergeTab.tsx deleted file mode 100644 index 71ba90c..0000000 --- a/apps/web/src/app/(portal)/modules/cert-manager/components/MergeTab.tsx +++ /dev/null @@ -1,159 +0,0 @@ -'use client'; - -import { useState } from 'react'; -import { useTranslations } from 'next-intl'; -import { mergeCertsAction, downloadBase64 } from '../actions'; - -type MergeOutputFormat = 'pem' | 'pfx'; - -interface MergeTabProps { - /** Shared password from page.tsx (shown when PFX output is chosen) */ - password: string; - /** Callback to notify page.tsx when output format changes (for shared PasswordField visibility) */ - onOutputFormatChange?: (format: string) => void; -} - -/** - * MergeTab — multi-file cert merge into PEM chain or password-protected PFX/PKCS12. - * - * Files are managed locally (separate from the shared single-file DropZone in page.tsx). - * Password is shared from page.tsx via prop — the PasswordField becomes visible when - * PFX output is selected (controlled by onOutputFormatChange → page.tsx showPassword). - * - * Security contract (T-09-02): password is never logged, never placed in URLs. - * Security contract (T-09-03): server enforces 20-file max + 5 MB per file. - */ -export function MergeTab({ password, onOutputFormatChange }: MergeTabProps) { - const t = useTranslations('certManager'); - const [files, setFiles] = useState([]); - const [outputFormat, setOutputFormat] = useState('pem'); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - - function handleFormatChange(format: MergeOutputFormat) { - setOutputFormat(format); - onOutputFormatChange?.(format); - } - - function handleFileChange(e: React.ChangeEvent) { - const selected = Array.from(e.target.files ?? []); - if (selected.length > 0) { - setFiles((prev) => [...prev, ...selected]); - } - // Reset input so the same file can be re-added if needed - e.target.value = ''; - } - - function removeFile(index: number) { - setFiles((prev) => prev.filter((_, i) => i !== index)); - } - - async function handleMerge() { - setLoading(true); - setError(null); - try { - const response = await mergeCertsAction( - files, - outputFormat, - outputFormat === 'pfx' ? password : undefined, - ); - downloadBase64(response.filename, response.content, response.mimeType); - } catch (err) { - const msg = err instanceof Error ? err.message.toLowerCase() : ''; - if (msg.includes('password') || msg.includes('passwort')) { - setError(t('error.wrongPassword')); - } else if ( - msg.includes('format') || - msg.includes('invalid') || - msg.includes('unknown') - ) { - setError(t('error.unknownFormat')); - } else { - setError(t('error.generic')); - } - } finally { - setLoading(false); - } - } - - const canMerge = files.length >= 2; - - return ( -
- {/* Selected file list */} - {files.length > 0 && ( -
    - {files.map((f, i) => ( -
  • - {f.name} - -
  • - ))} -
  • - {files.length} {files.length === 1 ? 'Datei' : 'Dateien'} ausgewählt -
  • -
- )} - - {/* Multi-file input */} -
- -
- - {/* Output format selector */} -
- - -
- - {/* Primary action button — disabled until >= 2 files selected */} - - - {/* Error state */} - {error &&

{error}

} - - {/* Empty state — shown when no files and no error */} - {files.length === 0 && !error && ( -
-

{t('emptyState.merge')}

-

{t('emptyState.mergeBody')}

-
- )} -
- ); -} diff --git a/apps/web/src/app/(portal)/modules/cert-manager/components/OverviewTab.test.tsx b/apps/web/src/app/(portal)/modules/cert-manager/components/OverviewTab.test.tsx deleted file mode 100644 index 88d294a..0000000 --- a/apps/web/src/app/(portal)/modules/cert-manager/components/OverviewTab.test.tsx +++ /dev/null @@ -1,217 +0,0 @@ -import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react'; -import { afterEach, describe, expect, it, vi } from 'vitest'; - -vi.mock('next-intl', () => ({ - useTranslations: () => (key: string, values?: Record) => - values ? `${key} ${JSON.stringify(values)}` : key, -})); - -vi.mock('../actions', () => ({ - analyzeBundleAction: vi.fn(), - exportBundleItemAction: vi.fn(), - downloadBase64: vi.fn(), -})); - -import { - analyzeBundleAction, - type BundleItem, - downloadBase64, - exportBundleItemAction, -} from '../actions'; -import { OverviewTab } from './OverviewTab'; - -const mockAnalyze = analyzeBundleAction as ReturnType; -const mockExport = exportBundleItemAction as ReturnType; -const mockDownload = downloadBase64 as ReturnType; - -function item(over: Partial): BundleItem { - return { - id: 'x', - kind: 'certificate', - sources: ['a.pem'], - pem: 'PEM', - baseName: 'www.example.test', - cn: 'www.example.test', - organization: '', - issuerCn: '', - notBefore: null, - notAfter: null, - isExpired: null, - daysLeft: null, - san: [], - keyType: 'RSA', - keyBits: 2048, - serialNumber: '', - sha256: '', - matchId: null, - chainIds: [], - formats: [], - ...over, - }; -} - -const LEAF = item({ - id: 'leaf', - role: 'end-entity', - pem: 'LEAF-PEM', - issuerCn: 'Test Intermediate CA', - notBefore: '2026-01-01T00:00:00.000Z', - notAfter: '2027-01-01T00:00:00.000Z', - isExpired: false, - daysLeft: 200, - san: ['www.example.test', 'example.test'], - matchId: 'key', - chainIds: ['inter'], - formats: ['crt', 'cer', 'fullchain', 'p7b', 'pfx'], -}); -const INTER = item({ - id: 'inter', - role: 'intermediate', - pem: 'INTER-PEM', - cn: 'Test Intermediate CA', - isExpired: false, - daysLeft: 900, - formats: ['crt', 'cer', 'fullchain', 'p7b', 'pfx'], -}); -const KEY = item({ - id: 'key', - kind: 'privateKey', - pem: 'KEY-PEM', - matchId: 'leaf', - formats: ['key', 'key-rsa', 'key-der'], -}); -const CSR = item({ - id: 'csr', - kind: 'csr', - pem: 'CSR-PEM', - matchId: 'leaf', - formats: ['csr', 'csr-der'], -}); - -afterEach(() => { - cleanup(); - vi.clearAllMocks(); -}); - -async function upload(files: File[]) { - render(); - fireEvent.change(screen.getByTestId('overview-file-input'), { target: { files } }); - await waitFor(() => expect(mockAnalyze).toHaveBeenCalled()); -} - -describe('OverviewTab', () => { - it('analysiert die abgelegten Dateien und zeigt je Teil Typ und Download-Knoepfe', async () => { - mockAnalyze.mockResolvedValue({ - items: [LEAF, INTER, KEY, CSR], - locked: [], - ignored: ['.dnstxtrecord'], - }); - const zip = new File(['zip'], 'paket.zip'); - await upload([zip]); - - expect(mockAnalyze).toHaveBeenCalledWith([zip], undefined); - const cards = await screen.findAllByTestId('bundle-item'); - expect(cards).toHaveLength(4); - expect(within(cards[0]).getByText('type.end-entity')).toBeInTheDocument(); - expect(within(cards[1]).getByText('type.intermediate')).toBeInTheDocument(); - expect(within(cards[2]).getByText('type.privateKey')).toBeInTheDocument(); - expect(within(cards[3]).getByText('type.csr')).toBeInTheDocument(); - // Zuordnung und Gueltigkeit - expect(within(cards[0]).getByText('www.example.test, example.test')).toBeInTheDocument(); - expect(within(cards[0]).getByText('valid')).toBeInTheDocument(); - expect(within(cards[2]).getByText(/type\.end-entity/)).toBeInTheDocument(); - // alle Formate als Knoepfe - expect( - within(cards[0]) - .getAllByRole('button') - .map((b) => b.textContent), - ).toEqual(['format.crt', 'format.cer', 'format.fullchain', 'format.p7b', 'format.pfx']); - expect(within(cards[2]).getAllByRole('button')).toHaveLength(3); - expect(screen.getByText(/ignored/)).toBeInTheDocument(); - }); - - it('Download schickt Kette und Schluessel mit und loest die Datei aus', async () => { - mockAnalyze.mockResolvedValue({ items: [LEAF, INTER, KEY], locked: [], ignored: [] }); - mockExport.mockResolvedValue({ - filename: 'www.example.test-fullchain.pem', - content: 'Zm9v', - mimeType: 'x', - }); - await upload([new File(['x'], 'a.pem')]); - const [leafCard] = await screen.findAllByTestId('bundle-item'); - - fireEvent.click(within(leafCard).getByText('format.fullchain')); - await waitFor(() => - expect(mockDownload).toHaveBeenCalledWith('www.example.test-fullchain.pem', 'Zm9v', 'x'), - ); - expect(mockExport).toHaveBeenCalledWith({ - kind: 'certificate', - pem: 'LEAF-PEM', - format: 'fullchain', - baseName: 'www.example.test', - chain: ['INTER-PEM'], - keyPem: 'KEY-PEM', - password: undefined, - }); - }); - - it('PFX fragt erst ein Passwort ab', async () => { - mockAnalyze.mockResolvedValue({ items: [LEAF, INTER, KEY], locked: [], ignored: [] }); - mockExport.mockResolvedValue({ - filename: 'www.example.test.pfx', - content: 'Zm9v', - mimeType: 'x', - }); - await upload([new File(['x'], 'a.pem')]); - const [leafCard] = await screen.findAllByTestId('bundle-item'); - - fireEvent.click(within(leafCard).getByText('format.pfx')); - expect(mockExport).not.toHaveBeenCalled(); - expect(within(leafCard).getByText('pfxWithKey')).toBeInTheDocument(); - const download = within(leafCard).getByText('pfxDownload'); - expect(download).toBeDisabled(); - - fireEvent.change(within(leafCard).getByLabelText('pfxPassword'), { - target: { value: 'geheim' }, - }); - fireEvent.click(download); - await waitFor(() => - expect(mockExport).toHaveBeenCalledWith( - expect.objectContaining({ format: 'pfx', password: 'geheim' }), - ), - ); - }); - - it('geschuetzte PFX ohne Schluessel im Paket: Passwort eingeben und erneut pruefen', async () => { - mockAnalyze.mockResolvedValueOnce({ - items: [{ ...LEAF, matchId: null }], - locked: ['a.pfx'], - ignored: [], - }); - const pfx = new File(['x'], 'a.pfx'); - await upload([pfx]); - - fireEvent.change(await screen.findByLabelText('lockedPassword'), { target: { value: 'pw' } }); - mockAnalyze.mockResolvedValueOnce({ items: [LEAF, KEY], locked: [], ignored: [] }); - fireEvent.click(screen.getByText('unlock')); - await waitFor(() => expect(mockAnalyze).toHaveBeenLastCalledWith([pfx], 'pw')); - expect(await screen.findAllByTestId('bundle-item')).toHaveLength(2); - }); - - it('geschuetzte PFX, aber Zertifikat und Schluessel liegen schon vor: ruhiger Hinweis, Passwort optional', async () => { - mockAnalyze.mockResolvedValue({ items: [LEAF, INTER, KEY], locked: ['a.pfx'], ignored: [] }); - await upload([new File(['x'], 'paket.zip')]); - - const notice = await screen.findByTestId('locked-notice'); - expect(within(notice).getByText(/lockedNotNeeded/)).toBeInTheDocument(); - expect(within(notice).queryByLabelText('lockedPassword')).not.toBeInTheDocument(); - fireEvent.click(within(notice).getByText('unlockAnyway')); - expect(within(notice).getByLabelText('lockedPassword')).toBeInTheDocument(); - }); - - it('Fehler beim Pruefen wird angezeigt', async () => { - mockAnalyze.mockRejectedValue(new Error('400')); - await upload([new File(['x'], 'a.txt')]); - expect(await screen.findByText('error')).toBeInTheDocument(); - }); -}); diff --git a/apps/web/src/app/(portal)/modules/cert-manager/components/OverviewTab.tsx b/apps/web/src/app/(portal)/modules/cert-manager/components/OverviewTab.tsx deleted file mode 100644 index 3975c7d..0000000 --- a/apps/web/src/app/(portal)/modules/cert-manager/components/OverviewTab.tsx +++ /dev/null @@ -1,402 +0,0 @@ -'use client'; - -import { useTranslations } from 'next-intl'; -import { useRef, useState } from 'react'; -import { - analyzeBundleAction, - type BundleAnalysis, - type BundleExportFormat, - type BundleItem, - downloadBase64, - exportBundleItemAction, -} from '../actions'; - -/** - * OverviewTab (quick-261001-l4q) — Zertifikatspaket vom Aussteller auf einmal - * hochladen (mehrere Dateien oder ZIP), sehen, welche Datei was ist, und jedes - * Teil in jedem passenden Format herunterladen. Die Erkennung macht der Server - * (POST analyze), der Export ebenfalls (POST export) — beides zustandslos. - * - * T-09-02: Passwoerter leben nur im lokalen Zustand, nie in URL oder Log. - */ - -const ACCEPT = '.zip,.pem,.crt,.cer,.der,.pfx,.p12,.p7b,.p7c,.key,.csr'; - -const ROLE_STYLES: Record = { - 'end-entity': 'bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300', - intermediate: 'bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300', - root: 'bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-300', - privateKey: 'bg-violet-100 text-violet-800 dark:bg-violet-900/40 dark:text-violet-300', - csr: 'bg-muted text-muted-foreground', -}; - -function typeKey(item: BundleItem): string { - return item.kind === 'certificate' ? (item.role ?? 'end-entity') : item.kind; -} - -/** In UTC wie im Zertifikat: „bis 23:59:59 UTC“ ist bei uns schon der Folgetag — der Aussteller nennt aber dieses Datum. */ -function formatDate(iso: string | null): string { - if (!iso) return ''; - return new Date(iso).toLocaleDateString('de-DE', { - day: '2-digit', - month: '2-digit', - year: 'numeric', - timeZone: 'UTC', - }); -} - -export function OverviewTab() { - const t = useTranslations('certManager.overview'); - const inputRef = useRef(null); - const [files, setFiles] = useState([]); - const [password, setPassword] = useState(''); - const [isDragOver, setIsDragOver] = useState(false); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - const [result, setResult] = useState(null); - const [unlockOpen, setUnlockOpen] = useState(false); - - async function analyze(next: File[], pw: string) { - if (next.length === 0) { - setResult(null); - return; - } - setLoading(true); - setError(null); - try { - setResult(await analyzeBundleAction(next, pw || undefined)); - } catch { - setResult(null); - setError(t('error')); - } finally { - setLoading(false); - } - } - - function addFiles(list: FileList | null) { - const added = Array.from(list ?? []); - if (added.length === 0) return; - const next = [...files, ...added]; - setFiles(next); - void analyze(next, password); - } - - function removeFile(index: number) { - const next = files.filter((_, i) => i !== index); - setFiles(next); - void analyze(next, password); - } - - function reset() { - setFiles([]); - setPassword(''); - setResult(null); - setError(null); - } - - const byId = Object.fromEntries((result?.items ?? []).map((i) => [i.id, i])); - - return ( -
- { - addFiles(e.target.files); - e.target.value = ''; - }} - /> - - - {files.length > 0 && ( -
- {files.map((file, i) => ( - - {file.name} - - - ))} - -
- )} - - {loading &&

{t('analyzing')}

} - {error &&

{error}

} - - {result && result.locked.length > 0 && ( - i.kind === 'certificate' && i.matchId !== null)} - open={unlockOpen} - onOpen={() => setUnlockOpen(true)} - password={password} - onPassword={setPassword} - loading={loading} - onUnlock={() => void analyze(files, password)} - /> - )} - - {result && result.items.length === 0 && !loading && ( -

{t('nothingFound')}

- )} - - {result && result.items.length > 0 && ( -
    - {result.items.map((item) => ( - - ))} -
- )} - - {result && result.ignored.length > 0 && ( -

- {t('ignored', { files: result.ignored.join(', ') })} -

- )} -
- ); -} - -function LockedNotice(props: { - files: string[]; - notNeeded: boolean; - open: boolean; - onOpen: () => void; - password: string; - onPassword: (value: string) => void; - loading: boolean; - onUnlock: () => void; -}) { - const t = useTranslations('certManager.overview'); - const showInput = !props.notNeeded || props.open; - return ( -
-

- {t(props.notNeeded ? 'lockedNotNeeded' : 'locked', { files: props.files.join(', ') })} -

- {!showInput && ( - - )} - {showInput && ( -
- props.onPassword(e.target.value)} - placeholder={t('lockedPassword')} - aria-label={t('lockedPassword')} - autoComplete="off" - className="w-64 rounded border border-border bg-background px-3 py-1.5 text-sm" - /> - -
- )} -
- ); -} - -function BundleItemCard({ item, byId }: { item: BundleItem; byId: Record }) { - const t = useTranslations('certManager.overview'); - const [pfxOpen, setPfxOpen] = useState(false); - const [pfxPassword, setPfxPassword] = useState(''); - const [busy, setBusy] = useState(null); - const [error, setError] = useState(null); - - const match = item.matchId ? byId[item.matchId] : undefined; - const chain = item.chainIds.map((id) => byId[id]).filter(Boolean); - - async function download(format: BundleExportFormat, password?: string) { - setBusy(format); - setError(null); - try { - const file = await exportBundleItemAction({ - kind: item.kind, - pem: item.pem, - format, - baseName: item.baseName, - chain: item.kind === 'certificate' ? chain.map((c) => c.pem) : undefined, - keyPem: item.kind === 'certificate' && match ? match.pem : undefined, - password, - }); - downloadBase64(file.filename, file.content, file.mimeType); - if (format === 'pfx') { - setPfxOpen(false); - setPfxPassword(''); - } - } catch { - setError(t('exportError')); - } finally { - setBusy(null); - } - } - - const status = - item.isExpired === null - ? null - : item.isExpired - ? { cls: 'bg-status-down/15 text-status-down-fg', text: t('expired') } - : (item.daysLeft ?? 0) <= 30 - ? { - cls: 'bg-status-warn/15 text-status-warn-fg', - text: t('expiresSoon', { days: item.daysLeft ?? 0 }), - } - : { cls: 'bg-status-ok/15 text-status-ok-fg', text: t('valid') }; - - return ( -
  • -
    - - {t(`type.${typeKey(item)}`)} - - {item.cn || item.baseName} - {status && ( - - {status.text} - - )} -
    -

    {t(`explain.${typeKey(item)}`)}

    - -
    - {item.kind === 'certificate' && ( - <> -
    {t('issuer')}
    -
    {item.issuerCn}
    -
    {t('validity')}
    -
    - {formatDate(item.notBefore)} – {formatDate(item.notAfter)} -
    - - )} - {item.san.length > 0 && ( - <> -
    {t('names')}
    -
    {item.san.join(', ')}
    - - )} - {item.keyType && ( - <> -
    {t('key')}
    -
    {item.keyBits > 0 ? `${item.keyType} ${item.keyBits} Bit` : item.keyType}
    - - )} - {match && ( - <> -
    {t('belongsTo')}
    -
    - {t(`type.${typeKey(match)}`)} {match.cn} -
    - - )} -
    {t('source')}
    -
    {item.sources.join(', ')}
    -
    - - {item.kind === 'privateKey' && ( -

    {t('keySecret')}

    - )} - -
    - {item.formats.map((format) => ( - - ))} -
    - - {pfxOpen && ( -
    -

    - {match ? t('pfxWithKey') : t('pfxWithoutKey')} -

    -
    - setPfxPassword(e.target.value)} - placeholder={t('pfxPassword')} - aria-label={t('pfxPassword')} - autoComplete="new-password" - className="w-64 rounded border border-border bg-background px-3 py-1.5 text-sm" - /> - -
    -
    - )} - - {error &&

    {error}

    } -
  • - ); -} diff --git a/apps/web/src/app/(portal)/modules/cert-manager/components/PasswordField.tsx b/apps/web/src/app/(portal)/modules/cert-manager/components/PasswordField.tsx deleted file mode 100644 index 78a6bd9..0000000 --- a/apps/web/src/app/(portal)/modules/cert-manager/components/PasswordField.tsx +++ /dev/null @@ -1,79 +0,0 @@ -'use client'; - -import { useState } from 'react'; -import { useTranslations } from 'next-intl'; - -interface PasswordFieldProps { - value: string; - onChange: (value: string) => void; - /** When false, the component renders nothing (T-09-02: no DOM reflow) */ - show: boolean; -} - -export function PasswordField({ value, onChange, show }: PasswordFieldProps) { - const t = useTranslations('certManager'); - const [revealed, setRevealed] = useState(false); - - if (!show) return null; - - return ( -
    - -
    - onChange(e.target.value)} - className="w-full rounded border border-border bg-background px-3 py-2 text-sm text-foreground pr-10 focus:outline-none focus:ring-2 focus:ring-ring" - autoComplete="off" - /> - -
    -
    - ); -} diff --git a/apps/web/src/app/(portal)/modules/cert-manager/components/SplitTab.tsx b/apps/web/src/app/(portal)/modules/cert-manager/components/SplitTab.tsx deleted file mode 100644 index b51374b..0000000 --- a/apps/web/src/app/(portal)/modules/cert-manager/components/SplitTab.tsx +++ /dev/null @@ -1,145 +0,0 @@ -'use client'; - -import { useState } from 'react'; -import { useTranslations } from 'next-intl'; -import { zipSync } from 'fflate'; -import { type SplitResponse, type CertRole, splitCertsAction, downloadBase64 } from '../actions'; -import { sanitizeZipFilename } from '../zip-filename'; - -interface SplitTabProps { - file: File | null; - pemText: string; - password: string; -} - -const ROLE_STYLES: Record = { - 'root': 'bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-300', - 'intermediate': 'bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300', - 'end-entity': 'bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300', -}; - -// `downloadAllAsZip` liegt ausserhalb der Komponente und kann den -// Uebersetzungs-Hook nicht selbst aufrufen — der uebersetzte Name kommt -// deshalb als Parameter herein (Restposten 1, quick-260921-jt4). Die -// einzelnen Dateinamen IM Archiv stammen weiterhin unveraendert aus der API. -function downloadAllAsZip(certs: SplitResponse['certs'], zipFilename: string) { - const files: Record = {}; - for (const cert of certs) { - const bytes = Uint8Array.from(atob(cert.content), (c) => c.charCodeAt(0)); - // Deduplicate filenames (fflate overwrites silently otherwise) - let name = cert.filename; - let n = 1; - while (name in files) { - const base = cert.filename.replace(/\.pem$/, ''); - name = `${base}-${++n}.pem`; - } - files[name] = bytes; - } - const zipped = zipSync(files); - const blob = new Blob([zipped], { type: 'application/zip' }); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - // T-JT4-05: der uebersetzte Name kann Zeichen tragen, die Windows - // verbietet (Umlaute, Sonderzeichen) — sanitizeZipFilename schneidet ihn - // auf das fuer eine Windows-Freigabe Zulaessige zurueck, unabhaengig davon, - // ob der aktuelle Katalogwert zufaellig schon harmlos ist. - a.download = sanitizeZipFilename(zipFilename); - a.click(); - URL.revokeObjectURL(url); -} - -export function SplitTab({ file, pemText: _pemText, password: _password }: SplitTabProps) { - const t = useTranslations('certManager'); - const [loading, setLoading] = useState(false); - const [result, setResult] = useState(null); - const [error, setError] = useState(null); - - async function handleSplit() { - if (!file) return; - setLoading(true); - setError(null); - setResult(null); - try { - const response = await splitCertsAction(file); - setResult(response); - } catch (err) { - const msg = err instanceof Error ? err.message.toLowerCase() : ''; - if (msg.includes('format') || msg.includes('invalid') || msg.includes('unknown')) { - setError(t('error.unknownFormat')); - } else { - setError(t('error.generic')); - } - } finally { - setLoading(false); - } - } - - return ( -
    -
    - - - {result && result.certs.length > 1 && ( - - )} -
    - - {error &&

    {error}

    } - - {!result && !error && ( -
    -

    {t('emptyState.split')}

    -

    {t('emptyState.splitBody')}

    -
    - )} - - {result && ( -
      - {result.certs.map((cert) => ( -
    • -
      - - {t(`certRole.${cert.certRole ?? 'end-entity'}`)} - - - {cert.subject.cn || cert.filename} - - - {cert.validity.notAfter} - -
      - -
    • - ))} -
    - )} -
    - ); -} diff --git a/apps/web/src/app/(portal)/modules/cert-manager/page.tsx b/apps/web/src/app/(portal)/modules/cert-manager/page.tsx index d18fdcc..63fcc58 100644 --- a/apps/web/src/app/(portal)/modules/cert-manager/page.tsx +++ b/apps/web/src/app/(portal)/modules/cert-manager/page.tsx @@ -1,160 +1,38 @@ 'use client'; -import { PageHeader } from '@/components/layout/page-header'; import { useTranslations } from 'next-intl'; import { useState } from 'react'; -import { DropZone } from './components/DropZone'; -import { PasswordField } from './components/PasswordField'; -import { InspectTab } from './components/InspectTab'; -import { SplitTab } from './components/SplitTab'; -import { MergeTab } from './components/MergeTab'; -import { ConvertTab } from './components/ConvertTab'; -import { OverviewTab } from './components/OverviewTab'; +import { TabBar } from '@/components/accounting/tab-bar'; +import { PageHeader } from '@/components/layout/page-header'; +import { FilesTab } from './components/FilesTab'; +import { useCertWorkspace } from './use-cert-workspace'; -type TabId = 'overview' | 'inspect' | 'split' | 'merge' | 'convert'; - -const TABS: TabId[] = ['overview', 'inspect', 'split', 'merge', 'convert']; - -/** Returns true if the selected file is a PFX/P12 (requires decryption password) */ -function isPfxFile(file: File | null): boolean { - if (!file) return false; - const name = file.name.toLowerCase(); - return name.endsWith('.pfx') || name.endsWith('.p12'); -} +type TabId = 'files'; /** - * CertManagerPage — tab-based shell for certificate operations. - * quick-261001-l4q: erster Reiter „Übersicht“ fuer ganze Zertifikatspakete. - * Layout per UI-SPEC: max-w-4xl, shared input card, tab nav, tab content card. - * T-09-02: password lives in local React state only; never logged or placed in URLs. - * T-09-04: all API calls via postForm() which sends credentials:'include'. - * - * The shared PasswordField is shown when: - * 1. A PFX/P12 file is selected in the shared DropZone, OR - * 2. The active tab (Merge or Convert) has PFX chosen as the output format. + * CertManagerPage: Zertifikat-Manager rund um einen gemeinsamen Arbeitsbereich (quick-261009-ikt). + * Der erste Reiter „Dateien“ sammelt alles; die weiteren Reiter (ab Task 2) arbeiten auf dieser Liste. + * Die Liste lebt nur im Arbeitsspeicher dieses Browserfensters; Reiterwechsel behaelt sie. */ export default function CertManagerPage() { const t = useTranslations('certManager'); + const workspace = useCertWorkspace(); + const [activeTab, setActiveTab] = useState('files'); - const [activeTab, setActiveTab] = useState('overview'); - const [file, setFile] = useState(null); - const [pemText, setPemText] = useState(''); - const [password, setPassword] = useState(''); - - // Track the output format of the active Merge and Convert tabs - // so the shared PasswordField can be shown when PFX output is chosen. - const [mergeOutputFormat, setMergeOutputFormat] = useState('pem'); - const [convertOutputFormat, setConvertOutputFormat] = useState('pem'); - - const showPassword = - isPfxFile(file) || - (activeTab === 'merge' && mergeOutputFormat === 'pfx') || - (activeTab === 'convert' && convertOutputFormat === 'pfx'); - - const handleFile = (selected: File) => { - setFile(selected); - setPemText(''); // Single active source: file XOR paste - setPassword(''); - }; - - const handleClearFile = () => { - setFile(null); - setPassword(''); - }; - - const handlePemChange = (text: string) => { - setPemText(text); - if (text) { - setFile(null); // Single active source: file XOR paste - setPassword(''); - } - }; - - const handleTabChange = (tab: TabId) => { - setActiveTab(tab); - // Tab switch: keep shared input, clear result (result cleared per tab re-render) - }; - - const renderActiveTab = () => { - switch (activeTab) { - case 'overview': - return ; - case 'inspect': - return ; - case 'split': - return ; - case 'merge': - return ; - case 'convert': - return ( - - ); - } - }; + const count = workspace.entries.length; + const tabs: { id: TabId; label: string }[] = [ + { + id: 'files', + label: count > 0 ? t('tabs.filesWithCount', { count }) : t('tabs.files'), + }, + ]; return (
    - - {/* Tab navigation */} - - - {/* Shared input card — nur fuer die Einzeldatei-Werkzeuge; die Uebersicht - (quick-261001-l4q) hat ihre eigene Mehrfach-Ablage. */} - {activeTab !== 'overview' && ( -
    - {/* DropZone */} - - - {/* ODER divider */} -
    -
    - {t('or')} -
    -
    - - {/* PEM textarea */} -