From f446b027d5858eebfdfaa2595c1c4e82ca724d7c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 21 Jan 2026 13:27:49 +0000 Subject: [PATCH 1/4] Initial plan From 262ca6805d1338c0d32319161185a1362b0f867a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 21 Jan 2026 13:36:09 +0000 Subject: [PATCH 2/4] feat(nip44): add cross-implementation test vectors for conversation key derivation Co-authored-by: tcheeric <6341500+tcheeric@users.noreply.github.com> --- .../nip44/Nip44ConversationKeyTest.java | 193 ++++++++++++++++++ 1 file changed, 193 insertions(+) create mode 100644 nostr-java-crypto/src/test/java/nostr/crypto/nip44/Nip44ConversationKeyTest.java diff --git a/nostr-java-crypto/src/test/java/nostr/crypto/nip44/Nip44ConversationKeyTest.java b/nostr-java-crypto/src/test/java/nostr/crypto/nip44/Nip44ConversationKeyTest.java new file mode 100644 index 00000000..ff3b6d87 --- /dev/null +++ b/nostr-java-crypto/src/test/java/nostr/crypto/nip44/Nip44ConversationKeyTest.java @@ -0,0 +1,193 @@ +package nostr.crypto.nip44; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; + +/** + * Cross-implementation test vectors for NIP-44 conversation key derivation. + * + * These test vectors are from the official NIP-44 specification test vectors + * (https://github.com/paulmillr/nip44/blob/main/nip44.vectors.json) and verify + * that this Java implementation produces the same conversation keys as the + * JavaScript implementations (nostr-tools) and other language implementations. + * + * This ensures DM interoperability across different Nostr client implementations. + */ +public class Nip44ConversationKeyTest { + + /** + * Test conversation key derivation with official test vectors from NIP-44 spec. + * Verifies that ECDH key agreement and HKDF-Extract produce compatible results. + */ + @Test + public void testConversationKeyWithOfficialVectors() { + // Vector 1 + assertConversationKey( + "315e59ff51cb9209768cf7da80791ddcaae56ac9775eb25b6dee1234bc5d2268", + "02c2f9d9948dc8c7c38321e4b85c8558872eafa0641cd269db76848a6073e69133", + "3dfef0ce2a4d80a25e7a328accf73448ef67096f65f79588e358d9a0eb9013f1" + ); + + // Vector 2 + assertConversationKey( + "a1e37752c9fdc1273be53f68c5f74be7c8905728e8de75800b94262f9497c86e", + "0303bb7947065dde12ba991ea045132581d0954f042c84e06d8c00066e23c1a800", + "4d14f36e81b8452128da64fe6f1eae873baae2f444b02c950b90e43553f2178b" + ); + + // Vector 3 + assertConversationKey( + "98a5902fd67518a0c900f0fb62158f278f94a21d6f9d33d30cd3091195500311", + "02aae65c15f98e5e677b5050de82e3aba47a6fe49b3dab7863cf35d9478ba9f7d1", + "9c00b769d5f54d02bf175b7284a1cbd28b6911b06cda6666b2243561ac96bad7" + ); + + // Vector 4 + assertConversationKey( + "86ae5ac8034eb2542ce23ec2f84375655dab7f836836bbd3c54cefe9fdc9c19f", + "0259f90272378089d73f1339710c02e2be6db584e9cdbe86eed3578f0c67c23585", + "19f934aafd3324e8415299b64df42049afaa051c71c98d0aa10e1081f2e3e2ba" + ); + + // Vector 5 + assertConversationKey( + "2528c287fe822421bc0dc4c3615878eb98e8a8c31657616d08b29c00ce209e34", + "02f66ea16104c01a1c532e03f166c5370a22a5505753005a566366097150c6df60", + "c833bbb292956c43366145326d53b955ffb5da4e4998a2d853611841903f5442" + ); + + // Vector 6 + assertConversationKey( + "49808637b2d21129478041813aceb6f2c9d4929cd1303cdaf4fbdbd690905ff2", + "0274d2aab13e97827ea21baf253ad7e39b974bb2498cc747cdb168582a11847b65", + "4bf304d3c8c4608864c0fe03890b90279328cd24a018ffa9eb8f8ccec06b505d" + ); + + // Vector 7 + assertConversationKey( + "af67c382106242c5baabf856efdc0629cc1c5b4061f85b8ceaba52aa7e4b4082", + "02bdaf0001d63e7ec994fad736eab178ee3c2d7cfc925ae29f37d19224486db57b", + "a3a575dd66d45e9379904047ebfb9a7873c471687d0535db00ef2daa24b391db" + ); + + // Vector 8 + assertConversationKey( + "0e44e2d1db3c1717b05ffa0f08d102a09c554a1cbbf678ab158b259a44e682f1", + "021ffa76c5cc7a836af6914b840483726207cb750889753d7499fb8b76aa8fe0de", + "a39970a667b7f861f100e3827f4adbf6f464e2697686fe1a81aeda817d6b8bdf" + ); + + // Vector 9 + assertConversationKey( + "5fc0070dbd0666dbddc21d788db04050b86ed8b456b080794c2a0c8e33287bb6", + "0231990752f296dd22e146c9e6f152a269d84b241cc95bb3ff8ec341628a54caf0", + "72c21075f4b2349ce01a3e604e02a9ab9f07e35dd07eff746de348b4f3c6365e" + ); + + // Vector 10 + assertConversationKey( + "1b7de0d64d9b12ddbb52ef217a3a7c47c4362ce7ea837d760dad58ab313cba64", + "0224383541dd8083b93d144b431679d70ef4eec10c98fceef1eff08b1d81d4b065", + "dd152a76b44e63d1afd4dfff0785fa07b3e494a9e8401aba31ff925caeb8f5b1" + ); + + // Vector 11 + assertConversationKey( + "df2f560e213ca5fb33b9ecde771c7c0cbd30f1cf43c2c24de54480069d9ab0af", + "03eeea26e552fc8b5e377acaa03e47daa2d7b0c787fac1e0774c9504d9094c430e", + "770519e803b80f411c34aef59c3ca018608842ebf53909c48d35250bd9323af6" + ); + + // Vector 12 + assertConversationKey( + "cffff919fcc07b8003fdc63bc8a00c0f5dc81022c1c927c62c597352190d95b9", + "03eb5c3cca1a968e26684e5b0eb733aecfc844f95a09ac4e126a9e58a4e4902f92", + "46a14ee7e80e439ec75c66f04ad824b53a632b8409a29bbb7c192e43c00bb795" + ); + + // Vector 13 + assertConversationKey( + "64ba5a685e443e881e9094647ddd32db14444bb21aa7986beeba3d1c4673ba0a", + "0250e6a4339fac1f3bf86f2401dd797af43ad45bbf58e0801a7877a3984c77c3c4", + "968b9dbbfcede1664a4ca35a5d3379c064736e87aafbf0b5d114dff710b8a946" + ); + + // Vector 14 + assertConversationKey( + "dd0c31ccce4ec8083f9b75dbf23cc2878e6d1b6baa17713841a2428f69dee91a", + "02b483e84c1339812bed25be55cff959778dfc6edde97ccd9e3649f442472c091b", + "09024503c7bde07eb7865505891c1ea672bf2d9e25e18dd7a7cea6c69bf44b5d" + ); + + // Vector 15 + assertConversationKey( + "af71313b0d95c41e968a172b33ba5ebd19d06cdf8a7a98df80ecf7af4f6f0358", + "022a5c25266695b461ee2af927a6c44a3c598b8095b0557e9bd7f787067435bc7c", + "fe5155b27c1c4b4e92a933edae23726a04802a7cc354a77ac273c85aa3c97a92" + ); + } + + /** + * Test edge case conversation key derivation with boundary values. + * These vectors test edge cases like sec1 = n-2, sec1 = 2, and sec1 == pub2. + */ + @Test + public void testConversationKeyEdgeCases() { + // Edge case: sec1 = n-2, pub2: random, 0x02 + assertConversationKey( + "fffffffffffffffffffffffffffffffebaaedce6af48a03bbfd25e8cd0364139", + "020000000000000000000000000000000000000000000000000000000000000002", + "8b6392dbf2ec6a2b2d5b1477fc2be84d63ef254b667cadd31bd3f444c44ae6ba" + ); + + // Edge case: sec1 = 2, pub2: random + assertConversationKey( + "0000000000000000000000000000000000000000000000000000000000000002", + "021234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdeb", + "be234f46f60a250bef52a5ee34c758800c4ca8e5030bf4cc1a31d37ba2104d43" + ); + + // Edge case: sec1 == pub2 (G point) + assertConversationKey( + "0000000000000000000000000000000000000000000000000000000000000001", + "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", + "3b4610cb7189beb9cc29eb3716ecc6102f1247e8f3101a03a1787d8908aeb54e" + ); + } + + /** + * Helper method to assert conversation key derivation matches expected value. + * + * @param sec1 Private key as hex string + * @param pub2 Public key as hex string (compressed format with 02/03 prefix) + * @param expectedConversationKey Expected conversation key as hex string + */ + private void assertConversationKey(String sec1, String pub2, String expectedConversationKey) { + byte[] conversationKey = EncryptedPayloads.getConversationKey(sec1, pub2); + byte[] expected = EncryptedPayloads.hexStringToByteArray(expectedConversationKey); + + assertArrayEquals( + expected, + conversationKey, + String.format( + "Conversation key mismatch for sec1=%s, pub2=%s. Expected=%s, Got=%s", + sec1.substring(0, 16) + "...", + pub2.substring(0, 16) + "...", + expectedConversationKey.substring(0, 16) + "...", + bytesToHex(conversationKey).substring(0, 16) + "..." + ) + ); + } + + /** + * Convert byte array to hex string for debugging. + */ + private String bytesToHex(byte[] bytes) { + StringBuilder sb = new StringBuilder(); + for (byte b : bytes) { + sb.append(String.format("%02x", b)); + } + return sb.toString(); + } +} From 624ba8cf5fc784d4e68d62bebccb7d8057e7b267 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 21 Jan 2026 13:45:12 +0000 Subject: [PATCH 3/4] feat(nip44): add cross-implementation test vectors and fix ChaCha20ParameterSpec Co-authored-by: tcheeric <6341500+tcheeric@users.noreply.github.com> --- .../nostr/crypto/nip44/EncryptedPayloads.java | 6 +- .../crypto/nip44/Nip44EncryptDecryptTest.java | 81 +++++++++++++++++++ 2 files changed, 84 insertions(+), 3 deletions(-) create mode 100644 nostr-java-crypto/src/test/java/nostr/crypto/nip44/Nip44EncryptDecryptTest.java diff --git a/nostr-java-crypto/src/main/java/nostr/crypto/nip44/EncryptedPayloads.java b/nostr-java-crypto/src/main/java/nostr/crypto/nip44/EncryptedPayloads.java index 833391ec..f0821482 100644 --- a/nostr-java-crypto/src/main/java/nostr/crypto/nip44/EncryptedPayloads.java +++ b/nostr-java-crypto/src/main/java/nostr/crypto/nip44/EncryptedPayloads.java @@ -2,7 +2,7 @@ import javax.crypto.Cipher; import javax.crypto.Mac; -import javax.crypto.spec.IvParameterSpec; +import javax.crypto.spec.ChaCha20ParameterSpec; import javax.crypto.spec.SecretKeySpec; import lombok.NonNull; import lombok.extern.slf4j.Slf4j; @@ -42,7 +42,7 @@ public static String encrypt(String plaintext, byte[] conversationKey, byte[] no cipher.init( Cipher.ENCRYPT_MODE, new SecretKeySpec(chachaKey, Constants.ENCRYPTION_ALGORITHM), - new IvParameterSpec(chachaNonce)); + new ChaCha20ParameterSpec(chachaNonce, 0)); byte[] ciphertext = cipher.doFinal(padded); Mac mac = Mac.getInstance(Constants.HMAC_ALGORITHM); @@ -89,7 +89,7 @@ public static String decrypt(String payload, byte[] conversationKey) throws Exce cipher.init( Cipher.DECRYPT_MODE, new SecretKeySpec(chachaKey, Constants.ENCRYPTION_ALGORITHM), - new IvParameterSpec(chachaNonce)); + new ChaCha20ParameterSpec(chachaNonce, 0)); byte[] paddedPlaintext = cipher.doFinal(ciphertext); return EncryptedPayloads.unpad(paddedPlaintext); diff --git a/nostr-java-crypto/src/test/java/nostr/crypto/nip44/Nip44EncryptDecryptTest.java b/nostr-java-crypto/src/test/java/nostr/crypto/nip44/Nip44EncryptDecryptTest.java new file mode 100644 index 00000000..2dd0025b --- /dev/null +++ b/nostr-java-crypto/src/test/java/nostr/crypto/nip44/Nip44EncryptDecryptTest.java @@ -0,0 +1,81 @@ +package nostr.crypto.nip44; + +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * Cross-implementation test vectors for NIP-44 decryption compatibility. + * + * These test vectors are from the official NIP-44 specification test vectors + * (https://github.com/paulmillr/nip44/blob/main/nip44.vectors.json) and verify + * that this Java implementation can decrypt messages encrypted by + * JavaScript implementations (nostr-tools) and other language implementations. + * + * This ensures DM interoperability across different Nostr client implementations. + */ +public class Nip44EncryptDecryptTest { + + /** + * Test that decryption correctly recovers plaintext from official test vectors. + * This tests the decrypt-only path to ensure compatibility with messages encrypted + * by other implementations. + */ + @Test + public void testDecryptWithOfficialVectors() throws Exception { + // Vector 1: Single character + assertDecrypt( + "c41c775356fd92eadc63ff5a0dc1da211b268cbea22316767095b2871ea1412d", + "AgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABee0G5VSK0/9YypIObAtDKfYEAjD35uVkHyB0F4DwrcNaCXlCWZKaArsGrY6M9wnuTMxWfp1RTN9Xga8no+kF5Vsb", + "a" + ); + + // Vector 2: Emoji + assertDecrypt( + "c41c775356fd92eadc63ff5a0dc1da211b268cbea22316767095b2871ea1412d", + "AvAAAAAAAAAAAAAAAAAAAPAAAAAAAAAAAAAAAAAAAAAPSKSK6is9ngkX2+cSq85Th16oRTISAOfhStnixqZziKMDvB0QQzgFZdjLTPicCJaV8nDITO+QfaQ61+KbWQIOO2Yj", + "🍕🫃" + ); + + // Vector 3: Complex Unicode + assertDecrypt( + "3e2b52a63be47d34fe0a80e34e73d436d6963bc8f39827f327057a9986c20a45", + "ArY1I2xC2yDwIbuNHN/1ynXdGgzHLqdCrXUPMwELJPc7s7JqlCMJBAIIjfkpHReBPXeoMCyuClwgbT419jUWU1PwaNl4FEQYKCDKVJz+97Mp3K+Q2YGa77B6gpxB/lr1QgoqpDf7wDVrDmOqGoiPjWDqy8KzLueKDcm9BVP8xeTJIxs=", + "表ポあA鷗ŒéB逍Üߪąñ丂㐀𠀀" + ); + + // Vector 4: Multi-language + assertDecrypt( + "d5a2f879123145a4b291d767428870f5a8d9e5007193321795b40183d4ab8c2b", + "ArIJia3D3cQc0sQ1lSwNWakTFdjFIY1QQFc/w3SVQ6yvbG2S0x4Yu86QGwPTy7mP3961I1XqB6SFFTzqDZZavhxoWMj7mEVGMQIsh2RLWI5EYQaQDIePSnXPlzf7CIt+voTD", + "ability🤝的 ȺȾ" + ); + + // Vector 5: Cyrillic with emoji + assertDecrypt( + "3b15c977e20bfe4b8482991274635edd94f366595b1a3d2993515705ca3cedb8", + "Ao1EQnE+udR5EXXLBA2Y1vxb6IZNbsL4nPCJWisrctGxY3AduCS+jTUgAAnfvKafkmpy15+i9YMwCdccisRa8SvzW671T2JO4LFSPX31K4kYUKelSAdSPwe9NwO6LhOsnoJ+", + "pepper👀їжак" + ); + } + + /** + * Helper method to assert decryption of official test vector. + * + * @param conversationKeyHex Conversation key as hex string + * @param payload Base64-encoded encrypted payload + * @param expectedPlaintext Expected decrypted plaintext + */ + private void assertDecrypt(String conversationKeyHex, String payload, String expectedPlaintext) + throws Exception { + byte[] conversationKey = EncryptedPayloads.hexStringToByteArray(conversationKeyHex); + + String decrypted = EncryptedPayloads.decrypt(payload, conversationKey); + assertEquals( + expectedPlaintext, + decrypted, + String.format( + "Decryption mismatch for payload=%s...", + payload.length() > 20 ? payload.substring(0, 20) + "..." : payload)); + } +} From 62da314d67c2c53c56628fa339087a1c679515ea Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 21 Jan 2026 13:49:01 +0000 Subject: [PATCH 4/4] refactor: address code review feedback - extract magic numbers and add version comment Co-authored-by: tcheeric <6341500+tcheeric@users.noreply.github.com> --- .../java/nostr/crypto/nip44/EncryptedPayloads.java | 2 +- .../nostr/crypto/nip44/Nip44ConversationKeyTest.java | 10 ++++++---- .../nostr/crypto/nip44/Nip44EncryptDecryptTest.java | 4 +++- 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/nostr-java-crypto/src/main/java/nostr/crypto/nip44/EncryptedPayloads.java b/nostr-java-crypto/src/main/java/nostr/crypto/nip44/EncryptedPayloads.java index f0821482..e65a3c08 100644 --- a/nostr-java-crypto/src/main/java/nostr/crypto/nip44/EncryptedPayloads.java +++ b/nostr-java-crypto/src/main/java/nostr/crypto/nip44/EncryptedPayloads.java @@ -2,7 +2,7 @@ import javax.crypto.Cipher; import javax.crypto.Mac; -import javax.crypto.spec.ChaCha20ParameterSpec; +import javax.crypto.spec.ChaCha20ParameterSpec; // Requires Java 11+ import javax.crypto.spec.SecretKeySpec; import lombok.NonNull; import lombok.extern.slf4j.Slf4j; diff --git a/nostr-java-crypto/src/test/java/nostr/crypto/nip44/Nip44ConversationKeyTest.java b/nostr-java-crypto/src/test/java/nostr/crypto/nip44/Nip44ConversationKeyTest.java index ff3b6d87..a37420eb 100644 --- a/nostr-java-crypto/src/test/java/nostr/crypto/nip44/Nip44ConversationKeyTest.java +++ b/nostr-java-crypto/src/test/java/nostr/crypto/nip44/Nip44ConversationKeyTest.java @@ -16,6 +16,8 @@ */ public class Nip44ConversationKeyTest { + private static final int HEX_DISPLAY_LENGTH = 16; + /** * Test conversation key derivation with official test vectors from NIP-44 spec. * Verifies that ECDH key agreement and HKDF-Extract produce compatible results. @@ -172,10 +174,10 @@ private void assertConversationKey(String sec1, String pub2, String expectedConv conversationKey, String.format( "Conversation key mismatch for sec1=%s, pub2=%s. Expected=%s, Got=%s", - sec1.substring(0, 16) + "...", - pub2.substring(0, 16) + "...", - expectedConversationKey.substring(0, 16) + "...", - bytesToHex(conversationKey).substring(0, 16) + "..." + sec1.substring(0, HEX_DISPLAY_LENGTH) + "...", + pub2.substring(0, HEX_DISPLAY_LENGTH) + "...", + expectedConversationKey.substring(0, HEX_DISPLAY_LENGTH) + "...", + bytesToHex(conversationKey).substring(0, HEX_DISPLAY_LENGTH) + "..." ) ); } diff --git a/nostr-java-crypto/src/test/java/nostr/crypto/nip44/Nip44EncryptDecryptTest.java b/nostr-java-crypto/src/test/java/nostr/crypto/nip44/Nip44EncryptDecryptTest.java index 2dd0025b..57ed9b49 100644 --- a/nostr-java-crypto/src/test/java/nostr/crypto/nip44/Nip44EncryptDecryptTest.java +++ b/nostr-java-crypto/src/test/java/nostr/crypto/nip44/Nip44EncryptDecryptTest.java @@ -16,6 +16,8 @@ */ public class Nip44EncryptDecryptTest { + private static final int DISPLAY_LENGTH = 20; + /** * Test that decryption correctly recovers plaintext from official test vectors. * This tests the decrypt-only path to ensure compatibility with messages encrypted @@ -76,6 +78,6 @@ private void assertDecrypt(String conversationKeyHex, String payload, String exp decrypted, String.format( "Decryption mismatch for payload=%s...", - payload.length() > 20 ? payload.substring(0, 20) + "..." : payload)); + payload.length() > DISPLAY_LENGTH ? payload.substring(0, DISPLAY_LENGTH) + "..." : payload)); } }