diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/Nip04DecryptResponse.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/Nip04DecryptResponse.kt new file mode 100644 index 000000000..a671fbc58 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/Nip04DecryptResponse.kt @@ -0,0 +1,42 @@ +/** + * 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 +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponseDecrypt +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponseError + +class Nip04DecryptResponse { + companion object { + fun parse(response: BunkerResponse): SignerResult.RequestAddressed = + when (response) { + is BunkerResponseDecrypt -> { + SignerResult.RequestAddressed.Successful(DecryptionResult(response.plaintext)) + } + is BunkerResponseError -> { + SignerResult.RequestAddressed.Rejected() + } + else -> { + SignerResult.RequestAddressed.ReceivedButCouldNotPerform() + } + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/Nip04EncryptResponse.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/Nip04EncryptResponse.kt new file mode 100644 index 000000000..39e3d9ef9 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/Nip04EncryptResponse.kt @@ -0,0 +1,44 @@ +/** + * 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 +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponseEncrypt +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponseError + +class Nip04EncryptResponse { + companion object { + fun parse(response: BunkerResponse): SignerResult.RequestAddressed = + when (response) { + is BunkerResponseEncrypt -> { + SignerResult.RequestAddressed.Successful(EncryptionResult(response.ciphertext)) + } + + is BunkerResponseError -> { + SignerResult.RequestAddressed.Rejected() + } + + else -> { + SignerResult.RequestAddressed.ReceivedButCouldNotPerform() + } + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/Nip44DecryptResponse.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/Nip44DecryptResponse.kt new file mode 100644 index 000000000..7349f8991 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/Nip44DecryptResponse.kt @@ -0,0 +1,44 @@ +/** + * 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 +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponseDecrypt +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponseError + +class Nip44DecryptResponse { + companion object { + fun parse(response: BunkerResponse): SignerResult.RequestAddressed = + when (response) { + is BunkerResponseDecrypt -> { + SignerResult.RequestAddressed.Successful(DecryptionResult(response.plaintext)) + } + + is BunkerResponseError -> { + SignerResult.RequestAddressed.Rejected() + } + + else -> { + SignerResult.RequestAddressed.ReceivedButCouldNotPerform() + } + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/Nip44EncryptResponse.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/Nip44EncryptResponse.kt new file mode 100644 index 000000000..255fe5f46 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/Nip44EncryptResponse.kt @@ -0,0 +1,44 @@ +/** + * 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 +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponseEncrypt +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponseError + +class Nip44EncryptResponse { + companion object { + fun parse(response: BunkerResponse): SignerResult.RequestAddressed = + when (response) { + is BunkerResponseEncrypt -> { + SignerResult.RequestAddressed.Successful(EncryptionResult(response.ciphertext)) + } + + is BunkerResponseError -> { + SignerResult.RequestAddressed.Rejected() + } + + else -> { + 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 new file mode 100644 index 000000000..58811230b --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/NostrSignerRemote.kt @@ -0,0 +1,314 @@ +/** + * 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.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.req +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nip01Core.signers.SignerExceptions +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestConnect +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestGetPublicKey +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestNip04Decrypt +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestNip04Encrypt +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestNip44Decrypt +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestNip44Encrypt +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestPing +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestSign +import com.vitorpamplona.quartz.nip46RemoteSigner.NostrConnectEvent +import com.vitorpamplona.quartz.nip57Zaps.LnZapPrivateEvent +import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent +import com.vitorpamplona.quartz.utils.Hex + +class NostrSignerRemote( + val signer: NostrSignerInternal, + val remotePubkey: HexKey, + val relays: Set, + val client: INostrClient, + val permissions: String? = null, + val secret: String? = null, +) : NostrSigner(signer.pubKey) { + private val manager = + RemoteSignerManager( + signer = signer, + remoteKey = remotePubkey, + relayList = relays, + client = client, + ) + + val subscription = + client.req( + relays = relays.toList(), + filter = + Filter( + kinds = listOf(NostrConnectEvent.KIND), + tags = mapOf("p" to listOf(signer.pubKey)), + ), + ) { event -> + manager.newResponse(event) + } + + fun openSubscription() { + subscription.updateFilter() + } + + fun closeSubscription() { + subscription.close() + } + + override fun isWriteable(): Boolean = true + + override suspend fun sign( + createdAt: Long, + kind: Int, + tags: Array>, + content: String, + ): T { + val result = + manager.launchWaitAndParse( + bunkerRequestBuilder = { + val template = + EventTemplate( + createdAt = createdAt, + kind = kind, + tags = tags, + content = content, + ) + + BunkerRequestSign( + event = template, + ) + }, + parser = SignResponse::parse, + ) + + if (result is SignerResult.RequestAddressed.Successful) { + (result.result.event as? T)?.let { + return it + } + } + + throw convertExceptions("Could not sign", result) + } + + override suspend fun nip04Encrypt( + plaintext: String, + toPublicKey: HexKey, + ): String { + val result = + manager.launchWaitAndParse( + bunkerRequestBuilder = { + BunkerRequestNip04Encrypt( + message = plaintext, + pubKey = toPublicKey, + ) + }, + parser = Nip04EncryptResponse::parse, + ) + + if (result is SignerResult.RequestAddressed.Successful) { + return result.result.ciphertext + } + + throw convertExceptions("Could not encrypt", result) + } + + override suspend fun nip04Decrypt( + ciphertext: String, + fromPublicKey: HexKey, + ): String { + val result = + manager.launchWaitAndParse( + bunkerRequestBuilder = { + BunkerRequestNip04Decrypt( + ciphertext = ciphertext, + pubKey = fromPublicKey, + ) + }, + parser = Nip04DecryptResponse::parse, + ) + + if (result is SignerResult.RequestAddressed.Successful) { + return result.result.plaintext + } + + throw convertExceptions("Could not decrypt", result) + } + + override suspend fun nip44Encrypt( + plaintext: String, + toPublicKey: HexKey, + ): String { + val result = + manager.launchWaitAndParse( + bunkerRequestBuilder = { + BunkerRequestNip44Encrypt( + message = plaintext, + pubKey = toPublicKey, + ) + }, + parser = Nip44EncryptResponse::parse, + ) + + if (result is SignerResult.RequestAddressed.Successful) { + return result.result.ciphertext + } + + throw convertExceptions("Could not encrypt", result) + } + + override suspend fun nip44Decrypt( + ciphertext: String, + fromPublicKey: HexKey, + ): String { + val result = + manager.launchWaitAndParse( + bunkerRequestBuilder = { + BunkerRequestNip44Decrypt( + ciphertext = ciphertext, + pubKey = fromPublicKey, + ) + }, + parser = Nip44DecryptResponse::parse, + ) + + if (result is SignerResult.RequestAddressed.Successful) { + return result.result.plaintext + } + + throw convertExceptions("Could not decrypt", result) + } + + suspend fun ping(): String { + val result = + manager.launchWaitAndParse( + bunkerRequestBuilder = { + BunkerRequestPing() + }, + parser = PingResponse::parse, + ) + + if (result is SignerResult.RequestAddressed.Successful) { + return result.result.pong + } + + throw convertExceptions("Could not ping", result) + } + + suspend fun connect(): HexKey { + val result = + manager.launchWaitAndParse( + bunkerRequestBuilder = { + BunkerRequestConnect( + remoteKey = remotePubkey, + permissions = permissions, + secret = secret, + ) + }, + parser = PubKeyResponse::parse, + ) + + if (result is SignerResult.RequestAddressed.Successful) { + return result.result.pubkey + } + + throw convertExceptions("Could not connect", result) + } + + suspend fun getPublicKey(): HexKey { + val result = + manager.launchWaitAndParse( + bunkerRequestBuilder = { + BunkerRequestGetPublicKey() + }, + parser = PubKeyResponse::parse, + ) + + if (result is SignerResult.RequestAddressed.Successful) { + return result.result.pubkey + } + + throw convertExceptions("Could not get public key", result) + } + + override suspend fun decryptZapEvent(event: LnZapRequestEvent): LnZapPrivateEvent { + TODO("Not yet implemented") + } + + override suspend fun deriveKey(nonce: HexKey): HexKey { + TODO("Not yet implemented") + } + + override fun hasForegroundSupport(): Boolean = true + + fun convertExceptions( + title: String, + result: SignerResult.RequestAddressed<*>, + ): Exception = + when (result) { + is SignerResult.RequestAddressed.Successful<*> -> IllegalStateException("$title: This should not happen. There is a bug on Quartz.") + is SignerResult.RequestAddressed.ReceivedButCouldNotParseEventFromResult<*> -> IllegalStateException("$title: Failed to parse event: ${result.eventJson}.") + is SignerResult.RequestAddressed.ReceivedButCouldNotVerifyResultingEvent<*> -> IllegalStateException("$title: Failed to verify event: ${result.invalidEvent.toJson()}.") + is SignerResult.RequestAddressed.ReceivedButCouldNotPerform<*> -> SignerExceptions.CouldNotPerformException("$title: ${result.message}") + is SignerResult.RequestAddressed.Rejected<*> -> SignerExceptions.ManuallyUnauthorizedException("$title: User has rejected the request.") + is SignerResult.RequestAddressed.TimedOut<*> -> SignerExceptions.TimedOutException("$title: User didn't accept or reject in time.") + } + + companion object { + fun fromBunkerUri( + bunkerUri: String, + signer: NostrSignerInternal, + client: INostrClient, + permissions: String? = null, + ): NostrSignerRemote { + if (!bunkerUri.startsWith("bunker://")) throw Exception("Invalid bunker uri") + val splitData = bunkerUri.split("?") + val remotePubkey = splitData[0].removePrefix("bunker://") + if (!Hex.isHex(remotePubkey)) throw Exception("Invalid pubkey in bunker uri") + val params = splitData[1].split("&") + val relays = mutableSetOf() + var secret: String? = null + for (param in params) { + val splitParam = param.split("=") + if (splitParam.size < 2) continue + if (splitParam.first() == "relay") { + relays.add(NormalizedRelayUrl(splitParam[1])) + } + if (splitParam.first() == "secret") { + secret = splitParam[1] + } + } + return NostrSignerRemote( + signer = signer, + remotePubkey = remotePubkey, + relays = relays, + client = client, + permissions = permissions, + secret = secret, + ) + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/PingResponse.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/PingResponse.kt new file mode 100644 index 000000000..754ce1c16 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/PingResponse.kt @@ -0,0 +1,44 @@ +/** + * 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 +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponseError +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponsePong + +class PingResponse { + companion object { + fun parse(response: BunkerResponse): SignerResult.RequestAddressed = + when (response) { + is BunkerResponsePong -> { + SignerResult.RequestAddressed.Successful(PingResult(response.id)) + } + + is BunkerResponseError -> { + SignerResult.RequestAddressed.Rejected() + } + + else -> { + SignerResult.RequestAddressed.ReceivedButCouldNotPerform() + } + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/PubKeyResponse.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/PubKeyResponse.kt new file mode 100644 index 000000000..43fed5d03 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/PubKeyResponse.kt @@ -0,0 +1,44 @@ +/** + * 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 +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponseError +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponsePublicKey + +class PubKeyResponse { + companion object { + fun parse(response: BunkerResponse): SignerResult.RequestAddressed = + when (response) { + is BunkerResponsePublicKey -> { + SignerResult.RequestAddressed.Successful(PublicKeyResult(response.pubkey)) + } + + is BunkerResponseError -> { + SignerResult.RequestAddressed.Rejected() + } + + else -> { + SignerResult.RequestAddressed.ReceivedButCouldNotPerform() + } + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/RemoteSignerManager.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/RemoteSignerManager.kt new file mode 100644 index 000000000..eb07ca232 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/RemoteSignerManager.kt @@ -0,0 +1,88 @@ +/** + * 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.core.OptimizedJsonMapper +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequest +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponse +import com.vitorpamplona.quartz.nip46RemoteSigner.NostrConnectEvent +import com.vitorpamplona.quartz.utils.cache.LargeCache +import com.vitorpamplona.quartz.utils.tryAndWait +import kotlin.coroutines.Continuation +import kotlin.coroutines.resume + +class RemoteSignerManager( + val timeout: Long = 30000, + val client: INostrClient, + val signer: NostrSignerInternal, + val remoteKey: String, + val relayList: Set, +) { + private val awaitingRequests = LargeCache>() + + fun newResponse(responseEvent: Event) { + val bunkerResponse = OptimizedJsonMapper.fromJsonTo(responseEvent.content) + awaitingRequests.get(bunkerResponse.id)?.resume(bunkerResponse) + } + + /** + * Launches the signer, waits and parses the result + * + * @param bunkerRequestBuilder The BunkerRequest to be sent. + * @param parser A function that parses the BunkerResponse into a [SignerResult.RequestAddressed]. + * @return The result after parsing the BunkerResponse using the provided parser. + * + * This function uses the [tryAndWait] utility to implement a timeout on the Bunker Request approval. + * It assigns a unique ID to the request and keeps a continuation to resume once the result is received. + * If the timeout occurs or the continuation is cancelled, the request ID is cleaned up from [awaitingRequests]. + */ + suspend fun launchWaitAndParse( + bunkerRequestBuilder: () -> BunkerRequest, + parser: (response: BunkerResponse) -> SignerResult.RequestAddressed, + ): SignerResult.RequestAddressed { + val request = bunkerRequestBuilder() + val event = + NostrConnectEvent.create( + message = request, + remoteKey = remoteKey, + signer = signer, + ) + val result = + tryAndWait(timeout) { continuation -> + continuation.invokeOnCancellation { + awaitingRequests.remove(request.id) + } + + awaitingRequests.put(request.id, continuation) + + client.send(event, relayList = relayList) + } + + return when (result) { + null -> SignerResult.RequestAddressed.TimedOut() + else -> parser(result) + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/SignResponse.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/SignResponse.kt new file mode 100644 index 000000000..35f47498b --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/SignResponse.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.nip01Core.crypto.verify +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponse +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponseError +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponseEvent + +class SignResponse { + companion object { + fun parse(response: BunkerResponse): SignerResult.RequestAddressed = + if (response is BunkerResponseEvent) { + if (!response.event.verify()) { + SignerResult.RequestAddressed.ReceivedButCouldNotVerifyResultingEvent(response.event) + } else { + SignerResult.RequestAddressed.Successful(SignResult(response.event)) + } + } else if (response is BunkerResponseError) { + SignerResult.RequestAddressed.Rejected() + } else { + SignerResult.RequestAddressed.ReceivedButCouldNotPerform() + } + } +} 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 new file mode 100644 index 000000000..1bb20fc06 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/signer/SignerResult.kt @@ -0,0 +1,69 @@ +/** + * 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 + +sealed interface SignerResult { + sealed interface RequestAddressed : SignerResult { + class Successful( + val result: T, + ) : RequestAddressed + + class Rejected : RequestAddressed + + class TimedOut : RequestAddressed + + class ReceivedButCouldNotPerform( + val message: String? = null, + ) : RequestAddressed + + class ReceivedButCouldNotParseEventFromResult( + val eventJson: String, + ) : RequestAddressed + + class ReceivedButCouldNotVerifyResultingEvent( + val invalidEvent: Event, + ) : RequestAddressed + } +} + +interface IResult + +data class SignResult( + val event: Event, +) : IResult + +data class EncryptionResult( + val ciphertext: String, +) : IResult + +data class DecryptionResult( + val plaintext: String, +) : IResult + +data class PingResult( + val pong: String, +) : IResult + +data class PublicKeyResult( + val pubkey: String, +) : IResult