diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/ConnectResponse.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/ConnectResponse.kt new file mode 100644 index 000000000..a6583a80b --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/ConnectResponse.kt @@ -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 { + 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() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/NostrSignerRemote.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/NostrSignerRemote.kt index 66810e41f..fb2e344a8 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/NostrSignerRemote.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/NostrSignerRemote.kt @@ -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) { - return result.result.pubkey + if (result is SignerResult.RequestAddressed.Successful) { + return } throw convertExceptions("Could not connect", result) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/SignerResult.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/SignerResult.kt index cb7927d10..a0cba9eb9 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/SignerResult.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/SignerResult.kt @@ -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 +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip44Encryption/Nip44v2JvmTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip44Encryption/Nip44v2JvmTest.kt new file mode 100644 index 000000000..4173f9e22 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip44Encryption/Nip44v2JvmTest.kt @@ -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) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/ResponseParserTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/ResponseParserTest.kt new file mode 100644 index 000000000..af0e62c21 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/ResponseParserTest.kt @@ -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>(result) + assertIs(result.result) + } + + @Test + fun connectParseSecret() { + val response = BunkerResponse("req-0", "my-secret-token", null) + val result = ConnectResponse.parse(response) + assertIs>(result) + assertIs(result.result) + } + + @Test + fun connectParseAlreadyConnected() { + val response = BunkerResponse("req-0", null, "already connected") + val result = ConnectResponse.parse(response) + assertIs>(result) + assertIs(result.result) + } + + @Test + fun connectParseAlreadyConnectedCaseInsensitive() { + val response = BunkerResponse("req-0", null, "Already Connected") + val result = ConnectResponse.parse(response) + assertIs>(result) + assertIs(result.result) + } + + @Test + fun connectParseRealError() { + val response = BunkerResponse("req-0", null, "unauthorized") + val result = ConnectResponse.parse(response) + assertIs>(result) + } + + @Test + fun connectParseNoResultNoError() { + val response = BunkerResponse("req-0", null, null) + val result = ConnectResponse.parse(response) + assertIs>(result) + } + + // --- PingResponse --- + + @Test + fun pingParsePong() { + val response = BunkerResponsePong("req-1") + val result = PingResponse.parse(response) + assertIs>(result) + assertEquals("req-1", result.result.pong) + } + + @Test + fun pingParseError() { + val response = BunkerResponseError("req-1", "not allowed") + val result = PingResponse.parse(response) + assertIs>(result) + } + + @Test + fun pingParseUnexpected() { + val response = BunkerResponseAck("req-1") + val result = PingResponse.parse(response) + assertIs>(result) + } + + // --- PubKeyResponse --- + + @Test + fun pubKeyParseSuccess() { + val hex = "a".repeat(64) + val response = BunkerResponsePublicKey("req-2", hex) + val result = PubKeyResponse.parse(response) + assertIs>(result) + assertEquals(hex, result.result.pubkey) + } + + @Test + fun pubKeyParseError() { + val response = BunkerResponseError("req-2", "denied") + val result = PubKeyResponse.parse(response) + assertIs>(result) + } + + @Test + fun pubKeyParseUnexpected() { + val response = BunkerResponseAck("req-2") + val result = PubKeyResponse.parse(response) + assertIs>(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>(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>(result) + } + + @Test + fun signParseError() { + val response = BunkerResponseError("req-3", "denied") + val result = SignResponse.parse(response) + assertIs>(result) + } + + @Test + fun signParseUnexpected() { + val response = BunkerResponseAck("req-3") + val result = SignResponse.parse(response) + assertIs>(result) + } + + // --- Nip04EncryptResponse --- + + @Test + fun nip04EncryptParseSuccess() { + val response = BunkerResponseEncrypt("req-4", "ciphertext-data") + val result = Nip04EncryptResponse.parse(response) + assertIs>(result) + assertEquals("ciphertext-data", result.result.ciphertext) + } + + @Test + fun nip04EncryptParseError() { + val response = BunkerResponseError("req-4", "fail") + val result = Nip04EncryptResponse.parse(response) + assertIs>(result) + } + + @Test + fun nip04EncryptParseUnexpected() { + val response = BunkerResponsePong("req-4") + val result = Nip04EncryptResponse.parse(response) + assertIs>(result) + } + + // --- Nip04DecryptResponse --- + + @Test + fun nip04DecryptParseSuccess() { + val response = BunkerResponseDecrypt("req-5", "plain-text") + val result = Nip04DecryptResponse.parse(response) + assertIs>(result) + assertEquals("plain-text", result.result.plaintext) + } + + @Test + fun nip04DecryptParseError() { + val response = BunkerResponseError("req-5", "fail") + val result = Nip04DecryptResponse.parse(response) + assertIs>(result) + } + + @Test + fun nip04DecryptParseUnexpected() { + val response = BunkerResponsePong("req-5") + val result = Nip04DecryptResponse.parse(response) + assertIs>(result) + } + + // --- Nip44EncryptResponse --- + + @Test + fun nip44EncryptParseSuccess() { + val response = BunkerResponseEncrypt("req-6", "nip44-cipher") + val result = Nip44EncryptResponse.parse(response) + assertIs>(result) + assertEquals("nip44-cipher", result.result.ciphertext) + } + + @Test + fun nip44EncryptParseError() { + val response = BunkerResponseError("req-6", "fail") + val result = Nip44EncryptResponse.parse(response) + assertIs>(result) + } + + @Test + fun nip44EncryptParseUnexpected() { + val response = BunkerResponseAck("req-6") + val result = Nip44EncryptResponse.parse(response) + assertIs>(result) + } + + // --- Nip44DecryptResponse --- + + @Test + fun nip44DecryptParseSuccess() { + val response = BunkerResponseDecrypt("req-7", "nip44-plain") + val result = Nip44DecryptResponse.parse(response) + assertIs>(result) + assertEquals("nip44-plain", result.result.plaintext) + } + + @Test + fun nip44DecryptParseError() { + val response = BunkerResponseError("req-7", "fail") + val result = Nip44DecryptResponse.parse(response) + assertIs>(result) + } + + @Test + fun nip44DecryptParseUnexpected() { + val response = BunkerResponseAck("req-7") + val result = Nip44DecryptResponse.parse(response) + assertIs>(result) + } +} diff --git a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/utils/mac/FixedKey.kt b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/utils/mac/FixedKey.kt index 5c884998c..9d875956b 100644 --- a/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/utils/mac/FixedKey.kt +++ b/quartz/src/jvmAndroid/kotlin/com/vitorpamplona/quartz/utils/mac/FixedKey.kt @@ -31,7 +31,7 @@ class FixedKey( ) : SecretKey { override fun getAlgorithm() = algo - override fun getEncoded() = key + override fun getEncoded() = key.copyOf() override fun getFormat() = "RAW"