diff --git a/amethyst/plans/2026-07-16-nip46-signer-device-checklist.md b/amethyst/plans/2026-07-16-nip46-signer-device-checklist.md new file mode 100644 index 0000000000..eb3d9241b6 --- /dev/null +++ b/amethyst/plans/2026-07-16-nip46-signer-device-checklist.md @@ -0,0 +1,72 @@ +# 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. +- [ ] **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). + +## 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 + +## Deliberately NOT changed +The always-on foreground **notification** was left as-is: it is shared with the +relay/DM always-on service, so retitling it "Signing for N apps" or deep-linking +it to the signer screen would be wrong when the service is up for another reason. +Interactive consent uses its own dedicated dialog Activity, so it needs no +notification actions. diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/connectedApps/nip46/Nip46ConsentIntegrationTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/connectedApps/nip46/Nip46ConsentIntegrationTest.kt new file mode 100644 index 0000000000..ef7c927dc0 --- /dev/null +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/connectedApps/nip46/Nip46ConsentIntegrationTest.kt @@ -0,0 +1,123 @@ +/* + * 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.signers.EventTemplate +import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestConnect +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerRequestSign +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponseError +import com.vitorpamplona.quartz.nip46RemoteSigner.BunkerResponseEvent +import com.vitorpamplona.quartz.nip46RemoteSigner.server.BunkerRequestProcessor +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * End-to-end proof that interactive consent flows through the real dispatch path: + * [BunkerRequestProcessor] → [Nip46PermissionAuthorizer] → the `opConsent`/`connectConsent` + * prompts, with a real [NostrSignerInternal] doing the signing. Covers the two outcomes the + * per-op prompt must produce (a signed event vs. an `unauthorized` error) and a dangerous kind + * being allowed once the app is FULL_TRUST. + */ +class Nip46ConsentIntegrationTest { + private val signer = NostrSignerInternal(KeyPair()) + private val client = "c".repeat(64) + private val coordinate get() = Nip46PermissionAuthorizer.coordinateFor(signer.pubKey, client) + + private fun ledger() = NostrSignerPermissionLedger(InMemoryNostrSignerPermissionStore()) + + private fun signRequest(kind: Int) = BunkerRequestSign("1", EventTemplate(createdAt = 1L, kind = kind, tags = emptyArray(), content = "hi")) + + @Test + fun askSignPromptedAllowProducesASignedEvent() = + runTest { + val ledger = ledger() + ledger.setPolicy(coordinate, AppSignerPolicy.PARANOID) + val authorizer = + Nip46PermissionAuthorizer( + ledger, + signerPubKey = signer.pubKey, + validateSecret = { _, _ -> true }, + opConsent = { _, _, _, _ -> SignerOpGrant.AllowOnce }, + ) + val processor = BunkerRequestProcessor(signer, { emptySet() }, authorizer) + + val response = processor.process(client, signRequest(1)) + + assertTrue(response is BunkerResponseEvent, "an allowed sign returns the signed event") + assertEquals(signer.pubKey, response.event.pubKey, "the event is signed by the identity key") + } + + @Test + fun askSignDeniedReturnsUnauthorized() = + runTest { + val ledger = ledger() + ledger.setPolicy(coordinate, AppSignerPolicy.PARANOID) + val authorizer = + Nip46PermissionAuthorizer( + ledger, + signerPubKey = signer.pubKey, + validateSecret = { _, _ -> true }, + opConsent = { _, _, _, _ -> SignerOpGrant.DenyOnce }, + ) + val processor = BunkerRequestProcessor(signer, { emptySet() }, authorizer) + + val response = processor.process(client, signRequest(1)) + + assertTrue(response is BunkerResponseError) + assertEquals(BunkerRequestProcessor.ERROR_UNAUTHORIZED, response.error) + } + + @Test + fun 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") + } +}