diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt index 5a77e2a369..de5815af52 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletBrokerService.kt @@ -277,7 +277,7 @@ class NappletBrokerService : Service() { // Bind to the account active at mint time: a browser token minted for one account must // never sign as another if the user switches while the page is still open. val mintAccount = Amethyst.instance.sessionManager.loggedInAccount() ?: return true - val token = NappletLaunchRegistry.register(identity, setOf(NappletCapability.IDENTITY, NappletCapability.RELAY), mintAccount.pubKey) + val token = NappletLaunchRegistry.register(identity, NappletCapability.WEBSITE_CAPABILITIES, mintAccount.pubKey) val response = Message.obtain(null, NappletIpc.MSG_BROWSER_TOKEN).apply { this.data = diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletCapabilityLabels.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletCapabilityLabels.kt index 15ab1abd71..5e988ce809 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletCapabilityLabels.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletCapabilityLabels.kt @@ -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 diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletConsentSummary.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletConsentSummary.kt index 583cda40b4..6b520ba30a 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletConsentSummary.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NappletConsentSummary.kt @@ -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) diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NostrSignerOpLabels.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NostrSignerOpLabels.kt index 3472835691..e53b9aceac 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NostrSignerOpLabels.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/napplet/NostrSignerOpLabels.kt @@ -27,6 +27,8 @@ import com.vitorpamplona.amethyst.commons.connectedApps.signers.NostrSignerOp import com.vitorpamplona.amethyst.commons.napplet.NappletCapability import com.vitorpamplona.amethyst.commons.napplet.NappletIdentity import com.vitorpamplona.amethyst.commons.napplet.protocol.NappletRequest +import com.vitorpamplona.amethyst.commons.napplet.protocol.counterpartyPubKey +import com.vitorpamplona.amethyst.commons.napplet.protocol.toNarrowSignerOp import com.vitorpamplona.amethyst.connectedApps.consent.SignerConnectInfo import com.vitorpamplona.amethyst.connectedApps.consent.SignerConsentInfo import com.vitorpamplona.amethyst.favorites.BrowserIconRegistry @@ -78,12 +80,22 @@ fun buildSignerConsentInfo( } else { resolveNappletMeta(identity.authorPubKey, identity.identifier, untitled) } - val summary = op.label(context) + // A decrypt grant can be scoped to one conversation: offer "always allow for Alice" next to the + // broad "always allow", instead of only the all-conversations-forever choice. Mirrors the NIP-46 + // dialog, so the same decision reads the same way whichever surface asked. + val narrowOp = request.toNarrowSignerOp() + val counterparty = request.counterpartyPubKey() + // For decrypt this names the counterparty ("read your private messages with Alice"). + val summary = (narrowOp ?: op).label(context) val preview = when (request) { 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 +117,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 = @@ -122,6 +148,16 @@ fun buildSignerConsentInfo( rawData = rawData, iconUrl = iconUrl, previewTemplate = previewTemplate, + counterpartyName = counterparty?.let { counterpartyLabel(it) }, + counterpartyPicture = counterparty?.let { LocalCache.getUserIfExists(it)?.profilePicture() }, + counterpartyPubKey = counterparty, + narrowOp = narrowOp, + // Read the pubkey off the narrow op itself: the dialog drops the button unless BOTH halves + // are present, so deriving them from one value keeps them from disagreeing. + narrowOpLabel = + (narrowOp as? NostrSignerOp.DecryptFrom)?.let { + context.getString(R.string.nip46_signer_allow_always_for, counterpartyLabel(it.counterparty)) + }, ) } diff --git a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/NappletCapabilityExt.kt b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/NappletCapabilityExt.kt index 201c818aa3..c6f554e7b3 100644 --- a/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/NappletCapabilityExt.kt +++ b/amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/napplets/NappletCapabilityExt.kt @@ -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 diff --git a/amethyst/src/main/res/values/strings.xml b/amethyst/src/main/res/values/strings.xml index da24c209c9..f4e2d47349 100644 --- a/amethyst/src/main/res/values/strings.xml +++ b/amethyst/src/main/res/values/strings.xml @@ -401,6 +401,7 @@ Identity Keyboard actions Relays + Encryption Storage Payments Network @@ -409,6 +410,7 @@ Read your public key Bind keyboard shortcuts Read, and sign & publish your events + Encrypt and decrypt private messages with your key Its own private storage Pay Lightning invoices Fetch web and Blossom resources @@ -426,6 +428,8 @@ This nApplet wants to sign and publish a kind %1$d event as you. This nApplet wants to sign and publish a kind %1$d event as you: This nApplet wants to send an encrypted event as you. + This site wants to encrypt a message to %1$s with your Nostr key. + This site wants to decrypt a message from %1$s with your Nostr key. This nApplet wants to read events from your relays. This nApplet wants to use its private storage. This nApplet wants to pay a Lightning invoice. diff --git a/amethyst/src/test/java/com/vitorpamplona/amethyst/napplet/NappletProtocolJsonTest.kt b/amethyst/src/test/java/com/vitorpamplona/amethyst/napplet/NappletProtocolJsonTest.kt index 211ea1a7be..1f2422de4b 100644 --- a/amethyst/src/test/java/com/vitorpamplona/amethyst/napplet/NappletProtocolJsonTest.kt +++ b/amethyst/src/test/java/com/vitorpamplona/amethyst/napplet/NappletProtocolJsonTest.kt @@ -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) + } } diff --git a/commons/src/commonMain/composeResources/files/napplet/shim.js b/commons/src/commonMain/composeResources/files/napplet/shim.js index f36c48280b..1d91fae295 100644 --- a/commons/src/commonMain/composeResources/files/napplet/shim.js +++ b/commons/src/commonMain/composeResources/files/napplet/shim.js @@ -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'); } + }) }); } })(); diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBroker.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBroker.kt index b7f9adf73d..31f5bea4af 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBroker.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBroker.kt @@ -34,6 +34,7 @@ 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.protocol.NappletStorageScope +import com.vitorpamplona.amethyst.commons.napplet.protocol.toNarrowSignerOp import com.vitorpamplona.amethyst.commons.napplet.protocol.toSignerOp import com.vitorpamplona.quartz.nip01Core.core.Event import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner @@ -245,6 +246,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)) @@ -449,51 +456,58 @@ class NappletBroker( ): Boolean = signerConsentLock.withLock { val sl = signerLedger ?: return@withLock true + val coordinate = signerCoordinateFor(identity) + // A decrypt request also carries a narrower op ("decrypt messages from THIS + // counterparty"). A standing narrow grant satisfies it without widening the broad one. + val narrowOp = request.toNarrowSignerOp() + // Session grants win immediately without touching storage. Scoped to this applet: a // grant made for one app never authorizes another. - if (sessionKey(signerCoordinateFor(identity), op) in sessionAllows) { - sl.updateLastUsed(signerCoordinateFor(identity)) + if (sessionKey(coordinate, op) in sessionAllows) { + sl.updateLastUsed(coordinate) return@withLock true } - when (sl.decide(signerCoordinateFor(identity), op)) { + when (sl.decide(coordinate, op)) { NostrOpDecision.ALLOW -> { - sl.updateLastUsed(signerCoordinateFor(identity)) + sl.updateLastUsed(coordinate) true } + // An explicit DENY on the broad op is final — a narrow grant never overrides it. NostrOpDecision.DENY -> false NostrOpDecision.ASK -> { - val prompt = signerConsentPrompt ?: return@withLock true - when (val grant = prompt.request(identity, op, request)) { - is SignerOpGrant.AllowAll -> { - sl.setPolicy(signerCoordinateFor(identity), AppSignerPolicy.FULL_TRUST) - sl.updateLastUsed(signerCoordinateFor(identity)) - true - } - is SignerOpGrant.AllowForOp -> { - sl.setOpDecision(signerCoordinateFor(identity), op, NostrOpDecision.ALLOW) - sl.updateLastUsed(signerCoordinateFor(identity)) - true - } - is SignerOpGrant.AllowForSession -> { - sessionAllows.add(sessionKey(signerCoordinateFor(identity), op)) - sl.updateLastUsed(signerCoordinateFor(identity)) - true - } - is SignerOpGrant.AllowUntil -> { - sl.setTimedOpDecision(signerCoordinateFor(identity), op, NostrOpDecision.ALLOW, grant.expiresAt) - sl.updateLastUsed(signerCoordinateFor(identity)) - true - } - is SignerOpGrant.DenyForOp -> { - sl.setOpDecision(signerCoordinateFor(identity), op, NostrOpDecision.DENY) - false - } - else -> grant.isAllowed + if (narrowOp != null && isNarrowAllowed(sl, coordinate, narrowOp)) { + sl.updateLastUsed(coordinate) + return@withLock true } + val prompt = signerConsentPrompt ?: return@withLock true + val grant = prompt.request(identity, op, request) + // Record the GRANT's own op, never the requested one: the dialog may hand back a + // narrower op ("only from this counterparty"), and a stored grant must never be + // wider than what the user actually tapped. + sl.record(coordinate, grant) + if (grant is SignerOpGrant.AllowForSession) { + sessionAllows.add(sessionKey(coordinate, grant.op)) + } + if (grant.isAllowed) sl.updateLastUsed(coordinate) + grant.isAllowed } } } + /** + * True when a standing or session grant exists for the narrower [narrowOp] (e.g. decrypt-from-X). + * Only an explicit per-op override counts: [NostrSignerPermissionLedger.decide] would otherwise + * fall through to the app's policy, and FULL_TRUST/REASONABLE would answer for an op nobody ever + * granted. Mirrors the NIP-46 authorizer so both surfaces honour a narrow grant identically. + */ + private suspend fun isNarrowAllowed( + sl: NostrSignerPermissionLedger, + coordinate: String, + narrowOp: NostrSignerOp, + ): Boolean = + sessionKey(coordinate, narrowOp) in sessionAllows || + sl.store.loadOpDecision(coordinate, narrowOp)?.let { sl.decide(coordinate, narrowOp) == NostrOpDecision.ALLOW } ?: false + /** * The signer-ledger coordinate for [identity] under the current account. The signer permission * store is shared with NIP-46, which already namespaces by account diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletCapability.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletCapability.kt index 33ee94cf7c..ad26a962ce 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletCapability.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletCapability.kt @@ -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, @@ -92,6 +107,18 @@ enum class NappletCapability { get() = !requiresPerUseConsent companion object { + /** + * What a page in the **website** posture (the NIP-07 `window.nostr` surface) may ask the + * broker for. There are two independent mints of this set — the nSite host derives it from + * `HostProfile.WEBSITE`, while the in-app browser mints a fresh per-origin token — so it + * lives here, once: when the two drifted, the browser silently denied every call to a + * capability the injected shim was still advertising. + * + * Widening this widens what any visited site can request, so it is a security decision, not + * a convenience list. + */ + val WEBSITE_CAPABILITIES: Set = setOf(IDENTITY, RELAY, SIGNER) + /** * Maps a bare, currently supported NAP domain to the capability the broker enforces. * Returns `null` for unknown and partial/legacy domains — callers MUST treat that as diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/protocol/NappletRequest.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/protocol/NappletRequest.kt index 25a58bca43..385a28596a 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/protocol/NappletRequest.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/protocol/NappletRequest.kt @@ -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, diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/protocol/NappletRequestSignerOp.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/protocol/NappletRequestSignerOp.kt index 905f137517..34821e119b 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/protocol/NappletRequestSignerOp.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/protocol/NappletRequestSignerOp.kt @@ -34,5 +34,38 @@ 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 + } + +/** + * The NARROWER op a request may alternatively be granted — today only + * [NostrSignerOp.DecryptFrom], i.e. "always allow, but only for this counterparty". Mirrors the + * NIP-46 authorizer's `toNarrowSignerOp`. + * + * A single broad "always allow decrypt" hands an app every private conversation the user will ever + * have; this is the granular alternative the consent dialog offers alongside it. `null` for every + * request without a counterparty — signing and encryption already name the thing being granted. + */ +fun NappletRequest.toNarrowSignerOp(): NostrSignerOp? = + when (this) { + is NappletRequest.Nip44Decrypt -> NostrSignerOp.DecryptFrom(peer) + else -> null + } + +/** + * The counterparty whose conversation a decrypt request asks to read, or `null` for every other + * request. Scoped to decryption to match the NIP-46 authorizer and what the consent dialog + * documents: it drives "X wants to read your messages with Alice", a categorically different + * decision from the encrypt/sign case, where the counterparty is already part of what the user + * is composing. + */ +fun NappletRequest.counterpartyPubKey(): String? = + when (this) { + is NappletRequest.Nip44Decrypt -> peer else -> null } diff --git a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/protocol/NappletResponse.kt b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/protocol/NappletResponse.kt index 07f0394b0c..f87cff6438 100644 --- a/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/protocol/NappletResponse.kt +++ b/commons/src/commonMain/kotlin/com/vitorpamplona/amethyst/commons/napplet/protocol/NappletResponse.kt @@ -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, diff --git a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBrokerTest.kt b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBrokerTest.kt index fc59bb7f87..a5d33e5a23 100644 --- a/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBrokerTest.kt +++ b/commons/src/commonTest/kotlin/com/vitorpamplona/amethyst/commons/napplet/NappletBrokerTest.kt @@ -745,4 +745,207 @@ class NappletBrokerTest { assertIs(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(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(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(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(response) + } + + @Test + fun theWebsiteCapabilitySetCarriesEverythingNip07Needs() { + // Two places mint this set (the nSite HostProfile and the in-app browser's per-origin + // token). They drifted once: the browser kept IDENTITY+RELAY while the shim advertised + // nip44, so every call was denied by a capability the page was told it had. + assertTrue(NappletCapability.IDENTITY in NappletCapability.WEBSITE_CAPABILITIES) + assertTrue(NappletCapability.RELAY in NappletCapability.WEBSITE_CAPABILITIES) + assertTrue(NappletCapability.SIGNER in NappletCapability.WEBSITE_CAPABILITIES) + } + + @Test + fun nip44WorksUnderTheWebsiteCapabilitySetAlone() = + runTest { + // What a browsed page actually gets — not `allDeclared`, which would hide a missing grant. + val peer = NostrSignerInternal(KeyPair("44".repeat(32).hexToByteArray())) + val broker = broker(ScriptedPrompt(GrantState.ALLOW_ALWAYS)) + + val response = + broker.handle( + applet, + NappletRequest.Nip44Encrypt(peer.pubKey, "gm"), + NappletCapability.WEBSITE_CAPABILITIES, + ) + + assertIs(response) + assertEquals("gm", peer.nip44Decrypt(response.value, signer.pubKey)) + } + + @Test + fun anAlwaysAllowForOneCounterpartyDoesNotUnlockTheRest() = + runTest { + // The dialog can hand back DecryptFrom(alice) instead of the broad Decrypt. Recording the + // REQUESTED op there would silently upgrade "only Alice" into every conversation forever. + val signerLedger = NostrSignerPermissionLedger(InMemoryNostrSignerPermissionStore()) + signerLedger.setPolicy("napplet:${signer.pubKey}:${applet.coordinate}", AppSignerPolicy.REASONABLE) + + val alice = NostrSignerInternal(KeyPair("55".repeat(32).hexToByteArray())) + val bob = NostrSignerInternal(KeyPair("66".repeat(32).hexToByteArray())) + + val opPrompt = ScriptedSignerPrompt(SignerOpGrant.AllowForOp(NostrSignerOp.DecryptFrom(alice.pubKey))) + val broker = + NappletBroker( + signer = signer, + ledger = NappletPermissionLedger(InMemoryNappletPermissionStore()), + consentPrompt = ScriptedPrompt(GrantState.ALLOW_ALWAYS), + signerLedger = signerLedger, + signerConsentPrompt = opPrompt, + ) + + val fromAlice = alice.nip44Encrypt("hi", signer.pubKey) + val fromBob = bob.nip44Encrypt("hi", signer.pubKey) + + // 1. First read from Alice prompts; the user allows, but only for Alice. + assertIs(broker.handle(applet, NappletRequest.Nip44Decrypt(alice.pubKey, fromAlice), allDeclared)) + assertEquals(1, opPrompt.calls) + + // 2. Reading Alice again rides the narrow grant — no second prompt. + assertIs(broker.handle(applet, NappletRequest.Nip44Decrypt(alice.pubKey, fromAlice), allDeclared)) + assertEquals(1, opPrompt.calls) + + // 3. Bob is a different conversation and must ask again. If the broad Decrypt had been + // recorded in step 1, this would sail through without the user ever agreeing to it. + broker.handle(applet, NappletRequest.Nip44Decrypt(bob.pubKey, fromBob), allDeclared) + assertEquals(2, opPrompt.calls) + + // The broad grant was never written. + assertNull(signerLedger.store.loadOpDecision("napplet:${signer.pubKey}:${applet.coordinate}", NostrSignerOp.Decrypt)) + } + + @Test + fun aSessionGrantIsStoredNoWiderThanTheUserGaveIt() = + runTest { + val signerLedger = NostrSignerPermissionLedger(InMemoryNostrSignerPermissionStore()) + signerLedger.setPolicy("napplet:${signer.pubKey}:${applet.coordinate}", AppSignerPolicy.PARANOID) + + val alice = NostrSignerInternal(KeyPair("77".repeat(32).hexToByteArray())) + val bob = NostrSignerInternal(KeyPair("88".repeat(32).hexToByteArray())) + + val opPrompt = ScriptedSignerPrompt(SignerOpGrant.AllowForSession(NostrSignerOp.DecryptFrom(alice.pubKey))) + val broker = + NappletBroker( + signer = signer, + ledger = NappletPermissionLedger(InMemoryNappletPermissionStore()), + consentPrompt = ScriptedPrompt(GrantState.ALLOW_ALWAYS), + signerLedger = signerLedger, + signerConsentPrompt = opPrompt, + ) + + broker.handle(applet, NappletRequest.Nip44Decrypt(alice.pubKey, alice.nip44Encrypt("a", signer.pubKey)), allDeclared) + assertEquals(1, opPrompt.calls) + + // Same counterparty rides the session grant; a different one must not. + broker.handle(applet, NappletRequest.Nip44Decrypt(alice.pubKey, alice.nip44Encrypt("a", signer.pubKey)), allDeclared) + assertEquals(1, opPrompt.calls) + broker.handle(applet, NappletRequest.Nip44Decrypt(bob.pubKey, bob.nip44Encrypt("b", signer.pubKey)), allDeclared) + assertEquals(2, opPrompt.calls) + } } diff --git a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/napplet/protocol/NappletProtocolJson.kt b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/napplet/protocol/NappletProtocolJson.kt index 7cfb2a95e0..027cae99df 100644 --- a/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/napplet/protocol/NappletProtocolJson.kt +++ b/commons/src/jvmAndroid/kotlin/com/vitorpamplona/amethyst/commons/napplet/protocol/NappletProtocolJson.kt @@ -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`. diff --git a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/HostProfile.kt b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/HostProfile.kt index eb9e32e67c..698362883e 100644 --- a/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/HostProfile.kt +++ b/nappletHost/src/main/kotlin/com/vitorpamplona/amethyst/napplethost/HostProfile.kt @@ -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): Set = when (this) { - WEBSITE -> setOf(NappletCapability.IDENTITY, NappletCapability.RELAY) + WEBSITE -> NappletCapability.WEBSITE_CAPABILITIES NAPPLET -> resolveRequiredCapabilities(requires).capabilities.toSet() } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip52Calendar/appt/tags/DayIndexTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip52Calendar/appt/tags/DayIndexTag.kt new file mode 100644 index 0000000000..a1181ee75b --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip52Calendar/appt/tags/DayIndexTag.kt @@ -0,0 +1,89 @@ +/* + * 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.nip52Calendar.appt.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +/** + * NIP-52's uppercase `D` day index on a kind:31923 time-based calendar event: the + * day-granularity unix timestamp `floor(unix_seconds / 86400)`, one tag per UTC calendar + * day the event's `start`..`end` range spans. + * + * It is what makes an event discoverable by date — a client browsing "what's on the 14th" + * queries `{"kinds":[31923],"#D":[""]}` instead of pulling every calendar event in + * existence and filtering client-side. Uppercase single-letter tags are indexed by relays + * exactly like lowercase ones; the case only distinguishes it from the `d` identifier. + */ +class DayIndexTag { + companion object { + const val TAG_NAME = "D" + + const val SECONDS_IN_DAY = 86400L + + /** + * The most `D` tags one event may carry. NIP-52 puts no ceiling on the range, but a + * multi-year event would otherwise emit thousands of tags and blow past relay event-size + * limits; capping keeps a mistyped end date from producing an unpublishable event. Beyond + * the cap the range is truncated (the start day is always emitted), never dropped. + */ + const val MAX_DAYS = 366 + + /** The day index containing [timestamp] (unix seconds). */ + fun dayIndex(timestamp: Long): Long = timestamp.floorDiv(SECONDS_IN_DAY) + + fun parse(tag: Array): Long? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + return tag[1].toLongOrNull() + } + + fun assemble(dayIndex: Long) = arrayOf(TAG_NAME, dayIndex.toString()) + + /** + * Every day index a `start`..`end` range touches, in order. + * + * `end` is **exclusive** per NIP-52, so an event that finishes exactly at midnight does not + * claim the following day; an event with no end (or an end at/before start) is instantaneous + * and occupies only its start day. Result is capped at [MAX_DAYS]. + */ + fun dayIndexes( + start: Long, + end: Long? = null, + ): List { + val first = dayIndex(start) + if (end == null || end <= start) return listOf(first) + + // An exclusive end lands on the first instant NOT in the range, so the last day is the + // one holding end-1. Without the -1, an event ending at 00:00:00 would tag the next day. + val last = dayIndex(end - 1) + if (last <= first) return listOf(first) + + return (first..minOf(last, first + MAX_DAYS - 1)).toList() + } + + /** [dayIndexes] already assembled into tags. */ + fun assembleAll( + start: Long, + end: Long? = null, + ) = dayIndexes(start, end).map { assemble(it) } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip52Calendar/appt/time/CalendarTimeSlotEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip52Calendar/appt/time/CalendarTimeSlotEvent.kt index af20d0abfa..a5888a45c2 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip52Calendar/appt/time/CalendarTimeSlotEvent.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip52Calendar/appt/time/CalendarTimeSlotEvent.kt @@ -37,6 +37,7 @@ import com.vitorpamplona.quartz.nip23LongContent.tags.SummaryTag import com.vitorpamplona.quartz.nip23LongContent.tags.TitleTag import com.vitorpamplona.quartz.nip50Search.IndexableFieldVisitor import com.vitorpamplona.quartz.nip50Search.SearchableEvent +import com.vitorpamplona.quartz.nip52Calendar.appt.tags.DayIndexTag import com.vitorpamplona.quartz.nip52Calendar.appt.tags.LocationTag import com.vitorpamplona.quartz.utils.TimeUtils import kotlin.uuid.ExperimentalUuidApi @@ -76,6 +77,9 @@ class CalendarTimeSlotEvent( fun endTzId() = tags.firstTagValue("end_tzid") + /** The NIP-52 `D` day indexes this event claims. Empty for an event published without them. */ + fun dayIndexes() = tags.mapNotNull(DayIndexTag.Companion::parse) + fun summary() = tags.firstNotNullOfOrNull(SummaryTag.Companion::parse) fun image() = tags.firstNotNullOfOrNull(ImageTag.Companion::parse) @@ -109,6 +113,9 @@ class CalendarTimeSlotEvent( end?.let { endTimestamp(it) } startTzId?.let { startTzId(it) } endTzId?.let { endTzId(it) } + // NIP-52 requires the `D` day index on 31923; without it the event is invisible to + // date-indexed calendar clients, which query by day rather than scanning every event. + dayIndexes(start, end) initializer() } } diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip52Calendar/appt/time/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip52Calendar/appt/time/TagArrayBuilderExt.kt index 092eab71e0..9c8e4e06e1 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip52Calendar/appt/time/TagArrayBuilderExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip52Calendar/appt/time/TagArrayBuilderExt.kt @@ -26,6 +26,7 @@ import com.vitorpamplona.quartz.nip01Core.tags.people.PTag import com.vitorpamplona.quartz.nip23LongContent.tags.ImageTag import com.vitorpamplona.quartz.nip23LongContent.tags.SummaryTag import com.vitorpamplona.quartz.nip23LongContent.tags.TitleTag +import com.vitorpamplona.quartz.nip52Calendar.appt.tags.DayIndexTag import com.vitorpamplona.quartz.nip52Calendar.appt.tags.LocationTag fun TagArrayBuilder.titleTime(title: String) = addUnique(TitleTag.assemble(title)) @@ -34,6 +35,16 @@ fun TagArrayBuilder.startTimestamp(timestamp: Long) = add fun TagArrayBuilder.endTimestamp(timestamp: Long) = addUnique(arrayOf("end", timestamp.toString())) +/** + * Replaces the `D` day-index set with the one [start]..[end] spans (NIP-52). Removing first is what + * makes an edit correct: shortening an event that used to run three days must drop the two days it + * no longer covers, and appending alone would leave them claiming it forever. + */ +fun TagArrayBuilder.dayIndexes( + start: Long, + end: Long? = null, +) = remove(DayIndexTag.TAG_NAME).addAll(DayIndexTag.assembleAll(start, end)) + fun TagArrayBuilder.startTzId(tzId: String) = addUnique(arrayOf("start_tzid", tzId)) fun TagArrayBuilder.endTzId(tzId: String) = addUnique(arrayOf("end_tzid", tzId)) diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip52Calendar/DayIndexTagTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip52Calendar/DayIndexTagTest.kt new file mode 100644 index 0000000000..3805ebd246 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip52Calendar/DayIndexTagTest.kt @@ -0,0 +1,133 @@ +/* + * 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.nip52Calendar + +import com.vitorpamplona.quartz.nip01Core.core.firstTagValue +import com.vitorpamplona.quartz.nip52Calendar.appt.tags.DayIndexTag +import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent +import com.vitorpamplona.quartz.nip52Calendar.appt.time.dayIndexes +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * NIP-52's uppercase `D` day index on kind:31923 — the tag that makes a calendar event + * discoverable by date. Covers the exclusive-`end` boundary and the case split from the + * lowercase `d` identifier, which shares the same tag map. + */ +class DayIndexTagTest { + // 2026-02-14T00:00:00Z — day index 20498. + private val feb14 = 1771027200L + private val dayIndexFeb14 = 20498L + + @Test + fun dayIndexMatchesTheSpecFormula() { + // The worked example from NIP-52 itself. + assertEquals(82549L, DayIndexTag.dayIndex(82549L * 86400L)) + assertEquals(dayIndexFeb14, DayIndexTag.dayIndex(feb14)) + // Any instant inside the day maps to the same index. + assertEquals(dayIndexFeb14, DayIndexTag.dayIndex(feb14 + 86399)) + } + + @Test + fun noEndMeansASingleDay() { + assertEquals(listOf(dayIndexFeb14), DayIndexTag.dayIndexes(feb14 + 3600)) + } + + @Test + fun endBeforeOrAtStartStillEmitsTheStartDay() { + assertEquals(listOf(dayIndexFeb14), DayIndexTag.dayIndexes(feb14, feb14)) + assertEquals(listOf(dayIndexFeb14), DayIndexTag.dayIndexes(feb14 + 100, feb14)) + } + + @Test + fun aMultiDayRangeCoversEveryDayItTouches() { + // 14th 10:00 → 16th 15:00 spans three days. + val days = DayIndexTag.dayIndexes(feb14 + 10 * 3600, feb14 + 2 * 86400 + 15 * 3600) + assertEquals(listOf(dayIndexFeb14, dayIndexFeb14 + 1, dayIndexFeb14 + 2), days) + } + + @Test + fun endIsExclusiveSoMidnightDoesNotClaimTheNextDay() { + // Ends exactly at the 15th 00:00:00 — that instant belongs to the 15th, but the event + // does not, so only the 14th is tagged. + assertEquals(listOf(dayIndexFeb14), DayIndexTag.dayIndexes(feb14, feb14 + 86400)) + // One second later it genuinely runs into the 15th. + assertEquals( + listOf(dayIndexFeb14, dayIndexFeb14 + 1), + DayIndexTag.dayIndexes(feb14, feb14 + 86401), + ) + } + + @Test + fun anAbsurdRangeIsCappedRatherThanEmittingThousandsOfTags() { + val days = DayIndexTag.dayIndexes(feb14, feb14 + 4000L * 86400L) + assertEquals(DayIndexTag.MAX_DAYS, days.size) + assertEquals(dayIndexFeb14, days.first()) + } + + @Test + fun buildEmitsTheDayTagsAndKeepsTheIdentifierSeparate() { + val template = + CalendarTimeSlotEvent.build( + title = "Nostrautica", + start = feb14 + 10 * 3600, + end = feb14 + 86400 + 2 * 3600, + dTag = "my-event", + ) + + val dayTags = template.tags.filter { it[0] == DayIndexTag.TAG_NAME }.map { it[1] } + assertEquals(listOf(dayIndexFeb14.toString(), (dayIndexFeb14 + 1).toString()), dayTags) + + // The uppercase D must not disturb the lowercase d identifier — they collide in the + // builder's tag map if either side is ever case-folded. + assertEquals("my-event", template.tags.firstTagValue("d")) + } + + @Test + fun parseReadsBackWhatBuildWrote() { + val template = CalendarTimeSlotEvent.build(title = "T", start = feb14, end = feb14 + 86401) + val event = CalendarTimeSlotEvent("id", "pub", 0L, template.tags, template.content, "sig") + + assertEquals(listOf(dayIndexFeb14, dayIndexFeb14 + 1), event.dayIndexes()) + } + + @Test + fun rebuildingAfterAnEditDropsDaysTheEventNoLongerCovers() { + // A three-day event shortened to one: the two dropped days must not linger, or they keep + // advertising the event on dates it no longer runs. + val shortened = + CalendarTimeSlotEvent.build(title = "T", start = feb14, end = feb14 + 3 * 86400, dTag = "same") { + dayIndexes(feb14, feb14 + 3600) + } + + val dayTags = shortened.tags.filter { it[0] == DayIndexTag.TAG_NAME }.map { it[1] } + assertEquals(listOf(dayIndexFeb14.toString()), dayTags) + } + + @Test + fun parseRejectsNonDayTagsAndGarbage() { + assertEquals(null, DayIndexTag.parse(arrayOf("d", "1234"))) + assertEquals(null, DayIndexTag.parse(arrayOf("D"))) + assertEquals(null, DayIndexTag.parse(arrayOf("D", "not-a-number"))) + assertTrue(DayIndexTag.parse(arrayOf("D", "82549")) == 82549L) + } +}