test(nip46): consent integration test + device verification checklist (Tier 4)

- Nip46ConsentIntegrationTest: end-to-end through the real dispatch path
  (BunkerRequestProcessor → Nip46PermissionAuthorizer → opConsent/connectConsent)
  with a real NostrSignerInternal — proves an ASK sign prompts and returns a
  signed event on allow, "unauthorized" on deny, and that a FULL_TRUST app
  signs even a dangerous kind (0) without prompting.
- Device checklist (amethyst/plans/) for the interactive/background/interop
  behavior JVM tests can't cover: pairing paths, consent variants, rotation,
  activity feed, relay health, boot restart, and the reference-client matrix.

Notification polish was deliberately skipped: the always-on notification is
shared with the relay/DM service, and consent uses its own dialog Activity, so
neither retitling nor notification actions are warranted. Documented in the
checklist.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015FHr2mu5SiHwYNR7evYUuF
This commit is contained in:
Claude
2026-07-17 15:18:29 +00:00
parent 684ce26f63
commit ac6697330e
2 changed files with 195 additions and 0 deletions
@@ -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.
@@ -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<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 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")
}
}