From 62e10cbf7096a474c96d765ff6bdd01c79dbdd54 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 3 Jun 2026 10:20:28 +0000 Subject: [PATCH] nip44 v3: accept non-standard padding lengths on decrypt The nip44v3 spec was updated (nostr-land/nip44v3) to clarify that the padding length is not constrained: "The padding should be checked to be all-zeroes. Implementations must not do any other checks on the padding length." The standard padding algorithm is only a SHOULD (anti- fingerprinting), so a conforming peer may send more or fewer padding bytes than we produce. Our unpad() previously rejected any ciphertext whose buffer length did not match our own canonical target size, which would wrongly reject valid messages. Remove that check and keep only the all-zeroes validation (and the declared-length bounds check). Also sync the test vectors with upstream, adding the new decrypt_only section (5 vectors with non-standard padding sizes) and a test that exercises them. --- .../nostrsigner/service/nip44v3/Nip44v3.kt | 13 ++-- .../service/nip44v3/Nip44v3Test.kt | 27 ++++++++ app/src/test/resources/nip44v3-vectors.json | 69 ++++++++++++++++++- 3 files changed, 102 insertions(+), 7 deletions(-) diff --git a/app/src/main/java/com/greenart7c3/nostrsigner/service/nip44v3/Nip44v3.kt b/app/src/main/java/com/greenart7c3/nostrsigner/service/nip44v3/Nip44v3.kt index fe153435..be550e14 100644 --- a/app/src/main/java/com/greenart7c3/nostrsigner/service/nip44v3/Nip44v3.kt +++ b/app/src/main/java/com/greenart7c3/nostrsigner/service/nip44v3/Nip44v3.kt @@ -180,12 +180,13 @@ object Nip44v3 { if (plaintextLen.toLong() + 4L > padded.size.toLong()) { throw Nip44v3Exception("invalid padding: declared $plaintextLen, available ${padded.size - 4}") } - // The padded buffer length is fully determined by the plaintext length; - // reject anything that does not match the canonical size so attackers - // can't smuggle data in the padding. - if (padded.size.toLong() != targetSizeLong(4L + plaintextLen)) { - throw Nip44v3Exception("invalid padding: wrong target size") - } + // The NIP-44 v3 spec deliberately does NOT mandate a canonical padding + // length: "implementations must not do any other checks on the padding + // length". The standard padding algorithm is only a SHOULD, so a peer + // may legitimately send more (or fewer) padding bytes than we would + // produce. Validating against our own target size would reject those + // otherwise-valid messages, so we only require that the padding region + // is all zeroes. // Constant-time zero check over the padding region. var diff = 0 for (i in 4 + plaintextLen until padded.size) { diff --git a/app/src/test/java/com/greenart7c3/nostrsigner/service/nip44v3/Nip44v3Test.kt b/app/src/test/java/com/greenart7c3/nostrsigner/service/nip44v3/Nip44v3Test.kt index 9b32b4f4..575986cf 100644 --- a/app/src/test/java/com/greenart7c3/nostrsigner/service/nip44v3/Nip44v3Test.kt +++ b/app/src/test/java/com/greenart7c3/nostrsigner/service/nip44v3/Nip44v3Test.kt @@ -90,6 +90,33 @@ class Nip44v3Test { } } + @Test + fun `decrypt-only vectors with non-standard padding decrypt correctly`() { + // The spec allows ciphertexts whose padding length differs from the one + // our encryptor would produce (the standard padding algorithm is a + // SHOULD, not a MUST). We must still decrypt them, validating only that + // the padding bytes are zero. These are decrypt-only because re-encrypting + // would yield the canonical padding length and a different ciphertext. + val vectors = loadVectors().get("decrypt_only") + assertNotNull("decrypt_only section missing", vectors) + for ((idx, v) in vectors.withIndex()) { + val priv1 = hex(v.get("secret1").textValue()) + val priv2 = hex(v.get("secret2").textValue()) + val pub1 = pubKeyFor(v.get("secret1").textValue()) + val pub2 = pubKeyFor(v.get("secret2").textValue()) + val kind = v.get("kind").intValue() + val scope = String(hex(v.get("scope_hex").textValue()), Charsets.UTF_8) + val plaintext = hex(v.get("plaintext_hex").textValue()) + val ciphertext = v.get("ciphertext").textValue() + val note = v.get("note")?.textValue() ?: "" + + val dec1 = Nip44v3.decrypt(ciphertext, priv1, pub2, kind, scope) + assertArrayEquals("vector $idx ($note): decrypt from secret1", plaintext, dec1) + val dec2 = Nip44v3.decrypt(ciphertext, priv2, pub1, kind, scope) + assertArrayEquals("vector $idx ($note): decrypt from secret2", plaintext, dec2) + } + } + @Test fun `long encrypt and decrypt vectors match sha256 of ciphertext`() { val vectors = loadVectors().get("long_encrypt_decrypt") diff --git a/app/src/test/resources/nip44v3-vectors.json b/app/src/test/resources/nip44v3-vectors.json index 1f21901a..63f0a7c5 100644 --- a/app/src/test/resources/nip44v3-vectors.json +++ b/app/src/test/resources/nip44v3-vectors.json @@ -121,6 +121,73 @@ "ciphertext": "A6BaEdzVCqHoVbfhGoFhWKGkgn0hoAtgEF7TyOgCdw13O273WC9FSDyMtfOYNFvOlZQcaSrLdo6WBQ7ZI2UWn5MAAAABAAAAA++AgPPJWHFZya+M6arLz4wrWMHfL4Wyv4gYZBkicAvVBX0dMsr5tBcTP5xaM4lJZZnokEvMZRzYbjrfNTjT2gCWBapNdr/QrHxlTDa54nRmVR/2GBLkmQ5QeIiDm6OhfjXyYA==" } ], + "decrypt_only": [ + { + "secret1": "fce6ddb6b9611964b37466e29f89f212f6905a70e5b0ea20b33ac9a4a74e60cb", + "secret2": "45d28e26e1072c9495857efc85bb56ba0833271d7cb183c6459b8ca17311ed7e", + "nonce": "d55b86093a16aabd228b9ac1724749e492fc3a81491c7374bd7a1d28a7b3b4a3", + "kind": 1, + "scope_hex": "", + "prk": "5f359143a097d9b66ec8e0cff7c111076efa06b5ec2f9185e13a304c4467a4e8", + "encryption_key": "cbb7467f7c3a6f04c5ac6e4554de2034b67f2ac32a94d58f44e7a14e80912b0b", + "mac_key": "784feeb31cf134baaa13d387f5102cf1f06a0e4d60cc737ba4c0311987401e9e", + "plaintext_hex": "efbbbf48656c6c6f20776f726c6421", + "ciphertext": "A9Vbhgk6Fqq9IouawXJHSeSS/DqBSRxzdL16HSins7Sji9VE1vdW4PQiqseqUsGZsaAvIe2yGmfWOXiimOZHRUUAAAABAAAAAHm5SMpSTmibFgS1CqDSU5sC6MEPKNyTHS7oxNAb/AAFwta2Xpcc", + "note": "non-standard padding (23 bytes instead of 17)" + }, + { + "secret1": "099f9dd917f81a515d587164ed21bfcc3897bf705a7c9b3de85abcea809831df", + "secret2": "9806d1447c80921a15ff6c737204d985fbee09a6fb123ec8c1ef8749a939ac6d", + "nonce": "382baacbba8cba0cc6e8a7b4444fb157186118a18b3dbf652fb6b1e8267bcac1", + "kind": 30078, + "scope_hex": "737065632e6e6f7374722e6c616e642f6e697034347633", + "prk": "57dec8dfe66043f16b839513bffe2855984a725e50d8c63d4f5738d9dc6d0ed9", + "encryption_key": "c8ef9801a429de8526739c8d84c62eaf632b0d52023590c7449c8178885dcd57", + "mac_key": "7ad04de35b988b240f78285e597fdca8e963988664f4808878afc2ba0aa9a4c1", + "plaintext_hex": "6e6f7374722e6c616e64206e697034347633", + "ciphertext": "Azgrqsu6jLoMxuintERPsVcYYRihiz2/ZS+2segme8rBD0AqKypuSHff1x0FW+qO4lQlLltEjPWrvoMo7fbKOfwAAHV+AAAAF3NwZWMubm9zdHIubGFuZC9uaXA0NHYznEO1UCJd2Ld0YV51u6kOkY3g22UhvNBAXY3sFnKmPu6fYx9s0rDWwNZcGxsMs+VydrONvM5F", + "note": "non-standard padding (36 bytes instead of 14)" + }, + { + "secret1": "a62c0eae8281f8997c6391889be1c39df8d06acbde7cbb5e23b8e6ba28c95328", + "secret2": "c51b92dfea8551090c2e17d22953f7124fdd6a59e7c67daadcb92819279801d4", + "nonce": "f08ea755450d9666cc122f2aa89794b170b8c69c6d7ff5f1d25bfae52164ca3a", + "kind": 1, + "scope_hex": "e381afe4b896e7958c", + "prk": "916e05d26ccb1d5f58698911c85789b89529baa9c4fb5fdcdac80f94afb525f8", + "encryption_key": "9110aa1049b70cdd93c38fa4888459d7286a78f5ca584d4dd658905660d4faea", + "mac_key": "a597dea28fc706883f9dcc4917209ffc10788c95e86f090a694ea51bc5fe7a11", + "plaintext_hex": "f09f9088f09fa694", + "ciphertext": "A/COp1VFDZZmzBIvKqiXlLFwuMacbX/18dJb+uUhZMo6uAhk3+WwQOcgQUgH4zhxaRzi80m70t5a9uV5B11EdpcAAAABAAAACeOBr+S4lueVjPeRbg5pf83dLf79+g1wPXb8mjm/rT9e", + "note": "non-standard padding (15 bytes instead of 24)" + }, + { + "secret1": "740853175b987393b113ac196ccf5152ed4a206b1a365291abacaf106c7a3bca", + "secret2": "da1a37af7852a7e5c0a7ce90a39950039bfaeab96297ef83b9d8817f5a2d26f9", + "nonce": "f7e0f4b83ebb87657001b8e47d5940a3d062dfebae66da5a2ad0f4e498fedf85", + "kind": 1, + "scope_hex": "efbbbfefbfbe", + "prk": "75c09260e4c8b8240514aee23723531df5f7816a25601e74e0ccbe87a965b309", + "encryption_key": "94cf5b47ccfd36dadee996e637ae2a11bd9ac53ff7a29573fe368adf8b1e4f20", + "mac_key": "a2e6d59755d7193c5cb9eba5965a42d2696062d455c2c7c535ad88b1c13d415b", + "plaintext_hex": "e69a97e58fb7e58c96e381aee3819fe38281e381aee382a2e382afe382bbe382b9e588b6e5bea1e6a99fe883bde3818ce799bbe5a0b4e38197e381bee38197e3819fefbc81", + "ciphertext": "A/fg9Lg+u4dlcAG45H1ZQKPQYt/rrmbaWirQ9OSY/t+Fhm1xiEi20bWbMLIDrNw7Gz6XU0bDmmmgYl3g4z68Z+4AAAABAAAABu+7v++/vhLMBXKVPi51crCqEwCQnuB0V13+nE22PpVe7jQiELzDMqdrE6intBrXyHJLrJ4VteEZoiU92jZDoG9ieltDh0NnKRKq7cuW/om3DIhB0DCb0Pq6C5g/VaFaz2+mVPXV0p2BMW2MpuKUJ7/VYAlJPNSlK/JSYpQq", + "note": "non-standard padding (50 bytes instead of 27)" + }, + { + "secret1": "28531e3c28be034f8bcca7bc25e5b9a6cbbcce8567f2a9016b3f1ad7707a724a", + "secret2": "96108e608b4231562b07d21021361e3efab87350021a9247ce410585c0adf757", + "nonce": "a82a808ca1a40368336f19e9d3f83bfaaa35e4b8bffc9b5d9426ae518b9f34d1", + "kind": 1, + "scope_hex": "ef8080", + "prk": "38846e1253b841b6960c3be79ec4b6bc1e0ce7c6e40a05f6ea2190ff0c53044b", + "encryption_key": "cbecc983553fb82f9f3f615dd959a451d66388d8632d2028a684c9f02c611f3f", + "mac_key": "5ed551ef02ba987cfdd4716c4a840a654501ea57aee9da055651669f85ac016e", + "plaintext_hex": "9b8de973ddf42a02103de24d9b7a4f0c4f551abaf7cd88f08e7a9c4d41ec5f777b45c890c112968fee50dccd3287583e9a3a33f962d78054f36dcb6f1ea9a8aa3fcb80953e04f6a2b3c3c4e26909ef7c5e84da6df3fd423215015640b249c91b28b38b18499b615bf1e92635e1df15aeeba2063692ce7cc8296582ceed25ceda", + "ciphertext": "A6gqgIyhpANoM28Z6dP4O/qqNeS4v/ybXZQmrlGLnzTRH0t5gnTV0ylQcxxkbLRHyKXnIagqYk5XMlG/85NYwuwAAAABAAAAA++AgM9zXmWWTYSixZDotwM+8HmJHW3aBt44KvZkhInVvt+Xmzh1YaPW8cbVr9kOQ38+cc5E285UfL164P71915Pr6mHNGOHtpKcX3P6TXCrch2MLux4m9xHf0BPcuv4+bwC0fLpNKVJLnNAnnGm7VOXdE2HhX4NP8ujzvH6cKTGfiyK0OlVpWX6Le+v/h2wm6m2SAHSJLbi6fIdA6MDilizVMqyaEFZ3n8eRYQ+To6eFKojRQw=", + "note": "non-standard padding (50 bytes instead of 64)" + } + ], "long_encrypt_decrypt": [ { "secret1": "e35f016acdf0bec26f9f0e97fd813aa042727cb1e5ac2adf1c7b8d18d393f455", @@ -458,4 +525,4 @@ "why": "invalid scope (not valid utf8)" } ] -} \ No newline at end of file +}