diff --git a/amethyst/plans/2026-07-16-nip46-signer-device-checklist.md b/amethyst/plans/2026-07-16-nip46-signer-device-checklist.md new file mode 100644 index 0000000000..efbd3507bb --- /dev/null +++ b/amethyst/plans/2026-07-16-nip46-signer-device-checklist.md @@ -0,0 +1,184 @@ +# NIP-46 Signer — device verification checklist + +Everything below is behavior that JVM unit tests **cannot** exercise: interactive +consent dialogs, the foreground service, real relay traffic, deep links, and +cross-app interop. The protocol/authorization logic underneath is covered by +`quartz` (`NostrConnectSignerServiceTest`) and `commons` +(`Nip46PermissionAuthorizerTest`, `Nip46ConsentIntegrationTest`) unit tests; this +list is the manual pass that earns "first-class" on a real device. + +Run as the signer on one device/account ("bunker"); use a second app/account as +the client. + +## Pairing +- [ ] **Bunker flow**: Settings → Nostr Signer → turn on → scan/copy the + `bunker://` QR into a client (nsec.app, Coracle, Nostrudel, or a second + Amethyst via `amy login bunker://…`). Client resolves your npub via + `get_public_key`. +- [ ] **NostrConnect flow**: client shows a `nostrconnect://` code → "Scan a + code" on the signer screen pairs it and the signer turns on. +- [ ] **NostrConnect informed consent**: pairing a `nostrconnect://` offer that + carries `perms=` shows a connect sheet listing the app's requested + permissions (e.g. "Sign notes (kind 1)", "Decrypt messages") + a trust + picker BEFORE anything is granted. Approving pre-grants exactly those ops + (unless Paranoid); Cancel/Block declines and nothing is registered. A + re-pair of a known app skips the sheet and keeps prior decisions. + Repro: `amy login --nostrconnect --perms sign_event:1,nip44_encrypt` + prints an offer that carries exactly those perms (see CLI interop driver). +- [ ] **Global scanner**: scan a `nostrconnect://` from the profile/search + camera → lands on the signer screen and pairs. +- [ ] **Deep link**: tap a `nostrconnect://` link (web/other app) → Amethyst + opens the signer screen and pairs (cold start AND already-running). + +## Note preview in the sign dialog (device-only) +- [ ] A `sign_event`/publish request renders the unsigned event as a **NoteCompose + preview** (text + media + mentions, authored by the signing account), with + the "Show event" JSON toggle still available below it. +- [ ] Works for both a NIP-46 remote app and a napplet Publish/SignEvent. +- [ ] When the main Activity is gone (app fully backgrounded, only the signer + foreground service alive → `CallSessionBridge.accountViewModel` is null), + the dialog falls back to the plain content quote + JSON without crashing. +- [ ] **Risk to watch:** NoteCompose is feed UI rendered inside a standalone + dialog Activity; if it reads a CompositionLocal only provided by the main + scaffold it could crash at runtime (compiles fine). Verify on device; if it + misbehaves, the JSON fallback path is one boolean away. + +## Entry point + connected-apps management (2026-07-16) +- [ ] **Drawer entry**: the signer opens from the left drawer's "You" section, directly + under Wallet (moved out of Settings). It's also available as a bottom-bar favorite. +- [ ] **Dedicated apps screen**: "Manage connected apps" on the signer screen opens a + NIP-46-only list (name, npub, relay count, last-used, trust chip), separate from the + napplet/nsite/browser Connected Apps screen. NIP-46 apps no longer appear there. +- [ ] **Idle auto-forget**: an app left unused for 7 days is dropped on the next signer + start (its background relay subscription goes with it); an app still signing is kept. + +## Comes-to-front on a request (2026-07-16) +- [ ] **Backgrounded surfacing**: with Amethyst fully backgrounded (Android 12+), a client + signing/connect request pops the consent dialog — via a full-screen-intent notification + on the high-importance "Signing requests" channel (the `startActivity` fast path is + BAL-blocked when backgrounded). On a locked screen it launches straight to the dialog; + while actively on another app it shows a heads-up prompt to tap. +- [ ] **Foreground**: with Amethyst in the foreground the dialog opens directly (no extra + notification — `SignerConsentNotifier` no-ops when `foregroundTracker.isForeground`). +- [ ] **Android 14+ caveat**: `USE_FULL_SCREEN_INTENT` is restricted for non-calling apps, + so the FSI may degrade to a heads-up rather than auto-launch — verify the prompt still + arrives and is tappable. Requires notification permission (already needed for the + always-on service). + +## Consent (Tier 1) +- [ ] **First-connect trust picker**: a bunker-flow connect with a valid secret + shows the trust-level dialog (Full trust / Reasonable / Paranoid) BEFORE any + signing; choosing a level records it in Connected Apps. +- [ ] **Cancel/Block**: dismissing the connect dialog rejects the connection (no + silent grant). +- [ ] **Per-op ASK**: with a REASONABLE app, ask the client to sign a + **kind 0 / kind 3 / delete (5)** or **decrypt a DM** → the per-op dialog + appears (these are excluded from the auto-allowed set). +- [ ] **Remember variants**: "allow for this op" stops re-prompting; "session" + stops until the signer restarts; "24h/30d" expire; "deny for op" sticks. +- [ ] **PARANOID app** prompts on every request; **FULL_TRUST** never prompts. +- [ ] **Timeout**: ignore a per-op dialog for 2 minutes → the request fails + closed (deny) and the signer keeps serving later requests (not wedged). + +## Anti-spam rotation (already shipped) +- [ ] "New address" → confirm dialog → old `bunker://` goes dark, connected apps + drop, QR updates; re-pairing a legit app keeps its trust level. + +## Visibility (Tier 2) +- [ ] Signer screen shows "Signing as npub1…", a live "Recent activity" feed + (signed kind N / encrypted / decrypted / shared pubkey, green/red dot, + relative time), and per-app history on the Connected-App detail screen. +- [ ] The Connected-App detail screen for a remote client shows its name/url, + not a raw `nip46:` coordinate. + +## Reliability (Tier 3) +- [ ] **Relay health**: kill connectivity → status shows "X of N relays + connected"; restore → "all connected". +- [ ] **Boot restart**: enable the signer, reboot the device → the foreground + service comes back and the signer answers a request without reopening the + app. (Same for an app update via `MY_PACKAGE_REPLACED`.) +- [ ] **Doze/background**: after ~30 min idle in Doze, a request still gets + serviced (may lag by a relay reconnect). + +## Interop matrix +Pair + sign + nip44 encrypt/decrypt + logout against each: +- [ ] nsec.app +- [ ] Coracle +- [ ] Nostrudel +- [ ] snort / other NIP-46 client + +## CLI interop driver (`amy`) — added 2026-07-17 +The `cli` module (`amy`) drives the same quartz/commons code, so it plays either +side of every NIP-46 flow for reproducible interop tests without a second phone. +All of it reuses `Nip46PermissionAuthorizer.parsePerms` / `toSignerOp` — no +protocol logic in `cli`. See `amy --help` (the `Remote signing (NIP-46)` block). + +Amy as the **client** (Amethyst is the signer): +- `amy login bunker://…` — pair against Amethyst's advertised `bunker://`, then + every `amy` signing verb routes through it. Surfaces `auth_url` challenges to + stderr, so it completes even when Amethyst defers consent. +- `amy login --nostrconnect [--perms sign_event:1,nip44_encrypt,…]` — mint an + offer for Amethyst to scan. `--perms` is what exercises the app's + **informed-consent** sheet (the offer carries the declared ops). + +Amy as the **signer** (Amethyst, or any client, is the client): +- `amy bunker` — headless auto-approve signer for the operator's own key. +- `amy bunker --perms sign_event:1,nip44_encrypt` — restricted signer: allows + only the listed ops, **rejects** the rest. Use to test how the app-as-client + handles a signer that says no. +- `amy bunker --interactive` — keeps listening and prompts `y/N` per request on + the terminal (TTY-only, default-deny, prompts serialized). Composes with + `--perms` (auto-allow the listed ops, prompt for the rest = the "Reasonable" + policy on the CLI). + +## Audit findings — known limitations (2026-07-16) + +An adversarial review of the signer logic surfaced these. The head-of-line +issues below share one root cause: `authorize()`/`onConnect()` run **inline** in +`NostrConnectSignerService`'s single-consumer loop, and relay-set changes restart +that loop via `collectLatest`. + +- **FIXED — unbounded first-connect prompt.** `Nip46ConsentBridge.requestConnect` + now has the same 120s `withTimeoutOrNull` as `requestOp`, so an ignored + first-connect dialog can no longer wedge the loop forever. +- **FIXED — consent no longer blocks other clients (needs on-device + validation).** The service now fans each request out into a child coroutine + under a `Semaphore(maxConcurrentHandles=16)`; dedup/staleness/rate-limit stay on + the single consumer, only `handle()` runs concurrently. So a request awaiting a + prompt no longer stalls auto-allowed traffic, and several prompts can be pending + at once. Two guards keep this safe: (1) the identity signer's crypto is + serialized by `BunkerRequestProcessor.cryptoLock` — authorization (the prompt) + runs UNLOCKED, only the sign/encrypt/decrypt holds the lock — so an external + NIP-55 app never sees concurrent IPC ops; (2) first-connect consent is + serialized by `Nip46PermissionAuthorizer.connectLock` so two connects can't stack + dialogs. Per-op prompts batch: the shared `SignerConsentCoordinator.pending` + flow drives one dialog (1 pending) or a checkbox list (>1). Covered by + `BunkerRequestProcessorConcurrencyTest`, but the on-device paths below still need + a real run: + - [ ] **Burst batching:** a client fires several dangerous-kind requests at once + → one batched sheet with checkboxes + select-all, Allow/Deny selected, + "Remember" toggle. Approving a subset leaves the rest pending. + - [ ] **Auto-allowed keeps flowing:** while a prompt sits open, a REASONABLE + auto-allowed request from another app still gets signed and answered. + - [ ] **No concurrent external-signer ops:** with a NIP-55 external signer, two + approved requests do not drive overlapping IPC (they serialize). + - [ ] **Fail-closed on dismiss:** backing out of the batched sheet denies every + still-open request (not just the selected ones). +- **Relay-set change cancels in-flight work.** A `logout` (or a new nostrconnect + pairing) mutates the listen set → `collectLatest` restarts the service → + cancels the in-flight `handle()`. Practical impact is low (a logout ACK is lost + but the client is leaving; a pairing-time cancel makes other clients retry). + Proper fix: manage subscriptions incrementally (diff add/remove) instead of a + full restart. Deferred (same reason). +- **Low-severity, left as-is:** activity-log records an O(capacity) list copy per + serviced request (negligible under rate-limiting); the per-author rate limiter + evicts by insertion order rather than LRU (the 3-arg `accessOrder` + `LinkedHashMap` isn't in KMP commonMain); first-time transport-key/secret mint + is unsynchronized (practically serialized on the UI thread). + +## Deliberately NOT changed +The always-on foreground **notification** was left as-is: it is shared with the +relay/DM always-on service, so retitling it "Signing for N apps" or deep-linking +it to the signer screen would be wrong when the service is up for another reason. +Interactive consent uses its own dedicated dialog Activity, so it needs no +notification actions. diff --git a/amethyst/src/main/AndroidManifest.xml b/amethyst/src/main/AndroidManifest.xml index 42e59a2bb1..fa5b7740ef 100644 --- a/amethyst/src/main/AndroidManifest.xml +++ b/amethyst/src/main/AndroidManifest.xml @@ -151,6 +151,14 @@ + + + + + + + + @@ -463,14 +471,14 @@ android:theme="@android:style/Theme.Translucent.NoTitleBar" /> { - val dir = File(filesDir, "datastore") - if (!dir.exists()) return emptyMap() - val result = mutableMapOf() - for (file in dir.listFiles { f -> f.name.startsWith("nsp_") } ?: emptyArray()) { - val ds = - cache.getOrCreate(file.absolutePath) { - PreferenceDataStoreFactory.create(produceFile = { file }) - } - val coordinate = ds.data.first()[KEY_COORDINATE] ?: continue - val policy = loadPolicy(coordinate) ?: continue - result[coordinate] = policy + override suspend fun allPolicies(): Map = + // Enumerates the datastore directory + reads each file — blocking disk IO, so keep it off the + // caller's thread (callers invoke this from Compose LaunchedEffects on the main dispatcher). + withContext(Dispatchers.IO) { + val dir = File(filesDir, "datastore") + if (!dir.exists()) return@withContext emptyMap() + val result = mutableMapOf() + for (file in dir.listFiles { f -> f.name.startsWith("nsp_") } ?: emptyArray()) { + val ds = + cache.getOrCreate(file.absolutePath) { + PreferenceDataStoreFactory.create(produceFile = { file }) + } + val coordinate = ds.data.first()[KEY_COORDINATE] ?: continue + val policy = loadPolicy(coordinate) ?: continue + result[coordinate] = policy + } + result } - return result - } override suspend fun allOpDecisions(coordinate: String): Map { val prefs = storeFor(coordinate).data.first() diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/consent/ConnectedAccountRow.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/consent/ConnectedAccountRow.kt new file mode 100644 index 0000000000..c4e20fe1cb --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/consent/ConnectedAccountRow.kt @@ -0,0 +1,68 @@ +/* + * 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.amethyst.connectedApps.consent + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage + +/** + * The account a signer request acts as — avatar + display name — so it's clear WHICH logged-in + * identity is approving/signing/encrypting/decrypting. Shown in both consent dialogs in place of a + * raw pubkey. Falls back to a robohash avatar seeded on [pubKey] when there's no [picture]. + */ +@Composable +fun ConnectedAccountRow( + name: String, + picture: String?, + pubKey: String?, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + RobohashFallbackAsyncImage( + robot = pubKey ?: name, + model = picture, + contentDescription = null, + modifier = Modifier.size(26.dp).clip(CircleShape), + loadProfilePicture = true, + loadRobohash = true, + ) + Text( + name, + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurface, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletConnectActivity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/consent/SignerConnectActivity.kt similarity index 74% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletConnectActivity.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/consent/SignerConnectActivity.kt index a5c2f15811..950cb81816 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletConnectActivity.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/consent/SignerConnectActivity.kt @@ -18,7 +18,7 @@ * 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.amethyst.napplet +package com.vitorpamplona.amethyst.connectedApps.consent import android.os.Bundle import androidx.activity.ComponentActivity @@ -58,23 +58,24 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.connectedApps.signers.AppConnectResult +import com.vitorpamplona.amethyst.commons.connectedApps.signers.AppSignerPolicy import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp import com.vitorpamplona.amethyst.commons.favorites.FavoriteAppIcon import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbol import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols -import com.vitorpamplona.amethyst.commons.napplet.signers.AppConnectResult -import com.vitorpamplona.amethyst.commons.napplet.signers.AppSignerPolicy import com.vitorpamplona.amethyst.ui.theme.AmethystTheme -class NappletConnectActivity : ComponentActivity() { +class SignerConnectActivity : ComponentActivity() { private var token: String? = null private var decided = false override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - val token = intent.getStringExtra(NappletConnectCoordinator.EXTRA_TOKEN) + val token = intent.getStringExtra(SignerConnectCoordinator.EXTRA_TOKEN) this.token = token - val info = token?.let { NappletConnectCoordinator.infoFor(it) } + val info = token?.let { SignerConnectCoordinator.infoFor(it) } if (token == null || info == null) { finish() return @@ -82,21 +83,21 @@ class NappletConnectActivity : ComponentActivity() { setContent { AmethystTheme { - NappletConnectScreen( + SignerConnectScreen( info = info, onConnect = { policy -> decided = true - NappletConnectCoordinator.complete(token, AppConnectResult.Connected(policy)) + SignerConnectCoordinator.complete(token, AppConnectResult.Connected(policy)) finish() }, onBlock = { decided = true - NappletConnectCoordinator.complete(token, AppConnectResult.Blocked) + SignerConnectCoordinator.complete(token, AppConnectResult.Blocked) finish() }, onCancel = { decided = true - NappletConnectCoordinator.complete(token, AppConnectResult.Cancelled) + SignerConnectCoordinator.complete(token, AppConnectResult.Cancelled) finish() }, ) @@ -105,14 +106,14 @@ class NappletConnectActivity : ComponentActivity() { } override fun finish() { - if (!decided) token?.let { NappletConnectCoordinator.cancel(it) } + if (!decided) token?.let { SignerConnectCoordinator.cancel(it) } super.finish() } } @Composable -private fun NappletConnectScreen( - info: NappletConnectInfo, +private fun SignerConnectScreen( + info: SignerConnectInfo, onConnect: (AppSignerPolicy) -> Unit, onBlock: () -> Unit, onCancel: () -> Unit, @@ -168,12 +169,46 @@ private fun NappletConnectScreen( color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center, ) - Text( - info.domain, - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center, - ) + // Show WHICH account is being connected (avatar + name), not a raw pubkey. + if (info.accountName != null) { + ConnectedAccountRow(info.accountName, info.accountPicture, info.accountPubKey) + } else { + Text( + info.domain, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + } + } + + // What the app declared it needs (nostrconnect `perms`) — so consent is informed, + // not a silent grant. Approving connects and pre-grants exactly these. + if (info.requestedPermissions.isNotEmpty()) { + Spacer(Modifier.height(16.dp)) + Surface( + modifier = Modifier.padding(horizontal = 24.dp).fillMaxWidth(), + color = MaterialTheme.colorScheme.surfaceVariant, + shape = MaterialTheme.shapes.medium, + ) { + Column(modifier = Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text( + stringResource(R.string.nip46_connect_requests_title), + style = MaterialTheme.typography.labelLarge, + ) + info.requestedPermissions.forEach { perm -> + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Icon( + MaterialSymbols.Check, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(16.dp), + ) + Text(perm, style = MaterialTheme.typography.bodySmall) + } + } + } + } } Spacer(Modifier.height(16.dp)) @@ -194,21 +229,21 @@ private fun NappletConnectScreen( ) { PolicyOption( selected = selected == AppSignerPolicy.FULL_TRUST, - icon = "❤", + symbol = MaterialSymbols.LockOpen, label = stringResource(R.string.napplet_policy_full_trust), description = stringResource(R.string.napplet_policy_full_trust_desc), onClick = { selected = AppSignerPolicy.FULL_TRUST }, ) PolicyOption( selected = selected == AppSignerPolicy.REASONABLE, - icon = "👍", + symbol = MaterialSymbols.Shield, label = stringResource(R.string.napplet_policy_reasonable), description = stringResource(R.string.napplet_policy_reasonable_desc), onClick = { selected = AppSignerPolicy.REASONABLE }, ) PolicyOption( selected = selected == AppSignerPolicy.PARANOID, - icon = "🕶", + symbol = MaterialSymbols.Lock, label = stringResource(R.string.napplet_policy_paranoid), description = stringResource(R.string.napplet_policy_paranoid_desc), onClick = { selected = AppSignerPolicy.PARANOID }, @@ -249,7 +284,7 @@ private fun NappletConnectScreen( @Composable private fun PolicyOption( selected: Boolean, - icon: String, + symbol: MaterialSymbol, label: String, description: String, onClick: () -> Unit, @@ -271,7 +306,12 @@ private fun PolicyOption( verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(12.dp), ) { - Text(icon, style = MaterialTheme.typography.headlineSmall) + Icon( + symbol = symbol, + contentDescription = null, + tint = if (selected) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(26.dp), + ) Column(modifier = Modifier.weight(1f)) { Text(label, style = MaterialTheme.typography.titleSmall, color = MaterialTheme.colorScheme.onSurface) Text(description, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletConnectCoordinator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/consent/SignerConnectCoordinator.kt similarity index 54% rename from amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletConnectCoordinator.kt rename to amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/consent/SignerConnectCoordinator.kt index 0467578f2e..43059fa570 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletConnectCoordinator.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/consent/SignerConnectCoordinator.kt @@ -18,21 +18,36 @@ * 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.amethyst.napplet +package com.vitorpamplona.amethyst.connectedApps.consent import android.content.Context import android.content.Intent -import com.vitorpamplona.amethyst.commons.napplet.signers.AppConnectResult +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.connectedApps.signers.AppConnectResult import kotlinx.coroutines.CompletableDeferred import java.util.UUID import java.util.concurrent.ConcurrentHashMap /** Everything the "Connect to Nostr" dialog needs to render. */ -data class NappletConnectInfo( +data class SignerConnectInfo( val appletTitle: String, val coordinate: String, val domain: String, val iconUrl: String? = null, + /** + * The account the app is connecting to, shown as an avatar + name instead of a raw pubkey. When + * [accountName] is null (e.g. napplet/browser paths that don't resolve it) the dialog falls back + * to [domain]. [accountPubKey] seeds the robohash avatar fallback when there's no picture. + */ + val accountName: String? = null, + val accountPicture: String? = null, + val accountPubKey: String? = null, + /** + * Human-readable permissions the app declared it needs (from a `nostrconnect://…?perms=` offer), + * shown so the user gives INFORMED consent before those ops are pre-granted. Empty for flows that + * carry no declaration (bunker connect), which just show the trust picker. + */ + val requestedPermissions: List = emptyList(), ) /** @@ -40,9 +55,9 @@ data class NappletConnectInfo( * the Activity resolves the deferred with the user's choice. * A dismissed dialog resolves to [AppConnectResult.Cancelled] — fails closed, no silent grant. */ -object NappletConnectCoordinator { +object SignerConnectCoordinator { private class Pending( - val info: NappletConnectInfo, + val info: SignerConnectInfo, val deferred: CompletableDeferred, ) @@ -50,26 +65,41 @@ object NappletConnectCoordinator { suspend fun requestConnect( context: Context, - info: NappletConnectInfo, + info: SignerConnectInfo, ): AppConnectResult { val token = UUID.randomUUID().toString() val deferred = CompletableDeferred() pending[token] = Pending(info, deferred) - context.startActivity( - Intent(context, NappletConnectActivity::class.java) - .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - .putExtra(EXTRA_TOKEN, token), - ) + // Fast path when Amethyst already owns the foreground; the full-screen-intent notification + // below is what surfaces the dialog when a connect request arrives while backgrounded (see + // SignerConsentNotifier). Wrapped because a BAL-blocked launch can throw on some OEMs. + runCatching { + context.startActivity( + Intent(context, SignerConnectActivity::class.java) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + .putExtra(EXTRA_TOKEN, token), + ) + } + + val notificationId = + SignerConsentNotifier.show( + context = context, + activityClass = SignerConnectActivity::class.java, + extraKey = EXTRA_TOKEN, + token = token, + titleRes = R.string.nip46_signer_notif_connect_title, + ) return try { deferred.await() } finally { pending.remove(token) + SignerConsentNotifier.cancel(context, notificationId) } } - fun infoFor(token: String): NappletConnectInfo? = pending[token]?.info + fun infoFor(token: String): SignerConnectInfo? = pending[token]?.info fun complete( token: String, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/consent/SignerConsentActivity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/consent/SignerConsentActivity.kt new file mode 100644 index 0000000000..e9528737f2 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/consent/SignerConsentActivity.kt @@ -0,0 +1,562 @@ +/* + * 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.amethyst.connectedApps.consent + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Checkbox +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Surface +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.connectedApps.signers.SignerOpGrant +import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp +import com.vitorpamplona.amethyst.commons.favorites.FavoriteAppIcon +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.service.call.CallSessionBridge +import com.vitorpamplona.amethyst.ui.components.RobohashFallbackAsyncImage +import com.vitorpamplona.amethyst.ui.navigation.navs.EmptyNav +import com.vitorpamplona.amethyst.ui.note.NoteCompose +import com.vitorpamplona.amethyst.ui.theme.AmethystTheme +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip59Giftwrap.rumors.RumorAssembler +import com.vitorpamplona.quartz.utils.TimeUtils + +class SignerConsentActivity : ComponentActivity() { + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContent { + AmethystTheme { + // The signer services requests concurrently, so more than one may await consent. We never + // mix accounts in one sheet: render only the requests for the OLDEST-pending account as a + // group. When that account's group clears, the next account's requests render (a fresh + // per-account sheet). One request → the rich dialog; several → a batched list. When the + // whole queue empties, close. + val pending by SignerConsentCoordinator.pending.collectAsStateWithLifecycle() + val group = + run { + val account = pending.firstOrNull()?.info?.accountPubKey + pending.filter { it.info.accountPubKey == account } + } + LaunchedEffect(pending.isEmpty()) { if (pending.isEmpty()) finish() } + when { + group.isEmpty() -> Unit + group.size == 1 -> { + val p = group.first() + SignerConsentDialog( + info = p.info, + onGrant = { SignerConsentCoordinator.complete(p.token, it) }, + onDismiss = { SignerConsentCoordinator.complete(p.token, SignerOpGrant.DenyOnce) }, + ) + } + else -> + BatchedConsentDialog( + pending = group, + onResolve = { tokens, grant -> SignerConsentCoordinator.completeAll(tokens, grant) }, + // Dismissing denies only THIS account's group; other accounts' requests stay + // pending and render next as their own sheet. + onDismiss = { SignerConsentCoordinator.completeAll(group.map { it.token }, SignerOpGrant.DenyOnce) }, + ) + } + } + } + } + // Dismissal is failed-closed at the source: each dialog's onDismissRequest (back / tap-outside) + // denies its own request(s). We deliberately do NOT deny-all in onDestroy — a request arriving as + // this Activity finishes is owned by a freshly-launched instance, and denying it here would race + // that instance and reject a legitimate request. A process kill falls back to the bridge's 120s + // timeout, which also fails closed. +} + +@Composable +private fun SignerConsentDialog( + info: SignerConsentInfo, + onGrant: (SignerOpGrant) -> Unit, + onDismiss: () -> Unit, +) { + var showMoreOptions by remember { mutableStateOf(false) } + val scrollState = rememberScrollState() + val maxHeight = LocalConfiguration.current.screenHeightDp.dp * 0.85f + + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties(usePlatformDefaultWidth = false), + ) { + Surface( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .heightIn(max = maxHeight), + shape = MaterialTheme.shapes.extraLarge, + color = MaterialTheme.colorScheme.surface, + tonalElevation = 6.dp, + ) { + Column( + modifier = + Modifier + .verticalScroll(scrollState) + .padding(vertical = 24.dp), + ) { + // Centered header: icon + title + description + Column( + modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + val isBrowser = info.coordinate.startsWith("browser:") + FavoriteAppIcon( + app = + if (isBrowser) { + FavoriteApp.WebApp(info.coordinate.substringAfter(':'), info.appletTitle, 0L, info.iconUrl) + } else { + FavoriteApp.NostrApp(info.coordinate, info.appletTitle, 0L, info.iconUrl) + }, + tint = MaterialTheme.colorScheme.onPrimaryContainer, + modifier = Modifier.size(56.dp), + ) + Text( + info.appletTitle, + style = MaterialTheme.typography.titleLarge, + textAlign = TextAlign.Center, + ) + Text( + stringResource(R.string.napplet_consent_wants_to, info.operationSummary), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + // Show WHICH account would sign/encrypt/decrypt (avatar + name), not the coordinate hex. + if (info.accountName != null) { + ConnectedAccountRow(info.accountName, info.accountPicture, info.accountPubKey) + } + } + + Spacer(Modifier.height(12.dp)) + Box(modifier = Modifier.padding(horizontal = 24.dp)) { + SignerConsentPreview(info) + } + + Spacer(Modifier.height(16.dp)) + HorizontalDivider() + Spacer(Modifier.height(8.dp)) + + // Primary: always allow this op + Button( + onClick = { onGrant(SignerOpGrant.AllowForOp(info.op)) }, + modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp), + ) { + Text(stringResource(R.string.napplet_consent_allow_always)) + } + + // Secondary: allow just once + OutlinedButton( + onClick = { onGrant(SignerOpGrant.AllowOnce) }, + modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp), + ) { + Text(stringResource(R.string.napplet_signer_allow_once)) + } + + // "More options" toggle: session and time-bound grants + TextButton( + onClick = { showMoreOptions = !showMoreOptions }, + modifier = Modifier.fillMaxWidth(), + contentPadding = PaddingValues(horizontal = 24.dp, vertical = 8.dp), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + if (showMoreOptions) { + stringResource(R.string.napplet_consent_fewer_options) + } else { + stringResource(R.string.napplet_consent_more_options) + }, + style = MaterialTheme.typography.bodyMedium, + ) + Icon( + if (showMoreOptions) MaterialSymbols.ExpandLess else MaterialSymbols.ExpandMore, + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + } + } + + if (showMoreOptions) { + OutlinedButton( + onClick = { onGrant(SignerOpGrant.AllowForSession(info.op)) }, + modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp), + ) { + Text(stringResource(R.string.napplet_signer_allow_session)) + } + OutlinedButton( + onClick = { onGrant(SignerOpGrant.AllowUntil(info.op, TimeUtils.now() + 86_400L)) }, + modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp), + ) { + Text(stringResource(R.string.napplet_signer_allow_24h)) + } + OutlinedButton( + onClick = { onGrant(SignerOpGrant.AllowUntil(info.op, TimeUtils.now() + 30L * 86_400L)) }, + modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp), + ) { + Text(stringResource(R.string.napplet_signer_allow_30d)) + } + OutlinedButton( + onClick = { onGrant(SignerOpGrant.AllowAll) }, + modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp), + ) { + Text(stringResource(R.string.napplet_signer_allow_all)) + } + } + + Spacer(Modifier.height(4.dp)) + HorizontalDivider() + Spacer(Modifier.height(8.dp)) + + OutlinedButton( + onClick = { onGrant(SignerOpGrant.DenyOnce) }, + modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp), + colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error), + ) { + Text(stringResource(R.string.napplet_signer_deny_once)) + } + OutlinedButton( + onClick = { onGrant(SignerOpGrant.DenyForOp(info.op)) }, + modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp), + colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error), + ) { + Text(stringResource(R.string.napplet_signer_deny_op, info.operationSummary)) + } + } + } + } +} + +/** + * The "what you're acting on" block: the unsigned event rendered as a real NoteCompose (what it will + * look like once signed) with a JSON toggle for sign/publish, or the raw content / decrypted plaintext + * for encrypt/decrypt. Shared by the single-request dialog and each expanded batch row so a user can + * always inspect exactly what they are signing/encrypting/decrypting. Best-effort: if the main Activity + * is gone (only the foreground signer service alive) the NoteCompose is skipped and the JSON stands in. + */ +@Composable +private fun SignerConsentPreview(info: SignerConsentInfo) { + var showRawData by remember(info) { mutableStateOf(false) } + val accountViewModel = remember { CallSessionBridge.accountViewModel } + val previewNav = remember { EmptyNav() } + val previewNote = + remember(info, accountViewModel) { + val template = info.previewTemplate + val author = info.accountPubKey ?: accountViewModel?.account?.signer?.pubKey + if (template != null && author != null && accountViewModel != null) { + runCatching { + val unsigned = RumorAssembler.assembleRumor(author, template) + accountViewModel.createTempDraftNote(unsigned, LocalCache.getOrCreateUser(author)) + }.getOrNull() + } else { + null + } + } + + val hasContent = previewNote != null || info.contentPreview.isNotBlank() || info.rawData.isNotBlank() + if (!hasContent) return + Surface( + modifier = Modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.surfaceVariant, + shape = MaterialTheme.shapes.medium, + ) { + Column(modifier = Modifier.padding(12.dp)) { + if (previewNote != null && accountViewModel != null) { + NoteCompose( + baseNote = previewNote, + isQuotedNote = true, + quotesLeft = 0, + accountViewModel = accountViewModel, + nav = previewNav, + ) + } else if (info.contentPreview.isNotBlank()) { + Text("“${info.contentPreview}”", style = MaterialTheme.typography.bodySmall) + } + if (info.rawData.isNotBlank()) { + if (showRawData) { + Spacer(Modifier.height(8.dp)) + Box(modifier = Modifier.horizontalScroll(rememberScrollState())) { + SelectionContainer { + Text( + info.rawData, + style = MaterialTheme.typography.labelSmall.copy(fontFamily = FontFamily.Monospace), + color = MaterialTheme.colorScheme.onSurfaceVariant, + softWrap = false, + ) + } + } + } + TextButton( + onClick = { showRawData = !showRawData }, + contentPadding = PaddingValues(horizontal = 4.dp, vertical = 0.dp), + ) { + Text( + if (showRawData) { + stringResource(R.string.napplet_consent_hide_event) + } else { + stringResource(R.string.napplet_consent_show_event) + }, + style = MaterialTheme.typography.labelSmall, + ) + } + } + } + } +} + +/** + * Shown when more than one request is awaiting consent at once (the signer services requests + * concurrently). Lists each with a checkbox — all selected by default — and resolves the selected + * ones together as Allow or Deny. "Remember" makes an Allow persist per-op ([SignerOpGrant.AllowForOp]); + * off is a one-time [SignerOpGrant.AllowOnce]. Requests left unselected stay pending and re-render + * (as this list, or the single-request dialog once one remains). + */ +@Composable +private fun BatchedConsentDialog( + pending: List, + onResolve: (tokens: List, grant: SignerOpGrant) -> Unit, + onDismiss: () -> Unit, +) { + val maxHeight = LocalConfiguration.current.screenHeightDp.dp * 0.85f + val tokens = pending.map { it.token }.toSet() + // Seed all-selected ONCE for the initial batch the user opened. The signer services requests + // concurrently, so `tokens` can change under an open sheet; reconcile incrementally instead of + // re-seeding — drop resolved tokens but KEEP the user's deselections, and never auto-select a + // newly-arrived request. Otherwise a request landing (or resolving) mid-decision would silently + // re-check everything, and an "Allow selected" tap would grant ops the user deselected or never saw. + var selected by remember { mutableStateOf(tokens) } + LaunchedEffect(tokens) { selected = selected intersect tokens } + var rememberChoice by remember { mutableStateOf(false) } + // Tokens whose full preview (rendered event + JSON, or encrypt/decrypt plaintext) is expanded. + var expanded by remember { mutableStateOf(emptySet()) } + + Dialog( + onDismissRequest = onDismiss, + properties = DialogProperties(usePlatformDefaultWidth = false), + ) { + Surface( + modifier = + Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .heightIn(max = maxHeight), + shape = MaterialTheme.shapes.extraLarge, + color = MaterialTheme.colorScheme.surface, + tonalElevation = 6.dp, + ) { + Column(modifier = Modifier.padding(vertical = 20.dp)) { + // The sheet is single-account (grouped upstream), so the account is a header, not a + // per-row label. It says WHO every request in this sheet would act as. + val account = pending.first().info + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + account.accountName?.let { name -> + RobohashFallbackAsyncImage( + robot = account.accountPubKey ?: name, + model = account.accountPicture, + contentDescription = null, + modifier = Modifier.size(34.dp).clip(CircleShape), + loadProfilePicture = true, + loadRobohash = true, + ) + } + Column(modifier = Modifier.weight(1f)) { + Text( + pluralStringResource(R.plurals.nip46_signer_batch_title, pending.size, pending.size), + style = MaterialTheme.typography.titleLarge, + ) + account.accountName?.let { name -> + Text( + stringResource(R.string.nip46_signer_batch_signing_as, name), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } + TextButton( + onClick = { + selected = if (selected.size == pending.size) emptySet() else pending.map { it.token }.toSet() + }, + contentPadding = PaddingValues(horizontal = 20.dp, vertical = 2.dp), + ) { + Text( + stringResource( + if (selected.size == pending.size) R.string.nip46_signer_batch_select_none else R.string.nip46_signer_batch_select_all, + ), + style = MaterialTheme.typography.labelLarge, + ) + } + + Column( + modifier = + Modifier + .weight(1f, fill = false) + .verticalScroll(rememberScrollState()), + ) { + pending.forEach { p -> + val isExpanded = p.token in expanded + Column(modifier = Modifier.fillMaxWidth()) { + Row( + modifier = + Modifier + .fillMaxWidth() + .clickable { + expanded = if (isExpanded) expanded - p.token else expanded + p.token + }.padding(horizontal = 12.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + // Checkbox handles its own tap (select); tapping elsewhere on the row expands. + Checkbox( + checked = p.token in selected, + onCheckedChange = { on -> selected = if (on) selected + p.token else selected - p.token }, + ) + Column(modifier = Modifier.weight(1f)) { + Text( + "${p.info.appletTitle} · ${p.info.operationSummary}", + style = MaterialTheme.typography.bodyMedium, + maxLines = 1, + ) + if (p.info.contentPreview.isNotBlank()) { + Text( + p.info.contentPreview, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + ) + } + } + Icon( + if (isExpanded) MaterialSymbols.ExpandLess else MaterialSymbols.ExpandMore, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(20.dp), + ) + } + if (isExpanded) { + Box(modifier = Modifier.padding(start = 12.dp, end = 12.dp, bottom = 8.dp)) { + SignerConsentPreview(p.info) + } + } + } + } + } + + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp, vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Switch(checked = rememberChoice, onCheckedChange = { rememberChoice = it }) + Text( + stringResource(R.string.nip46_signer_batch_remember), + style = MaterialTheme.typography.bodyMedium, + ) + } + + Spacer(Modifier.height(8.dp)) + HorizontalDivider() + Spacer(Modifier.height(8.dp)) + + Button( + onClick = { + val tokens = pending.filter { it.token in selected } + // Per-op remember uses each request's own op; one-time is a single AllowOnce. + if (rememberChoice) { + tokens.forEach { onResolve(listOf(it.token), SignerOpGrant.AllowForOp(it.info.op)) } + } else { + onResolve(tokens.map { it.token }, SignerOpGrant.AllowOnce) + } + }, + enabled = selected.isNotEmpty(), + modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp), + ) { + Text(stringResource(R.string.nip46_signer_batch_allow, selected.size)) + } + OutlinedButton( + onClick = { onResolve(pending.filter { it.token in selected }.map { it.token }, SignerOpGrant.DenyOnce) }, + enabled = selected.isNotEmpty(), + modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp), + colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error), + ) { + Text(stringResource(R.string.nip46_signer_batch_deny, selected.size)) + } + } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/consent/SignerConsentCoordinator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/consent/SignerConsentCoordinator.kt new file mode 100644 index 0000000000..92730ea5b7 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/consent/SignerConsentCoordinator.kt @@ -0,0 +1,158 @@ +/* + * 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.amethyst.connectedApps.consent + +import android.content.Context +import android.content.Intent +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerOp +import com.vitorpamplona.amethyst.commons.connectedApps.signers.SignerOpGrant +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.flow.updateAndGet +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import java.util.UUID +import java.util.concurrent.ConcurrentHashMap + +/** Everything the per-operation consent dialog needs to render. */ +data class SignerConsentInfo( + val appletTitle: String, + val coordinate: String, + val op: NostrSignerOp, + val operationSummary: String, + /** Short excerpt shown in the dialog body (≤ 160 chars). */ + val contentPreview: String, + /** + * Full raw content for the "See more" toggle — event JSON for sign/encrypt operations, + * decrypted plaintext for decrypt (Amethyst decrypts first, then asks permission to expose). + */ + val rawData: String = "", + val iconUrl: String? = null, + /** + * The account that would sign/encrypt/decrypt, shown as an avatar + name so it's clear which + * logged-in identity is acting. Null on paths that don't resolve it; [accountPubKey] seeds the + * robohash avatar fallback when there's no picture. + */ + val accountName: String? = null, + val accountPicture: String? = null, + val accountPubKey: String? = null, + /** + * The unsigned event a `sign_event`/publish request would sign, so the dialog can render it as a + * note preview (what it will look like) in addition to the raw JSON. Null for encrypt/decrypt and + * non-event ops. + */ + val previewTemplate: EventTemplate? = null, +) + +/** One pending per-operation consent request, as the batched sheet renders it. */ +data class PendingConsent( + val token: String, + val info: SignerConsentInfo, +) + +/** + * Bridges the broker to the per-operation signer consent UI. The signer services requests + * concurrently (so their prompts can batch), so several requests can await consent at once: they all + * land in [pending], one [SignerConsentActivity] observes that list and shows a single-request dialog + * or a batched list, and each resolved token completes its own deferred. A dismissed/ignored request + * resolves to [SignerOpGrant.DenyOnce] — fails closed. + */ +object SignerConsentCoordinator { + private val deferreds = ConcurrentHashMap>() + private val _pending = MutableStateFlow>(emptyList()) + + /** The live set of requests awaiting the user's decision; the Activity renders this. */ + val pending: StateFlow> = _pending + + // A stable notification id (one prompt notification for the whole batch, updated as requests + // arrive) so concurrent requests don't each post their own. + private val batchNotificationId = "nip46-signer-consent".hashCode() + + // Guards the surface (post/cancel of the one shared notification) against the pending set so a + // concurrent arrival's post can't be clobbered by another request's teardown cancel. Without it, + // request A could read "pending now empty" and then cancel AFTER request B posted a fresh + // notification under the same id, leaving B with no UI while backgrounded (silent deny at timeout). + private val surfaceLock = Mutex() + + suspend fun requestConsent( + context: Context, + info: SignerConsentInfo, + ): SignerOpGrant { + val token = UUID.randomUUID().toString() + val deferred = CompletableDeferred() + deferreds[token] = deferred + + surfaceLock.withLock { + _pending.update { it + PendingConsent(token, info) } + // Fast path when Amethyst already owns the foreground: open the dialog directly. When the app + // is backgrounded this is silently dropped by Android 12+ BAL, so the full-screen-intent + // notification is what surfaces the prompt. Both are idempotent — the Activity is singleTop and + // observes [pending], and the notification uses a stable id, so concurrent requests just + // refresh the one prompt. Wrapped because a BAL-blocked launch can throw rather than no-op. + runCatching { + context.startActivity( + Intent(context, SignerConsentActivity::class.java) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP), + ) + } + SignerConsentNotifier.show( + context = context, + activityClass = SignerConsentActivity::class.java, + extraKey = EXTRA_TOKEN, + token = "nip46-signer-consent", + titleRes = R.string.nip46_signer_notif_sign_title, + ) + } + + return try { + deferred.await() + } finally { + deferreds.remove(token) + surfaceLock.withLock { + // Remove + emptiness check + cancel are one critical section vs. another request's + // add + show, so a fresh notification is never cancelled out from under a live request. + val stillPending = _pending.updateAndGet { list -> list.filterNot { it.token == token } } + if (stillPending.isEmpty()) SignerConsentNotifier.cancel(context, batchNotificationId) + } + } + } + + fun complete( + token: String, + grant: SignerOpGrant, + ) { + deferreds[token]?.complete(grant) + } + + fun completeAll( + tokens: Collection, + grant: SignerOpGrant, + ) { + tokens.forEach { complete(it, grant) } + } + + const val EXTRA_TOKEN = "napplet_signer_consent_token" +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/consent/SignerConsentNotifier.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/consent/SignerConsentNotifier.kt new file mode 100644 index 0000000000..b76ca93b78 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/consent/SignerConsentNotifier.kt @@ -0,0 +1,141 @@ +/* + * 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.amethyst.connectedApps.consent + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import androidx.core.app.NotificationCompat +import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.ui.stringRes + +/** + * Surfaces a signer consent/connect [android.app.Activity] from the **background**. + * + * A bare `context.startActivity(...)` from the application context only opens a window while + * Amethyst already owns the foreground. When a signing request arrives over a relay while the app + * is backgrounded, Android 12+ background-activity-launch (BAL) restrictions silently drop that + * `startActivity`, so the dialog would never appear and the request would sit until it times out. + * + * A full-screen-intent notification on an `IMPORTANCE_HIGH` channel (with the + * `USE_FULL_SCREEN_INTENT` permission the manifest declares) is the documented BAL exception — the + * same mechanism [com.vitorpamplona.amethyst.service.call.notification.CallNotifier] uses for + * incoming calls. On a locked/idle screen it launches the Activity immediately; while the user is + * actively on another app it shows as a heads-up banner they tap to review. + * + * Each coordinator posts one notification keyed by the request token's hash so concurrent requests + * don't clobber each other, and cancels it once the deferred resolves (approved, denied, or timed + * out) so no stale prompt lingers. + */ +object SignerConsentNotifier { + private const val CHANNEL_ID = "com.vitorpamplona.amethyst.SIGNER_CONSENT_CHANNEL" + + private fun ensureChannel(context: Context): NotificationChannel { + val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + manager.getNotificationChannel(CHANNEL_ID)?.let { return it } + + val channel = + NotificationChannel( + CHANNEL_ID, + stringRes(context, R.string.nip46_signer_notif_channel_name), + NotificationManager.IMPORTANCE_HIGH, + ).apply { + description = stringRes(context, R.string.nip46_signer_notif_channel_desc) + } + manager.createNotificationChannel(channel) + return channel + } + + /** + * Posts a full-screen-intent notification whose content/full-screen [PendingIntent] opens + * [activityClass] carrying [token]. Returns the notification id to pass to [cancel] once the + * request resolves. + */ + fun show( + context: Context, + activityClass: Class<*>, + extraKey: String, + token: String, + titleRes: Int, + ): Int { + // When Amethyst already owns the foreground the direct startActivity opens the dialog, so a + // heads-up notification would just be redundant noise on top of it. Only fall back to the + // full-screen intent when we're backgrounded — the case where startActivity is BAL-blocked. + if (appInForeground()) return NO_NOTIFICATION + + val channel = ensureChannel(context) + val notificationId = token.hashCode() + + val intent = + Intent(context, activityClass) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP) + .putExtra(extraKey, token) + + val pendingIntent = + PendingIntent.getActivity( + context, + notificationId, + intent, + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, + ) + + val notification = + NotificationCompat + .Builder(context, channel.id) + .setSmallIcon(R.drawable.amethyst) + .setContentTitle(stringRes(context, titleRes)) + .setContentText(stringRes(context, R.string.nip46_signer_notif_tap)) + .setContentIntent(pendingIntent) + .setFullScreenIntent(pendingIntent, true) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .setCategory(NotificationCompat.CATEGORY_RECOMMENDATION) + .setAutoCancel(true) + .setOngoing(true) + .setTimeoutAfter(TIMEOUT_MS) + .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) + .build() + + val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + manager.notify(notificationId, notification) + return notificationId + } + + fun cancel( + context: Context, + notificationId: Int, + ) { + if (notificationId == NO_NOTIFICATION) return + val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + manager.cancel(notificationId) + } + + private fun appInForeground(): Boolean = + // Defensive: the signer consent path only runs in the main process (where Amethyst.instance + // is set), but touching it from the keyless :napplet process would throw. Treat any failure + // as "not foreground" so the notification fallback still fires. + runCatching { Amethyst.instance.foregroundTracker.isForeground.value }.getOrDefault(false) + + private const val NO_NOTIFICATION = Int.MIN_VALUE + private const val TIMEOUT_MS = 120_000L +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/nip46/DataStoreNip46ClientStore.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/nip46/DataStoreNip46ClientStore.kt new file mode 100644 index 0000000000..eaa68c4031 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/connectedApps/nip46/DataStoreNip46ClientStore.kt @@ -0,0 +1,128 @@ +/* + * 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.amethyst.connectedApps.nip46 + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.PreferenceDataStoreFactory +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import com.vitorpamplona.amethyst.commons.connectedApps.nip46.Nip46ClientInfo +import com.vitorpamplona.amethyst.commons.connectedApps.nip46.Nip46ClientStore +import kotlinx.coroutines.flow.first +import java.io.File +import java.security.MessageDigest +import java.util.concurrent.ConcurrentHashMap + +/** + * Single-file DataStore-backed [Nip46ClientStore]. Every connected client's + * display + relay info lives in one `datastore/nip46_clients.preferences_pb` + * file; a SHA-256 prefix of the coordinate is the key so the (already public) + * coordinate is kept alongside for [all]'s reverse lookup. Fields are stored + * individually so no serialization library is needed; [relays] is newline-joined. + */ +class DataStoreNip46ClientStore( + private val filesDir: File, +) : Nip46ClientStore { + constructor(context: Context) : this(context.applicationContext.filesDir) + + private val store: DataStore get() = dataStoreFor(File(filesDir, "datastore/nip46_clients.preferences_pb")) + + override suspend fun load(coordinate: String): Nip46ClientInfo? { + val prefs = store.data.first() + if (prefs[coordKey(coordinate)] == null) return null + return Nip46ClientInfo( + name = prefs[nameKey(coordinate)], + url = prefs[urlKey(coordinate)], + image = prefs[imageKey(coordinate)], + relays = prefs[relaysKey(coordinate)].toRelaySet(), + ) + } + + override suspend fun store( + coordinate: String, + info: Nip46ClientInfo, + ) { + store.edit { prefs -> + prefs[coordKey(coordinate)] = coordinate + info.name?.let { prefs[nameKey(coordinate)] = it } ?: prefs.remove(nameKey(coordinate)) + info.url?.let { prefs[urlKey(coordinate)] = it } ?: prefs.remove(urlKey(coordinate)) + info.image?.let { prefs[imageKey(coordinate)] = it } ?: prefs.remove(imageKey(coordinate)) + if (info.relays.isNotEmpty()) prefs[relaysKey(coordinate)] = info.relays.joinToString("\n") else prefs.remove(relaysKey(coordinate)) + } + } + + override suspend fun remove(coordinate: String) { + store.edit { prefs -> + prefs.remove(coordKey(coordinate)) + prefs.remove(nameKey(coordinate)) + prefs.remove(urlKey(coordinate)) + prefs.remove(imageKey(coordinate)) + prefs.remove(relaysKey(coordinate)) + } + } + + override suspend fun all(): Map { + val prefs = store.data.first() + val result = mutableMapOf() + for ((key, value) in prefs.asMap()) { + if (!key.name.startsWith(COORD_PREFIX)) continue + val coordinate = value as? String ?: continue + result[coordinate] = + Nip46ClientInfo( + name = prefs[nameKey(coordinate)], + url = prefs[urlKey(coordinate)], + image = prefs[imageKey(coordinate)], + relays = prefs[relaysKey(coordinate)].toRelaySet(), + ) + } + return result + } + + private fun String?.toRelaySet(): Set = this?.split("\n")?.filterTo(mutableSetOf()) { it.isNotEmpty() } ?: emptySet() + + private fun coordKey(coordinate: String) = stringPreferencesKey("$COORD_PREFIX${hash(coordinate)}") + + private fun nameKey(coordinate: String) = stringPreferencesKey("name:${hash(coordinate)}") + + private fun urlKey(coordinate: String) = stringPreferencesKey("url:${hash(coordinate)}") + + private fun imageKey(coordinate: String) = stringPreferencesKey("img:${hash(coordinate)}") + + private fun relaysKey(coordinate: String) = stringPreferencesKey("relays:${hash(coordinate)}") + + companion object { + private val stores = ConcurrentHashMap>() + + private fun dataStoreFor(file: File): DataStore = + stores.computeIfAbsent(file.absolutePath) { + PreferenceDataStoreFactory.create(produceFile = { file }) + } + + private const val COORD_PREFIX = "coord:" + + private fun hash(coordinate: String): String { + val digest = MessageDigest.getInstance("SHA-256").digest(coordinate.toByteArray()) + return digest.take(8).joinToString("") { "%02x".format(it) } + } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt index f94e7e73b2..040429e1f8 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt @@ -27,6 +27,11 @@ import com.vitorpamplona.amethyst.LocalPreferences import com.vitorpamplona.amethyst.commons.actions.ConcordActions import com.vitorpamplona.amethyst.commons.actions.ConcordModeration import com.vitorpamplona.amethyst.commons.audio.VisualizerStyle +import com.vitorpamplona.amethyst.commons.connectedApps.nip46.InMemoryNip46ClientStore +import com.vitorpamplona.amethyst.commons.connectedApps.nip46.Nip46ClientStore +import com.vitorpamplona.amethyst.commons.connectedApps.signers.InMemoryNostrSignerPermissionStore +import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerPermissionLedger +import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerPermissionStore import com.vitorpamplona.amethyst.commons.marmot.MarmotManager import com.vitorpamplona.amethyst.commons.model.IAccount import com.vitorpamplona.amethyst.commons.model.concord.ConcordChannel @@ -90,6 +95,7 @@ import com.vitorpamplona.amethyst.model.nip03Timestamp.OtsState import com.vitorpamplona.amethyst.model.nip17Dms.DmInboxRelayState import com.vitorpamplona.amethyst.model.nip17Dms.DmRelayListState import com.vitorpamplona.amethyst.model.nip30CustomEmojis.OwnedEmojiPacksState +import com.vitorpamplona.amethyst.model.nip46Signer.Nip46SignerState import com.vitorpamplona.amethyst.model.nip47WalletConnect.NwcSignerState import com.vitorpamplona.amethyst.model.nip51Lists.BookmarkListState import com.vitorpamplona.amethyst.model.nip51Lists.GitRepositoryListState @@ -384,6 +390,8 @@ class Account( val marmotKeyPackageStore: com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageBundleStore? = null, val powQueue: () -> PoWPublishQueue? = { null }, relayAuthPermissionStore: RelayAuthPermissionStore = InMemoryRelayAuthPermissionStore(), + signerPermissionStore: NostrSignerPermissionStore = InMemoryNostrSignerPermissionStore(), + nip46ClientStore: Nip46ClientStore = InMemoryNip46ClientStore(), ) : IAccount { private var userProfileCache: User? = null @@ -438,6 +446,25 @@ class Account( val nip65RelayList = Nip65RelayListState(signer, cache, scope, settings) val localRelayList = LocalRelayListState(signer, cache, scope, settings) + /** Connected-Apps signer permission ledger, shared by napplets and the NIP-46 bunker. */ + val signerPermissionLedger = NostrSignerPermissionLedger(signerPermissionStore) + + /** + * Runs this account as a NIP-46 remote signer for other apps when + * [AccountSettings.nip46SignerEnabled] is on, listening on the inbox relays + * and dispatching to [signer] (see [Nip46SignerState]). + */ + val nip46Signer = + Nip46SignerState( + signer = signer, + client = client, + ledger = signerPermissionLedger, + clientStore = nip46ClientStore, + inboxRelays = nip65RelayList.inboxFlow, + scope = scope, + settings = settings, + ) + val forwardKind0ToLocalRelay = ForwardKind0ToLocalRelayState(client, localRelayList, settings) val dmRelayList = DmRelayListState(signer, cache, scope, settings) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt index 70f5e57625..69fc7d61a5 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt @@ -185,6 +185,35 @@ class AccountSettings( var stripLocationOnUpload: Boolean = true, val useLocalBlossomCache: MutableStateFlow = MutableStateFlow(true), val localBlossomCacheProfilePicturesOnly: MutableStateFlow = MutableStateFlow(false), + /** + * NIP-46: when true, this account acts as a remote signer (a "bunker") for + * other apps, listening on the user's inbox relays for kind:24133 requests. + * See [com.vitorpamplona.amethyst.model.nip46Signer.Nip46SignerState]. + */ + val nip46SignerEnabled: MutableStateFlow = MutableStateFlow(false), + /** + * The active pairing secret advertised in this account's `bunker://` URI. An + * app that connects with this secret is accepted and registered as a + * connected app; regenerating it revokes the ability of not-yet-connected + * apps to pair with an old string. + */ + val nip46BunkerSecret: MutableStateFlow = MutableStateFlow(""), + /** + * A dedicated per-account transport keypair (hex private key) for the NIP-46 + * bunker. The kind-24133 envelope is wrapped with THIS key, not the account's + * identity key, so the bunker address and on-relay traffic don't reveal which + * user the bunker belongs to (the identity is disclosed only to a connected + * app via `get_public_key`). Generated once and kept stable so the advertised + * `bunker://` address doesn't change. + */ + val nip46TransportKey: MutableStateFlow = MutableStateFlow(""), + /** + * The kind-24133 **event ids** this signer recently serviced. Persisted so that a relay replaying + * stored ephemeral requests across an app restart doesn't make it sign the same request twice — + * matched by exact event id, so it is immune to client clock skew (unlike a timestamp watermark, + * a global timestamp would wrongly drop a second app whose clock lags). Bounded to a recent window. + */ + val nip46SeenRequestIds: MutableStateFlow> = MutableStateFlow(emptySet()), /** * NIP-9B opt-in: when true, community feeds drop events whose latest cached * `kind:34551` rules document fails [com.vitorpamplona.quartz.nip72ModCommunities.rules.CommunityRulesValidator]. @@ -582,6 +611,35 @@ class AccountSettings( } } + fun changeNip46SignerEnabled(enabled: Boolean) { + if (nip46SignerEnabled.value != enabled) { + nip46SignerEnabled.tryEmit(enabled) + saveAccountSettings() + } + } + + fun changeNip46BunkerSecret(secret: String) { + if (nip46BunkerSecret.value != secret) { + nip46BunkerSecret.tryEmit(secret) + saveAccountSettings() + } + } + + fun changeNip46TransportKey(hexPrivKey: String) { + if (nip46TransportKey.value != hexPrivKey) { + nip46TransportKey.tryEmit(hexPrivKey) + saveAccountSettings() + } + } + + /** Replaces the recent serviced-request id set (already bounded by the caller). */ + fun changeNip46SeenRequestIds(ids: Set) { + if (nip46SeenRequestIds.value != ids) { + nip46SeenRequestIds.tryEmit(ids) + saveAccountSettings() + } + } + fun changeLocalBlossomCacheProfilePicturesOnly(enabled: Boolean) { if (localBlossomCacheProfilePicturesOnly.value != enabled) { localBlossomCacheProfilePicturesOnly.tryEmit(enabled) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/accountsCache/AccountCacheState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/accountsCache/AccountCacheState.kt index 2fd320e03c..00b375d9bf 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/accountsCache/AccountCacheState.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/accountsCache/AccountCacheState.kt @@ -22,6 +22,10 @@ package com.vitorpamplona.amethyst.model.accountsCache import android.content.ContentResolver import com.vitorpamplona.amethyst.LocalPreferences +import com.vitorpamplona.amethyst.commons.connectedApps.nip46.InMemoryNip46ClientStore +import com.vitorpamplona.amethyst.commons.connectedApps.nip46.Nip46ClientStore +import com.vitorpamplona.amethyst.commons.connectedApps.signers.InMemoryNostrSignerPermissionStore +import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerPermissionStore import com.vitorpamplona.amethyst.commons.service.pow.PoWPublishQueue import com.vitorpamplona.amethyst.model.Account import com.vitorpamplona.amethyst.model.AccountSettings @@ -66,9 +70,16 @@ class AccountCacheState( val powQueue: () -> PoWPublishQueue? = { null }, /** Optional resource-ledger wrapper applied to every account signer (see MeteringNostrSigner). */ val meterSigner: (NostrSigner) -> NostrSigner = { it }, + /** App-global Connected-Apps signer permission store (shared with napplets), gating the NIP-46 bunker. */ + val signerPermissionStore: NostrSignerPermissionStore = InMemoryNostrSignerPermissionStore(), + /** App-global store of connected NIP-46 client display + relay info. */ + val nip46ClientStore: Nip46ClientStore = InMemoryNip46ClientStore(), ) { val accounts = MutableStateFlow>(emptyMap()) + /** Guards [loadAccount]'s check-then-create so concurrent callers can't build twin Accounts. */ + private val loadLock = Any() + fun removeAccount(pubkey: HexKey) { accounts.update { existingAccounts -> val oldValue = existingAccounts[pubkey] @@ -179,6 +190,22 @@ class AccountCacheState( val cached = accounts.value[signer.pubKey] if (cached != null) return cached + // Serialize construction: the UI login path and the always-on service's preload race + // to load the same account on cold start. Without the lock both see a null cache and + // both build an Account — the loser is never cancelled, leaving a zombie whose + // Nip46SignerState answers bunker requests with a NostrSignerExternal no Activity + // ever registers a launcher on (every sign fails "No activity to launch from"), + // while duplicating consent prompts and racing error replies to NIP-46 clients. + return synchronized(loadLock) { + accounts.value[signer.pubKey]?.let { return it } + createAccount(signer, accountSettings) + } + } + + private fun createAccount( + signer: NostrSigner, + accountSettings: AccountSettings, + ): Account { val signerWithClientTag = NostrSignerWithClientTag( inner = meterSigner(signer), @@ -258,6 +285,8 @@ class AccountCacheState( marmotKeyPackageStore = marmotKeyPackageStore, powQueue = powQueue, relayAuthPermissionStore = relayAuthPermissionStore, + signerPermissionStore = signerPermissionStore, + nip46ClientStore = nip46ClientStore, ).also { newAccount -> accounts.update { existingAccounts -> existingAccounts.plus(Pair(signer.pubKey, newAccount)) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip46Signer/Nip46ActivityLog.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip46Signer/Nip46ActivityLog.kt new file mode 100644 index 0000000000..d047d23314 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip46Signer/Nip46ActivityLog.kt @@ -0,0 +1,56 @@ +/* + * 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.amethyst.model.nip46Signer + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.update + +/** One serviced NIP-46 request, for the "recent activity" feed. */ +data class Nip46ActivityEntry( + val atSeconds: Long, + val clientPubKey: HexKey, + /** The NIP-46 method (`sign_event`, `nip44_encrypt`, `get_public_key`, …). */ + val method: String, + /** Event kind for a `sign_event`, else `null`. */ + val kind: Int? = null, + /** `null` when the request succeeded; the error string when it failed or was denied. */ + val error: String? = null, +) { + val ok: Boolean get() = error == null +} + +/** + * A bounded, newest-first, in-memory log of the requests this account's signer has serviced, so the + * user can see what apps are actually doing. Not persisted across app restarts (it is a live feed, + * not an audit trail); it survives service restarts because it lives on the account's signer state. + */ +class Nip46ActivityLog( + private val capacity: Int = 100, +) { + private val _entries = MutableStateFlow>(emptyList()) + val entries: StateFlow> = _entries + + fun record(entry: Nip46ActivityEntry) { + _entries.update { (listOf(entry) + it).take(capacity) } + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip46Signer/Nip46ConsentBridge.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip46Signer/Nip46ConsentBridge.kt new file mode 100644 index 0000000000..caf6de75ec --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip46Signer/Nip46ConsentBridge.kt @@ -0,0 +1,174 @@ +/* + * 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.amethyst.model.nip46Signer + +import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.connectedApps.nip46.Nip46PermissionAuthorizer +import com.vitorpamplona.amethyst.commons.connectedApps.signers.AppConnectResult +import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerOp +import com.vitorpamplona.amethyst.commons.connectedApps.signers.SignerOpGrant +import com.vitorpamplona.amethyst.connectedApps.consent.SignerConnectCoordinator +import com.vitorpamplona.amethyst.connectedApps.consent.SignerConnectInfo +import com.vitorpamplona.amethyst.connectedApps.consent.SignerConsentCoordinator +import com.vitorpamplona.amethyst.connectedApps.consent.SignerConsentInfo +import com.vitorpamplona.amethyst.model.LocalCache +import com.vitorpamplona.amethyst.napplet.label +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.jackson.JacksonMapper +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequest +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestConnect +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestSign +import kotlinx.coroutines.withTimeoutOrNull + +/** + * Bridges the (KMP, headless) [com.vitorpamplona.amethyst.commons.connectedApps.nip46.Nip46PermissionAuthorizer] + * to the interactive consent UI. It reuses the SAME dialogs the napplet/browser signer path uses — + * [SignerConnectCoordinator] (first-connect trust picker) and [SignerConsentCoordinator] + * (per-operation allow/deny) — so a NIP-46 remote app prompts through one consistent surface, and + * the user's "remember" choices land in the same [com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerPermissionLedger]. + * + * Runs only in the main process (the signer never runs in `:napplet`), so [Amethyst.instance] is set; + * the coordinators launch their Activity from the application context. + */ +object Nip46ConsentBridge { + /** + * Upper bound on how long a consent prompt may block the signer's single-consumer loop. A user who + * ignores the dialog eventually fails the request closed (deny / declined) instead of wedging the + * signer for every other client whose requests queue behind that one blocked prompt. + */ + private const val CONSENT_TIMEOUT_MS = 120_000L + + /** First-connect consent: show the app's self-declared identity and let the user pick a trust level. */ + suspend fun requestConnect( + coordinate: String, + clientPubKey: HexKey, + request: BunkerRequestConnect, + ): AppConnectResult { + val context = Amethyst.instance.appContext + val meta = request.clientMetadata + val title = meta?.name?.ifBlank { null } ?: context.getString(R.string.nip46_signer_remote_app) + val domain = meta?.url?.ifBlank { null } ?: (clientPubKey.take(12) + "…") + // The identity being connected to lives in the coordinate; show it as an avatar + name. + val face = accountFace(coordinate) + val info = + SignerConnectInfo( + appletTitle = title, + coordinate = coordinate, + domain = domain, + iconUrl = meta?.image, + accountName = face.name, + accountPicture = face.picture, + accountPubKey = face.pubKey, + ) + // Fail closed (declined) if the prompt is never answered, so a stuck first-connect dialog can't + // hold the single-consumer loop hostage against every other client. + return withTimeoutOrNull(CONSENT_TIMEOUT_MS) { + SignerConnectCoordinator.requestConnect(context, info) + } ?: AppConnectResult.Cancelled + } + + /** + * First-connect consent for the client-initiated (`nostrconnect://`) flow: like [requestConnect] + * but built from the pasted/scanned offer, and — crucially — it surfaces the app's declared + * [requestedOps] so the user gives informed consent before those ops are pre-granted. Returns the + * user's [AppConnectResult] (or [AppConnectResult.Cancelled] if the prompt is never answered). + */ + suspend fun requestNostrConnectConsent( + coordinate: String, + name: String?, + url: String?, + image: String?, + requestedOps: List, + ): AppConnectResult { + val context = Amethyst.instance.appContext + val title = name?.ifBlank { null } ?: context.getString(R.string.nip46_signer_remote_app) + val domain = url?.ifBlank { null } ?: (Nip46PermissionAuthorizer.clientPubKeyOf(coordinate)?.take(12)?.plus("…") ?: "") + val face = accountFace(coordinate) + val info = + SignerConnectInfo( + appletTitle = title, + coordinate = coordinate, + domain = domain, + iconUrl = image, + accountName = face.name, + accountPicture = face.picture, + accountPubKey = face.pubKey, + requestedPermissions = requestedOps.map { it.label(context) }, + ) + return withTimeoutOrNull(CONSENT_TIMEOUT_MS) { + SignerConnectCoordinator.requestConnect(context, info) + } ?: AppConnectResult.Cancelled + } + + /** Per-operation consent: describe the request (op + event preview) and await the user's grant. */ + suspend fun requestOp( + coordinate: String, + clientPubKey: HexKey, + op: NostrSignerOp, + request: BunkerRequest, + ): SignerOpGrant { + val context = Amethyst.instance.appContext + val info = runCatching { Amethyst.instance.nip46ClientStore.load(coordinate) }.getOrNull() + val title = info?.name?.ifBlank { null } ?: context.getString(R.string.nip46_signer_remote_app) + val preview = + if (request is BunkerRequestSign) { + request.event.content + .take(160) + .trim() + } else { + "" + } + val rawData = if (request is BunkerRequestSign) JacksonMapper.toJsonPretty(request.event) else "" + val face = accountFace(coordinate) + val consentInfo = + SignerConsentInfo( + appletTitle = title, + coordinate = coordinate, + op = op, + operationSummary = op.label(context), + contentPreview = preview, + rawData = rawData, + iconUrl = info?.image, + accountName = face.name, + accountPicture = face.picture, + accountPubKey = face.pubKey, + previewTemplate = (request as? BunkerRequestSign)?.event, + ) + // Fail closed if the prompt is never answered so a stuck dialog can't hold the signer hostage. + return withTimeoutOrNull(CONSENT_TIMEOUT_MS) { + SignerConsentCoordinator.requestConsent(context, consentInfo) + } ?: SignerOpGrant.DenyOnce + } + + /** The account being signed for (avatar + name), resolved from the coordinate's signer pubkey. */ + private fun accountFace(coordinate: String): AccountFace { + val pubKey = Nip46PermissionAuthorizer.signerPubKeyOf(coordinate) + val user = pubKey?.let { LocalCache.getUserIfExists(it) } + return AccountFace(name = user?.toBestDisplayName(), picture = user?.profilePicture(), pubKey = pubKey) + } + + private data class AccountFace( + val name: String?, + val picture: String?, + val pubKey: String?, + ) +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip46Signer/Nip46SignerState.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip46Signer/Nip46SignerState.kt new file mode 100644 index 0000000000..5466080f30 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/model/nip46Signer/Nip46SignerState.kt @@ -0,0 +1,396 @@ +/* + * 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.amethyst.model.nip46Signer + +import com.vitorpamplona.amethyst.commons.connectedApps.nip46.Nip46ClientInfo +import com.vitorpamplona.amethyst.commons.connectedApps.nip46.Nip46ClientStore +import com.vitorpamplona.amethyst.commons.connectedApps.nip46.Nip46PermissionAuthorizer +import com.vitorpamplona.amethyst.commons.connectedApps.signers.AppConnectResult +import com.vitorpamplona.amethyst.commons.connectedApps.signers.AppSignerPolicy +import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrOpDecision +import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerPermissionLedger +import com.vitorpamplona.amethyst.model.AccountSettings +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.single.newSubId +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestSign +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponse +import com.vitorpamplona.quartz.nip46RemoteSigner.NostrConnectEvent +import com.vitorpamplona.quartz.nip46RemoteSigner.NostrConnectURI +import com.vitorpamplona.quartz.nip46RemoteSigner.server.BunkerRequestProcessor +import com.vitorpamplona.quartz.nip46RemoteSigner.server.NostrConnectSignerService +import com.vitorpamplona.quartz.utils.Log +import com.vitorpamplona.quartz.utils.RandomInstance +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch + +/** How many recently-serviced request ids to persist for cross-restart replay dedup. */ +private const val MAX_SEEN_IDS = 128 + +/** + * Auto-forget a connected app after this long with no activity. Each connected NIP-46 app makes the + * signer hold a background relay subscription indefinitely, so an app paired once and abandoned would + * leak a relay connection forever; pruning idle apps bounds that growth. Last-used is stamped on + * connect and on every serviced request, so an app still in use is never pruned. + */ +private const val IDLE_PRUNE_SECONDS = 7 * 24 * 60 * 60L + +/** + * Runs Amethyst as a NIP-46 remote signer ("bunker") for the account, so other + * apps can sign through it. While [AccountSettings.nip46SignerEnabled] is on, a + * [NostrConnectSignerService] listens on the user's inbox relays (plus any relays + * pulled in by a pasted `nostrconnect://` offer) for kind:24133 requests. + * + * Two keys are kept apart: a dedicated local [transportSigner] wraps/unwraps the + * kind-24133 envelope (so the bunker address never reveals the user, and an + * external NIP-55 account pays no IPC cost for envelope crypto), while the actual + * sign/encrypt/decrypt and `get_public_key` use the account's identity [signer] — + * a local key or a NIP-55 external app, whichever the user logged in with. + * + * Every request is gated by [Nip46PermissionAuthorizer], i.e. the same + * "Connected Apps" trust ledger that governs napplets and web origins: a remote + * client is a connected app under the coordinate `nip46::`. + * + * The listener restarts whenever the enabled flag or the relay set changes + * ([collectLatest] cancels the previous run), so editing inbox relays or toggling + * the feature takes effect immediately. + */ +class Nip46SignerState( + val signer: NostrSigner, + val client: INostrClient, + val ledger: NostrSignerPermissionLedger, + val clientStore: Nip46ClientStore, + val inboxRelays: StateFlow>, + val scope: CoroutineScope, + val settings: AccountSettings, +) { + /** Relays contributed by pasted `nostrconnect://` offers this session, unioned with the inbox set. */ + private val extraRelays = MutableStateFlow>(emptySet()) + + /** Newest-first, in-memory feed of serviced requests, so the UI can show what apps are doing. */ + val activityLog = Nip46ActivityLog() + + /** + * A bounded, recently-serviced set of kind-24133 event ids, persisted so a relay replaying stored + * requests after an app restart is deduped by exact id (see [NostrConnectSignerService.initialSeen]). + * Touched only from the service's single consumer coroutine, so it needs no synchronization. + */ + private val recentHandledIds = LinkedHashSet(settings.nip46SeenRequestIds.value) + + private fun rememberHandledId(eventId: HexKey) { + if (!recentHandledIds.add(eventId)) return + while (recentHandledIds.size > MAX_SEEN_IDS) { + recentHandledIds.iterator().let { + it.next() + it.remove() + } + } + settings.changeNip46SeenRequestIds(recentHandledIds.toSet()) + } + + /** + * The dedicated per-account transport signer that wraps the kind-24133 envelope — a local key + * unrelated to the account identity, so the bunker address/traffic doesn't reveal who it is for, + * and (unlike the identity signer) an external NIP-55 account pays no IPC cost for envelope crypto. + * Generated + persisted lazily on first use so accounts that never enable the signer mint nothing. + * + * Rebuilt from the persisted key on every call rather than cached, so [rotateAddress] takes effect: + * the service-restart trigger includes [AccountSettings.nip46TransportKey], and this reads the + * current value — deriving a keypair from stored bytes is cheap enough for the per-restart cost. + */ + private fun transportSigner(): NostrSignerInternal = NostrSignerInternal(KeyPair(ensureTransportKeyBytes())) + + /** All relays the signer listens on: the account inbox plus any nostrconnect offer relays. */ + val listeningRelays: StateFlow> = + combine(inboxRelays, extraRelays) { inbox, extra -> inbox + extra } + .stateIn(scope, SharingStarted.Eagerly, inboxRelays.value) + + private val authorizer = + Nip46PermissionAuthorizer( + ledger = ledger, + signerPubKey = signer.pubKey, + validateSecret = { clientPubKey, offered -> + // A new app pairs with the current bunker secret; an already-connected app + // re-authenticates by identity (it already holds a trust level in the ledger). + val secret = settings.nip46BunkerSecret.value + (secret.isNotEmpty() && offered == secret) || + ledger.hasPolicy(Nip46PermissionAuthorizer.coordinateFor(signer.pubKey, clientPubKey)) + }, + onConnected = { clientPubKey, request -> + // A bunker-flow client talks to us on the inbox relays we always listen on, so we + // only persist its self-declared display metadata (never as authorization — just a label). + val meta = request.clientMetadata + if (meta != null && !meta.isEmpty()) { + clientStore.store( + Nip46PermissionAuthorizer.coordinateFor(signer.pubKey, clientPubKey), + Nip46ClientInfo(name = meta.name, url = meta.url, image = meta.image), + ) + } + }, + clientStore = clientStore, + // A forgotten client's relays are gone from the store now; recompute the listen set so we + // stop listening on them this session instead of waiting for a restart. + onDisconnected = { refreshExtraRelaysFromStore() }, + // Interactive consent through the shared signer dialogs: a trust-level picker on first + // connect, and an allow/deny prompt whenever the ledger says ASK (dangerous kinds, + // decryption, DMs, or a PARANOID app). Same surface + ledger as napplet/browser signing. + connectConsent = Nip46ConsentBridge::requestConnect, + opConsent = Nip46ConsentBridge::requestOp, + ) + + init { + // extraRelays is a live projection of the persisted client store (the nostrconnect apps' own + // relays). Load it on start so paired apps stay reachable across restarts; it is refreshed + // whenever a client connects or is forgotten (bunker-flow apps use the inbox relays instead). + // Prune apps idle past IDLE_PRUNE_SECONDS first so we don't re-subscribe to a relay only an + // abandoned app used — forget() already refreshes extraRelays, and we refresh again in case + // nothing was pruned. + scope.launch(Dispatchers.IO) { + runCatching { authorizer.pruneIdle(IDLE_PRUNE_SECONDS) } + .onFailure { Log.w("NIP46Signer") { "idle prune failed: ${it.message}" } } + refreshExtraRelaysFromStore() + } + + scope.launch(Dispatchers.IO) { + combine(settings.nip46SignerEnabled, listeningRelays, settings.nip46TransportKey) { enabled, relays, transportKey -> + Triple(enabled, relays, transportKey) + } + // Inbox/relay/key StateFlows can re-emit an identical value; without this every duplicate + // would tear the subscription down and re-open it on every relay for no reason. Including + // the transport key here makes rotateAddress() re-subscribe under the fresh key. + .distinctUntilChanged() + .collectLatest { (enabled, relays, _) -> + if (!enabled) return@collectLatest + if (!signer.isWriteable()) { + Log.w("NIP46Signer") { "signer not writeable; cannot host a bunker" } + return@collectLatest + } + if (relays.isEmpty()) return@collectLatest + + // Envelope wrapped with the local transport key; the actual work (and get_public_key) + // uses the account's identity signer inside the processor. + val processor = BunkerRequestProcessor(signer, { listeningRelays.value }, authorizer) + val service = + NostrConnectSignerService( + client = client, + transportSigner = transportSigner(), + processor = processor, + relays = relays, + onServiced = { request, clientPubKey, error -> + Log.d("NIP46Signer") { "${request.method} from ${clientPubKey.take(8)}… → ${error ?: "ok"}" } + activityLog.record( + Nip46ActivityEntry( + atSeconds = TimeUtils.now(), + clientPubKey = clientPubKey, + method = request.method, + kind = (request as? BunkerRequestSign)?.event?.kind, + error = error, + ), + ) + }, + // Seed dedup with the ids we serviced last session so an app restart doesn't + // re-sign a relay's replay of the same stored requests (matched by exact id). + initialSeen = settings.nip46SeenRequestIds.value, + onHandledId = { id -> rememberHandledId(id) }, + ) + service.run() + } + } + } + + /** Whether the account is currently advertising itself as a signer. */ + val enabled: StateFlow get() = settings.nip46SignerEnabled + + fun setEnabled(enabled: Boolean) { + if (enabled) { + // Settle the secret and transport key BEFORE flipping the flag, so the service-restart + // trigger sees the final transport key on its first emission (no throwaway double-start). + ensureSecret() + ensureTransportKeyBytes() + } + settings.changeNip46SignerEnabled(enabled) + } + + /** The `bunker://?relay=…&secret=…` string to paste into another app. Generates keys/secret if needed. */ + fun bunkerUri(): String { + val secret = ensureSecret() + // Advertise the transport key, not the identity key, so the address doesn't reveal who we are. + return NostrConnectURI.buildBunker(transportSigner().pubKey, inboxRelays.value, secret) + } + + /** Replaces the pairing secret with a fresh one, revoking the ability of not-yet-connected apps to use the old one. */ + fun regenerateSecret(): String { + val fresh = RandomInstance.randomChars(32) + settings.changeNip46BunkerSecret(fresh) + return fresh + } + + /** + * The anti-spam "burn it down" action: mints a brand-new transport key (and pairing secret), so + * the old `bunker://` address goes dark — anyone who had it (a spammer included) can no longer + * reach us, and every app talking to the old transport pubkey is dropped. The running service + * re-subscribes under the new key because [AccountSettings.nip46TransportKey] feeds the restart + * trigger. Legit apps re-pair by re-scanning the new address; their trust survives because the + * Connected-Apps coordinate keys off the stable identity pubkey, not the transport key. + */ + fun rotateAddress(): String { + val fresh = KeyPair() + settings.changeNip46TransportKey(fresh.privKey!!.toHexKey()) + regenerateSecret() + return NostrConnectURI.buildBunker(fresh.pubKey.toHexKey(), inboxRelays.value, settings.nip46BunkerSecret.value) + } + + /** Recomputes [extraRelays] from the persisted client store — the source of truth for nostrconnect relays. */ + private suspend fun refreshExtraRelaysFromStore() { + extraRelays.value = + clientStore + .all() + .filterKeys { Nip46PermissionAuthorizer.belongsTo(it, signer.pubKey) } + .values + .flatMap { it.relays } + .mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) } + .toSet() + } + + /** + * Forgets a connected client (the user's "Forget" action): revokes its grant, drops its stored + * metadata/relays, and stops listening on relays that only it used. Same path as a client-sent + * `logout`, so both are consistent. + */ + suspend fun forgetClient(clientPubKey: HexKey) = authorizer.forget(clientPubKey) + + /** Returns the current pairing secret, generating and persisting one the first time. */ + private fun ensureSecret(): String { + val current = settings.nip46BunkerSecret.value + if (current.isNotEmpty()) return current + val fresh = RandomInstance.randomChars(32) + settings.changeNip46BunkerSecret(fresh) + return fresh + } + + /** The transport private key bytes, generating and persisting a fresh keypair the first time (or if corrupt). */ + private fun ensureTransportKeyBytes(): ByteArray { + val stored = settings.nip46TransportKey.value + val existing = stored.takeIf { it.length == 64 }?.let { runCatching { it.hexToByteArray() }.getOrNull() } + if (existing != null) return existing + val fresh = KeyPair() + settings.changeNip46TransportKey(fresh.privKey!!.toHexKey()) + return fresh.privKey!! + } + + /** + * The client-initiated (`nostrconnect://`) pairing flow: parse a client's + * offer, send the connect ack that echoes its secret (so the client learns + * our signer pubkey), register it as a connected app, and start listening on + * its relays. Enables the signer if it was off. + */ + suspend fun connectViaNostrConnect(uri: String): ConnectResult { + val offer = NostrConnectURI.parseNostrConnect(uri) ?: return ConnectResult.InvalidUri + if (offer.relays.isEmpty()) return ConnectResult.NoRelays + if (!signer.isWriteable()) return ConnectResult.NotWriteable + + val coordinate = authorizer.coordinateFor(offer.clientPubKey) + val requestedOps = Nip46PermissionAuthorizer.parsePerms(offer.perms) + val firstContact = !ledger.hasPolicy(coordinate) + + // First contact: get informed consent — the app's identity, the perms it declared, and a trust + // level — BEFORE we publish the ack or grant anything. A re-pair keeps the existing trust and + // per-op decisions the user may have since changed (e.g. an op set to DENY), so it skips the prompt. + val grantedPolicy: AppSignerPolicy? = + if (firstContact) { + when (val result = Nip46ConsentBridge.requestNostrConnectConsent(coordinate, offer.name, offer.url, offer.image, requestedOps)) { + is AppConnectResult.Connected -> result.policy + AppConnectResult.Blocked, AppConnectResult.Cancelled -> return ConnectResult.Declined + } + } else { + null + } + + return try { + // Echo the offer secret back to the client — authored by the transport key so the client + // learns THAT as our remote-signer pubkey (not our identity). Only after consent. + val ack = BunkerResponse(newSubId(), offer.secret, null) + val reply = NostrConnectEvent.create(ack, offer.clientPubKey, transportSigner()) + client.publish(reply, offer.relays) + + if (grantedPolicy != null) { + ledger.setPolicy(coordinate, grantedPolicy) + // The user just reviewed and approved these declared perms, so honor them — including + // sensitive ones — unless they chose PARANOID (ask every time, pre-grant nothing). + if (grantedPolicy != AppSignerPolicy.PARANOID) { + requestedOps.forEach { ledger.setOpDecision(coordinate, it, NostrOpDecision.ALLOW) } + } + } + ledger.updateLastUsed(coordinate) + // Persist the app's label + its relays so it survives a restart, then start listening now. + clientStore.store( + coordinate, + Nip46ClientInfo(name = offer.name, url = offer.url, image = offer.image, relays = offer.relays.map { it.url }.toSet()), + ) + refreshExtraRelaysFromStore() + + setEnabled(true) + ConnectResult.Connected(offer.clientPubKey, offer.name) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Log.w("NIP46Signer") { "nostrconnect pairing failed: ${e.message}" } + ConnectResult.Failed(e.message ?: "unknown error") + } + } + + sealed interface ConnectResult { + data class Connected( + val clientPubKey: String, + val name: String?, + ) : ConnectResult + + data object InvalidUri : ConnectResult + + data object NoRelays : ConnectResult + + data object NotWriteable : ConnectResult + + /** The user reviewed the connect request and declined (cancelled or blocked). */ + data object Declined : ConnectResult + + data class Failed( + val reason: String, + ) : ConnectResult + } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt index c08d211060..0eb2a39323 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt @@ -34,6 +34,7 @@ import android.os.SystemClock import android.util.Log import androidx.core.net.toUri import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerPermissionLedger import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp import com.vitorpamplona.amethyst.commons.napplet.NappletBroker import com.vitorpamplona.amethyst.commons.napplet.NappletCapability @@ -42,7 +43,6 @@ import com.vitorpamplona.amethyst.commons.napplet.NappletRequestRouter import com.vitorpamplona.amethyst.commons.napplet.permissions.NappletPermissionLedger import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletProtocolJson import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletResponse -import com.vitorpamplona.amethyst.commons.napplet.signers.NostrSignerPermissionLedger import com.vitorpamplona.amethyst.favorites.BrowserHistoryRegistry import com.vitorpamplona.amethyst.favorites.BrowserIconRegistry import com.vitorpamplona.amethyst.favorites.FavoriteAppsRegistry diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletSignerConsentActivity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletSignerConsentActivity.kt deleted file mode 100644 index 6fa8bad403..0000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletSignerConsentActivity.kt +++ /dev/null @@ -1,325 +0,0 @@ -/* - * 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.amethyst.napplet - -import android.os.Bundle -import androidx.activity.ComponentActivity -import androidx.activity.compose.setContent -import androidx.compose.foundation.horizontalScroll -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.heightIn -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.text.selection.SelectionContainer -import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.HorizontalDivider -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedButton -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalConfiguration -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.unit.dp -import androidx.compose.ui.window.Dialog -import androidx.compose.ui.window.DialogProperties -import com.vitorpamplona.amethyst.R -import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp -import com.vitorpamplona.amethyst.commons.favorites.FavoriteAppIcon -import com.vitorpamplona.amethyst.commons.icons.symbols.Icon -import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols -import com.vitorpamplona.amethyst.commons.napplet.signers.SignerOpGrant -import com.vitorpamplona.amethyst.ui.theme.AmethystTheme -import com.vitorpamplona.quartz.utils.TimeUtils - -class NappletSignerConsentActivity : ComponentActivity() { - private var token: String? = null - private var decided = false - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - val token = intent.getStringExtra(NappletSignerConsentCoordinator.EXTRA_TOKEN) - this.token = token - val info = token?.let { NappletSignerConsentCoordinator.infoFor(it) } - if (token == null || info == null) { - finish() - return - } - - setContent { - AmethystTheme { - NappletSignerConsentDialog( - info = info, - onGrant = { grant -> - decided = true - NappletSignerConsentCoordinator.complete(token, grant) - finish() - }, - onDismiss = { - decided = true - NappletSignerConsentCoordinator.cancel(token) - finish() - }, - ) - } - } - } - - override fun finish() { - if (!decided) token?.let { NappletSignerConsentCoordinator.cancel(it) } - super.finish() - } -} - -@Composable -private fun NappletSignerConsentDialog( - info: NappletSignerConsentInfo, - onGrant: (SignerOpGrant) -> Unit, - onDismiss: () -> Unit, -) { - var showRawData by remember { mutableStateOf(false) } - var showMoreOptions by remember { mutableStateOf(false) } - val scrollState = rememberScrollState() - val maxHeight = LocalConfiguration.current.screenHeightDp.dp * 0.85f - - Dialog( - onDismissRequest = onDismiss, - properties = DialogProperties(usePlatformDefaultWidth = false), - ) { - Surface( - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp) - .heightIn(max = maxHeight), - shape = MaterialTheme.shapes.extraLarge, - color = MaterialTheme.colorScheme.surface, - tonalElevation = 6.dp, - ) { - Column( - modifier = - Modifier - .verticalScroll(scrollState) - .padding(vertical = 24.dp), - ) { - // Centered header: icon + title + description - Column( - modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - val isBrowser = info.coordinate.startsWith("browser:") - FavoriteAppIcon( - app = - if (isBrowser) { - FavoriteApp.WebApp(info.coordinate.substringAfter(':'), info.appletTitle, 0L, info.iconUrl) - } else { - FavoriteApp.NostrApp(info.coordinate, info.appletTitle, 0L, info.iconUrl) - }, - tint = MaterialTheme.colorScheme.onPrimaryContainer, - modifier = Modifier.size(56.dp), - ) - Text( - info.appletTitle, - style = MaterialTheme.typography.titleLarge, - textAlign = TextAlign.Center, - ) - Text( - stringResource(R.string.napplet_consent_wants_to, info.operationSummary), - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center, - ) - Text( - info.coordinate.substringAfter(':', "").ifBlank { info.coordinate.substringBefore(':').take(12) + "…" }, - style = MaterialTheme.typography.labelSmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - textAlign = TextAlign.Center, - ) - } - - val hasContent = info.contentPreview.isNotBlank() || info.rawData.isNotBlank() - if (hasContent) { - Spacer(Modifier.height(12.dp)) - Surface( - modifier = - Modifier - .padding(horizontal = 24.dp) - .fillMaxWidth(), - color = MaterialTheme.colorScheme.surfaceVariant, - shape = MaterialTheme.shapes.medium, - ) { - Column(modifier = Modifier.padding(12.dp)) { - if (info.contentPreview.isNotBlank()) { - Text( - "“${info.contentPreview}”", - style = MaterialTheme.typography.bodySmall, - ) - } - if (info.rawData.isNotBlank()) { - if (showRawData) { - Spacer(Modifier.height(8.dp)) - Box(modifier = Modifier.horizontalScroll(rememberScrollState())) { - SelectionContainer { - Text( - info.rawData, - style = - MaterialTheme.typography.labelSmall.copy( - fontFamily = FontFamily.Monospace, - ), - color = MaterialTheme.colorScheme.onSurfaceVariant, - softWrap = false, - ) - } - } - } - TextButton( - onClick = { showRawData = !showRawData }, - contentPadding = PaddingValues(horizontal = 4.dp, vertical = 0.dp), - ) { - Text( - if (showRawData) { - stringResource(R.string.napplet_consent_hide_event) - } else { - stringResource(R.string.napplet_consent_show_event) - }, - style = MaterialTheme.typography.labelSmall, - ) - } - } - } - } - } - - Spacer(Modifier.height(16.dp)) - HorizontalDivider() - Spacer(Modifier.height(8.dp)) - - // Primary: always allow this op - Button( - onClick = { onGrant(SignerOpGrant.AllowForOp(info.op)) }, - modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp), - ) { - Text(stringResource(R.string.napplet_consent_allow_always)) - } - - // Secondary: allow just once - OutlinedButton( - onClick = { onGrant(SignerOpGrant.AllowOnce) }, - modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp), - ) { - Text(stringResource(R.string.napplet_signer_allow_once)) - } - - // "More options" toggle: session and time-bound grants - TextButton( - onClick = { showMoreOptions = !showMoreOptions }, - modifier = Modifier.fillMaxWidth(), - contentPadding = PaddingValues(horizontal = 24.dp, vertical = 8.dp), - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(4.dp), - ) { - Text( - if (showMoreOptions) { - stringResource(R.string.napplet_consent_fewer_options) - } else { - stringResource(R.string.napplet_consent_more_options) - }, - style = MaterialTheme.typography.bodyMedium, - ) - Icon( - if (showMoreOptions) MaterialSymbols.ExpandLess else MaterialSymbols.ExpandMore, - contentDescription = null, - modifier = Modifier.size(18.dp), - ) - } - } - - if (showMoreOptions) { - OutlinedButton( - onClick = { onGrant(SignerOpGrant.AllowForSession(info.op)) }, - modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp), - ) { - Text(stringResource(R.string.napplet_signer_allow_session)) - } - OutlinedButton( - onClick = { onGrant(SignerOpGrant.AllowUntil(info.op, TimeUtils.now() + 86_400L)) }, - modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp), - ) { - Text(stringResource(R.string.napplet_signer_allow_24h)) - } - OutlinedButton( - onClick = { onGrant(SignerOpGrant.AllowUntil(info.op, TimeUtils.now() + 30L * 86_400L)) }, - modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp), - ) { - Text(stringResource(R.string.napplet_signer_allow_30d)) - } - OutlinedButton( - onClick = { onGrant(SignerOpGrant.AllowAll) }, - modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp), - ) { - Text(stringResource(R.string.napplet_signer_allow_all)) - } - } - - Spacer(Modifier.height(4.dp)) - HorizontalDivider() - Spacer(Modifier.height(8.dp)) - - OutlinedButton( - onClick = { onGrant(SignerOpGrant.DenyOnce) }, - modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp), - colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error), - ) { - Text(stringResource(R.string.napplet_signer_deny_once)) - } - OutlinedButton( - onClick = { onGrant(SignerOpGrant.DenyForOp(info.op)) }, - modifier = Modifier.fillMaxWidth().padding(horizontal = 24.dp), - colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error), - ) { - Text(stringResource(R.string.napplet_signer_deny_op, info.operationSummary)) - } - } - } - } -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletSignerConsentCoordinator.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletSignerConsentCoordinator.kt deleted file mode 100644 index 9febb3b0f2..0000000000 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletSignerConsentCoordinator.kt +++ /dev/null @@ -1,94 +0,0 @@ -/* - * 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.amethyst.napplet - -import android.content.Context -import android.content.Intent -import com.vitorpamplona.amethyst.commons.napplet.signers.NostrSignerOp -import com.vitorpamplona.amethyst.commons.napplet.signers.SignerOpGrant -import kotlinx.coroutines.CompletableDeferred -import java.util.UUID -import java.util.concurrent.ConcurrentHashMap - -/** Everything the per-operation consent dialog needs to render. */ -data class NappletSignerConsentInfo( - val appletTitle: String, - val coordinate: String, - val op: NostrSignerOp, - val operationSummary: String, - /** Short excerpt shown in the dialog body (≤ 160 chars). */ - val contentPreview: String, - /** - * Full raw content for the "See more" toggle — event JSON for sign/encrypt operations, - * decrypted plaintext for decrypt (Amethyst decrypts first, then asks permission to expose). - */ - val rawData: String = "", - val iconUrl: String? = null, -) - -/** - * Bridges the broker to the per-operation signer consent UI. - * A dismissed dialog resolves to [SignerOpGrant.DenyOnce] — fails closed. - */ -object NappletSignerConsentCoordinator { - private class Pending( - val info: NappletSignerConsentInfo, - val deferred: CompletableDeferred, - ) - - private val pending = ConcurrentHashMap() - - suspend fun requestConsent( - context: Context, - info: NappletSignerConsentInfo, - ): SignerOpGrant { - val token = UUID.randomUUID().toString() - val deferred = CompletableDeferred() - pending[token] = Pending(info, deferred) - - context.startActivity( - Intent(context, NappletSignerConsentActivity::class.java) - .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - .putExtra(EXTRA_TOKEN, token), - ) - - return try { - deferred.await() - } finally { - pending.remove(token) - } - } - - fun infoFor(token: String): NappletSignerConsentInfo? = pending[token]?.info - - fun complete( - token: String, - grant: SignerOpGrant, - ) { - pending[token]?.deferred?.complete(grant) - } - - fun cancel(token: String) { - pending[token]?.deferred?.complete(SignerOpGrant.DenyOnce) - } - - const val EXTRA_TOKEN = "napplet_signer_consent_token" -} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NostrSignerOpLabels.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NostrSignerOpLabels.kt index 9c7bb146d9..7d6932b6f6 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NostrSignerOpLabels.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NostrSignerOpLabels.kt @@ -23,11 +23,14 @@ package com.vitorpamplona.amethyst.napplet import android.content.Context import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.browser.OmniboxInput +import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerOp import com.vitorpamplona.amethyst.commons.napplet.NappletIdentity import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletRequest -import com.vitorpamplona.amethyst.commons.napplet.signers.NostrSignerOp +import com.vitorpamplona.amethyst.connectedApps.consent.SignerConnectInfo +import com.vitorpamplona.amethyst.connectedApps.consent.SignerConsentInfo import com.vitorpamplona.amethyst.favorites.BrowserIconRegistry import com.vitorpamplona.amethyst.ui.screen.loggedIn.relays.kindNameFor +import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.jackson.JacksonMapper import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate import com.vitorpamplona.quartz.utils.TimeUtils @@ -40,13 +43,13 @@ fun NostrSignerOp.label(context: Context): String = NostrSignerOp.Decrypt -> context.getString(R.string.napplet_op_decrypt) } -/** Builds the [NappletSignerConsentInfo] needed by the per-op consent dialog. */ +/** Builds the [SignerConsentInfo] needed by the per-op consent dialog. */ fun buildSignerConsentInfo( context: Context, identity: NappletIdentity, op: NostrSignerOp, request: NappletRequest, -): NappletSignerConsentInfo { +): SignerConsentInfo { val untitled = context.getString(R.string.napplet_fallback_title, identity.authorPubKey.take(8)) val (title, iconUrl) = if (identity.authorPubKey == "browser") { @@ -84,7 +87,13 @@ fun buildSignerConsentInfo( } else -> "" } - return NappletSignerConsentInfo( + val previewTemplate = + when (request) { + is NappletRequest.Publish -> EventTemplate(TimeUtils.now(), request.kind, request.tags, request.content) + is NappletRequest.SignEvent -> EventTemplate(request.createdAt, request.kind, request.tags, request.content) + else -> null + } + return SignerConsentInfo( appletTitle = title, coordinate = identity.coordinate, op = op, @@ -92,14 +101,15 @@ fun buildSignerConsentInfo( contentPreview = preview, rawData = rawData, iconUrl = iconUrl, + previewTemplate = previewTemplate, ) } -/** Creates a [NappletConnectInfo] for the first-connect dialog. */ +/** Creates a [SignerConnectInfo] for the first-connect dialog. */ fun buildConnectInfo( context: Context, identity: NappletIdentity, -): NappletConnectInfo { +): SignerConnectInfo { val untitled = context.getString(R.string.napplet_fallback_title, identity.authorPubKey.take(8)) val (title, iconUrl) = if (identity.authorPubKey == "browser") { @@ -114,5 +124,5 @@ fun buildConnectInfo( } else { identity.identifier.ifBlank { identity.authorPubKey.take(12) + "…" } } - return NappletConnectInfo(appletTitle = title, coordinate = identity.coordinate, domain = domain, iconUrl = iconUrl) + return SignerConnectInfo(appletTitle = title, coordinate = identity.coordinate, domain = domain, iconUrl = iconUrl) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/gateways/AccountNappletGateways.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/gateways/AccountNappletGateways.kt index 89bf3ecf31..e73d3e89a3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/gateways/AccountNappletGateways.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/gateways/AccountNappletGateways.kt @@ -27,6 +27,9 @@ import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrConnectPrompt +import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerConsentPrompt +import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerPermissionLedger import com.vitorpamplona.amethyst.commons.napplet.NappletBroker import com.vitorpamplona.amethyst.commons.napplet.NappletConsentPrompt import com.vitorpamplona.amethyst.commons.napplet.NappletIdentityGateway @@ -41,15 +44,12 @@ import com.vitorpamplona.amethyst.commons.napplet.NappletUploadGateway import com.vitorpamplona.amethyst.commons.napplet.NappletUploadResult import com.vitorpamplona.amethyst.commons.napplet.NappletWalletGateway import com.vitorpamplona.amethyst.commons.napplet.permissions.NappletPermissionLedger -import com.vitorpamplona.amethyst.commons.napplet.signers.NostrConnectPrompt -import com.vitorpamplona.amethyst.commons.napplet.signers.NostrSignerConsentPrompt -import com.vitorpamplona.amethyst.commons.napplet.signers.NostrSignerPermissionLedger +import com.vitorpamplona.amethyst.connectedApps.consent.SignerConnectCoordinator +import com.vitorpamplona.amethyst.connectedApps.consent.SignerConsentCoordinator import com.vitorpamplona.amethyst.model.Account -import com.vitorpamplona.amethyst.napplet.NappletConnectCoordinator import com.vitorpamplona.amethyst.napplet.NappletConsentCoordinator import com.vitorpamplona.amethyst.napplet.NappletConsentSummary import com.vitorpamplona.amethyst.napplet.NappletNotificationStore -import com.vitorpamplona.amethyst.napplet.NappletSignerConsentCoordinator import com.vitorpamplona.amethyst.napplet.buildConnectInfo import com.vitorpamplona.amethyst.napplet.buildSignerConsentInfo import com.vitorpamplona.amethyst.service.uploads.blossom.BlossomUploader @@ -139,7 +139,7 @@ class AccountNappletGateways( val connectPrompt = NostrConnectPrompt { identity -> - NappletConnectCoordinator.requestConnect( + SignerConnectCoordinator.requestConnect( context = context, info = buildConnectInfo(context, identity), ) @@ -147,7 +147,7 @@ class AccountNappletGateways( val signerConsent = NostrSignerConsentPrompt { identity, op, request -> - NappletSignerConsentCoordinator.requestConsent( + SignerConsentCoordinator.requestConsent( context = context, info = buildSignerConsentInfo(context, identity, op, request), ) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/AlwaysOnNotificationServiceManager.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/AlwaysOnNotificationServiceManager.kt index 58c11797f9..4af3a66589 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/AlwaysOnNotificationServiceManager.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/AlwaysOnNotificationServiceManager.kt @@ -53,8 +53,10 @@ import kotlinx.coroutines.launch * this is the battery-saver "airplane mode". Persisted, so an explicit off survives * restarts and crashes. * - The **per-account participation** flag ([com.vitorpamplona.amethyst.model.AccountSettings.alwaysOnNotificationService], - * "Keep this account active in the background"). While the master is on, the service - * runs as long as **at least one** writable account participates. + * "Keep this account active in the background") **or** its NIP-46 signer toggle + * ([com.vitorpamplona.amethyst.model.AccountSettings.nip46SignerEnabled]). While the master is + * on, the service runs as long as **at least one** writable account has either flag on — the + * background signer relies on the same foreground service to keep answering requests. * * While the master is on, every saved writable account is kept loaded in * [AccountCacheState] so (a) its participation flag is observable and (b) GiftWraps @@ -83,6 +85,11 @@ class AlwaysOnNotificationServiceManager( * loaded writable account. The service layers run while the master is on AND at * least one account participates; the master overrides everything when off. * + * An account "participates" when either its always-on setting **or** its NIP-46 + * signer toggle is on: the foreground service (and its restart layers) keep the + * process + shared relay client alive, which is exactly what the background signer + * needs to keep answering requests, so the signer toggle keeps the layers up too. + * * Idempotent: safe to call again on account switch/login — it restarts the watch. */ @OptIn(ExperimentalCoroutinesApi::class) @@ -105,11 +112,17 @@ class AlwaysOnNotificationServiceManager( // Master on: keep every writable account loaded so its participation // flag is observable and its gift wraps can decrypt, then run the - // service only while at least one account is participating. + // service only while at least one account is participating. An account + // participates when its always-on setting OR its NIP-46 signer toggle is + // on — the background signer needs the same foreground service alive. startMultiAccountPreload() accountsCache.accounts .flatMapLatest { accounts -> - val flags = accounts.values.map { it.settings.alwaysOnNotificationService } + val flags = + accounts.values.map { account -> + account.settings.alwaysOnNotificationService + .combine(account.settings.nip46SignerEnabled) { alwaysOn, signer -> alwaysOn || signer } + } if (flags.isEmpty()) { flowOf(false) } else { diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationRelayService.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationRelayService.kt index 2f48e5466b..c2fa2eab2d 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationRelayService.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/service/notifications/NotificationRelayService.kt @@ -126,9 +126,11 @@ class NotificationRelayService : Service() { // the service itself once a participant exists. fun isEnabled(context: Context): Boolean = try { + // The service also backs the NIP-46 signer, so an account with the signer on + // participates just like one with always-on notifications on. LocalPreferences.isNotificationServiceEnabled() && Amethyst.instance.accountsCache.accounts.value.values.any { - it.settings.alwaysOnNotificationService.value + it.settings.alwaysOnNotificationService.value || it.settings.nip46SignerEnabled.value } } catch (e: Exception) { false diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt index 30fa7e80c0..bfb3a0f6d7 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/MainActivity.kt @@ -53,6 +53,7 @@ import com.vitorpamplona.quartz.nip19Bech32.entities.NPub import com.vitorpamplona.quartz.nip29RelayGroups.GroupInviteLink import com.vitorpamplona.quartz.nip29RelayGroups.GroupNAddrInvite import com.vitorpamplona.quartz.nip29RelayGroups.metadata.GroupMetadataEvent +import com.vitorpamplona.quartz.nip46RemoteSigner.NostrConnectURI import com.vitorpamplona.quartz.nip47WalletConnect.Nip47WalletConnect import com.vitorpamplona.quartz.nip52Calendar.appt.day.CalendarDateSlotEvent import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent @@ -224,6 +225,12 @@ fun uriToRoute( return connectedAppRoute(uri) } + // A scanned/opened `nostrconnect://` offer is an app asking to connect to our signer: open the + // NIP-46 signer screen and let it run the pairing (it enables the signer as part of connecting). + if (uri.startsWith(NostrConnectURI.NOSTRCONNECT_SCHEME)) { + return Route.Nip46Signer(connectUri = uri) + } + relayGroupInviteRoute(uri)?.let { return it } concordInviteRoute(uri)?.let { return it } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt index 78b2b0d7d3..c60fbefdca 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/AppNavigation.kt @@ -258,6 +258,8 @@ import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.SpammingUsersScree import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.UpdateZapAmountScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.UserSettingsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.VideoPlayerSettingsScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.nip46.Nip46ConnectedAppsScreen +import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.nip46.Nip46SignerScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.shorts.ShortsScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.softwareapps.SoftwareAppDetailScreen import com.vitorpamplona.amethyst.ui.screen.loggedIn.softwareapps.SoftwareAppsScreen @@ -423,6 +425,8 @@ fun BuildNavigation( composableFromEndArgs(capWidth = false) { NostrAppScreen(it.coordinate, accountViewModel, nav) } composableFromEnd { ConnectedAppsScreen(accountViewModel, nav) } composableFromEndArgs { ConnectedAppDetailScreen(it.coordinate, accountViewModel, nav) } + composableFromEndArgs { Nip46SignerScreen(accountViewModel, nav, it.connectUri) } + composableFromEnd { Nip46ConnectedAppsScreen(accountViewModel, nav) } composableFromEnd { RelayAuthSettingsScreen(accountViewModel, nav) } composableFromEndArgs { SoftwareAppDetailScreen(Address(it.kind, it.pubKeyHex, it.dTag), accountViewModel, nav) } composableFromEnd { CalendarsScreen(accountViewModel, nav) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/NavBarItem.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/NavBarItem.kt index 78cd5f8f7c..c3a53e7919 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/NavBarItem.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/bottombars/NavBarItem.kt @@ -48,6 +48,7 @@ enum class NavBarItem { INTEREST_SETS, EMOJI_PACKS, WALLET, + NOSTR_SIGNER, COMMUNITIES, ARTICLES, PICTURES, @@ -195,6 +196,13 @@ val NavBarCatalog: Map = icon = MaterialSymbols.AccountBalanceWallet, resolveRoute = { Route.Wallet }, ), + NavBarItem.NOSTR_SIGNER to + NavBarItemDef( + id = NavBarItem.NOSTR_SIGNER, + labelRes = R.string.nip46_signer_title, + icon = MaterialSymbols.Key, + resolveRoute = { Route.Nip46Signer() }, + ), NavBarItem.COMMUNITIES to NavBarItemDef( id = NavBarItem.COMMUNITIES, @@ -443,6 +451,7 @@ val DrawerYouItems: List = NavBarItem.INTEREST_SETS, NavBarItem.EMOJI_PACKS, NavBarItem.WALLET, + NavBarItem.NOSTR_SIGNER, ) /** @@ -495,6 +504,7 @@ val BottomBarCategories: List = NavBarItem.FAVORITE_ALGO_FEEDS, NavBarItem.EMOJI_PACKS, NavBarItem.WALLET, + NavBarItem.NOSTR_SIGNER, ), ), NavBarCategory( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt index e7c0a69b71..c2c39eb3ac 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/navigation/routes/Routes.kt @@ -107,6 +107,13 @@ sealed class Route { @Serializable object ConnectedApps : Route() + @Serializable data class Nip46Signer( + /** When set (from a scanned/opened `nostrconnect://` offer), the screen connects that app on open. */ + val connectUri: String? = null, + ) : Route() + + @Serializable object Nip46ConnectedApps : Route() + @Serializable object RelayAuthSettings : Route() @Serializable data class ConnectedAppDetail( diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/BottomBarFeedPreloaders.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/BottomBarFeedPreloaders.kt index 69c6a2f3eb..41b489db8e 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/BottomBarFeedPreloaders.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/BottomBarFeedPreloaders.kt @@ -170,6 +170,7 @@ private fun PreloadFor( NavBarItem.INTEREST_SETS, NavBarItem.EMOJI_PACKS, NavBarItem.WALLET, + NavBarItem.NOSTR_SIGNER, NavBarItem.FAVORITE_ALGO_FEEDS, NavBarItem.SETTINGS, -> Unit diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppDetailScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppDetailScreen.kt index b74352c169..920d0dd38a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppDetailScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppDetailScreen.kt @@ -20,6 +20,7 @@ */ package com.vitorpamplona.amethyst.ui.screen.loggedIn.napplets +import android.widget.Toast import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -54,13 +55,22 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.R import com.vitorpamplona.amethyst.commons.browser.OmniboxInput +import com.vitorpamplona.amethyst.commons.connectedApps.nip46.Nip46ClientInfo +import com.vitorpamplona.amethyst.commons.connectedApps.nip46.Nip46PermissionAuthorizer +import com.vitorpamplona.amethyst.commons.connectedApps.signers.AppSignerPolicy +import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrOpDecision +import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerOp +import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerPermissionLedger import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp import com.vitorpamplona.amethyst.commons.favorites.FavoriteAppIcon import com.vitorpamplona.amethyst.commons.icons.symbols.Icon @@ -69,10 +79,6 @@ import com.vitorpamplona.amethyst.commons.napplet.NappletCapability import com.vitorpamplona.amethyst.commons.napplet.NappletIdentity import com.vitorpamplona.amethyst.commons.napplet.permissions.GrantState import com.vitorpamplona.amethyst.commons.napplet.permissions.NappletPermissionLedger -import com.vitorpamplona.amethyst.commons.napplet.signers.AppSignerPolicy -import com.vitorpamplona.amethyst.commons.napplet.signers.NostrOpDecision -import com.vitorpamplona.amethyst.commons.napplet.signers.NostrSignerOp -import com.vitorpamplona.amethyst.commons.napplet.signers.NostrSignerPermissionLedger import com.vitorpamplona.amethyst.favorites.BrowserIconRegistry import com.vitorpamplona.amethyst.favorites.rememberManifestIconModel import com.vitorpamplona.amethyst.favorites.rememberWebAppIconModel @@ -82,6 +88,15 @@ import com.vitorpamplona.amethyst.napplet.resolveNappletMeta import com.vitorpamplona.amethyst.ui.navigation.navs.INav import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.nip46.Nip46ActivityCard +import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.nip46.Nip46AppIcon +import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.nip46.Nip46LiveStatus +import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.nip46.Nip46ReconnectPill +import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.nip46.Nip46StatusDot +import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.nip46.nip46AppOnline +import com.vitorpamplona.amethyst.ui.screen.loggedIn.settings.nip46.nip46ClientSubtitle +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -132,8 +147,31 @@ fun ConnectedAppDetailScreen( ) } + // NIP-46 remote clients are tailored: their self-declared metadata (name/url) heads the screen and + // their serviced-request history is shown, instead of the napplet manifest path this coordinate can't + // be resolved through. `null` for napplet/browser coordinates, which keep the generic rendering. + val nip46Client = remember(coordinate) { Nip46PermissionAuthorizer.clientPubKeyOf(coordinate) } + var nip46Info by remember(coordinate) { mutableStateOf(null) } + LaunchedEffect(coordinate) { + if (nip46Client != null) { + nip46Info = withContext(Dispatchers.Default) { Amethyst.instance.nip46ClientStore.load(coordinate) } + } + } + val allActivity by accountViewModel.account.nip46Signer.activityLog.entries + .collectAsStateWithLifecycle() + val nip46Activity = remember(allActivity, nip46Client) { allActivity.filter { nip46Client != null && it.clientPubKey == nip46Client } } + val nip46Title = nip46Info?.name?.ifBlank { null } ?: stringResource(R.string.nip46_signer_remote_app) + Scaffold( - topBar = { TopBarWithBackButton(state?.title ?: coordinate.substringAfter(':', "").ifBlank { coordinate.take(12) + "…" }, nav) }, + topBar = { + val title = + if (nip46Client != null) { + nip46Title + } else { + state?.title ?: coordinate.substringAfter(':', "").ifBlank { coordinate.take(12) + "…" } + } + TopBarWithBackButton(title, nav) + }, ) { padding -> val current = state if (current == null) { @@ -153,7 +191,27 @@ fun ConnectedAppDetailScreen( verticalArrangement = Arrangement.spacedBy(16.dp), ) { // App identity header - AppIdentityHeader(current) + if (nip46Client != null) { + Nip46AppHeader(title = nip46Title, url = nip46Info?.url, image = nip46Info?.image, clientPubKey = nip46Client) + } else { + AppIdentityHeader(current) + } + + // Relays this remote client is reached on — the whole reason it costs a background + // connection, so surface them (with live status) for debugging relay footprint. + if (nip46Client != null) { + val connectedRelays by accountViewModel.account.client + .connectedRelaysFlow() + .collectAsStateWithLifecycle() + val inboxRelays by accountViewModel.account.nip46Signer.inboxRelays + .collectAsStateWithLifecycle() + Nip46RelaysSection( + relays = nip46Info?.relays.orEmpty(), + inboxRelays = inboxRelays, + connectedRelays = connectedRelays, + onReconnect = { accountViewModel.account.client.reconnect(ignoreRetryDelays = true) }, + ) + } // Signing trust level section if (current.signerPolicy != null) { @@ -216,12 +274,25 @@ fun ConnectedAppDetailScreen( } } + // Recent activity (NIP-46 clients only) + if (nip46Client != null && nip46Activity.isNotEmpty()) { + SectionHeader(stringResource(R.string.nip46_signer_activity_title)) + Nip46ActivityCard(nip46Activity) + } + // Forget button Spacer(Modifier.size(8.dp)) Button( onClick = { mutate { - signerLedger.revokeAll(coordinate) + val nip46Client = Nip46PermissionAuthorizer.clientPubKeyOf(coordinate) + if (nip46Client != null) { + // Route NIP-46 clients through the host so the client store + the running + // listen set are cleared too, not just the permission ledger. + accountViewModel.account.nip46Signer.forgetClient(nip46Client) + } else { + signerLedger.revokeAll(coordinate) + } capabilityLedger.revokeAll(identity) } nav.popBack() @@ -237,6 +308,119 @@ fun ConnectedAppDetailScreen( } } +/** + * Lists the relays a NIP-46 remote client is reached on, each with a live status dot. When the client + * brought its own relays (the `nostrconnect://` flow) each one is a background connection Amethyst + * keeps open while the app stays connected — exactly what a user debugging relay footprint wants to + * see, including which are actually up right now. An empty set means the client talks over the + * account's inbox relays (the bunker flow), so it adds no extra connection. + */ +@Composable +private fun Nip46RelaysSection( + relays: Set, + inboxRelays: Set, + connectedRelays: Set, + onReconnect: () -> Unit, +) { + val context = LocalContext.current + val anyOffline = + remember(relays, inboxRelays, connectedRelays) { + if (relays.isEmpty()) { + nip46AppOnline(emptySet(), inboxRelays, connectedRelays) == false + } else { + relays.any { RelayUrlNormalizer.normalizeOrNull(it)?.let { r -> r !in connectedRelays } ?: true } + } + } + Row(verticalAlignment = Alignment.CenterVertically) { + Box(Modifier.weight(1f)) { SectionHeader(stringResource(R.string.nip46_signer_app_relays_title)) } + if (anyOffline) { + Nip46ReconnectPill { + onReconnect() + Toast.makeText(context, R.string.nip46_signer_reconnecting, Toast.LENGTH_SHORT).show() + } + } + } + Surface( + color = MaterialTheme.colorScheme.surfaceVariant, + shape = MaterialTheme.shapes.medium, + modifier = Modifier.fillMaxWidth(), + ) { + Column(modifier = Modifier.padding(vertical = 12.dp, horizontal = 16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + if (relays.isEmpty()) { + // Bunker-flow app: rides the inbox relays. Show the overall inbox liveness so the row + // still reflects whether the signer can be reached at all. + val online = nip46AppOnline(emptySet(), inboxRelays, connectedRelays) + Text( + stringResource(R.string.nip46_signer_app_relays_inbox), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + online?.let { Nip46LiveStatus(it) } + } else { + relays.forEach { relay -> + val relayOnline = + remember(relay, connectedRelays) { + RelayUrlNormalizer.normalizeOrNull(relay)?.let { it in connectedRelays } ?: false + } + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) { + Nip46StatusDot(relayOnline) + Text( + relay, + style = MaterialTheme.typography.bodyMedium, + fontFamily = FontFamily.Monospace, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + Text( + stringResource(R.string.nip46_signer_app_relays_own_hint), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } +} + +@Composable +private fun Nip46AppHeader( + title: String, + url: String?, + image: String?, + clientPubKey: String, +) { + Surface( + color = MaterialTheme.colorScheme.surfaceVariant, + shape = MaterialTheme.shapes.large, + modifier = Modifier.fillMaxWidth(), + ) { + Row( + modifier = Modifier.padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Nip46AppIcon(image, Modifier.size(48.dp)) + Column(modifier = Modifier.weight(1f)) { + Text(title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, maxLines = 1, overflow = TextOverflow.Ellipsis) + Text( + nip46ClientSubtitle(url, clientPubKey), + style = MaterialTheme.typography.bodySmall, + fontFamily = FontFamily.Monospace, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + stringResource(R.string.nip46_signer_remote_app), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } +} + @Composable private fun AppIdentityHeader(state: ConnectedAppDetailState) { Surface( @@ -314,7 +498,7 @@ private fun PolicyPicker( Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { PolicyCard( selected = selected == AppSignerPolicy.FULL_TRUST, - symbol = MaterialSymbols.Favorite, + symbol = MaterialSymbols.LockOpen, label = stringResource(R.string.napplet_policy_full_trust), description = stringResource(R.string.napplet_policy_full_trust_desc), onClick = { onSelect(AppSignerPolicy.FULL_TRUST) }, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppsScreen.kt index e32255cd30..2768f983b3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppsScreen.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/ConnectedAppsScreen.kt @@ -55,13 +55,14 @@ import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.vitorpamplona.amethyst.Amethyst import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.connectedApps.nip46.Nip46PermissionAuthorizer +import com.vitorpamplona.amethyst.commons.connectedApps.signers.AppSignerPolicy +import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerPermissionLedger import com.vitorpamplona.amethyst.commons.favorites.FavoriteApp import com.vitorpamplona.amethyst.commons.favorites.FavoriteAppIcon import com.vitorpamplona.amethyst.commons.icons.symbols.Icon import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols import com.vitorpamplona.amethyst.commons.napplet.permissions.NappletPermissionLedger -import com.vitorpamplona.amethyst.commons.napplet.signers.AppSignerPolicy -import com.vitorpamplona.amethyst.commons.napplet.signers.NostrSignerPermissionLedger import com.vitorpamplona.amethyst.favorites.rememberManifestIconModel import com.vitorpamplona.amethyst.favorites.rememberWebAppIconModel import com.vitorpamplona.amethyst.model.LocalCache @@ -107,11 +108,12 @@ fun ConnectedAppsScreen( loadConnectedApps(capabilityLedger, signerLedger) } items = initial - // Only include real pubkeys (not the "browser" sentinel) in the relay subscription. + // Only include real napplet authors in the manifest subscription — skip the "browser" + // sentinel and NIP-46 remote-signer clients (whose author segment is the "nip46" prefix). nappletAuthors = initial .map { it.coordinate.substringBefore(':') } - .filter { it != BROWSER_AUTHOR } + .filter { it != BROWSER_AUTHOR && it != Nip46PermissionAuthorizer.COORDINATE_PREFIX } .toSet() } @@ -201,11 +203,12 @@ private fun ConnectedAppCard( onClick: () -> Unit, ) { val author = remember(entry.coordinate) { entry.coordinate.substringBefore(':') } - if (author == BROWSER_AUTHOR) { - val url = remember(entry.coordinate) { entry.coordinate.substringAfter(':', "") } - BrowserAppCard(url = url, entry = entry, onClick = onClick) - } else { - NappletAppCard(author = author, entry = entry, untitled = untitled, onClick = onClick) + when { + author == BROWSER_AUTHOR -> { + val url = remember(entry.coordinate) { entry.coordinate.substringAfter(':', "") } + BrowserAppCard(url = url, entry = entry, onClick = onClick) + } + else -> NappletAppCard(author = author, entry = entry, untitled = untitled, onClick = onClick) } } @@ -379,6 +382,10 @@ private suspend fun loadConnectedApps( val signerPolicies = signerLedger.store.allPolicies() val allCoordinates = (capGrants.keys + signerPolicies.keys).toSet() return allCoordinates + // NIP-46 remote-signer clients have their own screen (Nip46ConnectedAppsScreen) because, + // unlike napplets/browser origins, each holds a live background relay subscription that + // needs managing. Exclude them here so this screen stays napplet/nsite/browser only. + .filter { coordinate -> coordinate.substringBefore(':') != Nip46PermissionAuthorizer.COORDINATE_PREFIX } .map { coordinate -> ConnectedAppEntry( coordinate = coordinate, diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/nip46/Nip46ActivityUi.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/nip46/Nip46ActivityUi.kt new file mode 100644 index 0000000000..70319cbfa0 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/nip46/Nip46ActivityUi.kt @@ -0,0 +1,116 @@ +/* + * 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.amethyst.ui.screen.loggedIn.settings.nip46 + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.model.nip46Signer.Nip46ActivityEntry +import com.vitorpamplona.amethyst.ui.note.elements.TimeAgo + +private val LiveGreen = Color(0xFF3DDC84) + +/** A card listing the most recent [entries] a NIP-46 signer serviced (newest first). */ +@Composable +fun Nip46ActivityCard( + entries: List, + max: Int = 8, +) { + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), + ) { + Column(modifier = Modifier.padding(vertical = 4.dp)) { + entries.take(max).forEach { Nip46ActivityRow(it) } + } + } +} + +@Composable +private fun Nip46ActivityRow(entry: Nip46ActivityEntry) { + Row( + modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + Box( + modifier = + Modifier + .size(8.dp) + .clip(CircleShape) + .background(if (entry.ok) LiveGreen else MaterialTheme.colorScheme.error), + ) + Column(modifier = Modifier.weight(1f)) { + Text( + describeNip46Activity(entry), + style = MaterialTheme.typography.bodyMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + entry.clientPubKey.take(12) + "…", + style = MaterialTheme.typography.labelSmall, + fontFamily = FontFamily.Monospace, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + TimeAgo(entry.atSeconds) + } +} + +/** A friendly, localized one-liner for a serviced request (e.g. "Signed an event (kind 1)"). */ +@Composable +fun describeNip46Activity(entry: Nip46ActivityEntry): String { + val base = + when (entry.method) { + "sign_event" -> stringResource(R.string.nip46_signer_act_signed_kind, entry.kind ?: 0) + "nip04_encrypt", "nip44_encrypt" -> stringResource(R.string.nip46_signer_act_encrypted) + "nip04_decrypt", "nip44_decrypt" -> stringResource(R.string.nip46_signer_act_decrypted) + "get_public_key" -> stringResource(R.string.nip46_signer_act_shared_pubkey) + "connect" -> stringResource(R.string.nip46_signer_act_connected) + "ping" -> stringResource(R.string.nip46_signer_act_ping) + "get_relays" -> stringResource(R.string.nip46_signer_act_listed_relays) + else -> stringResource(R.string.nip46_signer_act_other, entry.method) + } + return if (entry.ok) base else "$base · ${stringResource(R.string.nip46_signer_activity_denied)}" +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/nip46/Nip46ConnectedAppsScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/nip46/Nip46ConnectedAppsScreen.kt new file mode 100644 index 0000000000..fa3cdc90a7 --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/nip46/Nip46ConnectedAppsScreen.kt @@ -0,0 +1,410 @@ +/* + * 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.amethyst.ui.screen.loggedIn.settings.nip46 + +import android.widget.Toast +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.SuggestionChip +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import coil3.compose.AsyncImage +import com.vitorpamplona.amethyst.Amethyst +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.connectedApps.nip46.Nip46ClientInfo +import com.vitorpamplona.amethyst.commons.connectedApps.nip46.Nip46PermissionAuthorizer +import com.vitorpamplona.amethyst.commons.connectedApps.signers.AppSignerPolicy +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.commons.icons.symbols.rememberMaterialSymbolPainter +import com.vitorpamplona.amethyst.commons.util.toTimeAgo +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route +import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip19Bech32.entities.NPub +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +/** One connected NIP-46 remote-signer client, with everything the row needs to render. */ +private data class Nip46AppEntry( + val coordinate: String, + val clientPubKey: HexKey, + val policy: AppSignerPolicy?, + val info: Nip46ClientInfo?, + val lastUsedSeconds: Long?, +) + +/** + * The remote-signer clients connected to this account. Kept separate from the shared Connected Apps + * screen because — unlike napplets/nsites/browser origins — each NIP-46 app can carry its own relays + * (from a `nostrconnect://` offer), which the signer subscribes to in the background for as long as + * the app stays connected. Surfacing them here, with their relay footprint and last-used time, lets + * the user prune the background relay connections they no longer need (idle apps are also auto-forgotten + * after a week; see `Nip46SignerState.IDLE_PRUNE_SECONDS`). + * + * Tapping a row opens the shared [Route.ConnectedAppDetail], which already renders NIP-46 clients + * (history + Forget). + */ +@Composable +fun Nip46ConnectedAppsScreen( + accountViewModel: AccountViewModel, + nav: INav, +) { + val account = accountViewModel.account + val signerPubKey = remember { account.signer.pubKey } + val context = LocalContext.current + + // Live relay state so each app shows whether the signer currently reaches it. An app that brought + // its own relays (nostrconnect) is judged on those; a bunker-flow app rides the inbox relays. + val connectedRelays by account.client.connectedRelaysFlow().collectAsStateWithLifecycle() + val inboxRelays by account.nip46Signer.inboxRelays.collectAsStateWithLifecycle() + + var items by remember { mutableStateOf?>(null) } + // Bumped on resume so a Forget performed on the detail screen is reflected when we return. + var refreshKey by remember { mutableIntStateOf(0) } + + LaunchedEffect(refreshKey) { + items = withContext(Dispatchers.Default) { loadNip46Apps(signerPubKey) } + } + + Scaffold( + topBar = { TopBarWithBackButton(stringResource(R.string.nip46_signer_manage_apps), nav) }, + ) { padding -> + val current = items + when { + current == null -> + Box(Modifier.fillMaxSize().padding(padding), contentAlignment = Alignment.Center) { + CircularProgressIndicator() + } + + current.isEmpty() -> + Box(Modifier.fillMaxSize().padding(padding).padding(32.dp), contentAlignment = Alignment.Center) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Icon( + MaterialSymbols.Key, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(56.dp), + ) + Text( + stringResource(R.string.nip46_signer_apps_empty), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + } + } + + else -> + LazyColumn( + modifier = Modifier.fillMaxSize().padding(padding), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + items(current, key = { it.coordinate }) { entry -> + val online = + remember(entry.info?.relays, inboxRelays, connectedRelays) { + nip46AppOnline(entry.info?.relays.orEmpty(), inboxRelays, connectedRelays) + } + Nip46AppCard( + entry = entry, + online = online, + onReconnect = { + account.client.reconnect(ignoreRetryDelays = true) + Toast.makeText(context, R.string.nip46_signer_reconnecting, Toast.LENGTH_SHORT).show() + }, + onClick = { nav.nav(Route.ConnectedAppDetail(entry.coordinate)) }, + ) + } + } + } + } +} + +@Composable +private fun Nip46AppCard( + entry: Nip46AppEntry, + online: Boolean?, + onReconnect: () -> Unit, + onClick: () -> Unit, +) { + // Same identity line the detail screen (Nip46AppHeader) uses: the app's self-declared website + // when it has one, npub otherwise — so a row and its detail never disagree. + val subtitle = remember(entry.info?.url, entry.clientPubKey) { nip46ClientSubtitle(entry.info?.url, entry.clientPubKey) } + val title = entry.info?.name?.ifBlank { null } ?: stringResource(R.string.nip46_signer_remote_app) + val relayCount = entry.info?.relays?.size ?: 0 + + Card( + modifier = Modifier.fillMaxWidth().clickable(onClick = onClick), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), + ) { + Row( + modifier = Modifier.padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Nip46AppIcon(entry.info?.image, Modifier.size(48.dp)) + Column( + modifier = Modifier.weight(1f), + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + Text( + title, + style = MaterialTheme.typography.titleSmall, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + subtitle, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + fontFamily = FontFamily.Monospace, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + val meta = + buildList { + if (relayCount > 0) add(pluralStringResource(R.plurals.nip46_signer_app_relay_count, relayCount, relayCount)) + entry.lastUsedSeconds?.let { add(stringResource(R.string.nip46_signer_app_last_used, it.toTimeAgo().trim())) } + }.joinToString(" · ") + if (meta.isNotEmpty()) { + Text( + meta, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + online?.let { Nip46LiveStatus(it) } + } + Column( + horizontalAlignment = Alignment.End, + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + entry.policy?.let { policy -> + SuggestionChip( + onClick = {}, + label = { Text(policy.shortLabel(), style = MaterialTheme.typography.labelSmall) }, + ) + } + if (online == false) { + Nip46ReconnectPill(onReconnect) + } else { + Icon( + MaterialSymbols.ChevronRight, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(20.dp), + ) + } + } + } + } +} + +@Composable +private fun AppSignerPolicy.shortLabel(): String = + when (this) { + AppSignerPolicy.FULL_TRUST -> stringResource(R.string.napplet_policy_full_trust) + AppSignerPolicy.REASONABLE -> stringResource(R.string.napplet_policy_reasonable) + AppSignerPolicy.PARANOID -> stringResource(R.string.napplet_policy_paranoid) + } + +private val LiveGreen = Color(0xFF3DDC84) + +/** A colored dot: green when the signer currently reaches the relay(s), muted otherwise. */ +@Composable +internal fun Nip46StatusDot(online: Boolean) { + Box(Modifier.size(8.dp).clip(CircleShape).background(if (online) LiveGreen else MaterialTheme.colorScheme.outline)) +} + +/** + * A small "Reconnect" pill shown when an app's relays are offline. Forces the whole relay pool to + * re-dial now (ignoring backoff), which re-establishes the offline relays. Consumes its own tap so a + * pill inside a clickable card doesn't also open the card. + */ +@Composable +internal fun Nip46ReconnectPill(onClick: () -> Unit) { + Text( + stringResource(R.string.nip46_signer_app_reconnect), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.primary, + modifier = + Modifier + .clip(RoundedCornerShape(999.dp)) + .border(1.dp, MaterialTheme.colorScheme.outline, RoundedCornerShape(999.dp)) + .clickable(onClick = onClick) + .padding(horizontal = 11.dp, vertical = 4.dp), + ) +} + +/** A [Nip46StatusDot] + Connected/Offline label showing whether the signer currently reaches an app's relays. */ +@Composable +internal fun Nip46LiveStatus(online: Boolean) { + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) { + Nip46StatusDot(online) + Text( + stringResource(if (online) R.string.nip46_signer_app_online else R.string.nip46_signer_app_offline), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } +} + +/** + * Whether the signer currently reaches [appRelays] (an app's own `nostrconnect://` relays). An app + * that brought no relays of its own rides the account [inboxRelays], so it's judged on those. Returns + * `null` when neither resolves to any relay (nothing to show a status for). "Online" means at least + * one of the app's relays is in [connectedRelays]. + */ +internal fun nip46AppOnline( + appRelays: Set, + inboxRelays: Set, + connectedRelays: Set, +): Boolean? { + val effective = appRelays.mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) }.toSet().ifEmpty { inboxRelays } + if (effective.isEmpty()) return null + return effective.any { it in connectedRelays } +} + +/** + * The avatar for a NIP-46 client: its self-declared [image] (the app/site icon it advertised in its + * connect metadata) drawn in a circle, falling back to the Key glyph when it has none or the image + * fails to load. Shared by the list card and the detail header. We only render an icon the app itself + * provided — we never fetch a site favicon from the main app, which would bypass Tor and leak the + * user's IP (see BrowserIconRegistry). + */ +@Composable +internal fun Nip46AppIcon( + image: String?, + modifier: Modifier = Modifier, +) { + val model = image?.takeIf { it.isNotBlank() } + Box( + modifier = modifier.clip(CircleShape).background(MaterialTheme.colorScheme.primaryContainer), + contentAlignment = Alignment.Center, + ) { + if (model == null) { + Icon( + MaterialSymbols.Key, + contentDescription = null, + tint = MaterialTheme.colorScheme.onPrimaryContainer, + modifier = Modifier.size(24.dp), + ) + } else { + val glyph = rememberMaterialSymbolPainter(MaterialSymbols.Key, MaterialTheme.colorScheme.onPrimaryContainer) + AsyncImage( + model = model, + contentDescription = null, + modifier = Modifier.fillMaxSize().clip(CircleShape), + contentScale = ContentScale.Crop, + placeholder = glyph, + error = glyph, + fallback = glyph, + ) + } + } +} + +/** + * The identity line shown for a NIP-46 client: its self-declared website (host only) when it + * advertised one, otherwise its npub. Shared by the list card and the detail header so a row and + * the screen it opens always agree. + */ +internal fun nip46ClientSubtitle( + url: String?, + clientPubKey: HexKey, +): String { + val host = + url + ?.ifBlank { null } + ?.removePrefix("https://") + ?.removePrefix("http://") + ?.substringBefore('/') + ?.ifBlank { null } + return host ?: runCatching { NPub.create(clientPubKey) }.getOrDefault(clientPubKey.take(12) + "…") +} + +private suspend fun loadNip46Apps(signerPubKey: HexKey): List { + val store = Amethyst.instance.signerPermissionStore + val clientStore = Amethyst.instance.nip46ClientStore + // allPolicies() already carries each app's policy — read it from the map instead of a second + // loadPolicy() call per app. + return store + .allPolicies() + .filterKeys { Nip46PermissionAuthorizer.belongsTo(it, signerPubKey) } + .mapNotNull { (coordinate, policy) -> + val clientPubKey = Nip46PermissionAuthorizer.clientPubKeyOf(coordinate) ?: return@mapNotNull null + Nip46AppEntry( + coordinate = coordinate, + clientPubKey = clientPubKey, + policy = policy, + info = clientStore.load(coordinate), + lastUsedSeconds = store.loadLastUsed(coordinate), + ) + }.sortedByDescending { it.lastUsedSeconds ?: 0L } +} diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/nip46/Nip46SignerScreen.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/nip46/Nip46SignerScreen.kt new file mode 100644 index 0000000000..54e211f4db --- /dev/null +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/settings/nip46/Nip46SignerScreen.kt @@ -0,0 +1,598 @@ +/* + * 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.amethyst.ui.screen.loggedIn.settings.nip46 + +import android.content.Context +import android.widget.Toast +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.vitorpamplona.amethyst.R +import com.vitorpamplona.amethyst.commons.connectedApps.nip46.Nip46PermissionAuthorizer +import com.vitorpamplona.amethyst.commons.icons.symbols.Icon +import com.vitorpamplona.amethyst.commons.icons.symbols.MaterialSymbols +import com.vitorpamplona.amethyst.model.nip46Signer.Nip46ActivityEntry +import com.vitorpamplona.amethyst.model.nip46Signer.Nip46SignerState +import com.vitorpamplona.amethyst.ui.navigation.navs.INav +import com.vitorpamplona.amethyst.ui.navigation.routes.Route +import com.vitorpamplona.amethyst.ui.navigation.topbars.TopBarWithBackButton +import com.vitorpamplona.amethyst.ui.screen.loggedIn.AccountViewModel +import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.QrCodeDrawer +import com.vitorpamplona.amethyst.ui.screen.loggedIn.qrcode.SimpleQrCodeScanner +import kotlinx.coroutines.launch + +private val LiveGreen = Color(0xFF3DDC84) + +@Composable +fun Nip46SignerScreen( + accountViewModel: AccountViewModel, + nav: INav, + connectUri: String? = null, +) { + val account = accountViewModel.account + val signer = account.nip46Signer + val scope = rememberCoroutineScope() + val context = LocalContext.current + val clipboard = LocalClipboardManager.current + + val enabled by account.settings.nip46SignerEnabled.collectAsStateWithLifecycle() + val secret by account.settings.nip46BunkerSecret.collectAsStateWithLifecycle() + val relays by signer.listeningRelays.collectAsStateWithLifecycle() + val connectedRelays by account.client.connectedRelaysFlow().collectAsStateWithLifecycle() + val liveRelayCount = remember(relays, connectedRelays) { relays.count { it in connectedRelays } } + val activity by signer.activityLog.entries.collectAsStateWithLifecycle() + val writeable = remember { account.signer.isWriteable() } + + var connectedCount by remember { mutableIntStateOf(0) } + var refreshKey by remember { mutableIntStateOf(0) } + var scanning by remember { mutableStateOf(false) } + var confirmRotate by remember { mutableStateOf(false) } + + var bunkerUri by remember { mutableStateOf(null) } + LaunchedEffect(enabled, secret, relays) { + bunkerUri = if (enabled && writeable && relays.isNotEmpty()) signer.bunkerUri() else null + } + LaunchedEffect(enabled, refreshKey) { + connectedCount = + account.signerPermissionLedger.store + .allPolicies() + .keys + .count { Nip46PermissionAuthorizer.belongsTo(it, account.signer.pubKey) } + } + + fun onConnect(uri: String) { + scope.launch { + val result = signer.connectViaNostrConnect(uri.trim()) + Toast.makeText(context, describe(context, result), Toast.LENGTH_LONG).show() + refreshKey++ + } + } + + // Opened from a scanned/shared nostrconnect:// offer — pair that app on open (this also enables the signer). + LaunchedEffect(connectUri) { + if (!connectUri.isNullOrBlank()) onConnect(connectUri) + } + + if (scanning) { + SimpleQrCodeScanner { contents -> + scanning = false + contents?.let { onConnect(it) } + } + } + + Scaffold( + topBar = { TopBarWithBackButton(stringResource(R.string.nip46_signer_title), nav) }, + ) { padding -> + Column( + modifier = + Modifier + .fillMaxSize() + .padding(padding) + .verticalScroll(rememberScrollState()) + .padding(horizontal = 20.dp, vertical = 16.dp), + verticalArrangement = Arrangement.spacedBy(18.dp), + ) { + if (!writeable) { + ReadOnlyNotice() + return@Column + } + + if (enabled) { + LiveStatusCard( + relayCount = relays.size, + liveRelayCount = liveRelayCount, + connectedCount = connectedCount, + onToggleOff = { signer.setEnabled(false) }, + ) + + if (relays.isEmpty()) { + WarningCard(stringResource(R.string.nip46_signer_status_no_relays)) + } + + bunkerUri?.let { uri -> + QrHeroCard( + uri = uri, + onCopy = { + clipboard.setText(AnnotatedString(uri)) + Toast.makeText(context, R.string.nip46_signer_copied, Toast.LENGTH_SHORT).show() + }, + onRegenerate = { confirmRotate = true }, + ) + } + } else { + DisabledHero(onEnable = { signer.setEnabled(true) }) + } + + // The Connect section stays reachable even while off: scanning an app's nostrconnect:// + // code pairs it and enables the signer as part of connecting (no separate "enable" step). + ConnectSection( + onScan = { scanning = true }, + onPaste = { onConnect(it) }, + ) + + if (enabled || connectedCount > 0) { + ConnectedAppsRow( + count = connectedCount, + onClick = { nav.nav(Route.Nip46ConnectedApps) }, + ) + } + + if (enabled && activity.isNotEmpty()) { + ActivitySection(activity) + } + + if (enabled) { + Text( + stringResource(R.string.nip46_signer_background_hint), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + + if (confirmRotate) { + RotateAddressDialog( + onConfirm = { + confirmRotate = false + signer.rotateAddress() + Toast.makeText(context, R.string.nip46_signer_regenerated, Toast.LENGTH_SHORT).show() + }, + onDismiss = { confirmRotate = false }, + ) + } +} + +@Composable +private fun RotateAddressDialog( + onConfirm: () -> Unit, + onDismiss: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + icon = { Icon(MaterialSymbols.Refresh, contentDescription = null, modifier = Modifier.size(24.dp)) }, + title = { Text(stringResource(R.string.nip46_signer_rotate_confirm_title)) }, + text = { Text(stringResource(R.string.nip46_signer_rotate_confirm_message)) }, + confirmButton = { + TextButton(onClick = onConfirm) { + Text(stringResource(R.string.nip46_signer_rotate_confirm_button)) + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text(stringResource(R.string.nip46_signer_cancel)) + } + }, + ) +} + +// ---------------------------------------------------------------------------- + +@Composable +private fun DisabledHero(onEnable: () -> Unit) { + Column( + modifier = + Modifier + .fillMaxWidth() + .padding(top = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(20.dp), + ) { + Box( + modifier = + Modifier + .size(104.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.primaryContainer), + contentAlignment = Alignment.Center, + ) { + Icon( + MaterialSymbols.Key, + contentDescription = null, + tint = MaterialTheme.colorScheme.onPrimaryContainer, + modifier = Modifier.size(52.dp), + ) + } + Text( + stringResource(R.string.nip46_signer_hero_title), + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + textAlign = TextAlign.Center, + ) + Text( + stringResource(R.string.nip46_signer_explainer), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + Button( + onClick = onEnable, + modifier = + Modifier + .fillMaxWidth() + .height(56.dp), + shape = RoundedCornerShape(16.dp), + ) { + Icon(MaterialSymbols.Key, contentDescription = null, modifier = Modifier.size(20.dp)) + Spacer(Modifier.width(8.dp)) + Text(stringResource(R.string.nip46_signer_turn_on), fontWeight = FontWeight.SemiBold) + } + } +} + +@Composable +private fun LiveStatusCard( + relayCount: Int, + liveRelayCount: Int, + connectedCount: Int, + onToggleOff: () -> Unit, +) { + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(20.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer), + ) { + Row( + modifier = Modifier.padding(18.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(14.dp), + ) { + LiveDot() + Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) { + Text( + stringResource(R.string.nip46_signer_live), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onPrimaryContainer, + ) + val relayStatus = + if (liveRelayCount < relayCount) { + stringResource(R.string.nip46_signer_relays_some_down, liveRelayCount, relayCount) + } else { + pluralStringResource(R.plurals.nip46_signer_relays_all_live, relayCount, relayCount) + } + Text( + buildString { + append(pluralStringResource(R.plurals.nip46_signer_connected_count, connectedCount, connectedCount)) + append(" · ") + append(relayStatus) + }, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.8f), + ) + } + Switch(checked = true, onCheckedChange = { onToggleOff() }) + } + } +} + +@Composable +private fun LiveDot() { + val transition = rememberInfiniteTransition(label = "live") + val alpha by transition.animateFloat( + initialValue = 0.25f, + targetValue = 1f, + animationSpec = infiniteRepeatable(tween(900), RepeatMode.Reverse), + label = "pulse", + ) + Box(contentAlignment = Alignment.Center, modifier = Modifier.size(18.dp)) { + Canvas(Modifier.size(18.dp)) { drawCircle(color = LiveGreen, alpha = alpha * 0.35f) } + Canvas(Modifier.size(9.dp)) { drawCircle(color = LiveGreen) } + } +} + +@Composable +private fun QrHeroCard( + uri: String, + onCopy: () -> Unit, + onRegenerate: () -> Unit, +) { + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(24.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), + ) { + Column( + modifier = Modifier.padding(20.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + Surface( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(20.dp), + color = Color.White, + ) { + QrCodeDrawer( + contents = uri, + modifier = + Modifier + .padding(16.dp) + .fillMaxWidth() + .aspectRatio(1f), + ) + } + Text( + stringResource(R.string.nip46_signer_scan_caption), + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + textAlign = TextAlign.Center, + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + FilledTonalButton(onClick = onCopy, modifier = Modifier.weight(1f)) { + Icon(MaterialSymbols.ContentCopy, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(6.dp)) + Text(stringResource(R.string.nip46_signer_copy)) + } + FilledTonalButton(onClick = onRegenerate, modifier = Modifier.weight(1f)) { + Icon(MaterialSymbols.Refresh, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(6.dp)) + Text(stringResource(R.string.nip46_signer_regenerate)) + } + } + } + } +} + +@Composable +private fun ConnectSection( + onScan: () -> Unit, + onPaste: (String) -> Unit, +) { + var showPaste by remember { mutableStateOf(false) } + var input by remember { mutableStateOf("") } + + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + Text( + stringResource(R.string.nip46_signer_connect_label), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + ) + Button( + onClick = onScan, + modifier = + Modifier + .fillMaxWidth() + .height(54.dp), + shape = RoundedCornerShape(16.dp), + ) { + Icon(MaterialSymbols.CameraAlt, contentDescription = null, modifier = Modifier.size(20.dp)) + Spacer(Modifier.width(8.dp)) + Text(stringResource(R.string.nip46_signer_scan_connect), fontWeight = FontWeight.SemiBold) + } + + TextButton(onClick = { showPaste = !showPaste }, modifier = Modifier.fillMaxWidth()) { + Text(stringResource(R.string.nip46_signer_paste_link)) + } + + AnimatedVisibility(visible = showPaste) { + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + OutlinedTextField( + value = input, + onValueChange = { input = it }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + shape = RoundedCornerShape(14.dp), + placeholder = { Text(stringResource(R.string.nip46_signer_connect_hint)) }, + ) + Button( + onClick = { + if (input.isNotBlank()) { + onPaste(input) + input = "" + } + }, + modifier = Modifier.fillMaxWidth(), + enabled = input.isNotBlank(), + shape = RoundedCornerShape(14.dp), + colors = ButtonDefaults.filledTonalButtonColors(), + ) { + Text(stringResource(R.string.nip46_signer_connect_button)) + } + } + } + } +} + +@Composable +private fun ConnectedAppsRow( + count: Int, + onClick: () -> Unit, +) { + Card( + modifier = Modifier.fillMaxWidth().clickable(onClick = onClick), + shape = RoundedCornerShape(18.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), + ) { + Row( + modifier = Modifier.padding(16.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(14.dp), + ) { + Icon( + MaterialSymbols.Apps, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.size(28.dp), + ) + Column(modifier = Modifier.weight(1f)) { + Text( + stringResource(R.string.nip46_signer_manage_apps), + style = MaterialTheme.typography.titleSmall, + ) + Text( + pluralStringResource(R.plurals.nip46_signer_connected_count, count, count), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Icon( + MaterialSymbols.ChevronRight, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.size(22.dp), + ) + } + } +} + +@Composable +private fun ActivitySection(entries: List) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text( + stringResource(R.string.nip46_signer_activity_title), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold, + ) + Nip46ActivityCard(entries) + } +} + +@Composable +private fun WarningCard(message: String) { + Card( + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer), + ) { + Text( + message, + modifier = Modifier.padding(16.dp), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onErrorContainer, + ) + } +} + +@Composable +private fun ReadOnlyNotice() { + Card( + modifier = Modifier.fillMaxWidth().padding(top = 24.dp), + shape = RoundedCornerShape(16.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), + ) { + Row( + modifier = Modifier.padding(18.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon(MaterialSymbols.Key, contentDescription = null, tint = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.size(28.dp)) + Text( + stringResource(R.string.nip46_signer_readonly), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} + +private fun describe( + context: Context, + result: Nip46SignerState.ConnectResult, +): String = + when (result) { + is Nip46SignerState.ConnectResult.Connected -> + result.name?.let { context.getString(R.string.nip46_signer_connected_named, it) } + ?: context.getString(R.string.nip46_signer_connected_ok) + Nip46SignerState.ConnectResult.InvalidUri -> context.getString(R.string.nip46_signer_connect_invalid) + Nip46SignerState.ConnectResult.NoRelays -> context.getString(R.string.nip46_signer_connect_no_relays) + Nip46SignerState.ConnectResult.NotWriteable -> context.getString(R.string.nip46_signer_readonly) + Nip46SignerState.ConnectResult.Declined -> context.getString(R.string.nip46_signer_connect_declined) + is Nip46SignerState.ConnectResult.Failed -> context.getString(R.string.nip46_signer_connect_failed, result.reason) + } diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index d42255f6d0..348b08fecc 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -898,6 +898,95 @@ Ask Deny apps permissions signer napplet nsite webapp trust connected + + + Remote Signer + signer bunker nip46 nostr connect remote sign + Let other apps sign with your key. Amethyst listens on your inbox relays and checks each app\'s permissions before signing — using the same trust levels as Connected Apps. + Act as a remote signer + + Listening on %1$d relay + Listening on %1$d relays + + No inbox relays configured. Add inbox relays so apps can reach your signer. + + Listening on %1$d relay + Listening on %1$d relays, all connected + + %1$d of %2$d relays connected + Your bunker address + Paste this into another app to connect it to your key. + Copy + Bunker address copied + New address + Generated a new bunker address + Generate a new address? + This mints a fresh bunker address and disconnects every app currently connected. Anyone who has the old address — a spammer included — can no longer reach you. Reconnect your own apps by scanning the new code. + Generate + Cancel + Connect an app + Paste a nostrconnect:// link + Connect + Manage connected apps + No apps are connected to your signer yet.\n\nScan or paste an app\'s code to connect it. Connected apps appear here, and idle ones are removed automatically after a week. + used %1$s + Connected + Offline + Reconnect + Reconnecting to relays… + Relays + Signs over your inbox relays — this app brought none of its own, so it keeps no extra background connection. + Amethyst holds a background connection to each of these while this app stays connected. Forget the app to drop them. + + %1$d relay + %1$d relays + + App connected. Amethyst now signs for it in the background. + Connected %1$s. Amethyst now signs for it in the background. + Not a valid nostrconnect:// link + That link carries no relay to connect on + Could not connect: %1$s + Connection declined + This app is requesting: + This is a read-only account and cannot sign. + Remote signer app + Amethyst keeps a background connection (shown as an ongoing notification) so it can answer signing requests while closed. + Sign for other apps + Turn on signer + Live + + %1$d signing request + %1$d signing requests + + Select all + Select none + Remember these for each app + as %1$s + Allow %1$d + Deny %1$d + Recent activity + No requests serviced yet. + denied + Signed an event (kind %1$d) + Encrypted a message + Decrypted a message + Shared your public key + Connected + Ping + Listed relays + %1$s + Signing requests + Full-screen prompts asking you to approve an app that wants Amethyst to sign, encrypt, or decrypt with your key. + Approve a signing request? + An app wants to connect + Tap to review + Scan to connect an app to your key + Scan a code + Paste a link instead + + %1$d connected app + %1$d connected apps + Signing trust level Capabilities Forget this app diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/UriToRouteTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/UriToRouteTest.kt index 798f847409..309e8f338f 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/UriToRouteTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/ui/UriToRouteTest.kt @@ -58,6 +58,12 @@ class UriToRouteTest { assertEquals(Route.Hashtag("foo"), uriToRoute("nostr:hashtag?id=foo", account)) } + @Test + fun nostrConnectOfferRoutesToTheSignerScreenCarryingTheUri() { + val offer = "nostrconnect://" + "b".repeat(64) + "?relay=wss%3A%2F%2Frelay.example.com&secret=abc123" + assertEquals(Route.Nip46Signer(connectUri = offer), uriToRoute(offer, account)) + } + @Test fun fragmentHashtagOrNullExtractsTheTag() { assertEquals("NostrMultiplayerGames", fragmentHashtagOrNull("#NostrMultiplayerGames")) diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt index 8ec28dac1c..5aaba4ff9f 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt @@ -473,13 +473,18 @@ private fun printUsage() { | |Remote signing (NIP-46): | bunker [--relay URL[,URL…]] run a remote signer for this (local-key) account; prints a - | [--secret S] [--timeout SECS] bunker:// uri and signs requests until interrupt/timeout + | [--secret S] [--timeout SECS] bunker:// uri and signs requests until interrupt/timeout. + | [--perms P] [--interactive] --perms sign_event:1,nip44_encrypt,… restricts which ops + | are allowed (rest rejected); --interactive prompts y/N on + | the terminal for anything not pre-allowed (needs a TTY). + | Default (neither): approve everything. | bunker connect NOSTRCONNECT-URI act as signer for a client's nostrconnect:// - | [--timeout SECS] offer (acks + services its requests) + | [--perms P] [--interactive] [--timeout SECS] offer (acks + services; same gating flags) | login bunker://PUBKEY?relay=…&secret=… sign through a remote bunker (mints a local | transport key; the account acts as PUBKEY) | login --nostrconnect [--relay URL[,URL…]] client-initiated: print a nostrconnect:// offer, - | [--name N] [--timeout SECS] wait for a signer to connect, then persist it + | [--name N] [--perms P] [--timeout SECS] wait for a signer to connect, then persist it + | (--perms sign_event:1,nip44_encrypt,… requests ops) | |Relays: `relay NOUN [add|remove|set|clear|list] …` (bare NOUN lists it) | NOUN = outbox|inbox|nip65 (kind:10002) dm (10050) key-package (10051) diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/BunkerCommand.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/BunkerCommand.kt index 12f68685d7..21ace10de9 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/BunkerCommand.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/BunkerCommand.kt @@ -24,40 +24,32 @@ import com.vitorpamplona.amethyst.cli.Args import com.vitorpamplona.amethyst.cli.Context import com.vitorpamplona.amethyst.cli.DataDir import com.vitorpamplona.amethyst.cli.Output -import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.amethyst.commons.connectedApps.nip46.Nip46PermissionAuthorizer +import com.vitorpamplona.amethyst.commons.connectedApps.nip46.Nip46PermissionAuthorizer.Companion.toSignerOp +import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerOp +import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.toHexKey import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair -import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener import com.vitorpamplona.quartz.nip01Core.relay.client.single.newSubId -import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequest import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestConnect -import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestGetPublicKey -import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestGetRelays -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.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.BunkerResponseGetRelays -import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponsePong -import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponsePublicKey import com.vitorpamplona.quartz.nip46RemoteSigner.NostrConnectEvent -import com.vitorpamplona.quartz.nip46RemoteSigner.ReadWrite -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.channels.Channel.Factory.UNLIMITED +import com.vitorpamplona.quartz.nip46RemoteSigner.NostrConnectURI +import com.vitorpamplona.quartz.nip46RemoteSigner.server.BunkerRequestProcessor +import com.vitorpamplona.quartz.nip46RemoteSigner.server.Nip46ConnectDecision +import com.vitorpamplona.quartz.nip46RemoteSigner.server.Nip46RequestAuthorizer +import com.vitorpamplona.quartz.nip46RemoteSigner.server.NostrConnectSignerService +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeoutOrNull /** - * `amy bunker [--relay URL[,URL…]] [--secret S] [--timeout SECS]` + * `amy bunker [--relay URL[,URL…]] [--secret S] [--perms P] [--interactive] [--timeout SECS]` * * Run a NIP-46 remote signer (a "bunker") for the active LOCAL account * (nak's `bunker`). Prints a `bunker://…` connection string, then listens on @@ -69,11 +61,83 @@ import kotlinx.coroutines.withTimeoutOrNull * remotely through this bunker. Long-running — stops at `--timeout` SECS or on * interrupt. * - * Thin assembly only: every request/response type + the encrypted wrapper - * live in quartz (`BunkerRequest*`, `BunkerResponse*`, `NostrConnectEvent`); - * this file dispatches to `ctx.signer`. + * By default every request is approved (the CLI bunker hosts the operator's own + * key — the pairing secret is the gate). Two opt-in gates narrow that: + * - `--perms sign_event:1,nip44_encrypt,…` restricts the signer to the listed + * ops (anything else is rejected). Fully scriptable/headless. + * - `--interactive` prompts `y/N` on the terminal for any op the policy doesn't + * already allow, so the operator approves/rejects each one live. Requires a + * TTY; composes with `--perms` (perms auto-allow, prompt for the rest). + * + * Thin assembly only: the request dispatch, the encrypted wrapper and the + * subscribe/serve loop all live in quartz (`BunkerRequestProcessor`, + * `NostrConnectSignerService`, `NostrConnectEvent`); the permission parsing + + * request→op mapping are reused from commons (`Nip46PermissionAuthorizer`). */ object BunkerCommand { + /** + * A bunker authorizer: validate the connect [secret], then decide each op. + * + * When [gated] is false (no `--perms`, no `--interactive`) every op is + * approved — the headless default for hosting the operator's own key. When + * gated, an op is allowed if [allowAllSignKinds] covers it or it is in + * [allowedOps]; otherwise it is denied, unless [interactive] is set, in which + * case the operator is prompted on the terminal. Prompts are serialized by + * [promptLock] because the service dispatches requests concurrently. + */ + private class CliAuthorizer( + val secret: String, + val allowedOps: List, + val allowAllSignKinds: Boolean, + val interactive: Boolean, + val gated: Boolean, + ) : Nip46RequestAuthorizer { + private val promptLock = Mutex() + + override suspend fun onConnect( + clientPubKey: HexKey, + request: BunkerRequestConnect, + ): Nip46ConnectDecision = + if (request.secret == secret) { + Nip46ConnectDecision.Accept(BunkerRequestProcessor.ACK) + } else { + Nip46ConnectDecision.Reject("invalid secret") + } + + override suspend fun authorize( + clientPubKey: HexKey, + request: BunkerRequest, + ): Boolean { + if (!gated) return true + // Metadata ops (ping / get_public_key / get_relays) map to no op and need no grant. + val op = request.toSignerOp() ?: return true + val statically = (op is NostrSignerOp.SignKind && allowAllSignKinds) || op in allowedOps + if (statically) return true + return if (interactive) prompt(clientPubKey, request) else false + } + + /** Ask the operator on the terminal. Serialized so concurrent requests don't interleave prompts. */ + private suspend fun prompt( + clientPubKey: HexKey, + request: BunkerRequest, + ): Boolean = + promptLock.withLock { + withContext(Dispatchers.IO) { + val kindInfo = (request as? BunkerRequestSign)?.let { " (kind:${it.event.kind})" } ?: "" + System.err.println("[bunker] ${clientPubKey.take(8)}… requests ${request.method}$kindInfo") + (request as? BunkerRequestSign)?.event?.content?.take(160)?.trim()?.let { + if (it.isNotEmpty()) System.err.println(" content: ${it.replace('\n', ' ')}") + } + System.err.print("[bunker] approve? [y/N] ") + System.err.flush() + val answer = readlnOrNull()?.trim()?.lowercase() + val approved = answer == "y" || answer == "yes" + System.err.println(if (approved) "[bunker] → approved" else "[bunker] → denied") + approved + } + } + } + suspend fun run( dataDir: DataDir, rest: Array, @@ -91,6 +155,7 @@ object BunkerCommand { ): Int { val args = Args(rest) val timeoutMs = args.flag("timeout")?.toLongOrNull()?.let { it * 1000 } + interactiveTtyError(args)?.let { return it } val accountError = checkHostable(dataDir) if (accountError != null) return accountError @@ -105,14 +170,8 @@ object BunkerCommand { val secret = args.flag("secret") ?: KeyPair().privKey!!.toHexKey().take(32) val self = ctx.identity.pubKeyHex - // Percent-encode params (spec/nak convention: relay=wss%3A%2F%2F…). - val enc = { s: String -> java.net.URLEncoder.encode(s, "UTF-8") } - val uri = - buildString { - append("bunker://").append(self) - append("?").append(relays.joinToString("&") { "relay=${enc(it.url)}" }) - append("&secret=").append(enc(secret)) - } + // Percent-encoded per the spec/nak convention (relay=wss%3A%2F%2F…). + val uri = NostrConnectURI.buildBunker(self, relays, secret) Output.emit( mapOf( "bunker_uri" to uri, @@ -123,7 +182,9 @@ object BunkerCommand { ) System.err.println("[bunker] listening as ${self.take(8)}… on ${relays.size} relay(s); paste the bunker:// uri into `amy login`") - serve(ctx, relays, secret, timeoutMs) + val authorizer = buildAuthorizer(args, secret) + logPolicy(args) + serve(ctx, relays, authorizer, timeoutMs) return 0 } } @@ -140,6 +201,7 @@ object BunkerCommand { ): Int { val args = Args(rest) val timeoutMs = args.flag("timeout")?.toLongOrNull()?.let { it * 1000 } + interactiveTtyError(args)?.let { return it } val uri = args.positional(0, "nostrconnect-uri") val offer = NostrConnect.parseOffer(uri) ?: return Output.error("bad_args", "not a valid nostrconnect:// uri") val accountError = checkHostable(dataDir) @@ -161,11 +223,17 @@ object BunkerCommand { "connected_to" to offer.clientPubkey, "pubkey" to ctx.identity.pubKeyHex, "relays" to offer.relays.map { it.url }, + "requested_perms" to offer.perms, ), ) System.err.println("[bunker] acked nostrconnect from ${offer.clientPubkey.take(8)}…; now servicing requests") + // Surface what the client asked for; whether it's honored depends on this bunker's own + // --perms/--interactive gate (below), not on the client's self-declared `perms`. + offer.perms?.let { System.err.println("[bunker] client requested perms: $it") } - serve(ctx, offer.relays, offer.secret, timeoutMs) + val authorizer = buildAuthorizer(args, offer.secret) + logPolicy(args) + serve(ctx, offer.relays, authorizer, timeoutMs) return 0 } } @@ -178,91 +246,77 @@ object BunkerCommand { null } + /** + * `--interactive` prompts the operator on the terminal, so it needs a real TTY; a piped/headless + * stdin would make [readlnOrNull] return null and silently deny everything. Fail fast instead. + */ + private fun interactiveTtyError(args: Args): Int? = + if (args.bool("interactive") && System.console() == null) { + Output.error("no_tty", "--interactive needs a terminal (stdin/stdout is not a TTY); use --perms for headless gating") + } else { + null + } + + /** Builds the request gate from `--perms` / `--interactive` (both absent → approve everything). */ + private fun buildAuthorizer( + args: Args, + secret: String, + ): CliAuthorizer { + val perms = args.flag("perms")?.ifBlank { null } + val interactive = args.bool("interactive") + // parsePerms drops a bare `sign_event` (Amethyst grants per kind), so detect it here for "any kind". + val allowAllSignKinds = + perms + ?.split(',') + ?.any { it.trim().lowercase() == "sign_event" || it.trim().lowercase() == "sign" } ?: false + return CliAuthorizer( + secret = secret, + allowedOps = Nip46PermissionAuthorizer.parsePerms(perms), + allowAllSignKinds = allowAllSignKinds, + interactive = interactive, + gated = perms != null || interactive, + ) + } + + /** Tell the operator which gate is active, so an unexpectedly-restrictive run is obvious. */ + private fun logPolicy(args: Args) { + val perms = args.flag("perms")?.ifBlank { null } + val interactive = args.bool("interactive") + when { + perms != null && interactive -> System.err.println("[bunker] gate: auto-allow [$perms], prompt for the rest") + perms != null -> System.err.println("[bunker] gate: allow only [$perms], reject the rest") + interactive -> System.err.println("[bunker] gate: prompt on the terminal for every op") + else -> System.err.println("[bunker] gate: auto-approve every request (secret is the only gate)") + } + } + /** Subscribe for kind:24133 requests addressed to us and service them until timeout/interrupt. */ private suspend fun serve( ctx: Context, relays: Set, - secret: String, + authorizer: CliAuthorizer, timeoutMs: Long?, ) { - val self = ctx.identity.pubKeyHex - val events = Channel(UNLIMITED) - val seen = mutableSetOf() - val subId = newSubId() - val listener = - object : SubscriptionListener { - override fun onEvent( - event: Event, - isLive: Boolean, - relay: NormalizedRelayUrl, - forFilters: List?, - ) { - if (event is NostrConnectEvent && seen.add(event.id)) events.trySend(event) - } - } + val processor = + BunkerRequestProcessor( + signer = ctx.signer, + relays = { relays }, + authorizer = authorizer, + ) + val service = + NostrConnectSignerService( + client = ctx.client, + // The CLI bunker deliberately advertises the operator's own key as the transport key + // (a simple dev/interop tool); the app uses a separate transport key for privacy. + transportSigner = ctx.signer, + processor = processor, + relays = relays, + onServiced = { request, client, error -> + val outcome = if (error != null) "error: $error" else "ok" + System.err.println("[bunker] ${request.method} from ${client.take(8)}… → $outcome") + }, + ) - val filter = Filter(kinds = listOf(NostrConnectEvent.KIND), tags = mapOf("p" to listOf(self))) - ctx.client.subscribe(subId, relays.associateWith { listOf(filter) }, listener) - try { - val loop: suspend () -> Unit = { - while (true) handle(ctx, events.receive(), secret, relays) - } - if (timeoutMs != null) withTimeoutOrNull(timeoutMs) { loop() } else loop() - } finally { - ctx.client.unsubscribe(subId) - events.close() - } - } - - private suspend fun handle( - ctx: Context, - event: NostrConnectEvent, - secret: String, - relays: Set, - ) { - val signer = ctx.signer - val client = event.talkingWith(signer.pubKey) - val request = - try { - event.decryptMessage(signer) as? BunkerRequest ?: return - } catch (e: Exception) { - System.err.println("[bunker] could not decrypt request ${event.id.take(8)}: ${e.message}") - return - } - - val response: BunkerResponse = - try { - when (request) { - is BunkerRequestConnect -> - if (request.secret == secret) { - BunkerResponseAck(request.id) - } else { - BunkerResponseError(request.id, "invalid secret") - } - is BunkerRequestGetPublicKey -> BunkerResponsePublicKey(request.id, signer.pubKey) - is BunkerRequestGetRelays -> BunkerResponseGetRelays(request.id, relays.associate { it.url to ReadWrite(read = true, write = true) }) - is BunkerRequestPing -> BunkerResponsePong(request.id) - is BunkerRequestSign -> { - val signed = signer.sign(request.event.createdAt, request.event.kind, request.event.tags, request.event.content) - BunkerResponseEvent(request.id, signed) - } - is BunkerRequestNip04Encrypt -> BunkerResponseEncrypt(request.id, signer.nip04Encrypt(request.message, request.pubKey)) - is BunkerRequestNip04Decrypt -> BunkerResponseDecrypt(request.id, signer.nip04Decrypt(request.ciphertext, request.pubKey)) - is BunkerRequestNip44Encrypt -> BunkerResponseEncrypt(request.id, signer.nip44Encrypt(request.message, request.pubKey)) - is BunkerRequestNip44Decrypt -> BunkerResponseDecrypt(request.id, signer.nip44Decrypt(request.ciphertext, request.pubKey)) - else -> BunkerResponseError(request.id, "unsupported method: ${request.method}") - } - } catch (e: Exception) { - BunkerResponseError(request.id, "${e::class.simpleName}: ${e.message}") - } - - System.err.println("[bunker] ${request.method} from ${client.take(8)}… → ${if (response is BunkerResponseError) "error: ${response.error}" else "ok"}") - - try { - val reply = NostrConnectEvent.create(response, client, signer) - ctx.client.publish(reply, relays) - } catch (e: Exception) { - System.err.println("[bunker] failed to send reply for ${request.method}: ${e.message}") - } + if (timeoutMs != null) withTimeoutOrNull(timeoutMs) { service.run() } else service.run() } } diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/NostrConnect.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/NostrConnect.kt index a24ded7865..8c20a2b2b5 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/NostrConnect.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/NostrConnect.kt @@ -33,12 +33,12 @@ import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener import com.vitorpamplona.quartz.nip01Core.relay.client.single.newSubId import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl -import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer import com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.BasicOkHttpWebSocket import com.vitorpamplona.quartz.nip01Core.relay.sockets.okhttp.TcpNoDelaySocketFactory import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponse import com.vitorpamplona.quartz.nip46RemoteSigner.NostrConnectEvent +import com.vitorpamplona.quartz.nip46RemoteSigner.NostrConnectURI import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.Channel.Factory.UNLIMITED import kotlinx.coroutines.withTimeoutOrNull @@ -52,36 +52,19 @@ import okhttp3.OkHttpClient * which only parses). The signer side lives in [BunkerCommand]. */ object NostrConnect { - private const val NOSTRCONNECT_SCHEME = "nostrconnect://" - data class Offer( val clientPubkey: String, val relays: Set, val secret: String, val name: String?, + /** The client's self-declared permission request (`perms=sign_event:1,nip44_encrypt,…`), if any. */ + val perms: String? = null, ) - /** Parse `nostrconnect://?relay=…&secret=…&name=…` (percent-decoded). */ + /** Parse `nostrconnect://?relay=…&secret=…&perms=…&name=…` (percent-decoded). */ fun parseOffer(uri: String): Offer? { - if (!uri.startsWith(NOSTRCONNECT_SCHEME)) return null - val parts = uri.removePrefix(NOSTRCONNECT_SCHEME).split("?", limit = 2) - val clientPubkey = parts[0].lowercase() - if (clientPubkey.length != 64 || clientPubkey.any { it !in "0123456789abcdef" }) return null - val relays = mutableSetOf() - var secret: String? = null - var name: String? = null - parts.getOrNull(1)?.split("&")?.forEach { param -> - val kv = param.split("=", limit = 2) - if (kv.size < 2) return@forEach - val value = java.net.URLDecoder.decode(kv[1], "UTF-8") - when (kv[0]) { - "relay" -> RelayUrlNormalizer.normalizeOrNull(value)?.let { relays.add(it) } - "secret" -> secret = value - "name" -> name = value - } - } - if (secret == null) return null - return Offer(clientPubkey, relays, secret, name) + val parsed = NostrConnectURI.parseNostrConnect(uri) ?: return null + return Offer(parsed.clientPubKey, parsed.relays, parsed.secret, parsed.name, parsed.perms) } private fun buildOffer( @@ -89,15 +72,8 @@ object NostrConnect { relays: Set, secret: String, name: String?, - ): String { - val enc = { s: String -> java.net.URLEncoder.encode(s, "UTF-8") } - return buildString { - append(NOSTRCONNECT_SCHEME).append(clientPubkey) - append("?").append(relays.joinToString("&") { "relay=${enc(it.url)}" }) - append("&secret=").append(enc(secret)) - if (name != null) append("&name=").append(enc(name)) - } - } + perms: String?, + ): String = NostrConnectURI.buildNostrConnect(clientPubkey, relays, secret, perms = perms, name = name) /** * `amy login --nostrconnect [--relay URL[,URL…]] [--name N] [--timeout SECS]` @@ -118,12 +94,16 @@ object NostrConnect { if (relays.isEmpty()) return Output.error("bad_args", "no relays; pass --relay URL[,URL…]") val timeoutMs = (args.flag("timeout")?.toLongOrNull() ?: 120L) * 1000 val name = args.flag("name") + // Optional NIP-46 permission request (`--perms sign_event:1,nip44_encrypt,…`). When present it + // rides along in the offer so a signer that honors `perms` (e.g. Amethyst's informed-consent + // sheet) can pre-grant exactly these ops. Blank is treated as absent. + val perms = args.flag("perms")?.ifBlank { null } val clientKey = KeyPair() val clientSigner = NostrSignerInternal(clientKey) val clientPub = clientKey.pubKey.toHexKey() val secret = KeyPair().privKey!!.toHexKey().take(32) - val offer = buildOffer(clientPub, relays, secret, name) + val offer = buildOffer(clientPub, relays, secret, name, perms) // Surface the offer immediately so the human/harness can paste it. System.err.println("[nostrconnect] paste this into your signer within ${timeoutMs / 1000}s:") diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/connectedApps/nip46/Nip46ClientStore.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/connectedApps/nip46/Nip46ClientStore.kt new file mode 100644 index 0000000000..54636e61c0 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/connectedApps/nip46/Nip46ClientStore.kt @@ -0,0 +1,82 @@ +/* + * 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.amethyst.commons.connectedApps.nip46 + +import com.vitorpamplona.amethyst.commons.util.KmpLock +import com.vitorpamplona.amethyst.commons.util.withLock + +/** + * Display + transport info a NIP-46 remote-signer client sent us when it paired: + * its self-declared [name]/[url]/[image] (shown in Connected Apps) and the + * [relays] it talks to us on. The relays matter for the `nostrconnect://` flow — + * an app that offered its own relays (not the user's inbox) is only reachable + * there, so they are persisted and re-added to the listen set on the next launch. + */ +data class Nip46ClientInfo( + val name: String? = null, + val url: String? = null, + val image: String? = null, + val relays: Set = emptySet(), +) { + fun isEmpty() = name == null && url == null && image == null && relays.isEmpty() +} + +/** + * Persists [Nip46ClientInfo] per connected client, keyed by the same + * `nip46::` coordinate the permission ledger uses, so + * a client's metadata and its trust grant live under one key and are namespaced + * per account. Unlike the permission ledger this is display/transport data, not a + * security decision — a hostile value can only mislabel a card, never grant access. + */ +interface Nip46ClientStore { + suspend fun load(coordinate: String): Nip46ClientInfo? + + suspend fun store( + coordinate: String, + info: Nip46ClientInfo, + ) + + suspend fun remove(coordinate: String) + + /** All stored client info, keyed by coordinate — for startup relay recovery + the management screen. */ + suspend fun all(): Map +} + +/** A thread-safe in-memory [Nip46ClientStore] for tests and ephemeral sessions. */ +class InMemoryNip46ClientStore : Nip46ClientStore { + private val lock = KmpLock() + private val map = mutableMapOf() + + override suspend fun load(coordinate: String): Nip46ClientInfo? = lock.withLock { map[coordinate] } + + override suspend fun store( + coordinate: String, + info: Nip46ClientInfo, + ) = lock.withLock { map[coordinate] = info } + + override suspend fun remove(coordinate: String) = + lock.withLock { + map.remove(coordinate) + Unit + } + + override suspend fun all(): Map = lock.withLock { map.toMap() } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/connectedApps/nip46/Nip46PermissionAuthorizer.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/connectedApps/nip46/Nip46PermissionAuthorizer.kt new file mode 100644 index 0000000000..1eb84daa24 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/connectedApps/nip46/Nip46PermissionAuthorizer.kt @@ -0,0 +1,348 @@ +/* + * 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.amethyst.commons.connectedApps.nip46 + +import com.vitorpamplona.amethyst.commons.connectedApps.signers.AppConnectResult +import com.vitorpamplona.amethyst.commons.connectedApps.signers.AppSignerPolicy +import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrOpDecision +import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerOp +import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerPermissionLedger +import com.vitorpamplona.amethyst.commons.connectedApps.signers.SignerOpGrant +import com.vitorpamplona.amethyst.commons.util.KmpLock +import com.vitorpamplona.amethyst.commons.util.withLock +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequest +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestConnect +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.BunkerRequestSign +import com.vitorpamplona.quartz.nip46RemoteSigner.server.Nip46ConnectDecision +import com.vitorpamplona.quartz.nip46RemoteSigner.server.Nip46RequestAuthorizer +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +/** + * Bridges the NIP-46 signer core to Amethyst's shared "Connected Apps" trust + * model: a remote client that signs through Amethyst is a connected app just + * like a napplet or a sandboxed web origin, gated by the same + * [NostrSignerPermissionLedger] (per-app [AppSignerPolicy] + per-op + * [NostrOpDecision]) and surfaced on the same management screen. + * + * A NIP-46 client is identified by its transport pubkey, mapped to the ledger + * coordinate `nip46::` (see [coordinateFor]) so it + * lives in its own namespace next to `browser:` and napplet + * `:` keys, and so the same client paired with two accounts on one + * device gets independent grants. + * + * Authorization mirrors the napplet path: + * - each signing/encryption/decryption request maps to a [NostrSignerOp] + * ([sign:kind][NostrSignerOp.SignKind] / [encrypt][NostrSignerOp.Encrypt] / + * [decrypt][NostrSignerOp.Decrypt]). `ALLOW` proceeds, `DENY` is refused, and + * `ASK` triggers [opConsent] — the interactive prompt through the shared signer + * consent dialog (with in-memory session grants and a remembered per-op result). + * When no [opConsent] is wired (headless CLI, tests) `ASK` fails closed, so the + * signer only ever performs pre-granted operations. + * - a `connect` request first validates the pairing secret via + * [validateSecret]; on first contact (no standing policy) it asks [connectConsent] + * for the trust level, falling back to [defaultPolicyOnConnect] when no prompt is + * wired; an existing policy is never downgraded. [onConnected] then runs so the + * host can record display metadata. + */ +class Nip46PermissionAuthorizer( + val ledger: NostrSignerPermissionLedger, + /** The user's own signer pubkey — namespaces this account's grants in the app-global store. */ + val signerPubKey: HexKey, + /** Validates the connect secret for a client (bunker secret, or the offer secret in the nostrconnect flow). */ + val validateSecret: suspend (clientPubKey: HexKey, offeredSecret: String?) -> Boolean, + /** Trust level assigned to a freshly paired app that has no policy yet. */ + val defaultPolicyOnConnect: AppSignerPolicy = AppSignerPolicy.REASONABLE, + /** Invoked after a successful connect so the host can persist display metadata (name/url/image). */ + val onConnected: (suspend (clientPubKey: HexKey, request: BunkerRequestConnect) -> Unit)? = null, + /** Persisted client metadata/relays; cleared on logout so a disconnected app leaves nothing behind. */ + val clientStore: Nip46ClientStore? = null, + /** + * Invoked after a client is fully forgotten ([onLogout]/[forget]) so the host can react in the + * running session — e.g. stop listening on relays that only that client used, instead of waiting + * for the next restart. + */ + val onDisconnected: (suspend (clientPubKey: HexKey) -> Unit)? = null, + /** + * Interactive first-connect consent. When a client connects with a valid secret but has no + * standing policy, this is asked (showing the app's metadata) and its [AppConnectResult] decides + * the trust level. `null` (headless CLI, tests) keeps the non-interactive behavior: auto-register + * at [defaultPolicyOnConnect]. + */ + val connectConsent: (suspend (coordinate: String, clientPubKey: HexKey, request: BunkerRequestConnect) -> AppConnectResult)? = null, + /** + * Interactive per-operation consent. Asked when the ledger's standing decision for a request is + * [NostrOpDecision.ASK]; the returned [SignerOpGrant] both decides the in-flight request and is + * recorded (remember/session/deny variants). `null` keeps the non-interactive behavior: ASK is + * treated as deny, so a headless signer only ever performs pre-granted operations. + */ + val opConsent: (suspend (coordinate: String, clientPubKey: HexKey, op: NostrSignerOp, request: BunkerRequest) -> SignerOpGrant)? = null, +) : Nip46RequestAuthorizer { + // Session-only ("allow until the signer restarts") grants: coordinate + op key, held in memory + // and never persisted — mirrors the napplet broker's sessionAllows. Guarded by [throttleLock]. + private val sessionAllows = mutableSetOf() + + // A high-throughput client can authorize many signs per second; last-used is display-only, + // so coalesce the DataStore write to at most one per client per LAST_USED_THROTTLE_SECS + // instead of writing the whole per-client preferences file on every request. + private val lastUsedThrottle = mutableMapOf() + private val throttleLock = KmpLock() + + // Serializes first-connect consent. The service now handles requests concurrently so per-op prompts + // can batch, but the connect prompt is a separate single dialog — this keeps two clients connecting + // at once from stacking two connect dialogs; the second waits for the first to resolve. A coroutine + // Mutex (not KmpLock) because the guarded region awaits a user dialog and must suspend, not block. + private val connectLock = Mutex() + + /** The ledger coordinate for [clientPubKey] under this account. */ + fun coordinateFor(clientPubKey: HexKey): String = coordinateFor(signerPubKey, clientPubKey) + + private suspend fun touchLastUsed(coordinate: String) { + val now = TimeUtils.now() + val shouldWrite = + throttleLock.withLock { + val previous = lastUsedThrottle[coordinate] ?: 0L + if (now - previous >= LAST_USED_THROTTLE_SECS) { + lastUsedThrottle[coordinate] = now + true + } else { + false + } + } + if (shouldWrite) ledger.updateLastUsed(coordinate, now) + } + + override suspend fun onConnect( + clientPubKey: HexKey, + request: BunkerRequestConnect, + ): Nip46ConnectDecision { + if (!validateSecret(clientPubKey, request.secret)) { + return Nip46ConnectDecision.Reject("invalid secret") + } + + val coordinate = coordinateFor(clientPubKey) + // Serialize first-contact consent so two concurrent connects don't stack dialogs. The + // hasPolicy re-check inside the lock also means a client that connected on another in-flight + // request isn't prompted twice. + val rejection = + connectLock.withLock { + if (ledger.hasPolicy(coordinate)) { + null + } else { + // First contact: ask the user (if a prompt is wired) which trust level to grant; a + // headless signer with no prompt falls back to the non-interactive default. + when (val consent = connectConsent?.invoke(coordinate, clientPubKey, request)) { + null -> { + ledger.setPolicy(coordinate, defaultPolicyOnConnect) + null + } + is AppConnectResult.Connected -> { + ledger.setPolicy(coordinate, consent.policy) + null + } + AppConnectResult.Blocked -> "blocked by user" + AppConnectResult.Cancelled -> "connection declined" + } + } + } + if (rejection != null) return Nip46ConnectDecision.Reject(rejection) + touchLastUsed(coordinate) + onConnected?.invoke(clientPubKey, request) + + // Echo the offered secret when present (the client validates it); otherwise ack. + val echo = request.secret?.takeIf { it.isNotEmpty() } ?: ACK + return Nip46ConnectDecision.Accept(echo) + } + + override suspend fun authorize( + clientPubKey: HexKey, + request: BunkerRequest, + ): Boolean { + // Requests the core routes here always map to an op; if a future method + // is added that does not, default to allow (the core only gates + // sign/encrypt/decrypt, so this branch is a safety net). + val op = request.toSignerOp() ?: return true + val coordinate = coordinateFor(clientPubKey) + + val allowed = + when (ledger.decide(coordinate, op)) { + NostrOpDecision.ALLOW -> true + NostrOpDecision.DENY -> false + // ASK: honor a live session grant first, otherwise prompt the user (if wired). No + // prompt → deny, so a headless signer only ever performs pre-granted operations. + NostrOpDecision.ASK -> { + if (isSessionAllowed(coordinate, op)) { + true + } else { + askOpConsent(coordinate, clientPubKey, op, request) + } + } + } + if (allowed) touchLastUsed(coordinate) + return allowed + } + + private suspend fun askOpConsent( + coordinate: String, + clientPubKey: HexKey, + op: NostrSignerOp, + request: BunkerRequest, + ): Boolean { + val grant = opConsent?.invoke(coordinate, clientPubKey, op, request) ?: return false + ledger.record(coordinate, grant) + if (grant is SignerOpGrant.AllowForSession) { + throttleLock.withLock { sessionAllows.add(sessionKey(coordinate, op)) } + } + return grant.isAllowed + } + + private suspend fun isSessionAllowed( + coordinate: String, + op: NostrSignerOp, + ): Boolean = throttleLock.withLock { sessionAllows.contains(sessionKey(coordinate, op)) } + + private fun sessionKey( + coordinate: String, + op: NostrSignerOp, + ): String = "$coordinate|${op.key}" + + override suspend fun onLogout(clientPubKey: HexKey) = forget(clientPubKey) + + /** + * Fully disconnects [clientPubKey], from either the client's `logout` request or the user's + * "Forget" action: drops its standing grant (so it must pair again), its persisted metadata/relays, + * and its in-memory throttle entry, then signals [onDisconnected] so the running session can stop + * listening on relays that only this client used. + */ + suspend fun forget(clientPubKey: HexKey) { + val coordinate = coordinateFor(clientPubKey) + ledger.revokeAll(coordinate) + clientStore?.remove(coordinate) + throttleLock.withLock { + lastUsedThrottle.remove(coordinate) + sessionAllows.removeAll { it.startsWith("$coordinate|") } + } + onDisconnected?.invoke(clientPubKey) + } + + /** + * Forgets every app under this account that hasn't been used in [maxIdleSeconds]. Each connected + * NIP-46 app makes the signer hold a background relay subscription forever, so an app the user + * paired once and abandoned would keep a relay connection alive indefinitely; this bounds that + * growth. `last-used` is stamped on connect and on every serviced operation, so an app that is + * still signing is never pruned. Returns the client pubkeys that were forgotten. + */ + suspend fun pruneIdle( + maxIdleSeconds: Long, + now: Long = TimeUtils.now(), + ): List { + val cutoff = now - maxIdleSeconds + val stale = + ledger.store + .allPolicies() + .keys + .filter { belongsTo(it, signerPubKey) } + .filter { (ledger.lastUsed(it) ?: 0L) < cutoff } + .mapNotNull { clientPubKeyOf(it) } + stale.forEach { forget(it) } + return stale + } + + companion object { + /** Ledger coordinate namespace for NIP-46 remote-signer clients. */ + const val COORDINATE_PREFIX = "nip46" + + private const val ACK = "ack" + + /** Minimum seconds between persisted last-used updates for one client (write-coalescing). */ + private const val LAST_USED_THROTTLE_SECS = 60L + + /** + * The Connected-Apps ledger coordinate for a NIP-46 client, namespaced by + * the user's signer so the same client paired with two accounts on one + * device gets independent grants: `nip46::`. + */ + fun coordinateFor( + signerPubKey: HexKey, + clientPubKey: HexKey, + ): String = "$COORDINATE_PREFIX:$signerPubKey:$clientPubKey" + + /** True when [coordinate] is a NIP-46 grant belonging to [signerPubKey]. */ + fun belongsTo( + coordinate: String, + signerPubKey: HexKey, + ): Boolean = coordinate.startsWith("$COORDINATE_PREFIX:$signerPubKey:") + + /** The client pubkey of a `nip46::` coordinate, or `null` if it is not one. */ + fun clientPubKeyOf(coordinate: String): HexKey? = if (coordinate.startsWith("$COORDINATE_PREFIX:")) coordinate.substringAfterLast(':') else null + + /** The signer (account identity) pubkey of a `nip46::` coordinate, or `null`. */ + fun signerPubKeyOf(coordinate: String): HexKey? = + if (coordinate.startsWith("$COORDINATE_PREFIX:")) { + coordinate.substringAfter("$COORDINATE_PREFIX:").substringBefore(':').ifBlank { null } + } else { + null + } + + /** + * Parses a NIP-46 `perms` string (the comma-separated permission list a client advertises in + * its `nostrconnect://…?perms=` offer) into the [NostrSignerOp]s it asks for. Tokens follow the + * de-facto convention: `sign_event:`, `nip04_encrypt`/`nip44_encrypt` (→ [NostrSignerOp.Encrypt]), + * `nip04_decrypt`/`nip44_decrypt` (→ [NostrSignerOp.Decrypt]); `get_public_key`, `connect`, `ping` + * and any unrecognized/blank token are ignored (get_public_key is always allowed and needs no grant). + * A bare `sign_event` with no kind is ignored — Amethyst grants per kind, so a kindless request is + * too broad to honor. + */ + fun parsePerms(perms: String?): List = + perms + ?.split(',') + ?.mapNotNull { token -> + when (val t = token.trim().lowercase()) { + "nip04_encrypt", "nip44_encrypt", "encrypt" -> NostrSignerOp.Encrypt + "nip04_decrypt", "nip44_decrypt", "decrypt" -> NostrSignerOp.Decrypt + else -> + if (t.startsWith("sign_event:")) { + t.removePrefix("sign_event:").toIntOrNull()?.let { NostrSignerOp.SignKind(it) } + } else { + null + } + } + }?.distinct() + .orEmpty() + + /** Maps a signing/encryption/decryption [BunkerRequest] to the [NostrSignerOp] it needs. */ + fun BunkerRequest.toSignerOp(): NostrSignerOp? = + when (this) { + is BunkerRequestSign -> NostrSignerOp.SignKind(event.kind) + is BunkerRequestNip04Encrypt -> NostrSignerOp.Encrypt + is BunkerRequestNip44Encrypt -> NostrSignerOp.Encrypt + is BunkerRequestNip04Decrypt -> NostrSignerOp.Decrypt + is BunkerRequestNip44Decrypt -> NostrSignerOp.Decrypt + else -> null + } + } +} diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/signers/AppSignerPolicy.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/connectedApps/signers/AppSignerPolicy.kt similarity index 97% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/signers/AppSignerPolicy.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/connectedApps/signers/AppSignerPolicy.kt index f1a506f6a0..a12dbf1bc8 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/signers/AppSignerPolicy.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/connectedApps/signers/AppSignerPolicy.kt @@ -18,7 +18,7 @@ * 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.amethyst.commons.napplet.signers +package com.vitorpamplona.amethyst.commons.connectedApps.signers /** * The user's top-level trust decision for one app's access to the internal Nostr signer. diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/signers/NostrOpDecision.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/connectedApps/signers/NostrOpDecision.kt similarity index 95% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/signers/NostrOpDecision.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/connectedApps/signers/NostrOpDecision.kt index a6cbda8f6b..82f2821fbf 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/signers/NostrOpDecision.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/connectedApps/signers/NostrOpDecision.kt @@ -18,7 +18,7 @@ * 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.amethyst.commons.napplet.signers +package com.vitorpamplona.amethyst.commons.connectedApps.signers /** * A per-operation standing decision stored in [NostrSignerPermissionStore]. diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/signers/NostrSignerConsentPrompt.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/connectedApps/signers/NostrSignerConsentPrompt.kt similarity index 98% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/signers/NostrSignerConsentPrompt.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/connectedApps/signers/NostrSignerConsentPrompt.kt index ea6f87bd34..e614b4f51b 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/signers/NostrSignerConsentPrompt.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/connectedApps/signers/NostrSignerConsentPrompt.kt @@ -18,7 +18,7 @@ * 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.amethyst.commons.napplet.signers +package com.vitorpamplona.amethyst.commons.connectedApps.signers import com.vitorpamplona.amethyst.commons.napplet.NappletIdentity import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletRequest diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/signers/NostrSignerOp.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/connectedApps/signers/NostrSignerOp.kt similarity index 79% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/signers/NostrSignerOp.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/connectedApps/signers/NostrSignerOp.kt index bdb31e981f..5987dc46ef 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/signers/NostrSignerOp.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/connectedApps/signers/NostrSignerOp.kt @@ -18,9 +18,7 @@ * 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.amethyst.commons.napplet.signers - -import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletRequest +package com.vitorpamplona.amethyst.commons.connectedApps.signers /** * A Nostr-specific cryptographic operation that requires the internal signer. @@ -57,15 +55,3 @@ sealed interface NostrSignerOp { } } } - -/** - * Maps a [NappletRequest] to the [NostrSignerOp] it represents, or `null` if the request - * does not involve signing or encryption. - */ -fun NappletRequest.toSignerOp(): NostrSignerOp? = - when (this) { - is NappletRequest.Publish -> NostrSignerOp.SignKind(kind) - is NappletRequest.SignEvent -> NostrSignerOp.SignKind(kind) - is NappletRequest.PublishEncrypted -> NostrSignerOp.Encrypt - else -> null - } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/signers/NostrSignerPermissionLedger.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/connectedApps/signers/NostrSignerPermissionLedger.kt similarity index 91% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/signers/NostrSignerPermissionLedger.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/connectedApps/signers/NostrSignerPermissionLedger.kt index 621aaf3149..ae8d1cd9d4 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/signers/NostrSignerPermissionLedger.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/connectedApps/signers/NostrSignerPermissionLedger.kt @@ -18,7 +18,7 @@ * 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.amethyst.commons.napplet.signers +package com.vitorpamplona.amethyst.commons.connectedApps.signers import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent import com.vitorpamplona.quartz.nip18Reposts.GenericRepostEvent @@ -144,6 +144,26 @@ class NostrSignerPermissionLedger( /** Removes all signer permissions for [coordinate] — trust level and all per-op overrides. */ suspend fun revokeAll(coordinate: String) = store.clearAll(coordinate) + /** + * Applies the persisted part of a user's per-operation consent [grant] to [coordinate], so a + * "remember" choice sticks. The transient variants ([SignerOpGrant.AllowOnce], + * [SignerOpGrant.DenyOnce], [SignerOpGrant.AllowForSession]) persist nothing — the caller keeps + * session grants in memory. Mirrors the broker's per-op recording so every consent surface + * (napplet, browser, NIP-46) writes the ledger the same way. + */ + suspend fun record( + coordinate: String, + grant: SignerOpGrant, + ) { + when (grant) { + is SignerOpGrant.AllowForOp -> setOpDecision(coordinate, grant.op, NostrOpDecision.ALLOW) + is SignerOpGrant.AllowUntil -> setTimedOpDecision(coordinate, grant.op, NostrOpDecision.ALLOW, grant.expiresAt) + is SignerOpGrant.AllowAll -> setPolicy(coordinate, AppSignerPolicy.FULL_TRUST) + is SignerOpGrant.DenyForOp -> setOpDecision(coordinate, grant.op, NostrOpDecision.DENY) + SignerOpGrant.AllowOnce, SignerOpGrant.DenyOnce, is SignerOpGrant.AllowForSession -> Unit + } + } + private fun reasonableDecision(op: NostrSignerOp): NostrOpDecision = when (op) { is NostrSignerOp.SignKind -> diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/signers/NostrSignerPermissionStore.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/connectedApps/signers/NostrSignerPermissionStore.kt similarity index 99% rename from commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/signers/NostrSignerPermissionStore.kt rename to commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/connectedApps/signers/NostrSignerPermissionStore.kt index cc0e2bca8a..ef2e0b2afd 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/signers/NostrSignerPermissionStore.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/connectedApps/signers/NostrSignerPermissionStore.kt @@ -18,7 +18,7 @@ * 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.amethyst.commons.napplet.signers +package com.vitorpamplona.amethyst.commons.connectedApps.signers import com.vitorpamplona.amethyst.commons.util.KmpLock import com.vitorpamplona.amethyst.commons.util.withLock diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBroker.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBroker.kt index 81216f0d78..5c2501011b 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBroker.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBroker.kt @@ -20,20 +20,20 @@ */ package com.vitorpamplona.amethyst.commons.napplet +import com.vitorpamplona.amethyst.commons.connectedApps.signers.AppConnectResult +import com.vitorpamplona.amethyst.commons.connectedApps.signers.AppSignerPolicy +import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrConnectPrompt +import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrOpDecision +import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerConsentPrompt +import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerOp +import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerPermissionLedger +import com.vitorpamplona.amethyst.commons.connectedApps.signers.SignerOpGrant import com.vitorpamplona.amethyst.commons.napplet.permissions.GrantState import com.vitorpamplona.amethyst.commons.napplet.permissions.NappletPermissionLedger import com.vitorpamplona.amethyst.commons.napplet.permissions.PermissionDecision import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletRequest import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletResponse -import com.vitorpamplona.amethyst.commons.napplet.signers.AppConnectResult -import com.vitorpamplona.amethyst.commons.napplet.signers.AppSignerPolicy -import com.vitorpamplona.amethyst.commons.napplet.signers.NostrConnectPrompt -import com.vitorpamplona.amethyst.commons.napplet.signers.NostrOpDecision -import com.vitorpamplona.amethyst.commons.napplet.signers.NostrSignerConsentPrompt -import com.vitorpamplona.amethyst.commons.napplet.signers.NostrSignerOp -import com.vitorpamplona.amethyst.commons.napplet.signers.NostrSignerPermissionLedger -import com.vitorpamplona.amethyst.commons.napplet.signers.SignerOpGrant -import com.vitorpamplona.amethyst.commons.napplet.signers.toSignerOp +import com.vitorpamplona.amethyst.commons.napplet.protocol.toSignerOp import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner import com.vitorpamplona.quartz.utils.TimeUtils diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/protocol/NappletRequestSignerOp.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/protocol/NappletRequestSignerOp.kt new file mode 100644 index 0000000000..905f137517 --- /dev/null +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/protocol/NappletRequestSignerOp.kt @@ -0,0 +1,38 @@ +/* + * 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.amethyst.commons.napplet.protocol + +import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerOp + +/** + * Maps a [NappletRequest] to the [NostrSignerOp] it represents, or `null` if the request + * does not involve signing or encryption. + * + * This lives on the napplet side (not in the generic signer-permission layer) because it + * is the napplet protocol's own translation into the shared [NostrSignerOp] vocabulary. + */ +fun NappletRequest.toSignerOp(): NostrSignerOp? = + when (this) { + is NappletRequest.Publish -> NostrSignerOp.SignKind(kind) + is NappletRequest.SignEvent -> NostrSignerOp.SignKind(kind) + is NappletRequest.PublishEncrypted -> NostrSignerOp.Encrypt + else -> null + } diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/connectedApps/nip46/Nip46ConsentIntegrationTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/connectedApps/nip46/Nip46ConsentIntegrationTest.kt new file mode 100644 index 0000000000..412b069606 --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/connectedApps/nip46/Nip46ConsentIntegrationTest.kt @@ -0,0 +1,161 @@ +/* + * 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.amethyst.commons.connectedApps.nip46 + +import com.vitorpamplona.amethyst.commons.connectedApps.signers.AppConnectResult +import com.vitorpamplona.amethyst.commons.connectedApps.signers.AppSignerPolicy +import com.vitorpamplona.amethyst.commons.connectedApps.signers.InMemoryNostrSignerPermissionStore +import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerPermissionLedger +import com.vitorpamplona.amethyst.commons.connectedApps.signers.SignerOpGrant +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair +import com.vitorpamplona.quartz.nip01Core.crypto.verify +import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestConnect +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestSign +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponseError +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponseEvent +import com.vitorpamplona.quartz.nip46RemoteSigner.server.BunkerRequestProcessor +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * End-to-end proof that interactive consent flows through the real dispatch path: + * [BunkerRequestProcessor] → [Nip46PermissionAuthorizer] → the `opConsent`/`connectConsent` + * prompts, with a real [NostrSignerInternal] doing the signing. Covers the two outcomes the + * per-op prompt must produce (a signed event vs. an `unauthorized` error) and a dangerous kind + * being allowed once the app is FULL_TRUST. + */ +class Nip46ConsentIntegrationTest { + private val signer = NostrSignerInternal(KeyPair()) + private val client = "c".repeat(64) + private val coordinate get() = Nip46PermissionAuthorizer.coordinateFor(signer.pubKey, client) + + private fun ledger() = NostrSignerPermissionLedger(InMemoryNostrSignerPermissionStore()) + + private fun signRequest(kind: Int) = BunkerRequestSign("1", EventTemplate(createdAt = 1L, kind = kind, tags = emptyArray(), content = "hi")) + + @Test + fun askSignPromptedAllowProducesASignedEvent() = + runTest { + val ledger = ledger() + ledger.setPolicy(coordinate, AppSignerPolicy.PARANOID) + val authorizer = + Nip46PermissionAuthorizer( + ledger, + signerPubKey = signer.pubKey, + validateSecret = { _, _ -> true }, + opConsent = { _, _, _, _ -> SignerOpGrant.AllowOnce }, + ) + val processor = BunkerRequestProcessor(signer, { emptySet() }, authorizer) + + val response = processor.process(client, signRequest(1)) + + assertTrue(response is BunkerResponseEvent, "an allowed sign returns the signed event") + assertEquals(signer.pubKey, response.event.pubKey, "the event is signed by the identity key") + } + + @Test + fun askSignDeniedReturnsUnauthorized() = + runTest { + val ledger = ledger() + ledger.setPolicy(coordinate, AppSignerPolicy.PARANOID) + val authorizer = + Nip46PermissionAuthorizer( + ledger, + signerPubKey = signer.pubKey, + validateSecret = { _, _ -> true }, + opConsent = { _, _, _, _ -> SignerOpGrant.DenyOnce }, + ) + val processor = BunkerRequestProcessor(signer, { emptySet() }, authorizer) + + val response = processor.process(client, signRequest(1)) + + assertTrue(response is BunkerResponseError) + assertEquals(BunkerRequestProcessor.ERROR_UNAUTHORIZED, response.error) + } + + @Test + fun signsARealWorldKind1FromAClientPreservingTheTemplate() = + runTest { + // The exact payload a client like Ditto would hand the bunker: a kind-1 note with a + // `client` tag, a fixed created_at, and content — signed remotely while the key stays on + // the identity signer (here NostrSignerInternal; in production an external Amber account). + val ledger = ledger() + ledger.setPolicy(coordinate, AppSignerPolicy.REASONABLE) + val authorizer = + Nip46PermissionAuthorizer( + ledger, + signerPubKey = signer.pubKey, + validateSecret = { _, _ -> true }, + // No opConsent wired: proves kind 1 is auto-allowed under REASONABLE (no prompt). + ) + val processor = BunkerRequestProcessor(signer, { emptySet() }, authorizer) + + val template = + EventTemplate( + createdAt = 1784214635L, + kind = 1, + tags = arrayOf(arrayOf("client", "Ditto", "31990:781a1527055f74c1f70230f10384609b34548f8ab6a0a6caa74025827f9fdae5:ditto")), + content = "Posting on Ditto through Amethyst's NIP-46 signer, with the keys fully offline on Amber.", + ) + + val response = processor.process(client, BunkerRequestSign("42", template)) + + assertTrue(response is BunkerResponseEvent, "kind 1 signs without a prompt under REASONABLE") + val event = response.event + assertEquals(1, event.kind) + assertEquals(1784214635L, event.createdAt, "the client-supplied created_at is preserved") + assertEquals(template.content, event.content) + assertEquals(listOf("client", "Ditto", "31990:781a1527055f74c1f70230f10384609b34548f8ab6a0a6caa74025827f9fdae5:ditto"), event.tags[0].toList()) + assertEquals(signer.pubKey, event.pubKey, "authored by the identity key, never the transport key") + assertTrue(event.verify(), "the signature and id are valid") + } + + @Test + fun fullTrustFromConnectConsentSignsEvenDangerousKinds() = + runTest { + val ledger = NostrSignerPermissionLedger(InMemoryNostrSignerPermissionStore()) + var opPrompts = 0 + val authorizer = + Nip46PermissionAuthorizer( + ledger, + signerPubKey = signer.pubKey, + validateSecret = { _, _ -> true }, + connectConsent = { _, _, _ -> AppConnectResult.Connected(AppSignerPolicy.FULL_TRUST) }, + opConsent = { _, _, _, _ -> + opPrompts++ + SignerOpGrant.DenyOnce + }, + ) + val processor = BunkerRequestProcessor(signer, { emptySet() }, authorizer) + + authorizer.onConnect(client, BunkerRequestConnect(id = "1", remoteKey = client, secret = "x")) + // kind 0 (profile) is a dangerous kind that REASONABLE would ASK for; FULL_TRUST allows it outright. + val response = processor.process(client, signRequest(0)) + + assertTrue(response is BunkerResponseEvent, "FULL_TRUST signs without prompting") + assertEquals(0, opPrompts, "a FULL_TRUST app never reaches the per-op prompt") + } +} diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/connectedApps/nip46/Nip46PermissionAuthorizerTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/connectedApps/nip46/Nip46PermissionAuthorizerTest.kt new file mode 100644 index 0000000000..96a51803c7 --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/connectedApps/nip46/Nip46PermissionAuthorizerTest.kt @@ -0,0 +1,397 @@ +/* + * 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.amethyst.commons.connectedApps.nip46 + +import com.vitorpamplona.amethyst.commons.connectedApps.signers.AppConnectResult +import com.vitorpamplona.amethyst.commons.connectedApps.signers.AppSignerPolicy +import com.vitorpamplona.amethyst.commons.connectedApps.signers.InMemoryNostrSignerPermissionStore +import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrOpDecision +import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerOp +import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerPermissionLedger +import com.vitorpamplona.amethyst.commons.connectedApps.signers.SignerOpGrant +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate +import com.vitorpamplona.quartz.nip10Notes.TextNoteEvent +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestConnect +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestNip44Decrypt +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestSign +import com.vitorpamplona.quartz.nip46RemoteSigner.server.Nip46ConnectDecision +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class Nip46PermissionAuthorizerTest { + private val signer = "a".repeat(64) + private val client = "c".repeat(64) + private val coordinate = Nip46PermissionAuthorizer.coordinateFor(signer, client) + + private fun ledger() = NostrSignerPermissionLedger(InMemoryNostrSignerPermissionStore()) + + private fun signRequest(kind: Int) = BunkerRequestSign("1", EventTemplate(createdAt = 1L, kind = kind, tags = emptyArray(), content = "x")) + + @Test + fun connectWithValidSecretRegistersReasonablePolicyAndEchoesSecret() = + runTest { + val ledger = ledger() + val authorizer = Nip46PermissionAuthorizer(ledger, signerPubKey = signer, validateSecret = { _, s -> s == "good" }) + + val decision = authorizer.onConnect(client, BunkerRequestConnect(id = "1", remoteKey = client, secret = "good")) + + assertTrue(decision is Nip46ConnectDecision.Accept) + assertEquals("good", decision.ackSecret) + assertEquals(AppSignerPolicy.REASONABLE, ledger.store.loadPolicy(coordinate)) + } + + @Test + fun connectWithBadSecretRejectsAndDoesNotRegister() = + runTest { + val ledger = ledger() + val authorizer = Nip46PermissionAuthorizer(ledger, signerPubKey = signer, validateSecret = { _, s -> s == "good" }) + + val decision = authorizer.onConnect(client, BunkerRequestConnect(id = "1", remoteKey = client, secret = "bad")) + + assertTrue(decision is Nip46ConnectDecision.Reject) + assertEquals(null, ledger.store.loadPolicy(coordinate)) + } + + @Test + fun connectDoesNotDowngradeAnExistingPolicy() = + runTest { + val ledger = ledger() + ledger.setPolicy(coordinate, AppSignerPolicy.FULL_TRUST) + val authorizer = Nip46PermissionAuthorizer(ledger, signerPubKey = signer, validateSecret = { _, _ -> true }) + + authorizer.onConnect(client, BunkerRequestConnect(id = "1", remoteKey = client, secret = "x")) + + assertEquals(AppSignerPolicy.FULL_TRUST, ledger.store.loadPolicy(coordinate)) + } + + @Test + fun reasonableAppAllowsTextNoteButRefusesDecrypt() = + runTest { + val ledger = ledger() + ledger.setPolicy(coordinate, AppSignerPolicy.REASONABLE) + val authorizer = Nip46PermissionAuthorizer(ledger, signerPubKey = signer, validateSecret = { _, _ -> true }) + + assertTrue(authorizer.authorize(client, signRequest(TextNoteEvent.KIND))) + assertFalse(authorizer.authorize(client, BunkerRequestNip44Decrypt("2", client, "ct"))) + } + + @Test + fun paranoidAppRefusesEverythingUntilPerOpGrant() = + runTest { + val ledger = ledger() + ledger.setPolicy(coordinate, AppSignerPolicy.PARANOID) + val authorizer = Nip46PermissionAuthorizer(ledger, signerPubKey = signer, validateSecret = { _, _ -> true }) + + assertFalse(authorizer.authorize(client, signRequest(TextNoteEvent.KIND))) + + ledger.setOpDecision(coordinate, NostrSignerOp.SignKind(TextNoteEvent.KIND), NostrOpDecision.ALLOW) + assertTrue(authorizer.authorize(client, signRequest(TextNoteEvent.KIND))) + } + + @Test + fun askWithoutAPromptFailsClosed() = + runTest { + val ledger = ledger() + ledger.setPolicy(coordinate, AppSignerPolicy.REASONABLE) + // No opConsent wired: an ASK op (decrypt under REASONABLE) is denied, never silently allowed. + val authorizer = Nip46PermissionAuthorizer(ledger, signerPubKey = signer, validateSecret = { _, _ -> true }) + assertFalse(authorizer.authorize(client, BunkerRequestNip44Decrypt("2", client, "ct"))) + } + + @Test + fun askPromptsAndAllowOnceIsNotPersisted() = + runTest { + val ledger = ledger() + ledger.setPolicy(coordinate, AppSignerPolicy.PARANOID) + var prompts = 0 + val authorizer = + Nip46PermissionAuthorizer( + ledger, + signerPubKey = signer, + validateSecret = { _, _ -> true }, + opConsent = { _, _, _, _ -> + prompts++ + SignerOpGrant.AllowOnce + }, + ) + + assertTrue(authorizer.authorize(client, signRequest(TextNoteEvent.KIND))) + assertTrue(authorizer.authorize(client, signRequest(TextNoteEvent.KIND))) + assertEquals(2, prompts, "allow-once must prompt every time") + } + + @Test + fun askPromptAllowForOpIsRememberedAndStopsPrompting() = + runTest { + val ledger = ledger() + ledger.setPolicy(coordinate, AppSignerPolicy.PARANOID) + var prompts = 0 + val op = NostrSignerOp.SignKind(TextNoteEvent.KIND) + val authorizer = + Nip46PermissionAuthorizer( + ledger, + signerPubKey = signer, + validateSecret = { _, _ -> true }, + opConsent = { _, _, _, _ -> + prompts++ + SignerOpGrant.AllowForOp(op) + }, + ) + + assertTrue(authorizer.authorize(client, signRequest(TextNoteEvent.KIND))) + assertTrue(authorizer.authorize(client, signRequest(TextNoteEvent.KIND))) + assertEquals(1, prompts, "allow-for-op persists, so the second request does not prompt") + assertEquals(NostrOpDecision.ALLOW, ledger.store.loadOpDecision(coordinate, op)) + } + + @Test + fun askPromptDenyOnceRefusesTheRequest() = + runTest { + val ledger = ledger() + ledger.setPolicy(coordinate, AppSignerPolicy.PARANOID) + val authorizer = + Nip46PermissionAuthorizer( + ledger, + signerPubKey = signer, + validateSecret = { _, _ -> true }, + opConsent = { _, _, _, _ -> SignerOpGrant.DenyOnce }, + ) + assertFalse(authorizer.authorize(client, signRequest(TextNoteEvent.KIND))) + } + + @Test + fun allowForSessionSkipsFuturePromptsUntilForgotten() = + runTest { + val ledger = ledger() + ledger.setPolicy(coordinate, AppSignerPolicy.PARANOID) + var prompts = 0 + val op = NostrSignerOp.SignKind(TextNoteEvent.KIND) + val authorizer = + Nip46PermissionAuthorizer( + ledger, + signerPubKey = signer, + validateSecret = { _, _ -> true }, + opConsent = { _, _, _, _ -> + prompts++ + SignerOpGrant.AllowForSession(op) + }, + ) + + assertTrue(authorizer.authorize(client, signRequest(TextNoteEvent.KIND))) + assertTrue(authorizer.authorize(client, signRequest(TextNoteEvent.KIND))) + assertEquals(1, prompts, "session grant is remembered in memory, no re-prompt") + assertEquals(null, ledger.store.loadOpDecision(coordinate, op), "session grant is not persisted") + + authorizer.forget(client) + assertTrue(authorizer.authorize(client, signRequest(TextNoteEvent.KIND))) + assertEquals(2, prompts, "forgetting clears the session grant, so it prompts again") + } + + @Test + fun firstConnectConsentChoosesTheTrustLevel() = + runTest { + val ledger = ledger() + val authorizer = + Nip46PermissionAuthorizer( + ledger, + signerPubKey = signer, + validateSecret = { _, _ -> true }, + connectConsent = { _, _, _ -> AppConnectResult.Connected(AppSignerPolicy.FULL_TRUST) }, + ) + + val decision = authorizer.onConnect(client, BunkerRequestConnect(id = "1", remoteKey = client, secret = "x")) + + assertTrue(decision is Nip46ConnectDecision.Accept) + assertEquals(AppSignerPolicy.FULL_TRUST, ledger.store.loadPolicy(coordinate)) + } + + @Test + fun cancelledConnectConsentRejectsAndStoresNoPolicy() = + runTest { + val ledger = ledger() + val authorizer = + Nip46PermissionAuthorizer( + ledger, + signerPubKey = signer, + validateSecret = { _, _ -> true }, + connectConsent = { _, _, _ -> AppConnectResult.Cancelled }, + ) + + val decision = authorizer.onConnect(client, BunkerRequestConnect(id = "1", remoteKey = client, secret = "x")) + + assertTrue(decision is Nip46ConnectDecision.Reject) + assertEquals(null, ledger.store.loadPolicy(coordinate)) + } + + @Test + fun parsePermsMapsTokensToOpsAndIgnoresTheRest() { + val ops = + Nip46PermissionAuthorizer.parsePerms( + "sign_event:1, sign_event:5, nip44_encrypt, nip04_decrypt, get_public_key, connect, sign_event", + ) + assertEquals( + listOf( + NostrSignerOp.SignKind(1), + NostrSignerOp.SignKind(5), + NostrSignerOp.Encrypt, + NostrSignerOp.Decrypt, + ), + ops, + "known tokens map to ops (encrypt/decrypt collapse NIP-04+44); get_public_key/connect/kindless sign_event are dropped", + ) + } + + @Test + fun parsePermsDeduplicatesAndHandlesBlank() { + assertTrue(Nip46PermissionAuthorizer.parsePerms(null).isEmpty()) + assertTrue(Nip46PermissionAuthorizer.parsePerms("").isEmpty()) + assertEquals( + listOf(NostrSignerOp.Encrypt), + Nip46PermissionAuthorizer.parsePerms("nip04_encrypt,nip44_encrypt"), + "NIP-04 and NIP-44 encrypt both map to Encrypt and are de-duplicated", + ) + } + + @Test + fun coordinateRoundTrips() { + assertEquals(client, Nip46PermissionAuthorizer.clientPubKeyOf(coordinate)) + assertEquals(null, Nip46PermissionAuthorizer.clientPubKeyOf("browser:https://x.com")) + } + + @Test + fun sameClientOnTwoAccountsGetsIndependentCoordinates() { + val otherSigner = "d".repeat(64) + val a = Nip46PermissionAuthorizer.coordinateFor(signer, client) + val b = Nip46PermissionAuthorizer.coordinateFor(otherSigner, client) + assertTrue(a != b) + assertTrue(Nip46PermissionAuthorizer.belongsTo(a, signer)) + assertFalse(Nip46PermissionAuthorizer.belongsTo(a, otherSigner)) + } + + @Test + fun logoutRevokesTheClientsGrant() = + runTest { + val ledger = ledger() + ledger.setPolicy(coordinate, AppSignerPolicy.FULL_TRUST) + val authorizer = Nip46PermissionAuthorizer(ledger, signerPubKey = signer, validateSecret = { _, _ -> true }) + + authorizer.onLogout(client) + + assertEquals(null, ledger.store.loadPolicy(coordinate)) + } + + @Test + fun forgetClearsGrantAndStoreAndSignalsDisconnect() = + runTest { + val ledger = ledger() + ledger.setPolicy(coordinate, AppSignerPolicy.FULL_TRUST) + val store = InMemoryNip46ClientStore() + store.store(coordinate, Nip46ClientInfo(name = "X", relays = setOf("wss://relay.example.com"))) + var disconnected: String? = null + val authorizer = + Nip46PermissionAuthorizer( + ledger, + signerPubKey = signer, + validateSecret = { _, _ -> true }, + clientStore = store, + onDisconnected = { disconnected = it }, + ) + + authorizer.forget(client) + + assertEquals(null, ledger.store.loadPolicy(coordinate), "grant cleared") + assertEquals(null, store.load(coordinate), "stored metadata + relays cleared") + assertEquals(client, disconnected, "host notified so it can drop the relays this session") + } + + @Test + fun pruneIdleForgetsAppsPastTheCutoffAndKeepsActiveOnes() = + runTest { + val ledger = ledger() + val store = InMemoryNip46ClientStore() + val idle = "b".repeat(64) + val active = "e".repeat(64) + val idleCoord = Nip46PermissionAuthorizer.coordinateFor(signer, idle) + val activeCoord = Nip46PermissionAuthorizer.coordinateFor(signer, active) + ledger.setPolicy(idleCoord, AppSignerPolicy.REASONABLE) + ledger.setPolicy(activeCoord, AppSignerPolicy.REASONABLE) + store.store(idleCoord, Nip46ClientInfo(name = "Idle", relays = setOf("wss://idle.example.com"))) + store.store(activeCoord, Nip46ClientInfo(name = "Active", relays = setOf("wss://active.example.com"))) + + val now = 1_000_000L + val idleSeconds = 100L + ledger.updateLastUsed(idleCoord, now - idleSeconds - 1) + ledger.updateLastUsed(activeCoord, now - idleSeconds + 1) + + val disconnected = mutableListOf() + val authorizer = + Nip46PermissionAuthorizer( + ledger, + signerPubKey = signer, + validateSecret = { _, _ -> true }, + clientStore = store, + onDisconnected = { disconnected.add(it) }, + ) + + val pruned = authorizer.pruneIdle(idleSeconds, now = now) + + assertEquals(listOf(idle), pruned, "only the idle app is forgotten") + assertEquals(listOf(idle), disconnected, "host notified for the idle app so its relay is dropped") + assertEquals(null, ledger.store.loadPolicy(idleCoord), "idle grant cleared") + assertEquals(AppSignerPolicy.REASONABLE, ledger.store.loadPolicy(activeCoord), "active grant kept") + assertEquals(null, store.load(idleCoord), "idle metadata cleared") + } + + @Test + fun pruneIdleIgnoresOtherAccountsApps() = + runTest { + val ledger = ledger() + val otherSigner = "f".repeat(64) + val otherCoord = Nip46PermissionAuthorizer.coordinateFor(otherSigner, client) + ledger.setPolicy(otherCoord, AppSignerPolicy.REASONABLE) + ledger.updateLastUsed(otherCoord, 0L) + val authorizer = Nip46PermissionAuthorizer(ledger, signerPubKey = signer, validateSecret = { _, _ -> true }) + + val pruned = authorizer.pruneIdle(100L, now = 1_000_000L) + + assertTrue(pruned.isEmpty(), "an app belonging to another account is never pruned by this signer") + assertEquals(AppSignerPolicy.REASONABLE, ledger.store.loadPolicy(otherCoord)) + } + + @Test + fun logoutIsEquivalentToForget() = + runTest { + val ledger = ledger() + ledger.setPolicy(coordinate, AppSignerPolicy.REASONABLE) + val store = InMemoryNip46ClientStore() + store.store(coordinate, Nip46ClientInfo(name = "X")) + val authorizer = Nip46PermissionAuthorizer(ledger, signerPubKey = signer, validateSecret = { _, _ -> true }, clientStore = store) + + authorizer.onLogout(client) + + assertEquals(null, ledger.store.loadPolicy(coordinate)) + assertEquals(null, store.load(coordinate)) + } +} diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/napplet/signers/NostrSignerPermissionLedgerTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/connectedApps/signers/NostrSignerPermissionLedgerTest.kt similarity index 98% rename from commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/napplet/signers/NostrSignerPermissionLedgerTest.kt rename to commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/connectedApps/signers/NostrSignerPermissionLedgerTest.kt index e872b25ddb..f07eb5e833 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/napplet/signers/NostrSignerPermissionLedgerTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/connectedApps/signers/NostrSignerPermissionLedgerTest.kt @@ -18,7 +18,7 @@ * 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.amethyst.commons.napplet.signers +package com.vitorpamplona.amethyst.commons.connectedApps.signers import kotlinx.coroutines.test.runTest import kotlin.test.Test diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBrokerTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBrokerTest.kt index 6e43798da5..6cc8c16213 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBrokerTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBrokerTest.kt @@ -20,17 +20,17 @@ */ package com.vitorpamplona.amethyst.commons.napplet +import com.vitorpamplona.amethyst.commons.connectedApps.signers.AppConnectResult +import com.vitorpamplona.amethyst.commons.connectedApps.signers.AppSignerPolicy +import com.vitorpamplona.amethyst.commons.connectedApps.signers.InMemoryNostrSignerPermissionStore +import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrConnectPrompt +import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerPermissionLedger import com.vitorpamplona.amethyst.commons.napplet.permissions.GrantState import com.vitorpamplona.amethyst.commons.napplet.permissions.InMemoryNappletPermissionStore import com.vitorpamplona.amethyst.commons.napplet.permissions.NappletPermissionLedger import com.vitorpamplona.amethyst.commons.napplet.permissions.PermissionDecision import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletRequest import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletResponse -import com.vitorpamplona.amethyst.commons.napplet.signers.AppConnectResult -import com.vitorpamplona.amethyst.commons.napplet.signers.AppSignerPolicy -import com.vitorpamplona.amethyst.commons.napplet.signers.InMemoryNostrSignerPermissionStore -import com.vitorpamplona.amethyst.commons.napplet.signers.NostrConnectPrompt -import com.vitorpamplona.amethyst.commons.napplet.signers.NostrSignerPermissionLedger import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/NostrConnectURI.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/NostrConnectURI.kt new file mode 100644 index 0000000000..004fdcb4ff --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/NostrConnectURI.kt @@ -0,0 +1,193 @@ +/* + * 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 + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.utils.Hex + +/** + * KMP-safe parsing and building of the two NIP-46 pairing URIs: + * + * - `bunker://?relay=…&secret=…` — the **signer** + * advertises how to reach it (Amethyst mints this when it acts as a bunker). + * - `nostrconnect://?relay=…&secret=…&perms=…&name=…&url=…&image=…` + * — the **client** offers to connect and the signer answers with a connect + * ack that echoes the secret (Amethyst parses this when a user pastes an + * offer to pair a new app). + * + * Values are percent-encoded per the spec/nak convention (`relay=wss%3A%2F%2F…`); + * this object encodes/decodes without any JVM-only `java.net.URLEncoder`, so it + * lives in `commonMain` and is shared by the CLI, desktop and Android. + */ +object NostrConnectURI { + const val BUNKER_SCHEME = "bunker://" + const val NOSTRCONNECT_SCHEME = "nostrconnect://" + + /** A parsed `bunker://` advertisement. */ + data class Bunker( + val remoteSignerPubKey: HexKey, + val relays: Set, + val secret: String?, + ) + + /** A parsed `nostrconnect://` client offer. */ + data class NostrConnect( + val clientPubKey: HexKey, + val relays: Set, + val secret: String, + val perms: String? = null, + val name: String? = null, + val url: String? = null, + val image: String? = null, + ) + + /** Parse a `bunker://?relay=…&secret=…` URI, or `null` if malformed. */ + fun parseBunker(uri: String): Bunker? { + if (!uri.startsWith(BUNKER_SCHEME)) return null + val (pubkey, params) = splitAuthority(uri.removePrefix(BUNKER_SCHEME)) ?: return null + if (!isValidPubKey(pubkey)) return null + val relays = mutableSetOf() + var secret: String? = null + forEachParam(params) { key, value -> + when (key) { + "relay" -> RelayUrlNormalizer.normalizeOrNull(value)?.let { relays.add(it) } + "secret" -> secret = value + } + } + return Bunker(pubkey, relays, secret) + } + + /** Build a `bunker://?relay=…&secret=…` advertisement URI. */ + fun buildBunker( + remoteSignerPubKey: HexKey, + relays: Collection, + secret: String?, + ): String = + buildString { + append(BUNKER_SCHEME).append(remoteSignerPubKey) + append('?').append(relays.joinToString("&") { "relay=${encode(it.url)}" }) + if (secret != null) append("&secret=").append(encode(secret)) + } + + /** Parse a `nostrconnect://?relay=…&secret=…&…` offer, or `null` if malformed. */ + fun parseNostrConnect(uri: String): NostrConnect? { + if (!uri.startsWith(NOSTRCONNECT_SCHEME)) return null + val (pubkey, params) = splitAuthority(uri.removePrefix(NOSTRCONNECT_SCHEME)) ?: return null + if (!isValidPubKey(pubkey)) return null + val relays = mutableSetOf() + var secret: String? = null + var perms: String? = null + var name: String? = null + var url: String? = null + var image: String? = null + forEachParam(params) { key, value -> + when (key) { + "relay" -> RelayUrlNormalizer.normalizeOrNull(value)?.let { relays.add(it) } + "secret" -> secret = value + "perms" -> perms = value + "name" -> name = value + "url" -> url = value + "image" -> image = value + } + } + val validSecret = secret ?: return null + return NostrConnect(pubkey.lowercase(), relays, validSecret, perms, name, url, image) + } + + /** Build a `nostrconnect://?relay=…&secret=…&…` offer URI. */ + fun buildNostrConnect( + clientPubKey: HexKey, + relays: Collection, + secret: String, + perms: String? = null, + name: String? = null, + url: String? = null, + image: String? = null, + ): String = + buildString { + append(NOSTRCONNECT_SCHEME).append(clientPubKey) + append('?').append(relays.joinToString("&") { "relay=${encode(it.url)}" }) + append("&secret=").append(encode(secret)) + if (perms != null) append("&perms=").append(encode(perms)) + if (name != null) append("&name=").append(encode(name)) + if (url != null) append("&url=").append(encode(url)) + if (image != null) append("&image=").append(encode(image)) + } + + private fun isValidPubKey(pubkey: String): Boolean = pubkey.length == 64 && Hex.isHex(pubkey) + + /** Splits `?` into the authority and the raw query (empty when no `?`). */ + private fun splitAuthority(rest: String): Pair? { + val parts = rest.split("?", limit = 2) + val authority = parts[0] + if (authority.isEmpty()) return null + return authority to (parts.getOrNull(1) ?: "") + } + + private inline fun forEachParam( + query: String, + action: (key: String, value: String) -> Unit, + ) { + if (query.isEmpty()) return + for (param in query.split("&")) { + val kv = param.split("=", limit = 2) + if (kv.size < 2) continue + action(kv[0], decode(kv[1])) + } + } + + /** Percent-decode a query value (e.g. `wss%3A%2F%2F…` → `wss://…`). */ + fun decode(input: String): String { + if ('%' !in input) return input + val bytes = ArrayList(input.length) + var i = 0 + while (i < input.length) { + val c = input[i] + if (c == '%' && i + 2 < input.length) { + val code = input.substring(i + 1, i + 3).toIntOrNull(16) + if (code != null) { + bytes.add(code.toByte()) + i += 3 + continue + } + } + for (b in c.toString().encodeToByteArray()) bytes.add(b) + i++ + } + return bytes.toByteArray().decodeToString() + } + + /** Percent-encode a query value; only unreserved `A-Za-z0-9-._~` pass through. */ + fun encode(input: String): String { + val sb = StringBuilder(input.length) + for (b in input.encodeToByteArray()) { + val c = (b.toInt() and 0xFF).toChar() + if (c.isLetterOrDigit() && c.code < 128 || c in "-._~") { + sb.append(c) + } else { + sb.append('%').append((b.toInt() and 0xFF).toString(16).padStart(2, '0').uppercase()) + } + } + return sb.toString() + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/BunkerRequestProcessor.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/BunkerRequestProcessor.kt new file mode 100644 index 0000000000..973f288b45 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/BunkerRequestProcessor.kt @@ -0,0 +1,181 @@ +/* + * 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.server + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequest +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestConnect +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestGetPublicKey +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestGetRelays +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.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.BunkerResponseGetRelays +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponsePong +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponsePublicKey +import com.vitorpamplona.quartz.nip46RemoteSigner.ReadWrite +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +/** + * The signer/bunker side of NIP-46: turns a decrypted [BunkerRequest] from a + * client into the [BunkerResponse] the client expects, performing the actual + * crypto with the user's own [signer]. + * + * This is the "NIP-46 processor" — it is deliberately signer-agnostic: [signer] + * can be a local keypair ([NostrSignerInternal]) or an external NIP-55 app + * ([NostrSignerExternal]), and every request is fulfilled through the same + * [NostrSigner] surface (`sign`, `nip04/44Encrypt/Decrypt`). Whichever signer + * the user logged in with is the one that ultimately performs the work. + * + * Authorization is delegated to [authorizer]: signing/encryption/decryption are + * gated, while public/harmless reads (`get_public_key`, `ping`, `get_relays`) + * always succeed. All failures — decryption, authorization, an unsupported + * method, or an exception from the signer — are turned into a + * [BunkerResponseError] carrying the request id, so the client always gets a + * reply it can correlate. + * + * Pairs with [NostrConnectSignerService], which subscribes to the relays, + * decrypts each kind-24133 request, calls [process], and publishes the reply. + */ +class BunkerRequestProcessor( + val signer: NostrSigner, + val relays: suspend () -> Set, + val authorizer: Nip46RequestAuthorizer, +) { + /** + * Serializes the actual crypto ([signer] `sign`/`nip04|44_*`) across concurrent [process] calls. + * The service may run several requests at once so their consent prompts can batch, but the identity + * signer — especially an external NIP-55 app reached over IPC — must not see concurrent operations, + * so only the crypto runs under this lock. Authorization (which may open a user prompt and block for + * a long time) runs OUTSIDE the lock, so a pending prompt never stalls other clients' signing. + */ + private val cryptoLock = Mutex() + + /** + * Fulfils a single decrypted [request] sent by [clientPubKey], returning the + * response to encrypt and send back. Never throws — signer/authorizer errors + * are captured as [BunkerResponseError]. + */ + suspend fun process( + clientPubKey: HexKey, + request: BunkerRequest, + ): BunkerResponse = + try { + when (request) { + is BunkerRequestConnect -> + when (val decision = authorizer.onConnect(clientPubKey, request)) { + is Nip46ConnectDecision.Accept -> BunkerResponse(request.id, decision.ackSecret, null) + is Nip46ConnectDecision.Reject -> BunkerResponseError(request.id, decision.reason) + } + + is BunkerRequestGetPublicKey -> BunkerResponsePublicKey(request.id, signer.pubKey) + + is BunkerRequestPing -> BunkerResponsePong(request.id) + + is BunkerRequestGetRelays -> + BunkerResponseGetRelays(request.id, relays().associate { it.url to ReadWrite(read = true, write = true) }) + + is BunkerRequestSign -> + ifAuthorized(clientPubKey, request) { + val signed = signer.sign(request.event.createdAt, request.event.kind, request.event.tags, request.event.content) + BunkerResponseEvent(request.id, signed) + } + + is BunkerRequestNip04Encrypt -> + ifAuthorized(clientPubKey, request) { + BunkerResponseEncrypt(request.id, signer.nip04Encrypt(request.message, request.pubKey)) + } + + is BunkerRequestNip04Decrypt -> + ifAuthorized(clientPubKey, request) { + BunkerResponseDecrypt(request.id, signer.nip04Decrypt(request.ciphertext, request.pubKey)) + } + + is BunkerRequestNip44Encrypt -> + ifAuthorized(clientPubKey, request) { + BunkerResponseEncrypt(request.id, signer.nip44Encrypt(request.message, request.pubKey)) + } + + is BunkerRequestNip44Decrypt -> + ifAuthorized(clientPubKey, request) { + BunkerResponseDecrypt(request.id, signer.nip44Decrypt(request.ciphertext, request.pubKey)) + } + + else -> + when (request.method) { + METHOD_LOGOUT -> { + authorizer.onLogout(clientPubKey) + BunkerResponseAck(request.id) + } + else -> BunkerResponseError(request.id, "unsupported method: ${request.method}") + } + } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + BunkerResponseError(request.id, "${e::class.simpleName}: ${e.message}") + } + + private suspend inline fun ifAuthorized( + clientPubKey: HexKey, + request: BunkerRequest, + block: () -> BunkerResponse, + ): BunkerResponse = + if (!signer.isWriteable()) { + // The account this bunker signs for is no longer usable — logged out, read-only, or its + // external signer is gone — so refuse rather than prompt or hang on a key we can't use. + BunkerResponseError(request.id, ERROR_ACCOUNT_UNAVAILABLE) + } else if (authorizer.authorize(clientPubKey, request)) { + // Authorization ran unlocked (it may have blocked on a user prompt); the crypto itself runs + // under [cryptoLock] so concurrent authorized requests don't hit the signer at the same time. + cryptoLock.withLock { block() } + } else { + BunkerResponseError(request.id, ERROR_UNAUTHORIZED) + } + + companion object { + /** Ack result for a `connect` request with no secret to echo (mirrors [BunkerResponseAck.RESULT]). */ + const val ACK: String = "ack" + + /** Error result returned when [Nip46RequestAuthorizer.authorize] denies a request. */ + const val ERROR_UNAUTHORIZED: String = "unauthorized" + + /** Error result returned when the account can no longer sign (logged out / read-only / no signer). */ + const val ERROR_ACCOUNT_UNAVAILABLE: String = "account unavailable" + + /** NIP-46 `logout` method name — the client asks to be disconnected. */ + const val METHOD_LOGOUT: String = "logout" + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/Nip46RequestAuthorizer.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/Nip46RequestAuthorizer.kt new file mode 100644 index 0000000000..670208581b --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/Nip46RequestAuthorizer.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.server + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequest +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestConnect + +/** + * The authorization boundary a NIP-46 signer (a "bunker") consults before it + * performs a request on the user's behalf. It is the protocol-agnostic hook the + * host app uses to plug in *its* permission model — in Amethyst that is the + * shared "Connected Apps" ledger, so the same trust levels that gate napplets + * and web apps also gate remote NIP-46 clients. + * + * [BunkerRequestProcessor] owns the wire logic; this interface owns "may this + * client do this?". Everything here is `suspend` so an implementation may block + * on disk, a live user prompt, or IPC without changing the processor. + * + * Public, harmless requests (`get_public_key`, `ping`, `get_relays`) are NOT + * routed through [authorize]; only signing, encryption and decryption are. + */ +interface Nip46RequestAuthorizer { + /** + * Called when a client sends a `connect` request. The implementation + * validates the offered secret (the `bunker://…?secret=…` pairing token), + * registers the client as a connected app if it accepts, and returns the + * decision. On accept the returned [Nip46ConnectDecision.Accept.ackSecret] + * is echoed to the client (the offered secret, or `"ack"` when none was set). + */ + suspend fun onConnect( + clientPubKey: HexKey, + request: BunkerRequestConnect, + ): Nip46ConnectDecision + + /** + * Called before every signing/encryption/decryption request. Return `true` + * to perform it, `false` to reject the client with an "unauthorized" error. + * Implementations typically map [request] to a per-app permission and read + * the standing grant/deny decision for [clientPubKey]. + */ + suspend fun authorize( + clientPubKey: HexKey, + request: BunkerRequest, + ): Boolean + + /** + * Called when a client sends a `logout` request — the client is asking to be + * disconnected. Implementations typically revoke the app's stored grant so a + * later request has to pair again. The default is a no-op. + */ + suspend fun onLogout(clientPubKey: HexKey) {} +} + +/** The verdict for a NIP-46 `connect` request. */ +sealed class Nip46ConnectDecision { + /** + * Accept the connection. [ackSecret] is echoed back to the client as the + * connect result — the offered secret when one was set (so the client can + * validate it), otherwise `"ack"`. + */ + data class Accept( + val ackSecret: String, + ) : Nip46ConnectDecision() + + /** Reject the connection; [reason] is returned to the client as an error. */ + data class Reject( + val reason: String, + ) : Nip46ConnectDecision() +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/NostrConnectSignerService.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/NostrConnectSignerService.kt new file mode 100644 index 0000000000..4be37a0b84 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/NostrConnectSignerService.kt @@ -0,0 +1,282 @@ +/* + * 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.server + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener +import com.vitorpamplona.quartz.nip01Core.relay.client.single.newSubId +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequest +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponseError +import com.vitorpamplona.quartz.nip46RemoteSigner.NostrConnectEvent +import com.vitorpamplona.quartz.utils.Log +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.launch +import kotlinx.coroutines.supervisorScope +import kotlinx.coroutines.sync.Semaphore + +/** + * Runs a NIP-46 remote signer ("bunker") for one account: subscribes to the + * given [relays] for kind-24133 requests addressed to the bunker, decrypts each + * envelope, hands the request to [processor], and publishes the encrypted reply. + * + * Two keys are deliberately kept apart: + * - [transportSigner] wraps and unwraps the kind-24133 envelope (subscription + * p-tag, [NostrConnectEvent.decryptMessage] / [NostrConnectEvent.create]). It + * is a dedicated per-account key that has nothing to do with the user's + * identity, so the bunker address and the on-relay traffic don't reveal WHO + * the bunker is for, and — since it is a local key — the envelope crypto never + * round-trips an external NIP-55 signer. + * - The [processor]'s signer is the user's IDENTITY signer (a local key or a + * NIP-55 app); it performs the actual `sign_event`/`nip04|44_*` work and + * answers `get_public_key` with the real npub — revealed only to a connected + * client, over the already-encrypted channel. + * + * [run] is a long-running suspend loop: it services requests until the calling + * coroutine is cancelled, then tears the subscription down. Callers who need to + * follow a changing relay set (e.g. the user editing their inbox relays) should + * cancel and relaunch [run] with the new set. + */ +class NostrConnectSignerService( + val client: INostrClient, + val transportSigner: NostrSigner, + val processor: BunkerRequestProcessor, + val relays: Set, + /** Optional hook, invoked with each serviced request + client, for logging/metrics/activity feeds. */ + val onServiced: ((request: BunkerRequest, clientPubKey: String, error: String?) -> Unit)? = null, + /** + * Upper bound on the dedup set of seen kind-24133 **event ids** (the wrapper events, so the same + * request fanned in from multiple relays is handled once). A long-lived signer would otherwise + * accumulate every event id it saw; past this many the oldest are evicted (far past any realistic + * same-event redelivery window). NOTE: this is keyed by the wrapper event id, not the inner NIP-46 + * request id, and it does not survive a service restart — the [maxRequestAgeSeconds] gate is what + * suppresses relay replays of old requests across re-subscriptions. + */ + val seenCap: Int = 4096, + /** + * Bound on events buffered between the relay threads and the single consumer. + * Under a flood (a looping client, or a hostile peer p-tagging us) the newest + * events past this many are dropped instead of growing memory without limit. + * Envelope decryption is local, but each serviced request can drive an external + * NIP-55 op on the identity signer, so the queue must not run away. + */ + val maxQueue: Int = 256, + /** Max requests decrypted per author within [rateWindowSeconds] before further ones are dropped. */ + val maxRequestsPerWindow: Int = 40, + val rateWindowSeconds: Long = 10, + /** Cap on distinct authors tracked for rate-limiting (evicts oldest) so key-rotation can't grow it. */ + val maxTrackedAuthors: Int = 512, + /** + * Requests older than this (by `created_at`) are ignored. kind-24133 is ephemeral, but many relays + * store and REPLAY it whenever we re-subscribe (a relay-set change, reconnect, toggle, or rotation), + * and the in-memory dedup set does not survive that restart — so without an age gate the same + * minutes-old request gets signed again. Applied both as a `since` on the subscription (compliant + * relays never replay it) and as a receive-side guard (for relays that ignore `since`). The window + * must exceed realistic client/relay clock skew so a genuinely fresh request is never dropped, but + * kept small so an app restart re-signs as little as possible (relays replay only this far back). + */ + val maxRequestAgeSeconds: Long = 30, + /** + * How many requests may be in-flight (past dedup/rate-limit) at once. Each request is handled in + * its own child coroutine so that a request awaiting a user consent prompt does NOT block other + * clients' auto-allowed traffic — and so several prompts can be pending together and be approved in + * one batch. Intake (dedup, staleness, rate-limit) stays on the single consumer; only [handle] fans + * out. The actual crypto is still serialized inside [BunkerRequestProcessor]. This bounds how many + * child coroutines (and pending prompts) can accumulate under a flood. + */ + val maxConcurrentHandles: Int = 16, + /** + * Event ids serviced in a previous run, used to seed the in-memory dedup set so a relay replaying + * stored requests across an app restart is caught by EXACT event id — immune to client clock skew, + * unlike a timestamp floor (which would wrongly drop a second app whose clock lags). The host + * persists these (bounded) and feeds them back on start; see [onHandledId]. + */ + val initialSeen: Set = emptySet(), + /** Invoked with each serviced request's kind-24133 event id so the host can persist it for [initialSeen]. */ + val onHandledId: (suspend (eventId: String) -> Unit)? = null, +) { + /** + * Fixed-window per-author rate limit. Touched only by the single consumer + * coroutine (never the relay threads), so a plain map needs no synchronization. + */ + private class RateLimiter( + val maxPerWindow: Int, + val windowSeconds: Long, + val maxAuthors: Int, + ) { + private class Window( + var start: Long, + var count: Int, + ) + + private val windows = LinkedHashMap() + + fun allow( + author: String, + now: Long, + ): Boolean { + val window = windows.getOrPut(author) { Window(now, 0) } + if (now - window.start >= windowSeconds) { + window.start = now + window.count = 0 + } + if (windows.size > maxAuthors) { + windows.iterator().let { + it.next() + it.remove() + } + } + if (window.count >= maxPerWindow) return false + window.count++ + return true + } + } + + /** + * Subscribes and services requests until cancelled. Duplicate events (the + * same request seen on more than one relay) are handled once. Never returns + * normally — it loops until the coroutine is cancelled. + */ + suspend fun run() { + if (relays.isEmpty()) { + Log.w("NIP46Signer") { "no relays to listen on; signer service is idle" } + return + } + + // supervisorScope: each request is handled in a child coroutine so a prompt awaiting the user + // doesn't block the loop or other clients; a failing child never tears down the loop or siblings. + supervisorScope { + runLoop() + } + } + + private suspend fun CoroutineScope.runLoop() { + val self = transportSigner.pubKey + // Bounded + DROP_LATEST so a flood bounds memory instead of growing an unlimited queue. + val events = Channel(capacity = maxQueue, onBufferOverflow = BufferOverflow.DROP_LATEST) + val rateLimiter = RateLimiter(maxRequestsPerWindow, rateWindowSeconds, maxTrackedAuthors) + val subId = newSubId() + val listener = + object : SubscriptionListener { + override fun onEvent( + event: Event, + isLive: Boolean, + relay: NormalizedRelayUrl, + forFilters: List?, + ) { + // onEvent is invoked CONCURRENTLY from each relay's socket thread, so it must + // touch no shared mutable state here — the (thread-safe) channel send is all it + // does; dedup happens in the single-threaded consumer below. + if (event is NostrConnectEvent && event.verifiedRecipientPubKey() == self) { + events.trySend(event) + } + } + } + + // Insertion-ordered dedup, confined to this one consumer coroutine (never the relay threads); + // evicts the oldest id past the cap so a long-lived signer can't grow it without bound. + // Seed the dedup set with ids serviced in a prior run so a relay replaying stored requests after + // a restart is caught by exact id (see [initialSeen]). + val seen = LinkedHashSet(initialSeen) + // Bounds how many requests can be in-flight (and how many prompts can be pending) at once. + val handleGate = Semaphore(maxConcurrentHandles) + // Only ask relays for recent requests: kind-24133 is ephemeral, but relays that store it would + // otherwise replay every old request each time we (re)subscribe. See [maxRequestAgeSeconds]. + val filter = Filter(kinds = listOf(NostrConnectEvent.KIND), tags = mapOf("p" to listOf(self)), since = TimeUtils.now() - maxRequestAgeSeconds) + client.subscribe(subId, relays.associateWith { listOf(filter) }, listener) + try { + while (true) { + val event = events.receive() + if (!seen.add(event.id)) continue // same request already handled (arrived on another relay) + if (seen.size > seenCap) { + seen.iterator().let { + it.next() + it.remove() + } + } + // Drop stale requests a relay replayed from storage past the rolling age window (the + // `since` filter covers compliant relays; this covers the rest; exact-id replays within + // the window are already caught by [seen] above). A live NIP-46 request is seconds old. + if (TimeUtils.now() - event.createdAt > maxRequestAgeSeconds) { + Log.w("NIP46Signer") { "ignoring stale request ${event.id.take(8)}… (created ${event.createdAt})" } + continue + } + // Rate-limit per author BEFORE decrypting — decryption can be an external-signer + // round-trip, so a flooding client must not force one per event. + if (!rateLimiter.allow(event.pubKey, TimeUtils.now())) { + Log.w("NIP46Signer") { "rate-limited request from ${event.pubKey.take(8)}…" } + continue + } + // Remember this id (persisted by the host) so a later restart won't re-service the replay. + // Done on the single consumer — BEFORE fanning out — because the host's seen-id store is + // not synchronized; a request we decided to service here should not be re-prompted after a + // restart even if it is ultimately denied (the in-memory `seen` already covers this run). + onHandledId?.invoke(event.id) + // Acquire BEFORE launching so intake applies backpressure at the cap instead of spawning + // unbounded child coroutines; dedup/rate-limit above already ran on this single consumer. + handleGate.acquire() + launch { + try { + handle(event) + } finally { + handleGate.release() + } + } + } + } finally { + client.unsubscribe(subId) + events.close() + } + } + + private suspend fun handle(event: NostrConnectEvent) { + val client = event.talkingWith(transportSigner.pubKey) + val request = + try { + event.decryptMessage(transportSigner) as? BunkerRequest ?: return + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Log.w("NIP46Signer") { "could not decrypt request ${event.id.take(8)}: ${e.message}" } + return + } + + val response = processor.process(client, request) + val error = (response as? BunkerResponseError)?.error + onServiced?.invoke(request, client, error) + + try { + val reply = NostrConnectEvent.create(response, client, transportSigner) + this.client.publish(reply, relays) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Log.w("NIP46Signer") { "failed to send reply for ${request.method}: ${e.message}" } + } + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/NostrConnectURITest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/NostrConnectURITest.kt new file mode 100644 index 0000000000..30499d4276 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/NostrConnectURITest.kt @@ -0,0 +1,99 @@ +/* + * 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 + +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class NostrConnectURITest { + private val pubkey = "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798" + private val relay = RelayUrlNormalizer.normalizeOrNull("wss://relay.example.com")!! + + @Test + fun parseBunkerWithPercentEncodedRelay() { + val uri = "bunker://$pubkey?relay=wss%3A%2F%2Frelay.example.com&secret=abc123" + val parsed = NostrConnectURI.parseBunker(uri)!! + assertEquals(pubkey, parsed.remoteSignerPubKey) + assertEquals("abc123", parsed.secret) + assertTrue(parsed.relays.contains(relay)) + } + + @Test + fun parseBunkerRejectsWrongScheme() { + assertNull(NostrConnectURI.parseBunker("nostrconnect://$pubkey?secret=x")) + } + + @Test + fun parseBunkerRejectsBadPubkey() { + assertNull(NostrConnectURI.parseBunker("bunker://not-a-key?secret=x")) + } + + @Test + fun buildBunkerRoundTrips() { + val uri = NostrConnectURI.buildBunker(pubkey, setOf(relay), "s3cr3t") + val parsed = NostrConnectURI.parseBunker(uri)!! + assertEquals(pubkey, parsed.remoteSignerPubKey) + assertEquals("s3cr3t", parsed.secret) + assertTrue(parsed.relays.contains(relay)) + // relay must be percent-encoded in the built URI + assertTrue(uri.contains("relay=wss%3A%2F%2F")) + } + + @Test + fun parseNostrConnectFull() { + val uri = + "nostrconnect://$pubkey?relay=wss%3A%2F%2Frelay.example.com&secret=xyz&perms=sign_event%3A1%2Cnip44_encrypt&name=My%20App" + val parsed = NostrConnectURI.parseNostrConnect(uri)!! + assertEquals(pubkey, parsed.clientPubKey) + assertEquals("xyz", parsed.secret) + assertEquals("sign_event:1,nip44_encrypt", parsed.perms) + assertEquals("My App", parsed.name) + assertTrue(parsed.relays.contains(relay)) + } + + @Test + fun parseNostrConnectRequiresSecret() { + assertNull(NostrConnectURI.parseNostrConnect("nostrconnect://$pubkey?relay=wss%3A%2F%2Frelay.example.com")) + } + + @Test + fun nostrConnectRoundTrips() { + val uri = NostrConnectURI.buildNostrConnect(pubkey, setOf(relay), "sec", perms = "sign_event:1", name = "Amethyst") + val parsed = NostrConnectURI.parseNostrConnect(uri)!! + assertEquals(pubkey, parsed.clientPubKey) + assertEquals("sec", parsed.secret) + assertEquals("sign_event:1", parsed.perms) + assertEquals("Amethyst", parsed.name) + } + + @Test + fun decodeHandlesUtf8() { + assertEquals("café", NostrConnectURI.decode("caf%C3%A9")) + } + + @Test + fun encodeLeavesUnreservedUntouched() { + assertEquals("abcXYZ-._~", NostrConnectURI.encode("abcXYZ-._~")) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/BunkerRequestProcessorConcurrencyTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/BunkerRequestProcessorConcurrencyTest.kt new file mode 100644 index 0000000000..f64a4fc7a6 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/BunkerRequestProcessorConcurrencyTest.kt @@ -0,0 +1,172 @@ +/* + * 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.server + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequest +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestConnect +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestSign +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponse +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponseEvent +import com.vitorpamplona.quartz.nip57Zaps.LnZapPrivateEvent +import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * Concurrency guarantees the service relies on when it fans requests out into child coroutines so + * their consent prompts can batch: the identity signer's crypto must never run concurrently (an + * external NIP-55 app can't take overlapping IPC ops), while authorization — which may block on a + * user prompt for a long time — must NOT hold that lock, so a pending prompt can't stall other + * clients' signing. + */ +class BunkerRequestProcessorConcurrencyTest { + private val userPubKey = "a".repeat(64) + private val clientPubKey = "c".repeat(64) + private val relay = RelayUrlNormalizer.normalizeOrNull("wss://relay.example.com")!! + + private fun signTemplate(id: String) = BunkerRequestSign(id, EventTemplate(createdAt = 1L, kind = 1, tags = emptyArray(), content = "hi")) + + /** A signer whose sign() parks on [signGate] so tests can observe how many run at once. */ + private class GatedSigner( + pubKey: HexKey, + val signGate: CompletableDeferred, + ) : NostrSigner(pubKey) { + var inFlight = 0 + var maxConcurrent = 0 + var signCount = 0 + + val canned = Event(id = "e".repeat(64), pubKey = pubKey, createdAt = 1L, kind = 1, tags = emptyArray(), content = "s", sig = "f".repeat(128)) + + override fun isWriteable() = true + + @Suppress("UNCHECKED_CAST") + override suspend fun sign( + createdAt: Long, + kind: Int, + tags: Array>, + content: String, + ): T { + inFlight++ + maxConcurrent = maxOf(maxConcurrent, inFlight) + signGate.await() + inFlight-- + signCount++ + return canned as T + } + + override suspend fun nip04Encrypt( + plaintext: String, + toPublicKey: HexKey, + ) = "" + + override suspend fun nip04Decrypt( + ciphertext: String, + fromPublicKey: HexKey, + ) = "" + + override suspend fun nip44Encrypt( + plaintext: String, + toPublicKey: HexKey, + ) = "" + + override suspend fun nip44Decrypt( + ciphertext: String, + fromPublicKey: HexKey, + ) = "" + + override suspend fun decryptZapEvent(event: LnZapRequestEvent): LnZapPrivateEvent = throw NotImplementedError() + + override suspend fun deriveKey(nonce: HexKey): HexKey = throw NotImplementedError() + + override suspend fun signPsbt(psbtHex: String): String = throw NotImplementedError() + + override fun hasForegroundSupport() = false + } + + /** authorize() parks on the deferred returned by [gateFor] (null = allow immediately). */ + private class GatedAuthorizer( + val gateFor: (BunkerRequest) -> CompletableDeferred?, + ) : Nip46RequestAuthorizer { + override suspend fun onConnect( + clientPubKey: HexKey, + request: BunkerRequestConnect, + ) = Nip46ConnectDecision.Accept("ack") + + override suspend fun authorize( + clientPubKey: HexKey, + request: BunkerRequest, + ): Boolean = gateFor(request)?.await() ?: true + } + + @Test + fun cryptoIsSerializedAcrossConcurrentAuthorizedRequests() = + runTest { + val signGate = CompletableDeferred() + val signer = GatedSigner(userPubKey, signGate) + val processor = BunkerRequestProcessor(signer, { setOf(relay) }, GatedAuthorizer { null }) + + launch { processor.process(clientPubKey, signTemplate("1")) } + launch { processor.process(clientPubKey, signTemplate("2")) } + testScheduler.advanceUntilIdle() + + // Both were authorized instantly, but only one may be inside the signer at a time. + assertEquals(1, signer.inFlight, "only one sign holds the crypto lock") + assertEquals(1, signer.maxConcurrent, "crypto never overlapped") + + signGate.complete(Unit) + testScheduler.advanceUntilIdle() + assertEquals(2, signer.signCount, "both eventually signed, one after the other") + assertEquals(1, signer.maxConcurrent, "still never overlapped") + } + + @Test + fun aBlockedPromptDoesNotStallAnotherClientsSigning() = + runTest { + val signGate = CompletableDeferred().apply { complete(Unit) } // signing itself never blocks here + val signer = GatedSigner(userPubKey, signGate) + val prompt = CompletableDeferred() // stands in for a user consent dialog left open + val blocked = signTemplate("blocked") + val processor = + BunkerRequestProcessor(signer, { setOf(relay) }, GatedAuthorizer { if (it === blocked) prompt else null }) + + launch { processor.process(clientPubKey, blocked) } // parks in authorize(), never touching the lock + var fastResult: BunkerResponse? = null + launch { fastResult = processor.process(clientPubKey, signTemplate("fast")) } + testScheduler.advanceUntilIdle() + + // The auto-allowed request signed and returned while the prompt is still open. + assertTrue(fastResult is BunkerResponseEvent, "auto-allowed request completed while a prompt was pending") + assertEquals(1, signer.signCount) + + prompt.complete(true) + testScheduler.advanceUntilIdle() + assertEquals(2, signer.signCount, "the prompted request signs once approved") + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/BunkerRequestProcessorTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/BunkerRequestProcessorTest.kt new file mode 100644 index 0000000000..fcfb8a711b --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/BunkerRequestProcessorTest.kt @@ -0,0 +1,330 @@ +/* + * 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.server + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequest +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestConnect +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestGetPublicKey +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestGetRelays +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.BunkerResponseEncrypt +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponseError +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponseEvent +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponseGetRelays +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponsePong +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponsePublicKey +import com.vitorpamplona.quartz.nip57Zaps.LnZapPrivateEvent +import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * Pure dispatch/authorization tests for the NIP-46 signer core. Uses a canned + * [FakeSigner] so no secp256k1/NIP-44 crypto is required — these run in + * commonTest on every platform. + */ +class BunkerRequestProcessorTest { + private val userPubKey = "a".repeat(64) + private val clientPubKey = "c".repeat(64) + + /** Records what the signer was asked to do and returns fixed values. */ + private class FakeSigner( + pubKey: HexKey, + val writeable: Boolean = true, + ) : NostrSigner(pubKey) { + var signCount = 0 + var nip44EncryptCount = 0 + var nip44DecryptCount = 0 + + val cannedEvent = + Event( + id = "e".repeat(64), + pubKey = pubKey, + createdAt = 1L, + kind = 1, + tags = emptyArray(), + content = "signed", + sig = "f".repeat(128), + ) + + override fun isWriteable() = writeable + + @Suppress("UNCHECKED_CAST") + override suspend fun sign( + createdAt: Long, + kind: Int, + tags: Array>, + content: String, + ): T { + signCount++ + return cannedEvent as T + } + + override suspend fun nip04Encrypt( + plaintext: String, + toPublicKey: HexKey, + ) = "nip04:$plaintext" + + override suspend fun nip04Decrypt( + ciphertext: String, + fromPublicKey: HexKey, + ) = "nip04dec:$ciphertext" + + override suspend fun nip44Encrypt( + plaintext: String, + toPublicKey: HexKey, + ): String { + nip44EncryptCount++ + return "nip44:$plaintext" + } + + override suspend fun nip44Decrypt( + ciphertext: String, + fromPublicKey: HexKey, + ): String { + nip44DecryptCount++ + return "nip44dec:$ciphertext" + } + + override suspend fun decryptZapEvent(event: LnZapRequestEvent): LnZapPrivateEvent = throw NotImplementedError() + + override suspend fun deriveKey(nonce: HexKey): HexKey = throw NotImplementedError() + + override suspend fun signPsbt(psbtHex: String): String = throw NotImplementedError() + + override fun hasForegroundSupport() = false + } + + /** Configurable authorizer for the tests. */ + private class FakeAuthorizer( + val connectDecision: Nip46ConnectDecision, + val allow: Boolean, + ) : Nip46RequestAuthorizer { + var connectCalls = 0 + var authorizeCalls = 0 + + override suspend fun onConnect( + clientPubKey: HexKey, + request: BunkerRequestConnect, + ): Nip46ConnectDecision { + connectCalls++ + return connectDecision + } + + override suspend fun authorize( + clientPubKey: HexKey, + request: BunkerRequest, + ): Boolean { + authorizeCalls++ + return allow + } + } + + private val relay: NormalizedRelayUrl = RelayUrlNormalizer.normalizeOrNull("wss://relay.example.com")!! + + private fun processor( + signer: FakeSigner = FakeSigner(userPubKey), + authorizer: FakeAuthorizer = FakeAuthorizer(Nip46ConnectDecision.Accept("ack"), allow = true), + ) = BunkerRequestProcessor(signer, { setOf(relay) }, authorizer) + + @Test + fun getPublicKeyReturnsUserPubKeyWithoutAuthorization() = + runTest { + val authorizer = FakeAuthorizer(Nip46ConnectDecision.Accept("ack"), allow = false) + val res = processor(authorizer = authorizer).process(clientPubKey, BunkerRequestGetPublicKey("1")) + + assertTrue(res is BunkerResponsePublicKey) + assertEquals(userPubKey, res.pubkey) + assertEquals("1", res.id) + // public reads are never gated + assertEquals(0, authorizer.authorizeCalls) + } + + @Test + fun pingReturnsPong() = + runTest { + val res = processor().process(clientPubKey, BunkerRequestPing("2")) + assertTrue(res is BunkerResponsePong) + assertEquals("2", res.id) + } + + @Test + fun getRelaysReturnsConfiguredRelays() = + runTest { + val res = processor().process(clientPubKey, BunkerRequestGetRelays("3")) + assertTrue(res is BunkerResponseGetRelays) + assertTrue(res.relays.containsKey(relay.url)) + } + + @Test + fun connectAcceptEchoesSecret() = + runTest { + val authorizer = FakeAuthorizer(Nip46ConnectDecision.Accept("s3cr3t"), allow = true) + val res = processor(authorizer = authorizer).process(clientPubKey, BunkerRequestConnect(id = "4", remoteKey = userPubKey, secret = "s3cr3t")) + + assertEquals(1, authorizer.connectCalls) + assertEquals("4", res.id) + assertEquals("s3cr3t", res.result) + } + + @Test + fun connectRejectReturnsError() = + runTest { + val authorizer = FakeAuthorizer(Nip46ConnectDecision.Reject("invalid secret"), allow = true) + val res = processor(authorizer = authorizer).process(clientPubKey, BunkerRequestConnect(id = "5", remoteKey = userPubKey, secret = "wrong")) + + assertTrue(res is BunkerResponseError) + assertEquals("invalid secret", res.error) + } + + @Test + fun signAuthorizedSignsWithUserSigner() = + runTest { + val signer = FakeSigner(userPubKey) + val template = EventTemplate(createdAt = 1L, kind = 1, tags = emptyArray(), content = "hi") + val res = processor(signer = signer).process(clientPubKey, BunkerRequestSign("6", template)) + + assertTrue(res is BunkerResponseEvent) + assertEquals(1, signer.signCount) + assertEquals(signer.cannedEvent.id, res.event.id) + } + + @Test + fun signDeniedReturnsUnauthorized() = + runTest { + val signer = FakeSigner(userPubKey) + val authorizer = FakeAuthorizer(Nip46ConnectDecision.Accept("ack"), allow = false) + val template = EventTemplate(createdAt = 1L, kind = 1, tags = emptyArray(), content = "hi") + val res = processor(signer = signer, authorizer = authorizer).process(clientPubKey, BunkerRequestSign("7", template)) + + assertTrue(res is BunkerResponseError) + assertEquals(BunkerRequestProcessor.ERROR_UNAUTHORIZED, res.error) + assertEquals(0, signer.signCount) + } + + @Test + fun signRefusedWhenAccountNoLongerWriteable() = + runTest { + // The account was logged out / went read-only / lost its external signer: refuse without + // prompting or invoking the signer, even for an otherwise-authorized request. + val signer = FakeSigner(userPubKey, writeable = false) + val authorizer = FakeAuthorizer(Nip46ConnectDecision.Accept("ack"), allow = true) + val template = EventTemplate(createdAt = 1L, kind = 1, tags = emptyArray(), content = "hi") + val res = processor(signer = signer, authorizer = authorizer).process(clientPubKey, BunkerRequestSign("w", template)) + + assertTrue(res is BunkerResponseError) + assertEquals(BunkerRequestProcessor.ERROR_ACCOUNT_UNAVAILABLE, res.error) + assertEquals(0, signer.signCount, "the unusable signer is never invoked") + assertEquals(0, authorizer.authorizeCalls, "and we don't prompt for a key we can't use") + } + + @Test + fun nip44EncryptAuthorized() = + runTest { + val signer = FakeSigner(userPubKey) + val res = processor(signer = signer).process(clientPubKey, BunkerRequestNip44Encrypt("8", clientPubKey, "hello")) + + assertTrue(res is BunkerResponseEncrypt) + assertEquals("nip44:hello", res.ciphertext) + assertEquals(1, signer.nip44EncryptCount) + } + + @Test + fun nip44DecryptDeniedDoesNotCallSigner() = + runTest { + val signer = FakeSigner(userPubKey) + val authorizer = FakeAuthorizer(Nip46ConnectDecision.Accept("ack"), allow = false) + val res = processor(signer = signer, authorizer = authorizer).process(clientPubKey, BunkerRequestNip44Decrypt("9", clientPubKey, "ct")) + + assertTrue(res is BunkerResponseError) + assertEquals(0, signer.nip44DecryptCount) + } + + @Test + fun unsupportedMethodReturnsError() = + runTest { + val res = processor().process(clientPubKey, BunkerRequest("10", "made_up_method", emptyArray())) + assertTrue(res is BunkerResponseError) + assertTrue(res.error!!.contains("made_up_method")) + } + + @Test + fun signerExceptionBecomesErrorResponse() = + runTest { + val throwing = + object : NostrSigner(userPubKey) { + override fun isWriteable() = true + + override suspend fun sign( + createdAt: Long, + kind: Int, + tags: Array>, + content: String, + ): T = throw IllegalStateException("boom") + + override suspend fun nip04Encrypt( + plaintext: String, + toPublicKey: HexKey, + ) = "" + + override suspend fun nip04Decrypt( + ciphertext: String, + fromPublicKey: HexKey, + ) = "" + + override suspend fun nip44Encrypt( + plaintext: String, + toPublicKey: HexKey, + ) = "" + + override suspend fun nip44Decrypt( + ciphertext: String, + fromPublicKey: HexKey, + ) = "" + + override suspend fun decryptZapEvent(event: LnZapRequestEvent): LnZapPrivateEvent = throw NotImplementedError() + + override suspend fun deriveKey(nonce: HexKey): HexKey = throw NotImplementedError() + + override suspend fun signPsbt(psbtHex: String): String = throw NotImplementedError() + + override fun hasForegroundSupport() = false + } + val template = EventTemplate(createdAt = 1L, kind = 1, tags = emptyArray(), content = "hi") + val res = + BunkerRequestProcessor(throwing, { setOf(relay) }, FakeAuthorizer(Nip46ConnectDecision.Accept("ack"), allow = true)) + .process(clientPubKey, BunkerRequestSign("11", template)) + + assertTrue(res is BunkerResponseError) + assertTrue(res.error!!.contains("boom")) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/NostrConnectSignerServiceTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/NostrConnectSignerServiceTest.kt new file mode 100644 index 0000000000..98a7dcff66 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip46RemoteSigner/server/NostrConnectSignerServiceTest.kt @@ -0,0 +1,351 @@ +/* + * 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.server + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.client.EmptyNostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.INostrClient +import com.vitorpamplona.quartz.nip01Core.relay.client.reqs.SubscriptionListener +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequest +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestConnect +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestGetPublicKey +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestSign +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponse +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponseEvent +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponsePublicKey +import com.vitorpamplona.quartz.nip46RemoteSigner.NostrConnectEvent +import com.vitorpamplona.quartz.nip57Zaps.LnZapPrivateEvent +import com.vitorpamplona.quartz.nip57Zaps.LnZapRequestEvent +import com.vitorpamplona.quartz.utils.RandomInstance +import com.vitorpamplona.quartz.utils.TimeUtils +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * End-to-end wiring test for the signer service: a client crafts a real + * [NostrConnectEvent], the service (over a fake relay client) decrypts it, + * dispatches through the processor, and publishes a reply the client can read. + * + * Crypto is stubbed with a passthrough signer (NIP-44 is unavailable in + * commonTest), so this exercises the subscribe → decrypt → dispatch → publish + * plumbing and the JSON round-trip, not the cipher itself. + */ +class NostrConnectSignerServiceTest { + private val serverKey = "a".repeat(64) + private val clientKey = "b".repeat(64) + private val identityKey = "d".repeat(64) + private val relay: NormalizedRelayUrl = RelayUrlNormalizer.normalizeOrNull("wss://relay.example.com")!! + + /** Passthrough crypto + real event construction, so NostrConnectEvent.create/decryptMessage round-trip. */ + private class PassthroughSigner( + pubKey: HexKey, + ) : NostrSigner(pubKey) { + override fun isWriteable() = true + + @Suppress("UNCHECKED_CAST") + override suspend fun sign( + createdAt: Long, + kind: Int, + tags: Array>, + content: String, + ): T { + val id = RandomInstance.randomChars(64) + val sig = "0".repeat(128) + // Transport events must stay NostrConnectEvent (kind 24133 is baked in); + // any other kind is the actual event a sign_event request asked us to sign. + val event = + if (kind == NostrConnectEvent.KIND) { + NostrConnectEvent(id, pubKey, createdAt, tags, content, sig) + } else { + Event(id, pubKey, createdAt, kind, tags, content, sig) + } + return event as T + } + + override suspend fun nip04Encrypt( + plaintext: String, + toPublicKey: HexKey, + ) = plaintext + + override suspend fun nip04Decrypt( + ciphertext: String, + fromPublicKey: HexKey, + ) = ciphertext + + override suspend fun nip44Encrypt( + plaintext: String, + toPublicKey: HexKey, + ) = plaintext + + override suspend fun nip44Decrypt( + ciphertext: String, + fromPublicKey: HexKey, + ) = ciphertext + + override suspend fun decryptZapEvent(event: LnZapRequestEvent): LnZapPrivateEvent = throw NotImplementedError() + + override suspend fun deriveKey(nonce: HexKey): HexKey = throw NotImplementedError() + + override suspend fun signPsbt(psbtHex: String): String = throw NotImplementedError() + + override fun hasForegroundSupport() = false + } + + /** Captures the service's subscription listener and records published replies. */ + private class LoopbackClient : INostrClient by EmptyNostrClient() { + var listener: SubscriptionListener? = null + val published = mutableListOf() + + override fun subscribe( + subId: String, + filters: Map>, + listener: SubscriptionListener?, + ) { + this.listener = listener + } + + override fun publish( + event: Event, + relayList: Set, + ) { + published.add(event) + } + + fun deliver(event: Event) { + listener?.onEvent(event, isLive = true, relay = RelayUrlNormalizer.normalizeOrNull("wss://relay.example.com")!!, forFilters = null) + } + } + + private class AllowAuthorizer : Nip46RequestAuthorizer { + var logoutCalls = 0 + + override suspend fun onConnect( + clientPubKey: HexKey, + request: BunkerRequestConnect, + ) = Nip46ConnectDecision.Accept(request.secret ?: "ack") + + override suspend fun authorize( + clientPubKey: HexKey, + request: BunkerRequest, + ) = true + + override suspend fun onLogout(clientPubKey: HexKey) { + logoutCalls++ + } + } + + private fun serverSigner() = PassthroughSigner(serverKey) + + private fun clientSigner() = PassthroughSigner(clientKey) + + /** Builds the encrypted kind-24133 request the client would publish to the bunker. */ + private suspend fun request(message: BunkerRequest): NostrConnectEvent = NostrConnectEvent.create(message, remoteKey = serverKey, signer = clientSigner()) + + @Test + fun connectRequestGetsAckReply() = + runTest { + val client = LoopbackClient() + val signer = serverSigner() + val processor = BunkerRequestProcessor(signer, { setOf(relay) }, AllowAuthorizer()) + val service = NostrConnectSignerService(client, signer, processor, setOf(relay)) + + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { service.run() } + + client.deliver(request(BunkerRequestConnect(id = "req1", remoteKey = serverKey, secret = "s3cr3t"))) + + assertEquals(1, client.published.size) + val reply = (client.published.single() as NostrConnectEvent).decryptMessage(clientSigner()) as BunkerResponse + assertEquals("req1", reply.id) + assertEquals("s3cr3t", reply.result) + } + + @Test + fun getPublicKeyReturnsIdentityNotTransportKey() = + runTest { + val client = LoopbackClient() + // The client connects to the transport key (serverKey); the actual work signer is a + // separate identity key. get_public_key must reveal the identity, not the transport key. + val transport = serverSigner() + val identity = PassthroughSigner(identityKey) + val processor = BunkerRequestProcessor(identity, { setOf(relay) }, AllowAuthorizer()) + val service = NostrConnectSignerService(client, transport, processor, setOf(relay)) + + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { service.run() } + + client.deliver(request(BunkerRequestGetPublicKey("reqpk"))) + + val reply = (client.published.single() as NostrConnectEvent).decryptMessage(clientSigner()) + assertTrue(reply is BunkerResponsePublicKey) + assertEquals(identityKey, reply.pubkey) + assertTrue(reply.pubkey != serverKey, "must not leak the transport key") + } + + @Test + fun signRequestGetsSignedEventReply() = + runTest { + val client = LoopbackClient() + val signer = serverSigner() + val processor = BunkerRequestProcessor(signer, { setOf(relay) }, AllowAuthorizer()) + val service = NostrConnectSignerService(client, signer, processor, setOf(relay)) + + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { service.run() } + + val template = EventTemplate(createdAt = 1L, kind = 1, tags = emptyArray(), content = "hello") + client.deliver(request(BunkerRequestSign(id = "req2", event = template))) + + val reply = (client.published.single() as NostrConnectEvent).decryptMessage(clientSigner()) + assertTrue(reply is BunkerResponseEvent) + assertEquals("req2", reply.id) + assertEquals(1, reply.event.kind) + } + + @Test + fun staleReplayedRequestIsIgnored() = + runTest { + val client = LoopbackClient() + val signer = serverSigner() + val processor = BunkerRequestProcessor(signer, { setOf(relay) }, AllowAuthorizer()) + val service = NostrConnectSignerService(client, signer, processor, setOf(relay), maxRequestAgeSeconds = 120) + + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { service.run() } + + // A relay replays a request whose created_at is well past the age window (as if it had been + // stored and re-sent on resubscribe). It must not be serviced — no reply is published. + val template = EventTemplate(createdAt = 1L, kind = 1, tags = emptyArray(), content = "old") + val stale = + NostrConnectEvent.create( + BunkerRequestSign(id = "stale", event = template), + remoteKey = serverKey, + signer = clientSigner(), + createdAt = TimeUtils.now() - 600, + ) + client.deliver(stale) + + assertEquals(0, client.published.size, "a minutes-old replayed request is dropped, not re-signed") + } + + @Test + fun aFreshRequestWhoseIdWasServicedLastSessionIsNotRepeated() = + runTest { + val client = LoopbackClient() + val signer = serverSigner() + val processor = BunkerRequestProcessor(signer, { setOf(relay) }, AllowAuthorizer()) + + // A still-fresh request whose id the previous run already serviced (seeded via initialSeen, + // as the host would restore from disk). It must be deduped by exact id — even though its + // created_at is within the window — so an app restart doesn't re-sign a relay's replay. + val template = EventTemplate(createdAt = 1L, kind = 1, tags = emptyArray(), content = "again") + val replayed = request(BunkerRequestSign(id = "req", event = template)) + val service = NostrConnectSignerService(client, signer, processor, setOf(relay), initialSeen = setOf(replayed.id)) + + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { service.run() } + client.deliver(replayed) + + assertEquals(0, client.published.size, "an id serviced last session is not signed again after restart") + } + + @Test + fun aHandledIdIsReportedForPersistence() = + runTest { + val client = LoopbackClient() + val signer = serverSigner() + val processor = BunkerRequestProcessor(signer, { setOf(relay) }, AllowAuthorizer()) + val handled = mutableListOf() + val service = NostrConnectSignerService(client, signer, processor, setOf(relay), onHandledId = { handled.add(it) }) + + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { service.run() } + val event = request(BunkerRequestSign(id = "req", event = EventTemplate(createdAt = 1L, kind = 1, tags = emptyArray(), content = "x"))) + client.deliver(event) + + assertEquals(listOf(event.id), handled, "the serviced event id is reported so the host can persist it") + } + + @Test + fun logoutRequestInvokesAuthorizerAndAcks() = + runTest { + val client = LoopbackClient() + val signer = serverSigner() + val authorizer = AllowAuthorizer() + val processor = BunkerRequestProcessor(signer, { setOf(relay) }, authorizer) + val service = NostrConnectSignerService(client, signer, processor, setOf(relay)) + + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { service.run() } + + client.deliver(request(BunkerRequest(id = "req3", method = BunkerRequestProcessor.METHOD_LOGOUT))) + + assertEquals(1, authorizer.logoutCalls) + val reply = (client.published.single() as NostrConnectEvent).decryptMessage(clientSigner()) as BunkerResponse + assertEquals("ack", reply.result) + } + + @Test + fun floodingClientIsRateLimited() = + runTest { + val client = LoopbackClient() + val signer = serverSigner() + val processor = BunkerRequestProcessor(signer, { setOf(relay) }, AllowAuthorizer()) + val service = + NostrConnectSignerService( + client, + signer, + processor, + setOf(relay), + maxRequestsPerWindow = 2, + rateWindowSeconds = 3600, + ) + + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { service.run() } + + // Five distinct requests from the same author within one window → only 2 are serviced. + repeat(5) { i -> + client.deliver(request(BunkerRequestConnect(id = "req$i", remoteKey = serverKey, secret = "s"))) + } + + assertEquals(2, client.published.size) + } + + @Test + fun requestNotAddressedToUsIsIgnored() = + runTest { + val client = LoopbackClient() + val signer = serverSigner() + val processor = BunkerRequestProcessor(signer, { setOf(relay) }, AllowAuthorizer()) + val service = NostrConnectSignerService(client, signer, processor, setOf(relay)) + + backgroundScope.launch(UnconfinedTestDispatcher(testScheduler)) { service.run() } + + // p-tagged to someone else → the listener drops it before decryption. + val strayRecipient = "c".repeat(64) + client.deliver(NostrConnectEvent.create(BunkerRequestConnect(id = "x", remoteKey = strayRecipient), remoteKey = strayRecipient, signer = clientSigner())) + + assertTrue(client.published.isEmpty()) + } +}