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 ?? '');
}
/**
* POST /modules/cert-manager/export (quick-261001-l4q)
* Ein Teil aus `analyze` (PEM) in das gewuenschte Format bringen.
* JSON-Body; PFX verlangt ein Passwort fuer die neue Datei.
*/
@Post('export')
async export(
@Body('kind') kind: BundleItemKind,
@Body('pem') pem: string,
@Body('format') format: BundleExportFormat,
@Body('baseName') baseName?: string,
@Body('chain') chain?: string[],
@Body('keyPem') keyPem?: string,
@Body('password') password?: string,
) {
if (
chain !== undefined &&
(!Array.isArray(chain) || chain.some((c) => typeof c !== 'string'))
) {
throw new BadRequestException('chain must be a list of PEM strings');
analyze(@UploadedFiles() files: UploadedFileLike[] | undefined): AnalysisResult {
if (!files || files.length === 0) {
certError('invalidInput', 400, 'No files provided');
}
return exportBundleItem({ kind, pem, format, baseName, chain, keyPem, password });
const total = files.reduce((sum, f) => sum + f.buffer.length, 0);
if (total > CERT_MAX_TOTAL_BYTES) {
certError('tooLarge', 413, 'Files together exceed 20 MiB');
}
return analyzeWorkingSet(
files.map((f) => ({ originalname: repairFileName(f.originalname), buffer: f.buffer })),
);
}
}
@@ -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;
}