fix(cert-manager): ZIP-Bombe, PEM-Scanner und Umlaut-Passwörter in PFX behoben

- ZIP: eigenes Entpacken mit hartem Deckel (maxOutputLength), Größe, Verhältnis und
  CRC am echten Ergebnis; eine gefälschte Kopfgröße 0 umgeht keine Grenze mehr (CR-01)
- PEM: linearer Scanner statt Regex mit quadratischer Laufzeit, gemeinsam für
  Erkennung und Abruf (CR-02)
- PKCS#12: PBES2/AES leitet das Passwort aus UTF-8 ab, Lesen und Schreiben, mit
  OpenSSL-Gegenprobe für „pässwörd“ und „pw€“ (CR-03)
- Grenzen je Anfrage: höchstens 200 Zertifikate, 50 Schlüssel, 50 Anfragen (413
  tooManyItems); Ableitungsrunden höchstens 1 Million je Verfahren und 6 Millionen je
  Anfrage (protectionTooExpensive) (WR-04, WR-05)
- Upload: Gesamtgrenze beim Empfang, multer-Grenzen für Dateien, Felder, Teile (WR-06)
- build-Leser hinter CORS, damit 413/400 CORS-Kopfzeilen tragen (WR-01)
- Selbstsignierte Wurzeln ohne basicConstraints (Version 1, keyCertSign) sind Wurzeln,
  Serverzertifikate bleiben es (WR-07)
- Adressschutz: IPv6-Adressen in Klammern werden beurteilt; „CA Issuers“-Filter für
  Anzeige und Abruf gemeinsam, eigener Code aiaNotAllowed (IN-01, IN-02)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-10-09 17:10:31 +02:00
