diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip49PrivKeyEnc/Nip49.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip49PrivKeyEnc/Nip49.kt index 1e82206ad5..76d808e9e8 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip49PrivKeyEnc/Nip49.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip49PrivKeyEnc/Nip49.kt @@ -48,18 +48,28 @@ class Nip49 { val key = SCrypt.scrypt(normalizedPassword, encryptedInfo.salt, n, 8, 1, 32) val m = ByteArray(32) - LibSodiumInstance.cryptoAeadXChaCha20Poly1305IetfDecrypt( - m, - key, - encryptedInfo.encryptedKey, - byteArrayOf(encryptedInfo.keySecurity), - encryptedInfo.nonce, - key, - ) + try { + // The Poly1305 tag is what tells a wrong password apart. Inspecting the output + // instead (e.g. "any byte > 0") rejects valid keys whose bytes are all >= 0x80. + val authenticated = + LibSodiumInstance.cryptoAeadXChaCha20Poly1305IetfDecrypt( + m, + key, + encryptedInfo.encryptedKey, + byteArrayOf(encryptedInfo.keySecurity), + encryptedInfo.nonce, + key, + ) - check(m.any { it > 0 }) { "Incorrect password" } + check(authenticated) { "Incorrect password" } - return m.toHexKey() + return m.toHexKey() + } finally { + // NIP-49: the symmetric key should be zeroed and discarded after use. + key.fill(0) + normalizedPassword.fill(0) + m.fill(0) + } } fun encrypt( @@ -84,18 +94,28 @@ class Nip49 { val key = SCrypt.scrypt(normalizedPassword, salt, n, 8, 1, 32) val ciphertext = ByteArray(48) - // byte[] c, long[] cLen, - // byte[] m, long mLen, - // byte[] ad, long adLen, - // byte[] nSec, byte[] nPub, byte[] k - LibSodiumInstance.cryptoAeadXChaCha20Poly1305IetfEncrypt( - ciphertext, - secretKey, - byteArrayOf(ksb), - key, - nonce, - key, - ) + try { + // byte[] c, long[] cLen, + // byte[] m, long mLen, + // byte[] ad, long adLen, + // byte[] nSec, byte[] nPub, byte[] k + val encrypted = + LibSodiumInstance.cryptoAeadXChaCha20Poly1305IetfEncrypt( + ciphertext, + secretKey, + byteArrayOf(ksb), + key, + nonce, + key, + ) + // Never hand back an ncryptsec of an untouched (all-zero) buffer: it would be a + // backup that no password can ever open. + check(encrypted) { "Failed to encrypt the key" } + } finally { + // NIP-49: the symmetric key should be zeroed and discarded after use. + key.fill(0) + normalizedPassword.fill(0) + } return EncryptedInfo( EncryptedInfo.V, diff --git a/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip49PrivKeyEnc/Nip49SpecTest.kt b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip49PrivKeyEnc/Nip49SpecTest.kt new file mode 100644 index 0000000000..79a0023191 --- /dev/null +++ b/quartz/src/jvmTest/kotlin/com/vitorpamplona/quartz/nip49PrivKeyEnc/Nip49SpecTest.kt @@ -0,0 +1,109 @@ +/* + * Copyright (c) 2025 Vitor Pamplona + * + * Permission is hereby granted, free of charge, to any person obtaining a copy of + * this software and associated documentation files (the "Software"), to deal in + * the Software without restriction, including without limitation the rights to use, + * copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the + * Software, and to permit persons to whom the Software is furnished to do so, + * subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS + * FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR + * COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN + * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION + * WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + */ +package com.vitorpamplona.quartz.nip49PrivKeyEnc + +import com.vitorpamplona.quartz.nip19Bech32.Bech32Transcription +import com.vitorpamplona.quartz.nip19Bech32.bech32.bechToBytes +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertNotEquals + +/** + * NIP-49 conformance on the JVM. The device test (androidDeviceTest/NIP49Test) covers the + * same vectors but runs in neither `./gradlew test` nor pre-push, so nothing on the + * default path proved the spec vectors still decrypt. + */ +class Nip49SpecTest { + private val nip49 = Nip49() + + private val specNcryptsec = "ncryptsec1qgg9947rlpvqu76pj5ecreduf9jxhselq2nae2kghhvd5g7dgjtcxfqtd67p9m0w57lspw8gsq6yphnm8623nsl8xn9j4jdzz84zm3frztj3z7s35vpzmqf6ksu8r89qk5z2zxfmu5gv8th8wclt0h4p" + private val specKey = "3501454135014541350145413501453fefb02227e449e57cf4d3a3ce05378683" + + @Test + fun decryptsTheSpecVector() { + assertEquals(specKey, nip49.decrypt(specNcryptsec, "nostr")) + } + + @Test + fun specVectorFieldsDecode() { + val info = Nip49.EncryptedInfo.decodePayload(specNcryptsec)!! + assertEquals(0x02.toByte(), info.version) + assertEquals(16.toByte(), info.logn) + assertEquals(16, info.salt.size) + assertEquals(24, info.nonce.size) + assertEquals(48, info.encryptedKey.size) + } + + @Test + fun encryptProducesA91BytePayloadWithVersion2() { + val payload = nip49.encrypt(specKey, "nostr").bechToBytes() + assertEquals(91, payload.size) + assertEquals(0x02.toByte(), payload[0]) + assertEquals(16.toByte(), payload[1]) + assertEquals(Nip49.EncryptedInfo.CLIENT_DOES_NOT_TRACK, payload[2 + 16 + 24]) + } + + @Test + fun encryptionIsNonDeterministic() { + assertNotEquals(nip49.encrypt(specKey, "nostr"), nip49.encrypt(specKey, "nostr")) + } + + @Test + fun passwordsAreNfkcNormalized() { + // Spec vector: U+212B U+2126 U+1E9B U+0323 normalizes to U+00C5 U+03A9 U+1E69. + val typed = "ÅΩẛ̣" + val normalized = "ÅΩṩ" + assertNotEquals(typed, normalized) + + val encrypted = nip49.encrypt(specKey, typed, 8, Nip49.EncryptedInfo.CLIENT_DOES_NOT_TRACK) + assertEquals(specKey, nip49.decrypt(encrypted, normalized)) + } + + @Test + fun wrongPasswordIsRejected() { + assertFailsWith { nip49.decrypt(specNcryptsec, "nostr2") } + } + + @Test + fun anyNonEmptyPasswordShapeRoundTrips() { + // NIP-49 puts no length or character-class rules on the password. + listOf("a", " ", " leading and trailing ", "ção", "🔑🔒", "x".repeat(1000)).forEach { + val encrypted = nip49.encrypt(specKey, it, 8, Nip49.EncryptedInfo.CLIENT_DOES_NOT_TRACK) + assertEquals(specKey, nip49.decrypt(encrypted, it)) + } + } + + @Test + fun keysWithNoPositiveSignedByteDecrypt() { + // Every byte >= 0x80 is negative as a signed Kotlin Byte. A valid key (well below + // the secp256k1 order), so the correct password must decrypt it. + val key = "80".repeat(32) + val encrypted = nip49.encrypt(key, "nostr", 8, Nip49.EncryptedInfo.CLIENT_DOES_NOT_TRACK) + assertEquals(key, nip49.decrypt(encrypted, "nostr")) + } + + @Test + fun handCopiedNcryptsecDecrypts() { + val handCopied = Bech32Transcription.groups(specNcryptsec.uppercase()).joinToString("\n") { it.joinToString("-") } + assertEquals(specKey, nip49.decrypt(Bech32Transcription.normalize(handCopied), "nostr")) + } +}