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:
@@ -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-----
|
||||||
Binary file not shown.
@@ -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-----
|
||||||
Binary file not shown.
@@ -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-----
|
||||||
Binary file not shown.
@@ -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-----
|
||||||
Binary file not shown.
@@ -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.
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-----
|
||||||
Binary file not shown.
@@ -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-----
|
||||||
Binary file not shown.
@@ -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-----
|
||||||
Binary file not shown.
@@ -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-----
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -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 {
|
import { Controller, HttpCode, Post, UploadedFiles, UseInterceptors } from '@nestjs/common';
|
||||||
BadRequestException,
|
import { FilesInterceptor } from '@nestjs/platform-express';
|
||||||
Body,
|
|
||||||
Controller,
|
|
||||||
Post,
|
|
||||||
UploadedFile,
|
|
||||||
UploadedFiles,
|
|
||||||
UseInterceptors,
|
|
||||||
} from '@nestjs/common';
|
|
||||||
import { FileInterceptor, FilesInterceptor } from '@nestjs/platform-express';
|
|
||||||
import { UseModule } from '../module-registry/module.guard';
|
|
||||||
import type { UploadedFileLike } from '../auth/types/auth-user';
|
import type { UploadedFileLike } from '../auth/types/auth-user';
|
||||||
import {
|
import { UseModule } from '../module-registry/module.guard';
|
||||||
analyzeBundle,
|
import { analyzeWorkingSet } from './cert-analyze';
|
||||||
type BundleExportFormat,
|
import { type AnalysisResult, certError } from './cert-types';
|
||||||
type BundleItemKind,
|
|
||||||
exportBundleItem,
|
/** Obergrenzen (D-17): je Datei 5 MiB, alle Dateien zusammen 20 MiB, hoechstens 30 Dateien. */
|
||||||
} from './cert-bundle';
|
export const CERT_MAX_FILES = 30;
|
||||||
import { CertManagerService } from './cert-manager.service';
|
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:
|
* Zustandslos: nichts wird gespeichert, Passwoerter und Schluessel kommen nie in ein Log
|
||||||
* - Global JwtAuthGuard (authentication)
|
* (kein Logger-Aufruf mit Anfrageinhalt; das Request-Log kennt nur Pfad und Status).
|
||||||
* - Global TenantGuard (tenant context)
|
* Geschuetzt durch den globalen JwtAuthGuard, den TenantGuard und @UseModule('cert-manager').
|
||||||
* - @UseModule('cert-manager') ModuleGuard (module activation check)
|
|
||||||
*
|
*
|
||||||
* File size limit: 5 MB per file (T-09-03 — DoS mitigation).
|
* Routen (alle POST, 200):
|
||||||
* Password parameter is never passed to a logger (T-09-02 — InfoDisc mitigation).
|
* - 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')
|
@Controller('modules/cert-manager')
|
||||||
@UseModule('cert-manager')
|
@UseModule('cert-manager')
|
||||||
export class CertManagerController {
|
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')
|
@Post('analyze')
|
||||||
|
@HttpCode(200)
|
||||||
@UseInterceptors(
|
@UseInterceptors(
|
||||||
FilesInterceptor('files', 20, {
|
FilesInterceptor('files', CERT_MAX_FILES, { limits: { fileSize: CERT_MAX_FILE_BYTES } }),
|
||||||
limits: { fileSize: 5 * 1024 * 1024 },
|
|
||||||
}),
|
|
||||||
)
|
)
|
||||||
async analyze(
|
analyze(@UploadedFiles() files: UploadedFileLike[] | undefined): AnalysisResult {
|
||||||
@UploadedFiles() files: UploadedFileLike[] | undefined,
|
if (!files || files.length === 0) {
|
||||||
@Body('password') password?: string,
|
certError('invalidInput', 400, 'No files provided');
|
||||||
) {
|
|
||||||
return analyzeBundle(files ?? [], password ?? '');
|
|
||||||
}
|
}
|
||||||
|
const total = files.reduce((sum, f) => sum + f.buffer.length, 0);
|
||||||
/**
|
if (total > CERT_MAX_TOTAL_BYTES) {
|
||||||
* POST /modules/cert-manager/export (quick-261001-l4q)
|
certError('tooLarge', 413, 'Files together exceed 20 MiB');
|
||||||
* 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');
|
|
||||||
}
|
}
|
||||||
return exportBundleItem({ kind, pem, format, baseName, chain, keyPem, password });
|
return analyzeWorkingSet(
|
||||||
|
files.map((f) => ({ originalname: repairFileName(f.originalname), buffer: f.buffer })),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,13 +3,12 @@ import { ModuleRegistryModule } from '../module-registry/module-registry.module'
|
|||||||
import { ModuleRegistryService } from '../module-registry/module-registry.service';
|
import { ModuleRegistryService } from '../module-registry/module-registry.service';
|
||||||
import { CertManagerController } from './cert-manager.controller';
|
import { CertManagerController } from './cert-manager.controller';
|
||||||
import { seedCertManagerModule } from './cert-manager.seed';
|
import { seedCertManagerModule } from './cert-manager.seed';
|
||||||
import { CertManagerService } from './cert-manager.service';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* NestJS module for the Cert Manager feature.
|
* NestJS module for the Cert Manager feature.
|
||||||
*
|
*
|
||||||
* Provides server-side certificate inspection, splitting, merging,
|
* Zustandslose Zertifikat-Verarbeitung (node:crypto, node-forge nur fuer PKCS#12 und ASN.1);
|
||||||
* and format conversion using node-forge (pure JS, no native bindings).
|
* keine Provider, die Logik steht in reinen Funktionen (cert-model, cert-analyze, ...).
|
||||||
*
|
*
|
||||||
* Seeds itself into the module registry on application startup via
|
* Seeds itself into the module registry on application startup via
|
||||||
* OnModuleInit lifecycle hook (CERT-06).
|
* OnModuleInit lifecycle hook (CERT-06).
|
||||||
@@ -21,14 +20,11 @@ import { CertManagerService } from './cert-manager.service';
|
|||||||
@Module({
|
@Module({
|
||||||
imports: [ModuleRegistryModule],
|
imports: [ModuleRegistryModule],
|
||||||
controllers: [CertManagerController],
|
controllers: [CertManagerController],
|
||||||
providers: [CertManagerService],
|
|
||||||
})
|
})
|
||||||
export class CertManagerModule implements OnModuleInit {
|
export class CertManagerModule implements OnModuleInit {
|
||||||
private readonly logger = new Logger(CertManagerModule.name);
|
private readonly logger = new Logger(CertManagerModule.name);
|
||||||
|
|
||||||
constructor(
|
constructor(private readonly moduleRegistryService: ModuleRegistryService) {}
|
||||||
private readonly moduleRegistryService: ModuleRegistryService,
|
|
||||||
) {}
|
|
||||||
|
|
||||||
async onModuleInit(): Promise<void> {
|
async onModuleInit(): Promise<void> {
|
||||||
try {
|
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}$/);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
import { HttpException } from '@nestjs/common';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Vertrag des Zertifikat-Managers (quick-261009-ikt, D-15, D-19, D-24).
|
||||||
|
* Die Typen werden 1:1 in apps/web/.../cert-manager/actions.ts gespiegelt.
|
||||||
|
* Alles hier ist zustandslos: Tessera speichert weder Dateien noch Schluessel noch Passwoerter.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Wo ein Teil herkam: Index der hochgeladenen Datei (Reihenfolge der Anfrage) und Pfad (bei ZIP: "zipname/eintrag"). */
|
||||||
|
export interface ItemSource {
|
||||||
|
file: number;
|
||||||
|
path: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CertRole = 'end-entity' | 'intermediate' | 'root';
|
||||||
|
|
||||||
|
export interface CertItem {
|
||||||
|
/** 'c-' + die ersten 16 Hex-Zeichen (klein) von sha256(DER) */
|
||||||
|
id: string;
|
||||||
|
kind: 'certificate';
|
||||||
|
role: CertRole;
|
||||||
|
sources: ItemSource[];
|
||||||
|
pem: string;
|
||||||
|
baseName: string;
|
||||||
|
cn: string;
|
||||||
|
organization: string;
|
||||||
|
issuerCn: string;
|
||||||
|
issuerOrganization: string;
|
||||||
|
notBefore: string;
|
||||||
|
notAfter: string;
|
||||||
|
isExpired: boolean;
|
||||||
|
daysLeft: number;
|
||||||
|
/** DNS-Namen unveraendert, andere Arten mit Praefix wie 'IP:' */
|
||||||
|
san: string[];
|
||||||
|
keyType: string;
|
||||||
|
keyBits: number | null;
|
||||||
|
curve: string | null;
|
||||||
|
serialNumber: string;
|
||||||
|
sha256: string;
|
||||||
|
sha1: string;
|
||||||
|
isCa: boolean;
|
||||||
|
selfSigned: boolean;
|
||||||
|
/** nur http/https, hoechstens 5 */
|
||||||
|
aiaIssuerUrls: string[];
|
||||||
|
/** Kennung des oeffentlichen Schluessels: 'k-' + 16 Hex von sha256(SPKI-DER), passt zu KeyItem.id und CsrItem.keyId */
|
||||||
|
keyId: string | null;
|
||||||
|
csrIds: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface KeyItem {
|
||||||
|
/** 'k-' + 16 Hex von sha256(SPKI-DER) */
|
||||||
|
id: string;
|
||||||
|
kind: 'privateKey';
|
||||||
|
sources: ItemSource[];
|
||||||
|
/** unverschluesseltes PKCS#8 */
|
||||||
|
pem: string;
|
||||||
|
baseName: string;
|
||||||
|
keyType: string;
|
||||||
|
keyBits: number | null;
|
||||||
|
curve: string | null;
|
||||||
|
wasEncrypted: boolean;
|
||||||
|
certIds: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CsrItem {
|
||||||
|
/** 'r-' + 16 Hex von sha256(DER) */
|
||||||
|
id: string;
|
||||||
|
kind: 'csr';
|
||||||
|
sources: ItemSource[];
|
||||||
|
pem: string;
|
||||||
|
baseName: string;
|
||||||
|
cn: string;
|
||||||
|
organization: string;
|
||||||
|
san: string[];
|
||||||
|
keyType: string;
|
||||||
|
keyBits: number | null;
|
||||||
|
curve: string | null;
|
||||||
|
keyId: string | null;
|
||||||
|
certIds: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AnyItem = CertItem | KeyItem | CsrItem;
|
||||||
|
|
||||||
|
export interface ChainGap {
|
||||||
|
certId: string;
|
||||||
|
kind: 'afterLeaf' | 'afterCa';
|
||||||
|
missingIssuerCn: string;
|
||||||
|
aiaUrls: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChainInfo {
|
||||||
|
headId: string;
|
||||||
|
/** Kopf zuerst, dann jeder Aussteller, Wurzel zuletzt (falls vorhanden) */
|
||||||
|
path: string[];
|
||||||
|
rootId: string | null;
|
||||||
|
complete: boolean;
|
||||||
|
gap: ChainGap | null;
|
||||||
|
alternatives: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LockedEntry {
|
||||||
|
file: number;
|
||||||
|
path: string;
|
||||||
|
container: 'pkcs12' | 'privateKey';
|
||||||
|
reason: 'passwordNeeded' | 'passwordWrong';
|
||||||
|
}
|
||||||
|
|
||||||
|
export type IgnoredReason =
|
||||||
|
| 'unknown'
|
||||||
|
| 'nestedZip'
|
||||||
|
| 'encryptedZip'
|
||||||
|
| 'brokenZip'
|
||||||
|
| 'tooLarge'
|
||||||
|
| 'suspicious'
|
||||||
|
| 'zipTooLarge'
|
||||||
|
| 'tooManyEntries'
|
||||||
|
| 'unsupportedKey';
|
||||||
|
|
||||||
|
export interface IgnoredEntry {
|
||||||
|
file: number;
|
||||||
|
path: string;
|
||||||
|
reason: IgnoredReason;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnalysisResult {
|
||||||
|
items: AnyItem[];
|
||||||
|
chains: ChainInfo[];
|
||||||
|
locked: LockedEntry[];
|
||||||
|
ignored: IgnoredEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BuildContent = 'leaf' | 'fullchain' | 'chain' | 'leafKey' | 'pfx' | 'key' | 'csr';
|
||||||
|
|
||||||
|
export interface BuildInput {
|
||||||
|
content: BuildContent;
|
||||||
|
format?: string;
|
||||||
|
certPem?: string;
|
||||||
|
poolPems?: string[];
|
||||||
|
keyPem?: string;
|
||||||
|
csrPem?: string;
|
||||||
|
includeRoot?: boolean;
|
||||||
|
includeChain?: boolean;
|
||||||
|
password?: string;
|
||||||
|
pfxEncryption?: 'compat' | 'modern';
|
||||||
|
template?: string;
|
||||||
|
baseName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BuildFile {
|
||||||
|
filename: string;
|
||||||
|
/** Base64 */
|
||||||
|
content: string;
|
||||||
|
mimeType: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BuildResult {
|
||||||
|
files: BuildFile[];
|
||||||
|
chainComplete: boolean;
|
||||||
|
missingIssuerCn: string | null;
|
||||||
|
snippet?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fehlercodes (D-24); die Meldung ist Englisch fuer Entwickler, die Oberflaeche zeigt eigene Texte zum Code. */
|
||||||
|
export type CertErrorCode =
|
||||||
|
| 'invalidInput'
|
||||||
|
| 'notACertificate'
|
||||||
|
| 'noChain'
|
||||||
|
| 'keyMissing'
|
||||||
|
| 'keyMismatch'
|
||||||
|
| 'passwordRequired'
|
||||||
|
| 'formatNotPossible'
|
||||||
|
| 'templateNeedsKey'
|
||||||
|
| 'tooLarge'
|
||||||
|
| 'aiaMissing'
|
||||||
|
| 'aiaInternal'
|
||||||
|
| 'aiaNotIssuer'
|
||||||
|
| 'aiaUnreachable'
|
||||||
|
| 'aiaTooLarge';
|
||||||
|
|
||||||
|
/** Wirft eine Nest-Ausnahme mit dem Koerper `{ code, message }` (nie mit Passwort oder Schluessel im Text). */
|
||||||
|
export function certError(code: CertErrorCode, status: number, message: string): never {
|
||||||
|
throw new HttpException({ code, message }, status);
|
||||||
|
}
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
/**
|
|
||||||
* DTO for the cert-manager convert endpoint body fields.
|
|
||||||
* Used alongside FileInterceptor for single-file upload.
|
|
||||||
*/
|
|
||||||
export class ConvertCertDto {
|
|
||||||
targetFormat!: 'pem' | 'der' | 'pfx' | 'p7b';
|
|
||||||
password?: string;
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
/**
|
|
||||||
* DTO for the cert-manager merge endpoint body fields.
|
|
||||||
* Used alongside FilesInterceptor for multi-file upload.
|
|
||||||
*/
|
|
||||||
export class MergeCertsDto {
|
|
||||||
outputFormat!: 'pem' | 'pfx';
|
|
||||||
password?: string;
|
|
||||||
}
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
/**
|
|
||||||
* DTO for the cert-manager parse endpoint (text paste / JSON body path).
|
|
||||||
* For file uploads the body fields are extracted via @Body() in the controller.
|
|
||||||
*/
|
|
||||||
export class ParseCertDto {
|
|
||||||
pemText!: string;
|
|
||||||
password?: string;
|
|
||||||
}
|
|
||||||
@@ -1,110 +1,190 @@
|
|||||||
export const API_URL =
|
import { toFormData, type WorkingEntry } from './working-set';
|
||||||
process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
/**
|
||||||
// SplitResponse — mirrors CertManagerService.SplitResponse
|
* Aufrufe der Zertifikat-Manager-API (quick-261009-ikt). Die Typen spiegeln 1:1
|
||||||
// ---------------------------------------------------------------------------
|
* apps/api/src/cert-manager/cert-types.ts.
|
||||||
|
*
|
||||||
|
* Passwoerter und Schluessel stehen nur im Koerper der Anfrage, nie in einer Adresse
|
||||||
|
* und nie in einer Konsolenausgabe. Der Browser sendet das Sitzungs-Cookie mit.
|
||||||
|
*/
|
||||||
|
|
||||||
export type CertRole = 'root' | 'intermediate' | 'end-entity';
|
export const API_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
|
||||||
|
|
||||||
export interface SplitEntry {
|
export interface ItemSource {
|
||||||
index: number;
|
file: number;
|
||||||
filename: string;
|
path: string;
|
||||||
/** PEM content base64-encoded (one BEGIN CERTIFICATE block per entry) */
|
|
||||||
content: string;
|
|
||||||
subject: { cn: string };
|
|
||||||
validity: { notAfter: string };
|
|
||||||
certRole: CertRole;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SplitResponse {
|
export type CertRole = 'end-entity' | 'intermediate' | 'root';
|
||||||
count: number;
|
|
||||||
certs: SplitEntry[];
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
export interface CertItem {
|
||||||
// CertDetails — mirrors the API response shape from CertManagerService
|
id: string;
|
||||||
// ---------------------------------------------------------------------------
|
kind: 'certificate';
|
||||||
|
role: CertRole;
|
||||||
export interface CertDetails {
|
sources: ItemSource[];
|
||||||
subject: { cn: string; o: string; ou: string; c: string };
|
pem: string;
|
||||||
issuer: { cn: string; o: string; c: string };
|
baseName: string;
|
||||||
validity: { notBefore: string; notAfter: string; isExpired: boolean; daysLeft: number };
|
cn: string;
|
||||||
|
organization: string;
|
||||||
|
issuerCn: string;
|
||||||
|
issuerOrganization: string;
|
||||||
|
notBefore: string;
|
||||||
|
notAfter: string;
|
||||||
|
isExpired: boolean;
|
||||||
|
daysLeft: number;
|
||||||
san: string[];
|
san: string[];
|
||||||
keyType: string;
|
keyType: string;
|
||||||
keyBits: number;
|
keyBits: number | null;
|
||||||
|
curve: string | null;
|
||||||
serialNumber: string;
|
serialNumber: string;
|
||||||
signatureAlgorithm: string;
|
sha256: string;
|
||||||
fingerprint: { sha1: string; sha256: string };
|
sha1: string;
|
||||||
pemPreview: string;
|
isCa: boolean;
|
||||||
|
selfSigned: boolean;
|
||||||
|
aiaIssuerUrls: string[];
|
||||||
|
keyId: string | null;
|
||||||
|
csrIds: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
export interface KeyItem {
|
||||||
// inspectCertAction — calls POST /modules/cert-manager/parse
|
id: string;
|
||||||
// ---------------------------------------------------------------------------
|
kind: 'privateKey';
|
||||||
|
sources: ItemSource[];
|
||||||
|
pem: string;
|
||||||
|
baseName: string;
|
||||||
|
keyType: string;
|
||||||
|
keyBits: number | null;
|
||||||
|
curve: string | null;
|
||||||
|
wasEncrypted: boolean;
|
||||||
|
certIds: string[];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
export interface CsrItem {
|
||||||
* Call POST /modules/cert-manager/parse.
|
id: string;
|
||||||
* - If pemText is present → JSON body { pemText, password }
|
kind: 'csr';
|
||||||
* - Otherwise → multipart FormData with file + optional password
|
sources: ItemSource[];
|
||||||
*
|
pem: string;
|
||||||
* T-09-02: password is never placed in URL, logged, or echoed.
|
baseName: string;
|
||||||
* T-09-04: credentials:'include' ensures JWT cookie is sent.
|
cn: string;
|
||||||
*/
|
organization: string;
|
||||||
export async function inspectCertAction(input: {
|
san: string[];
|
||||||
file?: File | null;
|
keyType: string;
|
||||||
pemText?: string;
|
keyBits: number | null;
|
||||||
password?: string;
|
curve: string | null;
|
||||||
}): Promise<CertDetails> {
|
keyId: string | null;
|
||||||
const { file, pemText, password } = input;
|
certIds: string[];
|
||||||
|
}
|
||||||
|
|
||||||
if (pemText) {
|
export type AnyItem = CertItem | KeyItem | CsrItem;
|
||||||
// JSON path — content-type must be application/json (not multipart)
|
|
||||||
const response = await fetch(`${API_URL}/modules/cert-manager/parse`, {
|
export interface ChainGap {
|
||||||
|
certId: string;
|
||||||
|
kind: 'afterLeaf' | 'afterCa';
|
||||||
|
missingIssuerCn: string;
|
||||||
|
aiaUrls: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChainInfo {
|
||||||
|
headId: string;
|
||||||
|
path: string[];
|
||||||
|
rootId: string | null;
|
||||||
|
complete: boolean;
|
||||||
|
gap: ChainGap | null;
|
||||||
|
alternatives: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LockedEntry {
|
||||||
|
file: number;
|
||||||
|
path: string;
|
||||||
|
container: 'pkcs12' | 'privateKey';
|
||||||
|
reason: 'passwordNeeded' | 'passwordWrong';
|
||||||
|
}
|
||||||
|
|
||||||
|
export type IgnoredReason =
|
||||||
|
| 'unknown'
|
||||||
|
| 'nestedZip'
|
||||||
|
| 'encryptedZip'
|
||||||
|
| 'brokenZip'
|
||||||
|
| 'tooLarge'
|
||||||
|
| 'suspicious'
|
||||||
|
| 'zipTooLarge'
|
||||||
|
| 'tooManyEntries'
|
||||||
|
| 'unsupportedKey';
|
||||||
|
|
||||||
|
export interface IgnoredEntry {
|
||||||
|
file: number;
|
||||||
|
path: string;
|
||||||
|
reason: IgnoredReason;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AnalysisResult {
|
||||||
|
items: AnyItem[];
|
||||||
|
chains: ChainInfo[];
|
||||||
|
locked: LockedEntry[];
|
||||||
|
ignored: IgnoredEntry[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fehler der API mit Statuscode und (falls vorhanden) dem Fehlercode aus `{ code, message }`. */
|
||||||
|
export class CertManagerRequestError extends Error {
|
||||||
|
constructor(
|
||||||
|
readonly status: number,
|
||||||
|
readonly code: string | null,
|
||||||
|
) {
|
||||||
|
super(`cert-manager request failed (${status}${code ? ` ${code}` : ''})`);
|
||||||
|
this.name = 'CertManagerRequestError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const KNOWN_ERROR_CODES = new Set([
|
||||||
|
'invalidInput',
|
||||||
|
'notACertificate',
|
||||||
|
'noChain',
|
||||||
|
'keyMissing',
|
||||||
|
'keyMismatch',
|
||||||
|
'passwordRequired',
|
||||||
|
'formatNotPossible',
|
||||||
|
'templateNeedsKey',
|
||||||
|
'tooLarge',
|
||||||
|
'aiaMissing',
|
||||||
|
'aiaInternal',
|
||||||
|
'aiaNotIssuer',
|
||||||
|
'aiaUnreachable',
|
||||||
|
'aiaTooLarge',
|
||||||
|
]);
|
||||||
|
|
||||||
|
/** Schluessel unter `certManager.errors.<key>`: der Fehlercode, 413 ohne Code als tooLarge, sonst generic. */
|
||||||
|
export function certErrorKey(error: unknown): string {
|
||||||
|
if (error instanceof CertManagerRequestError) {
|
||||||
|
if (error.code && KNOWN_ERROR_CODES.has(error.code)) return error.code;
|
||||||
|
if (error.status === 413) return 'tooLarge';
|
||||||
|
}
|
||||||
|
return 'generic';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function errorFromResponse(response: Response): Promise<CertManagerRequestError> {
|
||||||
|
let code: string | null = null;
|
||||||
|
try {
|
||||||
|
const body = (await response.json()) as { code?: unknown };
|
||||||
|
if (typeof body.code === 'string') code = body.code;
|
||||||
|
} catch {
|
||||||
|
// Antwort ohne JSON (z. B. 413 vom Proxy): nur der Status zaehlt
|
||||||
|
}
|
||||||
|
return new CertManagerRequestError(response.status, code);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POST /modules/cert-manager/analyze: der ganze Arbeitsbereich in Reihenfolge, Antwort mit allen erkannten Teilen. */
|
||||||
|
export async function analyzeWorkingSet(entries: WorkingEntry[]): Promise<AnalysisResult> {
|
||||||
|
const response = await fetch(`${API_URL}/modules/cert-manager/analyze`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
body: toFormData(entries),
|
||||||
body: JSON.stringify({ pemText, password }),
|
|
||||||
credentials: 'include',
|
credentials: 'include',
|
||||||
});
|
});
|
||||||
if (!response.ok) {
|
if (!response.ok) throw await errorFromResponse(response);
|
||||||
const body = await response.text().catch(() => '');
|
return (await response.json()) as AnalysisResult;
|
||||||
throw new Error(`${response.status} ${body}`.trim());
|
|
||||||
}
|
|
||||||
return response.json() as Promise<CertDetails>;
|
|
||||||
} else {
|
|
||||||
// Multipart path — let browser set Content-Type with boundary
|
|
||||||
const form = new FormData();
|
|
||||||
if (file) form.append('file', file);
|
|
||||||
if (password) form.append('password', password);
|
|
||||||
return postForm('parse', form) as Promise<CertDetails>;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
/** Laedt eine Base64-Datei als Browser-Download herunter. Der Dateiname enthaelt nie ein Passwort. */
|
||||||
// splitCertsAction — calls POST /modules/cert-manager/split
|
export function downloadBase64(filename: string, content: string, mimeType: string): void {
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Upload a fullchain PEM or P7B bundle to POST /modules/cert-manager/split.
|
|
||||||
* Returns a SplitResponse with one entry per certificate in the bundle.
|
|
||||||
*
|
|
||||||
* T-09-04: credentials:'include' via postForm ensures JWT cookie is sent.
|
|
||||||
*/
|
|
||||||
export async function splitCertsAction(file: File): Promise<SplitResponse> {
|
|
||||||
const form = new FormData();
|
|
||||||
form.append('file', file);
|
|
||||||
return postForm('split', form) as Promise<SplitResponse>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Download a base64-encoded file as a browser download.
|
|
||||||
* T-09-02: password is never placed in URL, console.log, or filename.
|
|
||||||
*/
|
|
||||||
export function downloadBase64(
|
|
||||||
filename: string,
|
|
||||||
content: string,
|
|
||||||
mimeType: string,
|
|
||||||
): void {
|
|
||||||
const bytes = atob(content);
|
const bytes = atob(content);
|
||||||
const byteArray = new Uint8Array(bytes.length);
|
const byteArray = new Uint8Array(bytes.length);
|
||||||
for (let i = 0; i < bytes.length; i++) {
|
for (let i = 0; i < bytes.length; i++) {
|
||||||
@@ -118,171 +198,3 @@ export function downloadBase64(
|
|||||||
anchor.click();
|
anchor.click();
|
||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// FileResponse — mirrors CertManagerService.FileResponse
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export interface FileResponse {
|
|
||||||
/** Suggested download filename, e.g. "converted.der" */
|
|
||||||
filename: string;
|
|
||||||
/** Base64-encoded file content */
|
|
||||||
content: string;
|
|
||||||
/** MIME type for the download */
|
|
||||||
mimeType: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// mergeCertsAction — calls POST /modules/cert-manager/merge
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Upload multiple certificate files to POST /modules/cert-manager/merge.
|
|
||||||
* The API requires at least 2 files (enforced by controller).
|
|
||||||
* Returns a FileResponse — the caller should call downloadBase64(filename, content, mimeType).
|
|
||||||
*
|
|
||||||
* T-09-02: password is never placed in URL, logged, or echoed.
|
|
||||||
* T-09-03: maxCount 20 per FilesInterceptor (enforced server-side).
|
|
||||||
* T-09-04: credentials:'include' via postForm ensures JWT cookie is sent.
|
|
||||||
*/
|
|
||||||
export async function mergeCertsAction(
|
|
||||||
files: File[],
|
|
||||||
outputFormat: string,
|
|
||||||
password?: string,
|
|
||||||
): Promise<FileResponse> {
|
|
||||||
const form = new FormData();
|
|
||||||
files.forEach((file) => {
|
|
||||||
form.append('files', file);
|
|
||||||
});
|
|
||||||
form.append('outputFormat', outputFormat);
|
|
||||||
if (password) form.append('password', password);
|
|
||||||
return postForm('merge', form) as Promise<FileResponse>;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// convertCertAction — calls POST /modules/cert-manager/convert
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Upload a certificate and convert it to the chosen target format.
|
|
||||||
* Returns a FileResponse — the caller should call downloadBase64(filename, content, mimeType).
|
|
||||||
*
|
|
||||||
* T-09-02: password is never placed in URL, logged, or echoed.
|
|
||||||
* T-09-04: credentials:'include' via postForm ensures JWT cookie is sent.
|
|
||||||
*/
|
|
||||||
export async function convertCertAction(
|
|
||||||
input: { file?: File | null; pemText?: string; password?: string },
|
|
||||||
targetFormat: string,
|
|
||||||
): Promise<FileResponse> {
|
|
||||||
const { file, pemText, password } = input;
|
|
||||||
const form = new FormData();
|
|
||||||
if (file) form.append('file', file);
|
|
||||||
if (pemText) form.append('pemText', pemText);
|
|
||||||
if (password) form.append('password', password);
|
|
||||||
form.append('targetFormat', targetFormat);
|
|
||||||
return postForm('convert', form) as Promise<FileResponse>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* POST a FormData payload to a cert-manager endpoint.
|
|
||||||
* T-09-04: credentials:'include' ensures JWT cookie is sent for ModuleGuard.
|
|
||||||
* No manual Content-Type header — browser sets multipart boundary automatically.
|
|
||||||
*/
|
|
||||||
export async function postForm(
|
|
||||||
endpoint: string,
|
|
||||||
form: FormData,
|
|
||||||
): Promise<unknown> {
|
|
||||||
const response = await fetch(
|
|
||||||
`${API_URL}/modules/cert-manager/${endpoint}`,
|
|
||||||
{
|
|
||||||
method: 'POST',
|
|
||||||
body: form,
|
|
||||||
credentials: 'include',
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!response.ok) {
|
|
||||||
const body = await response.text().catch(() => '');
|
|
||||||
throw new Error(`${response.status} ${body}`.trim());
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Zertifikatspaket (quick-261001-l4q) — spiegelt apps/api/src/cert-manager/cert-bundle.ts
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
export type BundleItemKind = 'certificate' | 'privateKey' | 'csr';
|
|
||||||
export type BundleExportFormat =
|
|
||||||
| 'crt'
|
|
||||||
| 'cer'
|
|
||||||
| 'fullchain'
|
|
||||||
| 'p7b'
|
|
||||||
| 'pfx'
|
|
||||||
| 'key'
|
|
||||||
| 'key-rsa'
|
|
||||||
| 'key-der'
|
|
||||||
| 'csr'
|
|
||||||
| 'csr-der';
|
|
||||||
|
|
||||||
export interface BundleItem {
|
|
||||||
id: string;
|
|
||||||
kind: BundleItemKind;
|
|
||||||
role?: CertRole;
|
|
||||||
sources: string[];
|
|
||||||
pem: string;
|
|
||||||
baseName: string;
|
|
||||||
cn: string;
|
|
||||||
organization: string;
|
|
||||||
issuerCn: string;
|
|
||||||
notBefore: string | null;
|
|
||||||
notAfter: string | null;
|
|
||||||
isExpired: boolean | null;
|
|
||||||
daysLeft: number | null;
|
|
||||||
san: string[];
|
|
||||||
keyType: string;
|
|
||||||
keyBits: number;
|
|
||||||
serialNumber: string;
|
|
||||||
sha256: string;
|
|
||||||
matchId: string | null;
|
|
||||||
chainIds: string[];
|
|
||||||
formats: BundleExportFormat[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface BundleAnalysis {
|
|
||||||
items: BundleItem[];
|
|
||||||
locked: string[];
|
|
||||||
ignored: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Mehrere Dateien (auch ZIP) analysieren — POST /modules/cert-manager/analyze. */
|
|
||||||
export async function analyzeBundleAction(files: File[], password?: string): Promise<BundleAnalysis> {
|
|
||||||
const form = new FormData();
|
|
||||||
for (const file of files) form.append('files', file);
|
|
||||||
if (password) form.append('password', password);
|
|
||||||
return postForm('analyze', form) as Promise<BundleAnalysis>;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Ein Teil des Pakets in ein Format bringen — POST /modules/cert-manager/export (JSON). */
|
|
||||||
export async function exportBundleItemAction(input: {
|
|
||||||
kind: BundleItemKind;
|
|
||||||
pem: string;
|
|
||||||
format: BundleExportFormat;
|
|
||||||
baseName: string;
|
|
||||||
chain?: string[];
|
|
||||||
keyPem?: string;
|
|
||||||
password?: string;
|
|
||||||
}): Promise<FileResponse> {
|
|
||||||
const response = await fetch(`${API_URL}/modules/cert-manager/export`, {
|
|
||||||
method: 'POST',
|
|
||||||
headers: { 'Content-Type': 'application/json' },
|
|
||||||
body: JSON.stringify(input),
|
|
||||||
credentials: 'include',
|
|
||||||
});
|
|
||||||
if (!response.ok) {
|
|
||||||
const body = await response.text().catch(() => '');
|
|
||||||
throw new Error(`${response.status} ${body}`.trim());
|
|
||||||
}
|
|
||||||
return response.json() as Promise<FileResponse>;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,438 +1,70 @@
|
|||||||
import { cleanup, render, screen, fireEvent, waitFor } from '@testing-library/react';
|
import { cleanup, fireEvent, render as rtlRender, screen, waitFor } from '@testing-library/react';
|
||||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
import { NextIntlClientProvider } from 'next-intl';
|
||||||
|
import type { ReactElement } from 'react';
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import de from '@/messages/de.json';
|
||||||
|
import CertManagerPage from './page';
|
||||||
|
|
||||||
// Mock next-intl — provide certManager namespace keys
|
const mockAnalyze = vi.fn();
|
||||||
vi.mock('next-intl', () => ({
|
|
||||||
useTranslations: (ns: string) => (key: string) => {
|
vi.mock('./actions', async (importOriginal) => {
|
||||||
const certManager: Record<string, string> = {
|
const actual = await importOriginal<typeof import('./actions')>();
|
||||||
'title': 'Zertifikat-Manager',
|
return { ...actual, analyzeWorkingSet: (...args: unknown[]) => mockAnalyze(...args) };
|
||||||
'description': 'Zertifikate analysieren, aufteilen, zusammenfuehren und konvertieren.',
|
});
|
||||||
'tabs.overview': 'Uebersicht',
|
|
||||||
'tabs.inspect': 'Analysieren',
|
vi.mock('@/components/layout/page-header', () => ({
|
||||||
'tabs.split': 'Aufteilen',
|
PageHeader: ({ title, description }: { title: string; description?: string }) => (
|
||||||
'tabs.merge': 'Zusammenfuehren',
|
<header>
|
||||||
'tabs.convert': 'Konvertieren',
|
<h1>{title}</h1>
|
||||||
'dropZone.placeholder': 'Datei hierher ziehen oder klicken',
|
<p>{description}</p>
|
||||||
'dropZone.formats': '.pem, .crt, .cer, .der, .pfx, .p12, .p7b, .p7c',
|
</header>
|
||||||
'paste.placeholder': 'PEM-Inhalt einfuegen (-----BEGIN ...)',
|
),
|
||||||
'password.label': 'Passwort (PFX/P12)',
|
|
||||||
'or': 'oder',
|
|
||||||
'actions.inspect': 'Analysieren',
|
|
||||||
'actions.split': 'Aufteilen',
|
|
||||||
'actions.merge': 'Zusammenfuehren',
|
|
||||||
'actions.convert': 'Konvertieren',
|
|
||||||
'actions.download': 'Herunterladen',
|
|
||||||
'actions.processing': 'Wird verarbeitet...',
|
|
||||||
'emptyState.inspect': 'Kein Zertifikat geladen.',
|
|
||||||
'emptyState.inspectBody': 'Lade eine Datei hoch oder fuege PEM-Text ein.',
|
|
||||||
'emptyState.split': 'Keine Datei geladen.',
|
|
||||||
'emptyState.splitBody': 'Lade eine Fullchain- oder P7B-Datei hoch.',
|
|
||||||
'emptyState.merge': 'Keine Zertifikate ausgewaehlt.',
|
|
||||||
'emptyState.mergeBody': 'Lade mindestens zwei Dateien hoch.',
|
|
||||||
'emptyState.convert': 'Keine Datei geladen.',
|
|
||||||
'emptyState.convertBody': 'Lade eine Datei hoch und waehle ein Ausgabeformat.',
|
|
||||||
'error.generic': 'Verarbeitung fehlgeschlagen. Pruefe das Dateiformat oder das Passwort.',
|
|
||||||
'error.wrongPassword': 'Falsches Passwort. PFX/P12-Datei konnte nicht entschluesselt werden.',
|
|
||||||
'error.unknownFormat': 'Unbekanntes Format. Die Datei konnte nicht als Zertifikat erkannt werden.',
|
|
||||||
};
|
|
||||||
if (ns === 'certManager') {
|
|
||||||
return certManager[key] ?? key;
|
|
||||||
}
|
|
||||||
return key;
|
|
||||||
},
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import CertManagerPage from './page';
|
function render(ui: ReactElement) {
|
||||||
import { InspectTab } from './components/InspectTab';
|
return rtlRender(
|
||||||
import { SplitTab } from './components/SplitTab';
|
<NextIntlClientProvider locale="de" messages={de} timeZone="Europe/Berlin">
|
||||||
import { MergeTab } from './components/MergeTab';
|
{ui}
|
||||||
import { ConvertTab } from './components/ConvertTab';
|
</NextIntlClientProvider>,
|
||||||
import * as actions from './actions';
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
cleanup();
|
|
||||||
vi.restoreAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('CertManagerPage shell', () => {
|
|
||||||
it('renders the page title and description', () => {
|
|
||||||
render(<CertManagerPage />);
|
|
||||||
expect(screen.getByText('Zertifikat-Manager')).toBeInTheDocument();
|
|
||||||
expect(screen.getByText('Zertifikate analysieren, aufteilen, zusammenfuehren und konvertieren.')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders all four tab labels', () => {
|
|
||||||
render(<CertManagerPage />);
|
|
||||||
// "Analysieren" appears as both tab label (nav) and InspectTab action button
|
|
||||||
// getAllByText avoids the "multiple elements" error
|
|
||||||
expect(screen.getAllByText('Analysieren').length).toBeGreaterThanOrEqual(1);
|
|
||||||
expect(screen.getByText('Aufteilen')).toBeInTheDocument();
|
|
||||||
expect(screen.getByText('Zusammenfuehren')).toBeInTheDocument();
|
|
||||||
expect(screen.getByText('Konvertieren')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('does NOT render the password field on initial render (no PFX file selected)', () => {
|
|
||||||
render(<CertManagerPage />);
|
|
||||||
// Password field label should not be in document when no PFX is selected
|
|
||||||
expect(screen.queryByText('Passwort (PFX/P12)')).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('startet mit der Uebersicht ohne Einzeldatei-Eingabe (quick-261001-l4q)', () => {
|
|
||||||
render(<CertManagerPage />);
|
|
||||||
expect(screen.getByText('dropTitle')).toBeInTheDocument();
|
|
||||||
expect(screen.queryByText('Datei hierher ziehen oder klicken')).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('shows Inspect tab empty state when Inspect tab is active', () => {
|
|
||||||
render(<CertManagerPage />);
|
|
||||||
fireEvent.click(screen.getAllByText('Analysieren')[0]);
|
|
||||||
expect(screen.getByText('Kein Zertifikat geladen.')).toBeInTheDocument();
|
|
||||||
expect(screen.getByText('Lade eine Datei hoch oder fuege PEM-Text ein.')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('shows Split tab empty state when Split tab is active', () => {
|
|
||||||
render(<CertManagerPage />);
|
|
||||||
// Click the "Aufteilen" tab button (second one in nav)
|
|
||||||
const tabButtons = screen.getAllByText('Aufteilen');
|
|
||||||
// The tab nav button is the one that triggers tab change
|
|
||||||
fireEvent.click(tabButtons[0]);
|
|
||||||
expect(screen.getByText('Keine Datei geladen.')).toBeInTheDocument();
|
|
||||||
expect(screen.getByText('Lade eine Fullchain- oder P7B-Datei hoch.')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('shows Merge tab empty state when Merge tab is active', () => {
|
|
||||||
render(<CertManagerPage />);
|
|
||||||
fireEvent.click(screen.getByText('Zusammenfuehren'));
|
|
||||||
expect(screen.getByText('Keine Zertifikate ausgewaehlt.')).toBeInTheDocument();
|
|
||||||
expect(screen.getByText('Lade mindestens zwei Dateien hoch.')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('shows Convert tab empty state when Convert tab is active', () => {
|
|
||||||
render(<CertManagerPage />);
|
|
||||||
// "Konvertieren" appears once as tab label
|
|
||||||
fireEvent.click(screen.getByText('Konvertieren'));
|
|
||||||
expect(screen.getAllByText('Keine Datei geladen.').length).toBeGreaterThanOrEqual(1);
|
|
||||||
expect(screen.getByText('Lade eine Datei hoch und waehle ein Ausgabeformat.')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// InspectTab — unit tests for the Analysieren result grid and error state
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
const MOCK_CERT_DETAILS: actions.CertDetails = {
|
|
||||||
subject: { cn: 'example.com', o: 'Acme Corp', ou: 'IT', c: 'DE' },
|
|
||||||
issuer: { cn: 'Example CA', o: 'CA Corp', c: 'US' },
|
|
||||||
validity: {
|
|
||||||
notBefore: '2025-01-01T00:00:00.000Z',
|
|
||||||
notAfter: '2026-01-01T00:00:00.000Z',
|
|
||||||
isExpired: false,
|
|
||||||
daysLeft: 180,
|
|
||||||
},
|
|
||||||
san: ['example.com', 'www.example.com'],
|
|
||||||
keyType: 'RSA',
|
|
||||||
keyBits: 2048,
|
|
||||||
serialNumber: '01',
|
|
||||||
signatureAlgorithm: 'sha256WithRSAEncryption',
|
|
||||||
fingerprint: {
|
|
||||||
sha1: 'AA:BB:CC:DD:EE',
|
|
||||||
sha256: 'FF:00:11:22:33:44:55:66:77:88',
|
|
||||||
},
|
|
||||||
pemPreview: '-----BEGIN CERTIFICATE-----\nXXX\n-----END CERTIFICATE-----',
|
|
||||||
};
|
|
||||||
|
|
||||||
describe('InspectTab', () => {
|
|
||||||
it('renders CertDetails grid with subject CN and SHA-256 after successful inspect', async () => {
|
|
||||||
vi.spyOn(actions, 'inspectCertAction').mockResolvedValue(MOCK_CERT_DETAILS);
|
|
||||||
|
|
||||||
render(
|
|
||||||
<InspectTab
|
|
||||||
file={null}
|
|
||||||
pemText="-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----"
|
|
||||||
password=""
|
|
||||||
/>,
|
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// Click the Analysieren button
|
beforeEach(() => {
|
||||||
fireEvent.click(screen.getByText('Analysieren'));
|
mockAnalyze.mockReset();
|
||||||
|
mockAnalyze.mockResolvedValue({ items: [], chains: [], locked: [], ignored: [] });
|
||||||
|
});
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
// Wait for the async action to complete and result grid to render
|
describe('CertManagerPage', () => {
|
||||||
await waitFor(() => {
|
it('zeigt Titel und den einen Reiter „Dateien“ als aktiven Reiter', () => {
|
||||||
expect(screen.getByText('example.com')).toBeInTheDocument();
|
render(<CertManagerPage />);
|
||||||
|
expect(screen.getByRole('heading', { name: 'Zertifikat-Manager' })).toBeInTheDocument();
|
||||||
|
const tabs = screen.getAllByRole('button').filter((b) => b.hasAttribute('aria-current'));
|
||||||
|
expect(tabs).toHaveLength(1);
|
||||||
|
expect(tabs[0]).toHaveTextContent('Dateien');
|
||||||
|
expect(tabs[0]).toHaveAttribute('aria-current', 'page');
|
||||||
|
// Startansicht ist der Dateien-Reiter
|
||||||
|
expect(screen.getByText(/Dateien hierher ziehen/)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
// SHA-256 fingerprint is rendered
|
it('der Reiter zeigt die Anzahl der Dateien', async () => {
|
||||||
expect(screen.getByText('FF:00:11:22:33:44:55:66:77:88')).toBeInTheDocument();
|
render(<CertManagerPage />);
|
||||||
|
const input = screen.getByTestId('cert-file-input');
|
||||||
// Empty state is no longer shown
|
fireEvent.change(input, {
|
||||||
expect(screen.queryByText('Kein Zertifikat geladen.')).not.toBeInTheDocument();
|
target: {
|
||||||
});
|
files: [
|
||||||
|
new File(['a'], 'a.pem', { lastModified: 1 }),
|
||||||
it('shows text-destructive error when inspectCertAction rejects', async () => {
|
new File(['b'], 'b.pem', { lastModified: 2 }),
|
||||||
vi.spyOn(actions, 'inspectCertAction').mockRejectedValue(
|
|
||||||
new Error('wrong password'),
|
|
||||||
);
|
|
||||||
|
|
||||||
render(
|
|
||||||
<InspectTab
|
|
||||||
file={null}
|
|
||||||
pemText="test pem"
|
|
||||||
password="wrong"
|
|
||||||
/>,
|
|
||||||
);
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByText('Analysieren'));
|
|
||||||
|
|
||||||
// Wait for the error message to appear
|
|
||||||
await waitFor(() => {
|
|
||||||
// The wrong-password error message should be rendered
|
|
||||||
expect(
|
|
||||||
screen.getByText('Falsches Passwort. PFX/P12-Datei konnte nicht entschluesselt werden.'),
|
|
||||||
).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
// Error element should have the destructive class
|
|
||||||
const errorEl = screen.getByText(
|
|
||||||
'Falsches Passwort. PFX/P12-Datei konnte nicht entschluesselt werden.',
|
|
||||||
);
|
|
||||||
expect(errorEl.className).toContain('text-destructive');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// SplitTab — unit tests
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
const MOCK_SPLIT_RESPONSE: actions.SplitResponse = {
|
|
||||||
count: 2,
|
|
||||||
certs: [
|
|
||||||
{
|
|
||||||
index: 0,
|
|
||||||
filename: 'cert-1.pem',
|
|
||||||
content: btoa('-----BEGIN CERTIFICATE-----\nXXX\n-----END CERTIFICATE-----'),
|
|
||||||
subject: { cn: 'cert1.example.com' },
|
|
||||||
validity: { notAfter: '2027-01-01T00:00:00.000Z' },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
index: 1,
|
|
||||||
filename: 'cert-2.pem',
|
|
||||||
content: btoa('-----BEGIN CERTIFICATE-----\nYYY\n-----END CERTIFICATE-----'),
|
|
||||||
subject: { cn: 'cert2.example.com' },
|
|
||||||
validity: { notAfter: '2027-06-01T00:00:00.000Z' },
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
};
|
},
|
||||||
|
|
||||||
describe('SplitTab', () => {
|
|
||||||
it('renders two download buttons after clicking Aufteilen with mocked splitCertsAction', async () => {
|
|
||||||
vi.spyOn(actions, 'splitCertsAction').mockResolvedValue(MOCK_SPLIT_RESPONSE);
|
|
||||||
|
|
||||||
const testFile = new File(['cert data'], 'fullchain.pem', {
|
|
||||||
type: 'application/x-pem-file',
|
|
||||||
});
|
});
|
||||||
|
await waitFor(() =>
|
||||||
render(<SplitTab file={testFile} pemText="" password="" />);
|
expect(screen.getByRole('button', { name: 'Dateien (2)' })).toBeInTheDocument(),
|
||||||
|
|
||||||
// Initial empty state
|
|
||||||
expect(screen.getByText('Keine Datei geladen.')).toBeInTheDocument();
|
|
||||||
|
|
||||||
// Click the Aufteilen button (only one in this isolated render)
|
|
||||||
fireEvent.click(screen.getByText('Aufteilen'));
|
|
||||||
|
|
||||||
// Wait for the async action to resolve and per-cert list to appear
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getAllByText('Herunterladen').length).toBe(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Empty state is gone
|
|
||||||
expect(screen.queryByText('Keine Datei geladen.')).not.toBeInTheDocument();
|
|
||||||
|
|
||||||
// Both cert CN values are rendered
|
|
||||||
expect(screen.getByText('cert1.example.com')).toBeInTheDocument();
|
|
||||||
expect(screen.getByText('cert2.example.com')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('shows text-destructive error when splitCertsAction rejects', async () => {
|
|
||||||
vi.spyOn(actions, 'splitCertsAction').mockRejectedValue(
|
|
||||||
new Error('invalid format or corrupted file'),
|
|
||||||
);
|
|
||||||
|
|
||||||
const testFile = new File(['garbage'], 'bad.pem', { type: 'text/plain' });
|
|
||||||
|
|
||||||
render(<SplitTab file={testFile} pemText="" password="" />);
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByText('Aufteilen'));
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(
|
|
||||||
screen.getByText('Unbekanntes Format. Die Datei konnte nicht als Zertifikat erkannt werden.'),
|
|
||||||
).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
const errorEl = screen.getByText(
|
|
||||||
'Unbekanntes Format. Die Datei konnte nicht als Zertifikat erkannt werden.',
|
|
||||||
);
|
|
||||||
expect(errorEl.className).toContain('text-destructive');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// ConvertTab — unit tests
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
const MOCK_FILE_RESPONSE: actions.FileResponse = {
|
|
||||||
filename: 'converted.der',
|
|
||||||
content: btoa('fake der bytes'),
|
|
||||||
mimeType: 'application/x-x509-ca-cert',
|
|
||||||
};
|
|
||||||
|
|
||||||
describe('ConvertTab', () => {
|
|
||||||
it('renders format selector with pem, der, p7b options', () => {
|
|
||||||
render(<ConvertTab file={null} pemText="" password="" />);
|
|
||||||
const select = screen.getByRole('combobox');
|
|
||||||
const optionValues = Array.from(select.querySelectorAll('option')).map(
|
|
||||||
(o) => (o as HTMLOptionElement).value,
|
|
||||||
);
|
|
||||||
expect(optionValues).toContain('pem');
|
|
||||||
expect(optionValues).toContain('der');
|
|
||||||
expect(optionValues).toContain('p7b');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('calls downloadBase64 after clicking Konvertieren when convertCertAction resolves', async () => {
|
|
||||||
vi.spyOn(actions, 'convertCertAction').mockResolvedValue(MOCK_FILE_RESPONSE);
|
|
||||||
const downloadSpy = vi.spyOn(actions, 'downloadBase64').mockImplementation(() => {});
|
|
||||||
|
|
||||||
const testFile = new File(['cert data'], 'cert.pem', {
|
|
||||||
type: 'application/x-pem-file',
|
|
||||||
});
|
|
||||||
|
|
||||||
render(<ConvertTab file={testFile} pemText="" password="" />);
|
|
||||||
|
|
||||||
// Click the Konvertieren button
|
|
||||||
fireEvent.click(screen.getByText('Konvertieren'));
|
|
||||||
|
|
||||||
// Wait for async action to resolve and downloadBase64 to be called
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(downloadSpy).toHaveBeenCalledWith(
|
|
||||||
MOCK_FILE_RESPONSE.filename,
|
|
||||||
MOCK_FILE_RESPONSE.content,
|
|
||||||
MOCK_FILE_RESPONSE.mimeType,
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
|
||||||
|
|
||||||
it('shows text-destructive error when convertCertAction rejects with format error', async () => {
|
it('ruft die Analyse nicht ohne Dateien auf', () => {
|
||||||
vi.spyOn(actions, 'convertCertAction').mockRejectedValue(
|
|
||||||
new Error('unknown format or invalid certificate'),
|
|
||||||
);
|
|
||||||
|
|
||||||
const testFile = new File(['garbage'], 'bad.bin', { type: 'application/octet-stream' });
|
|
||||||
|
|
||||||
render(<ConvertTab file={testFile} pemText="" password="" />);
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByText('Konvertieren'));
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(
|
|
||||||
screen.getByText(
|
|
||||||
'Unbekanntes Format. Die Datei konnte nicht als Zertifikat erkannt werden.',
|
|
||||||
),
|
|
||||||
).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
const errorEl = screen.getByText(
|
|
||||||
'Unbekanntes Format. Die Datei konnte nicht als Zertifikat erkannt werden.',
|
|
||||||
);
|
|
||||||
expect(errorEl.className).toContain('text-destructive');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('renders pem, der, p7b, and pfx options in the format selector', () => {
|
|
||||||
render(<ConvertTab file={null} pemText="" password="" />);
|
|
||||||
const select = screen.getByRole('combobox');
|
|
||||||
const optionValues = Array.from(select.querySelectorAll('option')).map(
|
|
||||||
(o) => (o as HTMLOptionElement).value,
|
|
||||||
);
|
|
||||||
expect(optionValues).toContain('pem');
|
|
||||||
expect(optionValues).toContain('der');
|
|
||||||
expect(optionValues).toContain('p7b');
|
|
||||||
expect(optionValues).toContain('pfx');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// MergeTab — unit tests (CERT-03, CERT-05)
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
const MOCK_MERGE_PEM_RESPONSE: actions.FileResponse = {
|
|
||||||
filename: 'chain.pem',
|
|
||||||
content: btoa('-----BEGIN CERTIFICATE-----\nXXX\n-----END CERTIFICATE-----\n-----BEGIN CERTIFICATE-----\nYYY\n-----END CERTIFICATE-----'),
|
|
||||||
mimeType: 'application/x-pem-file',
|
|
||||||
};
|
|
||||||
|
|
||||||
describe('MergeTab', () => {
|
|
||||||
it('disables Zusammenfuehren action button when fewer than 2 files are selected', () => {
|
|
||||||
render(<MergeTab password="" />);
|
|
||||||
|
|
||||||
// The action button should be disabled (no files yet)
|
|
||||||
const actionBtn = screen.getByTestId('merge-action-btn');
|
|
||||||
expect(actionBtn).toBeDisabled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('enables Zusammenfuehren button when 2 or more files are selected', async () => {
|
|
||||||
render(<MergeTab password="" />);
|
|
||||||
|
|
||||||
const fileInput = screen.getByTestId('merge-file-input');
|
|
||||||
const file1 = new File(['cert1'], 'cert1.pem', { type: 'application/x-pem-file' });
|
|
||||||
const file2 = new File(['cert2'], 'cert2.pem', { type: 'application/x-pem-file' });
|
|
||||||
|
|
||||||
fireEvent.change(fileInput, { target: { files: [file1, file2] } });
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByTestId('merge-action-btn')).not.toBeDisabled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('shows password field in page.tsx when PFX output is selected in MergeTab', async () => {
|
|
||||||
// Render the full page so the shared PasswordField behaviour can be tested
|
|
||||||
render(<CertManagerPage />);
|
render(<CertManagerPage />);
|
||||||
|
expect(mockAnalyze).not.toHaveBeenCalled();
|
||||||
// Navigate to merge tab — only one "Zusammenfuehren" visible before tab switch
|
|
||||||
fireEvent.click(screen.getByText('Zusammenfuehren'));
|
|
||||||
|
|
||||||
// Change format selector to pfx
|
|
||||||
const formatSelect = screen.getByDisplayValue('PEM-Kette');
|
|
||||||
fireEvent.change(formatSelect, { target: { value: 'pfx' } });
|
|
||||||
|
|
||||||
// The shared PasswordField should now be visible
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByText('Passwort (PFX/P12)')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('calls downloadBase64 after successful merge when >= 2 files selected', async () => {
|
|
||||||
vi.spyOn(actions, 'mergeCertsAction').mockResolvedValue(MOCK_MERGE_PEM_RESPONSE);
|
|
||||||
const downloadSpy = vi.spyOn(actions, 'downloadBase64').mockImplementation(() => {});
|
|
||||||
|
|
||||||
render(<MergeTab password="" />);
|
|
||||||
|
|
||||||
const fileInput = screen.getByTestId('merge-file-input');
|
|
||||||
const file1 = new File(['cert1'], 'cert1.pem', { type: 'application/x-pem-file' });
|
|
||||||
const file2 = new File(['cert2'], 'cert2.pem', { type: 'application/x-pem-file' });
|
|
||||||
|
|
||||||
fireEvent.change(fileInput, { target: { files: [file1, file2] } });
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByTestId('merge-action-btn')).not.toBeDisabled();
|
|
||||||
});
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByTestId('merge-action-btn'));
|
|
||||||
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(downloadSpy).toHaveBeenCalledWith(
|
|
||||||
MOCK_MERGE_PEM_RESPONSE.filename,
|
|
||||||
MOCK_MERGE_PEM_RESPONSE.content,
|
|
||||||
MOCK_MERGE_PEM_RESPONSE.mimeType,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,96 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState } from 'react';
|
|
||||||
import { useTranslations } from 'next-intl';
|
|
||||||
import { convertCertAction, downloadBase64 } from '../actions';
|
|
||||||
|
|
||||||
interface ConvertTabProps {
|
|
||||||
file: File | null;
|
|
||||||
pemText: string;
|
|
||||||
password: string;
|
|
||||||
/** Callback to notify page.tsx when target format changes (for shared PasswordField visibility) */
|
|
||||||
onTargetFormatChange?: (format: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
type TargetFormat = 'pem' | 'der' | 'p7b' | 'pfx';
|
|
||||||
|
|
||||||
export function ConvertTab({ file, pemText, password, onTargetFormatChange }: ConvertTabProps) {
|
|
||||||
const t = useTranslations('certManager');
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [targetFormat, setTargetFormat] = useState<TargetFormat>('pem');
|
|
||||||
|
|
||||||
function handleFormatChange(format: TargetFormat) {
|
|
||||||
setTargetFormat(format);
|
|
||||||
onTargetFormatChange?.(format);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleConvert() {
|
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
const response = await convertCertAction({ file, pemText, password }, targetFormat);
|
|
||||||
downloadBase64(response.filename, response.content, response.mimeType);
|
|
||||||
} catch (err) {
|
|
||||||
const msg = err instanceof Error ? err.message.toLowerCase() : '';
|
|
||||||
if (msg.includes('password') || msg.includes('passwort')) {
|
|
||||||
setError(t('error.wrongPassword'));
|
|
||||||
} else if (
|
|
||||||
msg.includes('format') ||
|
|
||||||
msg.includes('invalid') ||
|
|
||||||
msg.includes('unknown')
|
|
||||||
) {
|
|
||||||
setError(t('error.unknownFormat'));
|
|
||||||
} else {
|
|
||||||
setError(t('error.generic'));
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const canConvert = Boolean(file || pemText);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
{/* Target format selector */}
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<label htmlFor="targetFormat" className="text-sm font-medium text-foreground">
|
|
||||||
Ausgabeformat
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
id="targetFormat"
|
|
||||||
value={targetFormat}
|
|
||||||
onChange={(e) => handleFormatChange(e.target.value as TargetFormat)}
|
|
||||||
className="rounded border border-border bg-background px-2 py-1 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
|
|
||||||
>
|
|
||||||
<option value="pem">PEM</option>
|
|
||||||
<option value="der">DER</option>
|
|
||||||
<option value="p7b">P7B</option>
|
|
||||||
<option value="pfx">PFX / PKCS12</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Primary action button */}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleConvert}
|
|
||||||
disabled={loading || !canConvert}
|
|
||||||
className="btn btn-primary"
|
|
||||||
>
|
|
||||||
{loading ? t('actions.processing') : t('actions.convert')}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Error state */}
|
|
||||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
|
||||||
|
|
||||||
{/* Empty state — shown when no error */}
|
|
||||||
{!error && (
|
|
||||||
<div className="text-center py-8 space-y-1">
|
|
||||||
<p className="text-sm font-medium text-foreground">{t('emptyState.convert')}</p>
|
|
||||||
<p className="text-sm text-muted-foreground">{t('emptyState.convertBody')}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,108 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useRef, useState } from 'react';
|
|
||||||
import { useTranslations } from 'next-intl';
|
|
||||||
|
|
||||||
interface DropZoneProps {
|
|
||||||
onFile: (file: File) => void;
|
|
||||||
accept: string;
|
|
||||||
currentFile: File | null;
|
|
||||||
onClear: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function DropZone({ onFile, accept, currentFile, onClear }: DropZoneProps) {
|
|
||||||
const t = useTranslations('certManager');
|
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
||||||
const [isDragOver, setIsDragOver] = useState(false);
|
|
||||||
|
|
||||||
const handleClick = () => {
|
|
||||||
fileInputRef.current?.click();
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
const file = e.target.files?.[0];
|
|
||||||
if (file) {
|
|
||||||
onFile(file);
|
|
||||||
}
|
|
||||||
// Allow re-selecting the same file
|
|
||||||
e.target.value = '';
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDragOver = (e: React.DragEvent<HTMLButtonElement>) => {
|
|
||||||
e.preventDefault();
|
|
||||||
setIsDragOver(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDragLeave = () => {
|
|
||||||
setIsDragOver(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDrop = (e: React.DragEvent<HTMLButtonElement>) => {
|
|
||||||
e.preventDefault();
|
|
||||||
setIsDragOver(false);
|
|
||||||
const file = e.dataTransfer.files?.[0];
|
|
||||||
if (file) {
|
|
||||||
onFile(file);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<input
|
|
||||||
ref={fileInputRef}
|
|
||||||
type="file"
|
|
||||||
accept={accept}
|
|
||||||
className="hidden"
|
|
||||||
onChange={handleFileChange}
|
|
||||||
/>
|
|
||||||
{/* a11y/useSemanticElements: kein role="button"-div mehr -- die
|
|
||||||
eigentliche Flaeche ist jetzt ein echtes <button> und traegt darum
|
|
||||||
auch Klick UND Drag-Ereignisse (ein <div> ohne Rolle mit
|
|
||||||
Ereignis-Handlern waere ein statisches, nicht interaktives Element
|
|
||||||
und faellt unter die zurueckgestellten Regeln noStaticElement-
|
|
||||||
Interactions/noNoninteractiveElementInteractions). Die "Entfernen"-
|
|
||||||
Schaltflaeche liegt darum als Geschwister daneben, nicht mehr
|
|
||||||
verschachtelt in der Flaechen-Schaltflaeche -- ein <button> darf
|
|
||||||
kein weiteres <button> enthalten (ungueltiges Verschachteln). */}
|
|
||||||
<div className="relative">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleClick}
|
|
||||||
onDragOver={handleDragOver}
|
|
||||||
onDragLeave={handleDragLeave}
|
|
||||||
onDrop={handleDrop}
|
|
||||||
className={`block w-full cursor-pointer rounded-lg border-2 border-dashed p-8 text-center transition-colors ${
|
|
||||||
isDragOver
|
|
||||||
? 'border-primary bg-primary/5'
|
|
||||||
: 'border-border hover:border-primary/50'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{currentFile ? (
|
|
||||||
<span className="text-sm text-foreground">
|
|
||||||
{currentFile.name}{' '}
|
|
||||||
<span className="text-muted-foreground">
|
|
||||||
({(currentFile.size / 1024).toFixed(1)} KB)
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-1">
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{t('dropZone.placeholder')}
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-muted-foreground">{t('dropZone.formats')}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
{currentFile && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={(e) => { e.stopPropagation(); onClear(); }}
|
|
||||||
className="absolute right-2 top-2 rounded border border-border bg-card px-2 py-0.5 text-xs text-muted-foreground hover:text-foreground"
|
|
||||||
>
|
|
||||||
✕
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,218 @@
|
|||||||
|
import {
|
||||||
|
cleanup,
|
||||||
|
fireEvent,
|
||||||
|
render as rtlRender,
|
||||||
|
screen,
|
||||||
|
waitFor,
|
||||||
|
within,
|
||||||
|
} from '@testing-library/react';
|
||||||
|
import { NextIntlClientProvider } from 'next-intl';
|
||||||
|
import type { ReactElement } from 'react';
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
import de from '@/messages/de.json';
|
||||||
|
import type { AnalysisResult, CertItem } from '../actions';
|
||||||
|
import { CertManagerRequestError } from '../actions';
|
||||||
|
import { useCertWorkspace } from '../use-cert-workspace';
|
||||||
|
import { FilesTab } from './FilesTab';
|
||||||
|
|
||||||
|
const mockAnalyze = vi.fn();
|
||||||
|
|
||||||
|
vi.mock('../actions', async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import('../actions')>();
|
||||||
|
return { ...actual, analyzeWorkingSet: (...args: unknown[]) => mockAnalyze(...args) };
|
||||||
|
});
|
||||||
|
|
||||||
|
function render(ui: ReactElement) {
|
||||||
|
return rtlRender(
|
||||||
|
<NextIntlClientProvider locale="de" messages={de} timeZone="Europe/Berlin">
|
||||||
|
{ui}
|
||||||
|
</NextIntlClientProvider>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Harness() {
|
||||||
|
const workspace = useCertWorkspace();
|
||||||
|
return <FilesTab workspace={workspace} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cert(id: string, cn: string, role: CertItem['role'], files: number[], names: string[]) {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
kind: 'certificate',
|
||||||
|
role,
|
||||||
|
sources: files.map((file, i) => ({ file, path: names[i] })),
|
||||||
|
pem: '',
|
||||||
|
baseName: cn,
|
||||||
|
cn,
|
||||||
|
organization: '',
|
||||||
|
issuerCn: '',
|
||||||
|
issuerOrganization: '',
|
||||||
|
notBefore: '2026-01-01T00:00:00.000Z',
|
||||||
|
notAfter: '2126-01-01T00:00:00.000Z',
|
||||||
|
isExpired: false,
|
||||||
|
daysLeft: 36000,
|
||||||
|
san: [],
|
||||||
|
keyType: 'RSA',
|
||||||
|
keyBits: 2048,
|
||||||
|
curve: null,
|
||||||
|
serialNumber: '01',
|
||||||
|
sha256: '',
|
||||||
|
sha1: '',
|
||||||
|
isCa: role !== 'end-entity',
|
||||||
|
selfSigned: role === 'root',
|
||||||
|
aiaIssuerUrls: [],
|
||||||
|
keyId: null,
|
||||||
|
csrIds: [],
|
||||||
|
} satisfies CertItem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fake-Server: erkennt anhand des Dateinamens, was in der Datei „steckt“. */
|
||||||
|
function fakeAnalyze(entries: { file: File }[]): AnalysisResult {
|
||||||
|
const items: CertItem[] = [];
|
||||||
|
const ignored: AnalysisResult['ignored'] = [];
|
||||||
|
entries.forEach((entry, index) => {
|
||||||
|
const name = entry.file.name;
|
||||||
|
if (name.startsWith('leaf'))
|
||||||
|
items.push(cert('c-leaf', 'www.example.test', 'end-entity', [index], [name]));
|
||||||
|
else if (name.startsWith('inter'))
|
||||||
|
items.push(cert('c-inter', 'Test Inter', 'intermediate', [index], [name]));
|
||||||
|
else ignored.push({ file: index, path: name, reason: 'unknown' });
|
||||||
|
});
|
||||||
|
return { items, chains: [], locked: [], ignored };
|
||||||
|
}
|
||||||
|
|
||||||
|
function makeFile(name: string, lastModified = 1) {
|
||||||
|
return new File(['x'], name, { lastModified });
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectFiles(files: File[]) {
|
||||||
|
const input = screen.getByTestId('cert-file-input') as HTMLInputElement;
|
||||||
|
fireEvent.change(input, { target: { files } });
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
mockAnalyze.mockReset();
|
||||||
|
mockAnalyze.mockImplementation(async (entries: { file: File }[]) => fakeAnalyze(entries));
|
||||||
|
});
|
||||||
|
afterEach(cleanup);
|
||||||
|
|
||||||
|
describe('FilesTab', () => {
|
||||||
|
it('zwei Auswahlen nacheinander: beide bleiben, die Analyse bekommt beide (Fehlerbild des Nutzers)', async () => {
|
||||||
|
render(<Harness />);
|
||||||
|
selectFiles([makeFile('leaf.pem')]);
|
||||||
|
await screen.findByText('www.example.test');
|
||||||
|
selectFiles([makeFile('inter.pem')]);
|
||||||
|
await screen.findByText('Test Inter');
|
||||||
|
expect(screen.getByText('leaf.pem')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('inter.pem')).toBeInTheDocument();
|
||||||
|
const lastCall = mockAnalyze.mock.calls.at(-1)?.[0] as { file: File }[];
|
||||||
|
expect(lastCall.map((e) => e.file.name)).toEqual(['leaf.pem', 'inter.pem']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('eine Auswahl mit zwei Dateien haengt beide an', async () => {
|
||||||
|
render(<Harness />);
|
||||||
|
selectFiles([makeFile('leaf.pem'), makeFile('inter.pem')]);
|
||||||
|
await screen.findByText('Test Inter');
|
||||||
|
expect(screen.getAllByRole('listitem').length).toBeGreaterThanOrEqual(2);
|
||||||
|
expect(mockAnalyze).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Ablegen von Dateien haengt an', async () => {
|
||||||
|
render(<Harness />);
|
||||||
|
const zone = screen.getByRole('button', { name: /Dateien hierher ziehen/ });
|
||||||
|
fireEvent.drop(zone, { dataTransfer: { files: [makeFile('leaf.pem')] } });
|
||||||
|
await screen.findByText('www.example.test');
|
||||||
|
fireEvent.drop(zone, { dataTransfer: { files: [makeFile('inter.pem')] } });
|
||||||
|
await screen.findByText('Test Inter');
|
||||||
|
expect(screen.getByText('leaf.pem')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Entfernen analysiert den Rest neu, die letzte Datei leert ohne Aufruf', async () => {
|
||||||
|
render(<Harness />);
|
||||||
|
selectFiles([makeFile('leaf.pem'), makeFile('inter.pem')]);
|
||||||
|
await screen.findByText('Test Inter');
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '„leaf.pem“ entfernen' }));
|
||||||
|
await waitFor(() => expect(screen.queryByText('leaf.pem')).not.toBeInTheDocument());
|
||||||
|
await waitFor(() => {
|
||||||
|
const last = mockAnalyze.mock.calls.at(-1)?.[0] as { file: File }[];
|
||||||
|
expect(last.map((e) => e.file.name)).toEqual(['inter.pem']);
|
||||||
|
});
|
||||||
|
const callsBefore = mockAnalyze.mock.calls.length;
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '„inter.pem“ entfernen' }));
|
||||||
|
await screen.findByText(/Noch keine Dateien/);
|
||||||
|
expect(mockAnalyze).toHaveBeenCalledTimes(callsBefore);
|
||||||
|
expect(screen.queryByText('Test Inter')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('zeigt je Eintrag Rolle und Name der erkannten Zertifikate', async () => {
|
||||||
|
render(<Harness />);
|
||||||
|
selectFiles([makeFile('leaf.pem')]);
|
||||||
|
const item = (await screen.findByText('www.example.test')).closest('li') as HTMLElement;
|
||||||
|
expect(within(item).getByText('Serverzertifikat')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('eine nicht erkannte Datei nennt den Grund', async () => {
|
||||||
|
render(<Harness />);
|
||||||
|
selectFiles([makeFile('readme.txt')]);
|
||||||
|
await screen.findByText('Darin wurde kein Zertifikat erkannt.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('abgelehnte Dateien werden mit Grund aufgelistet', async () => {
|
||||||
|
render(<Harness />);
|
||||||
|
selectFiles([makeFile('leaf.pem', 5)]);
|
||||||
|
await screen.findByText('www.example.test');
|
||||||
|
selectFiles([makeFile('leaf.pem', 5)]);
|
||||||
|
expect(await screen.findByText('„leaf.pem“ ist schon in der Liste.')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('der Hinweis, dass die Liste nur in diesem Browserfenster liegt, ist immer sichtbar', () => {
|
||||||
|
render(<Harness />);
|
||||||
|
expect(screen.getByText(/nur in diesem Browserfenster/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('zeigt waehrend der Analyse einen Statustext', async () => {
|
||||||
|
let resolve: (value: AnalysisResult) => void = () => {};
|
||||||
|
mockAnalyze.mockImplementationOnce(
|
||||||
|
() =>
|
||||||
|
new Promise<AnalysisResult>((r) => {
|
||||||
|
resolve = r;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
render(<Harness />);
|
||||||
|
selectFiles([makeFile('leaf.pem')]);
|
||||||
|
expect(await screen.findByRole('status')).toHaveTextContent('Dateien werden geprüft');
|
||||||
|
resolve({ items: [], chains: [], locked: [], ignored: [] });
|
||||||
|
await waitFor(() => expect(screen.queryByRole('status')).not.toBeInTheDocument());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('bei einem Analysefehler: Text zum Fehlercode und „Erneut versuchen“ wiederholt den Aufruf', async () => {
|
||||||
|
mockAnalyze.mockRejectedValueOnce(new CertManagerRequestError(413, 'tooLarge'));
|
||||||
|
render(<Harness />);
|
||||||
|
selectFiles([makeFile('leaf.pem')]);
|
||||||
|
expect(await screen.findByText(/zusammen zu groß/)).toBeInTheDocument();
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Erneut versuchen' }));
|
||||||
|
await screen.findByText('www.example.test');
|
||||||
|
expect(mockAnalyze).toHaveBeenCalledTimes(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('verwirft die Antwort einer aelteren Anfrage', async () => {
|
||||||
|
const resolvers: ((value: AnalysisResult) => void)[] = [];
|
||||||
|
mockAnalyze.mockImplementation(
|
||||||
|
() =>
|
||||||
|
new Promise<AnalysisResult>((r) => {
|
||||||
|
resolvers.push(r);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
render(<Harness />);
|
||||||
|
selectFiles([makeFile('leaf.pem')]);
|
||||||
|
selectFiles([makeFile('inter.pem')]);
|
||||||
|
await waitFor(() => expect(resolvers).toHaveLength(2));
|
||||||
|
const second = fakeAnalyze([{ file: makeFile('leaf.pem') }, { file: makeFile('inter.pem') }]);
|
||||||
|
resolvers[1](second);
|
||||||
|
await screen.findByText('Test Inter');
|
||||||
|
resolvers[0](fakeAnalyze([{ file: makeFile('readme.txt') }]));
|
||||||
|
await new Promise((r) => setTimeout(r, 20));
|
||||||
|
expect(screen.getByText('Test Inter')).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText('Darin wurde kein Zertifikat erkannt.')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,238 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
import { useRef, useState } from 'react';
|
||||||
|
import type { AnyItem } from '../actions';
|
||||||
|
import type { CertWorkspace } from '../use-cert-workspace';
|
||||||
|
import {
|
||||||
|
formatBytes,
|
||||||
|
MAX_ENTRIES,
|
||||||
|
MAX_FILE_BYTES,
|
||||||
|
MAX_TOTAL_BYTES,
|
||||||
|
type RejectedFile,
|
||||||
|
type WorkingEntry,
|
||||||
|
} from '../working-set';
|
||||||
|
|
||||||
|
/** Farben der Rollenmarken (wie in der frueheren Uebersicht). */
|
||||||
|
export const ROLE_STYLES: Record<string, string> = {
|
||||||
|
'end-entity': 'bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300',
|
||||||
|
intermediate: 'bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300',
|
||||||
|
root: 'bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-300',
|
||||||
|
privateKey: 'bg-violet-100 text-violet-800 dark:bg-violet-900/40 dark:text-violet-300',
|
||||||
|
csr: 'bg-muted text-muted-foreground',
|
||||||
|
};
|
||||||
|
|
||||||
|
const ACCEPT = [
|
||||||
|
'.zip',
|
||||||
|
'.pem',
|
||||||
|
'.crt',
|
||||||
|
'.cer',
|
||||||
|
'.cert',
|
||||||
|
'.der',
|
||||||
|
'.ca-bundle',
|
||||||
|
'.chain',
|
||||||
|
'.p7b',
|
||||||
|
'.p7c',
|
||||||
|
'.pfx',
|
||||||
|
'.p12',
|
||||||
|
'.csr',
|
||||||
|
'.req',
|
||||||
|
'.txt',
|
||||||
|
'.key',
|
||||||
|
].join(',');
|
||||||
|
|
||||||
|
const MB = 1024 * 1024;
|
||||||
|
|
||||||
|
function roleKey(item: AnyItem): string {
|
||||||
|
return item.kind === 'certificate' ? item.role : item.kind;
|
||||||
|
}
|
||||||
|
|
||||||
|
function itemName(item: AnyItem): string {
|
||||||
|
return item.kind === 'privateKey' ? item.baseName : item.cn || item.baseName;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FilesTabProps {
|
||||||
|
workspace: CertWorkspace;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reiter „Dateien“: der einzige Ort zum Hinzufuegen (D-01). Jede Auswahl haengt an die Liste an;
|
||||||
|
* eine zweite Datei ersetzt nie die erste. Pro Eintrag steht, was Tessera darin erkannt hat.
|
||||||
|
*/
|
||||||
|
export function FilesTab({ workspace }: FilesTabProps) {
|
||||||
|
const t = useTranslations('certManager');
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [dragging, setDragging] = useState(false);
|
||||||
|
const [rejected, setRejected] = useState<RejectedFile[]>([]);
|
||||||
|
const { entries, analysis, analysisIds, status, errorKey } = workspace;
|
||||||
|
|
||||||
|
const add = (list: FileList | File[] | null | undefined) => {
|
||||||
|
const files = Array.from(list ?? []);
|
||||||
|
if (files.length === 0) return;
|
||||||
|
setRejected(workspace.addFiles(files));
|
||||||
|
};
|
||||||
|
|
||||||
|
const itemsOf = (entry: WorkingEntry): AnyItem[] =>
|
||||||
|
(analysis?.items ?? []).filter((item) =>
|
||||||
|
item.sources.some((s) => analysisIds[s.file] === entry.id),
|
||||||
|
);
|
||||||
|
const ignoredOf = (entry: WorkingEntry) =>
|
||||||
|
(analysis?.ignored ?? []).filter((i) => analysisIds[i.file] === entry.id);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => inputRef.current?.click()}
|
||||||
|
onDragOver={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setDragging(true);
|
||||||
|
}}
|
||||||
|
onDragLeave={() => setDragging(false)}
|
||||||
|
onDrop={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setDragging(false);
|
||||||
|
add(e.dataTransfer?.files);
|
||||||
|
}}
|
||||||
|
className={`flex w-full flex-col items-center gap-1 rounded-lg border-2 border-dashed px-4 py-8 text-center transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring ${
|
||||||
|
dragging ? 'border-primary-strong bg-muted' : 'border-border hover:bg-muted'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className="text-sm font-medium text-foreground">{t('files.dropTitle')}</span>
|
||||||
|
<span className="text-xs text-muted-foreground">{t('files.dropHint')}</span>
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="file"
|
||||||
|
multiple
|
||||||
|
accept={ACCEPT}
|
||||||
|
className="hidden"
|
||||||
|
data-testid="cert-file-input"
|
||||||
|
tabIndex={-1}
|
||||||
|
onChange={(e) => {
|
||||||
|
add(e.target.files);
|
||||||
|
e.target.value = '';
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t('files.limits', {
|
||||||
|
maxFiles: MAX_ENTRIES,
|
||||||
|
maxFile: MAX_FILE_BYTES / MB,
|
||||||
|
maxTotal: MAX_TOTAL_BYTES / MB,
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{rejected.length > 0 && (
|
||||||
|
<div role="alert" className="rounded-lg border border-border bg-muted p-3 text-sm">
|
||||||
|
<p className="font-medium text-foreground">{t('files.rejectedTitle')}</p>
|
||||||
|
<ul className="mt-1 space-y-0.5 text-muted-foreground">
|
||||||
|
{rejected.map((r) => (
|
||||||
|
<li key={`${r.name}-${r.reason}`}>
|
||||||
|
{t(`files.rejected.${r.reason}`, {
|
||||||
|
name: r.name,
|
||||||
|
max: MAX_ENTRIES,
|
||||||
|
size: (r.reason === 'tooLarge' ? MAX_FILE_BYTES : MAX_TOTAL_BYTES) / MB,
|
||||||
|
})}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<p className="rounded-lg bg-muted p-3 text-xs text-muted-foreground">
|
||||||
|
{t('files.memoryNote')}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{entries.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">{t('files.empty')}</p>
|
||||||
|
) : (
|
||||||
|
<section className="space-y-3" aria-label={t('files.listTitle', { count: entries.length })}>
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<h2 className="text-sm font-semibold text-foreground">
|
||||||
|
{t('files.listTitle', { count: entries.length })}
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={workspace.clear}
|
||||||
|
className="rounded border border-border px-3 py-1 text-sm text-foreground hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
>
|
||||||
|
{t('files.removeAll')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{status === 'analyzing' && (
|
||||||
|
<p role="status" className="text-sm text-muted-foreground">
|
||||||
|
{t('files.analyzing')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{status === 'error' && (
|
||||||
|
<div role="alert" className="rounded-lg border border-border bg-muted p-3 text-sm">
|
||||||
|
<p className="text-foreground">{t(`errors.${errorKey ?? 'generic'}`)}</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={workspace.retry}
|
||||||
|
className="mt-2 rounded border border-border px-3 py-1 text-foreground hover:bg-card focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
>
|
||||||
|
{t('files.retry')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{entries.map((entry) => {
|
||||||
|
const items = itemsOf(entry);
|
||||||
|
const ignored = ignoredOf(entry);
|
||||||
|
const analysed = analysisIds.includes(entry.id);
|
||||||
|
return (
|
||||||
|
<li key={entry.id} className="rounded-lg border border-border p-3">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="break-all text-sm font-medium text-foreground">{entry.label}</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{formatBytes(entry.file.size)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => workspace.remove(entry.id)}
|
||||||
|
aria-label={t('files.remove', { name: entry.label })}
|
||||||
|
className="shrink-0 rounded border border-border px-2 py-1 text-xs text-foreground hover:bg-muted focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||||
|
>
|
||||||
|
{t('files.removeShort')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<ul className="mt-2 space-y-1">
|
||||||
|
{items.map((item) => (
|
||||||
|
<li key={item.id} className="flex flex-wrap items-center gap-2 text-sm">
|
||||||
|
<span
|
||||||
|
className={`rounded px-2 py-0.5 text-xs font-semibold ${ROLE_STYLES[roleKey(item)]}`}
|
||||||
|
>
|
||||||
|
{t(`roles.${roleKey(item)}`)}
|
||||||
|
</span>
|
||||||
|
<span className="break-all text-foreground">{itemName(item)}</span>
|
||||||
|
{item.kind === 'certificate' && item.isExpired && (
|
||||||
|
<span className="rounded bg-red-100 px-2 py-0.5 text-xs font-medium text-red-800 dark:bg-red-900/40 dark:text-red-300">
|
||||||
|
{t('files.expired')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
{ignored.map((i) => (
|
||||||
|
<li key={`${i.path}-${i.reason}`} className="text-sm text-muted-foreground">
|
||||||
|
{t(`files.ignored.${i.reason}`)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
{!analysed && status === 'analyzing' && (
|
||||||
|
<li className="text-sm text-muted-foreground">{t('files.checking')}</li>
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,144 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState } from 'react';
|
|
||||||
import { useTranslations } from 'next-intl';
|
|
||||||
import { type CertDetails, inspectCertAction } from '../actions';
|
|
||||||
|
|
||||||
interface InspectTabProps {
|
|
||||||
file: File | null;
|
|
||||||
pemText: string;
|
|
||||||
password: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function InspectTab({ file, pemText, password }: InspectTabProps) {
|
|
||||||
const t = useTranslations('certManager');
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [result, setResult] = useState<CertDetails | null>(null);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
async function handleInspect() {
|
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
setResult(null);
|
|
||||||
try {
|
|
||||||
const details = await inspectCertAction({ file, pemText, password });
|
|
||||||
setResult(details);
|
|
||||||
} catch (err) {
|
|
||||||
const msg = err instanceof Error ? err.message.toLowerCase() : '';
|
|
||||||
if (msg.includes('password') || msg.includes('passwort')) {
|
|
||||||
setError(t('error.wrongPassword'));
|
|
||||||
} else if (msg.includes('unknown') || msg.includes('format') || msg.includes('invalid')) {
|
|
||||||
setError(t('error.unknownFormat'));
|
|
||||||
} else {
|
|
||||||
setError(t('error.generic'));
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const canInspect = Boolean(file || pemText);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
{/* Primary action button */}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleInspect}
|
|
||||||
disabled={loading || !canInspect}
|
|
||||||
className="btn btn-primary"
|
|
||||||
>
|
|
||||||
{loading ? t('actions.processing') : t('actions.inspect')}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Error state */}
|
|
||||||
{error && (
|
|
||||||
<p className="text-sm text-destructive">{error}</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Empty state — shown when no result and no error */}
|
|
||||||
{!result && !error && (
|
|
||||||
<div className="text-center py-8 space-y-1">
|
|
||||||
<p className="text-sm font-medium text-foreground">{t('emptyState.inspect')}</p>
|
|
||||||
<p className="text-sm text-muted-foreground">{t('emptyState.inspectBody')}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Result grid — key-value layout per UI-SPEC (grid-cols-2 gap-2 text-sm) */}
|
|
||||||
{result && (
|
|
||||||
<div className="grid grid-cols-2 gap-2 text-sm">
|
|
||||||
<span className="font-medium text-muted-foreground">Subject CN</span>
|
|
||||||
<span className="break-all">{result.subject.cn}</span>
|
|
||||||
|
|
||||||
{result.subject.o && (
|
|
||||||
<>
|
|
||||||
<span className="font-medium text-muted-foreground">Subject O</span>
|
|
||||||
<span>{result.subject.o}</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{result.subject.ou && (
|
|
||||||
<>
|
|
||||||
<span className="font-medium text-muted-foreground">Subject OU</span>
|
|
||||||
<span>{result.subject.ou}</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{result.subject.c && (
|
|
||||||
<>
|
|
||||||
<span className="font-medium text-muted-foreground">Subject C</span>
|
|
||||||
<span>{result.subject.c}</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<span className="font-medium text-muted-foreground">Issuer CN</span>
|
|
||||||
<span className="break-all">{result.issuer.cn}</span>
|
|
||||||
|
|
||||||
{result.issuer.o && (
|
|
||||||
<>
|
|
||||||
<span className="font-medium text-muted-foreground">Issuer O</span>
|
|
||||||
<span>{result.issuer.o}</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<span className="font-medium text-muted-foreground">Not Before</span>
|
|
||||||
<span>{result.validity.notBefore}</span>
|
|
||||||
|
|
||||||
<span className="font-medium text-muted-foreground">Not After</span>
|
|
||||||
<span>{result.validity.notAfter}</span>
|
|
||||||
|
|
||||||
<span className="font-medium text-muted-foreground">Expired</span>
|
|
||||||
<span>{result.validity.isExpired ? 'Yes' : 'No'}</span>
|
|
||||||
|
|
||||||
<span className="font-medium text-muted-foreground">Days Left</span>
|
|
||||||
<span>{result.validity.daysLeft}</span>
|
|
||||||
|
|
||||||
<span className="font-medium text-muted-foreground">Key Type</span>
|
|
||||||
<span>{result.keyType}</span>
|
|
||||||
|
|
||||||
<span className="font-medium text-muted-foreground">Key Bits</span>
|
|
||||||
<span>{result.keyBits}</span>
|
|
||||||
|
|
||||||
<span className="font-medium text-muted-foreground">Serial Number</span>
|
|
||||||
<span className="break-all">{result.serialNumber}</span>
|
|
||||||
|
|
||||||
<span className="font-medium text-muted-foreground">Signature Algorithm</span>
|
|
||||||
<span>{result.signatureAlgorithm}</span>
|
|
||||||
|
|
||||||
<span className="font-medium text-muted-foreground">SHA-1</span>
|
|
||||||
<span className="break-all font-mono text-xs">{result.fingerprint.sha1}</span>
|
|
||||||
|
|
||||||
<span className="font-medium text-muted-foreground">SHA-256</span>
|
|
||||||
<span className="break-all font-mono text-xs">{result.fingerprint.sha256}</span>
|
|
||||||
|
|
||||||
{result.san.length > 0 && (
|
|
||||||
<>
|
|
||||||
<span className="font-medium text-muted-foreground">SANs</span>
|
|
||||||
<span className="break-all">{result.san.join(', ')}</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,74 +0,0 @@
|
|||||||
import { fireEvent, render, screen } from '@testing-library/react';
|
|
||||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
||||||
|
|
||||||
// Mock next-intl — passthrough t(key) => key (same pattern as stopwatch-widget.test.tsx)
|
|
||||||
vi.mock('next-intl', () => ({
|
|
||||||
useTranslations: () => (key: string) => key,
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Mock ../actions — MergeTab only needs mergeCertsAction and downloadBase64 to exist;
|
|
||||||
// this test never triggers the merge action itself, only file-list management.
|
|
||||||
vi.mock('../actions', () => ({
|
|
||||||
mergeCertsAction: vi.fn(),
|
|
||||||
downloadBase64: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
import { MergeTab } from './MergeTab';
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
vi.restoreAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('MergeTab — Dateiliste', () => {
|
|
||||||
it('quick-260921-iwr: Entfernen der mittleren Datei laesst genau die erste und dritte Datei uebrig, in dieser Reihenfolge, mit ihren eigenen Namen (belegt den Positionsschluessel bei einer schrumpfenden Liste)', async () => {
|
|
||||||
render(<MergeTab password="" />);
|
|
||||||
|
|
||||||
const fileInput = screen.getByTestId('merge-file-input');
|
|
||||||
const file1 = new File(['cert1'], 'erste.pem', { type: 'application/x-pem-file' });
|
|
||||||
const file2 = new File(['cert2'], 'mittlere.pem', { type: 'application/x-pem-file' });
|
|
||||||
const file3 = new File(['cert3'], 'dritte.pem', { type: 'application/x-pem-file' });
|
|
||||||
|
|
||||||
fireEvent.change(fileInput, { target: { files: [file1, file2, file3] } });
|
|
||||||
|
|
||||||
// Alle drei Dateien sind zunaechst gelistet
|
|
||||||
expect(screen.getByText('erste.pem')).toBeInTheDocument();
|
|
||||||
expect(screen.getByText('mittlere.pem')).toBeInTheDocument();
|
|
||||||
expect(screen.getByText('dritte.pem')).toBeInTheDocument();
|
|
||||||
|
|
||||||
// Die mittlere Datei ueber ihren Entfernen-Knopf loeschen (aria-label enthaelt den Dateinamen)
|
|
||||||
const removeMiddleBtn = screen.getByLabelText('Remove mittlere.pem');
|
|
||||||
fireEvent.click(removeMiddleBtn);
|
|
||||||
|
|
||||||
// "mittlere.pem" ist verschwunden
|
|
||||||
expect(screen.queryByText('mittlere.pem')).not.toBeInTheDocument();
|
|
||||||
|
|
||||||
// Die erste und dritte Datei stehen weiterhin mit ihren eigenen Namen in der Liste,
|
|
||||||
// in dieser Reihenfolge — kein Verwechseln durch den Positionsschluessel.
|
|
||||||
const remainingNames = screen
|
|
||||||
.getAllByText(/\.pem$/)
|
|
||||||
.map((el) => el.textContent);
|
|
||||||
expect(remainingNames).toEqual(['erste.pem', 'dritte.pem']);
|
|
||||||
|
|
||||||
// Die dritte Datei behaelt ihren eigenen Entfernen-Knopf (eigener Name im aria-label,
|
|
||||||
// nicht der der geloeschten mittleren Datei).
|
|
||||||
expect(screen.getByLabelText('Remove erste.pem')).toBeInTheDocument();
|
|
||||||
expect(screen.getByLabelText('Remove dritte.pem')).toBeInTheDocument();
|
|
||||||
expect(screen.queryByLabelText('Remove mittlere.pem')).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('quick-260921-iwr: Entfernen der ersten Datei laesst die zweite an ihrer eigenen Stelle mit eigenem Namen zurueck', async () => {
|
|
||||||
render(<MergeTab password="" />);
|
|
||||||
|
|
||||||
const fileInput = screen.getByTestId('merge-file-input');
|
|
||||||
const file1 = new File(['cert1'], 'a.pem', { type: 'application/x-pem-file' });
|
|
||||||
const file2 = new File(['cert2'], 'b.pem', { type: 'application/x-pem-file' });
|
|
||||||
|
|
||||||
fireEvent.change(fileInput, { target: { files: [file1, file2] } });
|
|
||||||
|
|
||||||
fireEvent.click(screen.getByLabelText('Remove a.pem'));
|
|
||||||
|
|
||||||
expect(screen.queryByText('a.pem')).not.toBeInTheDocument();
|
|
||||||
expect(screen.getByText('b.pem')).toBeInTheDocument();
|
|
||||||
expect(screen.getByLabelText('Remove b.pem')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,159 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState } from 'react';
|
|
||||||
import { useTranslations } from 'next-intl';
|
|
||||||
import { mergeCertsAction, downloadBase64 } from '../actions';
|
|
||||||
|
|
||||||
type MergeOutputFormat = 'pem' | 'pfx';
|
|
||||||
|
|
||||||
interface MergeTabProps {
|
|
||||||
/** Shared password from page.tsx (shown when PFX output is chosen) */
|
|
||||||
password: string;
|
|
||||||
/** Callback to notify page.tsx when output format changes (for shared PasswordField visibility) */
|
|
||||||
onOutputFormatChange?: (format: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* MergeTab — multi-file cert merge into PEM chain or password-protected PFX/PKCS12.
|
|
||||||
*
|
|
||||||
* Files are managed locally (separate from the shared single-file DropZone in page.tsx).
|
|
||||||
* Password is shared from page.tsx via prop — the PasswordField becomes visible when
|
|
||||||
* PFX output is selected (controlled by onOutputFormatChange → page.tsx showPassword).
|
|
||||||
*
|
|
||||||
* Security contract (T-09-02): password is never logged, never placed in URLs.
|
|
||||||
* Security contract (T-09-03): server enforces 20-file max + 5 MB per file.
|
|
||||||
*/
|
|
||||||
export function MergeTab({ password, onOutputFormatChange }: MergeTabProps) {
|
|
||||||
const t = useTranslations('certManager');
|
|
||||||
const [files, setFiles] = useState<File[]>([]);
|
|
||||||
const [outputFormat, setOutputFormat] = useState<MergeOutputFormat>('pem');
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
function handleFormatChange(format: MergeOutputFormat) {
|
|
||||||
setOutputFormat(format);
|
|
||||||
onOutputFormatChange?.(format);
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
|
|
||||||
const selected = Array.from(e.target.files ?? []);
|
|
||||||
if (selected.length > 0) {
|
|
||||||
setFiles((prev) => [...prev, ...selected]);
|
|
||||||
}
|
|
||||||
// Reset input so the same file can be re-added if needed
|
|
||||||
e.target.value = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
function removeFile(index: number) {
|
|
||||||
setFiles((prev) => prev.filter((_, i) => i !== index));
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleMerge() {
|
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
const response = await mergeCertsAction(
|
|
||||||
files,
|
|
||||||
outputFormat,
|
|
||||||
outputFormat === 'pfx' ? password : undefined,
|
|
||||||
);
|
|
||||||
downloadBase64(response.filename, response.content, response.mimeType);
|
|
||||||
} catch (err) {
|
|
||||||
const msg = err instanceof Error ? err.message.toLowerCase() : '';
|
|
||||||
if (msg.includes('password') || msg.includes('passwort')) {
|
|
||||||
setError(t('error.wrongPassword'));
|
|
||||||
} else if (
|
|
||||||
msg.includes('format') ||
|
|
||||||
msg.includes('invalid') ||
|
|
||||||
msg.includes('unknown')
|
|
||||||
) {
|
|
||||||
setError(t('error.unknownFormat'));
|
|
||||||
} else {
|
|
||||||
setError(t('error.generic'));
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const canMerge = files.length >= 2;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
{/* Selected file list */}
|
|
||||||
{files.length > 0 && (
|
|
||||||
<ul className="space-y-1 rounded border border-border bg-background p-3">
|
|
||||||
{files.map((f, i) => (
|
|
||||||
<li key={`${f.name}-${i}`} className="flex items-center justify-between text-sm">
|
|
||||||
<span className="text-foreground">{f.name}</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => removeFile(i)}
|
|
||||||
className="text-xs text-muted-foreground hover:text-destructive ml-2"
|
|
||||||
aria-label={`Remove ${f.name}`}
|
|
||||||
>
|
|
||||||
✕
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
<li className="pt-1 text-xs text-muted-foreground">
|
|
||||||
{files.length} {files.length === 1 ? 'Datei' : 'Dateien'} ausgewählt
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Multi-file input */}
|
|
||||||
<div>
|
|
||||||
<input
|
|
||||||
type="file"
|
|
||||||
multiple
|
|
||||||
accept=".pem,.crt,.cer,.der,.pfx,.p12,.p7b,.p7c"
|
|
||||||
onChange={handleFileChange}
|
|
||||||
data-testid="merge-file-input"
|
|
||||||
className="block text-sm text-foreground cursor-pointer"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Output format selector */}
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<label
|
|
||||||
htmlFor="mergeOutputFormat"
|
|
||||||
className="text-sm font-medium text-foreground"
|
|
||||||
>
|
|
||||||
Ausgabeformat
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
id="mergeOutputFormat"
|
|
||||||
value={outputFormat}
|
|
||||||
onChange={(e) => handleFormatChange(e.target.value as MergeOutputFormat)}
|
|
||||||
className="rounded border border-border bg-background px-2 py-1 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-ring"
|
|
||||||
>
|
|
||||||
<option value="pem">PEM-Kette</option>
|
|
||||||
<option value="pfx">PFX / PKCS12</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Primary action button — disabled until >= 2 files selected */}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
data-testid="merge-action-btn"
|
|
||||||
onClick={handleMerge}
|
|
||||||
disabled={loading || !canMerge}
|
|
||||||
className="btn btn-primary"
|
|
||||||
>
|
|
||||||
{loading ? t('actions.processing') : t('actions.merge')}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Error state */}
|
|
||||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
|
||||||
|
|
||||||
{/* Empty state — shown when no files and no error */}
|
|
||||||
{files.length === 0 && !error && (
|
|
||||||
<div className="text-center py-8 space-y-1">
|
|
||||||
<p className="text-sm font-medium text-foreground">{t('emptyState.merge')}</p>
|
|
||||||
<p className="text-sm text-muted-foreground">{t('emptyState.mergeBody')}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,217 +0,0 @@
|
|||||||
import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
|
|
||||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
||||||
|
|
||||||
vi.mock('next-intl', () => ({
|
|
||||||
useTranslations: () => (key: string, values?: Record<string, unknown>) =>
|
|
||||||
values ? `${key} ${JSON.stringify(values)}` : key,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock('../actions', () => ({
|
|
||||||
analyzeBundleAction: vi.fn(),
|
|
||||||
exportBundleItemAction: vi.fn(),
|
|
||||||
downloadBase64: vi.fn(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
import {
|
|
||||||
analyzeBundleAction,
|
|
||||||
type BundleItem,
|
|
||||||
downloadBase64,
|
|
||||||
exportBundleItemAction,
|
|
||||||
} from '../actions';
|
|
||||||
import { OverviewTab } from './OverviewTab';
|
|
||||||
|
|
||||||
const mockAnalyze = analyzeBundleAction as ReturnType<typeof vi.fn>;
|
|
||||||
const mockExport = exportBundleItemAction as ReturnType<typeof vi.fn>;
|
|
||||||
const mockDownload = downloadBase64 as ReturnType<typeof vi.fn>;
|
|
||||||
|
|
||||||
function item(over: Partial<BundleItem>): BundleItem {
|
|
||||||
return {
|
|
||||||
id: 'x',
|
|
||||||
kind: 'certificate',
|
|
||||||
sources: ['a.pem'],
|
|
||||||
pem: 'PEM',
|
|
||||||
baseName: 'www.example.test',
|
|
||||||
cn: 'www.example.test',
|
|
||||||
organization: '',
|
|
||||||
issuerCn: '',
|
|
||||||
notBefore: null,
|
|
||||||
notAfter: null,
|
|
||||||
isExpired: null,
|
|
||||||
daysLeft: null,
|
|
||||||
san: [],
|
|
||||||
keyType: 'RSA',
|
|
||||||
keyBits: 2048,
|
|
||||||
serialNumber: '',
|
|
||||||
sha256: '',
|
|
||||||
matchId: null,
|
|
||||||
chainIds: [],
|
|
||||||
formats: [],
|
|
||||||
...over,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const LEAF = item({
|
|
||||||
id: 'leaf',
|
|
||||||
role: 'end-entity',
|
|
||||||
pem: 'LEAF-PEM',
|
|
||||||
issuerCn: 'Test Intermediate CA',
|
|
||||||
notBefore: '2026-01-01T00:00:00.000Z',
|
|
||||||
notAfter: '2027-01-01T00:00:00.000Z',
|
|
||||||
isExpired: false,
|
|
||||||
daysLeft: 200,
|
|
||||||
san: ['www.example.test', 'example.test'],
|
|
||||||
matchId: 'key',
|
|
||||||
chainIds: ['inter'],
|
|
||||||
formats: ['crt', 'cer', 'fullchain', 'p7b', 'pfx'],
|
|
||||||
});
|
|
||||||
const INTER = item({
|
|
||||||
id: 'inter',
|
|
||||||
role: 'intermediate',
|
|
||||||
pem: 'INTER-PEM',
|
|
||||||
cn: 'Test Intermediate CA',
|
|
||||||
isExpired: false,
|
|
||||||
daysLeft: 900,
|
|
||||||
formats: ['crt', 'cer', 'fullchain', 'p7b', 'pfx'],
|
|
||||||
});
|
|
||||||
const KEY = item({
|
|
||||||
id: 'key',
|
|
||||||
kind: 'privateKey',
|
|
||||||
pem: 'KEY-PEM',
|
|
||||||
matchId: 'leaf',
|
|
||||||
formats: ['key', 'key-rsa', 'key-der'],
|
|
||||||
});
|
|
||||||
const CSR = item({
|
|
||||||
id: 'csr',
|
|
||||||
kind: 'csr',
|
|
||||||
pem: 'CSR-PEM',
|
|
||||||
matchId: 'leaf',
|
|
||||||
formats: ['csr', 'csr-der'],
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
cleanup();
|
|
||||||
vi.clearAllMocks();
|
|
||||||
});
|
|
||||||
|
|
||||||
async function upload(files: File[]) {
|
|
||||||
render(<OverviewTab />);
|
|
||||||
fireEvent.change(screen.getByTestId('overview-file-input'), { target: { files } });
|
|
||||||
await waitFor(() => expect(mockAnalyze).toHaveBeenCalled());
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('OverviewTab', () => {
|
|
||||||
it('analysiert die abgelegten Dateien und zeigt je Teil Typ und Download-Knoepfe', async () => {
|
|
||||||
mockAnalyze.mockResolvedValue({
|
|
||||||
items: [LEAF, INTER, KEY, CSR],
|
|
||||||
locked: [],
|
|
||||||
ignored: ['.dnstxtrecord'],
|
|
||||||
});
|
|
||||||
const zip = new File(['zip'], 'paket.zip');
|
|
||||||
await upload([zip]);
|
|
||||||
|
|
||||||
expect(mockAnalyze).toHaveBeenCalledWith([zip], undefined);
|
|
||||||
const cards = await screen.findAllByTestId('bundle-item');
|
|
||||||
expect(cards).toHaveLength(4);
|
|
||||||
expect(within(cards[0]).getByText('type.end-entity')).toBeInTheDocument();
|
|
||||||
expect(within(cards[1]).getByText('type.intermediate')).toBeInTheDocument();
|
|
||||||
expect(within(cards[2]).getByText('type.privateKey')).toBeInTheDocument();
|
|
||||||
expect(within(cards[3]).getByText('type.csr')).toBeInTheDocument();
|
|
||||||
// Zuordnung und Gueltigkeit
|
|
||||||
expect(within(cards[0]).getByText('www.example.test, example.test')).toBeInTheDocument();
|
|
||||||
expect(within(cards[0]).getByText('valid')).toBeInTheDocument();
|
|
||||||
expect(within(cards[2]).getByText(/type\.end-entity/)).toBeInTheDocument();
|
|
||||||
// alle Formate als Knoepfe
|
|
||||||
expect(
|
|
||||||
within(cards[0])
|
|
||||||
.getAllByRole('button')
|
|
||||||
.map((b) => b.textContent),
|
|
||||||
).toEqual(['format.crt', 'format.cer', 'format.fullchain', 'format.p7b', 'format.pfx']);
|
|
||||||
expect(within(cards[2]).getAllByRole('button')).toHaveLength(3);
|
|
||||||
expect(screen.getByText(/ignored/)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('Download schickt Kette und Schluessel mit und loest die Datei aus', async () => {
|
|
||||||
mockAnalyze.mockResolvedValue({ items: [LEAF, INTER, KEY], locked: [], ignored: [] });
|
|
||||||
mockExport.mockResolvedValue({
|
|
||||||
filename: 'www.example.test-fullchain.pem',
|
|
||||||
content: 'Zm9v',
|
|
||||||
mimeType: 'x',
|
|
||||||
});
|
|
||||||
await upload([new File(['x'], 'a.pem')]);
|
|
||||||
const [leafCard] = await screen.findAllByTestId('bundle-item');
|
|
||||||
|
|
||||||
fireEvent.click(within(leafCard).getByText('format.fullchain'));
|
|
||||||
await waitFor(() =>
|
|
||||||
expect(mockDownload).toHaveBeenCalledWith('www.example.test-fullchain.pem', 'Zm9v', 'x'),
|
|
||||||
);
|
|
||||||
expect(mockExport).toHaveBeenCalledWith({
|
|
||||||
kind: 'certificate',
|
|
||||||
pem: 'LEAF-PEM',
|
|
||||||
format: 'fullchain',
|
|
||||||
baseName: 'www.example.test',
|
|
||||||
chain: ['INTER-PEM'],
|
|
||||||
keyPem: 'KEY-PEM',
|
|
||||||
password: undefined,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('PFX fragt erst ein Passwort ab', async () => {
|
|
||||||
mockAnalyze.mockResolvedValue({ items: [LEAF, INTER, KEY], locked: [], ignored: [] });
|
|
||||||
mockExport.mockResolvedValue({
|
|
||||||
filename: 'www.example.test.pfx',
|
|
||||||
content: 'Zm9v',
|
|
||||||
mimeType: 'x',
|
|
||||||
});
|
|
||||||
await upload([new File(['x'], 'a.pem')]);
|
|
||||||
const [leafCard] = await screen.findAllByTestId('bundle-item');
|
|
||||||
|
|
||||||
fireEvent.click(within(leafCard).getByText('format.pfx'));
|
|
||||||
expect(mockExport).not.toHaveBeenCalled();
|
|
||||||
expect(within(leafCard).getByText('pfxWithKey')).toBeInTheDocument();
|
|
||||||
const download = within(leafCard).getByText('pfxDownload');
|
|
||||||
expect(download).toBeDisabled();
|
|
||||||
|
|
||||||
fireEvent.change(within(leafCard).getByLabelText('pfxPassword'), {
|
|
||||||
target: { value: 'geheim' },
|
|
||||||
});
|
|
||||||
fireEvent.click(download);
|
|
||||||
await waitFor(() =>
|
|
||||||
expect(mockExport).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({ format: 'pfx', password: 'geheim' }),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('geschuetzte PFX ohne Schluessel im Paket: Passwort eingeben und erneut pruefen', async () => {
|
|
||||||
mockAnalyze.mockResolvedValueOnce({
|
|
||||||
items: [{ ...LEAF, matchId: null }],
|
|
||||||
locked: ['a.pfx'],
|
|
||||||
ignored: [],
|
|
||||||
});
|
|
||||||
const pfx = new File(['x'], 'a.pfx');
|
|
||||||
await upload([pfx]);
|
|
||||||
|
|
||||||
fireEvent.change(await screen.findByLabelText('lockedPassword'), { target: { value: 'pw' } });
|
|
||||||
mockAnalyze.mockResolvedValueOnce({ items: [LEAF, KEY], locked: [], ignored: [] });
|
|
||||||
fireEvent.click(screen.getByText('unlock'));
|
|
||||||
await waitFor(() => expect(mockAnalyze).toHaveBeenLastCalledWith([pfx], 'pw'));
|
|
||||||
expect(await screen.findAllByTestId('bundle-item')).toHaveLength(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('geschuetzte PFX, aber Zertifikat und Schluessel liegen schon vor: ruhiger Hinweis, Passwort optional', async () => {
|
|
||||||
mockAnalyze.mockResolvedValue({ items: [LEAF, INTER, KEY], locked: ['a.pfx'], ignored: [] });
|
|
||||||
await upload([new File(['x'], 'paket.zip')]);
|
|
||||||
|
|
||||||
const notice = await screen.findByTestId('locked-notice');
|
|
||||||
expect(within(notice).getByText(/lockedNotNeeded/)).toBeInTheDocument();
|
|
||||||
expect(within(notice).queryByLabelText('lockedPassword')).not.toBeInTheDocument();
|
|
||||||
fireEvent.click(within(notice).getByText('unlockAnyway'));
|
|
||||||
expect(within(notice).getByLabelText('lockedPassword')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('Fehler beim Pruefen wird angezeigt', async () => {
|
|
||||||
mockAnalyze.mockRejectedValue(new Error('400'));
|
|
||||||
await upload([new File(['x'], 'a.txt')]);
|
|
||||||
expect(await screen.findByText('error')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,402 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useTranslations } from 'next-intl';
|
|
||||||
import { useRef, useState } from 'react';
|
|
||||||
import {
|
|
||||||
analyzeBundleAction,
|
|
||||||
type BundleAnalysis,
|
|
||||||
type BundleExportFormat,
|
|
||||||
type BundleItem,
|
|
||||||
downloadBase64,
|
|
||||||
exportBundleItemAction,
|
|
||||||
} from '../actions';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* OverviewTab (quick-261001-l4q) — Zertifikatspaket vom Aussteller auf einmal
|
|
||||||
* hochladen (mehrere Dateien oder ZIP), sehen, welche Datei was ist, und jedes
|
|
||||||
* Teil in jedem passenden Format herunterladen. Die Erkennung macht der Server
|
|
||||||
* (POST analyze), der Export ebenfalls (POST export) — beides zustandslos.
|
|
||||||
*
|
|
||||||
* T-09-02: Passwoerter leben nur im lokalen Zustand, nie in URL oder Log.
|
|
||||||
*/
|
|
||||||
|
|
||||||
const ACCEPT = '.zip,.pem,.crt,.cer,.der,.pfx,.p12,.p7b,.p7c,.key,.csr';
|
|
||||||
|
|
||||||
const ROLE_STYLES: Record<string, string> = {
|
|
||||||
'end-entity': 'bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300',
|
|
||||||
intermediate: 'bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300',
|
|
||||||
root: 'bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-300',
|
|
||||||
privateKey: 'bg-violet-100 text-violet-800 dark:bg-violet-900/40 dark:text-violet-300',
|
|
||||||
csr: 'bg-muted text-muted-foreground',
|
|
||||||
};
|
|
||||||
|
|
||||||
function typeKey(item: BundleItem): string {
|
|
||||||
return item.kind === 'certificate' ? (item.role ?? 'end-entity') : item.kind;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** In UTC wie im Zertifikat: „bis 23:59:59 UTC“ ist bei uns schon der Folgetag — der Aussteller nennt aber dieses Datum. */
|
|
||||||
function formatDate(iso: string | null): string {
|
|
||||||
if (!iso) return '';
|
|
||||||
return new Date(iso).toLocaleDateString('de-DE', {
|
|
||||||
day: '2-digit',
|
|
||||||
month: '2-digit',
|
|
||||||
year: 'numeric',
|
|
||||||
timeZone: 'UTC',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function OverviewTab() {
|
|
||||||
const t = useTranslations('certManager.overview');
|
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
|
||||||
const [files, setFiles] = useState<File[]>([]);
|
|
||||||
const [password, setPassword] = useState('');
|
|
||||||
const [isDragOver, setIsDragOver] = useState(false);
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [result, setResult] = useState<BundleAnalysis | null>(null);
|
|
||||||
const [unlockOpen, setUnlockOpen] = useState(false);
|
|
||||||
|
|
||||||
async function analyze(next: File[], pw: string) {
|
|
||||||
if (next.length === 0) {
|
|
||||||
setResult(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
setResult(await analyzeBundleAction(next, pw || undefined));
|
|
||||||
} catch {
|
|
||||||
setResult(null);
|
|
||||||
setError(t('error'));
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function addFiles(list: FileList | null) {
|
|
||||||
const added = Array.from(list ?? []);
|
|
||||||
if (added.length === 0) return;
|
|
||||||
const next = [...files, ...added];
|
|
||||||
setFiles(next);
|
|
||||||
void analyze(next, password);
|
|
||||||
}
|
|
||||||
|
|
||||||
function removeFile(index: number) {
|
|
||||||
const next = files.filter((_, i) => i !== index);
|
|
||||||
setFiles(next);
|
|
||||||
void analyze(next, password);
|
|
||||||
}
|
|
||||||
|
|
||||||
function reset() {
|
|
||||||
setFiles([]);
|
|
||||||
setPassword('');
|
|
||||||
setResult(null);
|
|
||||||
setError(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
const byId = Object.fromEntries((result?.items ?? []).map((i) => [i.id, i]));
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
<input
|
|
||||||
ref={inputRef}
|
|
||||||
type="file"
|
|
||||||
multiple
|
|
||||||
accept={ACCEPT}
|
|
||||||
className="hidden"
|
|
||||||
data-testid="overview-file-input"
|
|
||||||
onChange={(e) => {
|
|
||||||
addFiles(e.target.files);
|
|
||||||
e.target.value = '';
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => inputRef.current?.click()}
|
|
||||||
onDragOver={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
setIsDragOver(true);
|
|
||||||
}}
|
|
||||||
onDragLeave={() => setIsDragOver(false)}
|
|
||||||
onDrop={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
setIsDragOver(false);
|
|
||||||
addFiles(e.dataTransfer.files);
|
|
||||||
}}
|
|
||||||
className={`block w-full cursor-pointer rounded-lg border-2 border-dashed p-8 text-center transition-colors ${
|
|
||||||
isDragOver ? 'border-primary bg-primary/5' : 'border-border hover:border-primary/50'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<p className="text-sm text-foreground">{t('dropTitle')}</p>
|
|
||||||
<p className="mt-1 text-xs text-muted-foreground">{t('dropHint')}</p>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{files.length > 0 && (
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
|
||||||
{files.map((file, i) => (
|
|
||||||
<span
|
|
||||||
// biome-ignore lint/suspicious/noArrayIndexKey: dieselbe Datei darf zweimal in der Liste stehen; die Liste aendert sich nur durch Anhaengen/Entfernen
|
|
||||||
key={`${file.name}-${i}`}
|
|
||||||
className="inline-flex items-center gap-1.5 rounded border border-border px-2 py-1 text-xs"
|
|
||||||
>
|
|
||||||
{file.name}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-label={t('removeFile', { name: file.name })}
|
|
||||||
onClick={() => removeFile(i)}
|
|
||||||
className="text-muted-foreground hover:text-foreground"
|
|
||||||
>
|
|
||||||
✕
|
|
||||||
</button>
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
<button type="button" onClick={reset} className="text-xs text-muted-foreground underline">
|
|
||||||
{t('reset')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{loading && <p className="text-sm text-muted-foreground">{t('analyzing')}</p>}
|
|
||||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
|
||||||
|
|
||||||
{result && result.locked.length > 0 && (
|
|
||||||
<LockedNotice
|
|
||||||
files={result.locked}
|
|
||||||
// Liegen ein Zertifikat UND sein Schluessel schon einzeln vor,
|
|
||||||
// steckt in der geschuetzten PFX nichts Neues (Aussteller-ZIP vom
|
|
||||||
// 01.10.2026: PFX mit unbekanntem Passwort neben .pem/.key) — dann
|
|
||||||
// nur ein ruhiger Hinweis, das Passwort ist optional.
|
|
||||||
notNeeded={result.items.some((i) => i.kind === 'certificate' && i.matchId !== null)}
|
|
||||||
open={unlockOpen}
|
|
||||||
onOpen={() => setUnlockOpen(true)}
|
|
||||||
password={password}
|
|
||||||
onPassword={setPassword}
|
|
||||||
loading={loading}
|
|
||||||
onUnlock={() => void analyze(files, password)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{result && result.items.length === 0 && !loading && (
|
|
||||||
<p className="text-sm text-muted-foreground">{t('nothingFound')}</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{result && result.items.length > 0 && (
|
|
||||||
<ul className="space-y-4">
|
|
||||||
{result.items.map((item) => (
|
|
||||||
<BundleItemCard key={item.id} item={item} byId={byId} />
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{result && result.ignored.length > 0 && (
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{t('ignored', { files: result.ignored.join(', ') })}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function LockedNotice(props: {
|
|
||||||
files: string[];
|
|
||||||
notNeeded: boolean;
|
|
||||||
open: boolean;
|
|
||||||
onOpen: () => void;
|
|
||||||
password: string;
|
|
||||||
onPassword: (value: string) => void;
|
|
||||||
loading: boolean;
|
|
||||||
onUnlock: () => void;
|
|
||||||
}) {
|
|
||||||
const t = useTranslations('certManager.overview');
|
|
||||||
const showInput = !props.notNeeded || props.open;
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={`space-y-2 rounded-lg border p-4 ${
|
|
||||||
props.notNeeded ? 'border-border bg-muted/30' : 'border-status-warn/50 bg-status-warn/10'
|
|
||||||
}`}
|
|
||||||
data-testid="locked-notice"
|
|
||||||
>
|
|
||||||
<p className="text-sm text-foreground">
|
|
||||||
{t(props.notNeeded ? 'lockedNotNeeded' : 'locked', { files: props.files.join(', ') })}
|
|
||||||
</p>
|
|
||||||
{!showInput && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={props.onOpen}
|
|
||||||
className="text-xs text-muted-foreground underline"
|
|
||||||
>
|
|
||||||
{t('unlockAnyway')}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{showInput && (
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
value={props.password}
|
|
||||||
onChange={(e) => props.onPassword(e.target.value)}
|
|
||||||
placeholder={t('lockedPassword')}
|
|
||||||
aria-label={t('lockedPassword')}
|
|
||||||
autoComplete="off"
|
|
||||||
className="w-64 rounded border border-border bg-background px-3 py-1.5 text-sm"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn btn-secondary"
|
|
||||||
disabled={!props.password || props.loading}
|
|
||||||
onClick={props.onUnlock}
|
|
||||||
>
|
|
||||||
{t('unlock')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function BundleItemCard({ item, byId }: { item: BundleItem; byId: Record<string, BundleItem> }) {
|
|
||||||
const t = useTranslations('certManager.overview');
|
|
||||||
const [pfxOpen, setPfxOpen] = useState(false);
|
|
||||||
const [pfxPassword, setPfxPassword] = useState('');
|
|
||||||
const [busy, setBusy] = useState<BundleExportFormat | null>(null);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const match = item.matchId ? byId[item.matchId] : undefined;
|
|
||||||
const chain = item.chainIds.map((id) => byId[id]).filter(Boolean);
|
|
||||||
|
|
||||||
async function download(format: BundleExportFormat, password?: string) {
|
|
||||||
setBusy(format);
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
const file = await exportBundleItemAction({
|
|
||||||
kind: item.kind,
|
|
||||||
pem: item.pem,
|
|
||||||
format,
|
|
||||||
baseName: item.baseName,
|
|
||||||
chain: item.kind === 'certificate' ? chain.map((c) => c.pem) : undefined,
|
|
||||||
keyPem: item.kind === 'certificate' && match ? match.pem : undefined,
|
|
||||||
password,
|
|
||||||
});
|
|
||||||
downloadBase64(file.filename, file.content, file.mimeType);
|
|
||||||
if (format === 'pfx') {
|
|
||||||
setPfxOpen(false);
|
|
||||||
setPfxPassword('');
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
setError(t('exportError'));
|
|
||||||
} finally {
|
|
||||||
setBusy(null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const status =
|
|
||||||
item.isExpired === null
|
|
||||||
? null
|
|
||||||
: item.isExpired
|
|
||||||
? { cls: 'bg-status-down/15 text-status-down-fg', text: t('expired') }
|
|
||||||
: (item.daysLeft ?? 0) <= 30
|
|
||||||
? {
|
|
||||||
cls: 'bg-status-warn/15 text-status-warn-fg',
|
|
||||||
text: t('expiresSoon', { days: item.daysLeft ?? 0 }),
|
|
||||||
}
|
|
||||||
: { cls: 'bg-status-ok/15 text-status-ok-fg', text: t('valid') };
|
|
||||||
|
|
||||||
return (
|
|
||||||
<li className="rounded-lg border border-border p-4" data-testid="bundle-item">
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
|
||||||
<span className={`rounded px-2 py-0.5 text-xs font-semibold ${ROLE_STYLES[typeKey(item)]}`}>
|
|
||||||
{t(`type.${typeKey(item)}`)}
|
|
||||||
</span>
|
|
||||||
<span className="font-medium break-all">{item.cn || item.baseName}</span>
|
|
||||||
{status && (
|
|
||||||
<span className={`rounded px-2 py-0.5 text-xs font-medium ${status.cls}`}>
|
|
||||||
{status.text}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<p className="mt-1 text-xs text-muted-foreground">{t(`explain.${typeKey(item)}`)}</p>
|
|
||||||
|
|
||||||
<dl className="mt-3 grid grid-cols-[max-content_1fr] gap-x-4 gap-y-1 text-sm">
|
|
||||||
{item.kind === 'certificate' && (
|
|
||||||
<>
|
|
||||||
<dt className="text-muted-foreground">{t('issuer')}</dt>
|
|
||||||
<dd className="break-all">{item.issuerCn}</dd>
|
|
||||||
<dt className="text-muted-foreground">{t('validity')}</dt>
|
|
||||||
<dd>
|
|
||||||
{formatDate(item.notBefore)} – {formatDate(item.notAfter)}
|
|
||||||
</dd>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{item.san.length > 0 && (
|
|
||||||
<>
|
|
||||||
<dt className="text-muted-foreground">{t('names')}</dt>
|
|
||||||
<dd className="break-all">{item.san.join(', ')}</dd>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{item.keyType && (
|
|
||||||
<>
|
|
||||||
<dt className="text-muted-foreground">{t('key')}</dt>
|
|
||||||
<dd>{item.keyBits > 0 ? `${item.keyType} ${item.keyBits} Bit` : item.keyType}</dd>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{match && (
|
|
||||||
<>
|
|
||||||
<dt className="text-muted-foreground">{t('belongsTo')}</dt>
|
|
||||||
<dd className="break-all">
|
|
||||||
{t(`type.${typeKey(match)}`)} {match.cn}
|
|
||||||
</dd>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<dt className="text-muted-foreground">{t('source')}</dt>
|
|
||||||
<dd className="break-all">{item.sources.join(', ')}</dd>
|
|
||||||
</dl>
|
|
||||||
|
|
||||||
{item.kind === 'privateKey' && (
|
|
||||||
<p className="mt-2 text-xs text-status-warn-fg">{t('keySecret')}</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="mt-4 flex flex-wrap gap-2">
|
|
||||||
{item.formats.map((format) => (
|
|
||||||
<button
|
|
||||||
key={format}
|
|
||||||
type="button"
|
|
||||||
disabled={busy !== null}
|
|
||||||
onClick={() => (format === 'pfx' ? setPfxOpen((o) => !o) : void download(format))}
|
|
||||||
className="rounded border border-border px-3 py-1.5 text-xs font-medium transition-colors hover:bg-secondary disabled:opacity-50"
|
|
||||||
title={t(`formatHint.${format}`)}
|
|
||||||
>
|
|
||||||
{busy === format ? t('downloading') : t(`format.${format}`)}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{pfxOpen && (
|
|
||||||
<div className="mt-3 space-y-2 rounded border border-border p-3">
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{match ? t('pfxWithKey') : t('pfxWithoutKey')}
|
|
||||||
</p>
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
value={pfxPassword}
|
|
||||||
onChange={(e) => setPfxPassword(e.target.value)}
|
|
||||||
placeholder={t('pfxPassword')}
|
|
||||||
aria-label={t('pfxPassword')}
|
|
||||||
autoComplete="new-password"
|
|
||||||
className="w-64 rounded border border-border bg-background px-3 py-1.5 text-sm"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="btn btn-primary"
|
|
||||||
disabled={!pfxPassword || busy !== null}
|
|
||||||
onClick={() => void download('pfx', pfxPassword)}
|
|
||||||
>
|
|
||||||
{busy === 'pfx' ? t('downloading') : t('pfxDownload')}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{error && <p className="mt-2 text-sm text-destructive">{error}</p>}
|
|
||||||
</li>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,79 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState } from 'react';
|
|
||||||
import { useTranslations } from 'next-intl';
|
|
||||||
|
|
||||||
interface PasswordFieldProps {
|
|
||||||
value: string;
|
|
||||||
onChange: (value: string) => void;
|
|
||||||
/** When false, the component renders nothing (T-09-02: no DOM reflow) */
|
|
||||||
show: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function PasswordField({ value, onChange, show }: PasswordFieldProps) {
|
|
||||||
const t = useTranslations('certManager');
|
|
||||||
const [revealed, setRevealed] = useState(false);
|
|
||||||
|
|
||||||
if (!show) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-1">
|
|
||||||
<label htmlFor="cert-manager-password" className="block text-sm text-foreground">
|
|
||||||
{t('password.label')}
|
|
||||||
</label>
|
|
||||||
<div className="relative flex items-center">
|
|
||||||
<input
|
|
||||||
id="cert-manager-password"
|
|
||||||
type={revealed ? 'text' : 'password'}
|
|
||||||
value={value}
|
|
||||||
onChange={(e) => onChange(e.target.value)}
|
|
||||||
className="w-full rounded border border-border bg-background px-3 py-2 text-sm text-foreground pr-10 focus:outline-none focus:ring-2 focus:ring-ring"
|
|
||||||
autoComplete="off"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setRevealed((prev) => !prev)}
|
|
||||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
|
|
||||||
aria-label={revealed ? 'Passwort verbergen' : 'Passwort anzeigen'}
|
|
||||||
>
|
|
||||||
{revealed ? (
|
|
||||||
/* Eye-off inline SVG */
|
|
||||||
<svg
|
|
||||||
aria-hidden="true"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
width="16"
|
|
||||||
height="16"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
>
|
|
||||||
<path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94" />
|
|
||||||
<path d="M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19" />
|
|
||||||
<line x1="1" y1="1" x2="23" y2="23" />
|
|
||||||
</svg>
|
|
||||||
) : (
|
|
||||||
/* Eye inline SVG */
|
|
||||||
<svg
|
|
||||||
aria-hidden="true"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
width="16"
|
|
||||||
height="16"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
strokeWidth="2"
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
>
|
|
||||||
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
|
|
||||||
<circle cx="12" cy="12" r="3" />
|
|
||||||
</svg>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,145 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useState } from 'react';
|
|
||||||
import { useTranslations } from 'next-intl';
|
|
||||||
import { zipSync } from 'fflate';
|
|
||||||
import { type SplitResponse, type CertRole, splitCertsAction, downloadBase64 } from '../actions';
|
|
||||||
import { sanitizeZipFilename } from '../zip-filename';
|
|
||||||
|
|
||||||
interface SplitTabProps {
|
|
||||||
file: File | null;
|
|
||||||
pemText: string;
|
|
||||||
password: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ROLE_STYLES: Record<CertRole, string> = {
|
|
||||||
'root': 'bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-300',
|
|
||||||
'intermediate': 'bg-amber-100 text-amber-800 dark:bg-amber-900/40 dark:text-amber-300',
|
|
||||||
'end-entity': 'bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300',
|
|
||||||
};
|
|
||||||
|
|
||||||
// `downloadAllAsZip` liegt ausserhalb der Komponente und kann den
|
|
||||||
// Uebersetzungs-Hook nicht selbst aufrufen — der uebersetzte Name kommt
|
|
||||||
// deshalb als Parameter herein (Restposten 1, quick-260921-jt4). Die
|
|
||||||
// einzelnen Dateinamen IM Archiv stammen weiterhin unveraendert aus der API.
|
|
||||||
function downloadAllAsZip(certs: SplitResponse['certs'], zipFilename: string) {
|
|
||||||
const files: Record<string, Uint8Array> = {};
|
|
||||||
for (const cert of certs) {
|
|
||||||
const bytes = Uint8Array.from(atob(cert.content), (c) => c.charCodeAt(0));
|
|
||||||
// Deduplicate filenames (fflate overwrites silently otherwise)
|
|
||||||
let name = cert.filename;
|
|
||||||
let n = 1;
|
|
||||||
while (name in files) {
|
|
||||||
const base = cert.filename.replace(/\.pem$/, '');
|
|
||||||
name = `${base}-${++n}.pem`;
|
|
||||||
}
|
|
||||||
files[name] = bytes;
|
|
||||||
}
|
|
||||||
const zipped = zipSync(files);
|
|
||||||
const blob = new Blob([zipped], { type: 'application/zip' });
|
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
const a = document.createElement('a');
|
|
||||||
a.href = url;
|
|
||||||
// T-JT4-05: der uebersetzte Name kann Zeichen tragen, die Windows
|
|
||||||
// verbietet (Umlaute, Sonderzeichen) — sanitizeZipFilename schneidet ihn
|
|
||||||
// auf das fuer eine Windows-Freigabe Zulaessige zurueck, unabhaengig davon,
|
|
||||||
// ob der aktuelle Katalogwert zufaellig schon harmlos ist.
|
|
||||||
a.download = sanitizeZipFilename(zipFilename);
|
|
||||||
a.click();
|
|
||||||
URL.revokeObjectURL(url);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SplitTab({ file, pemText: _pemText, password: _password }: SplitTabProps) {
|
|
||||||
const t = useTranslations('certManager');
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [result, setResult] = useState<SplitResponse | null>(null);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
async function handleSplit() {
|
|
||||||
if (!file) return;
|
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
setResult(null);
|
|
||||||
try {
|
|
||||||
const response = await splitCertsAction(file);
|
|
||||||
setResult(response);
|
|
||||||
} catch (err) {
|
|
||||||
const msg = err instanceof Error ? err.message.toLowerCase() : '';
|
|
||||||
if (msg.includes('format') || msg.includes('invalid') || msg.includes('unknown')) {
|
|
||||||
setError(t('error.unknownFormat'));
|
|
||||||
} else {
|
|
||||||
setError(t('error.generic'));
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleSplit}
|
|
||||||
disabled={loading || !file}
|
|
||||||
className="btn btn-primary"
|
|
||||||
>
|
|
||||||
{loading ? t('actions.processing') : t('actions.split')}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{result && result.certs.length > 1 && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => downloadAllAsZip(result.certs, t('actions.zipFilename'))}
|
|
||||||
className="border border-border px-4 py-2 rounded text-sm font-medium hover:bg-secondary transition-colors"
|
|
||||||
>
|
|
||||||
{t('actions.downloadZip')}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
|
||||||
|
|
||||||
{!result && !error && (
|
|
||||||
<div className="text-center py-8 space-y-1">
|
|
||||||
<p className="text-sm font-medium text-foreground">{t('emptyState.split')}</p>
|
|
||||||
<p className="text-sm text-muted-foreground">{t('emptyState.splitBody')}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{result && (
|
|
||||||
<ul className="space-y-2">
|
|
||||||
{result.certs.map((cert) => (
|
|
||||||
<li
|
|
||||||
key={cert.index}
|
|
||||||
className="flex items-center justify-between rounded bg-secondary px-3 py-2 text-sm gap-3"
|
|
||||||
>
|
|
||||||
<div className="min-w-0 flex-1 flex items-center gap-2">
|
|
||||||
<span
|
|
||||||
className={`shrink-0 inline-block rounded px-2 py-0.5 text-xs font-semibold ${ROLE_STYLES[cert.certRole ?? 'end-entity']}`}
|
|
||||||
>
|
|
||||||
{t(`certRole.${cert.certRole ?? 'end-entity'}`)}
|
|
||||||
</span>
|
|
||||||
<span className="font-medium truncate">
|
|
||||||
{cert.subject.cn || cert.filename}
|
|
||||||
</span>
|
|
||||||
<span className="text-muted-foreground text-xs shrink-0">
|
|
||||||
{cert.validity.notAfter}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() =>
|
|
||||||
downloadBase64(cert.filename, cert.content, 'application/x-pem-file')
|
|
||||||
}
|
|
||||||
className="shrink-0 border border-border px-3 py-1 rounded text-xs font-medium hover:bg-background transition-colors"
|
|
||||||
>
|
|
||||||
{t('actions.download')}
|
|
||||||
</button>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,160 +1,38 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { PageHeader } from '@/components/layout/page-header';
|
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { DropZone } from './components/DropZone';
|
import { TabBar } from '@/components/accounting/tab-bar';
|
||||||
import { PasswordField } from './components/PasswordField';
|
import { PageHeader } from '@/components/layout/page-header';
|
||||||
import { InspectTab } from './components/InspectTab';
|
import { FilesTab } from './components/FilesTab';
|
||||||
import { SplitTab } from './components/SplitTab';
|
import { useCertWorkspace } from './use-cert-workspace';
|
||||||
import { MergeTab } from './components/MergeTab';
|
|
||||||
import { ConvertTab } from './components/ConvertTab';
|
|
||||||
import { OverviewTab } from './components/OverviewTab';
|
|
||||||
|
|
||||||
type TabId = 'overview' | 'inspect' | 'split' | 'merge' | 'convert';
|
type TabId = 'files';
|
||||||
|
|
||||||
const TABS: TabId[] = ['overview', 'inspect', 'split', 'merge', 'convert'];
|
|
||||||
|
|
||||||
/** Returns true if the selected file is a PFX/P12 (requires decryption password) */
|
|
||||||
function isPfxFile(file: File | null): boolean {
|
|
||||||
if (!file) return false;
|
|
||||||
const name = file.name.toLowerCase();
|
|
||||||
return name.endsWith('.pfx') || name.endsWith('.p12');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* CertManagerPage — tab-based shell for certificate operations.
|
* CertManagerPage: Zertifikat-Manager rund um einen gemeinsamen Arbeitsbereich (quick-261009-ikt).
|
||||||
* quick-261001-l4q: erster Reiter „Übersicht“ fuer ganze Zertifikatspakete.
|
* Der erste Reiter „Dateien“ sammelt alles; die weiteren Reiter (ab Task 2) arbeiten auf dieser Liste.
|
||||||
* Layout per UI-SPEC: max-w-4xl, shared input card, tab nav, tab content card.
|
* Die Liste lebt nur im Arbeitsspeicher dieses Browserfensters; Reiterwechsel behaelt sie.
|
||||||
* T-09-02: password lives in local React state only; never logged or placed in URLs.
|
|
||||||
* T-09-04: all API calls via postForm() which sends credentials:'include'.
|
|
||||||
*
|
|
||||||
* The shared PasswordField is shown when:
|
|
||||||
* 1. A PFX/P12 file is selected in the shared DropZone, OR
|
|
||||||
* 2. The active tab (Merge or Convert) has PFX chosen as the output format.
|
|
||||||
*/
|
*/
|
||||||
export default function CertManagerPage() {
|
export default function CertManagerPage() {
|
||||||
const t = useTranslations('certManager');
|
const t = useTranslations('certManager');
|
||||||
|
const workspace = useCertWorkspace();
|
||||||
|
const [activeTab, setActiveTab] = useState<TabId>('files');
|
||||||
|
|
||||||
const [activeTab, setActiveTab] = useState<TabId>('overview');
|
const count = workspace.entries.length;
|
||||||
const [file, setFile] = useState<File | null>(null);
|
const tabs: { id: TabId; label: string }[] = [
|
||||||
const [pemText, setPemText] = useState('');
|
{
|
||||||
const [password, setPassword] = useState('');
|
id: 'files',
|
||||||
|
label: count > 0 ? t('tabs.filesWithCount', { count }) : t('tabs.files'),
|
||||||
// Track the output format of the active Merge and Convert tabs
|
},
|
||||||
// so the shared PasswordField can be shown when PFX output is chosen.
|
];
|
||||||
const [mergeOutputFormat, setMergeOutputFormat] = useState('pem');
|
|
||||||
const [convertOutputFormat, setConvertOutputFormat] = useState('pem');
|
|
||||||
|
|
||||||
const showPassword =
|
|
||||||
isPfxFile(file) ||
|
|
||||||
(activeTab === 'merge' && mergeOutputFormat === 'pfx') ||
|
|
||||||
(activeTab === 'convert' && convertOutputFormat === 'pfx');
|
|
||||||
|
|
||||||
const handleFile = (selected: File) => {
|
|
||||||
setFile(selected);
|
|
||||||
setPemText(''); // Single active source: file XOR paste
|
|
||||||
setPassword('');
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleClearFile = () => {
|
|
||||||
setFile(null);
|
|
||||||
setPassword('');
|
|
||||||
};
|
|
||||||
|
|
||||||
const handlePemChange = (text: string) => {
|
|
||||||
setPemText(text);
|
|
||||||
if (text) {
|
|
||||||
setFile(null); // Single active source: file XOR paste
|
|
||||||
setPassword('');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleTabChange = (tab: TabId) => {
|
|
||||||
setActiveTab(tab);
|
|
||||||
// Tab switch: keep shared input, clear result (result cleared per tab re-render)
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderActiveTab = () => {
|
|
||||||
switch (activeTab) {
|
|
||||||
case 'overview':
|
|
||||||
return <OverviewTab />;
|
|
||||||
case 'inspect':
|
|
||||||
return <InspectTab file={file} pemText={pemText} password={password} />;
|
|
||||||
case 'split':
|
|
||||||
return <SplitTab file={file} pemText={pemText} password={password} />;
|
|
||||||
case 'merge':
|
|
||||||
return <MergeTab password={password} onOutputFormatChange={setMergeOutputFormat} />;
|
|
||||||
case 'convert':
|
|
||||||
return (
|
|
||||||
<ConvertTab
|
|
||||||
file={file}
|
|
||||||
pemText={pemText}
|
|
||||||
password={password}
|
|
||||||
onTargetFormatChange={setConvertOutputFormat}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-4xl space-y-6 p-3 sm:p-6">
|
<div className="mx-auto max-w-4xl space-y-6 p-3 sm:p-6">
|
||||||
<PageHeader moduleSlug="cert-manager" title={t('title')} description={t('description')} />
|
<PageHeader moduleSlug="cert-manager" title={t('title')} description={t('description')} />
|
||||||
|
<TabBar tabs={tabs} active={activeTab} onChange={setActiveTab} />
|
||||||
{/* Tab navigation */}
|
<div className="rounded-lg bg-card p-6 shadow-sm dark:border dark:border-border">
|
||||||
<nav className="flex gap-6 overflow-x-auto border-b border-border">
|
{activeTab === 'files' && <FilesTab workspace={workspace} />}
|
||||||
{TABS.map((tab) => (
|
|
||||||
<button
|
|
||||||
key={tab}
|
|
||||||
type="button"
|
|
||||||
onClick={() => handleTabChange(tab)}
|
|
||||||
className={`pb-2 text-sm font-medium transition-colors ${
|
|
||||||
activeTab === tab
|
|
||||||
? 'border-b-2 border-primary-strong font-semibold text-foreground'
|
|
||||||
: 'text-muted-foreground hover:text-foreground'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{t(`tabs.${tab}`)}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
{/* Shared input card — nur fuer die Einzeldatei-Werkzeuge; die Uebersicht
|
|
||||||
(quick-261001-l4q) hat ihre eigene Mehrfach-Ablage. */}
|
|
||||||
{activeTab !== 'overview' && (
|
|
||||||
<div className="rounded-lg bg-card dark:border dark:border-border p-6 shadow-sm space-y-4">
|
|
||||||
{/* DropZone */}
|
|
||||||
<DropZone
|
|
||||||
onFile={handleFile}
|
|
||||||
accept=".pem,.crt,.cer,.der,.pfx,.p12,.p7b,.p7c"
|
|
||||||
currentFile={file}
|
|
||||||
onClear={handleClearFile}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* ODER divider */}
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<hr className="flex-1 border-border" />
|
|
||||||
<span className="text-xs text-muted-foreground">{t('or')}</span>
|
|
||||||
<hr className="flex-1 border-border" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* PEM textarea */}
|
|
||||||
<textarea
|
|
||||||
value={pemText}
|
|
||||||
onChange={(e) => handlePemChange(e.target.value)}
|
|
||||||
placeholder={t('paste.placeholder')}
|
|
||||||
rows={4}
|
|
||||||
className="w-full rounded border border-border bg-background px-3 py-2 text-sm text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring resize-y"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Conditional password field (T-09-02) */}
|
|
||||||
<PasswordField value={password} onChange={setPassword} show={showPassword} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Tab content card */}
|
|
||||||
<div className="rounded-lg bg-card dark:border dark:border-border p-6 shadow-sm">
|
|
||||||
{renderActiveTab()}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useCallback, useRef, useState } from 'react';
|
||||||
|
import { type AnalysisResult, analyzeWorkingSet, certErrorKey } from './actions';
|
||||||
|
import {
|
||||||
|
addFiles as addToSet,
|
||||||
|
type RejectedFile,
|
||||||
|
removeEntry,
|
||||||
|
type WorkingEntry,
|
||||||
|
} from './working-set';
|
||||||
|
|
||||||
|
export type AnalysisStatus = 'idle' | 'analyzing' | 'error';
|
||||||
|
|
||||||
|
export interface CertWorkspace {
|
||||||
|
entries: WorkingEntry[];
|
||||||
|
/** Antwort der letzten Analyse; null bei leerem Arbeitsbereich */
|
||||||
|
analysis: AnalysisResult | null;
|
||||||
|
/** Eintrags-Kennungen zum Zeitpunkt dieser Analyse: Quelle `file` zeigt auf diesen Index */
|
||||||
|
analysisIds: string[];
|
||||||
|
status: AnalysisStatus;
|
||||||
|
/** Schluessel unter certManager.errors.* */
|
||||||
|
errorKey: string | null;
|
||||||
|
addFiles: (files: File[]) => RejectedFile[];
|
||||||
|
remove: (id: string) => void;
|
||||||
|
clear: () => void;
|
||||||
|
retry: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Der gemeinsame Arbeitsbereich (D-01, D-23): lebt nur in diesem Browserfenster. Nach jeder
|
||||||
|
* Aenderung wird der ganze Bereich neu analysiert; Antworten aelterer Anfragen werden verworfen.
|
||||||
|
*/
|
||||||
|
export function useCertWorkspace(): CertWorkspace {
|
||||||
|
const [entries, setEntriesState] = useState<WorkingEntry[]>([]);
|
||||||
|
const [analysis, setAnalysis] = useState<AnalysisResult | null>(null);
|
||||||
|
const [analysisIds, setAnalysisIds] = useState<string[]>([]);
|
||||||
|
const [status, setStatus] = useState<AnalysisStatus>('idle');
|
||||||
|
const [errorKey, setErrorKey] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const entriesRef = useRef<WorkingEntry[]>([]);
|
||||||
|
const requestCounter = useRef(0);
|
||||||
|
const idCounter = useRef(0);
|
||||||
|
|
||||||
|
const runAnalysis = useCallback((list: WorkingEntry[]) => {
|
||||||
|
const mine = ++requestCounter.current;
|
||||||
|
if (list.length === 0) {
|
||||||
|
setAnalysis(null);
|
||||||
|
setAnalysisIds([]);
|
||||||
|
setStatus('idle');
|
||||||
|
setErrorKey(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setStatus('analyzing');
|
||||||
|
setErrorKey(null);
|
||||||
|
const ids = list.map((e) => e.id);
|
||||||
|
analyzeWorkingSet(list).then(
|
||||||
|
(result) => {
|
||||||
|
if (mine !== requestCounter.current) return;
|
||||||
|
setAnalysis(result);
|
||||||
|
setAnalysisIds(ids);
|
||||||
|
setStatus('idle');
|
||||||
|
},
|
||||||
|
(error: unknown) => {
|
||||||
|
if (mine !== requestCounter.current) return;
|
||||||
|
setErrorKey(certErrorKey(error));
|
||||||
|
setStatus('error');
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const commit = useCallback(
|
||||||
|
(next: WorkingEntry[]) => {
|
||||||
|
entriesRef.current = next;
|
||||||
|
setEntriesState(next);
|
||||||
|
runAnalysis(next);
|
||||||
|
},
|
||||||
|
[runAnalysis],
|
||||||
|
);
|
||||||
|
|
||||||
|
const addFiles = useCallback(
|
||||||
|
(files: File[]): RejectedFile[] => {
|
||||||
|
const result = addToSet(entriesRef.current, files, () => `entry-${++idCounter.current}`);
|
||||||
|
if (result.entries.length !== entriesRef.current.length) commit(result.entries);
|
||||||
|
return result.rejected;
|
||||||
|
},
|
||||||
|
[commit],
|
||||||
|
);
|
||||||
|
|
||||||
|
const remove = useCallback(
|
||||||
|
(id: string) => {
|
||||||
|
commit(removeEntry(entriesRef.current, id));
|
||||||
|
},
|
||||||
|
[commit],
|
||||||
|
);
|
||||||
|
|
||||||
|
const clear = useCallback(() => {
|
||||||
|
commit([]);
|
||||||
|
}, [commit]);
|
||||||
|
|
||||||
|
const retry = useCallback(() => {
|
||||||
|
runAnalysis(entriesRef.current);
|
||||||
|
}, [runAnalysis]);
|
||||||
|
|
||||||
|
return { entries, analysis, analysisIds, status, errorKey, addFiles, remove, clear, retry };
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import {
|
||||||
|
addFiles,
|
||||||
|
MAX_ENTRIES,
|
||||||
|
MAX_FILE_BYTES,
|
||||||
|
removeEntry,
|
||||||
|
toFormData,
|
||||||
|
type WorkingEntry,
|
||||||
|
} from './working-set';
|
||||||
|
|
||||||
|
let counter = 0;
|
||||||
|
const nextId = () => `e${++counter}`;
|
||||||
|
|
||||||
|
function makeFile(name: string, size = 10, lastModified = 1): File {
|
||||||
|
const file = new File(['x'.repeat(Math.min(size, 20))], name, { lastModified });
|
||||||
|
if (size > 20) Object.defineProperty(file, 'size', { value: size });
|
||||||
|
return file;
|
||||||
|
}
|
||||||
|
|
||||||
|
const NONE: WorkingEntry[] = [];
|
||||||
|
|
||||||
|
describe('addFiles', () => {
|
||||||
|
it('zwei Aufrufe mit je einer Datei ergeben zwei Eintraege in Aufrufreihenfolge', () => {
|
||||||
|
const first = addFiles(NONE, [makeFile('a.pem')], nextId);
|
||||||
|
const second = addFiles(first.entries, [makeFile('b.pem')], nextId);
|
||||||
|
expect(second.entries.map((e) => e.label)).toEqual(['a.pem', 'b.pem']);
|
||||||
|
expect(second.rejected).toEqual([]);
|
||||||
|
expect(second.entries[0].origin).toBe('upload');
|
||||||
|
expect(second.entries[0].password).toBe('');
|
||||||
|
expect(second.entries[0].host).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('eine zweite Auswahl ersetzt die erste nie', () => {
|
||||||
|
const first = addFiles(NONE, [makeFile('a.pem'), makeFile('b.pem')], nextId);
|
||||||
|
const second = addFiles(first.entries, [makeFile('c.pem')], nextId);
|
||||||
|
expect(second.entries.map((e) => e.label)).toEqual(['a.pem', 'b.pem', 'c.pem']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dieselbe Datei (Name, Groesse, Datum) wird als Doppelte abgelehnt', () => {
|
||||||
|
const first = addFiles(NONE, [makeFile('a.pem', 10, 5)], nextId);
|
||||||
|
const again = addFiles(first.entries, [makeFile('a.pem', 10, 5)], nextId);
|
||||||
|
expect(again.entries).toHaveLength(1);
|
||||||
|
expect(again.rejected).toEqual([{ name: 'a.pem', reason: 'duplicate' }]);
|
||||||
|
const other = addFiles(first.entries, [makeFile('a.pem', 10, 6)], nextId);
|
||||||
|
expect(other.entries).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('die 31. Datei wird abgelehnt', () => {
|
||||||
|
const files = Array.from({ length: MAX_ENTRIES + 1 }, (_, i) => makeFile(`f${i}.pem`, 10, i));
|
||||||
|
const result = addFiles(NONE, files, nextId);
|
||||||
|
expect(result.entries).toHaveLength(MAX_ENTRIES);
|
||||||
|
expect(result.rejected).toEqual([{ name: `f${MAX_ENTRIES}.pem`, reason: 'tooMany' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('eine Datei ueber 5 MB wird abgelehnt', () => {
|
||||||
|
const result = addFiles(NONE, [makeFile('big.zip', MAX_FILE_BYTES + 1)], nextId);
|
||||||
|
expect(result.entries).toEqual([]);
|
||||||
|
expect(result.rejected).toEqual([{ name: 'big.zip', reason: 'tooLarge' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ein Satz ueber 10 MB gesamt wird abgelehnt', () => {
|
||||||
|
const four = MAX_FILE_BYTES - 1000;
|
||||||
|
const first = addFiles(NONE, [makeFile('a.zip', four, 1), makeFile('b.zip', four, 2)], nextId);
|
||||||
|
expect(first.entries).toHaveLength(2);
|
||||||
|
const third = addFiles(first.entries, [makeFile('c.zip', four, 3)], nextId);
|
||||||
|
expect(third.entries).toHaveLength(2);
|
||||||
|
expect(third.rejected).toEqual([{ name: 'c.zip', reason: 'totalTooLarge' }]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('removeEntry', () => {
|
||||||
|
it('entfernt nur den genannten Eintrag und behaelt die Reihenfolge', () => {
|
||||||
|
const { entries } = addFiles(
|
||||||
|
NONE,
|
||||||
|
[makeFile('a.pem'), makeFile('b.pem'), makeFile('c.pem')],
|
||||||
|
nextId,
|
||||||
|
);
|
||||||
|
const after = removeEntry(entries, entries[1].id);
|
||||||
|
expect(after.map((e) => e.label)).toEqual(['a.pem', 'c.pem']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('toFormData', () => {
|
||||||
|
it('haengt die Dateien als `files` in Reihenfolge der Eintraege an', () => {
|
||||||
|
const { entries } = addFiles(NONE, [makeFile('b.pem'), makeFile('a.pem')], nextId);
|
||||||
|
const form = toFormData(entries);
|
||||||
|
const names = form.getAll('files').map((f) => (f as File).name);
|
||||||
|
expect(names).toEqual(['b.pem', 'a.pem']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
/**
|
||||||
|
* Arbeitsbereich des Zertifikat-Managers (quick-261009-ikt, D-01, D-17, D-23).
|
||||||
|
*
|
||||||
|
* Reine Funktionen ohne React: eine Liste von Eintraegen, die nur wachsen oder gezielt
|
||||||
|
* schrumpfen kann. Eine zweite Auswahl haengt an, sie ersetzt nie die erste.
|
||||||
|
* Die Liste lebt nur im Arbeitsspeicher dieses Browserfensters (D-11): nichts davon wird
|
||||||
|
* gespeichert, auch Passwoerter nicht.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const MAX_ENTRIES = 30;
|
||||||
|
export const MAX_FILE_BYTES = 5 * 1024 * 1024;
|
||||||
|
export const MAX_TOTAL_BYTES = 10 * 1024 * 1024;
|
||||||
|
export const MAX_PASTE_CHARS = 256_000;
|
||||||
|
|
||||||
|
export type EntryOrigin = 'upload' | 'paste' | 'fetched';
|
||||||
|
|
||||||
|
export interface WorkingEntry {
|
||||||
|
id: string;
|
||||||
|
file: File;
|
||||||
|
label: string;
|
||||||
|
origin: EntryOrigin;
|
||||||
|
/** Nur fuer nachgeladene Zertifikate: der Server, von dem sie kamen */
|
||||||
|
host: string | null;
|
||||||
|
/** Passwort fuer diese Datei (ab Task 4); bleibt im Arbeitsspeicher */
|
||||||
|
password: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type RejectReason = 'duplicate' | 'tooMany' | 'tooLarge' | 'totalTooLarge';
|
||||||
|
|
||||||
|
export interface RejectedFile {
|
||||||
|
name: string;
|
||||||
|
reason: RejectReason;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AddFilesResult {
|
||||||
|
entries: WorkingEntry[];
|
||||||
|
rejected: RejectedFile[];
|
||||||
|
}
|
||||||
|
|
||||||
|
function sameFile(a: File, b: File): boolean {
|
||||||
|
return a.name === b.name && a.size === b.size && a.lastModified === b.lastModified;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Haengt Dateien an. Abgelehnte Dateien stehen mit Grund in `rejected`, die Liste bleibt sonst unveraendert. */
|
||||||
|
export function addFiles(
|
||||||
|
entries: WorkingEntry[],
|
||||||
|
files: File[],
|
||||||
|
nextId: () => string,
|
||||||
|
): AddFilesResult {
|
||||||
|
const next = [...entries];
|
||||||
|
const rejected: RejectedFile[] = [];
|
||||||
|
let total = next.reduce((sum, e) => sum + e.file.size, 0);
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
if (next.some((e) => e.origin === 'upload' && sameFile(e.file, file))) {
|
||||||
|
rejected.push({ name: file.name, reason: 'duplicate' });
|
||||||
|
} else if (file.size > MAX_FILE_BYTES) {
|
||||||
|
rejected.push({ name: file.name, reason: 'tooLarge' });
|
||||||
|
} else if (next.length >= MAX_ENTRIES) {
|
||||||
|
rejected.push({ name: file.name, reason: 'tooMany' });
|
||||||
|
} else if (total + file.size > MAX_TOTAL_BYTES) {
|
||||||
|
rejected.push({ name: file.name, reason: 'totalTooLarge' });
|
||||||
|
} else {
|
||||||
|
next.push({
|
||||||
|
id: nextId(),
|
||||||
|
file,
|
||||||
|
label: file.name,
|
||||||
|
origin: 'upload',
|
||||||
|
host: null,
|
||||||
|
password: '',
|
||||||
|
});
|
||||||
|
total += file.size;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { entries: next, rejected };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Entfernt einen Eintrag; die Reihenfolge der uebrigen bleibt. */
|
||||||
|
export function removeEntry(entries: WorkingEntry[], id: string): WorkingEntry[] {
|
||||||
|
return entries.filter((e) => e.id !== id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Multipart-Koerper fuer die Analyse: `files` in der Reihenfolge der Eintraege. */
|
||||||
|
export function toFormData(entries: WorkingEntry[]): FormData {
|
||||||
|
const form = new FormData();
|
||||||
|
for (const entry of entries) form.append('files', entry.file, entry.file.name);
|
||||||
|
return form;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatBytes(bytes: number): string {
|
||||||
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1).replace('.', ',')} KB`;
|
||||||
|
return `${(bytes / 1024 / 1024).toFixed(1).replace('.', ',')} MB`;
|
||||||
|
}
|
||||||
+51
-107
@@ -1250,123 +1250,67 @@
|
|||||||
},
|
},
|
||||||
"certManager": {
|
"certManager": {
|
||||||
"title": "Zertifikat-Manager",
|
"title": "Zertifikat-Manager",
|
||||||
"description": "Zertifikatspakete prüfen und in jedes Format bringen; einzelne Zertifikate analysieren, aufteilen, zusammenführen und konvertieren.",
|
"description": "Zertifikate, Zwischenzertifikate, Schlüssel und Anfragen sammeln, prüfen und in das gewünschte Format bringen.",
|
||||||
"tabs": {
|
"tabs": {
|
||||||
"overview": "Übersicht",
|
"files": "Dateien",
|
||||||
"inspect": "Analysieren",
|
"filesWithCount": "Dateien ({count})"
|
||||||
"split": "Aufteilen",
|
|
||||||
"merge": "Zusammenführen",
|
|
||||||
"convert": "Konvertieren"
|
|
||||||
},
|
},
|
||||||
"dropZone": {
|
"roles": {
|
||||||
"placeholder": "Datei hierher ziehen oder klicken",
|
|
||||||
"formats": ".pem, .crt, .cer, .der, .pfx, .p12, .p7b, .p7c"
|
|
||||||
},
|
|
||||||
"paste": {
|
|
||||||
"placeholder": "PEM-Inhalt einfügen (-----BEGIN ...)"
|
|
||||||
},
|
|
||||||
"password": {
|
|
||||||
"label": "Passwort (PFX/P12)"
|
|
||||||
},
|
|
||||||
"or": "oder",
|
|
||||||
"actions": {
|
|
||||||
"inspect": "Analysieren",
|
|
||||||
"split": "Aufteilen",
|
|
||||||
"merge": "Zusammenführen",
|
|
||||||
"convert": "Konvertieren",
|
|
||||||
"download": "Herunterladen",
|
|
||||||
"downloadZip": "Alle als ZIP herunterladen",
|
|
||||||
"processing": "Wird verarbeitet...",
|
|
||||||
"zipFilename": "Zertifikate.zip"
|
|
||||||
},
|
|
||||||
"certRole": {
|
|
||||||
"root": "Root-CA",
|
|
||||||
"intermediate": "Zwischen-CA",
|
|
||||||
"end-entity": "Zertifikat"
|
|
||||||
},
|
|
||||||
"emptyState": {
|
|
||||||
"inspect": "Kein Zertifikat geladen.",
|
|
||||||
"inspectBody": "Laden Sie eine Datei hoch oder fügen Sie PEM-Text ein.",
|
|
||||||
"split": "Keine Datei geladen.",
|
|
||||||
"splitBody": "Laden Sie eine Fullchain- oder P7B-Datei hoch.",
|
|
||||||
"merge": "Keine Zertifikate ausgewählt.",
|
|
||||||
"mergeBody": "Laden Sie mindestens zwei Dateien hoch.",
|
|
||||||
"convert": "Keine Datei geladen.",
|
|
||||||
"convertBody": "Laden Sie eine Datei hoch und wählen Sie ein Ausgabeformat."
|
|
||||||
},
|
|
||||||
"error": {
|
|
||||||
"generic": "Verarbeitung fehlgeschlagen. Prüfen Sie das Dateiformat oder das Passwort.",
|
|
||||||
"wrongPassword": "Falsches Passwort. PFX/P12-Datei konnte nicht entschlüsselt werden.",
|
|
||||||
"unknownFormat": "Unbekanntes Format. Die Datei konnte nicht als Zertifikat erkannt werden."
|
|
||||||
},
|
|
||||||
"overview": {
|
|
||||||
"dropTitle": "Zertifikatsdateien oder ZIP hierher ziehen oder klicken",
|
|
||||||
"dropHint": "Alles auf einmal, so wie es vom Aussteller kommt: .zip, .pem, .crt, .cer, .key, .csr, .pfx, .p12, .p7b",
|
|
||||||
"removeFile": "{name} entfernen",
|
|
||||||
"reset": "Alle entfernen",
|
|
||||||
"analyzing": "Dateien werden geprüft …",
|
|
||||||
"error": "Die Dateien konnten nicht geprüft werden. Bitte prüfen Sie, ob es Zertifikatsdateien sind.",
|
|
||||||
"locked": "Geschützt: {files}. Geben Sie das Passwort ein, um auch diesen Inhalt zu lesen.",
|
|
||||||
"lockedNotNeeded": "{files} ist mit einem Passwort geschützt. Sie brauchen es nicht: Zertifikat und privater Schlüssel liegen schon als einzelne Dateien vor und sind unten aufgeführt. Eine neue PFX-Datei mit eigenem Passwort können Sie beim Serverzertifikat erstellen.",
|
|
||||||
"lockedPassword": "Passwort der geschützten Datei",
|
|
||||||
"unlock": "Entsperren",
|
|
||||||
"unlockAnyway": "Trotzdem mit Passwort öffnen",
|
|
||||||
"nothingFound": "In den Dateien wurde kein Zertifikat, Schlüssel und keine Zertifikatsanfrage gefunden.",
|
|
||||||
"ignored": "Nicht verwendet (kein Zertifikat erkannt): {files}",
|
|
||||||
"type": {
|
|
||||||
"end-entity": "Serverzertifikat",
|
"end-entity": "Serverzertifikat",
|
||||||
"intermediate": "Zwischenzertifikat",
|
"intermediate": "Zwischenzertifikat",
|
||||||
"root": "Stammzertifikat",
|
"root": "Stammzertifikat",
|
||||||
"privateKey": "Privater Schlüssel",
|
"privateKey": "Privater Schlüssel",
|
||||||
"csr": "Zertifikatsanfrage (CSR)"
|
"csr": "Zertifikatsanfrage (CSR)"
|
||||||
},
|
},
|
||||||
"explain": {
|
"files": {
|
||||||
"end-entity": "Das eigentliche Zertifikat für Ihre Domain – das gehört auf den Webserver.",
|
"dropTitle": "Dateien hierher ziehen oder klicken",
|
||||||
"intermediate": "Bestätigt Ihr Serverzertifikat gegenüber dem Browser. Wird zusammen mit dem Serverzertifikat eingespielt (Kette).",
|
"dropHint": "Sie können mehrere Dateien nacheinander hinzufügen, jede bleibt in der Liste. Tessera erkennt den Inhalt selbst, die Dateiendung spielt keine Rolle.",
|
||||||
"root": "Oberste Zertifizierungsstelle. Ist in Browsern und Betriebssystemen meist schon vorhanden.",
|
"limits": "Bis zu {maxFiles} Dateien, je Datei höchstens {maxFile} MB, zusammen höchstens {maxTotal} MB.",
|
||||||
"privateKey": "Der geheime Schlüssel zum Serverzertifikat. Wird auf dem Server gebraucht, darf aber nie weitergegeben werden.",
|
"memoryNote": "Die Dateien bleiben nur in diesem Browserfenster. Tessera speichert nichts davon; nach dem Neuladen oder Schließen der Seite ist die Liste leer.",
|
||||||
"csr": "Die Anfrage, mit der das Zertifikat beim Aussteller bestellt wurde. Wird nur für eine Neuausstellung gebraucht."
|
"empty": "Noch keine Dateien. Fügen Sie Ihre Zertifikate hinzu, danach sehen Sie hier, was darin steckt.",
|
||||||
|
"listTitle": "Ihre Dateien ({count})",
|
||||||
|
"remove": "„{name}“ entfernen",
|
||||||
|
"removeShort": "Entfernen",
|
||||||
|
"removeAll": "Alle entfernen",
|
||||||
|
"analyzing": "Dateien werden geprüft …",
|
||||||
|
"checking": "Wird geprüft …",
|
||||||
|
"retry": "Erneut versuchen",
|
||||||
|
"expired": "abgelaufen",
|
||||||
|
"rejectedTitle": "Nicht hinzugefügt:",
|
||||||
|
"rejected": {
|
||||||
|
"duplicate": "„{name}“ ist schon in der Liste.",
|
||||||
|
"tooMany": "„{name}“: die Liste ist mit {max} Dateien voll.",
|
||||||
|
"tooLarge": "„{name}“ ist größer als {size} MB.",
|
||||||
|
"totalTooLarge": "„{name}“: zusammen wären es mehr als {size} MB."
|
||||||
},
|
},
|
||||||
"issuer": "Ausgestellt von",
|
"ignored": {
|
||||||
"validity": "Gültig",
|
"unknown": "Darin wurde kein Zertifikat erkannt.",
|
||||||
"names": "Gilt für",
|
"nestedZip": "Ein ZIP in einem ZIP wird nicht geöffnet.",
|
||||||
"key": "Schlüssel",
|
"encryptedZip": "Der ZIP-Inhalt ist mit einem Passwort geschützt und wird übersprungen.",
|
||||||
"belongsTo": "Gehört zu",
|
"brokenZip": "Das ZIP lässt sich nicht lesen.",
|
||||||
"source": "Gefunden in",
|
"tooLarge": "Die Datei ist zu groß und wird übersprungen.",
|
||||||
"valid": "Gültig",
|
"suspicious": "Die Datei ist ungewöhnlich stark gepackt und wird übersprungen.",
|
||||||
"expired": "Abgelaufen",
|
"zipTooLarge": "Das ZIP enthält zu viele Daten und wird nicht geöffnet.",
|
||||||
"expiresSoon": "Läuft in {days} Tagen ab",
|
"tooManyEntries": "Das ZIP enthält zu viele Dateien und wird nicht geöffnet.",
|
||||||
"keySecret": "Geheim halten: Wer diesen Schlüssel hat, kann sich als Ihre Website ausgeben.",
|
"unsupportedKey": "Dieser Schlüsseltyp wird nicht unterstützt."
|
||||||
"downloading": "Wird erstellt …",
|
}
|
||||||
"exportError": "Diese Datei konnte nicht erstellt werden.",
|
|
||||||
"format": {
|
|
||||||
"crt": "PEM (.crt)",
|
|
||||||
"cer": "DER (.cer)",
|
|
||||||
"fullchain": "Mit Kette (.pem)",
|
|
||||||
"p7b": "PKCS#7 (.p7b)",
|
|
||||||
"pfx": "PFX (.pfx)",
|
|
||||||
"key": "PEM (.key)",
|
|
||||||
"key-rsa": "RSA-PEM (.rsa.key)",
|
|
||||||
"key-der": "DER (.key.der)",
|
|
||||||
"csr": "PEM (.csr)",
|
|
||||||
"csr-der": "DER (.csr.der)"
|
|
||||||
},
|
},
|
||||||
"formatHint": {
|
"errors": {
|
||||||
"crt": "Textformat, z. B. für Apache, Nginx und die meisten Geräte",
|
"generic": "Die Dateien konnten nicht geprüft werden. Bitte versuchen Sie es erneut.",
|
||||||
"cer": "Binärformat, z. B. für Windows und Java",
|
"invalidInput": "Die Eingabe ist unvollständig oder ungültig. Bitte prüfen Sie Ihre Angaben.",
|
||||||
"fullchain": "Zertifikat und Kette in einer Datei, z. B. für Nginx",
|
"notACertificate": "Mindestens eine Angabe ist kein Zertifikat. Bitte prüfen Sie die Dateien.",
|
||||||
"p7b": "Zertifikat und Kette ohne Schlüssel, z. B. für Windows/IIS",
|
"noChain": "Es gibt keine Kette zum Herunterladen. Fügen Sie das Zwischenzertifikat hinzu.",
|
||||||
"pfx": "Zertifikat, Kette und Schlüssel in einer passwortgeschützten Datei, z. B. für Windows/IIS und Exchange",
|
"keyMissing": "Dafür wird der passende private Schlüssel gebraucht. Fügen Sie ihn im Reiter „Dateien“ hinzu.",
|
||||||
"key": "Schlüssel im Standardformat (PKCS#8)",
|
"keyMismatch": "Der Schlüssel gehört nicht zu diesem Zertifikat.",
|
||||||
"key-rsa": "Schlüssel im älteren RSA-Format (PKCS#1), für ältere Software",
|
"passwordRequired": "Bitte geben Sie ein Passwort an.",
|
||||||
"key-der": "Schlüssel als Binärdatei",
|
"formatNotPossible": "Dieses Format ist für den Schlüsseltyp nicht möglich. Wählen Sie ein anderes Format.",
|
||||||
"csr": "Zertifikatsanfrage als Text",
|
"templateNeedsKey": "Diese Vorlage braucht das Zertifikat und den passenden privaten Schlüssel.",
|
||||||
"csr-der": "Zertifikatsanfrage als Binärdatei"
|
"tooLarge": "Die Dateien sind zusammen zu groß. Entfernen Sie einzelne Dateien und versuchen Sie es erneut.",
|
||||||
},
|
"aiaMissing": "Im Zertifikat steht keine Adresse, unter der das fehlende Zertifikat zu holen wäre.",
|
||||||
"pfxWithKey": "Die PFX-Datei enthält Zertifikat, Kette und privaten Schlüssel. Legen Sie ein Passwort fest – es wird beim Einspielen abgefragt.",
|
"aiaInternal": "Die Adresse im Zertifikat verweist nicht auf einen öffentlichen Server und wird nicht abgerufen.",
|
||||||
"pfxWithoutKey": "Zu diesem Zertifikat liegt kein Schlüssel vor – die PFX-Datei enthält nur Zertifikat und Kette. Legen Sie ein Passwort fest.",
|
"aiaNotIssuer": "Die Adresse lieferte kein passendes Zertifikat des Ausstellers.",
|
||||||
"pfxPassword": "Passwort für die PFX-Datei",
|
"aiaUnreachable": "Der Server des Ausstellers ist nicht erreichbar. Laden Sie das Zertifikat selbst herunter und fügen Sie es hinzu.",
|
||||||
"pfxDownload": "PFX herunterladen"
|
"aiaTooLarge": "Die Antwort des Ausstellers ist zu groß und wurde verworfen."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"tenderRadar": {
|
"tenderRadar": {
|
||||||
|
|||||||
+51
-107
@@ -1250,123 +1250,67 @@
|
|||||||
},
|
},
|
||||||
"certManager": {
|
"certManager": {
|
||||||
"title": "Certificate Manager",
|
"title": "Certificate Manager",
|
||||||
"description": "Check certificate bundles and download them in any format; inspect, split, merge and convert single certificates.",
|
"description": "Collect, check and convert certificates, intermediate certificates, keys and requests into the format you need.",
|
||||||
"tabs": {
|
"tabs": {
|
||||||
"overview": "Overview",
|
"files": "Files",
|
||||||
"inspect": "Inspect",
|
"filesWithCount": "Files ({count})"
|
||||||
"split": "Split",
|
|
||||||
"merge": "Merge",
|
|
||||||
"convert": "Convert"
|
|
||||||
},
|
},
|
||||||
"dropZone": {
|
"roles": {
|
||||||
"placeholder": "Drag file here or click to browse",
|
|
||||||
"formats": ".pem, .crt, .cer, .der, .pfx, .p12, .p7b, .p7c"
|
|
||||||
},
|
|
||||||
"paste": {
|
|
||||||
"placeholder": "Paste PEM content (-----BEGIN ...)"
|
|
||||||
},
|
|
||||||
"password": {
|
|
||||||
"label": "Password (PFX/P12)"
|
|
||||||
},
|
|
||||||
"or": "or",
|
|
||||||
"actions": {
|
|
||||||
"inspect": "Inspect",
|
|
||||||
"split": "Split",
|
|
||||||
"merge": "Merge",
|
|
||||||
"convert": "Convert",
|
|
||||||
"download": "Download",
|
|
||||||
"downloadZip": "Download all as ZIP",
|
|
||||||
"processing": "Processing...",
|
|
||||||
"zipFilename": "certificates.zip"
|
|
||||||
},
|
|
||||||
"certRole": {
|
|
||||||
"root": "Root CA",
|
|
||||||
"intermediate": "Intermediate CA",
|
|
||||||
"end-entity": "Certificate"
|
|
||||||
},
|
|
||||||
"emptyState": {
|
|
||||||
"inspect": "No certificate loaded.",
|
|
||||||
"inspectBody": "Upload a file or paste PEM text.",
|
|
||||||
"split": "No file loaded.",
|
|
||||||
"splitBody": "Upload a fullchain or P7B file.",
|
|
||||||
"merge": "No certificates selected.",
|
|
||||||
"mergeBody": "Upload at least two files.",
|
|
||||||
"convert": "No file loaded.",
|
|
||||||
"convertBody": "Upload a file and select an output format."
|
|
||||||
},
|
|
||||||
"error": {
|
|
||||||
"generic": "Processing failed. Check the file format or password.",
|
|
||||||
"wrongPassword": "Wrong password. Could not decrypt the PFX/P12 file.",
|
|
||||||
"unknownFormat": "Unknown format. The file could not be recognized as a certificate."
|
|
||||||
},
|
|
||||||
"overview": {
|
|
||||||
"dropTitle": "Drop certificate files or a ZIP here, or click",
|
|
||||||
"dropHint": "Everything at once, as delivered by the issuer: .zip, .pem, .crt, .cer, .key, .csr, .pfx, .p12, .p7b",
|
|
||||||
"removeFile": "Remove {name}",
|
|
||||||
"reset": "Remove all",
|
|
||||||
"analyzing": "Checking files …",
|
|
||||||
"error": "The files could not be checked. Please make sure they are certificate files.",
|
|
||||||
"locked": "Protected: {files}. Enter the password to read this content as well.",
|
|
||||||
"lockedNotNeeded": "{files} is password-protected. You do not need it: certificate and private key are already available as separate files and listed below. You can create a new PFX file with your own password at the server certificate.",
|
|
||||||
"lockedPassword": "Password of the protected file",
|
|
||||||
"unlock": "Unlock",
|
|
||||||
"unlockAnyway": "Open with password anyway",
|
|
||||||
"nothingFound": "No certificate, key or certificate request was found in the files.",
|
|
||||||
"ignored": "Not used (no certificate detected): {files}",
|
|
||||||
"type": {
|
|
||||||
"end-entity": "Server certificate",
|
"end-entity": "Server certificate",
|
||||||
"intermediate": "Intermediate certificate",
|
"intermediate": "Intermediate certificate",
|
||||||
"root": "Root certificate",
|
"root": "Root certificate",
|
||||||
"privateKey": "Private key",
|
"privateKey": "Private key",
|
||||||
"csr": "Certificate request (CSR)"
|
"csr": "Certificate request (CSR)"
|
||||||
},
|
},
|
||||||
"explain": {
|
"files": {
|
||||||
"end-entity": "The actual certificate for your domain – it goes on the web server.",
|
"dropTitle": "Drop files here or click",
|
||||||
"intermediate": "Vouches for your server certificate towards the browser. Install it together with the server certificate (chain).",
|
"dropHint": "You can add several files one after another, each one stays in the list. Tessera recognises the content itself, the file extension does not matter.",
|
||||||
"root": "Top-level certificate authority. Usually already present in browsers and operating systems.",
|
"limits": "Up to {maxFiles} files, at most {maxFile} MB per file and {maxTotal} MB in total.",
|
||||||
"privateKey": "The secret key for the server certificate. Needed on the server, but must never be shared.",
|
"memoryNote": "The files stay in this browser window only. Tessera stores none of them; after reloading or closing the page the list is empty.",
|
||||||
"csr": "The request used to order the certificate from the issuer. Only needed for a reissue."
|
"empty": "No files yet. Add your certificates and you will see here what they contain.",
|
||||||
|
"listTitle": "Your files ({count})",
|
||||||
|
"remove": "Remove \"{name}\"",
|
||||||
|
"removeShort": "Remove",
|
||||||
|
"removeAll": "Remove all",
|
||||||
|
"analyzing": "Checking files …",
|
||||||
|
"checking": "Checking …",
|
||||||
|
"retry": "Try again",
|
||||||
|
"expired": "expired",
|
||||||
|
"rejectedTitle": "Not added:",
|
||||||
|
"rejected": {
|
||||||
|
"duplicate": "\"{name}\" is already in the list.",
|
||||||
|
"tooMany": "\"{name}\": the list is full with {max} files.",
|
||||||
|
"tooLarge": "\"{name}\" is larger than {size} MB.",
|
||||||
|
"totalTooLarge": "\"{name}\": together this would be more than {size} MB."
|
||||||
},
|
},
|
||||||
"issuer": "Issued by",
|
"ignored": {
|
||||||
"validity": "Valid",
|
"unknown": "No certificate was recognised in it.",
|
||||||
"names": "Valid for",
|
"nestedZip": "A ZIP inside a ZIP is not opened.",
|
||||||
"key": "Key",
|
"encryptedZip": "The ZIP content is password protected and is skipped.",
|
||||||
"belongsTo": "Belongs to",
|
"brokenZip": "The ZIP cannot be read.",
|
||||||
"source": "Found in",
|
"tooLarge": "The file is too large and is skipped.",
|
||||||
"valid": "Valid",
|
"suspicious": "The file is packed unusually tightly and is skipped.",
|
||||||
"expired": "Expired",
|
"zipTooLarge": "The ZIP contains too much data and is not opened.",
|
||||||
"expiresSoon": "Expires in {days} days",
|
"tooManyEntries": "The ZIP contains too many files and is not opened.",
|
||||||
"keySecret": "Keep secret: whoever has this key can impersonate your website.",
|
"unsupportedKey": "This key type is not supported."
|
||||||
"downloading": "Creating …",
|
}
|
||||||
"exportError": "This file could not be created.",
|
|
||||||
"format": {
|
|
||||||
"crt": "PEM (.crt)",
|
|
||||||
"cer": "DER (.cer)",
|
|
||||||
"fullchain": "With chain (.pem)",
|
|
||||||
"p7b": "PKCS#7 (.p7b)",
|
|
||||||
"pfx": "PFX (.pfx)",
|
|
||||||
"key": "PEM (.key)",
|
|
||||||
"key-rsa": "RSA PEM (.rsa.key)",
|
|
||||||
"key-der": "DER (.key.der)",
|
|
||||||
"csr": "PEM (.csr)",
|
|
||||||
"csr-der": "DER (.csr.der)"
|
|
||||||
},
|
},
|
||||||
"formatHint": {
|
"errors": {
|
||||||
"crt": "Text format, e.g. for Apache, Nginx and most devices",
|
"generic": "The files could not be checked. Please try again.",
|
||||||
"cer": "Binary format, e.g. for Windows and Java",
|
"invalidInput": "The input is incomplete or invalid. Please check your entries.",
|
||||||
"fullchain": "Certificate and chain in one file, e.g. for Nginx",
|
"notACertificate": "At least one entry is not a certificate. Please check the files.",
|
||||||
"p7b": "Certificate and chain without key, e.g. for Windows/IIS",
|
"noChain": "There is no chain to download. Add the intermediate certificate.",
|
||||||
"pfx": "Certificate, chain and key in one password-protected file, e.g. for Windows/IIS and Exchange",
|
"keyMissing": "This needs the matching private key. Add it in the \"Files\" tab.",
|
||||||
"key": "Key in standard format (PKCS#8)",
|
"keyMismatch": "The key does not belong to this certificate.",
|
||||||
"key-rsa": "Key in older RSA format (PKCS#1), for older software",
|
"passwordRequired": "Please enter a password.",
|
||||||
"key-der": "Key as binary file",
|
"formatNotPossible": "This format is not possible for the key type. Choose another format.",
|
||||||
"csr": "Certificate request as text",
|
"templateNeedsKey": "This template needs the certificate and the matching private key.",
|
||||||
"csr-der": "Certificate request as binary file"
|
"tooLarge": "The files are too large together. Remove some files and try again.",
|
||||||
},
|
"aiaMissing": "The certificate names no address from which the missing certificate could be fetched.",
|
||||||
"pfxWithKey": "The PFX file contains certificate, chain and private key. Set a password – it is asked for when importing.",
|
"aiaInternal": "The address in the certificate does not point to a public server and is not contacted.",
|
||||||
"pfxWithoutKey": "No key is available for this certificate – the PFX file contains only certificate and chain. Set a password.",
|
"aiaNotIssuer": "The address did not deliver a matching certificate of the issuer.",
|
||||||
"pfxPassword": "Password for the PFX file",
|
"aiaUnreachable": "The issuer's server cannot be reached. Download the certificate yourself and add it.",
|
||||||
"pfxDownload": "Download PFX"
|
"aiaTooLarge": "The issuer's answer is too large and was discarded."
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"tenderRadar": {
|
"tenderRadar": {
|
||||||
|
|||||||
@@ -243,4 +243,10 @@ export const UMLAUT_ALLOWLIST: readonly string[] = [
|
|||||||
'Suchergebnisse',
|
'Suchergebnisse',
|
||||||
// quick-261009-dkv (Aufgabe 2): Links und Übersichten — korrektes Deutsch mit „ss“
|
// quick-261009-dkv (Aufgabe 2): Links und Übersichten — korrektes Deutsch mit „ss“
|
||||||
'verlassen',
|
'verlassen',
|
||||||
|
// quick-261009-ikt: Zertifikat-Manager — korrektes Deutsch mit „ss“
|
||||||
|
'Schlüsseltyp',
|
||||||
|
'passende',
|
||||||
|
'passenden',
|
||||||
|
'passendes',
|
||||||
|
'Ausstellers',
|
||||||
];
|
];
|
||||||
|
|||||||
Reference in New Issue
Block a user