parent a2fc2cb300
commit ff2226dfbd
40 changed files with 1739 additions and 151 deletions
@@ -6,7 +6,10 @@ Nur Testdaten (quick-261009-ikt). Keine echten Zertifikate, keine echten Schlues
gleichen Namens: cross-signiert, abgelaufen, Lockvogel mit anderem Schluessel) und gleichen Namens: cross-signiert, abgelaufen, Lockvogel mit anderem Schluessel) und
EC (Wurzel P-384, Zwischenzertifikat P-384, Server P-256). EC (Wurzel P-384, Zwischenzertifikat P-384, Server P-256).
- Passwort aller geschuetzten Dateien (verschluesselte Schluessel, PFX, ZIP): `Test-Pass-123`. - Passwort aller geschuetzten Dateien (verschluesselte Schluessel, PFX, ZIP): `Test-Pass-123`.
Die Datei `rsa-nopass.pfx` hat ein leeres Passwort. Die Datei `rsa-nopass.pfx` hat ein leeres Passwort. Ausnahmen: `rsa-umlaut-*.pfx` (Passwort `pässwörd`),
`rsa-euro-*.pfx` (Passwort `pw€`), jeweils `modern` (AES-256) und `compat` (3DES).
`rsa-expensive.pfx` und `rsa-leaf-key-enc-expensive.pem` (Passwort `Test-Pass-123`) haben 2 000 000
Ableitungsrunden: zu aufwendig, die Grenze liegt bei 1 000 000.
- Die Schluessel der CAs liegen nur waehrend der Erzeugung in einem temporaeren Ordner und - Die Schluessel der CAs liegen nur waehrend der Erzeugung in einem temporaeren Ordner und
werden geloescht. Committet sind ausschliesslich Schluessel der Server-Zertifikate. werden geloescht. Committet sind ausschliesslich Schluessel der Server-Zertifikate.
- Dateinamen enden nie auf `.key` (die `.gitignore` ignoriert `*.key` wegen des Updater-Schluessels). - Dateinamen enden nie auf `.key` (die `.gitignore` ignoriert `*.key` wegen des Updater-Schluessels).
@@ -123,6 +123,21 @@ X
rsa_key "$T/rsa-self2.key" 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" selfsign "$T/rsa-self2.key" "/O=Tessera Test/CN=selfsigned.example.test" "$T/ext-self" "$OUT/selfsigned-leaf.pem"
# selbstsigniert ohne basicConstraints (Review WR-07):
# v1 (aeltere Wurzeln), v3 nur mit keyCertSign, v3 nur mit Serverschluesselverwendung
rsa_key "$T/rsa-v1.key"
printf '[req]\ndistinguished_name = dn\n[dn]\n' > "$T/v1.cnf"
openssl req -x509v1 -new -config "$T/v1.cnf" -key "$T/rsa-v1.key" -subj "/O=Tessera Test/CN=Tessera Test V1 Root" -days "$DAYS" -out "$OUT/selfsigned-v1-root.pem"
csr "$T/rsa-self2.key" "/O=Tessera Test/CN=v1-leaf.example.test" "$T/v1leaf.csr"
printf 'subjectAltName = DNS:v1-leaf.example.test\nauthorityKeyIdentifier = none\n' > "$T/ext-v1leaf"
sign "$T/v1leaf.csr" "$OUT/selfsigned-v1-root.pem" "$T/rsa-v1.key" 301 "$T/ext-v1leaf" "$OUT/v1-root-leaf.pem" -days "$DAYS"
printf 'keyUsage = critical,keyCertSign,cRLSign\nsubjectKeyIdentifier = hash\n' > "$T/ext-nobc-ca"
rsa_key "$T/rsa-nobc.key"
selfsign "$T/rsa-nobc.key" "/O=Tessera Test/CN=Tessera Test NoBC CA" "$T/ext-nobc-ca" "$OUT/selfsigned-nobc-ca.pem"
printf 'keyUsage = critical,digitalSignature,keyEncipherment\nsubjectAltName = DNS:nobc.example.test\n' > "$T/ext-nobc-leaf"
rsa_key "$T/rsa-nobc2.key"
selfsign "$T/rsa-nobc2.key" "/O=Tessera Test/CN=nobc.example.test" "$T/ext-nobc-leaf" "$OUT/selfsigned-nobc-leaf.pem"
cat "$OUT/rsa-leaf.pem" "$OUT/rsa-inter.pem" "$OUT/rsa-root.pem" > "$OUT/rsa-fullchain.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" -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 crl2pkcs7 -nocrl -certfile "$OUT/rsa-leaf.pem" -certfile "$OUT/rsa-inter.pem" -certfile "$OUT/rsa-root.pem" -outform DER -out "$OUT/rsa-chain.p7c"
@@ -178,6 +193,19 @@ openssl pkcs12 -export -inkey "$OUT/ec-leaf-key.pem" -in "$OUT/ec-leaf.pem" -cer
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" 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" cp "$OUT/rsa-modern.pfx" "$OUT/rsa-modern.bin"
# Passwoerter mit Umlauten und Eurozeichen (Review CR-03): modern = PBES2/AES-256 (Passwort als UTF-8),
# kompatibel = 3DES (PKCS#12-Ableitung, UTF-16). Datei rsa-<umlaut|euro>-<modern|compat>.pfx,
# Passwoerter `pässwörd` bzw. `pw€`.
for spec in "umlaut:pässwörd" "euro:pw€"; do
name="${spec%%:*}"; pass="${spec#*:}"
openssl pkcs12 -export -inkey "$OUT/rsa-leaf-key.pem" -in "$OUT/rsa-leaf.pem" -certfile "$T/rsa-ca.pem" -passout "pass:$pass" -out "$OUT/rsa-$name-modern.pfx"
openssl pkcs12 -export -inkey "$OUT/rsa-leaf-key.pem" -in "$OUT/rsa-leaf.pem" -certfile "$T/rsa-ca.pem" -passout "pass:$pass" "${COMPAT[@]}" -out "$OUT/rsa-$name-compat.pfx"
done
# Zu aufwendiger Passwortschutz (Review WR-04): 2 000 000 Ableitungsrunden, ueber der Grenze von 1 000 000.
openssl pkcs12 -export -inkey "$OUT/rsa-leaf-key.pem" -in "$OUT/rsa-leaf.pem" -certfile "$T/rsa-ca.pem" -passout "pass:$PW" -iter 2000000 -out "$OUT/rsa-expensive.pfx"
openssl pkcs8 -topk8 -v2 aes-256-cbc -iter 2000000 -in "$OUT/rsa-leaf-key.pem" -passout "pass:$PW" -out "$OUT/rsa-leaf-key-enc-expensive.pem"
# ----------------------------------------------------- ZIP mit Passwortschutz # ----------------------------------------------------- ZIP mit Passwortschutz
( cd "$OUT" && zip -q -j -P "$PW" encrypted-entry.zip rsa-leaf.pem ) ( cd "$OUT" && zip -q -j -P "$PW" encrypted-entry.zip rsa-leaf.pem )
@@ -0,0 +1,30 @@
-----BEGIN ENCRYPTED PRIVATE KEY-----
MIIFNjBgBgkqhkiG9w0BBQ0wUzAyBgkqhkiG9w0BBQwwJQQQdA7vJ5D+LYfl6Cs7
wsiWpwIDHoSAMAwGCCqGSIb3DQIJBQAwHQYJYIZIAWUDBAEqBBB91yzfIbFksyJS
3lqbRjRMBIIE0AOZxJA7uht1LJ2W+66QYzhd/+5M4PepjN0vqlYWilx7mdl6t5tv
aG5HvF6dxY4PpXvclxAWkgRhNpVwL9fSneFejdEfp/UFHSV/+60LfdY9JCxCB39Z
17lyfWjBEdAo3riorh/Jw+X6oXaayH6KfMRHTKbBV7CVry+0sT9ib84A9POZDZDW
KfXZA3L+dGpx7QkQo3jKhxVy5rgvba00vgUBsvV1yEMw1TrApOTH6i0LqY/OenXH
Hk8tYzvtth7s4D603Rnw68oUa4aCTaFK8xy9CEnoIl1zAZKOq1I/ahBMDDUd+lza
YYig14Gk+zat4hfFKi92waFOjhfPRrX874gNiMqcNOu7YDTyoc9TDl7anI3L8JkE
y5amj9EzLraurIo231YLfKHJNZdggL4fln1FJrN1VvYQx8sgQX3I6hfS4L5U12Fr
aaW8rqUS2Xaz0liybRbpo0De0XFAZmkkgS1OdwQxQ8hvSAReIq0dggibFcU3KgOT
PdWwSuLaUKUjj4XtK05/s8Slak9NfBdggi2wLzDf2tPP1PHBysGTKGHryyiY5cJI
NKaI35oHHhtjILRStl/O6GbIek4U0orTjlIILeONmr4Lixw/46YQTpd2D4LJggDn
OZwaa4aEu0gfvDg5wqMt0N0Gx/9wzneoz5pJENXuXPwzvjAJtllkpWDfoNd+Xq3I
oQYlAbaYHPSsqT4mQ9xfSNNtgCMjYtdJcXiI9x6Yc/CaEFh/S3TYbl9evArdpqjj
rZa/wyoNzgTPwwhPBij7xrT/5pw5yaP3y/czKIbxep2n6fvOErStysmJSemt2ev3
FV2K0I/LJx9qarVZasf+2EVTvoQyCAO2O94R9iBtt3fP7KFR6nxubPqnWYvRCumT
lLvtSgw0Zo7edA6wPCS2XbuUqjKzymAAzWTh5z+xexO7q9dPVUVvAERrSq6FMWi7
IAKxx3x7hrF9+/HDlOCnqP/cMnk6U+QvWKuI9nZAuZsPYdwJ2tFsACVPq0EPcR7w
xugNeb88JLi7tdcSHmZHG9GEF0U5ImmNjcxg2qqODlteovdFPBw0ru/PD8cSZF6F
enbPsTrt1rPVMCqi+5NAe1lFGcYGuSLREe+BHzTtbvsdect6mWCg0hlcpA6FioZn
TkwkRCruXuj8zzyEp/o3My5/9w7vjCIKuRjcasxisfCZOhTx12Pjv3dTyLP35C0B
p00Q4EVKjrygw5V7cNf+K+q7UQ/1X3pY3+cLAgCbSqKTBE2M5PesePdO8m7Hix7W
6cJbd+O66gz/GU0s8fIeksAjskWolu/vxIP9dhMksldckjFiQqU4inuaG9QicA3g
9Rm/0TDA3oMOFUpA7S0fT9XyWXeP2qa8warE0cKhWv9xrMS+xNMjNCntzsU5lvzN
bPD/Pr4tCwueyQOIawV7uKw1UJOMN691FCyjHWOekecFXeLexHT18ROwHcrUhCb2
csggt5aB+Z3mDWd55Ljle7ZzLiD1F/ndTkc8IxNU+cK5idYisCtq66LI5Fz8LPOy
EJZdSb7rX8RO8+f6TRVmPu3YAq6KKY23EAJdy7ip/6CVJBuBOn3xaYjqIzv1o83g
Iu6K4aXcJOSkJvTzBGwcuG6B3pHFMEaOEBAqOIXclqG03K895Kuykk13
-----END ENCRYPTED PRIVATE KEY-----
@@ -0,0 +1,20 @@
-----BEGIN CERTIFICATE-----
MIIDLTCCAhWgAwIBAgIUAgmiHMhq65ePF/4JWhFsBQphg78wDQYJKoZIhvcNAQEL
BQAwNjEVMBMGA1UECgwMVGVzc2VyYSBUZXN0MR0wGwYDVQQDDBRUZXNzZXJhIFRl
c3QgTm9CQyBDQTAgFw0yNjEwMDkxNDUxNTRaGA8yMTI2MDkxNTE0NTE1NFowNjEV
MBMGA1UECgwMVGVzc2VyYSBUZXN0MR0wGwYDVQQDDBRUZXNzZXJhIFRlc3QgTm9C
QyBDQTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAKimDRiNLflWRPQ2
Ws4g3eEIPIma0AL8lyIKbreZKHjeEIpVbUI9lTBoLM76ttNKY3qc8wxyur2w1pEH
3YpkDpXWYxXM0FpypMLqYqRcooT0/td5pdBEeoUbcMt9x37q/wUOtG5gaT+Y2U64
9WwbNTWuXfWoZ1nglT8oS6zJW57U9/9UjteBrT1KngIXvzz/gNV5PnfNtxXogIgH
jfkJbXa0M8g808APP2mt5ENLjwkiHnKQaA2h44uVxeHm4PVyOp3H2+qS7m8Z4pPm
u649IXJGxgJrtUQep/IWWQrO5CfgEZ9UGDUR9r09P+WU+hvr4XCh6yCm+HKWPIij
mc/sta8CAwEAAaMxMC8wDgYDVR0PAQH/BAQDAgEGMB0GA1UdDgQWBBSAvlAVbZR0
69z9WNh02qv4oI1voTANBgkqhkiG9w0BAQsFAAOCAQEAQGO0BWN3rftX0v7qTxj3
dLuAGHZW7EQTgNMcd+3L1EZZZO2fs/fb5R0BPrp3FzsSYKiDASXLeQOO5mc2Ntck
tg41odnB8U3ATvxn+eQkQvA53HrVKJ7M2p6SQ+quBNNKUsRkI9NxnwyDzdCeLOxp
nxhuWlvGU1c/E2l5uTqh09Hf0ywVcxBeHhqtNn48sEeLDxtyxvwB6uGkHfzypIw6
xLDzKOb6GZPX5ANFL3vzFyo0EceUNi12isDoCN5vwJ6Hc175qd3moio5pr4cZ2Gl
7I9vYXQT4U0LM/HFlNCBkRmLWyJ6FnQoAOMZ60HWSO+9514cjcscPeWvfPZ2aeRO
UQ==
-----END CERTIFICATE-----
@@ -0,0 +1,20 @@
-----BEGIN CERTIFICATE-----
MIIDRTCCAi2gAwIBAgIUbUtcyfYpcNo6vopvCbXddMHczxIwDQYJKoZIhvcNAQEL
BQAwMzEVMBMGA1UECgwMVGVzc2VyYSBUZXN0MRowGAYDVQQDDBFub2JjLmV4YW1w
bGUudGVzdDAgFw0yNjEwMDkxNDUxNTRaGA8yMTI2MDkxNTE0NTE1NFowMzEVMBMG
A1UECgwMVGVzc2VyYSBUZXN0MRowGAYDVQQDDBFub2JjLmV4YW1wbGUudGVzdDCC
ASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAOM6MWRooapjjTL0o3ecC4cQ
7sV6NFLKzzhfnBSZTlaFE/DvYxgJ4ToLqAPcVt+Ol9CkTcf7YVzDlUE/eZoIOXkQ
w+FO+b0Qj4/8M2Mh1We0eZgak9O9FLoPFMZZO03rCFu3cr1QAQTGjjVn4vA59+3M
ZlkP133C7ZvPD5YN8JRTyCHG/nbCS/pYTqbfWJt7yyVMKFEglR3r1VjqXVrjG7/5
wwBPIqo7hFY074cXlIlRp/SvCLzre45QOOnL4eGucRnoOddEAKEqWosjqv84fFjl
CdxWQTw600VFIz/WJQZtgMPReymhpJDBhfyaJF5Q8ZWSyaI1sp2twlrRwQybvWsC
AwEAAaNPME0wDgYDVR0PAQH/BAQDAgWgMBwGA1UdEQQVMBOCEW5vYmMuZXhhbXBs
ZS50ZXN0MB0GA1UdDgQWBBTVJOfoX1uJY7QAlvT65Q9kFCh3FDANBgkqhkiG9w0B
AQsFAAOCAQEAnnsIcJpdRR3KejtAPzAEfBR9VB6swD1xCWuMUExDAROsv8M9V520
GOy9z2pYi5cgC4TVP2qHjpYjv8NZX9Anzt0Nq1XC93FL2CWJ6FR02l9vh0qY6p1Z
GORsSAa9K+B2CFfZ0IGwEZGjhu9tKucahGJot3viPgtu5HMVQO/tvLC7DKrOgVB5
VwC53zbDEvuyWA7gHrlZvruqtyvW7y/hRGvxwXULZAJv4FuMV0al9kiEOZ8gcsVY
aIUJSZx5m10/G1OhXjbmF+B5YFkyLd9s3ag+Mq1rRt4W1+Zcs7vbmtDADGhGeClS
HUPsquidzPJgJlKhR+pc3yce0R+5gDdVNg==
-----END CERTIFICATE-----
@@ -0,0 +1,18 @@
-----BEGIN CERTIFICATE-----
MIIC9TCCAd0CFCijxXjfHCuBLAb1mLSMIU/04OLTMA0GCSqGSIb3DQEBCwUAMDYx
FTATBgNVBAoMDFRlc3NlcmEgVGVzdDEdMBsGA1UEAwwUVGVzc2VyYSBUZXN0IFYx
IFJvb3QwIBcNMjYxMDA5MTQ1MjI4WhgPMjEyNjA5MTUxNDUyMjhaMDYxFTATBgNV
BAoMDFRlc3NlcmEgVGVzdDEdMBsGA1UEAwwUVGVzc2VyYSBUZXN0IFYxIFJvb3Qw
ggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCcuEOxOngIR6lcKR9uglsf
G1upAsWqdDJzNgz1MB5abyleoxSgLncYrf84DVCFIa05Z2GFZZbi1/bysMon1JfL
kPRw/E5KrHECeRi4nF/6DMICVHopnbnwlDKb3pgzFT/Fun05qbffeBx7eZfupCuI
bfOXCho+lEQFO+JjFIP+qwPAkNlDTdjrDaqMDy2rskj+DoMQC3RlaKbuaV7RCe6x
Z1JXew4CCXdGirehYMcF4PFgRdoTYgDk5LGyRh4CGC/uldf4I6CrUGeDEl2aSdks
MvRSv7nEKvK7x0XF8jtSKlt9qvOsUtKI2LnfnGDjggcwUGHnRHgAHMj1Kx4xsUJ1
AgMBAAEwDQYJKoZIhvcNAQELBQADggEBAEd13u2UvqE1qZeFy4P3dzsRzTtaN+FD
jczMBI5DFfR27xrTZkkXBpvQgcJwhTa85GCwgw7H9Y0wtVHgtVt1Eq8Fw6Z7wqAF
zetmnA7RJG2vkv2d1+bsnaSqIIATCvECG+450V7fXgKKDf6A85ZliazZ0NU3HJA3
gfktRTi5dk6Re6efdKsskie7/BnhmB8yPXkVR+DMGRytS6hnK3C699yrioEOhG6Q
BcdTCHtPYpnZJSaPHAMTnyHAiF6km6CzaBjN0cv+R1EFycCstzP9EqAsTCZLZZV5
jeVEDww2BY7jnOETkDpMn0clvlC10IAeQVMb8rqH2e5meTl+LWUY7hg=
-----END CERTIFICATE-----
@@ -0,0 +1,19 @@
-----BEGIN CERTIFICATE-----
MIIDLDCCAhSgAwIBAgICAS0wDQYJKoZIhvcNAQELBQAwNjEVMBMGA1UECgwMVGVz
c2VyYSBUZXN0MR0wGwYDVQQDDBRUZXNzZXJhIFRlc3QgVjEgUm9vdDAgFw0yNjEw
MDkxNDUyMjhaGA8yMTI2MDkxNTE0NTIyOFowNjEVMBMGA1UECgwMVGVzc2VyYSBU
ZXN0MR0wGwYDVQQDDBR2MS1sZWFmLmV4YW1wbGUudGVzdDCCASIwDQYJKoZIhvcN
AQEBBQADggEPADCCAQoCggEBAMhTYUAgnrgYseCSMYSeecJR2VvETwGcOykavi5A
9KlkHjoFJ2etF3r7IzvWeS5F6u7wAvuaNFTdzt/hNH+2bqLyTzOMGplFDGptfGKl
dh2oaw1qEgrifBMlVnQ098Del5RujuWCz3vfUuFtsDzubhVEgkJC9qcbsrA+WHJA
KmAGzFD7ezrIwBE6t9CC6VFPBMRNVNpbvRik3prUEH+pWjKP0N8dKKtOXjsKvVPu
fAzBuTxslFw5TmKSY9YwOlw8IQAEGgI7Xael4D7IzhTuGGmTUCccql7GU2DOIyOY
7mj2fQ0lQA5hY8jJkCFbB3WAK9bGwOwc5IbcAzYAf6xuqNMCAwEAAaNCMEAwHwYD
VR0RBBgwFoIUdjEtbGVhZi5leGFtcGxlLnRlc3QwHQYDVR0OBBYEFFfmr7+Nimg/
G+Ai/TJy31WpKNkWMA0GCSqGSIb3DQEBCwUAA4IBAQA78ufqEU2AAyXZlAWEq1fZ
JfeYAMiMV5UOjheRJ9NCRUSsiV2/wzHsewqOfBcIgYh/Xluy886ytTH00vk7c9rt
CyqPfhtdotNU6Ls/1xK3aQJZ+D7U4LYYuvAnGtmn6gTc0D0pGo6NtUuAypGQb9zR
1m5x0WOPj9WUNLnVw6a/YkB6JFY8rJx2q6jOnq9IWfiGBOKsoFlFgcrsDtRby6Ez
R7/QnrQUQCbuuyDQSMzcWdulOC0QzJhSQau3Xsm8pWe2mv7oeba4NhdO1F+EhRCX
ckOFFwnvLBoADcALdmHHJ7NQFMeVHPVzx5GO+qA8JzFH0D8ZWJDUXGsrhb7ptKy8
-----END CERTIFICATE-----
@@ -0,0 +1,61 @@
import { describe, expect, it } from 'vitest';
import {
AIA_MAX_URLS,
acceptableIssuerUrl,
acceptableIssuerUrls,
rawIssuerEntries,
} from './cert-aia-url';
describe('acceptableIssuerUrl (Review IN-02)', () => {
it.each([
'http://pki.example.test/ca.crt',
'https://pki.example.test/ca.crt',
'http://pki.example.test',
'https://pki.example.test:443/ca.crt',
])('erlaubt %s', (entry) => {
expect(acceptableIssuerUrl(entry)).toBeInstanceOf(URL);
});
it.each([
['anderer Port', 'http://pki.example.test:8080/ca.crt'],
['Zugangsdaten', 'http://user:pw@pki.example.test/ca.crt'],
['ldap', 'ldap://ldap.example.test/cn=x'],
['ftp', 'ftp://pki.example.test/ca.crt'],
['zu lang', `http://pki.example.test/${'a'.repeat(2100)}`],
['keine Adresse', 'kein url'],
])('verwirft %s', (_name, entry) => {
expect(acceptableIssuerUrl(entry)).toBeNull();
});
it('verwirft Nicht-Zeichenketten', () => {
expect(acceptableIssuerUrl(42)).toBeNull();
expect(acceptableIssuerUrl(undefined)).toBeNull();
});
});
describe('acceptableIssuerUrls', () => {
it('liest eine Zeichenkette und eine Liste, filtert und begrenzt', () => {
const info = {
'CA Issuers - URI': [
'http://a.example.test/1.crt',
'http://b.example.test:8080/2.crt',
'ldap://c.example.test/x',
'http://d.example.test/3.crt',
'http://e.example.test/4.crt',
'http://f.example.test/5.crt',
],
};
const urls = acceptableIssuerUrls(info).map((u) => u.hostname);
expect(urls).toEqual(['a.example.test', 'd.example.test', 'e.example.test']);
expect(urls).toHaveLength(AIA_MAX_URLS);
expect(
acceptableIssuerUrls({ 'CA Issuers - URI': 'http://a.example.test/1.crt' }),
).toHaveLength(1);
expect(acceptableIssuerUrls(undefined)).toEqual([]);
});
it('rawIssuerEntries zaehlt auch verworfene Eintraege', () => {
expect(rawIssuerEntries({ 'CA Issuers - URI': ['http://x:81/', 'ldap://y/'] })).toHaveLength(2);
expect(rawIssuerEntries({})).toEqual([]);
});
});
+48
View File
@@ -0,0 +1,48 @@
/**
* Welche „CA Issuers“-Adressen eines Zertifikats ueberhaupt abgerufen werden duerfen
* (Review IN-02, quick-261009-ikt). Eine einzige Stelle fuer die Anzeige (cert-model.ts: erscheint der
* Knopf?) und den Abruf (cert-aia.ts): der Knopf erscheint nur, wenn ein Abruf auch moeglich ist.
*
* Erlaubt: http/https, ohne Benutzername und Kennwort, hoechstens 2048 Zeichen, nur der
* Standardport (80/443, sonst waere der Abruf ein Portscanner). Ob der Server oeffentlich ist,
* prueft cert-aia.ts beim Abruf selbst.
*/
export const AIA_MAX_URLS = 3;
export const MAX_URL_LENGTH = 2048;
/** Nur der Standardport (leer = 80/443). */
export function hasDefaultPort(url: URL): boolean {
return url.port === '';
}
/** Die Adresse als URL, wenn sie abgerufen werden duerfte; sonst null. */
export function acceptableIssuerUrl(entry: unknown): URL | null {
if (typeof entry !== 'string' || entry.length > MAX_URL_LENGTH) return null;
try {
const url = new URL(entry);
if (url.protocol !== 'http:' && url.protocol !== 'https:') return null;
if (url.username || url.password) return null;
if (!hasDefaultPort(url)) return null;
return url;
} catch {
return null;
}
}
/** Alle „CA Issuers“-Eintraege aus den Zugriffsinformationen, unveraendert. */
export function rawIssuerEntries(infoAccess: unknown): unknown[] {
const raw = (infoAccess as Record<string, unknown> | undefined)?.['CA Issuers - URI'];
return Array.isArray(raw) ? raw : typeof raw === 'string' ? [raw] : [];
}
/** Die abrufbaren Adressen, hoechstens AIA_MAX_URLS. */
export function acceptableIssuerUrls(infoAccess: unknown): URL[] {
const urls: URL[] = [];
for (const entry of rawIssuerEntries(infoAccess)) {
const url = acceptableIssuerUrl(entry);
if (url) urls.push(url);
if (urls.length >= AIA_MAX_URLS) break;
}
return urls;
}
+2 -2
View File
@@ -185,7 +185,7 @@ describe('fetchIssuer: Weiterleitungen, Grenzen, Zeit', () => {
const result = await failure( const result = await failure(
fetchIssuer(text('rsa-leaf.pem'), { fetchImpl: impl, isPublic: publicAlways }), fetchIssuer(text('rsa-leaf.pem'), { fetchImpl: impl, isPublic: publicAlways }),
); );
expect(result).toEqual({ status: 422, code: 'aiaInternal' }); expect(result).toEqual({ status: 422, code: 'aiaNotAllowed' });
expect(calls).toHaveLength(1); expect(calls).toHaveLength(1);
}); });
@@ -195,7 +195,7 @@ describe('fetchIssuer: Weiterleitungen, Grenzen, Zeit', () => {
await failure( await failure(
fetchIssuer(text('rsa-leaf.pem'), { fetchImpl: withLogin.impl, isPublic: publicAlways }), fetchIssuer(text('rsa-leaf.pem'), { fetchImpl: withLogin.impl, isPublic: publicAlways }),
), ),
).toEqual({ status: 422, code: 'aiaInternal' }); ).toEqual({ status: 422, code: 'aiaNotAllowed' });
const ftp = fakeFetch(() => redirect('ftp://pki.example.test/x')); const ftp = fakeFetch(() => redirect('ftp://pki.example.test/x'));
expect( expect(
await failure( await failure(
+30 -40
View File
@@ -3,9 +3,16 @@ import * as dns from 'node:dns';
import { Logger } from '@nestjs/common'; import { Logger } from '@nestjs/common';
import { Agent, type Dispatcher, fetch as undiciFetch } from 'undici'; import { Agent, type Dispatcher, fetch as undiciFetch } from 'undici';
import { isPrivateIpAddress, isPublicHttpUrl } from '../common/public-url-guard'; import { isPrivateIpAddress, isPublicHttpUrl } from '../common/public-url-guard';
import {
acceptableIssuerUrls,
hasDefaultPort,
MAX_URL_LENGTH,
rawIssuerEntries,
} from './cert-aia-url';
import { leadingDerSequence, pkcs7Certificates } from './cert-model'; import { leadingDerSequence, pkcs7Certificates } from './cert-model';
import { safeBaseName } from './cert-names'; import { safeBaseName } from './cert-names';
import { type CertErrorCode, certError } from './cert-types'; import { type CertErrorCode, certError } from './cert-types';
import { scanPemBlocks } from './pem-scan';
/** /**
* „Fehlendes Zertifikat holen“ (quick-261009-ikt, D-03, D-22). * „Fehlendes Zertifikat holen“ (quick-261009-ikt, D-03, D-22).
@@ -41,8 +48,6 @@ import { type CertErrorCode, certError } from './cert-types';
export const AIA_TIMEOUT_MS = 8000; export const AIA_TIMEOUT_MS = 8000;
export const AIA_MAX_REDIRECTS = 3; export const AIA_MAX_REDIRECTS = 3;
export const AIA_MAX_BYTES = 256 * 1024; export const AIA_MAX_BYTES = 256 * 1024;
export const AIA_MAX_URLS = 3;
const MAX_URL_LENGTH = 2048;
const MAX_ANSWER_CERTIFICATES = 20; const MAX_ANSWER_CERTIFICATES = 20;
export interface FetchIssuerResult { export interface FetchIssuerResult {
@@ -133,34 +138,12 @@ function discard(response: { body?: { cancel(): Promise<void> } | null }): void
} }
} }
/** Die „CA Issuers“-Adressen eines Zertifikats: nur http/https, ohne Zugangsdaten, hoechstens drei. */ /** Die abrufbaren „CA Issuers“-Adressen eines Zertifikats (gemeinsamer Filter, cert-aia-url.ts). */
function issuerUrls(target: X509Certificate): URL[] { function issuerUrls(target: X509Certificate): { urls: URL[]; declared: number } {
const info = (target.toLegacyObject() as { infoAccess?: Record<string, unknown> }).infoAccess; const info = (target.toLegacyObject() as { infoAccess?: Record<string, unknown> }).infoAccess;
const raw = info?.['CA Issuers - URI']; return { urls: acceptableIssuerUrls(info), declared: rawIssuerEntries(info).length };
const list = Array.isArray(raw) ? raw : typeof raw === 'string' ? [raw] : [];
const urls: URL[] = [];
for (const entry of list) {
if (typeof entry !== 'string' || entry.length > MAX_URL_LENGTH) continue;
try {
const url = new URL(entry);
if (url.protocol !== 'http:' && url.protocol !== 'https:') continue;
if (url.username || url.password) continue;
urls.push(url);
} catch {
// keine gueltige Adresse: ueberspringen
}
if (urls.length >= AIA_MAX_URLS) break;
}
return urls;
} }
/** Nur der Standardport (leer = 80/443), sonst waere der Abruf ein Portscanner. */
function hasDefaultPort(url: URL): boolean {
return url.port === '';
}
const PEM_BLOCK = /-----BEGIN ([A-Z0-9 ]+)-----([\s\S]*?)-----END \1-----/g;
/** Zertifikate aus einer Antwort: DER-Zertifikat, PKCS#7 (DER oder PEM) oder PEM-Text. */ /** Zertifikate aus einer Antwort: DER-Zertifikat, PKCS#7 (DER oder PEM) oder PEM-Text. */
function certificatesFromAnswer(data: Buffer): X509Certificate[] { function certificatesFromAnswer(data: Buffer): X509Certificate[] {
const found: X509Certificate[] = []; const found: X509Certificate[] = [];
@@ -182,12 +165,12 @@ function certificatesFromAnswer(data: Buffer): X509Certificate[] {
if (data.includes('-----BEGIN ')) { if (data.includes('-----BEGIN ')) {
const text = data.toString('latin1'); const text = data.toString('latin1');
for (const match of text.matchAll(PEM_BLOCK)) { for (const block of scanPemBlocks(text)) {
const label = match[1]; if (found.length >= MAX_ANSWER_CERTIFICATES) break;
if (label === 'CERTIFICATE' || label === 'X509 CERTIFICATE') { if (block.label === 'CERTIFICATE' || block.label === 'X509 CERTIFICATE') {
add(match[0]); add(block.text);
} else if (label === 'PKCS7' || label === 'CMS') { } else if (block.label === 'PKCS7' || block.label === 'CMS') {
addPkcs7(Buffer.from(match[2].replace(/\s+/g, ''), 'base64')); addPkcs7(Buffer.from(block.body.replace(/\s+/g, ''), 'base64'));
} }
} }
return found; return found;
@@ -222,12 +205,14 @@ const FAILURE_RANK: Record<string, number> = {
aiaNotIssuer: 4, aiaNotIssuer: 4,
aiaTooLarge: 3, aiaTooLarge: 3,
aiaUnreachable: 2, aiaUnreachable: 2,
aiaNotAllowed: 1,
aiaInternal: 1, aiaInternal: 1,
}; };
const FAILURE_STATUS: Record<string, number> = { const FAILURE_STATUS: Record<string, number> = {
aiaNotIssuer: 422, aiaNotIssuer: 422,
aiaInternal: 422, aiaInternal: 422,
aiaNotAllowed: 422,
aiaTooLarge: 502, aiaTooLarge: 502,
aiaUnreachable: 502, aiaUnreachable: 502,
}; };
@@ -235,13 +220,15 @@ const FAILURE_STATUS: Record<string, number> = {
const FAILURE_TEXT: Record<string, string> = { const FAILURE_TEXT: Record<string, string> = {
aiaNotIssuer: 'The downloaded certificate did not issue this certificate', aiaNotIssuer: 'The downloaded certificate did not issue this certificate',
aiaInternal: 'The issuer address is not a public address', aiaInternal: 'The issuer address is not a public address',
aiaNotAllowed:
'The issuer address is not allowed (only http or https on the default port, without credentials)',
aiaTooLarge: 'The issuer answer is too large', aiaTooLarge: 'The issuer answer is too large',
aiaUnreachable: 'The issuer address could not be reached', aiaUnreachable: 'The issuer address could not be reached',
}; };
/** /**
* Holt das Ausstellerzertifikat zum uebergebenen Zertifikat (PEM). Fehler: notACertificate 400, * Holt das Ausstellerzertifikat zum uebergebenen Zertifikat (PEM). Fehler: notACertificate 400,
* aiaMissing 422, aiaInternal 422, aiaNotIssuer 422, aiaUnreachable 502, aiaTooLarge 502. * aiaMissing 422, aiaNotAllowed 422, aiaInternal 422, aiaNotIssuer 422, aiaUnreachable 502, aiaTooLarge 502.
*/ */
export async function fetchIssuer( export async function fetchIssuer(
pem: string, pem: string,
@@ -254,10 +241,14 @@ export async function fetchIssuer(
return certError('notACertificate', 400, 'The provided text is not a certificate'); return certError('notACertificate', 400, 'The provided text is not a certificate');
} }
const urls = issuerUrls(target); const { urls, declared } = issuerUrls(target);
if (urls.length === 0) { if (declared === 0) {
return certError('aiaMissing', 422, 'The certificate names no issuer address'); return certError('aiaMissing', 422, 'The certificate names no issuer address');
} }
if (urls.length === 0) {
// Adressen vorhanden, aber keine abrufbare (Zugangsdaten, anderer Port, kein http/https, zu lang)
return certError('aiaNotAllowed', 422, FAILURE_TEXT.aiaNotAllowed);
}
const fetchImpl = opts.fetchImpl ?? undiciFetch; const fetchImpl = opts.fetchImpl ?? undiciFetch;
const isPublic = opts.isPublic ?? isPublicHttpUrl; const isPublic = opts.isPublic ?? isPublicHttpUrl;
@@ -275,9 +266,8 @@ export async function fetchIssuer(
const run = async (): Promise<AttemptResult> => { const run = async (): Promise<AttemptResult> => {
let current = first; let current = first;
for (let hop = 0; ; hop++) { for (let hop = 0; ; hop++) {
if (!hasDefaultPort(current) || !(await isPublic(current))) { if (!hasDefaultPort(current)) return { ok: false, code: 'aiaNotAllowed' };
return { ok: false, code: 'aiaInternal' }; if (!(await isPublic(current))) return { ok: false, code: 'aiaInternal' };
}
let response: Awaited<ReturnType<typeof undiciFetch>>; let response: Awaited<ReturnType<typeof undiciFetch>>;
try { try {
@@ -309,7 +299,7 @@ export async function fetchIssuer(
return { ok: false, code: 'aiaUnreachable' }; return { ok: false, code: 'aiaUnreachable' };
} }
if (next.username || next.password || next.href.length > MAX_URL_LENGTH) { if (next.username || next.password || next.href.length > MAX_URL_LENGTH) {
return { ok: false, code: 'aiaInternal' }; return { ok: false, code: 'aiaNotAllowed' };
} }
current = next; current = next;
continue; continue;
@@ -1,6 +1,8 @@
import { generateKeyPairSync } from 'node:crypto';
import { readFileSync } from 'node:fs'; import { readFileSync } from 'node:fs';
import { join } from 'node:path'; import { join } from 'node:path';
import AdmZip from 'adm-zip'; import AdmZip from 'adm-zip';
import * as forge from 'node-forge';
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { analyzeWorkingSet, cleanSourcePath } from './cert-analyze'; import { analyzeWorkingSet, cleanSourcePath } from './cert-analyze';
import type { CertItem, CsrItem, KeyItem } from './cert-types'; import type { CertItem, CsrItem, KeyItem } from './cert-types';
@@ -263,3 +265,116 @@ describe('analyzeWorkingSet: Schluessel, PFX und Anfragen mit Passwort je Datei'
expect(r.locked[0]?.reason).toBe('passwordNeeded'); expect(r.locked[0]?.reason).toBe('passwordNeeded');
}); });
}); });
// ---------------------------------------------------------------------------
// Review WR-05: Obergrenzen je Anfrage
// ---------------------------------------------------------------------------
/** n verschiedene selbstsignierte Zertifikate in einer PEM-Datei (nur fuer den Test, mit forge erzeugt). */
function manyCertificates(n: number): Buffer {
const keys = forge.pki.rsa.generateKeyPair({ bits: 1024, e: 0x10001 });
const pems: string[] = [];
for (let i = 0; i < n; i++) {
const cert = forge.pki.createCertificate();
cert.publicKey = keys.publicKey;
cert.serialNumber = (i + 1).toString(16).padStart(2, '0');
cert.validity.notBefore = new Date(Date.now() - 86_400_000);
cert.validity.notAfter = new Date(Date.now() + 86_400_000 * 365);
const attrs = [{ name: 'commonName', value: `massen-${i}.example.test` }];
cert.setSubject(attrs);
cert.setIssuer(attrs);
cert.sign(keys.privateKey, forge.md.sha256.create());
pems.push(forge.pki.certificateToPem(cert));
}
return Buffer.from(pems.join('\n'));
}
function statusAndCode(run: () => unknown): { status: number; code: string } {
try {
run();
} 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('analyzeWorkingSet: Obergrenzen je Anfrage', () => {
it('genau 200 Zertifikate sind erlaubt', () => {
const result = analyzeWorkingSet([
{ originalname: 'viele.pem', buffer: manyCertificates(200) },
]);
expect(result.items.filter((i) => i.kind === 'certificate')).toHaveLength(200);
}, 60_000);
it('201 Zertifikate in einer Datei: 413 tooManyItems, und zwar schnell', () => {
const buffer = manyCertificates(201);
const started = performance.now();
expect(statusAndCode(() => analyzeWorkingSet([{ originalname: 'viele.pem', buffer }]))).toEqual(
{
status: 413,
code: 'tooManyItems',
},
);
expect(performance.now() - started).toBeLessThan(5000);
}, 60_000);
it('die Grenze gilt ueber alle Dateien und auch im ZIP', () => {
const half = manyCertificates(101);
const other = manyCertificates(101);
const zip = new AdmZip();
zip.addFile('a.pem', other);
const files = [
{ originalname: 'eins.pem', buffer: half },
{ originalname: 'zwei.zip', buffer: zip.toBuffer() },
];
expect(statusAndCode(() => analyzeWorkingSet(files)).code).toBe('tooManyItems');
}, 60_000);
it('51 Schluessel: 413 tooManyItems', () => {
const keys = Array.from({ length: 51 }, () =>
generateKeyPairSync('ec', { namedCurve: 'P-256' }).privateKey.export({
type: 'pkcs8',
format: 'pem',
}),
);
const buffer = Buffer.from(keys.join('\n'));
expect(
statusAndCode(() => analyzeWorkingSet([{ originalname: 'keys.pem', buffer }])).code,
).toBe('tooManyItems');
});
it('50 Schluessel sind erlaubt', () => {
const keys = Array.from({ length: 50 }, () =>
generateKeyPairSync('ec', { namedCurve: 'P-256' }).privateKey.export({
type: 'pkcs8',
format: 'pem',
}),
);
const result = analyzeWorkingSet([
{ originalname: 'keys.pem', buffer: Buffer.from(keys.join('\n')) },
]);
expect(result.items.filter((i) => i.kind === 'privateKey')).toHaveLength(50);
});
it('unlesbare Dateien zaehlen nicht gegen die Grenzen', () => {
const junk = Array.from({ length: 30 }, (_, i) => ({
originalname: `muell-${i}.bin`,
buffer: Buffer.from(`kein Zertifikat ${i}`),
}));
const result = analyzeWorkingSet(junk);
expect(result.ignored).toHaveLength(30);
});
it('der Rechenaufwand des Passwortschutzes gilt je Anfrage (viele teure Dateien)', () => {
const files = Array.from({ length: 12 }, (_, i) => ({
originalname: `teuer-${i}.pfx`,
buffer: fx('rsa-expensive.pfx'),
}));
const started = performance.now();
const result = analyzeWorkingSet(files, ['Test-Pass-123']);
expect(result.items).toEqual([]);
expect(result.ignored.every((e) => e.reason === 'protectionTooExpensive')).toBe(true);
expect(performance.now() - started).toBeLessThan(2000);
});
});
@@ -1,3 +1,4 @@
import { RequestBudget } from './cert-budget';
import { buildChains, matchKeys } from './cert-chain'; import { buildChains, matchKeys } from './cert-chain';
import { candidatePasswords } from './cert-keys'; import { candidatePasswords } from './cert-keys';
import { detectBlob } from './cert-model'; import { detectBlob } from './cert-model';
@@ -51,6 +52,8 @@ export function analyzeWorkingSet(files: AnalyzeFile[], passwords: string[] = []
const ignored: IgnoredEntry[] = []; const ignored: IgnoredEntry[] = [];
const locked: LockedEntry[] = []; const locked: LockedEntry[] = [];
const lockedSeen = new Set<string>(); const lockedSeen = new Set<string>();
// Gemeinsame Grenzen aller Dateien dieser Anfrage (Review WR-04, WR-05).
const budget = new RequestBudget();
files.forEach((f, index) => { files.forEach((f, index) => {
const path = cleanSourcePath(f.originalname); const path = cleanSourcePath(f.originalname);
@@ -60,6 +63,7 @@ export function analyzeWorkingSet(files: AnalyzeFile[], passwords: string[] = []
path, path,
passwords: candidatePasswords(own, passwords), passwords: candidatePasswords(own, passwords),
ownPassword: own, ownPassword: own,
budget,
}); });
for (const item of result.items) { for (const item of result.items) {
const known = byId.get(item.id); const known = byId.get(item.id);
+71
View File
@@ -0,0 +1,71 @@
import { HttpException } from '@nestjs/common';
import { certError } from './cert-types';
/**
* Arbeitsgrenzen je Anfrage (Review WR-04, WR-05, quick-261009-ikt).
*
* Node ist einfaedig: alles, was ein Hochladender in einer Anfrage ausloesen kann, blockiert die
* ganze API. Darum gibt es zwei Grenzen, die ueber alle Dateien einer Anfrage zusammen gelten:
*
* - Anzahl: hoechstens 200 Zertifikate, 50 Schluessel, 50 Zertifikatsanfragen. Dahinter steht der
* quadratische Kettenbau und die Zuordnung. Mehr: 413 `tooManyItems` fuer die ganze Anfrage.
* - Rechenaufwand des Passwortschutzes: ein einzelnes Schluesselableitungsverfahren (PBKDF2 oder
* PKCS#12-KDF) darf hoechstens 1 000 000 Runden haben, alle zusammen hoechstens 6 000 000 je
* Anfrage (rund 3 Sekunden mit der nativen PBKDF2). Mehr: die Datei wird mit Grund
* `protectionTooExpensive` uebersprungen, bevor entschluesselt wird.
*/
export const MAX_CERTS_PER_REQUEST = 200;
export const MAX_KEYS_PER_REQUEST = 50;
export const MAX_CSRS_PER_REQUEST = 50;
export const MAX_KDF_ITERATIONS = 1_000_000;
export const MAX_KDF_WORK = 6_000_000;
/** Der Passwortschutz einer Datei ist zu aufwendig zu pruefen (zu viele Runden, oder die Anfragegrenze ist aufgebraucht). */
export class KdfTooExpensiveError extends Error {
constructor() {
super('The password protection needs too much work to check');
this.name = 'KdfTooExpensiveError';
}
}
export class RequestBudget {
private certs = 0;
private keys = 0;
private csrs = 0;
private kdfWork = 0;
spendCert(): void {
if (++this.certs > MAX_CERTS_PER_REQUEST) tooMany();
}
spendKey(): void {
if (++this.keys > MAX_KEYS_PER_REQUEST) tooMany();
}
spendCsr(): void {
if (++this.csrs > MAX_CSRS_PER_REQUEST) tooMany();
}
/** Rechenaufwand einer Schluesselableitung in Runden; wirft KdfTooExpensiveError, bevor sie laeuft. */
spendKdf(iterations: number): void {
if (!Number.isFinite(iterations) || iterations < 0 || iterations > MAX_KDF_ITERATIONS) {
throw new KdfTooExpensiveError();
}
this.kdfWork += iterations;
if (this.kdfWork > MAX_KDF_WORK) throw new KdfTooExpensiveError();
}
}
function tooMany(): never {
return certError('tooManyItems', 413, 'Too many certificates, keys or requests in one request');
}
/**
* Fuer Stellen, die Fehler beim Lesen einer Datei bewusst verschlucken: eine ueberschrittene
* Anfragegrenze (`tooManyItems`, eine HttpException) gehoert dem Aufrufer und wird weitergereicht.
*/
export function rethrowRequestError(error: unknown): void {
if (error instanceof HttpException) throw error;
}
@@ -222,3 +222,33 @@ describe('matchKeys', () => {
expect(keysOf(items)[0].certIds).toHaveLength(1); expect(keysOf(items)[0].certIds).toHaveLength(1);
}); });
}); });
describe('buildChains: Wurzel der Version 1 (Review WR-07)', () => {
it('Kette vom Blatt zur v1-Wurzel ist vollstaendig und die Wurzel wird erkannt', () => {
const items = ['v1-root-leaf.pem', 'selfsigned-v1-root.pem'].flatMap((name) =>
detectBlob(readFileSync(join(__dirname, '__fixtures__', name)), {
file: 0,
path: name,
passwords: [],
}).items.filter((i): i is CertItem => i.kind === 'certificate'),
);
const { chains } = buildChains(items);
expect(chains).toHaveLength(1);
expect(chains[0].complete).toBe(true);
const root = items.find((c) => c.cn === 'Tessera Test V1 Root');
expect(chains[0].rootId).toBe(root?.id);
});
it('die v1-Wurzel ist kein zusaetzlicher Kettenkopf neben dem Blatt', () => {
const items = ['v1-root-leaf.pem', 'selfsigned-v1-root.pem'].flatMap((name) =>
detectBlob(readFileSync(join(__dirname, '__fixtures__', name)), {
file: 0,
path: name,
passwords: [],
}).items.filter((i): i is CertItem => i.kind === 'certificate'),
);
expect(buildChains(items).chains.map((c) => c.headId)).toEqual([
items.find((c) => c.cn === 'v1-leaf.example.test')?.id,
]);
});
});
+70 -1
View File
@@ -1,8 +1,16 @@
import { createPrivateKey, createPublicKey, generateKeyPairSync } from 'node:crypto'; import { createPrivateKey, createPublicKey, generateKeyPairSync } from 'node:crypto';
import { readFileSync } from 'node:fs'; import { readFileSync } from 'node:fs';
import { join } from 'node:path'; import { join } from 'node:path';
import * as forge from 'node-forge';
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { candidatePasswords, exportKey, keyIdOf, MAX_PASSWORDS } from './cert-keys'; import { MAX_KDF_WORK, RequestBudget } from './cert-budget';
import {
candidatePasswords,
exportKey,
keyIdOf,
MAX_PASSWORDS,
pkcs8Iterations,
} from './cert-keys';
import { detectBlob } from './cert-model'; import { detectBlob } from './cert-model';
import type { KeyItem } from './cert-types'; import type { KeyItem } from './cert-types';
@@ -126,6 +134,67 @@ describe('verschluesselte Schluessel', () => {
}); });
}); });
describe('verschluesselter Schluessel mit zu vielen Ableitungsrunden (Review WR-04)', () => {
it('2 000 000 Runden: protectionTooExpensive, schnell, nicht gesperrt', () => {
const started = performance.now();
const result = detect('rsa-leaf-key-enc-expensive.pem', PASSWORD);
expect(result.items).toEqual([]);
expect(result.locked).toEqual([]);
expect(result.ignored).toEqual([
{ file: 0, path: 'rsa-leaf-key-enc-expensive.pem', reason: 'protectionTooExpensive' },
]);
// Die Ableitung (rund 1 s mit OpenSSL) laeuft gar nicht erst.
expect(performance.now() - started).toBeLessThan(500);
});
it('pkcs8Iterations liest die Runden aus PBES2 und aelteren Verfahren', () => {
const pbes2 = readFileSync(
join(__dirname, '__fixtures__', 'rsa-leaf-key-enc-pkcs8.pem'),
'utf8',
);
const der = Buffer.from(
pbes2
.split('\n')
.filter((l) => !l.startsWith('-----'))
.join(''),
'base64',
);
const iterations = pkcs8Iterations(
forge.asn1.fromDer(forge.util.createBuffer(der.toString('binary')), {
strict: false,
} as unknown as boolean),
);
expect(iterations).toBeGreaterThan(0);
expect(iterations).toBeLessThanOrEqual(1_000_000);
const legacy = fx('ec-leaf-key-enc-pkcs8.der'); // PBE-SHA1-3DES (PKCS#12-PBE)
const legacyIterations = pkcs8Iterations(
forge.asn1.fromDer(forge.util.createBuffer(legacy.toString('binary')), {
strict: false,
} as unknown as boolean),
);
expect(legacyIterations).toBeGreaterThan(0);
});
it('der Rechenaufwand der Anfrage wird ueber Dateien und Passwoerter summiert', () => {
const budget = new RequestBudget();
const ctx = {
file: 0,
path: 'rsa-leaf-key-enc-pkcs8.pem',
passwords: ['falsch'],
ownPassword: 'falsch',
budget,
};
// Erlaubt bleibt es nur, solange die Summe unter der Anfragegrenze liegt.
for (let spent = 0; spent < MAX_KDF_WORK - 100; spent += 1_000_000) {
budget.spendKdf(Math.min(1_000_000, MAX_KDF_WORK - 100 - spent));
}
const result = detectBlob(fx('rsa-leaf-key-enc-pkcs8.pem'), ctx);
expect(result.ignored).toEqual([
{ file: 0, path: 'rsa-leaf-key-enc-pkcs8.pem', reason: 'protectionTooExpensive' },
]);
});
});
describe('Schluessel im Text', () => { describe('Schluessel im Text', () => {
it('mehrere Bloecke in einer Datei: Zertifikat und Schluessel', () => { it('mehrere Bloecke in einer Datei: Zertifikat und Schluessel', () => {
const both = Buffer.concat([fx('rsa-leaf.pem'), Buffer.from('\n'), fx('rsa-leaf-key.pem')]); const both = Buffer.concat([fx('rsa-leaf.pem'), Buffer.from('\n'), fx('rsa-leaf-key.pem')]);
+116 -11
View File
@@ -1,5 +1,6 @@
import { createHash, createPrivateKey, createPublicKey, type KeyObject } from 'node:crypto'; import { createHash, createPrivateKey, createPublicKey, type KeyObject } from 'node:crypto';
import * as forge from 'node-forge'; import * as forge from 'node-forge';
import { KdfTooExpensiveError, RequestBudget } from './cert-budget';
import { import {
certError, certError,
type IgnoredEntry, type IgnoredEntry,
@@ -34,6 +35,8 @@ export interface KeyContext {
passwords: string[]; passwords: string[];
/** Das fuer genau diese Datei eingegebene Passwort (leer, wenn keines) */ /** Das fuer genau diese Datei eingegebene Passwort (leer, wenn keines) */
ownPassword?: string; ownPassword?: string;
/** Grenzen der Anfrage (Anzahl, Rechenaufwand); ohne Angabe gelten sie nicht */
budget?: RequestBudget;
} }
export function sha256Hex(data: Buffer | string): string { export function sha256Hex(data: Buffer | string): string {
@@ -122,6 +125,7 @@ function lockReason(ctx: KeyContext): 'passwordNeeded' | 'passwordWrong' {
} }
function locked(ctx: KeyContext): KeyDetectResult { function locked(ctx: KeyContext): KeyDetectResult {
ctx.budget?.spendKey();
return { return {
items: [], items: [],
ignored: [], ignored: [],
@@ -130,6 +134,7 @@ function locked(ctx: KeyContext): KeyDetectResult {
} }
function found(key: KeyObject, ctx: KeyContext, wasEncrypted: boolean): KeyDetectResult { function found(key: KeyObject, ctx: KeyContext, wasEncrypted: boolean): KeyDetectResult {
ctx.budget?.spendKey();
return { return {
items: [keyItemFromObject(key, { file: ctx.file, path: ctx.path }, wasEncrypted)], items: [keyItemFromObject(key, { file: ctx.file, path: ctx.path }, wasEncrypted)],
ignored: [], ignored: [],
@@ -137,12 +142,72 @@ function found(key: KeyObject, ctx: KeyContext, wasEncrypted: boolean): KeyDetec
}; };
} }
/** Probiert die Kandidaten der Reihe nach; der erste, der den Schluessel oeffnet, gewinnt. */ function tooExpensive(ctx: KeyContext): KeyDetectResult {
return {
items: [],
ignored: [{ file: ctx.file, path: ctx.path, reason: 'protectionTooExpensive' }],
locked: [],
};
}
const OID_PBES2 = '1.2.840.113549.1.5.13';
const OID_PBKDF2 = '1.2.840.113549.1.5.12';
/** Wert einer ASN.1-INTEGER als Zahl; Infinity, wenn sie nicht in 48 Bit passt (dann ist sie zu gross). */
function integerValue(node: forge.asn1.Asn1 | undefined): number | null {
if (!node || node.type !== forge.asn1.Type.INTEGER || typeof node.value !== 'string') return null;
const hex = forge.util.bytesToHex(node.value);
if (hex.length > 12 && !/^0+$/.test(hex.slice(0, hex.length - 12)))
return Number.POSITIVE_INFINITY;
return Number.parseInt(hex, 16);
}
function childrenOf(node: forge.asn1.Asn1 | undefined): forge.asn1.Asn1[] {
return node && Array.isArray(node.value) ? (node.value as forge.asn1.Asn1[]) : [];
}
/**
* Ableitungsrunden des Passwortschutzes eines verschluesselten PKCS#8 (EncryptedPrivateKeyInfo),
* gelesen aus der Verfahrensangabe, bevor irgendetwas entschluesselt wird (Review WR-04).
* PBES2 mit PBKDF2: die Rundenzahl der PBKDF2-Parameter; aeltere Verfahren (PBES1, PKCS#12-PBE):
* die INTEGER der Parameter (Salz, Runden). Nicht erkennbar (z. B. scrypt): 0, dort begrenzt
* OpenSSL den Speicher selbst.
*/
export function pkcs8Iterations(root: forge.asn1.Asn1): number {
const algorithm = childrenOf(root)[0];
const [oidNode, params] = childrenOf(algorithm);
if (!oidNode || typeof oidNode.value !== 'string') return 0;
const oid = forge.asn1.derToOid(oidNode.value);
if (oid === OID_PBES2) {
const kdf = childrenOf(params)[0];
const [kdfOid, kdfParams] = childrenOf(kdf);
if (!kdfOid || typeof kdfOid.value !== 'string') return 0;
if (forge.asn1.derToOid(kdfOid.value) !== OID_PBKDF2) return 0;
return (
childrenOf(kdfParams)
.map(integerValue)
.find((n) => n !== null) ?? 0
);
}
return (
childrenOf(params)
.map(integerValue)
.find((n) => n !== null) ?? 0
);
}
/**
* Probiert die Kandidaten der Reihe nach; der erste, der den Schluessel oeffnet, gewinnt. Jeder
* Versuch kostet `iterations` Runden von der Anfragegrenze; zu teuer: KdfTooExpensiveError.
*/
function openWithPasswords( function openWithPasswords(
open: (passphrase: string) => KeyObject, open: (passphrase: string) => KeyObject,
ctx: KeyContext, ctx: KeyContext,
iterations = 0,
): KeyObject | null { ): KeyObject | null {
const budget = ctx.budget ?? new RequestBudget();
for (const passphrase of ctx.passwords) { for (const passphrase of ctx.passwords) {
budget.spendKdf(iterations);
try { try {
return open(passphrase); return open(passphrase);
} catch { } catch {
@@ -152,6 +217,27 @@ function openWithPasswords(
return null; return null;
} }
/** Base64-Rumpf eines PEM-Blocks (ohne Kopfzeilen mit Doppelpunkt) als DER. */
function derOfPemText(text: string): Buffer | null {
const lines = text.split(/\r?\n/).slice(1, -1);
const body = lines
.filter((line) => !line.includes(':'))
.join('')
.replace(/\s+/g, '');
return body ? Buffer.from(body, 'base64') : null;
}
/** Rundenzahl eines verschluesselten PKCS#8 im PEM; 0, wenn nicht erkennbar (dann scheitert Node ohnehin). */
function pemPkcs8Iterations(text: string, label: string): number {
if (label !== 'ENCRYPTED PRIVATE KEY') return 0; // klassisch (Proc-Type): eine Runde MD5
try {
const der = derOfPemText(text);
return der ? pkcs8Iterations(readAsn1(der)) : 0;
} catch {
return 0;
}
}
/** /**
* Ein PEM-Block mit Etikett „... PRIVATE KEY“. `text` ist der ganze Block samt BEGIN/END-Zeilen, * Ein PEM-Block mit Etikett „... PRIVATE KEY“. `text` ist der ganze Block samt BEGIN/END-Zeilen,
* `encrypted` ist wahr bei „ENCRYPTED PRIVATE KEY“ oder einer Kopfzeile `Proc-Type: 4,ENCRYPTED`. * `encrypted` ist wahr bei „ENCRYPTED PRIVATE KEY“ oder einer Kopfzeile `Proc-Type: 4,ENCRYPTED`.
@@ -164,8 +250,9 @@ export function keyFromPemBlock(
): KeyDetectResult | null { ): KeyDetectResult | null {
if (!label.endsWith('PRIVATE KEY')) return null; if (!label.endsWith('PRIVATE KEY')) return null;
if (!encrypted && label !== 'ENCRYPTED PRIVATE KEY') { if (!encrypted && label !== 'ENCRYPTED PRIVATE KEY') {
let plain: KeyObject;
try { try {
return found(createPrivateKey(text), ctx, false); plain = createPrivateKey(text);
} catch { } catch {
return { return {
items: [], items: [],
@@ -173,9 +260,19 @@ export function keyFromPemBlock(
locked: [], locked: [],
}; };
} }
return found(plain, ctx, false);
}
try {
const key = openWithPasswords(
(passphrase) => createPrivateKey({ key: text, passphrase }),
ctx,
pemPkcs8Iterations(text, label),
);
return key ? found(key, ctx, true) : locked(ctx);
} catch (error) {
if (error instanceof KdfTooExpensiveError) return tooExpensive(ctx);
throw error;
} }
const key = openWithPasswords((passphrase) => createPrivateKey({ key: text, passphrase }), ctx);
return key ? found(key, ctx, true) : locked(ctx);
} }
/** ASN.1 mit unveraenderten Bitfolgen; Typdefinition kennt nur `strict: boolean`, forge nimmt ein Objekt. */ /** ASN.1 mit unveraenderten Bitfolgen; Typdefinition kennt nur `strict: boolean`, forge nimmt ein Objekt. */
@@ -215,18 +312,26 @@ export function keyFromDer(der: Buffer, ctx: KeyContext): KeyDetectResult | null
return null; return null;
} }
if (isEncryptedPkcs8(root)) { if (isEncryptedPkcs8(root)) {
const key = openWithPasswords( try {
(passphrase) => createPrivateKey({ key: der, format: 'der', type: 'pkcs8', passphrase }), const key = openWithPasswords(
ctx, (passphrase) => createPrivateKey({ key: der, format: 'der', type: 'pkcs8', passphrase }),
); ctx,
return key ? found(key, ctx, true) : locked(ctx); pkcs8Iterations(root),
);
return key ? found(key, ctx, true) : locked(ctx);
} catch (error) {
if (error instanceof KdfTooExpensiveError) return tooExpensive(ctx);
throw error;
}
} }
for (const type of DER_KEY_TYPES) { for (const type of DER_KEY_TYPES) {
let plain: KeyObject;
try { try {
return found(createPrivateKey({ key: der, format: 'der', type }), ctx, false); plain = createPrivateKey({ key: der, format: 'der', type });
} catch { } catch {
// anderer Aufbau: naechsten Typ probieren continue; // anderer Aufbau: naechsten Typ probieren
} }
return found(plain, ctx, false);
} }
return null; return null;
} }
@@ -22,8 +22,8 @@ export const CERT_MANAGER_CHANGELOG: ModuleChangelog = [
}, },
{ {
kind: 'new', kind: 'new',
de: 'Alle gängigen Formate rein und raus, auch Zertifikate und Schlüssel mit elliptischen Kurven (EC) und verschlüsselte Schlüssel. PFX-Dateien wahlweise kompatibel oder modern verschlüsselt.', de: 'Alle gängigen Formate rein und raus, auch Zertifikate und Schlüssel mit elliptischen Kurven (EC) und verschlüsselte Schlüssel. PFX-Dateien wahlweise kompatibel oder modern verschlüsselt, Passwörter dürfen Umlaute enthalten. Beim Aufteilen bleiben geschützte Schlüssel geschützt.',
en: 'All common formats in and out, including elliptic-curve (EC) certificates and keys and encrypted keys. PFX files with compatible or modern encryption.', en: 'All common formats in and out, including elliptic-curve (EC) certificates and keys and encrypted keys. PFX files with compatible or modern encryption, passwords may contain umlauts. When splitting, protected keys stay protected.',
}, },
{ {
kind: 'new', kind: 'new',
@@ -6,6 +6,7 @@ import { type FetchIssuerResult, fetchIssuer } from './cert-aia';
import { analyzeWorkingSet } from './cert-analyze'; import { analyzeWorkingSet } from './cert-analyze';
import { buildOutput } from './cert-output'; import { buildOutput } from './cert-output';
import { type AnalysisResult, type BuildResult, certError } from './cert-types'; import { type AnalysisResult, type BuildResult, certError } from './cert-types';
import { createTotalLimitedStorage } from './cert-upload';
import { BuildOutputDto } from './dto/cert-build.dto'; import { BuildOutputDto } from './dto/cert-build.dto';
import { FetchIssuerDto } from './dto/cert-fetch-issuer.dto'; import { FetchIssuerDto } from './dto/cert-fetch-issuer.dto';
@@ -14,6 +15,20 @@ export const CERT_MAX_FILES = 30;
export const CERT_MAX_FILE_BYTES = 5 * 1024 * 1024; export const CERT_MAX_FILE_BYTES = 5 * 1024 * 1024;
export const CERT_MAX_TOTAL_BYTES = 20 * 1024 * 1024; export const CERT_MAX_TOTAL_BYTES = 20 * 1024 * 1024;
/**
* Grenzen fuer multer (Review WR-06): Dateien, Felder und Teile insgesamt sind begrenzt (nur das Feld
* `passwords` und die Dateien kommen vor), ein Textfeld hoechstens 256 KiB (30 Passwoerter zu je 1024
* Zeichen, auch mit JSON-Maskierung weit darunter). Die Gesamtsumme der Dateien prueft der Speicher
* schon beim Empfang (cert-upload.ts).
*/
export const CERT_UPLOAD_LIMITS = {
fileSize: CERT_MAX_FILE_BYTES,
files: CERT_MAX_FILES,
fields: 5,
parts: CERT_MAX_FILES + 5,
fieldSize: 256 * 1024,
};
/** Das Feld `passwords`: ein JSON-Array von Zeichenketten, je Datei eines (Task 4). */ /** Das Feld `passwords`: ein JSON-Array von Zeichenketten, je Datei eines (Task 4). */
export const CERT_MAX_PASSWORDS = 30; export const CERT_MAX_PASSWORDS = 30;
export const CERT_MAX_PASSWORD_CHARS = 1024; export const CERT_MAX_PASSWORD_CHARS = 1024;
@@ -71,7 +86,10 @@ export class CertManagerController {
@Post('analyze') @Post('analyze')
@HttpCode(200) @HttpCode(200)
@UseInterceptors( @UseInterceptors(
FilesInterceptor('files', CERT_MAX_FILES, { limits: { fileSize: CERT_MAX_FILE_BYTES } }), FilesInterceptor('files', CERT_MAX_FILES, {
storage: createTotalLimitedStorage(CERT_MAX_TOTAL_BYTES),
limits: CERT_UPLOAD_LIMITS,
}),
) )
analyze( analyze(
@UploadedFiles() files: UploadedFileLike[] | undefined, @UploadedFiles() files: UploadedFileLike[] | undefined,
@@ -209,3 +209,68 @@ describe('detectBlob: ZIP', () => {
expect(r.ignored).toEqual([{ file: 0, path: 'leer.zip', reason: 'unknown' }]); expect(r.ignored).toEqual([{ file: 0, path: 'leer.zip', reason: 'unknown' }]);
}); });
}); });
describe('detectBlob: viele BEGIN-Zeilen (Review CR-02)', () => {
it('5 MiB BEGIN-Zeilen ohne END werden in weit unter 100 ms verarbeitet', () => {
const line = '-----BEGIN CERTIFICATE-----\n';
const blob = Buffer.from(line.repeat(Math.ceil((5 * 1024 * 1024) / line.length)));
let best = Number.POSITIVE_INFINITY;
for (let i = 0; i < 3; i++) {
const started = performance.now();
const r = detectBlob(blob, ctx('bomb.pem'));
best = Math.min(best, performance.now() - started);
expect(r.items).toEqual([]);
}
expect(best).toBeLessThan(100);
});
it('ein echtes Zertifikat hinter vielen unvollstaendigen BEGIN-Zeilen wird gefunden', () => {
const junk = '-----BEGIN BROKEN-----\n'.repeat(20_000);
const blob = Buffer.concat([Buffer.from(junk), fx('rsa-leaf.pem')]);
const started = performance.now();
const r = detectBlob(blob, ctx('mix.pem'));
expect(performance.now() - started).toBeLessThan(500);
expect(r.items.filter((i) => i.kind === 'certificate')).toHaveLength(1);
});
});
describe('detectBlob: selbstsigniert ohne basicConstraints (Review WR-07)', () => {
const one = (name: string): CertItem => {
const found = certs(name);
expect(found).toHaveLength(1);
return found[0];
};
it('Version 1 selbstsigniert (aeltere Wurzel): Wurzel und CA', () => {
const root = one('selfsigned-v1-root.pem');
expect(new X509Certificate(fx('selfsigned-v1-root.pem')).ca).toBe(false); // Node allein sieht keine CA
expect(root.selfSigned).toBe(true);
expect(root.isCa).toBe(true);
expect(root.role).toBe('root');
});
it('Version 3 ohne basicConstraints, aber mit keyCertSign: Wurzel', () => {
const root = one('selfsigned-nobc-ca.pem');
expect(root.isCa).toBe(true);
expect(root.role).toBe('root');
});
it('Version 3 ohne basicConstraints, nur Server-Schluesselverwendung: bleibt Serverzertifikat', () => {
const leaf = one('selfsigned-nobc-leaf.pem');
expect(leaf.selfSigned).toBe(true);
expect(leaf.isCa).toBe(false);
expect(leaf.role).toBe('end-entity');
});
it('mit basicConstraints cA:FALSE entscheidet allein diese Angabe', () => {
const leaf = one('selfsigned-leaf.pem');
expect(leaf.role).toBe('end-entity');
expect(leaf.isCa).toBe(false);
});
it('bestehende Rollen bleiben: Wurzel, Zwischenzertifikat, Serverzertifikat', () => {
expect(one('rsa-root.pem').role).toBe('root');
expect(one('rsa-inter.pem').role).toBe('intermediate');
expect(one('rsa-leaf.pem').role).toBe('end-entity');
});
});
+113 -31
View File
@@ -1,5 +1,7 @@
import { X509Certificate } from 'node:crypto'; import { X509Certificate } from 'node:crypto';
import * as forge from 'node-forge'; import * as forge from 'node-forge';
import { acceptableIssuerUrls } from './cert-aia-url';
import { type RequestBudget, rethrowRequestError } from './cert-budget';
import { csrItemFromDer } from './cert-csr'; import { csrItemFromDer } from './cert-csr';
import { import {
describeKey, describeKey,
@@ -15,10 +17,12 @@ import type {
AnyItem, AnyItem,
CertItem, CertItem,
CertRole, CertRole,
CsrItem,
IgnoredEntry, IgnoredEntry,
ItemSource, ItemSource,
LockedEntry, LockedEntry,
} from './cert-types'; } from './cert-types';
import { scanPemBlocks } from './pem-scan';
import { expandZip, isZip } from './zip-expand'; import { expandZip, isZip } from './zip-expand';
// Die Schluesselhelfer wohnen in cert-keys.ts (dort ohne Importschleife nutzbar); hier weiter erreichbar. // Die Schluesselhelfer wohnen in cert-keys.ts (dort ohne Importschleife nutzbar); hier weiter erreichbar.
@@ -47,6 +51,8 @@ export interface DetectContext {
passwords: string[]; passwords: string[];
/** Das fuer genau diese Datei eingegebene Passwort; leer, wenn keines. Nur fuer „gesperrt: Passwort falsch“. */ /** Das fuer genau diese Datei eingegebene Passwort; leer, wenn keines. Nur fuer „gesperrt: Passwort falsch“. */
ownPassword?: string; ownPassword?: string;
/** Grenzen der Anfrage (Anzahl, Rechenaufwand); ohne Angabe gelten sie nicht */
budget?: RequestBudget;
} }
export interface DetectResult { export interface DetectResult {
@@ -58,7 +64,6 @@ export interface DetectResult {
const CERT_LABELS = new Set(['CERTIFICATE', 'X509 CERTIFICATE', 'TRUSTED CERTIFICATE']); const CERT_LABELS = new Set(['CERTIFICATE', 'X509 CERTIFICATE', 'TRUSTED CERTIFICATE']);
const PKCS7_LABELS = new Set(['PKCS7', 'CMS']); const PKCS7_LABELS = new Set(['PKCS7', 'CMS']);
const CSR_LABELS = new Set(['CERTIFICATE REQUEST', 'NEW CERTIFICATE REQUEST']); const CSR_LABELS = new Set(['CERTIFICATE REQUEST', 'NEW CERTIFICATE REQUEST']);
const PEM_BLOCK = /-----BEGIN ([A-Z0-9 ]+)-----([\s\S]*?)-----END \1-----/g;
const BASE64_BODY = /^[A-Za-z0-9+/]+={0,2}$/; const BASE64_BODY = /^[A-Za-z0-9+/]+={0,2}$/;
function firstValue(v: unknown): string { function firstValue(v: unknown): string {
@@ -82,20 +87,8 @@ function sanList(subjectAltName: string | undefined): string[] {
} }
function aiaUrls(legacyInfoAccess: unknown): string[] { function aiaUrls(legacyInfoAccess: unknown): string[] {
const raw = (legacyInfoAccess as Record<string, unknown> | undefined)?.['CA Issuers - URI']; // Gleicher Filter wie beim Abruf (cert-aia-url.ts): der Knopf erscheint nur, wenn ein Abruf moeglich ist.
const list = Array.isArray(raw) ? raw : typeof raw === 'string' ? [raw] : []; return acceptableIssuerUrls(legacyInfoAccess).map((url) => url.href);
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;
} }
/** /**
@@ -112,6 +105,73 @@ function isSelfSigned(x: X509Certificate): boolean {
} }
} }
const OID_BASIC_CONSTRAINTS = '2.5.29.19';
const OID_KEY_USAGE = '2.5.29.15';
/** keyCertSign ist Bit 5 der Schluesselverwendung (RFC 5280); im ersten Byte ist das 0x04. */
const KEY_CERT_SIGN_MASK = 0x04;
interface CaHints {
/** Erweiterung basicConstraints vorhanden (dann entscheidet allein ihr cA-Wert) */
hasBasicConstraints: boolean;
/** Zertifikat der Version 1 (keine Erweiterungen) */
isV1: boolean;
/** Schluesselverwendung nennt keyCertSign */
keyCertSign: boolean;
}
/**
* Liest Version, basicConstraints und Schluesselverwendung aus dem DER (reiner ASN.1-Lauf; Node
* nennt `ca` nur aus basicConstraints und gibt die Schluesselverwendung nicht heraus). Nicht
* lesbar: alles falsch, das Zertifikat bleibt, was `x.ca` sagt.
*/
function caHints(raw: Buffer): CaHints {
const none: CaHints = { hasBasicConstraints: false, isV1: false, keyCertSign: false };
try {
const options = { decodeBitStrings: false } as unknown as boolean;
const root = forge.asn1.fromDer(forge.util.createBuffer(raw.toString('binary')), options);
const tbs = (root.value as forge.asn1.Asn1[])[0];
const parts = tbs.value as forge.asn1.Asn1[];
const isV1 = !(parts[0].tagClass === forge.asn1.Class.CONTEXT_SPECIFIC && parts[0].type === 0);
const extensionsHolder = parts.find(
(p) => p.tagClass === forge.asn1.Class.CONTEXT_SPECIFIC && p.type === 3,
);
const hints: CaHints = { ...none, isV1 };
const extensions = (extensionsHolder?.value as forge.asn1.Asn1[] | undefined)?.[0]?.value as
| forge.asn1.Asn1[]
| undefined;
for (const extension of extensions ?? []) {
const fields = extension.value as forge.asn1.Asn1[];
const oid = forge.asn1.derToOid(fields[0].value as string);
if (oid === OID_BASIC_CONSTRAINTS) hints.hasBasicConstraints = true;
if (oid === OID_KEY_USAGE) {
// OCTET STRING mit einer BIT STRING: Tag, Laenge, Anzahl ungenutzter Bits, dann die Bits
const octets = fields[fields.length - 1].value as string;
const bits = Buffer.from(octets, 'binary');
if (bits.length >= 4 && bits[0] === 0x03) {
hints.keyCertSign = (bits[3] & KEY_CERT_SIGN_MASK) !== 0;
}
}
}
return hints;
} catch {
return none;
}
}
/**
* Ist das Zertifikat eine CA? Wie OpenSSLs X509_check_ca: mit basicConstraints entscheidet cA;
* ohne diese Erweiterung zaehlt ein selbstsigniertes Zertifikat der Version 1 (aeltere Wurzeln)
* oder eines mit keyCertSign in der Schluesselverwendung als CA. Ein selbstsigniertes
* Serverzertifikat ohne basicConstraints (nur digitalSignature, keyEncipherment) bleibt ein
* Serverzertifikat (Review WR-07).
*/
function isCaCertificate(x: X509Certificate, selfSigned: boolean): boolean {
if (x.ca) return true;
const hints = caHints(x.raw);
if (hints.hasBasicConstraints) return false;
return (hints.isV1 && selfSigned) || hints.keyCertSign;
}
export function roleOf(isCa: boolean, selfSigned: boolean): CertRole { export function roleOf(isCa: boolean, selfSigned: boolean): CertRole {
if (!isCa) return 'end-entity'; if (!isCa) return 'end-entity';
return selfSigned ? 'root' : 'intermediate'; return selfSigned ? 'root' : 'intermediate';
@@ -127,7 +187,7 @@ export function certItemFromDer(der: Buffer, source: ItemSource): CertItem {
}; };
const cn = firstValue(legacy.subject?.CN); const cn = firstValue(legacy.subject?.CN);
const selfSigned = isSelfSigned(x); const selfSigned = isSelfSigned(x);
const isCa = x.ca; const isCa = isCaCertificate(x, selfSigned);
const role = roleOf(isCa, selfSigned); const role = roleOf(isCa, selfSigned);
const notAfter = x.validToDate; const notAfter = x.validToDate;
const key = describeKey(x.publicKey); const key = describeKey(x.publicKey);
@@ -268,9 +328,8 @@ interface PemBlock {
function pemBlocks(text: string): PemBlock[] { function pemBlocks(text: string): PemBlock[] {
const blocks: PemBlock[] = []; const blocks: PemBlock[] = [];
for (const m of text.matchAll(PEM_BLOCK)) { for (const m of scanPemBlocks(text)) {
const label = m[1]; const rawBody = m.body;
const rawBody = m[2];
const encrypted = /Proc-Type:\s*4,\s*ENCRYPTED/i.test(rawBody); const encrypted = /Proc-Type:\s*4,\s*ENCRYPTED/i.test(rawBody);
const body = rawBody const body = rawBody
.split(/\r?\n/) .split(/\r?\n/)
@@ -278,7 +337,12 @@ function pemBlocks(text: string): PemBlock[] {
.join('') .join('')
.replace(/\s+/g, ''); .replace(/\s+/g, '');
const valid = BASE64_BODY.test(body) && body.length % 4 === 0; const valid = BASE64_BODY.test(body) && body.length % 4 === 0;
blocks.push({ label, text: m[0], der: valid ? Buffer.from(body, 'base64') : null, encrypted }); blocks.push({
label: m.label,
text: m.text,
der: valid ? Buffer.from(body, 'base64') : null,
encrypted,
});
} }
return blocks; return blocks;
} }
@@ -286,11 +350,14 @@ function pemBlocks(text: string): PemBlock[] {
function certFromDer(der: Buffer, ctx: DetectContext): CertItem | null { function certFromDer(der: Buffer, ctx: DetectContext): CertItem | null {
const seq = leadingDerSequence(der); const seq = leadingDerSequence(der);
if (!seq) return null; if (!seq) return null;
let item: CertItem;
try { try {
return certItemFromDer(seq, { file: ctx.file, path: ctx.path }); item = certItemFromDer(seq, { file: ctx.file, path: ctx.path });
} catch { } catch {
return null; return null;
} }
ctx.budget?.spendCert();
return item;
} }
function mergeInto(result: DetectResult, part: DetectResult | null): void { function mergeInto(result: DetectResult, part: DetectResult | null): void {
@@ -313,7 +380,8 @@ function detectPem(blob: Buffer, ctx: DetectContext): DetectResult | null {
result, result,
keyFromPemBlock(block.text, block.label, block.encrypted, keyContext(ctx)), keyFromPemBlock(block.text, block.label, block.encrypted, keyContext(ctx)),
); );
} catch { } catch (error) {
rethrowRequestError(error);
// kaputter Schluesselblock: die anderen Bloecke der Datei bleiben gueltig // kaputter Schluesselblock: die anderen Bloecke der Datei bleiben gueltig
} }
continue; continue;
@@ -326,16 +394,22 @@ function detectPem(blob: Buffer, ctx: DetectContext): DetectResult | null {
if (PKCS7_LABELS.has(block.label)) { if (PKCS7_LABELS.has(block.label)) {
try { try {
result.items.push(...pkcs7Items(block.der, ctx)); result.items.push(...pkcs7Items(block.der, ctx));
} catch { } catch (error) {
rethrowRequestError(error);
// kaputter PKCS#7-Block: die anderen Bloecke der Datei bleiben gueltig // kaputter PKCS#7-Block: die anderen Bloecke der Datei bleiben gueltig
} }
} }
if (CSR_LABELS.has(block.label)) { if (CSR_LABELS.has(block.label)) {
let csr: CsrItem | null = null;
try { try {
result.items.push(csrItemFromDer(block.der, { file: ctx.file, path: ctx.path })); csr = csrItemFromDer(block.der, { file: ctx.file, path: ctx.path });
} catch { } catch {
// keine lesbare Anfrage: ueberspringen // keine lesbare Anfrage: ueberspringen
} }
if (csr) {
ctx.budget?.spendCsr();
result.items.push(csr);
}
} }
} }
const empty = const empty =
@@ -356,6 +430,7 @@ function keyContext(ctx: DetectContext) {
path: ctx.path, path: ctx.path,
passwords: ctx.passwords, passwords: ctx.passwords,
ownPassword: ctx.ownPassword, ownPassword: ctx.ownPassword,
budget: ctx.budget,
}; };
} }
@@ -365,8 +440,15 @@ function keyContext(ctx: DetectContext) {
*/ */
function detectPkcs12(blob: Buffer, ctx: DetectContext): DetectResult | null { function detectPkcs12(blob: Buffer, ctx: DetectContext): DetectResult | null {
if (!isPkcs12Der(blob)) return null; if (!isPkcs12Der(blob)) return null;
const read = readPkcs12(blob, ctx.passwords, ctx.ownPassword ?? ''); const read = readPkcs12(blob, ctx.passwords, ctx.ownPassword ?? '', ctx.budget);
if (!read.ok) { if (!read.ok) {
if (read.reason === 'tooExpensive') {
return {
items: [],
ignored: [{ file: ctx.file, path: ctx.path, reason: 'protectionTooExpensive' }],
locked: [],
};
}
return { return {
items: [], items: [],
ignored: [], ignored: [],
@@ -379,6 +461,7 @@ function detectPkcs12(blob: Buffer, ctx: DetectContext): DetectResult | null {
if (item) result.items.push(item); if (item) result.items.push(item);
} }
for (const { key, shrouded } of read.contents.keys) { for (const { key, shrouded } of read.contents.keys) {
ctx.budget?.spendKey();
result.items.push(keyItemFromObject(key, { file: ctx.file, path: ctx.path }, shrouded)); result.items.push(keyItemFromObject(key, { file: ctx.file, path: ctx.path }, shrouded));
} }
return result.items.length > 0 ? result : null; return result.items.length > 0 ? result : null;
@@ -397,11 +480,9 @@ function detectPrivateKey(blob: Buffer, ctx: DetectContext): DetectResult | null
/** Zertifikatsanfrage als DER. */ /** Zertifikatsanfrage als DER. */
function detectCsr(blob: Buffer, ctx: DetectContext): DetectResult | null { function detectCsr(blob: Buffer, ctx: DetectContext): DetectResult | null {
return { const csr = csrItemFromDer(blob, { file: ctx.file, path: ctx.path });
items: [csrItemFromDer(blob, { file: ctx.file, path: ctx.path })], ctx.budget?.spendCsr();
ignored: [], return { items: [csr], ignored: [], locked: [] };
locked: [],
};
} }
const STAGES: ((blob: Buffer, ctx: DetectContext) => DetectResult | null)[] = [ const STAGES: ((blob: Buffer, ctx: DetectContext) => DetectResult | null)[] = [
@@ -423,7 +504,8 @@ export function detectBlob(blob: Buffer, ctx: DetectContext): DetectResult {
try { try {
const result = stage(blob, ctx); const result = stage(blob, ctx);
if (result) return result; if (result) return result;
} catch { } catch (error) {
rethrowRequestError(error);
// diese Stufe kann den Inhalt nicht lesen: naechste Stufe versuchen // diese Stufe kann den Inhalt nicht lesen: naechste Stufe versuchen
} }
} }
+158 -1
View File
@@ -1,8 +1,9 @@
import { createPrivateKey, X509Certificate } from 'node:crypto'; import { createPrivateKey, createPublicKey, X509Certificate } from 'node:crypto';
import { readFileSync } from 'node:fs'; import { readFileSync } from 'node:fs';
import { join } from 'node:path'; import { join } from 'node:path';
import * as forge from 'node-forge'; import * as forge from 'node-forge';
import { describe, expect, it, vi } from 'vitest'; import { describe, expect, it, vi } from 'vitest';
import { MAX_KDF_ITERATIONS, RequestBudget } from './cert-budget';
import { candidatePasswords, keyIdOf } from './cert-keys'; import { candidatePasswords, keyIdOf } from './cert-keys';
import { detectBlob } from './cert-model'; import { detectBlob } from './cert-model';
import { isPkcs12Der, readPkcs12, writePkcs12 } from './cert-pkcs12'; import { isPkcs12Der, readPkcs12, writePkcs12 } from './cert-pkcs12';
@@ -292,3 +293,159 @@ describe('writePkcs12', () => {
expect(der.includes(Buffer.from(NEW_PASSWORD))).toBe(false); expect(der.includes(Buffer.from(NEW_PASSWORD))).toBe(false);
}); });
}); });
// ---------------------------------------------------------------------------
// Review CR-03: Passwoerter mit Umlauten und Eurozeichen (UTF-8 wie OpenSSL 3, Windows, Java)
// ---------------------------------------------------------------------------
/** Die drei Ableitungsfunktionen von forge, die readPkcs12 und writePkcs12 zeitweise austauschen. */
function forgeKdf(): unknown[] {
const pbe = (forge.pki as unknown as { pbe: Record<string, unknown> }).pbe;
return [forge.pkcs5.pbkdf2, pbe.generatePkcs12Key, forge.pkcs12.generateKey];
}
const UMLAUT_PASSWORDS = [
['pässwörd', 'umlaut'],
['pw€', 'euro'],
] as const;
describe('PKCS#12 mit Umlaut- und Euro-Passwoertern (OpenSSL-Dateien lesen)', () => {
const files = UMLAUT_PASSWORDS.flatMap(([password, name]) =>
(['modern', 'compat'] as const).map(
(profile) => [`rsa-${name}-${profile}.pfx`, password] as const,
),
);
it.each(files)('%s: das richtige Passwort oeffnet die Datei', (file, password) => {
const result = readPkcs12(fx(file), [password], password);
expect(result.ok).toBe(true);
if (!result.ok) return;
expect(result.contents.certDers).toHaveLength(3);
expect(result.contents.keys).toHaveLength(1);
expect(result.contents.keys[0].key.asymmetricKeyType).toBe('rsa');
});
it.each(files)('%s: ein falsches Passwort bleibt passwordWrong', (file) => {
expect(readPkcs12(fx(file), ['passwoerd'], 'passwoerd')).toEqual({
ok: false,
reason: 'passwordWrong',
});
});
it('forge ist nach dem Lesen unveraendert (PBKDF2 und PKCS#12-Ableitung)', () => {
const before = forgeKdf();
readPkcs12(fx('rsa-umlaut-modern.pfx'), ['pässwörd'], 'pässwörd');
readPkcs12(fx('rsa-umlaut-modern.pfx'), ['falsch'], 'falsch');
expect(forgeKdf()).toEqual(before);
});
});
/** Der verschluesselte Schluessel (EncryptedPrivateKeyInfo) aus einem PFX, ohne forges Entschluesselung. */
function shroudedKeyDer(pfx: Buffer): Buffer {
const OID_SHROUDED = '1.2.840.113549.1.12.10.1.2';
const root = forge.asn1.fromDer(forge.util.createBuffer(pfx.toString('binary')));
const authSafeContent = (root.value as forge.asn1.Asn1[])[1];
const octets = ((authSafeContent.value as forge.asn1.Asn1[])[1].value as forge.asn1.Asn1[])[0];
const authSafe = forge.asn1.fromDer(forge.util.createBuffer(octets.value as string));
for (const info of authSafe.value as forge.asn1.Asn1[]) {
const wrapped = ((info.value as forge.asn1.Asn1[])[1].value as forge.asn1.Asn1[])[0];
if (typeof wrapped.value !== 'string') continue; // verschluesselte Zertifikate
const safeContents = forge.asn1.fromDer(forge.util.createBuffer(wrapped.value));
for (const bag of safeContents.value as forge.asn1.Asn1[]) {
const [oid, content] = bag.value as forge.asn1.Asn1[];
if (forge.asn1.derToOid(oid.value as string) === OID_SHROUDED) {
const encrypted = (content.value as forge.asn1.Asn1[])[0];
return Buffer.from(forge.asn1.toDer(encrypted).getBytes(), 'binary');
}
}
}
throw new Error('no shrouded key bag');
}
describe('writePkcs12 mit Umlaut- und Euro-Passwoertern (fuer OpenSSL, Windows und Java lesbar)', () => {
const key = createPrivateKey(fx('rsa-leaf-key.pem'));
const certs = ['rsa-leaf.pem', 'rsa-inter.pem'].map((f) => new X509Certificate(fx(f)).raw);
it.each(
UMLAUT_PASSWORDS.flatMap(
([password]) =>
[
['modern', password],
['compat', password],
] as const,
),
)('%s mit %s: der Schluessel oeffnet sich mit UTF-8 (wie OpenSSL) und das Passwort ist lesbar', (profile, password) => {
const pfx = writePkcs12({
keyObject: key,
certDers: certs,
password,
profile,
friendlyName: 'server',
});
// Unabhaengig von forge: node:crypto leitet PBES2 aus den UTF-8-Bytes ab (wie OpenSSL 3).
if (profile === 'modern') {
const opened = createPrivateKey({
key: shroudedKeyDer(pfx),
format: 'der',
type: 'pkcs8',
passphrase: password,
});
expect(keyIdOf(createPublicKey(opened))).toBe(keyIdOf(createPublicKey(key)));
}
const back = readPkcs12(pfx, [password], password);
expect(back.ok).toBe(true);
if (back.ok) expect(back.contents.keys).toHaveLength(1);
});
});
// ---------------------------------------------------------------------------
// Review WR-04: Obergrenze fuer Ableitungsrunden
// ---------------------------------------------------------------------------
describe('PKCS#12: zu aufwendiger Passwortschutz', () => {
it('2 000 000 Runden: tooExpensive, schnell und ohne zu rechnen', () => {
const started = performance.now();
const result = readPkcs12(fx('rsa-expensive.pfx'), ['Test-Pass-123'], 'Test-Pass-123');
expect(result).toEqual({ ok: false, reason: 'tooExpensive' });
expect(performance.now() - started).toBeLessThan(1000);
});
it('detectBlob meldet protectionTooExpensive statt gesperrt', () => {
const r = detectBlob(fx('rsa-expensive.pfx'), {
file: 0,
path: 'teuer.pfx',
passwords: ['Test-Pass-123'],
ownPassword: 'Test-Pass-123',
});
expect(r.items).toEqual([]);
expect(r.locked).toEqual([]);
expect(r.ignored).toEqual([{ file: 0, path: 'teuer.pfx', reason: 'protectionTooExpensive' }]);
});
it('die Grenze steht bei einer Million Runden', () => {
expect(MAX_KDF_ITERATIONS).toBe(1_000_000);
const budget = new RequestBudget();
expect(() => budget.spendKdf(1_000_000)).not.toThrow();
expect(() => new RequestBudget().spendKdf(1_000_001)).toThrow();
});
it('die Summe je Anfrage ist begrenzt (viele Versuche mit hoher, erlaubter Rundenzahl)', () => {
const budget = new RequestBudget();
let accepted = 0;
try {
for (let i = 0; i < 100; i++) {
budget.spendKdf(600_000);
accepted++;
}
} catch {
// erwartet
}
expect(accepted).toBe(10);
});
it('forge ist nach einer abgelehnten Datei unveraendert', () => {
const before = forgeKdf();
readPkcs12(fx('rsa-expensive.pfx'), ['Test-Pass-123'], 'Test-Pass-123');
expect(forgeKdf()).toEqual(before);
});
});
+103 -13
View File
@@ -1,5 +1,6 @@
import { createPrivateKey, type KeyObject } from 'node:crypto'; import { createPrivateKey, type KeyObject, pbkdf2Sync } from 'node:crypto';
import * as forge from 'node-forge'; import * as forge from 'node-forge';
import { KdfTooExpensiveError, RequestBudget } from './cert-budget';
import { MAX_PASSWORDS } from './cert-keys'; import { MAX_PASSWORDS } from './cert-keys';
/** /**
@@ -26,7 +27,76 @@ export interface Pkcs12Contents {
export type Pkcs12Read = export type Pkcs12Read =
| { ok: true; contents: Pkcs12Contents } | { ok: true; contents: Pkcs12Contents }
| { ok: false; reason: 'passwordNeeded' | 'passwordWrong' }; | { ok: false; reason: 'passwordNeeded' | 'passwordWrong' | 'tooExpensive' };
/** Namen der PRF fuer node:crypto aus dem, was forge uebergibt (Zeichenkette oder md-Objekt). */
function prfName(md: unknown): string {
if (typeof md === 'string') return md;
const algorithm = (md as { algorithm?: unknown } | null | undefined)?.algorithm;
return typeof algorithm === 'string' ? algorithm : 'sha1';
}
/**
* Fuehrt `fn` mit zwei Eingriffen in forges Schluesselableitung aus (Review CR-03, WR-04). Alles
* laeuft synchron; die Originale stehen im `finally` wieder an ihrem Platz (gleiche Begruendung
* wie bei `writePkcs12`: Node ist einfaedig, kein anderer Aufrufer sieht den Austausch).
*
* 1. PBKDF2 (PBES2, AES): forge nimmt das Passwort als JS-Zeichenkette und macht daraus ein Byte
* je UTF-16-Einheit. OpenSSL 3, Windows und Java nehmen die UTF-8-Bytes; bei Umlauten oder dem
* Eurozeichen waere ein Container sonst fuer jedes andere Programm unlesbar und umgekehrt.
* Hier wird mit node:crypto aus den UTF-8-Bytes abgeleitet (auch SHA-256/512 nativ statt in
* reinem JavaScript). Die PKCS#12-eigene Ableitung (3DES, RC2, MAC) bleibt wie sie ist: sie
* nimmt BMPString (UTF-16) aus den Zeichen, genau wie OpenSSL.
* 2. Aufwandsgrenze: jede Ableitung meldet ihre Rundenzahl an `budget`, bevor sie rechnet; zu viele
* Runden (oder eine aufgebrauchte Anfragegrenze) brechen vorher ab.
*/
function withForgeKdf<T>(budget: RequestBudget, fn: () => T, onExpensive?: () => void): T {
const pkcs5 = forge.pkcs5 as unknown as Record<string, unknown>;
const pbe = (forge.pki as unknown as { pbe: Record<string, unknown> }).pbe;
const p12 = forge.pkcs12 as unknown as Record<string, unknown>;
const original = {
pbkdf2: pkcs5.pbkdf2,
generatePkcs12Key: pbe.generatePkcs12Key,
generateKey: p12.generateKey,
};
const spend = (iterations: number): void => {
try {
budget.spendKdf(iterations);
} catch (error) {
onExpensive?.();
throw error;
}
};
try {
pkcs5.pbkdf2 = (
password: string,
salt: string,
iterations: number,
keyLength: number,
md?: unknown,
): string => {
spend(iterations);
return pbkdf2Sync(
Buffer.from(password, 'utf8'),
Buffer.from(salt, 'binary'),
iterations,
keyLength,
prfName(md),
).toString('binary');
};
const guardedKdf = (...args: unknown[]): unknown => {
spend(args[3] as number);
return (original.generatePkcs12Key as (...a: unknown[]) => unknown)(...args);
};
pbe.generatePkcs12Key = guardedKdf;
p12.generateKey = guardedKdf;
return fn();
} finally {
pkcs5.pbkdf2 = original.pbkdf2;
pbe.generatePkcs12Key = original.generatePkcs12Key;
p12.generateKey = original.generateKey;
}
}
function parseAsn1(der: Buffer): forge.asn1.Asn1 { function parseAsn1(der: Buffer): forge.asn1.Asn1 {
// Die Typdefinition kennt nur `strict: boolean`; forge nimmt zur Laufzeit ein Optionsobjekt. // Die Typdefinition kennt nur `strict: boolean`; forge nimmt zur Laufzeit ein Optionsobjekt.
@@ -98,9 +168,15 @@ function extract(p12: forge.pkcs12.Pkcs12Pfx): Pkcs12Contents {
* Oeffnet einen PKCS#12-Container. Probiert das Passwort der Datei (`ownPassword`), das leere * Oeffnet einen PKCS#12-Container. Probiert das Passwort der Datei (`ownPassword`), das leere
* Passwort und danach die uebrigen `passwords`, zusammen hoechstens MAX_PASSWORDS + 1 Versuche. * Passwort und danach die uebrigen `passwords`, zusammen hoechstens MAX_PASSWORDS + 1 Versuche.
* Ohne passendes Passwort: gesperrt; `passwordWrong`, wenn die Datei ein eigenes Passwort hatte. * Ohne passendes Passwort: gesperrt; `passwordWrong`, wenn die Datei ein eigenes Passwort hatte.
* Zu viele Ableitungsrunden (WR-04): `tooExpensive`, bevor irgendetwas entschluesselt wird.
* Ein Container, den forge aus anderem Grund nicht lesen kann, wirft (der Aufrufer meldet „unbekannt“). * Ein Container, den forge aus anderem Grund nicht lesen kann, wirft (der Aufrufer meldet „unbekannt“).
*/ */
export function readPkcs12(der: Buffer, passwords: string[], ownPassword = ''): Pkcs12Read { export function readPkcs12(
der: Buffer,
passwords: string[],
ownPassword = '',
budget: RequestBudget = new RequestBudget(),
): Pkcs12Read {
const asn1 = parseAsn1(der); const asn1 = parseAsn1(der);
const tries: string[] = []; const tries: string[] = [];
for (const p of [ownPassword, '', ...passwords]) { for (const p of [ownPassword, '', ...passwords]) {
@@ -108,10 +184,22 @@ export function readPkcs12(der: Buffer, passwords: string[], ownPassword = ''):
} }
let passwordProblem = false; let passwordProblem = false;
let lastError: unknown = new Error('unreadable PKCS#12'); let lastError: unknown = new Error('unreadable PKCS#12');
let expensive = false;
for (const password of tries) { for (const password of tries) {
try { try {
return { ok: true, contents: extract(forge.pkcs12.pkcs12FromAsn1(asn1, false, password)) }; const p12 = withForgeKdf(
budget,
() => forge.pkcs12.pkcs12FromAsn1(asn1, false, password),
() => {
expensive = true;
},
);
return { ok: true, contents: extract(p12) };
} catch (error) { } catch (error) {
// forge verpackt Fehler aus der Ableitung teils neu: darum zaehlt die Markierung, nicht der Typ.
if (expensive || error instanceof KdfTooExpensiveError) {
return { ok: false, reason: 'tooExpensive' };
}
lastError = error; lastError = error;
const message = error instanceof Error ? error.message : ''; const message = error instanceof Error ? error.message : '';
if (/mac|password|decrypt|padding|invalid|asn\.?1|too few bytes/i.test(message)) { if (/mac|password|decrypt|padding|invalid|asn\.?1|too few bytes/i.test(message)) {
@@ -177,15 +265,17 @@ export function writePkcs12(options: WritePkcs12Options): Buffer {
pki.privateKeyToAsn1 = (wrapped: { asn1: forge.asn1.Asn1 }) => wrapped.asn1; pki.privateKeyToAsn1 = (wrapped: { asn1: forge.asn1.Asn1 }) => wrapped.asn1;
pki.wrapRsaPrivateKey = (asn1: forge.asn1.Asn1) => asn1; pki.wrapRsaPrivateKey = (asn1: forge.asn1.Asn1) => asn1;
pki.certificateToAsn1 = (wrapped: { asn1: forge.asn1.Asn1 }) => wrapped.asn1; pki.certificateToAsn1 = (wrapped: { asn1: forge.asn1.Asn1 }) => wrapped.asn1;
const p12 = forge.pkcs12.toPkcs12Asn1( const p12 = withForgeKdf(new RequestBudget(), () =>
(keyAsn1 ? { asn1: keyAsn1 } : null) as unknown as forge.pki.rsa.PrivateKey | null, forge.pkcs12.toPkcs12Asn1(
certs as unknown as forge.pki.Certificate[], (keyAsn1 ? { asn1: keyAsn1 } : null) as unknown as forge.pki.rsa.PrivateKey | null,
options.password, certs as unknown as forge.pki.Certificate[],
{ options.password,
algorithm: options.profile === 'modern' ? 'aes256' : '3des', {
friendlyName: bmpString(options.friendlyName), algorithm: options.profile === 'modern' ? 'aes256' : '3des',
generateLocalKeyId: true, friendlyName: bmpString(options.friendlyName),
}, generateLocalKeyId: true,
},
),
); );
return toBuffer(p12); return toBuffer(p12);
} finally { } finally {
+5 -2
View File
@@ -40,7 +40,7 @@ export interface CertItem {
sha1: string; sha1: string;
isCa: boolean; isCa: boolean;
selfSigned: boolean; selfSigned: boolean;
/** nur http/https, hoechstens 5 */ /** nur abrufbare Adressen (http/https, Standardport, ohne Zugangsdaten), hoechstens 3 */
aiaIssuerUrls: string[]; aiaIssuerUrls: string[];
/** Kennung des oeffentlichen Schluessels: 'k-' + 16 Hex von sha256(SPKI-DER), passt zu KeyItem.id und CsrItem.keyId */ /** Kennung des oeffentlichen Schluessels: 'k-' + 16 Hex von sha256(SPKI-DER), passt zu KeyItem.id und CsrItem.keyId */
keyId: string | null; keyId: string | null;
@@ -114,7 +114,8 @@ export type IgnoredReason =
| 'suspicious' | 'suspicious'
| 'zipTooLarge' | 'zipTooLarge'
| 'tooManyEntries' | 'tooManyEntries'
| 'unsupportedKey'; | 'unsupportedKey'
| 'protectionTooExpensive';
export interface IgnoredEntry { export interface IgnoredEntry {
file: number; file: number;
@@ -179,7 +180,9 @@ export type CertErrorCode =
| 'formatNotPossible' | 'formatNotPossible'
| 'templateNeedsKey' | 'templateNeedsKey'
| 'tooLarge' | 'tooLarge'
| 'tooManyItems'
| 'aiaMissing' | 'aiaMissing'
| 'aiaNotAllowed'
| 'aiaInternal' | 'aiaInternal'
| 'aiaNotIssuer' | 'aiaNotIssuer'
| 'aiaUnreachable' | 'aiaUnreachable'
@@ -0,0 +1,80 @@
import { PassThrough } from 'node:stream';
import { describe, expect, it } from 'vitest';
import { CERT_UPLOAD_LIMITS } from './cert-manager.controller';
import { createTotalLimitedStorage } from './cert-upload';
type Result = { error: unknown; info?: { buffer: Buffer; size: number } };
function handle(
storage: ReturnType<typeof createTotalLimitedStorage>,
req: object,
chunks: Buffer[],
): Promise<Result> {
return new Promise((resolve) => {
const stream = new PassThrough();
storage._handleFile(req, { stream }, (error, info) => resolve({ error, info }));
for (const chunk of chunks) stream.write(chunk);
stream.end();
});
}
describe('createTotalLimitedStorage (Review WR-06)', () => {
it('sammelt eine Datei wie multers Speicher (buffer und size)', async () => {
const storage = createTotalLimitedStorage(100);
const r = await handle(storage, {}, [Buffer.from('ab'), Buffer.from('cd')]);
expect(r.error).toBeNull();
expect(r.info?.buffer.toString()).toBe('abcd');
expect(r.info?.size).toBe(4);
});
it('die Summe mehrerer Dateien einer Anfrage zaehlt: die Datei, die die Grenze reisst, bricht ab', async () => {
const storage = createTotalLimitedStorage(10);
const req = {};
const first = await handle(storage, req, [Buffer.alloc(6)]);
expect(first.error).toBeNull();
const second = await handle(storage, req, [Buffer.alloc(3), Buffer.alloc(3)]);
const e = second.error as { getStatus(): number; getResponse(): { code: string } };
expect(e.getStatus()).toBe(413);
expect(e.getResponse().code).toBe('tooLarge');
expect(second.info).toBeUndefined();
});
it('meldet den Fehler genau einmal, auch wenn danach noch Daten eintreffen', async () => {
const storage = createTotalLimitedStorage(4);
let calls = 0;
await new Promise<void>((resolve) => {
const stream = new PassThrough();
storage._handleFile({}, { stream }, () => {
calls++;
});
stream.write(Buffer.alloc(10));
stream.write(Buffer.alloc(10));
stream.end();
stream.on('end', () => resolve());
stream.resume();
});
expect(calls).toBe(1);
});
it('verschiedene Anfragen zaehlen getrennt', async () => {
const storage = createTotalLimitedStorage(10);
expect((await handle(storage, {}, [Buffer.alloc(9)])).error).toBeNull();
expect((await handle(storage, {}, [Buffer.alloc(9)])).error).toBeNull();
});
it('_removeFile entfernt den Puffer', () => {
const storage = createTotalLimitedStorage(10);
const file = { stream: new PassThrough(), buffer: Buffer.from('x') };
storage._removeFile({}, file, () => undefined);
expect(file.buffer).toBeUndefined();
});
});
describe('Upload-Grenzen der Route analyze', () => {
it('Dateien, Felder und Teile sind begrenzt', () => {
expect(CERT_UPLOAD_LIMITS).toMatchObject({ fileSize: 5 * 1024 * 1024, files: 30 });
expect(CERT_UPLOAD_LIMITS.parts).toBeGreaterThanOrEqual(30);
expect(CERT_UPLOAD_LIMITS.parts).toBeLessThanOrEqual(40);
expect(CERT_UPLOAD_LIMITS.fields).toBeLessThanOrEqual(5);
});
});
+71
View File
@@ -0,0 +1,71 @@
import { PayloadTooLargeException } from '@nestjs/common';
/**
* Speicher fuer hochgeladene Dateien mit Gesamtgrenze beim Empfang (Review WR-06).
*
* multer mit Standard-Speicher haelt jede Datei komplett im Arbeitsspeicher; die Grenze je Datei
* (5 MiB) gilt, die Gesamtsumme wurde aber erst geprueft, nachdem alle 30 Dateien angekommen waren
* (bis zu 150 MiB). Dieser Speicher zaehlt mit, waehrend die Daten eintreffen, und bricht die
* Anfrage mit 413 `tooLarge` ab, sobald die Summe die Grenze ueberschreitet; alles bis dahin
* Gesammelte wird verworfen.
*
* Die Form entspricht multers Speicher-Schnittstelle (`_handleFile`, `_removeFile`); der Speicher
* liefert wie multer `buffer` und `size` an der Datei. Es gibt kein @types/multer im Projekt.
*/
interface IncomingFile {
stream: NodeJS.ReadableStream;
buffer?: Buffer;
}
type Callback = (error: unknown, info?: { buffer: Buffer; size: number }) => void;
export interface TotalLimitedStorage {
_handleFile(req: object, file: IncomingFile, cb: Callback): void;
_removeFile(req: object, file: IncomingFile, cb: (error: null) => void): void;
}
export function createTotalLimitedStorage(maxTotalBytes: number): TotalLimitedStorage {
// Summe je Anfrage; faellt mit der Anfrage weg
const totals = new WeakMap<object, number>();
return {
_handleFile(req, file, cb) {
const chunks: Buffer[] = [];
let finished = false;
const finish: Callback = (error, info) => {
if (finished) return;
finished = true;
chunks.length = 0;
if (error) cb(error);
else cb(null, info);
};
file.stream.on('data', (chunk: Buffer | string) => {
if (finished) return; // Rest der abgebrochenen Anfrage nur verwerfen
const part = typeof chunk === 'string' ? Buffer.from(chunk) : chunk;
const total = (totals.get(req) ?? 0) + part.length;
totals.set(req, total);
if (total > maxTotalBytes) {
finish(
new PayloadTooLargeException({
code: 'tooLarge',
message: 'Files together exceed 20 MiB',
}),
);
return;
}
chunks.push(part);
});
file.stream.on('end', () => {
if (finished) return;
const buffer = Buffer.concat(chunks);
finished = true;
cb(null, { buffer, size: buffer.length });
});
file.stream.on('error', (error) => finish(error));
},
_removeFile(_req, file, cb) {
delete file.buffer;
cb(null);
},
};
}
@@ -0,0 +1,89 @@
import { describe, expect, it } from 'vitest';
import { MAX_PEM_BLOCKS, scanPemBlocks } from './pem-scan';
const BLOCK = '-----BEGIN CERTIFICATE-----\nQUJD\n-----END CERTIFICATE-----\n';
describe('scanPemBlocks', () => {
it('liest Bloecke mit Etikett, Rumpf und Originaltext', () => {
const r = scanPemBlocks(`vorspann\n${BLOCK}zwischen\n${BLOCK}`);
expect(r).toHaveLength(2);
expect(r[0].label).toBe('CERTIFICATE');
expect(r[0].body).toBe('\nQUJD\n');
expect(r[0].text).toBe(BLOCK.trimEnd());
});
it('liest verschiedene Etiketten und Kopfzeilen im Rumpf', () => {
const key =
'-----BEGIN RSA PRIVATE KEY-----\nProc-Type: 4,ENCRYPTED\n\nQUJD\n-----END RSA PRIVATE KEY-----';
const r = scanPemBlocks(`${BLOCK}${key}\n`);
expect(r.map((b) => b.label)).toEqual(['CERTIFICATE', 'RSA PRIVATE KEY']);
expect(r[1].body).toContain('Proc-Type');
});
it('END mit anderem Etikett schliesst nicht; BEGIN ohne END wird uebersprungen', () => {
const r = scanPemBlocks(
`-----BEGIN A-----\nx\n-----END B-----\n${BLOCK}-----BEGIN CERTIFICATE-----\nohne ende`,
);
expect(r).toHaveLength(1);
expect(r[0].label).toBe('CERTIFICATE');
});
it('verschachtelt wie bisher: das innere Paar gewinnt, wenn das aeussere kein END hat', () => {
const r = scanPemBlocks(`-----BEGIN A-----\n${BLOCK}`);
expect(r.map((b) => b.label)).toEqual(['CERTIFICATE']);
});
it('ungueltige Etiketten (Kleinbuchstaben, leer, zu lang) ergeben nichts', () => {
expect(scanPemBlocks('-----BEGIN cert-----\n-----END cert-----')).toEqual([]);
expect(scanPemBlocks('-----BEGIN -----\n-----END -----')).toEqual([]);
const long = 'A'.repeat(200);
expect(scanPemBlocks(`-----BEGIN ${long}-----\n-----END ${long}-----`)).toEqual([]);
});
it('begrenzt die Blockzahl', () => {
const many = BLOCK.repeat(MAX_PEM_BLOCKS + 50);
expect(scanPemBlocks(many)).toHaveLength(MAX_PEM_BLOCKS);
});
});
describe('scanPemBlocks: Laufzeit (Review CR-02)', () => {
const MIB5 = 5 * 1024 * 1024;
/** Beste von drei Messungen: unabhaengig von Lastspitzen paralleler Testlaeufe (alt: Minuten). */
function timed(text: string): number {
let best = Number.POSITIVE_INFINITY;
for (let i = 0; i < 3; i++) {
const started = performance.now();
scanPemBlocks(text);
best = Math.min(best, performance.now() - started);
}
return best;
}
it('5 MiB gleiche BEGIN-Zeilen ohne END: weit unter 100 ms', () => {
const line = '-----BEGIN A-----\n';
const text = line.repeat(Math.ceil(MIB5 / line.length));
expect(timed(text)).toBeLessThan(100);
});
it('5 MiB BEGIN-Zeilen mit lauter verschiedenen Etiketten: weit unter 100 ms', () => {
const parts: string[] = [];
let size = 0;
for (let i = 0; size < MIB5; i++) {
const line = `-----BEGIN L${i}-----\n`;
parts.push(line);
size += line.length;
}
expect(timed(parts.join(''))).toBeLessThan(100);
});
it('5 MiB BEGIN-Zeilen und END-Zeilen mit anderem Etikett: unter einer Sekunde (alt: Minuten)', () => {
const pair = '-----BEGIN A-----\n-----END B-----\n';
expect(timed(pair.repeat(Math.ceil(MIB5 / pair.length)))).toBeLessThan(1000);
});
it('5 MiB "-----BEGIN " ohne Etikett und "-----END " Wiederholungen: weit unter 100 ms', () => {
expect(timed('-----BEGIN '.repeat(Math.ceil(MIB5 / 11)))).toBeLessThan(100);
expect(timed(`${'-----END '.repeat(Math.ceil(MIB5 / 9))}-----BEGIN A-----`)).toBeLessThan(100);
});
});
+98
View File
@@ -0,0 +1,98 @@
/**
* Linearer PEM-Block-Scanner (Review CR-02, quick-261009-ikt).
*
* Ersetzt die Regex `-----BEGIN (X)-----([\s\S]*?)-----END \1-----`: Bei jedem BEGIN ohne passendes
* END suchte die lazy Regex bis zum Dateiende, eine Datei aus lauter BEGIN-Zeilen kostete O(n^2)
* (400 KiB rund 5 s, 5 MiB rund 15 Minuten Stillstand des Node-Prozesses).
*
* Hier wird der Text hoechstens zweimal durchlaufen: einmal werden alle `-----END <Etikett>-----`
* Stellen je Etikett gesammelt, danach wandert ein Zeiger je Etikett vorwaerts. Ein BEGIN ohne
* passendes END kostet damit nur seine eigene Zeile. Die Block-Anzahl ist begrenzt.
*/
export interface RawPemBlock {
label: string;
/** Inhalt zwischen den Zeilen BEGIN und END (Kopfzeilen und Base64) */
body: string;
/** der ganze Block von BEGIN bis END, unveraendert */
text: string;
}
const BEGIN = '-----BEGIN ';
const END = '-----END ';
const DASHES = '-----';
const MAX_LABEL_LENGTH = 64;
/** Hoechstens so viele Bloecke je Text; mehr kommt nur von Missbrauch. */
export const MAX_PEM_BLOCKS = 1000;
function isLabelChar(code: number): boolean {
return (
(code >= 65 && code <= 90) || // A-Z
(code >= 48 && code <= 57) || // 0-9
code === 32 // Leerzeichen
);
}
/** Liest ein Etikett [A-Z0-9 ]+ ab `from`, gefolgt von "-----". null, wenn die Form nicht stimmt. */
function readLabel(text: string, from: number): { label: string; end: number } | null {
let i = from;
const limit = Math.min(text.length, from + MAX_LABEL_LENGTH + 1);
while (i < limit && isLabelChar(text.charCodeAt(i))) i++;
if (i === from || i - from > MAX_LABEL_LENGTH) return null;
if (!text.startsWith(DASHES, i)) return null;
return { label: text.slice(from, i), end: i + DASHES.length };
}
export function scanPemBlocks(text: string, maxBlocks: number = MAX_PEM_BLOCKS): RawPemBlock[] {
const blocks: RawPemBlock[] = [];
if (!text.includes(BEGIN)) return blocks;
// Erster Durchlauf: alle END-Zeilen je Etikett (Startpositionen, aufsteigend).
const ends = new Map<string, { starts: number[]; ends: number[]; next: number }>();
for (let at = text.indexOf(END); at !== -1; at = text.indexOf(END, at + END.length)) {
const parsed = readLabel(text, at + END.length);
if (!parsed) continue;
let entry = ends.get(parsed.label);
if (!entry) {
entry = { starts: [], ends: [], next: 0 };
ends.set(parsed.label, entry);
}
entry.starts.push(at);
entry.ends.push(parsed.end);
}
if (ends.size === 0) return blocks;
// Zweiter Durchlauf: BEGIN-Zeilen in Textreihenfolge.
let pos = 0;
while (blocks.length < maxBlocks) {
const begin = text.indexOf(BEGIN, pos);
if (begin === -1) break;
const header = readLabel(text, begin + BEGIN.length);
if (!header) {
pos = begin + BEGIN.length;
continue;
}
const entry = ends.get(header.label);
if (!entry) {
pos = begin + BEGIN.length;
continue;
}
// Zeiger vorwaerts bis zur ersten END-Stelle hinter dem Kopf (Positionen wachsen nur).
while (entry.next < entry.starts.length && entry.starts[entry.next] < header.end) entry.next++;
if (entry.next >= entry.starts.length) {
pos = begin + BEGIN.length;
continue;
}
const endStart = entry.starts[entry.next];
const endEnd = entry.ends[entry.next];
blocks.push({
label: header.label,
body: text.slice(header.end, endStart),
text: text.slice(begin, endEnd),
});
pos = endEnd;
}
return blocks;
}
+70 -2
View File
@@ -127,8 +127,8 @@ describe('expandZip: Grenzen und Fehler', () => {
AdmZip.prototype.getEntries = function patched(this: AdmZip) { AdmZip.prototype.getEntries = function patched(this: AdmZip) {
const entries = original.call(this); const entries = original.call(this);
for (const e of entries) { for (const e of entries) {
const get = e.getData.bind(e); const get = e.getCompressedData.bind(e);
e.getData = () => { e.getCompressedData = () => {
inflated++; inflated++;
return get(); return get();
}; };
@@ -154,3 +154,71 @@ describe('expandZip: Grenzen und Fehler', () => {
expect(r.blobs).toEqual([]); expect(r.blobs).toEqual([]);
}); });
}); });
/** Setzt die deklarierte Groesse im Zentralverzeichnis (Offset 24 des Eintrags) auf `size`. */
function patchDeclaredSize(zip: Buffer, size: number): Buffer {
const copy = Buffer.from(zip);
const sig = Buffer.from([0x50, 0x4b, 0x01, 0x02]);
let at = copy.indexOf(sig);
while (at !== -1) {
copy.writeUInt32LE(size, at + 24);
at = copy.indexOf(sig, at + 4);
}
return copy;
}
describe('expandZip: ZIP-Bombe mit gefaelschter Groesse (Review CR-01)', () => {
// 200 MiB Nullbytes packen auf rund 200 kB; die Kopfdaten behaupten Groesse 0.
const bomb = patchDeclaredSize(zipOf({ 'bombe.pem': Buffer.alloc(200 * 1024 * 1024) }), 0);
it('Groesse 0 im Kopf: wird abgewiesen, ohne 200 MiB zu entpacken', () => {
const before = process.memoryUsage().arrayBuffers;
const started = Date.now();
const r = expandZip(bomb, 'bombe.zip', 0);
expect(r.blobs).toEqual([]);
expect(r.ignored).toEqual([{ file: 0, path: 'bombe.zip/bombe.pem', reason: 'tooLarge' }]);
// Hartes Deckel-Entpacken: hoechstens die Einzelgrenze (1 MiB) liegt je im Speicher.
expect(process.memoryUsage().arrayBuffers - before).toBeLessThan(50 * 1024 * 1024);
expect(Date.now() - started).toBeLessThan(2000);
});
it('Groesse 1 im Kopf (kleiner als echt) ist ebenso wirkungslos', () => {
const r = expandZip(patchDeclaredSize(bomb, 1), 'bombe.zip', 0);
expect(r.blobs).toEqual([]);
expect(r.ignored[0].reason).toBe('tooLarge');
});
it('ehrliche Groesse am Deckel bleibt wie bisher abgelehnt', () => {
const r = expandZip(zipOf({ 'x.pem': Buffer.alloc(2 * 1024 * 1024) }), 'e.zip', 0);
expect(r.blobs).toEqual([]);
expect(r.ignored[0].reason).toBe('tooLarge');
});
it('Eintrag unter der Einzelgrenze mit falscher Kopfgroesse ist suspicious', () => {
const honest = zipOf({ 'k.pem': Buffer.from('abc'.repeat(100)) });
const r = expandZip(patchDeclaredSize(honest, 0), 'k.zip', 0);
expect(r.blobs).toEqual([]);
expect(r.ignored).toEqual([{ file: 0, path: 'k.zip/k.pem', reason: 'suspicious' }]);
});
it('Summe der echten Groessen ueber der Grenze lehnt das ganze ZIP ab, auch bei Kopfgroesse 0', () => {
const body = Buffer.alloc(300, 65);
const zip = patchDeclaredSize(zipOf({ 'a.pem': body, 'b.pem': body }), 0);
const r = expandZip(zip, 's.zip', 2, {
...ZIP_LIMITS,
maxEntryBytes: 1000,
maxTotalBytes: 400,
});
expect(r.blobs).toEqual([]);
expect(r.ignored).toEqual([{ file: 2, path: 's.zip', reason: 'zipTooLarge' }]);
});
it('kaputte CRC ergibt brokenZip statt Daten', () => {
const zip = Buffer.from(zipOf({ 'c.pem': 'inhalt' }));
const sig = Buffer.from([0x50, 0x4b, 0x01, 0x02]);
zip.writeUInt32LE(0xdeadbeef, zip.indexOf(sig) + 16);
const r = expandZip(zip, 'c.zip', 0);
expect(r.blobs).toEqual([]);
expect(r.ignored[0].reason).toBe('brokenZip');
});
});
+65 -9
View File
@@ -1,3 +1,4 @@
import { crc32, inflateRawSync } from 'node:zlib';
import AdmZip from 'adm-zip'; import AdmZip from 'adm-zip';
import { cleanSourcePath } from './cert-names'; import { cleanSourcePath } from './cert-names';
import type { IgnoredEntry } from './cert-types'; import type { IgnoredEntry } from './cert-types';
@@ -15,9 +16,13 @@ import type { IgnoredEntry } from './cert-types';
* `tooLarge`. Deklarierte Groesse / max(gepackt, 1) ueber `maxRatio`: `suspicious`. * `tooLarge`. Deklarierte Groesse / max(gepackt, 1) ueber `maxRatio`: `suspicious`.
* - Summe der deklarierten Groessen der behaltenen Eintraege ueber `maxTotalBytes`: das ganze ZIP * - Summe der deklarierten Groessen der behaltenen Eintraege ueber `maxTotalBytes`: das ganze ZIP
* wird mit `zipTooLarge` abgelehnt. * wird mit `zipTooLarge` abgelehnt.
* - Alle diese Pruefungen laufen auf den Kopfdaten und damit VOR dem ersten Entpacken. * - Die Kopfpruefungen laufen VOR dem ersten Entpacken, vertrauen aber nur ehrlichen Kopfdaten.
* adm-zip entpackt hoechstens die deklarierte Groesse und prueft die CRC; zusaetzlich wird das * Die deklarierte Groesse im Zentralverzeichnis ist Angreiferwunsch (adm-zip begrenzt bei
* Ergebnis noch einmal gegen die Grenze geprueft. * Groesse 0 gar nicht, Review CR-01). Deshalb entpackt dieses Modul selbst, mit hartem Deckel
* (`maxOutputLength` = Einzelgrenze, hoechstens die Restgrenze der Summe), und prueft Groesse,
* Verhaeltnis und CRC am tatsaechlichen Ergebnis. Ein Eintrag, dessen echte Groesse von der
* deklarierten abweicht, gilt als `suspicious`. Wird die Summe der echten Groessen ueberschritten,
* wird das ganze ZIP mit `zipTooLarge` abgelehnt.
* - Eintragsnamen dienen nur der Anzeige (Steuerzeichen entfernt, hoechstens 255 Zeichen) und * - Eintragsnamen dienen nur der Anzeige (Steuerzeichen entfernt, hoechstens 255 Zeichen) und
* werden nie als Dateipfad benutzt. * werden nie als Dateipfad benutzt.
*/ */
@@ -71,6 +76,39 @@ function displayPath(zipName: string, entryName: string): string {
return cleanSourcePath(`${zipName}/${entryName.replace(/^[\\/]+/, '')}`); return cleanSourcePath(`${zipName}/${entryName.replace(/^[\\/]+/, '')}`);
} }
class EntryTooLargeError extends Error {}
const STORED = 0;
const DEFLATED = 8;
/**
* Entpackt einen Eintrag mit hartem Deckel auf die echte Ausgabe (nicht auf die deklarierte
* Groesse). STORED-Eintraege werden vor dem Kopieren gegen den Deckel gemessen, DEFLATE-Daten
* laufen mit `maxOutputLength` durch zlib. Die CRC kommt aus dem Zentralverzeichnis.
*/
function inflateEntry(entry: AdmZip.IZipEntry, cap: number): Buffer {
const packed = entry.getCompressedData();
const method = entry.header.method;
let data: Buffer;
if (method === STORED) {
if (packed.length > cap) throw new EntryTooLargeError();
data = Buffer.from(packed);
} else if (method === DEFLATED) {
try {
data = inflateRawSync(packed, { maxOutputLength: cap });
} catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ERR_BUFFER_TOO_LARGE') {
throw new EntryTooLargeError();
}
throw error;
}
} else {
throw new Error('unsupported method');
}
if (crc32(data) !== entry.header.crc) throw new Error('bad crc');
return data;
}
export function expandZip( export function expandZip(
buffer: Buffer, buffer: Buffer,
zipName: string, zipName: string,
@@ -115,18 +153,36 @@ export function expandZip(
} }
if (total > limits.maxTotalBytes) return whole('zipTooLarge'); if (total > limits.maxTotalBytes) return whole('zipTooLarge');
let actualTotal = 0;
for (const entry of kept) { for (const entry of kept) {
const path = displayPath(zipName, entry.entryName); const path = displayPath(zipName, entry.entryName);
// Harter Deckel: nie mehr als die Einzelgrenze und nie mehr als die Restgrenze der Summe.
const remaining = limits.maxTotalBytes - actualTotal;
const cap = Math.min(limits.maxEntryBytes, remaining);
let data: Buffer; let data: Buffer;
try { try {
data = entry.getData(); data = inflateEntry(entry, cap);
} catch { } catch (error) {
ignored.push({ file, path, reason: 'brokenZip' }); if (error instanceof EntryTooLargeError) {
// Die Restgrenze der Summe war enger als die Einzelgrenze: das ganze ZIP ist zu gross.
if (cap < limits.maxEntryBytes) return whole('zipTooLarge');
ignored.push({ file, path, reason: 'tooLarge' });
} else {
ignored.push({ file, path, reason: 'brokenZip' });
}
continue; continue;
} }
if (data.length > limits.maxEntryBytes) { actualTotal += data.length;
ignored.push({ file, path, reason: 'tooLarge' }); if (actualTotal > limits.maxTotalBytes) return whole('zipTooLarge');
} else if (isZip(data)) { const declared = entry.header.size;
if (
data.length !== declared ||
data.length / Math.max(entry.header.compressedSize, 1) > limits.maxRatio
) {
ignored.push({ file, path, reason: 'suspicious' });
continue;
}
if (isZip(data)) {
ignored.push({ file, path, reason: 'nestedZip' }); ignored.push({ file, path, reason: 'nestedZip' });
} else { } else {
blobs.push({ path, buffer: data }); blobs.push({ path, buffer: data });
@@ -115,6 +115,24 @@ describe('isPublicHttpUrl', () => {
expect(lookupMock).not.toHaveBeenCalled(); expect(lookupMock).not.toHaveBeenCalled();
}); });
it('IPv6 als Zahl in eckigen Klammern: interne Schreibweisen gesperrt, oeffentliche erlaubt (Review IN-01)', async () => {
for (const blocked of [
'http://[::1]/',
'http://[::ffff:7f00:1]/',
'http://[::ffff:127.0.0.1]/',
'http://[fe80::1]/',
'http://[fc00::1]/',
'http://[64:ff9b::7f00:1]/',
'http://[2002:7f00:1::1]/',
'http://[::7f00:1]/',
]) {
expect(await isPublicHttpUrl(new URL(blocked)), blocked).toBe(false);
}
expect(await isPublicHttpUrl(new URL('http://[2606:4700:4700::1111]/'))).toBe(true);
expect(await isPublicHttpUrl(new URL('https://[2001:4860:4860::8888]:443/'))).toBe(true);
expect(lookupMock).not.toHaveBeenCalled();
});
it('lehnt ab, wenn die Aufloesung scheitert oder nichts liefert', async () => { it('lehnt ab, wenn die Aufloesung scheitert oder nichts liefert', async () => {
lookupMock.mockRejectedValueOnce(new Error('ENOTFOUND')); lookupMock.mockRejectedValueOnce(new Error('ENOTFOUND'));
expect(await isPublicHttpUrl(new URL('http://gibt-es-nicht.example.test/'))).toBe(false); expect(await isPublicHttpUrl(new URL('http://gibt-es-nicht.example.test/'))).toBe(false);
+5 -3
View File
@@ -159,14 +159,16 @@ export async function isPublicHttpUrl(url: URL): Promise<boolean> {
return false; return false;
} }
const directVersion = isIP(url.hostname); // URL.hostname behaelt bei IPv6 die eckigen Klammern ("[::1]"); isIP kennt sie nicht (Review IN-01).
const host = url.hostname.replace(/^\[|\]$/g, '');
const directVersion = isIP(host);
if (directVersion !== 0) { if (directVersion !== 0) {
return !isPrivateIpAddress(url.hostname); return !isPrivateIpAddress(host);
} }
try { try {
const addresses = await lookup(url.hostname, { all: true }); const addresses = await lookup(host, { all: true });
if (addresses.length === 0) return false; if (addresses.length === 0) return false;
+43
View File
@@ -0,0 +1,43 @@
import { describe, expect, it, vi } from 'vitest';
import { CERT_BUILD_ROUTE } from './cert-manager/cert-json-body';
import { configureHttp, type HttpApp } from './http-setup';
function recordingApp() {
const calls: string[] = [];
const app: HttpApp = {
use: vi.fn((...args: unknown[]) => {
calls.push(typeof args[0] === 'string' ? `use:${args[0]}` : 'use');
}),
useGlobalPipes: vi.fn(() => {
calls.push('pipes');
}),
enableCors: vi.fn(() => {
calls.push('cors');
}),
} as unknown as HttpApp;
return { app, calls };
}
describe('configureHttp (Review WR-01)', () => {
it('der JSON-Leser fuer build steht hinter CORS, damit 413 und 400 CORS-Kopfzeilen tragen', () => {
const { app, calls } = recordingApp();
configureHttp(app, 'http://localhost:3000');
expect(calls.indexOf(`use:${CERT_BUILD_ROUTE}`)).toBeGreaterThan(calls.indexOf('cors'));
expect(calls.indexOf('cors')).toBeGreaterThanOrEqual(0);
});
it('CORS bekommt den Ursprung und Anmeldedaten', () => {
const { app } = recordingApp();
configureHttp(app, 'https://alpha.example');
expect(app.enableCors).toHaveBeenCalledWith({
origin: 'https://alpha.example',
credentials: true,
});
});
it('Anfragelog und Cookies kommen vor allem anderen', () => {
const { app, calls } = recordingApp();
configureHttp(app, 'x');
expect(calls.slice(0, 2)).toEqual(['use', 'use']);
});
});
+47
View File
@@ -0,0 +1,47 @@
import { type INestApplication, ValidationPipe } from '@nestjs/common';
import cookieParser from 'cookie-parser';
import {
CERT_BUILD_ROUTE,
certBuildBodyErrors,
certBuildJsonBody,
} from './cert-manager/cert-json-body';
import { requestLogMiddleware } from './common/request-log';
/** Der Teil der Nest-Anwendung, den die HTTP-Einrichtung braucht (so laesst sich die Reihenfolge testen). */
export type HttpApp = Pick<INestApplication, 'use' | 'useGlobalPipes' | 'enableCors'>;
/**
* Gemeinsame HTTP-Einrichtung der API (aus main.ts, damit die Reihenfolge testbar ist).
*
* Reihenfolge der Schichten bei Express: Anfragelog, Cookies, CORS, dann der eigene JSON-Leser
* fuer POST build des Zertifikat-Managers. Der Leser muss HINTER enableCors stehen (Review WR-01):
* seine Fehlerantworten 413 und 400 entstehen, bevor Nests Schichten laufen, und ohne vorherige
* CORS-Schicht blockiert der Browser sie im Entwicklungsaufbau mit getrennten Ursprungsadressen
* (die Oberflaeche sieht dann nur einen Netzwerkfehler statt tooLarge / invalidInput).
* Er steht trotzdem vor Nests globalem JSON-Leser, der erst beim Start (listen) eingebaut wird.
*/
export function configureHttp(app: HttpApp, corsOrigin: string): void {
// Eine Zeile je Anfrage im Docker-Log (Pfad, Status, Dauer, Benutzer)
app.use(requestLogMiddleware);
// Cookie parser for JWT httpOnly cookies
app.use(cookieParser());
// Global validation pipe with whitelist and transform
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
transform: true,
}),
);
// CORS with credentials for cross-origin cookie support (Pitfall 4)
app.enableCors({
origin: corsOrigin,
credentials: true,
});
// Eigene JSON-Grenze (512 KiB) nur fuer POST build des Zertifikat-Managers, nach CORS und vor
// Nests globalem Leser (quick-261009-ikt D-26, WR-01)
app.use(CERT_BUILD_ROUTE, certBuildJsonBody, certBuildBodyErrors);
}
+2 -32
View File
@@ -1,44 +1,14 @@
import { ValidationPipe } from '@nestjs/common';
import { ConfigService } from '@nestjs/config'; import { ConfigService } from '@nestjs/config';
import { NestFactory } from '@nestjs/core'; import { NestFactory } from '@nestjs/core';
import cookieParser from 'cookie-parser';
import { AppModule } from './app.module'; import { AppModule } from './app.module';
import {
CERT_BUILD_ROUTE,
certBuildBodyErrors,
certBuildJsonBody,
} from './cert-manager/cert-json-body';
import { requestLogMiddleware } from './common/request-log';
import { formatAppVersionLine } from './health/app-version'; import { formatAppVersionLine } from './health/app-version';
import { configureHttp } from './http-setup';
async function bootstrap() { async function bootstrap() {
const app = await NestFactory.create(AppModule); const app = await NestFactory.create(AppModule);
const configService = app.get(ConfigService); const configService = app.get(ConfigService);
// Eine Zeile je Anfrage im Docker-Log (Pfad, Status, Dauer, Benutzer) configureHttp(app, configService.get<string>('CORS_ORIGIN', 'http://localhost:3000'));
app.use(requestLogMiddleware);
// Cookie parser for JWT httpOnly cookies
app.use(cookieParser());
// Eigene JSON-Grenze (512 KiB) nur fuer POST build des Zertifikat-Managers, vor Nests globalem Leser
// (quick-261009-ikt D-26)
app.use(CERT_BUILD_ROUTE, certBuildJsonBody, certBuildBodyErrors);
// Global validation pipe with whitelist and transform
app.useGlobalPipes(
new ValidationPipe({
whitelist: true,
transform: true,
}),
);
// CORS with credentials for cross-origin cookie support (Pitfall 4)
const corsOrigin = configService.get<string>('CORS_ORIGIN', 'http://localhost:3000');
app.enableCors({
origin: corsOrigin,
credentials: true,
});
await app.listen(3001); await app.listen(3001);
console.log('Tessera API running on port 3001'); console.log('Tessera API running on port 3001');