mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-08 23:54:39 +00:00
feat(napplets): require Amethyst consent for all signer types, auto-approve encrypt/decrypt
Remove the signer self-gating bypass that allowed external (Amber/NIP-55) and remote (NIP-46) signers to skip Amethyst's per-napplet consent UI. All signer types now go through Amethyst's consent dialogs first; the external signer then adds its own approval on top (double-prompting). This lets users differentiate signing requests by app inside the external signer, since Amethyst itself is the requesting app. Also expand the REASONABLE policy to auto-approve Encrypt and Decrypt operations, matching the intent that common/private-key operations that apps routinely need are pre-approved at the "reasonable" trust level. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013hTFpoExYYLYEGGtXBx6ZT
This commit is contained in:
+8
-17
@@ -36,7 +36,6 @@ import com.vitorpamplona.amethyst.commons.napplet.signers.SignerOpGrant
|
||||
import com.vitorpamplona.amethyst.commons.napplet.signers.toSignerOp
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
@@ -55,13 +54,10 @@ import kotlin.coroutines.cancellation.CancellationException
|
||||
* Two capability-specific policies refine step 2/3:
|
||||
* - **Per-use capabilities** ([NappletCapability.requiresPerUseConsent], i.e. [NappletCapability.VALUE])
|
||||
* never auto-approve from a prior grant — every payment is confirmed afresh, with the amount shown.
|
||||
* - **Signer self-gating**: an identity read or a sign-as-user op ([NappletRequest.signsAsUser])
|
||||
* is gated here only when the key lives in Amethyst (a [NostrSignerInternal]). Remote (NIP-46) and
|
||||
* external (NIP-55) signers run their own per-request consent UI, so we defer to them rather than
|
||||
* double-prompt. A standing DENY is still honored, and the applet must still have *declared* the
|
||||
* capability. This is safe only because the napplet host runs foreground-only, so the signer's
|
||||
* prompt appears in the clear context of the user interacting with that napplet (it can't be
|
||||
* fired from the background).
|
||||
* - All signer types — internal, NIP-46 remote, and NIP-55 external — are gated through Amethyst's
|
||||
* consent UI before the operation reaches the signer. External signers add their own per-request
|
||||
* prompt on top (double-prompting), ensuring the user can differentiate requests from different
|
||||
* apps inside the external signer.
|
||||
*
|
||||
* Security invariants enforced here (never trusted from the applet): the signing identity is
|
||||
* always the host's signer; the napplet only ever supplies an unsigned template — the shell signs
|
||||
@@ -125,8 +121,8 @@ class NappletBroker(
|
||||
return NappletResponse.Denied(capability, "Blocked by a standing denial.")
|
||||
}
|
||||
|
||||
// For internal signers, show the first-connect dialog if the app has no signer policy yet.
|
||||
if (signer is NostrSignerInternal && signerLedger != null && !signerLedger.hasPolicy(identity.coordinate)) {
|
||||
// Show the first-connect dialog if the app has no signer policy yet.
|
||||
if (signerLedger != null && !signerLedger.hasPolicy(identity.coordinate)) {
|
||||
if (!ensureConnected(identity, declared)) {
|
||||
return NappletResponse.Denied(capability, "Connection not authorized.")
|
||||
}
|
||||
@@ -139,8 +135,6 @@ class NappletBroker(
|
||||
request is NappletRequest.RegisterAction || request is NappletRequest.UnregisterAction -> true
|
||||
// Cosmetic/negotiation capabilities (theme) never prompt.
|
||||
!capability.requiresConsent -> true
|
||||
// Remote/external signers run their own per-request consent UI — defer to them.
|
||||
signerSelfGates(request) -> true
|
||||
// A standing allow short-circuits, except for per-use capabilities (e.g. payments).
|
||||
ledger.decide(identity, capability) == PermissionDecision.ALLOW && !capability.requiresPerUseConsent -> true
|
||||
else -> authorizeWithConsent(identity, capability, request)
|
||||
@@ -148,8 +142,8 @@ class NappletBroker(
|
||||
|
||||
if (!authorized) return NappletResponse.Denied(capability, "The user declined.")
|
||||
|
||||
// Additional per-operation gate for internal signer signing/encryption.
|
||||
if (signer is NostrSignerInternal && signerLedger != null) {
|
||||
// Additional per-operation gate for signing/encryption.
|
||||
if (signerLedger != null) {
|
||||
val op = request.toSignerOp()
|
||||
if (op != null && !authorizeSignerOp(identity, op, request)) {
|
||||
return NappletResponse.Denied(capability, "Signing operation declined.")
|
||||
@@ -193,9 +187,6 @@ class NappletBroker(
|
||||
grant.allowsExecution
|
||||
}
|
||||
|
||||
/** Identity reads and sign-as-user ops are gated by us only when we hold the key; remote/external signers gate themselves. */
|
||||
private fun signerSelfGates(request: NappletRequest): Boolean = (request.capability == NappletCapability.IDENTITY || request.signsAsUser) && signer !is NostrSignerInternal
|
||||
|
||||
/** Downgrades a grant to one-shot when the capability forbids persisting that scope (e.g. payments). */
|
||||
private fun effectiveGrant(
|
||||
capability: NappletCapability,
|
||||
|
||||
+3
-2
@@ -30,8 +30,9 @@ enum class AppSignerPolicy {
|
||||
FULL_TRUST,
|
||||
|
||||
/**
|
||||
* Auto-approve the most common operations (kind 1 short notes, kind 6 reposts, kind 7
|
||||
* reactions); ask before anything else. A reasonable default for most apps.
|
||||
* Auto-approve the most common operations: kind 1 short notes, kind 6 reposts, kind 7
|
||||
* reactions, and all encrypt/decrypt operations; ask before anything else.
|
||||
* A reasonable default for most apps.
|
||||
*/
|
||||
REASONABLE,
|
||||
|
||||
|
||||
+2
-2
@@ -123,7 +123,7 @@ class NostrSignerPermissionLedger(
|
||||
7 -> NostrOpDecision.ALLOW // Reactions / emoji
|
||||
else -> NostrOpDecision.ASK
|
||||
}
|
||||
NostrSignerOp.Encrypt -> NostrOpDecision.ASK
|
||||
NostrSignerOp.Decrypt -> NostrOpDecision.ASK
|
||||
NostrSignerOp.Encrypt -> NostrOpDecision.ALLOW
|
||||
NostrSignerOp.Decrypt -> NostrOpDecision.ALLOW
|
||||
}
|
||||
}
|
||||
|
||||
+16
-3
@@ -405,16 +405,29 @@ class NappletBrokerTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun externalSignerDefersIdentityWithoutPrompting() =
|
||||
fun externalSignerIsPromptedByAmethystForIdentity() =
|
||||
runTest {
|
||||
val prompt = ScriptedPrompt(GrantState.DENY) // would block if we asked
|
||||
val prompt = ScriptedPrompt(GrantState.DENY)
|
||||
val external = FakeExternalSigner("dd".repeat(32))
|
||||
|
||||
val response =
|
||||
broker(prompt, signer = external).handle(applet, NappletRequest.GetPublicKey, allDeclared)
|
||||
|
||||
assertIs<NappletResponse.Denied>(response) // Amethyst asked and user denied
|
||||
assertEquals(1, prompt.calls) // Amethyst prompts all signer types
|
||||
}
|
||||
|
||||
@Test
|
||||
fun externalSignerAllowedByAmethystReturnsPublicKey() =
|
||||
runTest {
|
||||
val prompt = ScriptedPrompt(GrantState.ALLOW_ONCE)
|
||||
val external = FakeExternalSigner("dd".repeat(32))
|
||||
|
||||
val response =
|
||||
broker(prompt, signer = external).handle(applet, NappletRequest.GetPublicKey, allDeclared)
|
||||
|
||||
assertEquals(NappletResponse.PublicKey(external.pubKey), response)
|
||||
assertEquals(0, prompt.calls) // deferred to the external signer; we did not prompt
|
||||
assertEquals(1, prompt.calls) // Amethyst prompted before the external signer
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
Reference in New Issue
Block a user