Merge pull request #1789 from nrobi144/fix/nip46-quartz-bugs

fix(quartz): fix NIP-44 key mutation and NIP-46 connect response handling
This commit is contained in:
Vitor Pamplona
2026-03-10 08:08:08 -04:00
committed by GitHub
6 changed files with 441 additions and 5 deletions
@@ -0,0 +1,43 @@
/*
* 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.nip46RemoteSigner.signer
import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponse
class ConnectResponse {
companion object {
fun parse(response: BunkerResponse): SignerResult.RequestAddressed<ConnectResult> {
if (response.error != null) {
return if (response.error.contains("already connected", ignoreCase = true)) {
SignerResult.RequestAddressed.Successful(ConnectResult.AlreadyConnected)
} else {
SignerResult.RequestAddressed.Rejected()
}
}
if (response.result != null) {
return SignerResult.RequestAddressed.Successful(ConnectResult.Ack)
}
return SignerResult.RequestAddressed.ReceivedButCouldNotPerform()
}
}
}
@@ -220,7 +220,7 @@ class NostrSignerRemote(
throw convertExceptions("Could not ping", result)
}
suspend fun connect(): HexKey {
suspend fun connect() {
val result =
manager.launchWaitAndParse(
bunkerRequestBuilder = {
@@ -230,11 +230,11 @@ class NostrSignerRemote(
secret = secret,
)
},
parser = PubKeyResponse::parse,
parser = ConnectResponse::parse,
)
if (result is SignerResult.RequestAddressed.Successful<PublicKeyResult>) {
return result.result.pubkey
if (result is SignerResult.RequestAddressed.Successful<ConnectResult>) {
return
}
throw convertExceptions("Could not connect", result)
@@ -67,3 +67,9 @@ data class PingResult(
data class PublicKeyResult(
val pubkey: String,
) : IResult
sealed interface ConnectResult : IResult {
data object Ack : ConnectResult
data object AlreadyConnected : ConnectResult
}
@@ -0,0 +1,103 @@
/*
* 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.nip44Encryption
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import kotlin.test.Test
import kotlin.test.assertEquals
/**
* NIP-44v2 tests verifying conversation key derivation, ECDH symmetry,
* and encrypt/decrypt round-trips on JVM Desktop.
*/
class Nip44v2JvmTest {
@Test
fun conversationKeyFromSpecVector1() {
val nip44v2 = Nip44v2()
val convKey =
nip44v2.getConversationKey(
"315e59ff51cb9209768cf7da80791ddcaae56ac9775eb25b6dee1234bc5d2268".hexToByteArray(),
"c2f9d9948dc8c7c38321e4b85c8558872eafa0641cd269db76848a6073e69133".hexToByteArray(),
)
assertEquals(
"3dfef0ce2a4d80a25e7a328accf73448ef67096f65f79588e358d9a0eb9013f1",
convKey.toHexKey(),
)
}
@Test
fun conversationKeyFromSpecVector2() {
val nip44v2 = Nip44v2()
val convKey =
nip44v2.getConversationKey(
"a1e37752c9fdc1273be53f68c5f74be7c8905728e8de75800b94262f9497c86e".hexToByteArray(),
"03bb7947065dde12ba991ea045132581d0954f042c84e06d8c00066e23c1a800".hexToByteArray(),
)
assertEquals(
"4d14f36e81b8452128da64fe6f1eae873baae2f444b02c950b90e43553f2178b",
convKey.toHexKey(),
)
}
@Test
fun conversationKeyIsSymmetric() {
val nip44v2 = Nip44v2()
val privA = "f410f88bcec6cbfda04d6a273c7b1dd8bba144cd45b71e87109cfa11dd7ed561".hexToByteArray()
val privB = "65f039136f8da8d3e87b4818746b53318d5481e24b2673f162815144223a0b5a".hexToByteArray()
val pubA = KeyPair(privA).pubKey
val pubB = KeyPair(privB).pubKey
val convAB = nip44v2.getConversationKey(privA, pubB)
val convBA = nip44v2.getConversationKey(privB, pubA)
assertEquals(convAB.toHexKey(), convBA.toHexKey())
}
@Test
fun encryptDecryptRoundTrip() {
val nip44v2 = Nip44v2()
val privA = "0000000000000000000000000000000000000000000000000000000000000001".hexToByteArray()
val privB = "0000000000000000000000000000000000000000000000000000000000000002".hexToByteArray()
val pubA = KeyPair(privA).pubKey
val pubB = KeyPair(privB).pubKey
val encrypted = nip44v2.encrypt("hello world", privA, pubB)
val decrypted = nip44v2.decrypt(encrypted, privB, pubA)
assertEquals("hello world", decrypted)
}
@Test
fun crossKeyPairDecrypt() {
val nip44v2 = Nip44v2()
val ephemeralPriv = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".hexToByteArray()
val signerPriv = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb".hexToByteArray()
val ephemeralPub = KeyPair(ephemeralPriv).pubKey
val signerPub = KeyPair(signerPriv).pubKey
val request = """{"id":"test","method":"connect","params":["deadbeef"]}"""
val encryptedRequest = nip44v2.encrypt(request, ephemeralPriv, signerPub)
val decryptedRequest = nip44v2.decrypt(encryptedRequest, signerPriv, ephemeralPub)
assertEquals(request, decryptedRequest)
}
}
@@ -0,0 +1,284 @@
/*
* 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.nip46RemoteSigner.signer
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponse
import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponseAck
import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponseDecrypt
import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponseEncrypt
import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponseError
import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponseEvent
import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponsePong
import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponsePublicKey
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertIs
class ResponseParserTest {
// --- ConnectResponse ---
// connect never returns a pubkey — it's "ack" or the secret string.
// Tests use base BunkerResponse to match production deserialization behavior.
@Test
fun connectParseAck() {
val response = BunkerResponse("req-0", "ack", null)
val result = ConnectResponse.parse(response)
assertIs<SignerResult.RequestAddressed.Successful<ConnectResult>>(result)
assertIs<ConnectResult.Ack>(result.result)
}
@Test
fun connectParseSecret() {
val response = BunkerResponse("req-0", "my-secret-token", null)
val result = ConnectResponse.parse(response)
assertIs<SignerResult.RequestAddressed.Successful<ConnectResult>>(result)
assertIs<ConnectResult.Ack>(result.result)
}
@Test
fun connectParseAlreadyConnected() {
val response = BunkerResponse("req-0", null, "already connected")
val result = ConnectResponse.parse(response)
assertIs<SignerResult.RequestAddressed.Successful<ConnectResult>>(result)
assertIs<ConnectResult.AlreadyConnected>(result.result)
}
@Test
fun connectParseAlreadyConnectedCaseInsensitive() {
val response = BunkerResponse("req-0", null, "Already Connected")
val result = ConnectResponse.parse(response)
assertIs<SignerResult.RequestAddressed.Successful<ConnectResult>>(result)
assertIs<ConnectResult.AlreadyConnected>(result.result)
}
@Test
fun connectParseRealError() {
val response = BunkerResponse("req-0", null, "unauthorized")
val result = ConnectResponse.parse(response)
assertIs<SignerResult.RequestAddressed.Rejected<ConnectResult>>(result)
}
@Test
fun connectParseNoResultNoError() {
val response = BunkerResponse("req-0", null, null)
val result = ConnectResponse.parse(response)
assertIs<SignerResult.RequestAddressed.ReceivedButCouldNotPerform<ConnectResult>>(result)
}
// --- PingResponse ---
@Test
fun pingParsePong() {
val response = BunkerResponsePong("req-1")
val result = PingResponse.parse(response)
assertIs<SignerResult.RequestAddressed.Successful<PingResult>>(result)
assertEquals("req-1", result.result.pong)
}
@Test
fun pingParseError() {
val response = BunkerResponseError("req-1", "not allowed")
val result = PingResponse.parse(response)
assertIs<SignerResult.RequestAddressed.Rejected<PingResult>>(result)
}
@Test
fun pingParseUnexpected() {
val response = BunkerResponseAck("req-1")
val result = PingResponse.parse(response)
assertIs<SignerResult.RequestAddressed.ReceivedButCouldNotPerform<PingResult>>(result)
}
// --- PubKeyResponse ---
@Test
fun pubKeyParseSuccess() {
val hex = "a".repeat(64)
val response = BunkerResponsePublicKey("req-2", hex)
val result = PubKeyResponse.parse(response)
assertIs<SignerResult.RequestAddressed.Successful<PublicKeyResult>>(result)
assertEquals(hex, result.result.pubkey)
}
@Test
fun pubKeyParseError() {
val response = BunkerResponseError("req-2", "denied")
val result = PubKeyResponse.parse(response)
assertIs<SignerResult.RequestAddressed.Rejected<PublicKeyResult>>(result)
}
@Test
fun pubKeyParseUnexpected() {
val response = BunkerResponseAck("req-2")
val result = PubKeyResponse.parse(response)
assertIs<SignerResult.RequestAddressed.ReceivedButCouldNotPerform<PublicKeyResult>>(result)
}
// --- SignResponse ---
@Test
fun signParseValidEvent() =
runTest {
val signer = NostrSignerInternal(KeyPair())
val event: Event =
signer.sign(
createdAt = 1234L,
kind = 1,
tags = emptyArray(),
content = "hello",
)
val response = BunkerResponseEvent("req-3", event)
val result = SignResponse.parse(response)
assertIs<SignerResult.RequestAddressed.Successful<SignResult>>(result)
assertEquals(event.id, result.result.event.id)
}
@Test
fun signParseInvalidSignature() {
val event =
Event(
id = "a".repeat(64),
pubKey = "b".repeat(64),
createdAt = 1234L,
kind = 1,
tags = emptyArray(),
content = "hello",
sig = "c".repeat(128),
)
val response = BunkerResponseEvent("req-3", event)
val result = SignResponse.parse(response)
assertIs<SignerResult.RequestAddressed.ReceivedButCouldNotVerifyResultingEvent<SignResult>>(result)
}
@Test
fun signParseError() {
val response = BunkerResponseError("req-3", "denied")
val result = SignResponse.parse(response)
assertIs<SignerResult.RequestAddressed.Rejected<SignResult>>(result)
}
@Test
fun signParseUnexpected() {
val response = BunkerResponseAck("req-3")
val result = SignResponse.parse(response)
assertIs<SignerResult.RequestAddressed.ReceivedButCouldNotPerform<SignResult>>(result)
}
// --- Nip04EncryptResponse ---
@Test
fun nip04EncryptParseSuccess() {
val response = BunkerResponseEncrypt("req-4", "ciphertext-data")
val result = Nip04EncryptResponse.parse(response)
assertIs<SignerResult.RequestAddressed.Successful<EncryptionResult>>(result)
assertEquals("ciphertext-data", result.result.ciphertext)
}
@Test
fun nip04EncryptParseError() {
val response = BunkerResponseError("req-4", "fail")
val result = Nip04EncryptResponse.parse(response)
assertIs<SignerResult.RequestAddressed.Rejected<EncryptionResult>>(result)
}
@Test
fun nip04EncryptParseUnexpected() {
val response = BunkerResponsePong("req-4")
val result = Nip04EncryptResponse.parse(response)
assertIs<SignerResult.RequestAddressed.ReceivedButCouldNotPerform<EncryptionResult>>(result)
}
// --- Nip04DecryptResponse ---
@Test
fun nip04DecryptParseSuccess() {
val response = BunkerResponseDecrypt("req-5", "plain-text")
val result = Nip04DecryptResponse.parse(response)
assertIs<SignerResult.RequestAddressed.Successful<DecryptionResult>>(result)
assertEquals("plain-text", result.result.plaintext)
}
@Test
fun nip04DecryptParseError() {
val response = BunkerResponseError("req-5", "fail")
val result = Nip04DecryptResponse.parse(response)
assertIs<SignerResult.RequestAddressed.Rejected<DecryptionResult>>(result)
}
@Test
fun nip04DecryptParseUnexpected() {
val response = BunkerResponsePong("req-5")
val result = Nip04DecryptResponse.parse(response)
assertIs<SignerResult.RequestAddressed.ReceivedButCouldNotPerform<DecryptionResult>>(result)
}
// --- Nip44EncryptResponse ---
@Test
fun nip44EncryptParseSuccess() {
val response = BunkerResponseEncrypt("req-6", "nip44-cipher")
val result = Nip44EncryptResponse.parse(response)
assertIs<SignerResult.RequestAddressed.Successful<EncryptionResult>>(result)
assertEquals("nip44-cipher", result.result.ciphertext)
}
@Test
fun nip44EncryptParseError() {
val response = BunkerResponseError("req-6", "fail")
val result = Nip44EncryptResponse.parse(response)
assertIs<SignerResult.RequestAddressed.Rejected<EncryptionResult>>(result)
}
@Test
fun nip44EncryptParseUnexpected() {
val response = BunkerResponseAck("req-6")
val result = Nip44EncryptResponse.parse(response)
assertIs<SignerResult.RequestAddressed.ReceivedButCouldNotPerform<EncryptionResult>>(result)
}
// --- Nip44DecryptResponse ---
@Test
fun nip44DecryptParseSuccess() {
val response = BunkerResponseDecrypt("req-7", "nip44-plain")
val result = Nip44DecryptResponse.parse(response)
assertIs<SignerResult.RequestAddressed.Successful<DecryptionResult>>(result)
assertEquals("nip44-plain", result.result.plaintext)
}
@Test
fun nip44DecryptParseError() {
val response = BunkerResponseError("req-7", "fail")
val result = Nip44DecryptResponse.parse(response)
assertIs<SignerResult.RequestAddressed.Rejected<DecryptionResult>>(result)
}
@Test
fun nip44DecryptParseUnexpected() {
val response = BunkerResponseAck("req-7")
val result = Nip44DecryptResponse.parse(response)
assertIs<SignerResult.RequestAddressed.ReceivedButCouldNotPerform<DecryptionResult>>(result)
}
}
@@ -31,7 +31,7 @@ class FixedKey(
) : SecretKey {
override fun getAlgorithm() = algo
override fun getEncoded() = key
override fun getEncoded() = key.copyOf()
override fun getFormat() = "RAW"