mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-06 11:48:24 +00:00
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:
@@ -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.
|
||||
@@ -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),
|
||||
|
||||
+24
-19
@@ -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()
|
||||
+68
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
+64
-24
@@ -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)
|
||||
+42
-12
@@ -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,
|
||||
+562
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+158
@@ -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"
|
||||
}
|
||||
+141
@@ -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
|
||||
}
|
||||
+128
@@ -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)
|
||||
|
||||
+29
@@ -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))
|
||||
|
||||
+56
@@ -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) }
|
||||
}
|
||||
}
|
||||
+174
@@ -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?,
|
||||
)
|
||||
}
|
||||
+396
@@ -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
|
||||
|
||||
-325
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
-94
@@ -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)
|
||||
}
|
||||
|
||||
+7
-7
@@ -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),
|
||||
)
|
||||
|
||||
+17
-4
@@ -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 {
|
||||
|
||||
+3
-1
@@ -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) }
|
||||
|
||||
+10
@@ -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(
|
||||
|
||||
+1
@@ -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
|
||||
|
||||
+192
-8
@@ -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) },
|
||||
|
||||
+16
-9
@@ -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,
|
||||
|
||||
+116
@@ -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)}"
|
||||
}
|
||||
+410
@@ -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 }
|
||||
}
|
||||
+598
@@ -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)
|
||||
}
|
||||
@@ -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:")
|
||||
|
||||
+82
@@ -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() }
|
||||
}
|
||||
+348
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -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.
|
||||
+1
-1
@@ -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].
|
||||
+1
-1
@@ -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
|
||||
+1
-15
@@ -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
|
||||
}
|
||||
+21
-1
@@ -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 ->
|
||||
+1
-1
@@ -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
|
||||
+9
-9
@@ -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
|
||||
|
||||
+38
@@ -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
|
||||
}
|
||||
+161
@@ -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")
|
||||
}
|
||||
}
|
||||
+397
@@ -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))
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -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
|
||||
+5
-5
@@ -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
|
||||
|
||||
+193
@@ -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()
|
||||
}
|
||||
}
|
||||
+181
@@ -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"
|
||||
}
|
||||
}
|
||||
+88
@@ -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()
|
||||
}
|
||||
+282
@@ -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}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
+99
@@ -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-._~"))
|
||||
}
|
||||
}
|
||||
+172
@@ -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")
|
||||
}
|
||||
}
|
||||
+330
@@ -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"))
|
||||
}
|
||||
}
|
||||
+351
@@ -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())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user