mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-06 03:38:23 +00:00
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:
@@ -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"))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user