Merge pull request #3612 from vitorpamplona/claude/amethyst-nip46-signer-njao2v

Add NIP-46 remote signer (bunker) support with consent UI
This commit is contained in:
Vitor Pamplona
2026-07-17 11:37:04 -04:00
committed by GitHub
62 changed files with 6597 additions and 699 deletions
@@ -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.
+10 -2
View File
@@ -151,6 +151,14 @@
<data android:scheme="amethyst+walletconnect" />
</intent-filter>
<!-- NIP-46: an app's `nostrconnect://` offer opens the signer screen, which pairs it. -->
<intent-filter android:label="Amethyst">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="nostrconnect" />
</intent-filter>
<intent-filter android:label="New Post">
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
@@ -463,14 +471,14 @@
android:theme="@android:style/Theme.Translucent.NoTitleBar" />
<!-- First-connect "Connect to Nostr" dialog. -->
<activity
android:name=".napplet.NappletConnectActivity"
android:name=".connectedApps.consent.SignerConnectActivity"
android:exported="false"
android:excludeFromRecents="true"
android:launchMode="singleTop"
android:theme="@android:style/Theme.Translucent.NoTitleBar" />
<!-- Per-operation signer consent dialog. -->
<activity
android:name=".napplet.NappletSignerConsentActivity"
android:name=".connectedApps.consent.SignerConsentActivity"
android:exported="false"
android:excludeFromRecents="true"
android:launchMode="singleTop"
@@ -35,6 +35,8 @@ import com.vitorpamplona.amethyst.commons.service.lnurl.OkHttpLnurlEndpointResol
import com.vitorpamplona.amethyst.commons.service.pow.PoWPolicy
import com.vitorpamplona.amethyst.commons.service.pow.PoWPublishQueue
import com.vitorpamplona.amethyst.commons.tor.TorSettings
import com.vitorpamplona.amethyst.connectedApps.DataStoreNostrSignerPermissionStore
import com.vitorpamplona.amethyst.connectedApps.nip46.DataStoreNip46ClientStore
import com.vitorpamplona.amethyst.model.Account
import com.vitorpamplona.amethyst.model.LocalCache
import com.vitorpamplona.amethyst.model.UiSettings
@@ -51,7 +53,6 @@ import com.vitorpamplona.amethyst.model.privacyOptions.RoleBasedHttpClientBuilde
import com.vitorpamplona.amethyst.model.torState.AccountsTorStateConnector
import com.vitorpamplona.amethyst.model.torState.TorRelayState
import com.vitorpamplona.amethyst.napplet.DataStoreNappletPermissionStore
import com.vitorpamplona.amethyst.napplet.DataStoreNostrSignerPermissionStore
import com.vitorpamplona.amethyst.service.calendar.CalendarReminderPrefs
import com.vitorpamplona.amethyst.service.calendar.CalendarReminderWorker
import com.vitorpamplona.amethyst.service.cast.CastRegistry
@@ -694,6 +695,9 @@ class AppModules(
val nappletPermissionStore by lazy { DataStoreNappletPermissionStore(appContext) }
val signerPermissionStore by lazy { DataStoreNostrSignerPermissionStore(appContext) }
// Display + relay info for connected NIP-46 remote-signer clients.
val nip46ClientStore by lazy { DataStoreNip46ClientStore(appContext) }
// Authenticates with relays.
val authCoordinator = AuthCoordinator(client, applicationIOScope)
@@ -810,6 +814,8 @@ class AppModules(
rootFilesDir = { appContext.filesDir },
powQueue = { powPublishQueue },
meterSigner = { MeteringNostrSigner(it, resourceUsage) },
signerPermissionStore = signerPermissionStore,
nip46ClientStore = nip46ClientStore,
)
val sessionManager =
@@ -109,6 +109,10 @@ private object PrefKeys {
const val USE_LOCAL_BLOSSOM_CACHE = "useLocalBlossomCache"
const val LOCAL_BLOSSOM_CACHE_PROFILE_PICTURES_ONLY = "localBlossomCacheProfilePicturesOnly"
const val HIDE_COMMUNITY_RULES_VIOLATIONS = "hideCommunityRulesViolations"
const val NIP46_SIGNER_ENABLED = "nip46SignerEnabled"
const val NIP46_BUNKER_SECRET = "nip46BunkerSecret"
const val NIP46_TRANSPORT_KEY = "nip46TransportKey"
const val NIP46_SEEN_IDS = "nip46SeenRequestIds"
const val DEFAULT_HOME_FOLLOW_LIST = "defaultHomeFollowList"
const val DEFAULT_STORIES_FOLLOW_LIST = "defaultStoriesFollowList"
const val DEFAULT_NOTIFICATION_FOLLOW_LIST = "defaultNotificationFollowList"
@@ -465,6 +469,10 @@ object LocalPreferences {
putBoolean(PrefKeys.USE_LOCAL_BLOSSOM_CACHE, settings.useLocalBlossomCache.value)
putBoolean(PrefKeys.LOCAL_BLOSSOM_CACHE_PROFILE_PICTURES_ONLY, settings.localBlossomCacheProfilePicturesOnly.value)
putBoolean(PrefKeys.HIDE_COMMUNITY_RULES_VIOLATIONS, settings.hideCommunityRulesViolations.value)
putBoolean(PrefKeys.NIP46_SIGNER_ENABLED, settings.nip46SignerEnabled.value)
putString(PrefKeys.NIP46_BUNKER_SECRET, settings.nip46BunkerSecret.value)
putString(PrefKeys.NIP46_TRANSPORT_KEY, settings.nip46TransportKey.value)
putStringSet(PrefKeys.NIP46_SEEN_IDS, settings.nip46SeenRequestIds.value)
putString(PrefKeys.DEFAULT_HOME_FOLLOW_LIST, JsonMapper.toJson(settings.defaultHomeFollowList.value))
putString(PrefKeys.DEFAULT_STORIES_FOLLOW_LIST, JsonMapper.toJson(settings.defaultStoriesFollowList.value))
@@ -675,6 +683,10 @@ object LocalPreferences {
val useLocalBlossomCache = getBoolean(PrefKeys.USE_LOCAL_BLOSSOM_CACHE, true)
val localBlossomCacheProfilePicturesOnly = getBoolean(PrefKeys.LOCAL_BLOSSOM_CACHE_PROFILE_PICTURES_ONLY, false)
val hideCommunityRulesViolations = getBoolean(PrefKeys.HIDE_COMMUNITY_RULES_VIOLATIONS, false)
val nip46SignerEnabled = getBoolean(PrefKeys.NIP46_SIGNER_ENABLED, false)
val nip46BunkerSecret = getString(PrefKeys.NIP46_BUNKER_SECRET, "") ?: ""
val nip46TransportKey = getString(PrefKeys.NIP46_TRANSPORT_KEY, "") ?: ""
val nip46SeenRequestIds = getStringSet(PrefKeys.NIP46_SEEN_IDS, null) ?: setOf()
val hideDeleteRequestDialog = getBoolean(PrefKeys.HIDE_DELETE_REQUEST_DIALOG, false)
val hideBlockAlertDialog = getBoolean(PrefKeys.HIDE_BLOCK_ALERT_DIALOG, false)
val hideNIP17WarningDialog = getBoolean(PrefKeys.HIDE_NIP_17_WARNING_DIALOG, false)
@@ -854,6 +866,10 @@ object LocalPreferences {
useLocalBlossomCache = MutableStateFlow(useLocalBlossomCache),
localBlossomCacheProfilePicturesOnly = MutableStateFlow(localBlossomCacheProfilePicturesOnly),
hideCommunityRulesViolations = MutableStateFlow(hideCommunityRulesViolations),
nip46SignerEnabled = MutableStateFlow(nip46SignerEnabled),
nip46BunkerSecret = MutableStateFlow(nip46BunkerSecret),
nip46TransportKey = MutableStateFlow(nip46TransportKey),
nip46SeenRequestIds = MutableStateFlow(nip46SeenRequestIds),
defaultHomeFollowList = MutableStateFlow(followListPrefs.home),
defaultStoriesFollowList = MutableStateFlow(followListPrefs.stories),
defaultNotificationFollowList = MutableStateFlow(followListPrefs.notification),
@@ -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
import android.content.Context
import androidx.datastore.core.DataStore
@@ -26,12 +26,14 @@ 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.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.NostrSignerPermissionStore
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.NostrSignerPermissionStore
import com.vitorpamplona.quartz.utils.cache.LargeCache
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.withContext
import java.io.File
import java.security.MessageDigest
@@ -102,21 +104,24 @@ class DataStoreNostrSignerPermissionStore(
storeFor(coordinate).edit { it.remove(opKey(op)) }
}
override suspend fun allPolicies(): Map<String, AppSignerPolicy> {
val dir = File(filesDir, "datastore")
if (!dir.exists()) return emptyMap()
val result = mutableMapOf<String, AppSignerPolicy>()
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<String, AppSignerPolicy> =
// 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<String, AppSignerPolicy>()
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<String, NostrOpDecision> {
val prefs = storeFor(coordinate).data.first()
@@ -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,
)
}
}
@@ -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)
@@ -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<String> = 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<AppConnectResult>,
)
@@ -50,26 +65,41 @@ object NappletConnectCoordinator {
suspend fun requestConnect(
context: Context,
info: NappletConnectInfo,
info: SignerConnectInfo,
): AppConnectResult {
val token = UUID.randomUUID().toString()
val deferred = CompletableDeferred<AppConnectResult>()
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,
@@ -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<Event>(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<PendingConsent>,
onResolve: (tokens: List<String>, 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<String>()) }
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))
}
}
}
}
}
@@ -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<Event>? = 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<String, CompletableDeferred<SignerOpGrant>>()
private val _pending = MutableStateFlow<List<PendingConsent>>(emptyList())
/** The live set of requests awaiting the user's decision; the Activity renders this. */
val pending: StateFlow<List<PendingConsent>> = _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<SignerOpGrant>()
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<String>,
grant: SignerOpGrant,
) {
tokens.forEach { complete(it, grant) }
}
const val EXTRA_TOKEN = "napplet_signer_consent_token"
}
@@ -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
}
@@ -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<Preferences> 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<String, Nip46ClientInfo> {
val prefs = store.data.first()
val result = mutableMapOf<String, Nip46ClientInfo>()
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<String> = 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<String, DataStore<Preferences>>()
private fun dataStoreFor(file: File): DataStore<Preferences> =
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) }
}
}
}
@@ -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)
@@ -185,6 +185,35 @@ class AccountSettings(
var stripLocationOnUpload: Boolean = true,
val useLocalBlossomCache: MutableStateFlow<Boolean> = MutableStateFlow(true),
val localBlossomCacheProfilePicturesOnly: MutableStateFlow<Boolean> = 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<Boolean> = 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<String> = 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<String> = 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<Set<String>> = 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<String>) {
if (nip46SeenRequestIds.value != ids) {
nip46SeenRequestIds.tryEmit(ids)
saveAccountSettings()
}
}
fun changeLocalBlossomCacheProfilePicturesOnly(enabled: Boolean) {
if (localBlossomCacheProfilePicturesOnly.value != enabled) {
localBlossomCacheProfilePicturesOnly.tryEmit(enabled)
@@ -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<Map<HexKey, Account>>(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))
@@ -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<List<Nip46ActivityEntry>>(emptyList())
val entries: StateFlow<List<Nip46ActivityEntry>> = _entries
fun record(entry: Nip46ActivityEntry) {
_entries.update { (listOf(entry) + it).take(capacity) }
}
}
@@ -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<NostrSignerOp>,
): 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?,
)
}
@@ -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:<signerPubKey>:<clientPubKey>`.
*
* 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<Set<NormalizedRelayUrl>>,
val scope: CoroutineScope,
val settings: AccountSettings,
) {
/** Relays contributed by pasted `nostrconnect://` offers this session, unioned with the inbox set. */
private val extraRelays = MutableStateFlow<Set<NormalizedRelayUrl>>(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<Set<NormalizedRelayUrl>> =
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<Boolean> 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://<transport-pubkey>?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
}
}
@@ -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
@@ -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))
}
}
}
}
}
@@ -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<SignerOpGrant>,
)
private val pending = ConcurrentHashMap<String, Pending>()
suspend fun requestConsent(
context: Context,
info: NappletSignerConsentInfo,
): SignerOpGrant {
val token = UUID.randomUUID().toString()
val deferred = CompletableDeferred<SignerOpGrant>()
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"
}
@@ -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<Event>(TimeUtils.now(), request.kind, request.tags, request.content)
is NappletRequest.SignEvent -> EventTemplate<Event>(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)
}
@@ -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),
)
@@ -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 {
@@ -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
@@ -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 }
@@ -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<Route.NostrApp>(capWidth = false) { NostrAppScreen(it.coordinate, accountViewModel, nav) }
composableFromEnd<Route.ConnectedApps> { ConnectedAppsScreen(accountViewModel, nav) }
composableFromEndArgs<Route.ConnectedAppDetail> { ConnectedAppDetailScreen(it.coordinate, accountViewModel, nav) }
composableFromEndArgs<Route.Nip46Signer> { Nip46SignerScreen(accountViewModel, nav, it.connectUri) }
composableFromEnd<Route.Nip46ConnectedApps> { Nip46ConnectedAppsScreen(accountViewModel, nav) }
composableFromEnd<Route.RelayAuthSettings> { RelayAuthSettingsScreen(accountViewModel, nav) }
composableFromEndArgs<Route.SoftwareAppDetail> { SoftwareAppDetailScreen(Address(it.kind, it.pubKeyHex, it.dTag), accountViewModel, nav) }
composableFromEnd<Route.Calendars> { CalendarsScreen(accountViewModel, nav) }
@@ -48,6 +48,7 @@ enum class NavBarItem {
INTEREST_SETS,
EMOJI_PACKS,
WALLET,
NOSTR_SIGNER,
COMMUNITIES,
ARTICLES,
PICTURES,
@@ -195,6 +196,13 @@ val NavBarCatalog: Map<NavBarItem, NavBarItemDef> =
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> =
NavBarItem.INTEREST_SETS,
NavBarItem.EMOJI_PACKS,
NavBarItem.WALLET,
NavBarItem.NOSTR_SIGNER,
)
/**
@@ -495,6 +504,7 @@ val BottomBarCategories: List<NavBarCategory> =
NavBarItem.FAVORITE_ALGO_FEEDS,
NavBarItem.EMOJI_PACKS,
NavBarItem.WALLET,
NavBarItem.NOSTR_SIGNER,
),
),
NavBarCategory(
@@ -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(
@@ -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
@@ -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<Nip46ClientInfo?>(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<String>,
inboxRelays: Set<NormalizedRelayUrl>,
connectedRelays: Set<NormalizedRelayUrl>,
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) },
@@ -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,
@@ -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<Nip46ActivityEntry>,
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)}"
}
@@ -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<List<Nip46AppEntry>?>(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<String>,
inboxRelays: Set<NormalizedRelayUrl>,
connectedRelays: Set<NormalizedRelayUrl>,
): 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<Nip46AppEntry> {
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 }
}
@@ -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<String?>(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<Nip46ActivityEntry>) {
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)
}
+89
View File
@@ -898,6 +898,95 @@
<string name="napplet_decision_ask">Ask</string>
<string name="napplet_decision_deny">Deny</string>
<string name="napplet_connected_apps_search_keywords">apps permissions signer napplet nsite webapp trust connected</string>
<!-- NIP-46 remote signer (bunker) -->
<string name="nip46_signer_title">Remote Signer</string>
<string name="nip46_signer_search_keywords">signer bunker nip46 nostr connect remote sign</string>
<string name="nip46_signer_explainer">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.</string>
<string name="nip46_signer_enable">Act as a remote signer</string>
<plurals name="nip46_signer_status_listening">
<item quantity="one">Listening on %1$d relay</item>
<item quantity="other">Listening on %1$d relays</item>
</plurals>
<string name="nip46_signer_status_no_relays">No inbox relays configured. Add inbox relays so apps can reach your signer.</string>
<plurals name="nip46_signer_relays_all_live">
<item quantity="one">Listening on %1$d relay</item>
<item quantity="other">Listening on %1$d relays, all connected</item>
</plurals>
<string name="nip46_signer_relays_some_down">%1$d of %2$d relays connected</string>
<string name="nip46_signer_bunker_uri_label">Your bunker address</string>
<string name="nip46_signer_bunker_uri_hint">Paste this into another app to connect it to your key.</string>
<string name="nip46_signer_copy">Copy</string>
<string name="nip46_signer_copied">Bunker address copied</string>
<string name="nip46_signer_regenerate">New address</string>
<string name="nip46_signer_regenerated">Generated a new bunker address</string>
<string name="nip46_signer_rotate_confirm_title">Generate a new address?</string>
<string name="nip46_signer_rotate_confirm_message">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.</string>
<string name="nip46_signer_rotate_confirm_button">Generate</string>
<string name="nip46_signer_cancel">Cancel</string>
<string name="nip46_signer_connect_label">Connect an app</string>
<string name="nip46_signer_connect_hint">Paste a nostrconnect:// link</string>
<string name="nip46_signer_connect_button">Connect</string>
<string name="nip46_signer_manage_apps">Manage connected apps</string>
<string name="nip46_signer_apps_empty">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.</string>
<string name="nip46_signer_app_last_used">used %1$s</string>
<string name="nip46_signer_app_online">Connected</string>
<string name="nip46_signer_app_offline">Offline</string>
<string name="nip46_signer_app_reconnect">Reconnect</string>
<string name="nip46_signer_reconnecting">Reconnecting to relays…</string>
<string name="nip46_signer_app_relays_title">Relays</string>
<string name="nip46_signer_app_relays_inbox">Signs over your inbox relays — this app brought none of its own, so it keeps no extra background connection.</string>
<string name="nip46_signer_app_relays_own_hint">Amethyst holds a background connection to each of these while this app stays connected. Forget the app to drop them.</string>
<plurals name="nip46_signer_app_relay_count">
<item quantity="one">%1$d relay</item>
<item quantity="other">%1$d relays</item>
</plurals>
<string name="nip46_signer_connected_ok">App connected. Amethyst now signs for it in the background.</string>
<string name="nip46_signer_connected_named">Connected %1$s. Amethyst now signs for it in the background.</string>
<string name="nip46_signer_connect_invalid">Not a valid nostrconnect:// link</string>
<string name="nip46_signer_connect_no_relays">That link carries no relay to connect on</string>
<string name="nip46_signer_connect_failed">Could not connect: %1$s</string>
<string name="nip46_signer_connect_declined">Connection declined</string>
<string name="nip46_connect_requests_title">This app is requesting:</string>
<string name="nip46_signer_readonly">This is a read-only account and cannot sign.</string>
<string name="nip46_signer_remote_app">Remote signer app</string>
<string name="nip46_signer_background_hint">Amethyst keeps a background connection (shown as an ongoing notification) so it can answer signing requests while closed.</string>
<string name="nip46_signer_hero_title">Sign for other apps</string>
<string name="nip46_signer_turn_on">Turn on signer</string>
<string name="nip46_signer_live">Live</string>
<plurals name="nip46_signer_batch_title">
<item quantity="one">%1$d signing request</item>
<item quantity="other">%1$d signing requests</item>
</plurals>
<string name="nip46_signer_batch_select_all">Select all</string>
<string name="nip46_signer_batch_select_none">Select none</string>
<string name="nip46_signer_batch_remember">Remember these for each app</string>
<string name="nip46_signer_batch_signing_as">as %1$s</string>
<string name="nip46_signer_batch_allow">Allow %1$d</string>
<string name="nip46_signer_batch_deny">Deny %1$d</string>
<string name="nip46_signer_activity_title">Recent activity</string>
<string name="nip46_signer_activity_empty">No requests serviced yet.</string>
<string name="nip46_signer_activity_denied">denied</string>
<string name="nip46_signer_act_signed_kind">Signed an event (kind %1$d)</string>
<string name="nip46_signer_act_encrypted">Encrypted a message</string>
<string name="nip46_signer_act_decrypted">Decrypted a message</string>
<string name="nip46_signer_act_shared_pubkey">Shared your public key</string>
<string name="nip46_signer_act_connected">Connected</string>
<string name="nip46_signer_act_ping">Ping</string>
<string name="nip46_signer_act_listed_relays">Listed relays</string>
<string name="nip46_signer_act_other">%1$s</string>
<string name="nip46_signer_notif_channel_name">Signing requests</string>
<string name="nip46_signer_notif_channel_desc">Full-screen prompts asking you to approve an app that wants Amethyst to sign, encrypt, or decrypt with your key.</string>
<string name="nip46_signer_notif_sign_title">Approve a signing request?</string>
<string name="nip46_signer_notif_connect_title">An app wants to connect</string>
<string name="nip46_signer_notif_tap">Tap to review</string>
<string name="nip46_signer_scan_caption">Scan to connect an app to your key</string>
<string name="nip46_signer_scan_connect">Scan a code</string>
<string name="nip46_signer_paste_link">Paste a link instead</string>
<plurals name="nip46_signer_connected_count">
<item quantity="one">%1$d connected app</item>
<item quantity="other">%1$d connected apps</item>
</plurals>
<string name="napplet_connected_app_trust_level">Signing trust level</string>
<string name="napplet_connected_app_capabilities">Capabilities</string>
<string name="napplet_connected_app_forget">Forget this app</string>
@@ -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"))
@@ -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)
@@ -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<NostrSignerOp>,
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<String>,
@@ -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<NormalizedRelayUrl>,
secret: String,
authorizer: CliAuthorizer,
timeoutMs: Long?,
) {
val self = ctx.identity.pubKeyHex
val events = Channel<NostrConnectEvent>(UNLIMITED)
val seen = mutableSetOf<String>()
val subId = newSubId()
val listener =
object : SubscriptionListener {
override fun onEvent(
event: Event,
isLive: Boolean,
relay: NormalizedRelayUrl,
forFilters: List<Filter>?,
) {
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<NormalizedRelayUrl>,
) {
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<Event>(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()
}
}
@@ -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<NormalizedRelayUrl>,
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://<client-pubkey>?relay=…&secret=…&name=…` (percent-decoded). */
/** Parse `nostrconnect://<client-pubkey>?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<NormalizedRelayUrl>()
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<NormalizedRelayUrl>,
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:")
@@ -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<String> = emptySet(),
) {
fun isEmpty() = name == null && url == null && image == null && relays.isEmpty()
}
/**
* Persists [Nip46ClientInfo] per connected client, keyed by the same
* `nip46:<signerPubKey>:<clientPubKey>` 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<String, Nip46ClientInfo>
}
/** A thread-safe in-memory [Nip46ClientStore] for tests and ephemeral sessions. */
class InMemoryNip46ClientStore : Nip46ClientStore {
private val lock = KmpLock()
private val map = mutableMapOf<String, Nip46ClientInfo>()
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<String, Nip46ClientInfo> = lock.withLock { map.toMap() }
}
@@ -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:<signerPubKey>:<clientPubKey>` (see [coordinateFor]) so it
* lives in its own namespace next to `browser:<origin>` and napplet
* `<author>:<id>` 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<String>()
// 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<String, Long>()
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<HexKey> {
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:<signerPubKey>:<clientPubKey>`.
*/
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:<signer>:<client>` 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:<signer>:<client>` 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:<kind>`, `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<NostrSignerOp> =
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
}
}
}
@@ -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.
@@ -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].
@@ -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
@@ -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
}
@@ -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 ->
@@ -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
@@ -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
@@ -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
}
@@ -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<Event>(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<Event>(
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")
}
}
@@ -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<Event>(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<String>()
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))
}
}
@@ -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
@@ -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
@@ -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://<remote-signer-pubkey>?relay=…&secret=…` — the **signer**
* advertises how to reach it (Amethyst mints this when it acts as a bunker).
* - `nostrconnect://<client-pubkey>?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<NormalizedRelayUrl>,
val secret: String?,
)
/** A parsed `nostrconnect://` client offer. */
data class NostrConnect(
val clientPubKey: HexKey,
val relays: Set<NormalizedRelayUrl>,
val secret: String,
val perms: String? = null,
val name: String? = null,
val url: String? = null,
val image: String? = null,
)
/** Parse a `bunker://<pubkey>?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<NormalizedRelayUrl>()
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://<pubkey>?relay=…&secret=…` advertisement URI. */
fun buildBunker(
remoteSignerPubKey: HexKey,
relays: Collection<NormalizedRelayUrl>,
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://<client-pubkey>?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<NormalizedRelayUrl>()
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://<client-pubkey>?relay=…&secret=…&…` offer URI. */
fun buildNostrConnect(
clientPubKey: HexKey,
relays: Collection<NormalizedRelayUrl>,
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 `<authority>?<query>` into the authority and the raw query (empty when no `?`). */
private fun splitAuthority(rest: String): Pair<String, String>? {
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<Byte>(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()
}
}
@@ -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<NormalizedRelayUrl>,
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<Event>(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"
}
}
@@ -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()
}
@@ -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<NormalizedRelayUrl>,
/** 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<String> = 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<String, Window>()
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<NostrConnectEvent>(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<Filter>?,
) {
// 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}" }
}
}
}
@@ -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-._~"))
}
}
@@ -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<Event>(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<Unit>,
) : 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 <T : Event> sign(
createdAt: Long,
kind: Int,
tags: Array<Array<String>>,
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<Boolean>?,
) : 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<Unit>()
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<Unit>().apply { complete(Unit) } // signing itself never blocks here
val signer = GatedSigner(userPubKey, signGate)
val prompt = CompletableDeferred<Boolean>() // 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")
}
}
@@ -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 <T : Event> sign(
createdAt: Long,
kind: Int,
tags: Array<Array<String>>,
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<Event>(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<Event>(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<Event>(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 <T : Event> sign(
createdAt: Long,
kind: Int,
tags: Array<Array<String>>,
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<Event>(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"))
}
}
@@ -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 <T : Event> sign(
createdAt: Long,
kind: Int,
tags: Array<Array<String>>,
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<Event>()
override fun subscribe(
subId: String,
filters: Map<NormalizedRelayUrl, List<Filter>>,
listener: SubscriptionListener?,
) {
this.listener = listener
}
override fun publish(
event: Event,
relayList: Set<NormalizedRelayUrl>,
) {
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<Event>(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<Event>(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<Event>(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<String>()
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<Event>(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())
}
}