feat(cert-manager): Reiter „Dateien“ mit mehreren Dateien und ein Parser für RSA und EC – Durchstich

- 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) <noreply@anthropic.com>
This commit is contained in:
2026-10-09 14:52:29 +02:00
parent e079c307e4
commit 75ea83fc01
84 changed files with 2906 additions and 5160 deletions
@@ -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).
@@ -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-----
@@ -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-----
Binary file not shown.
@@ -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-----
@@ -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-----
@@ -0,0 +1,6 @@
-----BEGIN ENCRYPTED PRIVATE KEY-----
MIGxMBwGCiqGSIb3DQEMAQMwDgQI4J4Nb3UgXXgCAggABIGQ/epVZs7aeAwW7aai
uGri0OKmYLfOjjcEHU/uvbAFhSFn8qQ+d+E6jlToOIR7ziS2RzUpPTWxmZ/08j2C
SL4tPlS6TZBtVMAAqfgAwYLOv6B3gbmL3WJ/T3shOf6qtJ9cklv7AjG3+ivPtfTb
YKUAkgRYJmuXQ6BxXwLE8wqy08DPJuw7RwTka2/zBjCRq9pP
-----END ENCRYPTED PRIVATE KEY-----
@@ -0,0 +1,8 @@
-----BEGIN EC PRIVATE KEY-----
Proc-Type: 4,ENCRYPTED
DEK-Info: AES-256-CBC,A541E4CD144B840064BB86BAF3666FFC
6S1gYfeZtkThiAKqZH2E4TMQppoaEBtVHSMKCqsfDDu5eIll2p/91NbtcC4L5WO8
NNyTeofOqe8TWt+6zrbJEjqob5Q4673JthJayFPOZeKMRhpAPe2Bg442vFBPYiJl
CwRyxJZAbf6oL8ukg6xMHT7iDyJBS32MK64owR4aBSg=
-----END EC PRIVATE KEY-----
@@ -0,0 +1,5 @@
-----BEGIN EC PRIVATE KEY-----
MHcCAQEEIDEFEkJwvCAUZT60LWQ1hQi/maGFIVMg5tD6f+FAXim3oAoGCCqGSM49
AwEHoUQDQgAEeY7t+BxIXCNmolAyuLxdVSFmnFc1+Ipj9Xalvuhrk6b00/msgJIQ
4+Dx2vciT9lGjfPJvlZqOfNNt+VVmsV7xw==
-----END EC PRIVATE KEY-----
@@ -0,0 +1,5 @@
-----BEGIN PRIVATE KEY-----
MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgMQUSQnC8IBRlPrQt
ZDWFCL+ZoYUhUyDm0Pp/4UBeKbehRANCAAR5ju34HEhcI2aiUDK4vF1VIWacVzX4
imP1dqW+6GuTpvTT+ayAkhDj4PHa9yJP2UaN88m+Vmo580235VWaxXvH
-----END PRIVATE KEY-----
Binary file not shown.
@@ -0,0 +1,8 @@
-----BEGIN CERTIFICATE REQUEST-----
MIIBGjCBwAIBADAxMRUwEwYDVQQKDAxUZXNzZXJhIFRlc3QxGDAWBgNVBAMMD2Vj
LmV4YW1wbGUudGVzdDBZMBMGByqGSM49AgEGCCqGSM49AwEHA0IABHmO7fgcSFwj
ZqJQMri8XVUhZpxXNfiKY/V2pb7oa5Om9NP5rICSEOPg8dr3Ik/ZRo3zyb5Wajnz
TbflVZrFe8egLTArBgkqhkiG9w0BCQ4xHjAcMBoGA1UdEQQTMBGCD2VjLmV4YW1w
bGUudGVzdDAKBggqhkjOPQQDAgNJADBGAiEAq1WTh8ixRI26kN4p6gmIE0h1TOua
hDLUu8FA9tXK73oCIQDAGX/FC2qjMuobhP+DX0Gre5D8B18VZyAaMiuBWCC/mg==
-----END CERTIFICATE REQUEST-----
@@ -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-----
Binary file not shown.
@@ -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-----
@@ -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 <key> <subject> <out>
csr() { openssl req -new -key "$1" -subj "$2" -out "$3"; }
# selfsign <key> <subject> <extfile> <out>
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 <csr> <issuer-cert> <issuer-key> <serial> <extfile> <out> [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() { # <file> <aia-url> [ski]
cat > "$1" <<X
basicConstraints = critical,CA:TRUE,pathlen:0
keyUsage = critical,keyCertSign,cRLSign
subjectKeyIdentifier = ${3:-hash}
authorityKeyIdentifier = keyid:always
authorityInfoAccess = caIssuers;URI:$2
X
}
ext_leaf() { # <file> <san> <aia-list>
cat > "$1" <<X
basicConstraints = critical,CA:FALSE
keyUsage = critical,digitalSignature,keyEncipherment
extendedKeyUsage = serverAuth
subjectKeyIdentifier = hash
authorityKeyIdentifier = keyid:always
subjectAltName = $2
authorityInfoAccess = $3
X
}
# ---------------------------------------------------------------- RSA PKI
rsa_key "$T/rsa-root.key"
rsa_key "$T/rsa-root2.key"
rsa_key "$T/rsa-inter.key"
rsa_key "$T/rsa-decoy.key"
rsa_key "$OUT/rsa-leaf-key.pem"
rsa_key "$T/rsa-leaf-noaki.key"
rsa_key "$T/rsa-self.key"
ext_root "$T/ext-root"
selfsign "$T/rsa-root.key" "/O=Tessera Test/CN=Tessera Test Root RSA" "$T/ext-root" "$OUT/rsa-root.pem"
selfsign "$T/rsa-root2.key" "/O=Tessera Test/CN=Tessera Test Root RSA 2" "$T/ext-root" "$OUT/rsa-root2.pem"
csr "$T/rsa-inter.key" "/O=Tessera Test/CN=Tessera Test Inter RSA" "$T/rsa-inter.csr"
ext_inter "$T/ext-inter" "http://pki.example.test/rsa-root.cer"
sign "$T/rsa-inter.csr" "$OUT/rsa-root.pem" "$T/rsa-root.key" 101 "$T/ext-inter" "$OUT/rsa-inter.pem" -days "$DAYS"
# gleicher Name, gleicher Schluessel, von einer anderen Wurzel unterschrieben (Cross-Signing)
sign "$T/rsa-inter.csr" "$OUT/rsa-root2.pem" "$T/rsa-root2.key" 102 "$T/ext-inter" "$OUT/rsa-inter-cross.pem" -days "$DAYS"
# gleicher Name, gleicher Schluessel, abgelaufen
sign "$T/rsa-inter.csr" "$OUT/rsa-root.pem" "$T/rsa-root.key" 103 "$T/ext-inter" "$OUT/rsa-inter-expired.pem" \
-not_before 20200101000000Z -not_after 20210101000000Z
# gleicher Name UND gleiche SubjectKeyIdentifier, aber anderer Schluessel: checkIssued klappt, die Signatur nicht
INTER_SKI="$(openssl x509 -in "$OUT/rsa-inter.pem" -noout -ext subjectKeyIdentifier | sed -n 2p | tr -d ' ')"
ext_inter "$T/ext-decoy" "http://pki.example.test/rsa-root.cer" "$INTER_SKI"
csr "$T/rsa-decoy.key" "/O=Tessera Test/CN=Tessera Test Inter RSA" "$T/rsa-decoy.csr"
sign "$T/rsa-decoy.csr" "$OUT/rsa-root.pem" "$T/rsa-root.key" 104 "$T/ext-decoy" "$OUT/rsa-inter-decoy.pem" -days "$DAYS"
ext_leaf "$T/ext-rsa-leaf" "DNS:www.example.test,DNS:example.test" "caIssuers;URI:http://pki.example.test/rsa-inter.cer"
csr "$OUT/rsa-leaf-key.pem" "/O=Tessera Test/CN=www.example.test" "$T/rsa-leaf.csr"
sign "$T/rsa-leaf.csr" "$OUT/rsa-inter.pem" "$T/rsa-inter.key" 201 "$T/ext-rsa-leaf" "$OUT/rsa-leaf.pem" -days "$DAYS"
openssl x509 -in "$OUT/rsa-leaf.pem" -outform DER -out "$OUT/rsa-leaf.cer"
# ohne AKI/SKI und ohne AIA
cat > "$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"
@@ -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-----
Binary file not shown.
@@ -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-----
@@ -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-----
@@ -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-----
@@ -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-----
@@ -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-----
@@ -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-----
@@ -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-----
@@ -0,0 +1,27 @@
-----BEGIN RSA PRIVATE KEY-----
MIIEowIBAAKCAQEAobMpQqjUG+aIs38pQKLsWGFfM4Pksku7YIviuAYTcW3yirIl
9wlN/NSHZzMcYW2Z46TuQRLCxsV5hbfbQ65XnpOAo/ibK923RgYXXQKtQ+qsqaL8
u424xls8JO0Wly7cy4IViTRfuWT0MaDbOOqhQuX+vwr7D+klqTWkBa+9Vmu7ASU6
grOJkzuseJoVQefd/EWvNeym1QT5wlhZK9eYBBq/4em+2eCaQFBsD1+J8Mfe/3lR
1LmabkEL/aKz3+4HZbhnvVjHrPs9gtX38vjqVtql4983iXXUrDGf2deoMle4Ce1e
n5L7syIheKbW4dqbrRt1RKEBzXIeV8FHI8qBjwIDAQABAoIBABL00Pp6llsq/I2m
c6ybLP/zcli0tqPcUvwvigWu2KqsjAmMdvzt/1GLjNAhYstdMPRKCpBgZastzWBK
vI7DqovxmNwgMfjDhk/UOPrzbGKQ6Wr9q4lFRQsj+PoPPV295a6J8oJiPHKcjjAf
PnP+tacFFYdJ/0LPP1YF7tOn1ZeC29hkhpsnrarrjLs6MJiSsIE+ffnlXZIx5sLv
OmrTQDKQthmc6TNB5nUjz+5mALpQ20eQ2/vMI+mBDhglbm2zOzMmnwRo5mLg49Rt
G6MX13a+CA2vEgid3BR9OYxb2KWmoLI5vWZ6fig+I6RYNTqaJaeGoq3nWSQTqaYf
JejtQlECgYEA2i7cr3Ppa2EpJbIClXXXgDdtJGO1HWTgWwpb17VKKkYEb5/Fuz3X
2G5uHBFIDvHqFFIsLiYgHnivU+sqcdk1AG77a862QTp0EOoT2A3ycGYGSFsc5Gmj
wm4jgWwEs/VkLMZmNL9tPxZDzZXJLYI7ipNPLCObErWJsv5epJvUzakCgYEAvboM
ER21Ic68vP7OSKBnoS2a5HCcP6XwirLa+Uf7UX43FBq47cdyffHt30vUiSVmBMYF
N0CjbGCP1UmQZV/uvi30uTDHSMdiu0OG6YrgvTpzcYCoekC2F6Dwj/+e38vR2HjK
6o9IVdyZsREnHSJ3Tp74OnM5NmJ8PMZIaSaoqHcCgYAUtspA5hJNHYZYi9Pu+Kcj
ByAXilfRmJgsTIl3q0CFTuxw5IxTQIXTiHhvtbxPnuwITdzf7MKz4eVTZEo9UM/V
YLQCAjgPbUtDp9WGd5yCy0zhZbUSgm0Hsokwal7v7uY50gQ2Ec5/ZM+/Hu9goLKi
b2Xe3OqGFjIiij/WBO1pMQKBgDF8g4U7YHFvAR+yvo+G90C9KneHP9oCKFmlCSSY
N98rsqYVykKLETv0jHz/xHH5RUDdfaLJw1aTYTDavUUT3qvQRNLA/FnpV8FMfbYQ
uUniIxZp3of0tQo4ukZ1ADWTbiquZw8DTHTI03Wx6afZw5DIOirUvwmGaq1Xcq+q
m7dFAoGBAMjSpKii/ShdmscrvQmdKxsKoxpN7LPnA10Z5fHe4EgDYH8ATugJXfMo
y4NEfb30DTYF6PqVn0Fry284kcyjQZEEq73mPdSOQK3olMK7AAi+04dbnNT8l6xX
0pzZoycBAsoAsW96PaWq0rHMyD09gKh56DeXEjGRCSRIB2TOG0AD
-----END RSA PRIVATE KEY-----
@@ -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-----
@@ -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-----
Binary file not shown.
@@ -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-----
@@ -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-----
@@ -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-----
@@ -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-----
@@ -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-----
@@ -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-----
@@ -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);
});
});
+75
View File
@@ -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<CertItem['role'], number> = { '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<string, AnyItem>();
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 };
}
@@ -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');
});
});
-655
View File
@@ -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<UploadedFileLike, 'buffer' | 'originalname'>;
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<string, { cert: forge.pki.Certificate; sources: Set<string> }>();
for (const { source, cert } of c.certs) {
const fp = sha256Of(cert);
const entry = certMap.get(fp) ?? { cert, sources: new Set<string>() };
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<string>([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<string, { key: RawKey; sources: Set<string> }>();
for (const key of c.keys) {
const id = key.modulus || key.pem;
const entry = keyMap.get(id) ?? { key, sources: new Set<string>() };
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<string, { pem: string; sources: Set<string> }>();
for (const csr of c.csrs) {
const norm = csr.pem.replace(/\s+/g, '');
const entry = csrMap.get(norm) ?? { pem: csr.pem, sources: new Set<string>() };
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<BundleCertRole, number> = { '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}`);
}
@@ -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');
});
});
@@ -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 ?? '');
analyze(@UploadedFiles() files: UploadedFileLike[] | undefined): AnalysisResult {
if (!files || files.length === 0) {
certError('invalidInput', 400, 'No files provided');
}
/**
* 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');
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 exportBundleItem({ kind, pem, format, baseName, chain, keyPem, password });
return analyzeWorkingSet(
files.map((f) => ({ originalname: repairFileName(f.originalname), buffer: f.buffer })),
);
}
}
@@ -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<void> {
try {
@@ -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<any>);
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<any>);
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<any>);
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<any>);
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<any>);
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<any>);
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<any>);
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<any>);
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<any>);
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<any>);
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<any>);
expect(result.subject.cn).toBe('valid-pfx.example.com');
}, 15000);
});
@@ -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<UploadedFileLike, 'buffer' | 'originalname'>;
// ---------------------------------------------------------------------------
// 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<string, string> = {
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<string, string> {
const result: Record<string, string> = {};
for (const [name, oid] of Object.entries(forge.pki.oids as Record<string, string>)) {
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<CertDetails> {
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<SplitResponse> {
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<PkcsEnvelopedData | PkcsSignedData>.
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<CertRole, number> = { 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<FileResponse> {
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<FileResponse> {
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<T>(fn: () => T, errorMsg: string): T {
try {
return fn();
} catch (_err) {
this.logger.warn(`Cert parse failed: ${errorMsg}`);
throw new BadRequestException(errorMsg);
}
}
}
@@ -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}$/);
});
});
+324
View File
@@ -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<string, { name: string; bits: number }> = {
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<string, unknown> | 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<string, unknown>;
issuer?: Record<string, unknown>;
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: [],
};
}
@@ -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');
});
});
+14
View File
@@ -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;
}
+183
View File
@@ -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);
}
@@ -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;
}
@@ -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;
}
@@ -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;
}
@@ -1,110 +1,190 @@
export const API_URL =
process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
import { toFormData, type WorkingEntry } from './working-set';
// ---------------------------------------------------------------------------
// SplitResponse — mirrors CertManagerService.SplitResponse
// ---------------------------------------------------------------------------
/**
* Aufrufe der Zertifikat-Manager-API (quick-261009-ikt). Die Typen spiegeln 1:1
* apps/api/src/cert-manager/cert-types.ts.
*
* 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 type CertRole = 'root' | 'intermediate' | 'end-entity';
export const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
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 ItemSource {
file: number;
path: string;
}
export interface SplitResponse {
count: number;
certs: SplitEntry[];
}
export type CertRole = 'end-entity' | 'intermediate' | 'root';
// ---------------------------------------------------------------------------
// 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 };
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;
keyBits: number | null;
curve: string | null;
serialNumber: string;
signatureAlgorithm: string;
fingerprint: { sha1: string; sha256: string };
pemPreview: string;
sha256: string;
sha1: string;
isCa: boolean;
selfSigned: boolean;
aiaIssuerUrls: string[];
keyId: string | null;
csrIds: string[];
}
// ---------------------------------------------------------------------------
// inspectCertAction — calls POST /modules/cert-manager/parse
// ---------------------------------------------------------------------------
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[];
}
/**
* Call POST /modules/cert-manager/parse.
* - If pemText is present → JSON body { pemText, password }
* - Otherwise → multipart FormData with file + optional password
*
* T-09-02: password is never placed in URL, logged, or echoed.
* T-09-04: credentials:'include' ensures JWT cookie is sent.
*/
export async function inspectCertAction(input: {
file?: File | null;
pemText?: string;
password?: string;
}): Promise<CertDetails> {
const { file, pemText, password } = input;
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[];
}
if (pemText) {
// JSON path — content-type must be application/json (not multipart)
const response = await fetch(`${API_URL}/modules/cert-manager/parse`, {
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';
}
}
const KNOWN_ERROR_CODES = new Set([
'invalidInput',
'notACertificate',
'noChain',
'keyMissing',
'keyMismatch',
'passwordRequired',
'formatNotPossible',
'templateNeedsKey',
'tooLarge',
'aiaMissing',
'aiaInternal',
'aiaNotIssuer',
'aiaUnreachable',
'aiaTooLarge',
]);
/** Schluessel unter `certManager.errors.<key>`: 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';
}
async function errorFromResponse(response: Response): Promise<CertManagerRequestError> {
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<AnalysisResult> {
const response = await fetch(`${API_URL}/modules/cert-manager/analyze`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ pemText, password }),
body: toFormData(entries),
credentials: 'include',
});
if (!response.ok) {
const body = await response.text().catch(() => '');
throw new Error(`${response.status} ${body}`.trim());
}
return response.json() as Promise<CertDetails>;
} 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<CertDetails>;
}
if (!response.ok) throw await errorFromResponse(response);
return (await response.json()) as AnalysisResult;
}
// ---------------------------------------------------------------------------
// splitCertsAction — calls POST /modules/cert-manager/split
// ---------------------------------------------------------------------------
/**
* 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<SplitResponse> {
const form = new FormData();
form.append('file', file);
return postForm('split', form) as Promise<SplitResponse>;
}
/**
* 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 {
/** 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<FileResponse> {
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<FileResponse>;
}
// ---------------------------------------------------------------------------
// 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<FileResponse> {
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<FileResponse>;
}
/**
* 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<unknown> {
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<BundleAnalysis> {
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<BundleAnalysis>;
}
/** 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<FileResponse> {
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<FileResponse>;
}
@@ -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<string, string> = {
'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<typeof import('./actions')>();
return { ...actual, analyzeWorkingSet: (...args: unknown[]) => mockAnalyze(...args) };
});
vi.mock('@/components/layout/page-header', () => ({
PageHeader: ({ title, description }: { title: string; description?: string }) => (
<header>
<h1>{title}</h1>
<p>{description}</p>
</header>
),
}));
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';
afterEach(() => {
cleanup();
vi.restoreAllMocks();
});
describe('CertManagerPage shell', () => {
it('renders the page title and description', () => {
render(<CertManagerPage />);
expect(screen.getByText('Zertifikat-Manager')).toBeInTheDocument();
expect(screen.getByText('Zertifikate analysieren, aufteilen, zusammenfuehren und konvertieren.')).toBeInTheDocument();
});
it('renders all four tab labels', () => {
render(<CertManagerPage />);
// "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();
});
it('does NOT render the password field on initial render (no PFX file selected)', () => {
render(<CertManagerPage />);
// 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(<CertManagerPage />);
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(<CertManagerPage />);
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(<CertManagerPage />);
// 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(<CertManagerPage />);
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(<CertManagerPage />);
// "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(
<InspectTab
file={null}
pemText="-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----"
password=""
/>,
function render(ui: ReactElement) {
return rtlRender(
<NextIntlClientProvider locale="de" messages={de} timeZone="Europe/Berlin">
{ui}
</NextIntlClientProvider>,
);
}
// Click the Analysieren button
fireEvent.click(screen.getByText('Analysieren'));
beforeEach(() => {
mockAnalyze.mockReset();
mockAnalyze.mockResolvedValue({ items: [], chains: [], locked: [], ignored: [] });
});
afterEach(cleanup);
// Wait for the async action to complete and result grid to render
await waitFor(() => {
expect(screen.getByText('example.com')).toBeInTheDocument();
describe('CertManagerPage', () => {
it('zeigt Titel und den einen Reiter „Dateien“ als aktiven Reiter', () => {
render(<CertManagerPage />);
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();
});
// 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(
<InspectTab
file={null}
pemText="test pem"
password="wrong"
/>,
);
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' },
},
it('der Reiter zeigt die Anzahl der Dateien', async () => {
render(<CertManagerPage />);
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 }),
],
};
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(<SplitTab file={testFile} pemText="" password="" />);
// 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(<SplitTab file={testFile} pemText="" password="" />);
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(<ConvertTab file={null} pemText="" password="" />);
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(<ConvertTab file={testFile} pemText="" password="" />);
// 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,
await waitFor(() =>
expect(screen.getByRole('button', { name: 'Dateien (2)' })).toBeInTheDocument(),
);
});
});
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(<ConvertTab file={testFile} pemText="" password="" />);
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(<ConvertTab file={null} pemText="" password="" />);
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(<MergeTab password="" />);
// 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(<MergeTab password="" />);
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
it('ruft die Analyse nicht ohne Dateien auf', () => {
render(<CertManagerPage />);
// 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(<MergeTab password="" />);
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();
});
});
@@ -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<string | null>(null);
const [targetFormat, setTargetFormat] = useState<TargetFormat>('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 (
<div className="space-y-4">
{/* Target format selector */}
<div className="flex items-center gap-3">
<label htmlFor="targetFormat" className="text-sm font-medium text-foreground">
Ausgabeformat
</label>
<select
id="targetFormat"
value={targetFormat}
onChange={(e) => handleFormatChange(e.target.value as TargetFormat)}
className="rounded border border-border bg-background px-2 py-1 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
>
<option value="pem">PEM</option>
<option value="der">DER</option>
<option value="p7b">P7B</option>
<option value="pfx">PFX / PKCS12</option>
</select>
</div>
{/* Primary action button */}
<button
type="button"
onClick={handleConvert}
disabled={loading || !canConvert}
className="btn btn-primary"
>
{loading ? t('actions.processing') : t('actions.convert')}
</button>
{/* Error state */}
{error && <p className="text-sm text-destructive">{error}</p>}
{/* Empty state — shown when no error */}
{!error && (
<div className="text-center py-8 space-y-1">
<p className="text-sm font-medium text-foreground">{t('emptyState.convert')}</p>
<p className="text-sm text-muted-foreground">{t('emptyState.convertBody')}</p>
</div>
)}
</div>
);
}
@@ -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<HTMLInputElement>(null);
const [isDragOver, setIsDragOver] = useState(false);
const handleClick = () => {
fileInputRef.current?.click();
};
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
onFile(file);
}
// Allow re-selecting the same file
e.target.value = '';
};
const handleDragOver = (e: React.DragEvent<HTMLButtonElement>) => {
e.preventDefault();
setIsDragOver(true);
};
const handleDragLeave = () => {
setIsDragOver(false);
};
const handleDrop = (e: React.DragEvent<HTMLButtonElement>) => {
e.preventDefault();
setIsDragOver(false);
const file = e.dataTransfer.files?.[0];
if (file) {
onFile(file);
}
};
return (
<div>
<input
ref={fileInputRef}
type="file"
accept={accept}
className="hidden"
onChange={handleFileChange}
/>
{/* a11y/useSemanticElements: kein role="button"-div mehr -- die
eigentliche Flaeche ist jetzt ein echtes <button> und traegt darum
auch Klick UND Drag-Ereignisse (ein <div> ohne Rolle mit
Ereignis-Handlern waere ein statisches, nicht interaktives Element
und faellt unter die zurueckgestellten Regeln noStaticElement-
Interactions/noNoninteractiveElementInteractions). Die "Entfernen"-
Schaltflaeche liegt darum als Geschwister daneben, nicht mehr
verschachtelt in der Flaechen-Schaltflaeche -- ein <button> darf
kein weiteres <button> enthalten (ungueltiges Verschachteln). */}
<div className="relative">
<button
type="button"
onClick={handleClick}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
className={`block w-full cursor-pointer rounded-lg border-2 border-dashed p-8 text-center transition-colors ${
isDragOver
? 'border-primary bg-primary/5'
: 'border-border hover:border-primary/50'
}`}
>
{currentFile ? (
<span className="text-sm text-foreground">
{currentFile.name}{' '}
<span className="text-muted-foreground">
({(currentFile.size / 1024).toFixed(1)} KB)
</span>
</span>
) : (
<div className="space-y-1">
<p className="text-sm text-muted-foreground">
{t('dropZone.placeholder')}
</p>
<p className="text-xs text-muted-foreground">{t('dropZone.formats')}</p>
</div>
)}
</button>
{currentFile && (
<button
type="button"
onClick={(e) => { e.stopPropagation(); onClear(); }}
className="absolute right-2 top-2 rounded border border-border bg-card px-2 py-0.5 text-xs text-muted-foreground hover:text-foreground"
>
&#x2715;
</button>
)}
</div>
</div>
);
}
@@ -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<typeof import('../actions')>();
return { ...actual, analyzeWorkingSet: (...args: unknown[]) => mockAnalyze(...args) };
});
function render(ui: ReactElement) {
return rtlRender(
<NextIntlClientProvider locale="de" messages={de} timeZone="Europe/Berlin">
{ui}
</NextIntlClientProvider>,
);
}
function Harness() {
const workspace = useCertWorkspace();
return <FilesTab workspace={workspace} />;
}
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(<Harness />);
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(<Harness />);
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(<Harness />);
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(<Harness />);
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(<Harness />);
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(<Harness />);
selectFiles([makeFile('readme.txt')]);
await screen.findByText('Darin wurde kein Zertifikat erkannt.');
});
it('abgelehnte Dateien werden mit Grund aufgelistet', async () => {
render(<Harness />);
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(<Harness />);
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<AnalysisResult>((r) => {
resolve = r;
}),
);
render(<Harness />);
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(<Harness />);
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<AnalysisResult>((r) => {
resolvers.push(r);
}),
);
render(<Harness />);
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();
});
});
@@ -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<string, string> = {
'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<HTMLInputElement>(null);
const [dragging, setDragging] = useState(false);
const [rejected, setRejected] = useState<RejectedFile[]>([]);
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 (
<div className="space-y-4">
<button
type="button"
onClick={() => inputRef.current?.click()}
onDragOver={(e) => {
e.preventDefault();
setDragging(true);
}}
onDragLeave={() => setDragging(false)}
onDrop={(e) => {
e.preventDefault();
setDragging(false);
add(e.dataTransfer?.files);
}}
className={`flex w-full flex-col items-center gap-1 rounded-lg border-2 border-dashed px-4 py-8 text-center transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring ${
dragging ? 'border-primary-strong bg-muted' : 'border-border hover:bg-muted'
}`}
>
<span className="text-sm font-medium text-foreground">{t('files.dropTitle')}</span>
<span className="text-xs text-muted-foreground">{t('files.dropHint')}</span>
</button>
<input
ref={inputRef}
type="file"
multiple
accept={ACCEPT}
className="hidden"
data-testid="cert-file-input"
tabIndex={-1}
onChange={(e) => {
add(e.target.files);
e.target.value = '';
}}
/>
<p className="text-xs text-muted-foreground">
{t('files.limits', {
maxFiles: MAX_ENTRIES,
maxFile: MAX_FILE_BYTES / MB,
maxTotal: MAX_TOTAL_BYTES / MB,
})}
</p>
{rejected.length > 0 && (
<div role="alert" className="rounded-lg border border-border bg-muted p-3 text-sm">
<p className="font-medium text-foreground">{t('files.rejectedTitle')}</p>
<ul className="mt-1 space-y-0.5 text-muted-foreground">
{rejected.map((r) => (
<li key={`${r.name}-${r.reason}`}>
{t(`files.rejected.${r.reason}`, {
name: r.name,
max: MAX_ENTRIES,
size: (r.reason === 'tooLarge' ? MAX_FILE_BYTES : MAX_TOTAL_BYTES) / MB,
})}
</li>
))}
</ul>
</div>
)}
<p className="rounded-lg bg-muted p-3 text-xs text-muted-foreground">
{t('files.memoryNote')}
</p>
{entries.length === 0 ? (
<p className="text-sm text-muted-foreground">{t('files.empty')}</p>
) : (
<section className="space-y-3" aria-label={t('files.listTitle', { count: entries.length })}>
<div className="flex flex-wrap items-center justify-between gap-2">
<h2 className="text-sm font-semibold text-foreground">
{t('files.listTitle', { count: entries.length })}
</h2>
<button
type="button"
onClick={workspace.clear}
className="rounded border border-border px-3 py-1 text-sm text-foreground hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
{t('files.removeAll')}
</button>
</div>
{status === 'analyzing' && (
<p role="status" className="text-sm text-muted-foreground">
{t('files.analyzing')}
</p>
)}
{status === 'error' && (
<div role="alert" className="rounded-lg border border-border bg-muted p-3 text-sm">
<p className="text-foreground">{t(`errors.${errorKey ?? 'generic'}`)}</p>
<button
type="button"
onClick={workspace.retry}
className="mt-2 rounded border border-border px-3 py-1 text-foreground hover:bg-card focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
{t('files.retry')}
</button>
</div>
)}
<ul className="space-y-2">
{entries.map((entry) => {
const items = itemsOf(entry);
const ignored = ignoredOf(entry);
const analysed = analysisIds.includes(entry.id);
return (
<li key={entry.id} className="rounded-lg border border-border p-3">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="break-all text-sm font-medium text-foreground">{entry.label}</p>
<p className="text-xs text-muted-foreground">
{formatBytes(entry.file.size)}
</p>
</div>
<button
type="button"
onClick={() => workspace.remove(entry.id)}
aria-label={t('files.remove', { name: entry.label })}
className="shrink-0 rounded border border-border px-2 py-1 text-xs text-foreground hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
{t('files.removeShort')}
</button>
</div>
<ul className="mt-2 space-y-1">
{items.map((item) => (
<li key={item.id} className="flex flex-wrap items-center gap-2 text-sm">
<span
className={`rounded px-2 py-0.5 text-xs font-semibold ${ROLE_STYLES[roleKey(item)]}`}
>
{t(`roles.${roleKey(item)}`)}
</span>
<span className="break-all text-foreground">{itemName(item)}</span>
{item.kind === 'certificate' && item.isExpired && (
<span className="rounded bg-red-100 px-2 py-0.5 text-xs font-medium text-red-800 dark:bg-red-900/40 dark:text-red-300">
{t('files.expired')}
</span>
)}
</li>
))}
{ignored.map((i) => (
<li key={`${i.path}-${i.reason}`} className="text-sm text-muted-foreground">
{t(`files.ignored.${i.reason}`)}
</li>
))}
{!analysed && status === 'analyzing' && (
<li className="text-sm text-muted-foreground">{t('files.checking')}</li>
)}
</ul>
</li>
);
})}
</ul>
</section>
)}
</div>
);
}
@@ -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<CertDetails | null>(null);
const [error, setError] = useState<string | null>(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 (
<div className="space-y-4">
{/* Primary action button */}
<button
type="button"
onClick={handleInspect}
disabled={loading || !canInspect}
className="btn btn-primary"
>
{loading ? t('actions.processing') : t('actions.inspect')}
</button>
{/* Error state */}
{error && (
<p className="text-sm text-destructive">{error}</p>
)}
{/* Empty state — shown when no result and no error */}
{!result && !error && (
<div className="text-center py-8 space-y-1">
<p className="text-sm font-medium text-foreground">{t('emptyState.inspect')}</p>
<p className="text-sm text-muted-foreground">{t('emptyState.inspectBody')}</p>
</div>
)}
{/* Result grid — key-value layout per UI-SPEC (grid-cols-2 gap-2 text-sm) */}
{result && (
<div className="grid grid-cols-2 gap-2 text-sm">
<span className="font-medium text-muted-foreground">Subject CN</span>
<span className="break-all">{result.subject.cn}</span>
{result.subject.o && (
<>
<span className="font-medium text-muted-foreground">Subject O</span>
<span>{result.subject.o}</span>
</>
)}
{result.subject.ou && (
<>
<span className="font-medium text-muted-foreground">Subject OU</span>
<span>{result.subject.ou}</span>
</>
)}
{result.subject.c && (
<>
<span className="font-medium text-muted-foreground">Subject C</span>
<span>{result.subject.c}</span>
</>
)}
<span className="font-medium text-muted-foreground">Issuer CN</span>
<span className="break-all">{result.issuer.cn}</span>
{result.issuer.o && (
<>
<span className="font-medium text-muted-foreground">Issuer O</span>
<span>{result.issuer.o}</span>
</>
)}
<span className="font-medium text-muted-foreground">Not Before</span>
<span>{result.validity.notBefore}</span>
<span className="font-medium text-muted-foreground">Not After</span>
<span>{result.validity.notAfter}</span>
<span className="font-medium text-muted-foreground">Expired</span>
<span>{result.validity.isExpired ? 'Yes' : 'No'}</span>
<span className="font-medium text-muted-foreground">Days Left</span>
<span>{result.validity.daysLeft}</span>
<span className="font-medium text-muted-foreground">Key Type</span>
<span>{result.keyType}</span>
<span className="font-medium text-muted-foreground">Key Bits</span>
<span>{result.keyBits}</span>
<span className="font-medium text-muted-foreground">Serial Number</span>
<span className="break-all">{result.serialNumber}</span>
<span className="font-medium text-muted-foreground">Signature Algorithm</span>
<span>{result.signatureAlgorithm}</span>
<span className="font-medium text-muted-foreground">SHA-1</span>
<span className="break-all font-mono text-xs">{result.fingerprint.sha1}</span>
<span className="font-medium text-muted-foreground">SHA-256</span>
<span className="break-all font-mono text-xs">{result.fingerprint.sha256}</span>
{result.san.length > 0 && (
<>
<span className="font-medium text-muted-foreground">SANs</span>
<span className="break-all">{result.san.join(', ')}</span>
</>
)}
</div>
)}
</div>
);
}
@@ -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(<MergeTab password="" />);
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(<MergeTab password="" />);
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();
});
});
@@ -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<File[]>([]);
const [outputFormat, setOutputFormat] = useState<MergeOutputFormat>('pem');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
function handleFormatChange(format: MergeOutputFormat) {
setOutputFormat(format);
onOutputFormatChange?.(format);
}
function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
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 (
<div className="space-y-4">
{/* Selected file list */}
{files.length > 0 && (
<ul className="space-y-1 rounded border border-border bg-background p-3">
{files.map((f, i) => (
<li key={`${f.name}-${i}`} className="flex items-center justify-between text-sm">
<span className="text-foreground">{f.name}</span>
<button
type="button"
onClick={() => removeFile(i)}
className="text-xs text-muted-foreground hover:text-destructive ml-2"
aria-label={`Remove ${f.name}`}
>
✕
</button>
</li>
))}
<li className="pt-1 text-xs text-muted-foreground">
{files.length} {files.length === 1 ? 'Datei' : 'Dateien'} ausgewählt
</li>
</ul>
)}
{/* Multi-file input */}
<div>
<input
type="file"
multiple
accept=".pem,.crt,.cer,.der,.pfx,.p12,.p7b,.p7c"
onChange={handleFileChange}
data-testid="merge-file-input"
className="block text-sm text-foreground cursor-pointer"
/>
</div>
{/* Output format selector */}
<div className="flex items-center gap-3">
<label
htmlFor="mergeOutputFormat"
className="text-sm font-medium text-foreground"
>
Ausgabeformat
</label>
<select
id="mergeOutputFormat"
value={outputFormat}
onChange={(e) => handleFormatChange(e.target.value as MergeOutputFormat)}
className="rounded border border-border bg-background px-2 py-1 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
>
<option value="pem">PEM-Kette</option>
<option value="pfx">PFX / PKCS12</option>
</select>
</div>
{/* Primary action button — disabled until >= 2 files selected */}
<button
type="button"
data-testid="merge-action-btn"
onClick={handleMerge}
disabled={loading || !canMerge}
className="btn btn-primary"
>
{loading ? t('actions.processing') : t('actions.merge')}
</button>
{/* Error state */}
{error && <p className="text-sm text-destructive">{error}</p>}
{/* Empty state — shown when no files and no error */}
{files.length === 0 && !error && (
<div className="text-center py-8 space-y-1">
<p className="text-sm font-medium text-foreground">{t('emptyState.merge')}</p>
<p className="text-sm text-muted-foreground">{t('emptyState.mergeBody')}</p>
</div>
)}
</div>
);
}
@@ -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<string, unknown>) =>
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<typeof vi.fn>;
const mockExport = exportBundleItemAction as ReturnType<typeof vi.fn>;
const mockDownload = downloadBase64 as ReturnType<typeof vi.fn>;
function item(over: Partial<BundleItem>): 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(<OverviewTab />);
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();
});
});
@@ -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<string, string> = {
'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<HTMLInputElement>(null);
const [files, setFiles] = useState<File[]>([]);
const [password, setPassword] = useState('');
const [isDragOver, setIsDragOver] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [result, setResult] = useState<BundleAnalysis | null>(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 (
<div className="space-y-6">
<input
ref={inputRef}
type="file"
multiple
accept={ACCEPT}
className="hidden"
data-testid="overview-file-input"
onChange={(e) => {
addFiles(e.target.files);
e.target.value = '';
}}
/>
<button
type="button"
onClick={() => inputRef.current?.click()}
onDragOver={(e) => {
e.preventDefault();
setIsDragOver(true);
}}
onDragLeave={() => setIsDragOver(false)}
onDrop={(e) => {
e.preventDefault();
setIsDragOver(false);
addFiles(e.dataTransfer.files);
}}
className={`block w-full cursor-pointer rounded-lg border-2 border-dashed p-8 text-center transition-colors ${
isDragOver ? 'border-primary bg-primary/5' : 'border-border hover:border-primary/50'
}`}
>
<p className="text-sm text-foreground">{t('dropTitle')}</p>
<p className="mt-1 text-xs text-muted-foreground">{t('dropHint')}</p>
</button>
{files.length > 0 && (
<div className="flex flex-wrap items-center gap-2">
{files.map((file, i) => (
<span
// biome-ignore lint/suspicious/noArrayIndexKey: dieselbe Datei darf zweimal in der Liste stehen; die Liste aendert sich nur durch Anhaengen/Entfernen
key={`${file.name}-${i}`}
className="inline-flex items-center gap-1.5 rounded border border-border px-2 py-1 text-xs"
>
{file.name}
<button
type="button"
aria-label={t('removeFile', { name: file.name })}
onClick={() => removeFile(i)}
className="text-muted-foreground hover:text-foreground"
>
&#x2715;
</button>
</span>
))}
<button type="button" onClick={reset} className="text-xs text-muted-foreground underline">
{t('reset')}
</button>
</div>
)}
{loading && <p className="text-sm text-muted-foreground">{t('analyzing')}</p>}
{error && <p className="text-sm text-destructive">{error}</p>}
{result && result.locked.length > 0 && (
<LockedNotice
files={result.locked}
// Liegen ein Zertifikat UND sein Schluessel schon einzeln vor,
// steckt in der geschuetzten PFX nichts Neues (Aussteller-ZIP vom
// 01.10.2026: PFX mit unbekanntem Passwort neben .pem/.key) — dann
// nur ein ruhiger Hinweis, das Passwort ist optional.
notNeeded={result.items.some((i) => 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 && (
<p className="text-sm text-muted-foreground">{t('nothingFound')}</p>
)}
{result && result.items.length > 0 && (
<ul className="space-y-4">
{result.items.map((item) => (
<BundleItemCard key={item.id} item={item} byId={byId} />
))}
</ul>
)}
{result && result.ignored.length > 0 && (
<p className="text-xs text-muted-foreground">
{t('ignored', { files: result.ignored.join(', ') })}
</p>
)}
</div>
);
}
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 (
<div
className={`space-y-2 rounded-lg border p-4 ${
props.notNeeded ? 'border-border bg-muted/30' : 'border-status-warn/50 bg-status-warn/10'
}`}
data-testid="locked-notice"
>
<p className="text-sm text-foreground">
{t(props.notNeeded ? 'lockedNotNeeded' : 'locked', { files: props.files.join(', ') })}
</p>
{!showInput && (
<button
type="button"
onClick={props.onOpen}
className="text-xs text-muted-foreground underline"
>
{t('unlockAnyway')}
</button>
)}
{showInput && (
<div className="flex flex-wrap items-center gap-2">
<input
type="password"
value={props.password}
onChange={(e) => 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"
/>
<button
type="button"
className="btn btn-secondary"
disabled={!props.password || props.loading}
onClick={props.onUnlock}
>
{t('unlock')}
</button>
</div>
)}
</div>
);
}
function BundleItemCard({ item, byId }: { item: BundleItem; byId: Record<string, BundleItem> }) {
const t = useTranslations('certManager.overview');
const [pfxOpen, setPfxOpen] = useState(false);
const [pfxPassword, setPfxPassword] = useState('');
const [busy, setBusy] = useState<BundleExportFormat | null>(null);
const [error, setError] = useState<string | null>(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 (
<li className="rounded-lg border border-border p-4" data-testid="bundle-item">
<div className="flex flex-wrap items-center gap-2">
<span className={`rounded px-2 py-0.5 text-xs font-semibold ${ROLE_STYLES[typeKey(item)]}`}>
{t(`type.${typeKey(item)}`)}
</span>
<span className="font-medium break-all">{item.cn || item.baseName}</span>
{status && (
<span className={`rounded px-2 py-0.5 text-xs font-medium ${status.cls}`}>
{status.text}
</span>
)}
</div>
<p className="mt-1 text-xs text-muted-foreground">{t(`explain.${typeKey(item)}`)}</p>
<dl className="mt-3 grid grid-cols-[max-content_1fr] gap-x-4 gap-y-1 text-sm">
{item.kind === 'certificate' && (
<>
<dt className="text-muted-foreground">{t('issuer')}</dt>
<dd className="break-all">{item.issuerCn}</dd>
<dt className="text-muted-foreground">{t('validity')}</dt>
<dd>
{formatDate(item.notBefore)} – {formatDate(item.notAfter)}
</dd>
</>
)}
{item.san.length > 0 && (
<>
<dt className="text-muted-foreground">{t('names')}</dt>
<dd className="break-all">{item.san.join(', ')}</dd>
</>
)}
{item.keyType && (
<>
<dt className="text-muted-foreground">{t('key')}</dt>
<dd>{item.keyBits > 0 ? `${item.keyType} ${item.keyBits} Bit` : item.keyType}</dd>
</>
)}
{match && (
<>
<dt className="text-muted-foreground">{t('belongsTo')}</dt>
<dd className="break-all">
{t(`type.${typeKey(match)}`)} {match.cn}
</dd>
</>
)}
<dt className="text-muted-foreground">{t('source')}</dt>
<dd className="break-all">{item.sources.join(', ')}</dd>
</dl>
{item.kind === 'privateKey' && (
<p className="mt-2 text-xs text-status-warn-fg">{t('keySecret')}</p>
)}
<div className="mt-4 flex flex-wrap gap-2">
{item.formats.map((format) => (
<button
key={format}
type="button"
disabled={busy !== null}
onClick={() => (format === 'pfx' ? setPfxOpen((o) => !o) : void download(format))}
className="rounded border border-border px-3 py-1.5 text-xs font-medium transition-colors hover:bg-secondary disabled:opacity-50"
title={t(`formatHint.${format}`)}
>
{busy === format ? t('downloading') : t(`format.${format}`)}
</button>
))}
</div>
{pfxOpen && (
<div className="mt-3 space-y-2 rounded border border-border p-3">
<p className="text-xs text-muted-foreground">
{match ? t('pfxWithKey') : t('pfxWithoutKey')}
</p>
<div className="flex flex-wrap items-center gap-2">
<input
type="password"
value={pfxPassword}
onChange={(e) => 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"
/>
<button
type="button"
className="btn btn-primary"
disabled={!pfxPassword || busy !== null}
onClick={() => void download('pfx', pfxPassword)}
>
{busy === 'pfx' ? t('downloading') : t('pfxDownload')}
</button>
</div>
</div>
)}
{error && <p className="mt-2 text-sm text-destructive">{error}</p>}
</li>
);
}
@@ -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 (
<div className="space-y-1">
<label htmlFor="cert-manager-password" className="block text-sm text-foreground">
{t('password.label')}
</label>
<div className="relative flex items-center">
<input
id="cert-manager-password"
type={revealed ? 'text' : 'password'}
value={value}
onChange={(e) => 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"
/>
<button
type="button"
onClick={() => setRevealed((prev) => !prev)}
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
aria-label={revealed ? 'Passwort verbergen' : 'Passwort anzeigen'}
>
{revealed ? (
/* Eye-off inline SVG */
<svg
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94" />
<path d="M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19" />
<line x1="1" y1="1" x2="23" y2="23" />
</svg>
) : (
/* Eye inline SVG */
<svg
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
<circle cx="12" cy="12" r="3" />
</svg>
)}
</button>
</div>
</div>
);
}
@@ -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<CertRole, string> = {
'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<string, Uint8Array> = {};
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<SplitResponse | null>(null);
const [error, setError] = useState<string | null>(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 (
<div className="space-y-4">
<div className="flex items-center gap-3">
<button
type="button"
onClick={handleSplit}
disabled={loading || !file}
className="btn btn-primary"
>
{loading ? t('actions.processing') : t('actions.split')}
</button>
{result && result.certs.length > 1 && (
<button
type="button"
onClick={() => downloadAllAsZip(result.certs, t('actions.zipFilename'))}
className="border border-border px-4 py-2 rounded text-sm font-medium hover:bg-secondary transition-colors"
>
{t('actions.downloadZip')}
</button>
)}
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
{!result && !error && (
<div className="text-center py-8 space-y-1">
<p className="text-sm font-medium text-foreground">{t('emptyState.split')}</p>
<p className="text-sm text-muted-foreground">{t('emptyState.splitBody')}</p>
</div>
)}
{result && (
<ul className="space-y-2">
{result.certs.map((cert) => (
<li
key={cert.index}
className="flex items-center justify-between rounded bg-secondary px-3 py-2 text-sm gap-3"
>
<div className="min-w-0 flex-1 flex items-center gap-2">
<span
className={`shrink-0 inline-block rounded px-2 py-0.5 text-xs font-semibold ${ROLE_STYLES[cert.certRole ?? 'end-entity']}`}
>
{t(`certRole.${cert.certRole ?? 'end-entity'}`)}
</span>
<span className="font-medium truncate">
{cert.subject.cn || cert.filename}
</span>
<span className="text-muted-foreground text-xs shrink-0">
{cert.validity.notAfter}
</span>
</div>
<button
type="button"
onClick={() =>
downloadBase64(cert.filename, cert.content, 'application/x-pem-file')
}
className="shrink-0 border border-border px-3 py-1 rounded text-xs font-medium hover:bg-background transition-colors"
>
{t('actions.download')}
</button>
</li>
))}
</ul>
)}
</div>
);
}
@@ -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<TabId>('files');
const [activeTab, setActiveTab] = useState<TabId>('overview');
const [file, setFile] = useState<File | null>(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 <OverviewTab />;
case 'inspect':
return <InspectTab file={file} pemText={pemText} password={password} />;
case 'split':
return <SplitTab file={file} pemText={pemText} password={password} />;
case 'merge':
return <MergeTab password={password} onOutputFormatChange={setMergeOutputFormat} />;
case 'convert':
return (
<ConvertTab
file={file}
pemText={pemText}
password={password}
onTargetFormatChange={setConvertOutputFormat}
/>
);
}
};
const count = workspace.entries.length;
const tabs: { id: TabId; label: string }[] = [
{
id: 'files',
label: count > 0 ? t('tabs.filesWithCount', { count }) : t('tabs.files'),
},
];
return (
<div className="mx-auto max-w-4xl space-y-6 p-3 sm:p-6">
<PageHeader moduleSlug="cert-manager" title={t('title')} description={t('description')} />
{/* Tab navigation */}
<nav className="flex gap-6 overflow-x-auto border-b border-border">
{TABS.map((tab) => (
<button
key={tab}
type="button"
onClick={() => handleTabChange(tab)}
className={`pb-2 text-sm font-medium transition-colors ${
activeTab === tab
? 'border-b-2 border-primary-strong font-semibold text-foreground'
: 'text-muted-foreground hover:text-foreground'
}`}
>
{t(`tabs.${tab}`)}
</button>
))}
</nav>
{/* Shared input card — nur fuer die Einzeldatei-Werkzeuge; die Uebersicht
(quick-261001-l4q) hat ihre eigene Mehrfach-Ablage. */}
{activeTab !== 'overview' && (
<div className="rounded-lg bg-card dark:border dark:border-border p-6 shadow-sm space-y-4">
{/* DropZone */}
<DropZone
onFile={handleFile}
accept=".pem,.crt,.cer,.der,.pfx,.p12,.p7b,.p7c"
currentFile={file}
onClear={handleClearFile}
/>
{/* ODER divider */}
<div className="flex items-center gap-3">
<hr className="flex-1 border-border" />
<span className="text-xs text-muted-foreground">{t('or')}</span>
<hr className="flex-1 border-border" />
</div>
{/* PEM textarea */}
<textarea
value={pemText}
onChange={(e) => handlePemChange(e.target.value)}
placeholder={t('paste.placeholder')}
rows={4}
className="w-full rounded border border-border bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring resize-y"
/>
{/* Conditional password field (T-09-02) */}
<PasswordField value={password} onChange={setPassword} show={showPassword} />
</div>
)}
{/* Tab content card */}
<div className="rounded-lg bg-card dark:border dark:border-border p-6 shadow-sm">
{renderActiveTab()}
<TabBar tabs={tabs} active={activeTab} onChange={setActiveTab} />
<div className="rounded-lg bg-card p-6 shadow-sm dark:border dark:border-border">
{activeTab === 'files' && <FilesTab workspace={workspace} />}
</div>
</div>
);
@@ -0,0 +1,105 @@
'use client';
import { useCallback, useRef, useState } from 'react';
import { type AnalysisResult, analyzeWorkingSet, certErrorKey } from './actions';
import {
addFiles as addToSet,
type RejectedFile,
removeEntry,
type WorkingEntry,
} from './working-set';
export type AnalysisStatus = 'idle' | 'analyzing' | 'error';
export interface CertWorkspace {
entries: WorkingEntry[];
/** Antwort der letzten Analyse; null bei leerem Arbeitsbereich */
analysis: AnalysisResult | null;
/** Eintrags-Kennungen zum Zeitpunkt dieser Analyse: Quelle `file` zeigt auf diesen Index */
analysisIds: string[];
status: AnalysisStatus;
/** Schluessel unter certManager.errors.* */
errorKey: string | null;
addFiles: (files: File[]) => RejectedFile[];
remove: (id: string) => void;
clear: () => void;
retry: () => void;
}
/**
* Der gemeinsame Arbeitsbereich (D-01, D-23): lebt nur in diesem Browserfenster. Nach jeder
* Aenderung wird der ganze Bereich neu analysiert; Antworten aelterer Anfragen werden verworfen.
*/
export function useCertWorkspace(): CertWorkspace {
const [entries, setEntriesState] = useState<WorkingEntry[]>([]);
const [analysis, setAnalysis] = useState<AnalysisResult | null>(null);
const [analysisIds, setAnalysisIds] = useState<string[]>([]);
const [status, setStatus] = useState<AnalysisStatus>('idle');
const [errorKey, setErrorKey] = useState<string | null>(null);
const entriesRef = useRef<WorkingEntry[]>([]);
const requestCounter = useRef(0);
const idCounter = useRef(0);
const runAnalysis = useCallback((list: WorkingEntry[]) => {
const mine = ++requestCounter.current;
if (list.length === 0) {
setAnalysis(null);
setAnalysisIds([]);
setStatus('idle');
setErrorKey(null);
return;
}
setStatus('analyzing');
setErrorKey(null);
const ids = list.map((e) => e.id);
analyzeWorkingSet(list).then(
(result) => {
if (mine !== requestCounter.current) return;
setAnalysis(result);
setAnalysisIds(ids);
setStatus('idle');
},
(error: unknown) => {
if (mine !== requestCounter.current) return;
setErrorKey(certErrorKey(error));
setStatus('error');
},
);
}, []);
const commit = useCallback(
(next: WorkingEntry[]) => {
entriesRef.current = next;
setEntriesState(next);
runAnalysis(next);
},
[runAnalysis],
);
const addFiles = useCallback(
(files: File[]): RejectedFile[] => {
const result = addToSet(entriesRef.current, files, () => `entry-${++idCounter.current}`);
if (result.entries.length !== entriesRef.current.length) commit(result.entries);
return result.rejected;
},
[commit],
);
const remove = useCallback(
(id: string) => {
commit(removeEntry(entriesRef.current, id));
},
[commit],
);
const clear = useCallback(() => {
commit([]);
}, [commit]);
const retry = useCallback(() => {
runAnalysis(entriesRef.current);
}, [runAnalysis]);
return { entries, analysis, analysisIds, status, errorKey, addFiles, remove, clear, retry };
}
@@ -0,0 +1,90 @@
import { describe, expect, it } from 'vitest';
import {
addFiles,
MAX_ENTRIES,
MAX_FILE_BYTES,
removeEntry,
toFormData,
type WorkingEntry,
} from './working-set';
let counter = 0;
const nextId = () => `e${++counter}`;
function makeFile(name: string, size = 10, lastModified = 1): File {
const file = new File(['x'.repeat(Math.min(size, 20))], name, { lastModified });
if (size > 20) Object.defineProperty(file, 'size', { value: size });
return file;
}
const NONE: WorkingEntry[] = [];
describe('addFiles', () => {
it('zwei Aufrufe mit je einer Datei ergeben zwei Eintraege in Aufrufreihenfolge', () => {
const first = addFiles(NONE, [makeFile('a.pem')], nextId);
const second = addFiles(first.entries, [makeFile('b.pem')], nextId);
expect(second.entries.map((e) => e.label)).toEqual(['a.pem', 'b.pem']);
expect(second.rejected).toEqual([]);
expect(second.entries[0].origin).toBe('upload');
expect(second.entries[0].password).toBe('');
expect(second.entries[0].host).toBeNull();
});
it('eine zweite Auswahl ersetzt die erste nie', () => {
const first = addFiles(NONE, [makeFile('a.pem'), makeFile('b.pem')], nextId);
const second = addFiles(first.entries, [makeFile('c.pem')], nextId);
expect(second.entries.map((e) => e.label)).toEqual(['a.pem', 'b.pem', 'c.pem']);
});
it('dieselbe Datei (Name, Groesse, Datum) wird als Doppelte abgelehnt', () => {
const first = addFiles(NONE, [makeFile('a.pem', 10, 5)], nextId);
const again = addFiles(first.entries, [makeFile('a.pem', 10, 5)], nextId);
expect(again.entries).toHaveLength(1);
expect(again.rejected).toEqual([{ name: 'a.pem', reason: 'duplicate' }]);
const other = addFiles(first.entries, [makeFile('a.pem', 10, 6)], nextId);
expect(other.entries).toHaveLength(2);
});
it('die 31. Datei wird abgelehnt', () => {
const files = Array.from({ length: MAX_ENTRIES + 1 }, (_, i) => makeFile(`f${i}.pem`, 10, i));
const result = addFiles(NONE, files, nextId);
expect(result.entries).toHaveLength(MAX_ENTRIES);
expect(result.rejected).toEqual([{ name: `f${MAX_ENTRIES}.pem`, reason: 'tooMany' }]);
});
it('eine Datei ueber 5 MB wird abgelehnt', () => {
const result = addFiles(NONE, [makeFile('big.zip', MAX_FILE_BYTES + 1)], nextId);
expect(result.entries).toEqual([]);
expect(result.rejected).toEqual([{ name: 'big.zip', reason: 'tooLarge' }]);
});
it('ein Satz ueber 10 MB gesamt wird abgelehnt', () => {
const four = MAX_FILE_BYTES - 1000;
const first = addFiles(NONE, [makeFile('a.zip', four, 1), makeFile('b.zip', four, 2)], nextId);
expect(first.entries).toHaveLength(2);
const third = addFiles(first.entries, [makeFile('c.zip', four, 3)], nextId);
expect(third.entries).toHaveLength(2);
expect(third.rejected).toEqual([{ name: 'c.zip', reason: 'totalTooLarge' }]);
});
});
describe('removeEntry', () => {
it('entfernt nur den genannten Eintrag und behaelt die Reihenfolge', () => {
const { entries } = addFiles(
NONE,
[makeFile('a.pem'), makeFile('b.pem'), makeFile('c.pem')],
nextId,
);
const after = removeEntry(entries, entries[1].id);
expect(after.map((e) => e.label)).toEqual(['a.pem', 'c.pem']);
});
});
describe('toFormData', () => {
it('haengt die Dateien als `files` in Reihenfolge der Eintraege an', () => {
const { entries } = addFiles(NONE, [makeFile('b.pem'), makeFile('a.pem')], nextId);
const form = toFormData(entries);
const names = form.getAll('files').map((f) => (f as File).name);
expect(names).toEqual(['b.pem', 'a.pem']);
});
});
@@ -0,0 +1,94 @@
/**
* Arbeitsbereich des Zertifikat-Managers (quick-261009-ikt, D-01, D-17, D-23).
*
* Reine Funktionen ohne React: eine Liste von Eintraegen, die nur wachsen oder gezielt
* schrumpfen kann. Eine zweite Auswahl haengt an, sie ersetzt nie die erste.
* Die Liste lebt nur im Arbeitsspeicher dieses Browserfensters (D-11): nichts davon wird
* gespeichert, auch Passwoerter nicht.
*/
export const MAX_ENTRIES = 30;
export const MAX_FILE_BYTES = 5 * 1024 * 1024;
export const MAX_TOTAL_BYTES = 10 * 1024 * 1024;
export const MAX_PASTE_CHARS = 256_000;
export type EntryOrigin = 'upload' | 'paste' | 'fetched';
export interface WorkingEntry {
id: string;
file: File;
label: string;
origin: EntryOrigin;
/** Nur fuer nachgeladene Zertifikate: der Server, von dem sie kamen */
host: string | null;
/** Passwort fuer diese Datei (ab Task 4); bleibt im Arbeitsspeicher */
password: string;
}
export type RejectReason = 'duplicate' | 'tooMany' | 'tooLarge' | 'totalTooLarge';
export interface RejectedFile {
name: string;
reason: RejectReason;
}
export interface AddFilesResult {
entries: WorkingEntry[];
rejected: RejectedFile[];
}
function sameFile(a: File, b: File): boolean {
return a.name === b.name && a.size === b.size && a.lastModified === b.lastModified;
}
/** Haengt Dateien an. Abgelehnte Dateien stehen mit Grund in `rejected`, die Liste bleibt sonst unveraendert. */
export function addFiles(
entries: WorkingEntry[],
files: File[],
nextId: () => string,
): AddFilesResult {
const next = [...entries];
const rejected: RejectedFile[] = [];
let total = next.reduce((sum, e) => sum + e.file.size, 0);
for (const file of files) {
if (next.some((e) => e.origin === 'upload' && sameFile(e.file, file))) {
rejected.push({ name: file.name, reason: 'duplicate' });
} else if (file.size > MAX_FILE_BYTES) {
rejected.push({ name: file.name, reason: 'tooLarge' });
} else if (next.length >= MAX_ENTRIES) {
rejected.push({ name: file.name, reason: 'tooMany' });
} else if (total + file.size > MAX_TOTAL_BYTES) {
rejected.push({ name: file.name, reason: 'totalTooLarge' });
} else {
next.push({
id: nextId(),
file,
label: file.name,
origin: 'upload',
host: null,
password: '',
});
total += file.size;
}
}
return { entries: next, rejected };
}
/** Entfernt einen Eintrag; die Reihenfolge der uebrigen bleibt. */
export function removeEntry(entries: WorkingEntry[], id: string): WorkingEntry[] {
return entries.filter((e) => e.id !== id);
}
/** Multipart-Koerper fuer die Analyse: `files` in der Reihenfolge der Eintraege. */
export function toFormData(entries: WorkingEntry[]): FormData {
const form = new FormData();
for (const entry of entries) form.append('files', entry.file, entry.file.name);
return form;
}
export function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1).replace('.', ',')} KB`;
return `${(bytes / 1024 / 1024).toFixed(1).replace('.', ',')} MB`;
}
+51 -107
View File
@@ -1250,123 +1250,67 @@
},
"certManager": {
"title": "Zertifikat-Manager",
"description": "Zertifikatspakete prüfen und in jedes Format bringen; einzelne Zertifikate analysieren, aufteilen, zusammenführen und konvertieren.",
"description": "Zertifikate, Zwischenzertifikate, Schlüssel und Anfragen sammeln, prüfen und in das gewünschte Format bringen.",
"tabs": {
"overview": "Übersicht",
"inspect": "Analysieren",
"split": "Aufteilen",
"merge": "Zusammenführen",
"convert": "Konvertieren"
"files": "Dateien",
"filesWithCount": "Dateien ({count})"
},
"dropZone": {
"placeholder": "Datei hierher ziehen oder klicken",
"formats": ".pem, .crt, .cer, .der, .pfx, .p12, .p7b, .p7c"
},
"paste": {
"placeholder": "PEM-Inhalt einfügen (-----BEGIN ...)"
},
"password": {
"label": "Passwort (PFX/P12)"
},
"or": "oder",
"actions": {
"inspect": "Analysieren",
"split": "Aufteilen",
"merge": "Zusammenführen",
"convert": "Konvertieren",
"download": "Herunterladen",
"downloadZip": "Alle als ZIP herunterladen",
"processing": "Wird verarbeitet...",
"zipFilename": "Zertifikate.zip"
},
"certRole": {
"root": "Root-CA",
"intermediate": "Zwischen-CA",
"end-entity": "Zertifikat"
},
"emptyState": {
"inspect": "Kein Zertifikat geladen.",
"inspectBody": "Laden Sie eine Datei hoch oder fügen Sie PEM-Text ein.",
"split": "Keine Datei geladen.",
"splitBody": "Laden Sie eine Fullchain- oder P7B-Datei hoch.",
"merge": "Keine Zertifikate ausgewählt.",
"mergeBody": "Laden Sie mindestens zwei Dateien hoch.",
"convert": "Keine Datei geladen.",
"convertBody": "Laden Sie eine Datei hoch und wählen Sie ein Ausgabeformat."
},
"error": {
"generic": "Verarbeitung fehlgeschlagen. Prüfen Sie das Dateiformat oder das Passwort.",
"wrongPassword": "Falsches Passwort. PFX/P12-Datei konnte nicht entschlüsselt werden.",
"unknownFormat": "Unbekanntes Format. Die Datei konnte nicht als Zertifikat erkannt werden."
},
"overview": {
"dropTitle": "Zertifikatsdateien oder ZIP hierher ziehen oder klicken",
"dropHint": "Alles auf einmal, so wie es vom Aussteller kommt: .zip, .pem, .crt, .cer, .key, .csr, .pfx, .p12, .p7b",
"removeFile": "{name} entfernen",
"reset": "Alle entfernen",
"analyzing": "Dateien werden geprüft …",
"error": "Die Dateien konnten nicht geprüft werden. Bitte prüfen Sie, ob es Zertifikatsdateien sind.",
"locked": "Geschützt: {files}. Geben Sie das Passwort ein, um auch diesen Inhalt zu lesen.",
"lockedNotNeeded": "{files} ist mit einem Passwort geschützt. Sie brauchen es nicht: Zertifikat und privater Schlüssel liegen schon als einzelne Dateien vor und sind unten aufgeführt. Eine neue PFX-Datei mit eigenem Passwort können Sie beim Serverzertifikat erstellen.",
"lockedPassword": "Passwort der geschützten Datei",
"unlock": "Entsperren",
"unlockAnyway": "Trotzdem mit Passwort öffnen",
"nothingFound": "In den Dateien wurde kein Zertifikat, Schlüssel und keine Zertifikatsanfrage gefunden.",
"ignored": "Nicht verwendet (kein Zertifikat erkannt): {files}",
"type": {
"roles": {
"end-entity": "Serverzertifikat",
"intermediate": "Zwischenzertifikat",
"root": "Stammzertifikat",
"privateKey": "Privater Schlüssel",
"csr": "Zertifikatsanfrage (CSR)"
},
"explain": {
"end-entity": "Das eigentliche Zertifikat für Ihre Domain – das gehört auf den Webserver.",
"intermediate": "Bestätigt Ihr Serverzertifikat gegenüber dem Browser. Wird zusammen mit dem Serverzertifikat eingespielt (Kette).",
"root": "Oberste Zertifizierungsstelle. Ist in Browsern und Betriebssystemen meist schon vorhanden.",
"privateKey": "Der geheime Schlüssel zum Serverzertifikat. Wird auf dem Server gebraucht, darf aber nie weitergegeben werden.",
"csr": "Die Anfrage, mit der das Zertifikat beim Aussteller bestellt wurde. Wird nur für eine Neuausstellung gebraucht."
"files": {
"dropTitle": "Dateien hierher ziehen oder klicken",
"dropHint": "Sie können mehrere Dateien nacheinander hinzufügen, jede bleibt in der Liste. Tessera erkennt den Inhalt selbst, die Dateiendung spielt keine Rolle.",
"limits": "Bis zu {maxFiles} Dateien, je Datei höchstens {maxFile} MB, zusammen höchstens {maxTotal} MB.",
"memoryNote": "Die Dateien bleiben nur in diesem Browserfenster. Tessera speichert nichts davon; nach dem Neuladen oder Schließen der Seite ist die Liste leer.",
"empty": "Noch keine Dateien. Fügen Sie Ihre Zertifikate hinzu, danach sehen Sie hier, was darin steckt.",
"listTitle": "Ihre Dateien ({count})",
"remove": "„{name}“ entfernen",
"removeShort": "Entfernen",
"removeAll": "Alle entfernen",
"analyzing": "Dateien werden geprüft …",
"checking": "Wird geprüft …",
"retry": "Erneut versuchen",
"expired": "abgelaufen",
"rejectedTitle": "Nicht hinzugefügt:",
"rejected": {
"duplicate": "„{name}“ ist schon in der Liste.",
"tooMany": "„{name}“: die Liste ist mit {max} Dateien voll.",
"tooLarge": "„{name}“ ist größer als {size} MB.",
"totalTooLarge": "„{name}“: zusammen wären es mehr als {size} MB."
},
"issuer": "Ausgestellt von",
"validity": "Gültig",
"names": "Gilt für",
"key": "Schlüssel",
"belongsTo": "Gehört zu",
"source": "Gefunden in",
"valid": "Gültig",
"expired": "Abgelaufen",
"expiresSoon": "Läuft in {days} Tagen ab",
"keySecret": "Geheim halten: Wer diesen Schlüssel hat, kann sich als Ihre Website ausgeben.",
"downloading": "Wird erstellt …",
"exportError": "Diese Datei konnte nicht erstellt werden.",
"format": {
"crt": "PEM (.crt)",
"cer": "DER (.cer)",
"fullchain": "Mit Kette (.pem)",
"p7b": "PKCS#7 (.p7b)",
"pfx": "PFX (.pfx)",
"key": "PEM (.key)",
"key-rsa": "RSA-PEM (.rsa.key)",
"key-der": "DER (.key.der)",
"csr": "PEM (.csr)",
"csr-der": "DER (.csr.der)"
"ignored": {
"unknown": "Darin wurde kein Zertifikat erkannt.",
"nestedZip": "Ein ZIP in einem ZIP wird nicht geöffnet.",
"encryptedZip": "Der ZIP-Inhalt ist mit einem Passwort geschützt und wird übersprungen.",
"brokenZip": "Das ZIP lässt sich nicht lesen.",
"tooLarge": "Die Datei ist zu groß und wird übersprungen.",
"suspicious": "Die Datei ist ungewöhnlich stark gepackt und wird übersprungen.",
"zipTooLarge": "Das ZIP enthält zu viele Daten und wird nicht geöffnet.",
"tooManyEntries": "Das ZIP enthält zu viele Dateien und wird nicht geöffnet.",
"unsupportedKey": "Dieser Schlüsseltyp wird nicht unterstützt."
}
},
"formatHint": {
"crt": "Textformat, z. B. für Apache, Nginx und die meisten Geräte",
"cer": "Binärformat, z. B. für Windows und Java",
"fullchain": "Zertifikat und Kette in einer Datei, z. B. für Nginx",
"p7b": "Zertifikat und Kette ohne Schlüssel, z. B. für Windows/IIS",
"pfx": "Zertifikat, Kette und Schlüssel in einer passwortgeschützten Datei, z. B. für Windows/IIS und Exchange",
"key": "Schlüssel im Standardformat (PKCS#8)",
"key-rsa": "Schlüssel im älteren RSA-Format (PKCS#1), für ältere Software",
"key-der": "Schlüssel als Binärdatei",
"csr": "Zertifikatsanfrage als Text",
"csr-der": "Zertifikatsanfrage als Binärdatei"
},
"pfxWithKey": "Die PFX-Datei enthält Zertifikat, Kette und privaten Schlüssel. Legen Sie ein Passwort fest – es wird beim Einspielen abgefragt.",
"pfxWithoutKey": "Zu diesem Zertifikat liegt kein Schlüssel vor – die PFX-Datei enthält nur Zertifikat und Kette. Legen Sie ein Passwort fest.",
"pfxPassword": "Passwort für die PFX-Datei",
"pfxDownload": "PFX herunterladen"
"errors": {
"generic": "Die Dateien konnten nicht geprüft werden. Bitte versuchen Sie es erneut.",
"invalidInput": "Die Eingabe ist unvollständig oder ungültig. Bitte prüfen Sie Ihre Angaben.",
"notACertificate": "Mindestens eine Angabe ist kein Zertifikat. Bitte prüfen Sie die Dateien.",
"noChain": "Es gibt keine Kette zum Herunterladen. Fügen Sie das Zwischenzertifikat hinzu.",
"keyMissing": "Dafür wird der passende private Schlüssel gebraucht. Fügen Sie ihn im Reiter „Dateien“ hinzu.",
"keyMismatch": "Der Schlüssel gehört nicht zu diesem Zertifikat.",
"passwordRequired": "Bitte geben Sie ein Passwort an.",
"formatNotPossible": "Dieses Format ist für den Schlüsseltyp nicht möglich. Wählen Sie ein anderes Format.",
"templateNeedsKey": "Diese Vorlage braucht das Zertifikat und den passenden privaten Schlüssel.",
"tooLarge": "Die Dateien sind zusammen zu groß. Entfernen Sie einzelne Dateien und versuchen Sie es erneut.",
"aiaMissing": "Im Zertifikat steht keine Adresse, unter der das fehlende Zertifikat zu holen wäre.",
"aiaInternal": "Die Adresse im Zertifikat verweist nicht auf einen öffentlichen Server und wird nicht abgerufen.",
"aiaNotIssuer": "Die Adresse lieferte kein passendes Zertifikat des Ausstellers.",
"aiaUnreachable": "Der Server des Ausstellers ist nicht erreichbar. Laden Sie das Zertifikat selbst herunter und fügen Sie es hinzu.",
"aiaTooLarge": "Die Antwort des Ausstellers ist zu groß und wurde verworfen."
}
},
"tenderRadar": {
+51 -107
View File
@@ -1250,123 +1250,67 @@
},
"certManager": {
"title": "Certificate Manager",
"description": "Check certificate bundles and download them in any format; inspect, split, merge and convert single certificates.",
"description": "Collect, check and convert certificates, intermediate certificates, keys and requests into the format you need.",
"tabs": {
"overview": "Overview",
"inspect": "Inspect",
"split": "Split",
"merge": "Merge",
"convert": "Convert"
"files": "Files",
"filesWithCount": "Files ({count})"
},
"dropZone": {
"placeholder": "Drag file here or click to browse",
"formats": ".pem, .crt, .cer, .der, .pfx, .p12, .p7b, .p7c"
},
"paste": {
"placeholder": "Paste PEM content (-----BEGIN ...)"
},
"password": {
"label": "Password (PFX/P12)"
},
"or": "or",
"actions": {
"inspect": "Inspect",
"split": "Split",
"merge": "Merge",
"convert": "Convert",
"download": "Download",
"downloadZip": "Download all as ZIP",
"processing": "Processing...",
"zipFilename": "certificates.zip"
},
"certRole": {
"root": "Root CA",
"intermediate": "Intermediate CA",
"end-entity": "Certificate"
},
"emptyState": {
"inspect": "No certificate loaded.",
"inspectBody": "Upload a file or paste PEM text.",
"split": "No file loaded.",
"splitBody": "Upload a fullchain or P7B file.",
"merge": "No certificates selected.",
"mergeBody": "Upload at least two files.",
"convert": "No file loaded.",
"convertBody": "Upload a file and select an output format."
},
"error": {
"generic": "Processing failed. Check the file format or password.",
"wrongPassword": "Wrong password. Could not decrypt the PFX/P12 file.",
"unknownFormat": "Unknown format. The file could not be recognized as a certificate."
},
"overview": {
"dropTitle": "Drop certificate files or a ZIP here, or click",
"dropHint": "Everything at once, as delivered by the issuer: .zip, .pem, .crt, .cer, .key, .csr, .pfx, .p12, .p7b",
"removeFile": "Remove {name}",
"reset": "Remove all",
"analyzing": "Checking files …",
"error": "The files could not be checked. Please make sure they are certificate files.",
"locked": "Protected: {files}. Enter the password to read this content as well.",
"lockedNotNeeded": "{files} is password-protected. You do not need it: certificate and private key are already available as separate files and listed below. You can create a new PFX file with your own password at the server certificate.",
"lockedPassword": "Password of the protected file",
"unlock": "Unlock",
"unlockAnyway": "Open with password anyway",
"nothingFound": "No certificate, key or certificate request was found in the files.",
"ignored": "Not used (no certificate detected): {files}",
"type": {
"roles": {
"end-entity": "Server certificate",
"intermediate": "Intermediate certificate",
"root": "Root certificate",
"privateKey": "Private key",
"csr": "Certificate request (CSR)"
},
"explain": {
"end-entity": "The actual certificate for your domain – it goes on the web server.",
"intermediate": "Vouches for your server certificate towards the browser. Install it together with the server certificate (chain).",
"root": "Top-level certificate authority. Usually already present in browsers and operating systems.",
"privateKey": "The secret key for the server certificate. Needed on the server, but must never be shared.",
"csr": "The request used to order the certificate from the issuer. Only needed for a reissue."
"files": {
"dropTitle": "Drop files here or click",
"dropHint": "You can add several files one after another, each one stays in the list. Tessera recognises the content itself, the file extension does not matter.",
"limits": "Up to {maxFiles} files, at most {maxFile} MB per file and {maxTotal} MB in total.",
"memoryNote": "The files stay in this browser window only. Tessera stores none of them; after reloading or closing the page the list is empty.",
"empty": "No files yet. Add your certificates and you will see here what they contain.",
"listTitle": "Your files ({count})",
"remove": "Remove \"{name}\"",
"removeShort": "Remove",
"removeAll": "Remove all",
"analyzing": "Checking files …",
"checking": "Checking …",
"retry": "Try again",
"expired": "expired",
"rejectedTitle": "Not added:",
"rejected": {
"duplicate": "\"{name}\" is already in the list.",
"tooMany": "\"{name}\": the list is full with {max} files.",
"tooLarge": "\"{name}\" is larger than {size} MB.",
"totalTooLarge": "\"{name}\": together this would be more than {size} MB."
},
"issuer": "Issued by",
"validity": "Valid",
"names": "Valid for",
"key": "Key",
"belongsTo": "Belongs to",
"source": "Found in",
"valid": "Valid",
"expired": "Expired",
"expiresSoon": "Expires in {days} days",
"keySecret": "Keep secret: whoever has this key can impersonate your website.",
"downloading": "Creating …",
"exportError": "This file could not be created.",
"format": {
"crt": "PEM (.crt)",
"cer": "DER (.cer)",
"fullchain": "With chain (.pem)",
"p7b": "PKCS#7 (.p7b)",
"pfx": "PFX (.pfx)",
"key": "PEM (.key)",
"key-rsa": "RSA PEM (.rsa.key)",
"key-der": "DER (.key.der)",
"csr": "PEM (.csr)",
"csr-der": "DER (.csr.der)"
"ignored": {
"unknown": "No certificate was recognised in it.",
"nestedZip": "A ZIP inside a ZIP is not opened.",
"encryptedZip": "The ZIP content is password protected and is skipped.",
"brokenZip": "The ZIP cannot be read.",
"tooLarge": "The file is too large and is skipped.",
"suspicious": "The file is packed unusually tightly and is skipped.",
"zipTooLarge": "The ZIP contains too much data and is not opened.",
"tooManyEntries": "The ZIP contains too many files and is not opened.",
"unsupportedKey": "This key type is not supported."
}
},
"formatHint": {
"crt": "Text format, e.g. for Apache, Nginx and most devices",
"cer": "Binary format, e.g. for Windows and Java",
"fullchain": "Certificate and chain in one file, e.g. for Nginx",
"p7b": "Certificate and chain without key, e.g. for Windows/IIS",
"pfx": "Certificate, chain and key in one password-protected file, e.g. for Windows/IIS and Exchange",
"key": "Key in standard format (PKCS#8)",
"key-rsa": "Key in older RSA format (PKCS#1), for older software",
"key-der": "Key as binary file",
"csr": "Certificate request as text",
"csr-der": "Certificate request as binary file"
},
"pfxWithKey": "The PFX file contains certificate, chain and private key. Set a password – it is asked for when importing.",
"pfxWithoutKey": "No key is available for this certificate – the PFX file contains only certificate and chain. Set a password.",
"pfxPassword": "Password for the PFX file",
"pfxDownload": "Download PFX"
"errors": {
"generic": "The files could not be checked. Please try again.",
"invalidInput": "The input is incomplete or invalid. Please check your entries.",
"notACertificate": "At least one entry is not a certificate. Please check the files.",
"noChain": "There is no chain to download. Add the intermediate certificate.",
"keyMissing": "This needs the matching private key. Add it in the \"Files\" tab.",
"keyMismatch": "The key does not belong to this certificate.",
"passwordRequired": "Please enter a password.",
"formatNotPossible": "This format is not possible for the key type. Choose another format.",
"templateNeedsKey": "This template needs the certificate and the matching private key.",
"tooLarge": "The files are too large together. Remove some files and try again.",
"aiaMissing": "The certificate names no address from which the missing certificate could be fetched.",
"aiaInternal": "The address in the certificate does not point to a public server and is not contacted.",
"aiaNotIssuer": "The address did not deliver a matching certificate of the issuer.",
"aiaUnreachable": "The issuer's server cannot be reached. Download the certificate yourself and add it.",
"aiaTooLarge": "The issuer's answer is too large and was discarded."
}
},
"tenderRadar": {
@@ -243,4 +243,10 @@ export const UMLAUT_ALLOWLIST: readonly string[] = [
'Suchergebnisse',
// quick-261009-dkv (Aufgabe 2): Links und Übersichten — korrektes Deutsch mit „ss“
'verlassen',
// quick-261009-ikt: Zertifikat-Manager — korrektes Deutsch mit „ss“
'Schlüsseltyp',
'passende',
'passenden',
'passendes',
'Ausstellers',
];