mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 19:28:25 +00:00
feat(napplet): expose NIP-44 encrypt/decrypt on the injected window.nostr
The injected NIP-07 provider offered only getPublicKey/getRelays/signEvent, so a page hosted in the nSite viewer or the in-app browser could sign but never seal: a NIP-59 kind:13 seal is NIP-44 ciphertext authored by the real key, and signEvent alone cannot produce one. That put NIP-17 DMs and every gift-wrapped app protocol out of reach of any web app logging in with Amethyst. Adds nostr.nip44Encrypt/nip44Decrypt to the broker, behind a new SIGNER capability. SIGNER is website-only by construction: no NIP-5D domain maps to it, so resolveRequiredCapabilities can never hand it to a locked napplet however its manifest is written. Individual calls still pass the per-operation signer ledger, reusing the vocabulary NIP-46 already uses for the same ops -- encrypt auto-allows under REASONABLE, decrypt always asks. nip04 stays deliberately absent. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012Hge2jR1BPnyZse75VQ4kg
This commit is contained in:
@@ -31,6 +31,7 @@ fun NappletCapability.labelRes(): Int =
|
||||
NappletCapability.IDENTITY -> R.string.napplet_cap_identity
|
||||
NappletCapability.KEYS -> R.string.napplet_cap_keys
|
||||
NappletCapability.RELAY -> R.string.napplet_cap_relay
|
||||
NappletCapability.SIGNER -> R.string.napplet_cap_signer
|
||||
NappletCapability.STORAGE -> R.string.napplet_cap_storage
|
||||
NappletCapability.VALUE -> R.string.napplet_cap_value
|
||||
NappletCapability.RESOURCE -> R.string.napplet_cap_resource
|
||||
@@ -47,6 +48,7 @@ fun NappletCapability.descriptionRes(): Int =
|
||||
NappletCapability.IDENTITY -> R.string.napplet_cap_identity_desc
|
||||
NappletCapability.KEYS -> R.string.napplet_cap_keys_desc
|
||||
NappletCapability.RELAY -> R.string.napplet_cap_relay_desc
|
||||
NappletCapability.SIGNER -> R.string.napplet_cap_signer_desc
|
||||
NappletCapability.STORAGE -> R.string.napplet_cap_storage_desc
|
||||
NappletCapability.VALUE -> R.string.napplet_cap_value_desc
|
||||
NappletCapability.RESOURCE -> R.string.napplet_cap_resource_desc
|
||||
|
||||
@@ -275,6 +275,14 @@ class NappletConsentSummary(
|
||||
}
|
||||
}
|
||||
is NappletRequest.PublishEncrypted -> context.getString(R.string.napplet_consent_publish_encrypted)
|
||||
is NappletRequest.Nip44Encrypt -> {
|
||||
val preview = request.plaintext.take(160).trim()
|
||||
val summary = context.getString(R.string.napplet_consent_nip44_encrypt, counterpartyLabel(request.peer))
|
||||
if (preview.isEmpty()) summary else summary + "\n\u201C$preview\u201D"
|
||||
}
|
||||
// The ciphertext is meaningless to show, so name the counterparty instead — that is
|
||||
// the decision the user is actually making ("let this page read messages from X").
|
||||
is NappletRequest.Nip44Decrypt -> context.getString(R.string.napplet_consent_nip44_decrypt, counterpartyLabel(request.peer))
|
||||
is NappletRequest.QueryEvents, is NappletRequest.Subscribe -> context.getString(R.string.napplet_consent_query)
|
||||
is NappletRequest.StorageGet, is NappletRequest.StorageSet, is NappletRequest.StorageRemove, is NappletRequest.StorageKeys ->
|
||||
context.getString(R.string.napplet_consent_storage)
|
||||
|
||||
@@ -84,6 +84,10 @@ fun buildSignerConsentInfo(
|
||||
is NappletRequest.Publish -> request.content.take(160).trim()
|
||||
is NappletRequest.SignEvent -> request.content.take(160).trim()
|
||||
is NappletRequest.PublishEncrypted -> request.content.take(160).trim()
|
||||
// Encryption shows the plaintext the page wants sealed; decryption has only ciphertext,
|
||||
// which tells the user nothing, so its preview stays empty and the counterparty in
|
||||
// rawData carries the meaning.
|
||||
is NappletRequest.Nip44Encrypt -> request.plaintext.take(160).trim()
|
||||
else -> ""
|
||||
}
|
||||
val rawData =
|
||||
@@ -105,6 +109,20 @@ fun buildSignerConsentInfo(
|
||||
node.put("content", request.content)
|
||||
JacksonMapper.mapper.writerWithDefaultPrettyPrinter().writeValueAsString(node)
|
||||
}
|
||||
is NappletRequest.Nip44Encrypt -> {
|
||||
val node = JacksonMapper.mapper.createObjectNode()
|
||||
node.put("operation", "nip44.encrypt")
|
||||
node.put("peer", request.peer)
|
||||
node.put("plaintext", request.plaintext)
|
||||
JacksonMapper.mapper.writerWithDefaultPrettyPrinter().writeValueAsString(node)
|
||||
}
|
||||
is NappletRequest.Nip44Decrypt -> {
|
||||
val node = JacksonMapper.mapper.createObjectNode()
|
||||
node.put("operation", "nip44.decrypt")
|
||||
node.put("peer", request.peer)
|
||||
node.put("ciphertext", request.ciphertext)
|
||||
JacksonMapper.mapper.writerWithDefaultPrettyPrinter().writeValueAsString(node)
|
||||
}
|
||||
else -> ""
|
||||
}
|
||||
val previewTemplate =
|
||||
|
||||
+1
@@ -29,6 +29,7 @@ internal fun NappletCapability.symbol(): MaterialSymbol =
|
||||
NappletCapability.IDENTITY -> MaterialSymbols.AccountCircle
|
||||
NappletCapability.KEYS -> MaterialSymbols.Key
|
||||
NappletCapability.RELAY -> MaterialSymbols.Public
|
||||
NappletCapability.SIGNER -> MaterialSymbols.Lock
|
||||
NappletCapability.STORAGE -> MaterialSymbols.Storage
|
||||
NappletCapability.VALUE -> MaterialSymbols.Bolt
|
||||
NappletCapability.RESOURCE -> MaterialSymbols.Language
|
||||
|
||||
@@ -401,6 +401,7 @@
|
||||
<string name="napplet_cap_identity">Identity</string>
|
||||
<string name="napplet_cap_keys">Keyboard actions</string>
|
||||
<string name="napplet_cap_relay">Relays</string>
|
||||
<string name="napplet_cap_signer">Encryption</string>
|
||||
<string name="napplet_cap_storage">Storage</string>
|
||||
<string name="napplet_cap_value">Payments</string>
|
||||
<string name="napplet_cap_resource">Network</string>
|
||||
@@ -409,6 +410,7 @@
|
||||
<string name="napplet_cap_identity_desc">Read your public key</string>
|
||||
<string name="napplet_cap_keys_desc">Bind keyboard shortcuts</string>
|
||||
<string name="napplet_cap_relay_desc">Read, and sign & publish your events</string>
|
||||
<string name="napplet_cap_signer_desc">Encrypt and decrypt private messages with your key</string>
|
||||
<string name="napplet_cap_storage_desc">Its own private storage</string>
|
||||
<string name="napplet_cap_value_desc">Pay Lightning invoices</string>
|
||||
<string name="napplet_cap_resource_desc">Fetch web and Blossom resources</string>
|
||||
@@ -426,6 +428,8 @@
|
||||
<string name="napplet_consent_publish">This nApplet wants to sign and publish a kind %1$d event as you.</string>
|
||||
<string name="napplet_consent_publish_preview">This nApplet wants to sign and publish a kind %1$d event as you:</string>
|
||||
<string name="napplet_consent_publish_encrypted">This nApplet wants to send an encrypted event as you.</string>
|
||||
<string name="napplet_consent_nip44_encrypt">This site wants to encrypt a message to %1$s with your Nostr key.</string>
|
||||
<string name="napplet_consent_nip44_decrypt">This site wants to decrypt a message from %1$s with your Nostr key.</string>
|
||||
<string name="napplet_consent_query">This nApplet wants to read events from your relays.</string>
|
||||
<string name="napplet_consent_storage">This nApplet wants to use its private storage.</string>
|
||||
<string name="napplet_consent_pay">This nApplet wants to pay a Lightning invoice.</string>
|
||||
|
||||
@@ -368,4 +368,33 @@ class NappletProtocolJsonTest {
|
||||
val failed = json.parseToJsonElement(NappletProtocolJson.encodeResponse("relay.publish", NappletResponse.Failed("boom"))).jsonObject
|
||||
assertEquals("boom", failed["reason"]?.jsonPrimitive?.content)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun decodesNip44EncryptAndDecrypt() {
|
||||
// The envelope the injected window.nostr.nip44 shim posts. Field names are the contract
|
||||
// between shim.js and this decoder — renaming either side silently breaks NIP-17 in nSites.
|
||||
assertEquals(
|
||||
NappletRequest.Nip44Encrypt("pk", "gm"),
|
||||
NappletProtocolJson.decodeRequest("""{"type":"nostr.nip44Encrypt","id":"1","peer":"pk","plaintext":"gm"}"""),
|
||||
)
|
||||
assertEquals(
|
||||
NappletRequest.Nip44Decrypt("pk", "cipher"),
|
||||
NappletProtocolJson.decodeRequest("""{"type":"nostr.nip44Decrypt","id":"1","peer":"pk","ciphertext":"cipher"}"""),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nip44WithoutAPeerIsRejectedRatherThanDefaultedToSomeKey() {
|
||||
// `peer` is required: silently substituting a default would encrypt to the wrong party.
|
||||
assertThrowsAny { NappletProtocolJson.decodeRequest("""{"type":"nostr.nip44Encrypt","id":"1","plaintext":"gm"}""") }
|
||||
assertThrowsAny { NappletProtocolJson.decodeRequest("""{"type":"nostr.nip44Decrypt","id":"1","ciphertext":"c"}""") }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun encodesTextResultsUnderValue() {
|
||||
val result = json.parseToJsonElement(NappletProtocolJson.encodeResponse("nostr.nip44Encrypt", NappletResponse.Text("CIPHER"))).jsonObject
|
||||
assertEquals("nostr.nip44Encrypt.result", result["type"]?.jsonPrimitive?.content)
|
||||
assertTrue(result["ok"]!!.jsonPrimitive.boolean)
|
||||
assertEquals("CIPHER", result["value"]?.jsonPrimitive?.content)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -812,11 +812,20 @@
|
||||
// window.__nappletNip07 synchronously before this shim). Lets standard Nostr web apps "log in with
|
||||
// Amethyst" and sign, bridged to the same consent-gated signer: getPublicKey + getRelays reuse the
|
||||
// identity reads; signEvent is sign-only (no publish) and honors the app's created_at.
|
||||
// nip44 is optional in NIP-07 but not optional in practice: a kind:13 seal is NIP-44 ciphertext
|
||||
// authored by the real key, so without it a page can sign yet cannot build a NIP-59 gift wrap —
|
||||
// NIP-17 DMs and every gift-wrapped app protocol are simply unreachable. The shell does the crypto
|
||||
// and returns only the result; the key never enters the page. nip04 stays deliberately absent
|
||||
// (deprecated, and nothing that still needs it should be encouraged).
|
||||
if (window.__nappletNip07 && !window.nostr) {
|
||||
window.nostr = Object.freeze({
|
||||
getPublicKey: function(){ return field(call('identity.getPublicKey'), 'pubkey'); },
|
||||
getRelays: function(){ return field(call('identity.getRelays'), 'relays'); },
|
||||
signEvent: function(event){ return field(call('nostr.signEvent', { event: event }), 'event'); }
|
||||
signEvent: function(event){ return field(call('nostr.signEvent', { event: event }), 'event'); },
|
||||
nip44: Object.freeze({
|
||||
encrypt: function(peer, plaintext){ return field(call('nostr.nip44Encrypt', { peer: peer, plaintext: String(plaintext) }), 'value'); },
|
||||
decrypt: function(peer, ciphertext){ return field(call('nostr.nip44Decrypt', { peer: peer, ciphertext: String(ciphertext) }), 'value'); }
|
||||
})
|
||||
});
|
||||
}
|
||||
})();
|
||||
|
||||
+6
@@ -245,6 +245,12 @@ class NappletBroker(
|
||||
signAndPublish(request.kind, withRecipientTag(request.tags, request.recipient), ciphertext)
|
||||
}
|
||||
|
||||
// NIP-07 nip44.encrypt/decrypt: the shell runs the crypto with the real key and hands back
|
||||
// only the result, so the page can build its own NIP-59 seals without ever seeing the key.
|
||||
is NappletRequest.Nip44Encrypt -> NappletResponse.Text(signer.nip44Encrypt(request.plaintext, request.peer))
|
||||
|
||||
is NappletRequest.Nip44Decrypt -> NappletResponse.Text(signer.nip44Decrypt(request.ciphertext, request.peer))
|
||||
|
||||
is NappletRequest.QueryEvents -> {
|
||||
val gateway = relay ?: return NappletResponse.Unsupported("relay.query")
|
||||
NappletResponse.Events(gateway.query(request.filters))
|
||||
|
||||
+15
@@ -44,6 +44,21 @@ enum class NappletCapability {
|
||||
/** `relay` — publish (shell-signed), query, and subscribe to the user's relays. */
|
||||
RELAY,
|
||||
|
||||
/**
|
||||
* NIP-44 encrypt/decrypt with the user's key, returning the ciphertext/plaintext to the caller
|
||||
* instead of publishing it. Needed by any standard Nostr web app that builds NIP-59 seals
|
||||
* itself (NIP-17 DMs, gift-wrapped app protocols) — signing alone cannot produce a seal.
|
||||
*
|
||||
* Deliberately **not** in [fromNapDomain]: no NIP-5D domain maps here, so a locked napplet can
|
||||
* never declare it. It is granted only to the website posture (the NIP-07 `window.nostr`
|
||||
* surface), where the page is already trusted with `signEvent`. Every individual call still
|
||||
* passes the per-operation signer ledger
|
||||
* ([Encrypt][com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerOp.Encrypt] /
|
||||
* [Decrypt][com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerOp.Decrypt]),
|
||||
* which is what actually keeps decryption behind a prompt.
|
||||
*/
|
||||
SIGNER,
|
||||
|
||||
/** `storage` — a per-applet sandboxed key-value store, namespaced by applet identity. */
|
||||
STORAGE,
|
||||
|
||||
|
||||
+35
@@ -175,6 +175,41 @@ sealed interface NappletRequest {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* NIP-07 `window.nostr.nip44.encrypt`: NIP-44 encrypt [plaintext] to [peer] with the user's key
|
||||
* and return the ciphertext **without publishing it**. This is what lets a web app build its own
|
||||
* NIP-59 seals (a kind:13 seal is NIP-44 ciphertext authored by the real key, so `signEvent`
|
||||
* alone cannot produce one) — NIP-17 DMs and every gift-wrapped app protocol need it.
|
||||
*
|
||||
* The page never touches the key: the shell encrypts and hands back only the result.
|
||||
*
|
||||
* [signsAsUser] stays false — this produces no event and no signature; the gate that matters is
|
||||
* the [NostrSignerOp][com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerOp]
|
||||
* mapping in `toSignerOp`.
|
||||
*/
|
||||
data class Nip44Encrypt(
|
||||
val peer: HexKey,
|
||||
val plaintext: String,
|
||||
) : NappletRequest {
|
||||
override val capability get() = NappletCapability.SIGNER
|
||||
}
|
||||
|
||||
/**
|
||||
* NIP-07 `window.nostr.nip44.decrypt`: NIP-44 decrypt [ciphertext] from [peer] with the user's
|
||||
* key and return the plaintext. The counterpart of [Nip44Encrypt] — a NIP-17 client needs it to
|
||||
* open incoming seals.
|
||||
*
|
||||
* Strictly more dangerous than encryption (it reads, rather than writes, private content), so it
|
||||
* maps to [NostrSignerOp.Decrypt][com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerOp.Decrypt],
|
||||
* which always asks under the REASONABLE policy instead of auto-approving.
|
||||
*/
|
||||
data class Nip44Decrypt(
|
||||
val peer: HexKey,
|
||||
val ciphertext: String,
|
||||
) : NappletRequest {
|
||||
override val capability get() = NappletCapability.SIGNER
|
||||
}
|
||||
|
||||
/** Read events matching [filters] (from the cache and/or a bounded relay fetch). */
|
||||
data class QueryEvents(
|
||||
val filters: List<Filter>,
|
||||
|
||||
+5
@@ -34,5 +34,10 @@ fun NappletRequest.toSignerOp(): NostrSignerOp? =
|
||||
is NappletRequest.Publish -> NostrSignerOp.SignKind(kind)
|
||||
is NappletRequest.SignEvent -> NostrSignerOp.SignKind(kind)
|
||||
is NappletRequest.PublishEncrypted -> NostrSignerOp.Encrypt
|
||||
is NappletRequest.Nip44Encrypt -> NostrSignerOp.Encrypt
|
||||
// The broad grant, matching what NIP-46's nip44_decrypt maps to. The narrower
|
||||
// DecryptFrom(peer) exists, but only the NIP-46 authorizer honours it today; recording one
|
||||
// here would be a grant this broker never reads back, so the user would re-prompt forever.
|
||||
is NappletRequest.Nip44Decrypt -> NostrSignerOp.Decrypt
|
||||
else -> null
|
||||
}
|
||||
|
||||
+8
@@ -65,6 +65,14 @@ sealed interface NappletResponse {
|
||||
val value: String?,
|
||||
) : NappletResponse
|
||||
|
||||
/**
|
||||
* A single opaque string result, returned in the `value` field. Used by the NIP-44 ops, where the
|
||||
* payload is ciphertext one way and plaintext the other and the host has nothing to interpret.
|
||||
*/
|
||||
data class Text(
|
||||
val value: String,
|
||||
) : NappletResponse
|
||||
|
||||
/** Result of `storage.keys` (and other string-list reads). */
|
||||
data class Strings(
|
||||
val values: List<String>,
|
||||
|
||||
+105
@@ -745,4 +745,109 @@ class NappletBrokerTest {
|
||||
assertIs<NappletResponse.ResourceFailure>(tooLarge)
|
||||
assertEquals("too-large", tooLarge.error)
|
||||
}
|
||||
|
||||
// ---- NIP-44 (window.nostr.nip44) ------------------------------------------------------------
|
||||
//
|
||||
// The gap these close: a page could sign but not encrypt, so it could not build a kind:13 seal
|
||||
// and every NIP-59 / NIP-17 flow was unreachable through the in-app browser.
|
||||
|
||||
@Test
|
||||
fun nip44RoundTripsThroughTheBrokerWithoutExposingTheKey() =
|
||||
runTest {
|
||||
val peer = NostrSignerInternal(KeyPair("11".repeat(32).hexToByteArray()))
|
||||
val broker = broker(ScriptedPrompt(GrantState.ALLOW_ALWAYS))
|
||||
|
||||
val encrypted =
|
||||
broker.handle(applet, NappletRequest.Nip44Encrypt(peer.pubKey, "gm from the nsite"), allDeclared)
|
||||
assertIs<NappletResponse.Text>(encrypted)
|
||||
|
||||
// The peer decrypts it with its own key: proof this is real NIP-44 to that pubkey and
|
||||
// not some shell-local encoding.
|
||||
assertEquals("gm from the nsite", peer.nip44Decrypt(encrypted.value, signer.pubKey))
|
||||
|
||||
// ...and back the other way, which is what an inbound seal needs.
|
||||
val sealed = peer.nip44Encrypt("hello back", signer.pubKey)
|
||||
val decrypted = broker.handle(applet, NappletRequest.Nip44Decrypt(peer.pubKey, sealed), allDeclared)
|
||||
assertIs<NappletResponse.Text>(decrypted)
|
||||
assertEquals("hello back", decrypted.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun nip44IsRefusedWhenTheHostDidNotDeclareTheSignerCapability() =
|
||||
runTest {
|
||||
val prompt = ScriptedPrompt(GrantState.ALLOW_ALWAYS)
|
||||
val broker = broker(prompt)
|
||||
|
||||
// A locked napplet's declared set can never contain SIGNER (no NAP domain maps to it),
|
||||
// so the request must die at the capability gate without ever reaching a prompt.
|
||||
val response =
|
||||
broker.handle(
|
||||
applet,
|
||||
NappletRequest.Nip44Decrypt("bb".repeat(32), "cipher"),
|
||||
setOf(NappletCapability.IDENTITY, NappletCapability.RELAY),
|
||||
)
|
||||
|
||||
assertIs<NappletResponse.Denied>(response)
|
||||
assertEquals(NappletCapability.SIGNER, response.capability)
|
||||
assertEquals(0, prompt.calls)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun noNapDomainCanEverGrantTheSignerCapability() {
|
||||
// The website-only guarantee is structural, not a policy someone can misconfigure: if any
|
||||
// domain string ever mapped to SIGNER, a manifest could ask for the user's decryption.
|
||||
assertNull(NappletCapability.fromNapDomain("signer"))
|
||||
assertTrue(NappletCapability.supportedNapDomains.none { NappletCapability.fromNapDomain(it) == NappletCapability.SIGNER })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun decryptAsksEveryTimeUnderTheReasonablePolicyWhileEncryptDoesNot() =
|
||||
runTest {
|
||||
// The asymmetry is the point: encrypting writes content the user is already composing,
|
||||
// decrypting reads private content, so only the latter keeps prompting.
|
||||
val signerLedger = NostrSignerPermissionLedger(InMemoryNostrSignerPermissionStore())
|
||||
signerLedger.setPolicy("napplet:${signer.pubKey}:${applet.coordinate}", AppSignerPolicy.REASONABLE)
|
||||
|
||||
val opPrompt = ScriptedSignerPrompt(SignerOpGrant.AllowOnce)
|
||||
val broker =
|
||||
NappletBroker(
|
||||
signer = signer,
|
||||
ledger = NappletPermissionLedger(InMemoryNappletPermissionStore()),
|
||||
consentPrompt = ScriptedPrompt(GrantState.ALLOW_ALWAYS),
|
||||
signerLedger = signerLedger,
|
||||
signerConsentPrompt = opPrompt,
|
||||
)
|
||||
|
||||
val peer = NostrSignerInternal(KeyPair("22".repeat(32).hexToByteArray()))
|
||||
broker.handle(applet, NappletRequest.Nip44Encrypt(peer.pubKey, "a"), allDeclared)
|
||||
broker.handle(applet, NappletRequest.Nip44Encrypt(peer.pubKey, "b"), allDeclared)
|
||||
assertEquals(0, opPrompt.calls)
|
||||
|
||||
val sealed = peer.nip44Encrypt("secret", signer.pubKey)
|
||||
broker.handle(applet, NappletRequest.Nip44Decrypt(peer.pubKey, sealed), allDeclared)
|
||||
broker.handle(applet, NappletRequest.Nip44Decrypt(peer.pubKey, sealed), allDeclared)
|
||||
assertEquals(2, opPrompt.calls)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aDeclinedDecryptReturnsNoPlaintext() =
|
||||
runTest {
|
||||
val signerLedger = NostrSignerPermissionLedger(InMemoryNostrSignerPermissionStore())
|
||||
signerLedger.setPolicy("napplet:${signer.pubKey}:${applet.coordinate}", AppSignerPolicy.PARANOID)
|
||||
|
||||
val broker =
|
||||
NappletBroker(
|
||||
signer = signer,
|
||||
ledger = NappletPermissionLedger(InMemoryNappletPermissionStore()),
|
||||
consentPrompt = ScriptedPrompt(GrantState.ALLOW_ALWAYS),
|
||||
signerLedger = signerLedger,
|
||||
signerConsentPrompt = ScriptedSignerPrompt(SignerOpGrant.DenyOnce),
|
||||
)
|
||||
|
||||
val peer = NostrSignerInternal(KeyPair("33".repeat(32).hexToByteArray()))
|
||||
val sealed = peer.nip44Encrypt("secret", signer.pubKey)
|
||||
|
||||
val response = broker.handle(applet, NappletRequest.Nip44Decrypt(peer.pubKey, sealed), allDeclared)
|
||||
assertIs<NappletResponse.Denied>(response)
|
||||
}
|
||||
}
|
||||
|
||||
+8
@@ -169,6 +169,10 @@ object NappletProtocolJson {
|
||||
createdAt = t["created_at"]?.jsonPrimitive?.long ?: (System.currentTimeMillis() / 1000),
|
||||
)
|
||||
}
|
||||
// NIP-07 nip44.encrypt/decrypt: crypto only, no publish. `peer` is the counterparty
|
||||
// pubkey the NIP-07 call names as its first argument.
|
||||
"nostr.nip44Encrypt" -> NappletRequest.Nip44Encrypt(peer = o.req("peer"), plaintext = o.str("plaintext") ?: "")
|
||||
"nostr.nip44Decrypt" -> NappletRequest.Nip44Decrypt(peer = o.req("peer"), ciphertext = o.str("ciphertext") ?: "")
|
||||
"storage.get" -> NappletRequest.StorageGet(o.req("key"), o.storageScope())
|
||||
"storage.set" -> NappletRequest.StorageSet(o.req("key"), o.req("value"), o.storageScope())
|
||||
"storage.remove" -> NappletRequest.StorageRemove(o.req("key"), o.storageScope())
|
||||
@@ -253,6 +257,10 @@ object NappletProtocolJson {
|
||||
put("ok", true)
|
||||
put("value", response.value)
|
||||
}
|
||||
is NappletResponse.Text -> {
|
||||
put("ok", true)
|
||||
put("value", response.value)
|
||||
}
|
||||
is NappletResponse.Strings -> {
|
||||
put("ok", true)
|
||||
// storage.keys returns `keys`; other string-list reads use `values`.
|
||||
|
||||
@@ -41,12 +41,17 @@ enum class HostProfile {
|
||||
|
||||
/**
|
||||
* What this posture is allowed to ask the broker for — THE security decision, minted into the
|
||||
* launch token in the trusted main process. A website gets the IDENTITY + RELAY pair NIP-07 needs
|
||||
* (consent-gated); a locked napplet gets only what its manifest `requires` declares.
|
||||
* launch token in the trusted main process. A website gets the IDENTITY + RELAY + SIGNER set
|
||||
* NIP-07 needs (consent-gated); a locked napplet gets only what its manifest `requires` declares.
|
||||
*
|
||||
* SIGNER (NIP-44 encrypt/decrypt) is website-only by construction: no NIP-5D domain maps to it,
|
||||
* so [resolveRequiredCapabilities] can never produce it for a napplet however its manifest is
|
||||
* written. Without it a page can sign but not seal, which locks it out of NIP-17 and every other
|
||||
* gift-wrapped protocol.
|
||||
*/
|
||||
fun declaredCapabilities(requires: List<String>): Set<NappletCapability> =
|
||||
when (this) {
|
||||
WEBSITE -> setOf(NappletCapability.IDENTITY, NappletCapability.RELAY)
|
||||
WEBSITE -> setOf(NappletCapability.IDENTITY, NappletCapability.RELAY, NappletCapability.SIGNER)
|
||||
NAPPLET -> resolveRequiredCapabilities(requires).capabilities.toSet()
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user