fix(nip49): trust the AEAD tag, zero derived keys, add JVM spec tests

- decrypt() ignored the XChaCha20-Poly1305 authentication result and treated
  "no byte > 0" as a wrong password. A valid key whose bytes are all >= 0x80
  (signed-negative) was rejected even with the right password. It now checks
  the tag. Reproduced first by Nip49SpecTest.keysWithNoPositiveSignedByteDecrypt.
- encrypt() ignored the AEAD result too, and on failure would have returned an
  ncryptsec of an all-zero buffer that no password opens. It now throws.
- The scrypt-derived key and the password bytes are zeroed after use, as
  NIP-49 recommends.
- Nip49SpecTest runs the spec vectors on the JVM: decrypt vector, 91-byte
  v2 payload, NFKC normalization vector, non-determinism, wrong password,
  arbitrary password shapes, and a hand-copied (uppercase, grouped)
  ncryptsec. The existing vectors only ran as device/iOS tests, which
  neither `./gradlew test` nor pre-push executes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012hhtLQhig6Wmt5U4C3owaP
This commit is contained in:
Claude
2026-09-26 20:32:13 +00:00
parent 33ea20effc
commit e0f200a5ac
2 changed files with 151 additions and 22 deletions
@@ -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,
@@ -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<IllegalStateException> { 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"))
}
}