create a RemoteSignerManager

This commit is contained in:
greenart7c3
2025-11-12 10:45:03 -03:00
parent 55b4ddc453
commit e02dfce152
3 changed files with 309 additions and 174 deletions
@@ -22,7 +22,7 @@ package com.vitorpamplona.quartz.nip46RemoteSigner.signer
import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.OptimizedJsonMapper import com.vitorpamplona.quartz.nip01Core.crypto.verify
import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient
import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.req import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.req
import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter
@@ -38,16 +38,15 @@ import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestNip44Decrypt
import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestNip44Encrypt import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestNip44Encrypt
import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestPing import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestPing
import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestSign import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestSign
import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponse import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponseDecrypt
import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponseEncrypt
import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponseEvent import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponseEvent
import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponsePong
import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponsePublicKey
import com.vitorpamplona.quartz.nip46RemoteSigner.NostrConnectEvent import com.vitorpamplona.quartz.nip46RemoteSigner.NostrConnectEvent
import com.vitorpamplona.quartz.nip57Zaps.LnZapPrivateEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapPrivateEvent
import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent
import com.vitorpamplona.quartz.utils.Hex import com.vitorpamplona.quartz.utils.Hex
import com.vitorpamplona.quartz.utils.cache.LargeCache
import com.vitorpamplona.quartz.utils.tryAndWait
import kotlin.coroutines.Continuation
import kotlin.coroutines.resume
class NostrSignerRemote( class NostrSignerRemote(
val signer: NostrSignerInternal, val signer: NostrSignerInternal,
@@ -57,8 +56,13 @@ class NostrSignerRemote(
val permissions: String? = null, val permissions: String? = null,
val secret: String? = null, val secret: String? = null,
) : NostrSigner(signer.pubKey) { ) : NostrSigner(signer.pubKey) {
private val timeout = 30_000L private val manager =
private val awaitingRequests = LargeCache<String, Continuation<BunkerResponse>>() RemoteSignerManager(
signer = signer,
remoteKey = remotePubkey,
relayList = relays,
client = client,
)
val subscription = val subscription =
client.req( client.req(
@@ -69,11 +73,7 @@ class NostrSignerRemote(
tags = mapOf("p" to listOf(signer.pubKey)), tags = mapOf("p" to listOf(signer.pubKey)),
), ),
) { event -> ) { event ->
val message = signer.signerSync.nip44Decrypt(event.content, remotePubkey) manager.newResponse(event)
val bunkerResponse = OptimizedJsonMapper.fromJsonTo<BunkerResponse>(message)
awaitingRequests.get(bunkerResponse.id)?.resume(bunkerResponse)
awaitingRequests.remove(bunkerResponse.id)
} }
fun openSubscription() { fun openSubscription() {
@@ -92,224 +92,251 @@ class NostrSignerRemote(
tags: Array<Array<String>>, tags: Array<Array<String>>,
content: String, content: String,
): T { ): T {
val template =
EventTemplate<Event>(
createdAt = createdAt,
kind = kind,
tags = tags,
content = content,
)
val request =
BunkerRequestSign(
event = template,
)
val event =
NostrConnectEvent.create(
message = request,
remoteKey = remotePubkey,
signer = signer,
)
val result = val result =
tryAndWait(timeout) { continuation -> manager.launchWaitAndParse(
continuation.invokeOnCancellation { bunkerRequestBuilder = {
awaitingRequests.remove(request.id) val template =
} EventTemplate<Event>(
createdAt = createdAt,
kind = kind,
tags = tags,
content = content,
)
awaitingRequests.put(request.id, continuation) BunkerRequestSign(
event = template,
)
},
parser = { response ->
if (response is BunkerResponseEvent) {
if (!response.event.verify()) {
SignerResult.RequestAddressed.ReceivedButCouldNotVerifyResultingEvent(response.event)
} else {
SignerResult.RequestAddressed.Successful(SignResult(response.event))
}
} else {
SignerResult.RequestAddressed.ReceivedButCouldNotPerform()
}
},
)
client.send(event, relayList = relays) if (result is SignerResult.RequestAddressed.Successful<SignResult>) {
(result.result.event as? T)?.let {
return it
} }
}
return (result as BunkerResponseEvent).event as T throw Exception("Could not sign")
} }
override suspend fun nip04Encrypt( override suspend fun nip04Encrypt(
plaintext: String, plaintext: String,
toPublicKey: HexKey, toPublicKey: HexKey,
): String { ): String {
val request =
BunkerRequestNip04Encrypt(
message = plaintext,
pubKey = toPublicKey,
)
val event =
NostrConnectEvent.create(
message = request,
remoteKey = remotePubkey,
signer = signer,
)
val result = val result =
tryAndWait(timeout) { continuation -> manager.launchWaitAndParse(
continuation.invokeOnCancellation { bunkerRequestBuilder = {
awaitingRequests.remove(request.id) BunkerRequestNip04Encrypt(
} message = plaintext,
pubKey = toPublicKey,
)
},
parser = { response ->
if (response is BunkerResponseEncrypt) {
if (response.error != null) {
SignerResult.RequestAddressed.Rejected()
} else {
SignerResult.RequestAddressed.Successful(EncryptionResult(response.ciphertext))
}
} else {
SignerResult.RequestAddressed.ReceivedButCouldNotPerform()
}
},
)
awaitingRequests.put(request.id, continuation) if (result is SignerResult.RequestAddressed.Successful<EncryptionResult>) {
return result.result.ciphertext
}
client.send(event, relayList = relays) throw Exception("Could not encrypt")
}
return result?.result ?: ""
} }
override suspend fun nip04Decrypt( override suspend fun nip04Decrypt(
ciphertext: String, ciphertext: String,
fromPublicKey: HexKey, fromPublicKey: HexKey,
): String { ): String {
val request =
BunkerRequestNip04Decrypt(
ciphertext = ciphertext,
pubKey = fromPublicKey,
)
val event =
NostrConnectEvent.create(
message = request,
remoteKey = remotePubkey,
signer = signer,
)
val result = val result =
tryAndWait(timeout) { continuation -> manager.launchWaitAndParse(
continuation.invokeOnCancellation { bunkerRequestBuilder = {
awaitingRequests.remove(request.id) BunkerRequestNip04Decrypt(
} ciphertext = ciphertext,
pubKey = fromPublicKey,
)
},
parser = { response ->
if (response is BunkerResponseDecrypt) {
if (response.error != null) {
SignerResult.RequestAddressed.Rejected()
} else {
SignerResult.RequestAddressed.Successful(DecryptionResult(response.plaintext))
}
} else {
SignerResult.RequestAddressed.ReceivedButCouldNotPerform()
}
},
)
awaitingRequests.put(request.id, continuation) if (result is SignerResult.RequestAddressed.Successful<DecryptionResult>) {
return result.result.plaintext
}
client.send(event, relayList = relays) throw Exception("Could not decrypt")
}
return result?.result ?: ""
} }
override suspend fun nip44Encrypt( override suspend fun nip44Encrypt(
plaintext: String, plaintext: String,
toPublicKey: HexKey, toPublicKey: HexKey,
): String { ): String {
val request =
BunkerRequestNip44Encrypt(
message = plaintext,
pubKey = toPublicKey,
)
val event =
NostrConnectEvent.create(
message = request,
remoteKey = remotePubkey,
signer = signer,
)
val result = val result =
tryAndWait(timeout) { continuation -> manager.launchWaitAndParse(
continuation.invokeOnCancellation { bunkerRequestBuilder = {
awaitingRequests.remove(request.id) BunkerRequestNip44Encrypt(
} message = plaintext,
pubKey = toPublicKey,
)
},
parser = { response ->
if (response is BunkerResponseEncrypt) {
if (response.error != null) {
SignerResult.RequestAddressed.Rejected()
} else {
SignerResult.RequestAddressed.Successful(EncryptionResult(response.ciphertext))
}
} else {
SignerResult.RequestAddressed.ReceivedButCouldNotPerform()
}
},
)
awaitingRequests.put(request.id, continuation) if (result is SignerResult.RequestAddressed.Successful<EncryptionResult>) {
return result.result.ciphertext
}
client.send(event, relayList = relays) throw Exception("Could not encrypt")
}
return result?.result ?: ""
} }
override suspend fun nip44Decrypt( override suspend fun nip44Decrypt(
ciphertext: String, ciphertext: String,
fromPublicKey: HexKey, fromPublicKey: HexKey,
): String { ): String {
val request =
BunkerRequestNip44Decrypt(
ciphertext = ciphertext,
pubKey = fromPublicKey,
)
val event =
NostrConnectEvent.create(
message = request,
remoteKey = remotePubkey,
signer = signer,
)
val result = val result =
tryAndWait(timeout) { continuation -> manager.launchWaitAndParse(
continuation.invokeOnCancellation { bunkerRequestBuilder = {
awaitingRequests.remove(request.id) BunkerRequestNip44Decrypt(
} ciphertext = ciphertext,
pubKey = fromPublicKey,
)
},
parser = { response ->
if (response is BunkerResponseDecrypt) {
if (response.error != null) {
SignerResult.RequestAddressed.Rejected()
} else {
SignerResult.RequestAddressed.Successful(DecryptionResult(response.plaintext))
}
} else {
SignerResult.RequestAddressed.ReceivedButCouldNotPerform()
}
},
)
awaitingRequests.put(request.id, continuation) if (result is SignerResult.RequestAddressed.Successful<DecryptionResult>) {
return result.result.plaintext
}
client.send(event, relayList = relays) throw Exception("Could not decrypt")
}
return result?.result ?: ""
} }
suspend fun ping(): String? { suspend fun ping(): String? {
val request = BunkerRequestPing() val result =
val event = manager.launchWaitAndParse(
NostrConnectEvent.create( bunkerRequestBuilder = {
message = request, BunkerRequestPing()
remoteKey = remotePubkey, },
signer = signer, parser = { response ->
if (response is BunkerResponsePong) {
if (response.result != null) {
SignerResult.RequestAddressed.Successful(PingResult(response.result))
} else {
SignerResult.RequestAddressed.Rejected()
}
} else {
SignerResult.RequestAddressed.ReceivedButCouldNotPerform()
}
},
) )
val result = if (result is SignerResult.RequestAddressed.Successful<PingResult>) {
tryAndWait(timeout) { continuation -> return result.result.pong
continuation.invokeOnCancellation { }
awaitingRequests.remove(request.id)
}
awaitingRequests.put(request.id, continuation) throw Exception("Could not ping")
client.send(event, relayList = relays)
}
return result?.result
} }
suspend fun connect(): HexKey? { suspend fun connect(): HexKey {
val request =
BunkerRequestConnect(
remoteKey = remotePubkey,
permissions = permissions,
secret = secret,
)
val event =
NostrConnectEvent.create(
message = request,
remoteKey = remotePubkey,
signer = signer,
)
val result = val result =
tryAndWait(timeout) { continuation -> manager.launchWaitAndParse(
continuation.invokeOnCancellation { bunkerRequestBuilder = {
awaitingRequests.remove(request.id) BunkerRequestConnect(
} remoteKey = remotePubkey,
permissions = permissions,
secret = secret,
)
},
parser = { response ->
if (response is BunkerResponsePublicKey) {
if (response.result != null) {
SignerResult.RequestAddressed.Successful(PublicKeyResult(response.result))
} else {
SignerResult.RequestAddressed.Rejected()
}
} else {
SignerResult.RequestAddressed.ReceivedButCouldNotPerform()
}
},
)
awaitingRequests.put(request.id, continuation) if (result is SignerResult.RequestAddressed.Successful<PublicKeyResult>) {
return result.result.pubkey
}
client.send(event, relayList = relays) throw Exception("Could not connect")
}
return result?.result
} }
suspend fun getPublicKey(): HexKey? { suspend fun getPublicKey(): HexKey {
val request = BunkerRequestGetPublicKey() val result =
val event = manager.launchWaitAndParse(
NostrConnectEvent.create( bunkerRequestBuilder = {
message = request, BunkerRequestGetPublicKey()
remoteKey = remotePubkey, },
signer = signer, parser = { response ->
if (response is BunkerResponsePublicKey) {
if (response.result != null) {
SignerResult.RequestAddressed.Successful(PublicKeyResult(response.result))
} else {
SignerResult.RequestAddressed.Rejected()
}
} else {
SignerResult.RequestAddressed.ReceivedButCouldNotPerform()
}
},
) )
val result = if (result is SignerResult.RequestAddressed.Successful<PublicKeyResult>) {
tryAndWait(timeout) { continuation -> return result.result.pubkey
continuation.invokeOnCancellation { }
awaitingRequests.remove(request.id)
}
awaitingRequests.put(request.id, continuation) throw Exception("Could not get public key")
client.send(event, relayList = relays)
}
return result?.result
} }
override suspend fun decryptZapEvent(event: LnZapRequestEvent): LnZapPrivateEvent { override suspend fun decryptZapEvent(event: LnZapRequestEvent): LnZapPrivateEvent {
@@ -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<NormalizedRelayUrl>,
) {
private val awaitingRequests = LargeCache<String, Continuation<BunkerResponse>>()
fun newResponse(responseEvent: Event) {
val bunkerResponse = OptimizedJsonMapper.fromJsonTo<BunkerResponse>(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<T>].
* @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 <T : IResult> launchWaitAndParse(
bunkerRequestBuilder: () -> BunkerRequest,
parser: (response: BunkerResponse) -> SignerResult.RequestAddressed<T>,
): SignerResult.RequestAddressed<T> {
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)
}
}
}
@@ -47,3 +47,23 @@ sealed interface SignerResult<T : IResult> {
} }
interface IResult 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