diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt index 3ebfac44f2..5ddad30bd8 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/Main.kt @@ -162,6 +162,14 @@ private suspend fun dispatch(argv: Array): Int { Commands.notes(dataDir, tail) } + "nsite" -> { + Commands.nsite(dataDir, tail) + } + + "napplet" -> { + Commands.napplet(dataDir, tail) + } + "store" -> { Commands.store(dataDir, tail) } @@ -358,6 +366,21 @@ private fun printUsage() { | [--since TS] [--until TS] | [--timeout SECS] | + |Static websites (NIP-5A kind:15128/35128): + | nsite fetch AUTHOR [--d ID] [--path P] resolve one path over Nostr + Blossom and + | [--server URL[,URL]] [--relay URL[,URL]] VERIFY it against the manifest's sha256 pin + | [--out FILE] [--timeout SECS] (AUTHOR: npub|nprofile|hex|name@domain; + | [--max-inline-bytes N] --d selects a kind:35128 named site, else the + | kind:15128 root site; --path defaults to /) + | + |Napplets (NIP-5D kind:5129/15129/35129): + | napplet fetch AUTHOR [--d ID] [--path P] like `nsite fetch`, plus NIP-5D verification: + | [--server URL[,URL]] [--relay URL[,URL]] recompute + check the `x` aggregate hash and + | [--out FILE] [--timeout SECS] report the napplet's `requires` capabilities + | [--max-inline-bytes N] (--d selects a kind:35129 named napplet, else + | napplet fetch --snapshot EVENT-ID the kind:15129 root; --snapshot pins a kind:5129 + | [--path P] … immutable snapshot by event id) + | |Contacts (NIP-02 kind:3): | follow USER [--timeout SECS] add USER to your contact list | unfollow USER [--timeout SECS] remove USER from your contact list diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/Commands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/Commands.kt index 527101e9ef..0859b4dc8f 100644 --- a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/Commands.kt +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/Commands.kt @@ -91,6 +91,16 @@ object Commands { tail: Array, ): Int = NotesCommands.dispatch(dataDir, tail) + suspend fun nsite( + dataDir: DataDir, + tail: Array, + ): Int = NsiteCommands.dispatch(dataDir, tail) + + suspend fun napplet( + dataDir: DataDir, + tail: Array, + ): Int = NappletCommands.dispatch(dataDir, tail) + suspend fun store( dataDir: DataDir, tail: Array, diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/NappletCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/NappletCommands.kt new file mode 100644 index 0000000000..d80c6c32c3 --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/NappletCommands.kt @@ -0,0 +1,216 @@ +/* + * 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.cli.commands + +import com.vitorpamplona.amethyst.cli.Args +import com.vitorpamplona.amethyst.cli.Context +import com.vitorpamplona.amethyst.cli.DataDir +import com.vitorpamplona.amethyst.cli.Output +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip5dNapplets.NamedNappletEvent +import com.vitorpamplona.quartz.nip5dNapplets.NappletManifest +import com.vitorpamplona.quartz.nip5dNapplets.NappletSnapshotEvent +import com.vitorpamplona.quartz.nip5dNapplets.RootNappletEvent + +/** + * `amy napplet fetch` — resolve a single path of a NIP-5D napplet over + * Nostr + Blossom with the full runtime verification contract: + * + * 1. fetch + signature-verify the manifest (kind 15129 root, 35129 named with + * `--d`, or 5129 snapshot with `--snapshot `), + * 2. recompute the NIP-5A aggregate hash and check it against the manifest's `x` + * tag — a mismatch is refused up front (`aggregate_mismatch`), + * 3. download the path's blob from Blossom and accept only a copy whose sha256 + * matches the per-path pin (an untrusted server cannot substitute a blob). + * + * It also reports the napplet's `requires` capabilities, so a shell can see which + * NAP domains it would have to broker. Steps 2–3 live in quartz + * (`NappletManifest.verifyAggregate`, `StaticSiteResolver`); this is thin glue, + * shared with `amy nsite` via [StaticSiteFetch]. + */ +object NappletCommands { + suspend fun dispatch( + dataDir: DataDir, + tail: Array, + ): Int { + if (tail.isEmpty()) return Output.error("bad_args", "napplet …") + val rest = tail.drop(1).toTypedArray() + return when (tail[0]) { + "fetch" -> fetch(dataDir, rest) + else -> Output.error("bad_args", "napplet ${tail[0]}") + } + } + + private suspend fun fetch( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val snapshotId = args.flag("snapshot") + val author = args.positionalOrNull(0) + if (snapshotId == null && author == null) { + return Output.error("bad_args", "napplet fetch [--d ID] | --snapshot [--path P]") + } + val identifier = args.flag("d") + val requestPath = args.flag("path", "/")!! + val outFile = args.flag("out") + val timeoutSecs = args.longFlag("timeout", 8L) + val maxInlineBytes = args.longFlag("max-inline-bytes", 65_536L) + val extraServers = StaticSiteFetch.commaList(args.flag("server")) + val extraRelays = StaticSiteFetch.commaList(args.flag("relay")) + + val ctx = Context.open(dataDir) + try { + ctx.prepare() + val relays = + extraRelays + .mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) } + .toSet() + .ifEmpty { ctx.bootstrapRelays() } + + val event = + if (snapshotId != null) { + fetchSnapshot(ctx, snapshotId, relays, timeoutSecs * 1000) + } else { + val authorHex = ctx.requireUserHex(author!!) + fetchByAuthor(ctx, authorHex, identifier, relays, timeoutSecs * 1000) + } + + if (event == null) { + return Output.error( + "not_found", + "no napplet manifest found", + mapOf( + "kind" to expectedKind(snapshotId, identifier), + "snapshot" to snapshotId, + "d" to identifier, + ), + ) + } + + val manifest = event as NappletManifest + + // NIP-5D step 3: the signed aggregate MUST match the path tags. Refuse a + // tampered/inconsistent manifest before fetching any third-party blob. + if (!manifest.verifyAggregate()) { + return Output.error( + "aggregate_mismatch", + "manifest x aggregate does not match its path tags", + mapOf( + "kind" to event.kind, + "manifest_event_id" to event.id, + "declared" to manifest.declaredAggregateHash(), + "computed" to manifest.computeAggregateHash(), + ), + ) + } + + val declaredAggregate = manifest.declaredAggregateHash() + return StaticSiteFetch.resolveAndEmit( + requestPath = requestPath, + paths = manifest.paths(), + servers = (manifest.servers() + extraServers).distinct(), + manifestFields = + mapOf( + "kind" to event.kind, + "manifest_event_id" to event.id, + "d" to identifier, + "requires" to manifest.requires(), + "aggregate_sha256" to declaredAggregate, + "aggregate_verified" to (declaredAggregate != null), + ), + outFile = outFile, + maxInlineBytes = maxInlineBytes, + ) + } finally { + ctx.close() + } + } + + private fun expectedKind( + snapshotId: String?, + identifier: String?, + ): Int = + when { + snapshotId != null -> NappletSnapshotEvent.KIND + identifier != null -> NamedNappletEvent.KIND + else -> RootNappletEvent.KIND + } + + /** + * Fetch the latest napplet manifest for [authorHex]: a [NamedNappletEvent] (kind + * 35129) addressed by [identifier] when `--d` is given, otherwise the author's + * root [RootNappletEvent] (kind 15129). + */ + private suspend fun fetchByAuthor( + ctx: Context, + authorHex: String, + identifier: String?, + relays: Set, + timeoutMs: Long, + ): Event? { + if (relays.isEmpty()) return null + val filter = + if (identifier != null) { + Filter( + kinds = listOf(NamedNappletEvent.KIND), + authors = listOf(authorHex), + tags = mapOf("d" to listOf(identifier)), + limit = 1, + ) + } else { + Filter(kinds = listOf(RootNappletEvent.KIND), authors = listOf(authorHex), limit = 1) + } + return ctx + .drain(relays.associateWith { listOf(filter) }, timeoutMs) + .map { (_, ev) -> ev } + .filter { it.pubKey == authorHex && matchesIdentifier(it, identifier) } + .maxByOrNull { it.createdAt } + } + + /** Fetch a specific immutable snapshot ([NappletSnapshotEvent] / kind 5129) by event id. */ + private suspend fun fetchSnapshot( + ctx: Context, + eventId: String, + relays: Set, + timeoutMs: Long, + ): Event? { + if (relays.isEmpty()) return null + val filter = Filter(ids = listOf(eventId), kinds = listOf(NappletSnapshotEvent.KIND), limit = 1) + return ctx + .drain(relays.associateWith { listOf(filter) }, timeoutMs) + .map { (_, ev) -> ev } + .firstOrNull { it is NappletSnapshotEvent && it.id == eventId } + } + + private fun matchesIdentifier( + event: Event, + identifier: String?, + ): Boolean = + when (event) { + is NamedNappletEvent -> event.identifier() == identifier + is RootNappletEvent -> identifier == null + else -> false + } +} diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/NsiteCommands.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/NsiteCommands.kt new file mode 100644 index 0000000000..81cdeb560d --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/NsiteCommands.kt @@ -0,0 +1,184 @@ +/* + * 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.cli.commands + +import com.vitorpamplona.amethyst.cli.Args +import com.vitorpamplona.amethyst.cli.Context +import com.vitorpamplona.amethyst.cli.DataDir +import com.vitorpamplona.amethyst.cli.Output +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.RelayUrlNormalizer +import com.vitorpamplona.quartz.nip5aStaticWebsites.NamedSiteEvent +import com.vitorpamplona.quartz.nip5aStaticWebsites.RootSiteEvent +import com.vitorpamplona.quartz.nip5aStaticWebsites.tags.PathTag + +/** + * `amy nsite fetch` — resolve a single path of a NIP-5A static website over + * Nostr + Blossom, **verifying** the content against the signed manifest. + * + * The manifest (`RootSiteEvent` kind 15128, or `NamedSiteEvent` kind 35128 with + * `--d`) pins each path to a sha256. This command fetches the manifest from + * relays, then downloads the requested path's blob from the manifest's Blossom + * servers and accepts the first copy whose recomputed sha256 matches the pin — an + * untrusted server that substitutes or corrupts the blob is skipped. + * + * For NIP-5D napplets (kinds 5129/15129/35129, with aggregate-hash + capability + * verification) use `amy napplet fetch`. + * + * Thin-assembly only: resolution + verification live in quartz (`StaticSiteResolver`), + * the byte fetch in commons (`BlossomClient.download`), shared via [StaticSiteFetch]. + */ +object NsiteCommands { + suspend fun dispatch( + dataDir: DataDir, + tail: Array, + ): Int { + if (tail.isEmpty()) return Output.error("bad_args", "nsite …") + val rest = tail.drop(1).toTypedArray() + return when (tail[0]) { + "fetch" -> fetch(dataDir, rest) + else -> Output.error("bad_args", "nsite ${tail[0]}") + } + } + + private suspend fun fetch( + dataDir: DataDir, + rest: Array, + ): Int { + val args = Args(rest) + val author = args.positionalOrNull(0) ?: return Output.error("bad_args", "nsite fetch [--d ID] [--path P]") + val identifier = args.flag("d") + val requestPath = args.flag("path", "/")!! + val outFile = args.flag("out") + val timeoutSecs = args.longFlag("timeout", 8L) + val maxInlineBytes = args.longFlag("max-inline-bytes", 65_536L) + val extraServers = StaticSiteFetch.commaList(args.flag("server")) + val extraRelays = StaticSiteFetch.commaList(args.flag("relay")) + + val ctx = Context.open(dataDir) + try { + ctx.prepare() + val authorHex = ctx.requireUserHex(author) + + val relays = + extraRelays + .mapNotNull { RelayUrlNormalizer.normalizeOrNull(it) } + .toSet() + .ifEmpty { ctx.bootstrapRelays() } + + val manifest = fetchManifest(ctx, authorHex, identifier, relays, timeoutSecs * 1000) + if (manifest == null) { + return Output.error( + "not_found", + "no static-website manifest for this author", + mapOf( + "pubkey" to authorHex, + "kind" to if (identifier != null) NamedSiteEvent.KIND else RootSiteEvent.KIND, + "d" to identifier, + ), + ) + } + + // Manifest servers first (author intent), then any --server fallbacks; keep order, dedupe. + return StaticSiteFetch.resolveAndEmit( + requestPath = requestPath, + paths = manifest.paths, + servers = (manifest.servers + extraServers).distinct(), + manifestFields = + mapOf( + "kind" to manifest.kind, + "manifest_event_id" to manifest.id, + "d" to identifier, + ), + outFile = outFile, + maxInlineBytes = maxInlineBytes, + ) + } finally { + ctx.close() + } + } + + /** + * Fetch the latest matching manifest from [relays]: a [NamedSiteEvent] (kind + * 35128) addressed by [identifier] when `--d` is given, otherwise the author's + * root [RootSiteEvent] (kind 15128). + */ + private suspend fun fetchManifest( + ctx: Context, + authorHex: String, + identifier: String?, + relays: Set, + timeoutMs: Long, + ): SiteManifest? { + if (relays.isEmpty()) return null + val filter = + if (identifier != null) { + Filter( + kinds = listOf(NamedSiteEvent.KIND), + authors = listOf(authorHex), + tags = mapOf("d" to listOf(identifier)), + limit = 1, + ) + } else { + Filter(kinds = listOf(RootSiteEvent.KIND), authors = listOf(authorHex), limit = 1) + } + val received = ctx.drain(relays.associateWith { listOf(filter) }, timeoutMs) + return received + .map { (_, ev) -> ev } + .filter { it.pubKey == authorHex } + .mapNotNull { toManifest(it, identifier) } + .maxByOrNull { it.createdAt } + } + + private fun toManifest( + event: Event, + identifier: String?, + ): SiteManifest? = + when (event) { + is NamedSiteEvent -> + if (event.identifier() == identifier) { + SiteManifest(event.kind, event.id, event.createdAt, event.paths(), event.servers()) + } else { + null + } + is RootSiteEvent -> + if (identifier == null) { + SiteManifest(event.kind, event.id, event.createdAt, event.paths(), event.servers()) + } else { + null + } + else -> null + } + + /** + * Flattened view of either manifest event type (`RootSiteEvent` / + * `NamedSiteEvent`) so the rest of the command doesn't branch on Root vs Named. + */ + private class SiteManifest( + val kind: Int, + val id: String, + val createdAt: Long, + val paths: List, + val servers: List, + ) +} diff --git a/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/StaticSiteFetch.kt b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/StaticSiteFetch.kt new file mode 100644 index 0000000000..c47a0d53cd --- /dev/null +++ b/cli/src/main/kotlin/com/vitorpamplona/amethyst/cli/commands/StaticSiteFetch.kt @@ -0,0 +1,131 @@ +/* + * 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.cli.commands + +import com.vitorpamplona.amethyst.cli.Output +import com.vitorpamplona.amethyst.commons.service.upload.BlossomClient +import com.vitorpamplona.quartz.nip5aStaticWebsites.resolver.StaticSiteResolution +import com.vitorpamplona.quartz.nip5aStaticWebsites.resolver.StaticSiteResolver +import com.vitorpamplona.quartz.nip5aStaticWebsites.tags.PathTag +import java.io.File + +/** + * Shared Blossom resolve-and-emit used by both `amy nsite` (NIP-5A) and + * `amy napplet` (NIP-5D): given a manifest's `path` + `server` tags, download the + * requested path's blob, verify its sha256 against the pin, and print the result. + * All resolution + verification lives in quartz (`StaticSiteResolver`); this is + * thin glue around it. + */ +internal object StaticSiteFetch { + fun commaList(value: String?): List = + value + ?.split(',') + ?.map { it.trim() } + ?.filter { it.isNotEmpty() } + ?: emptyList() + + /** + * Resolves [requestPath] against the manifest's [paths]/[servers], emitting the + * verified bytes (inlined for small text, or written to [outFile]) merged with + * the caller's [manifestFields] (kind, ids, requires, aggregate, …), or a + * structured `no_servers` / `path_not_found` / `unresolvable` error. + */ + suspend fun resolveAndEmit( + requestPath: String, + paths: List, + servers: List, + manifestFields: Map, + outFile: String?, + maxInlineBytes: Long, + ): Int { + if (servers.isEmpty()) { + return Output.error("no_servers", "manifest lists no Blossom servers; pass --server URL") + } + + val blossom = BlossomClient() + val resolution = + StaticSiteResolver.resolve( + requestPath = requestPath, + paths = paths, + servers = servers, + fetch = { url -> blossom.download(url) }, + ) + + return when (resolution) { + is StaticSiteResolution.PathNotInManifest -> + Output.error( + "path_not_found", + "manifest declares no such path", + mapOf("path" to requestPath, "available_paths" to paths.map { it.path }), + ) + + is StaticSiteResolution.Unresolvable -> + Output.error( + "unresolvable", + "no server returned a blob matching the manifest hash", + mapOf("path" to requestPath, "sha256" to resolution.hash, "servers" to servers), + ) + + is StaticSiteResolution.Resolved -> { + emitResolved(resolution, requestPath, manifestFields, outFile, maxInlineBytes) + 0 + } + } + } + + private fun emitResolved( + resolved: StaticSiteResolution.Resolved, + requestPath: String, + manifestFields: Map, + outFile: String?, + maxInlineBytes: Long, + ) { + val base = + linkedMapOf( + "found" to true, + "verified" to true, + "request_path" to requestPath, + "manifest_path" to resolved.path, + "sha256" to resolved.hash, + "content_type" to resolved.contentType, + "size" to resolved.bytes.size, + "server" to resolved.server, + ) + base.putAll(manifestFields) + + if (outFile != null) { + File(outFile).writeBytes(resolved.bytes) + base["out"] = outFile + } else if (isTextual(resolved.contentType) && resolved.bytes.size <= maxInlineBytes) { + base["content"] = resolved.bytes.decodeToString() + } else { + base["note"] = "binary or large blob not inlined; pass --out FILE to save it" + } + + Output.emit(base) + } + + private fun isTextual(contentType: String): Boolean = + contentType.startsWith("text/") || + contentType.startsWith("application/json") || + contentType.startsWith("application/xml") || + contentType.startsWith("image/svg") +} diff --git a/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/service/upload/BlossomClient.kt b/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/service/upload/BlossomClient.kt index 295165d7e5..32078036c5 100644 --- a/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/service/upload/BlossomClient.kt +++ b/commons/src/jvmMain/kotlin/com/vitorpamplona/amethyst/commons/service/upload/BlossomClient.kt @@ -81,6 +81,30 @@ open class BlossomClient( } } + /** + * Download a blob from an absolute URL — typically a Blossom GET endpoint + * `/`. Returns the raw bytes, or `null` when the server + * responds with a non-2xx status. Connection-level failures (DNS, refused, + * timeout) propagate as [java.io.IOException] so the caller can try the next + * server. + * + * This does NOT verify the blob's hash — content-addressed verification is + * the caller's responsibility (see quartz `StaticSiteResolver.verify`), since + * a Blossom server is untrusted and may return a substituted blob. + */ + open suspend fun download(url: String): ByteArray? = + withContext(Dispatchers.IO) { + val request = + Request + .Builder() + .url(url) + .get() + .build() + okHttpClient.newCall(request).execute().use { response -> + if (response.isSuccessful) response.body.bytes() else null + } + } + /** * Upload raw bytes (e.g. encrypted blobs) to a Blossom server. */ diff --git a/quartz/plans/2026-06-19-napplet-nip5a-resolver.md b/quartz/plans/2026-06-19-napplet-nip5a-resolver.md new file mode 100644 index 0000000000..75b3ef1afb --- /dev/null +++ b/quartz/plans/2026-06-19-napplet-nip5a-resolver.md @@ -0,0 +1,100 @@ +# NIP-5A static-site resolver + napplet (NIP-5D) alignment + +Date: 2026-06-19 +Status: resolver landed (quartz commonMain); alignment questions open + +## Context + +[napplet.run](https://napplet.run) proposes "napplets" — small, sandboxed web +apps distributed over Nostr + Blossom, where a **shell** brokers dangerous +capabilities (signing, relay access, storage) and the applet runs as untrusted +code behind a trust boundary. Relevant upstream material: + +- Web packages: +- NAPs track (capability + wire-format specs): +- Runtime packages: +- Playground: + +The important overlap with Amethyst: napplet **distribution** reuses the nsite +static-website event shape that Quartz already implements — +`com.vitorpamplona.quartz.nip5aStaticWebsites` (`RootSiteEvent` kind 15128, +`NamedSiteEvent` kind 35128). Each manifest pins request paths to content-addressed +Blossom blobs via `path` tags (`[path, ]`) plus `server` tags. **NIP-5D** +is the web projection on top of NIP-5A (iframe hosting, `postMessage` transport, +`window.napplet.*` capability surface). + +So Amethyst already owns the bottom half of the stack. The cheap, high-leverage +move (vs. building a full shell) is to be the reference **resolver** for NIP-5A and +help keep the event shape from forking across napplets / nsites / NMP / Tiles. + +## What landed + +A platform-agnostic resolver in `quartz/commonMain`, under +`nip5aStaticWebsites/resolver/`: + +- **`StaticSitePathLookup.kt`** — `normalizeStaticPath()` (strips query/fragment + + leading slash, expands root/dir requests to `index.html`), `List.resolvePath()` + (leading-slash-insensitive match), `guessStaticContentType()` (web asset MIME map; + Blossom serves blobs untyped, so the host must label them). +- **`StaticSiteResolver.kt`** — `verify(blob, hash)`, `candidateUrls(servers, hash)`, + and `suspend resolve(requestPath, paths, servers, fetch): StaticSiteResolution`. + HTTP is injected via a `BlobFetcher` typealias so Quartz keeps no HTTP dependency; + `commons`/`amethyst` supply an OkHttp-backed fetcher. + +**Trust model (the point):** the signed manifest is the authority; the Blossom +server is untrusted. `resolve` downloads the content-addressed blob from each listed +server in order and accepts the **first whose recomputed sha256 matches the pin**. A +server that substitutes/corrupts/truncates a blob fails verification and is silently +skipped — it can withhold content but can never forge it. Tests cover root/dir/query +normalization, slash-insensitive lookup, MIME guessing, and the security cases +(tampered server skipped → falls through to honest server; all-tampered → +`Unresolvable`; undeclared path → `PathNotInManifest` without fetching). + +Deliberately **out of scope** in the protocol layer: SPA "serve index.html for any +unknown route" fallback (weakens the path→hash guarantee — a shell policy decision), +and the author's kind:10063 Blossom-list as a server fallback (caller appends it +before calling `resolve`; `BlossomServerResolver`/BUD-10 already exists in `amethyst`). + +## Open alignment questions for the napplet author + +These are worth resolving before three projects (napplets, nsites, NMP, Tiles) fork +the event shape. Raise upstream on napplet/naps: + +1. **Manifest kind: 35128 vs 35129.** napplet/naps describes NIP-5A distribution as + **kind 35128** (the exact `NamedSiteEvent` Amethyst already renders), while + napplet/web describes the NIP-5D web manifest as **kind 35129**. Confirm the + intent: is a napplet a *plain* NIP-5A nsite (35128) that a NIP-5D-aware shell + simply *recognizes*, or a *distinct* 35129 event? If 35129 is distinct, what does + it add over 35128 — and should it embed/reference a 35128 rather than duplicate the + `path`/`server` tag set? Avoid silently colliding with the nsite 35128 Amethyst + already publishes and resolves. + +2. **Capability declaration vs NIP-89.** napplet manifests carry a `requires` / + capability declaration (which NAP domains the applet needs: identity, relay, + value, …). Amethyst already models "an app handles these event kinds" via NIP-89 + `AppDefinitionEvent` (kind 31990, `k`-tags). Should napplet capability `requires` + reuse NIP-89 semantics (or a documented superset) instead of a parallel tag, so a + single client can reason about both? + +3. **Aggregate build hash.** Both repos mention an aggregate hash over the per-file + set. Is that pinned as a dedicated tag on the manifest (canonical + serialization/ordering defined), or only implied by the set of `path` hashes? The + resolver verifies per-file hashes today; if there is a canonical aggregate, Quartz + should expose `aggregateHash()` and verify it too. + +4. **`server` semantics.** Are `server` tags an *ordered preference* list (our + resolver assumes order = priority) or an unordered set the host load-balances? And + is the author's kind:10063 Blossom list an implicit fallback, or must servers be + exhaustively listed on the manifest? + +## Possible follow-ups (not in this change) + +- Wire an OkHttp `BlobFetcher` in `commons` and point `StaticWebsite.kt` at the + resolver to render verified content (today it only shows manifest metadata + opens + links in an external browser). +- The full shell: Android WebView host (`allow-scripts`, no `allow-same-origin`) + + `postMessage`↔`NostrSigner`/Blossom/relay bridge + a per-applet permission ledger + (reuse the signer-prompt design in + `amethyst/plans/2026-05-25-appfunctions-signer-prompts.md`). The brokers it needs + (three `NostrSigner` types, Blossom upload/download, relay client, NIP-57 zaps) all + already ship — the WebView + consent UI are the only genuinely new pieces. diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip5aStaticWebsites/SiteAggregateHash.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip5aStaticWebsites/SiteAggregateHash.kt new file mode 100644 index 0000000000..4a3ffd5e17 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip5aStaticWebsites/SiteAggregateHash.kt @@ -0,0 +1,66 @@ +/* + * 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.nip5aStaticWebsites + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip5aStaticWebsites.tags.PathTag +import com.vitorpamplona.quartz.utils.sha256.sha256 + +/** + * NIP-5A aggregate hash — a single content hash binding every file in a + * static-website / napplet manifest, carried in the `["x", , "aggregate"]` + * tag and verified by NIP-5D runtimes before executing a napplet. + * + * Algorithm (verbatim from NIP-5A): + * 1. Collect every `path` tag. + * 2. For each, produce a line `" \n"`. + * 3. Sort all lines in ascending lexicographic order. + * 4. Concatenate the sorted lines as UTF-8 bytes. + * 5. SHA-256 the concatenation. + * + * Lines are sorted by Kotlin's natural `String` order (UTF-16 code units), which + * matches a JavaScript `Array.prototype.sort()` reference implementation. In + * practice each line is prefixed by its unique 64-char hex hash, so ordering is + * decided by the hash and the path encoding only ever breaks ties between two + * paths sharing one blob. + */ +object SiteAggregateHash { + /** Recomputes the aggregate hash hex from a manifest's [paths]. */ + fun compute(paths: List): HexKey { + val body = + paths + .map { "${it.hash} ${it.path}\n" } + .sorted() + .joinToString("") + return sha256(body.encodeToByteArray()).toHexKey() + } + + /** + * Verifies a declared aggregate hash against the one recomputed from [paths]. + * Returns `true` when [declared] is null (nothing to check) — per NIP-5D, + * the `x` tag is only enforced when present. + */ + fun verify( + paths: List, + declared: HexKey?, + ): Boolean = declared == null || declared.equals(compute(paths), ignoreCase = true) +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip5aStaticWebsites/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip5aStaticWebsites/TagArrayBuilderExt.kt index 3da5f0964d..6668880538 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip5aStaticWebsites/TagArrayBuilderExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip5aStaticWebsites/TagArrayBuilderExt.kt @@ -21,12 +21,14 @@ package com.vitorpamplona.quartz.nip5aStaticWebsites import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder import com.vitorpamplona.quartz.nip5aStaticWebsites.tags.DescriptionTag import com.vitorpamplona.quartz.nip5aStaticWebsites.tags.PathTag import com.vitorpamplona.quartz.nip5aStaticWebsites.tags.ServerTag import com.vitorpamplona.quartz.nip5aStaticWebsites.tags.SourceTag import com.vitorpamplona.quartz.nip5aStaticWebsites.tags.TitleTag +import com.vitorpamplona.quartz.nip5aStaticWebsites.tags.XTag fun TagArrayBuilder.sitePaths(paths: List) = addAll(PathTag.assemble(paths)) @@ -37,3 +39,8 @@ fun TagArrayBuilder.siteTitle(title: String) = addUnique(TitleTag fun TagArrayBuilder.siteDescription(description: String) = addUnique(DescriptionTag.assemble(description)) fun TagArrayBuilder.siteSource(url: String) = addUnique(SourceTag.assemble(url)) + +fun TagArrayBuilder.siteAggregateHash(aggregateHash: HexKey) = addUnique(XTag.assemble(aggregateHash)) + +/** Computes the NIP-5A aggregate hash from [paths] and adds it as the `x` tag. */ +fun TagArrayBuilder.siteAggregateHash(paths: List) = siteAggregateHash(SiteAggregateHash.compute(paths)) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip5aStaticWebsites/TagArrayExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip5aStaticWebsites/TagArrayExt.kt index f353893899..c8c2913f8a 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip5aStaticWebsites/TagArrayExt.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip5aStaticWebsites/TagArrayExt.kt @@ -26,6 +26,7 @@ import com.vitorpamplona.quartz.nip5aStaticWebsites.tags.PathTag import com.vitorpamplona.quartz.nip5aStaticWebsites.tags.ServerTag import com.vitorpamplona.quartz.nip5aStaticWebsites.tags.SourceTag import com.vitorpamplona.quartz.nip5aStaticWebsites.tags.TitleTag +import com.vitorpamplona.quartz.nip5aStaticWebsites.tags.XTag fun TagArray.sitePaths() = mapNotNull(PathTag::parse) @@ -36,3 +37,5 @@ fun TagArray.siteTitle() = firstNotNullOfOrNull(TitleTag::parse) fun TagArray.siteDescription() = firstNotNullOfOrNull(DescriptionTag::parse) fun TagArray.siteSource() = firstNotNullOfOrNull(SourceTag::parse) + +fun TagArray.siteAggregateHash() = firstNotNullOfOrNull(XTag::parse) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip5aStaticWebsites/resolver/StaticSitePathLookup.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip5aStaticWebsites/resolver/StaticSitePathLookup.kt new file mode 100644 index 0000000000..2d4f2f2362 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip5aStaticWebsites/resolver/StaticSitePathLookup.kt @@ -0,0 +1,103 @@ +/* + * 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.nip5aStaticWebsites.resolver + +import com.vitorpamplona.quartz.nip5aStaticWebsites.tags.PathTag + +/* + * Request-path lookup for NIP-5A static-website / napplet manifests. + * + * A manifest (`RootSiteEvent` kind 15128 / `NamedSiteEvent` kind 35128) maps request + * paths to content-addressed blobs through `path` tags (`PathTag` = request path + the + * blob's lowercase sha256). The same event shape backs both nsites and napplets + * (NIP-5D web projection), so the lookup here is deliberately runtime-agnostic. + * + * These helpers apply the usual static-host path conventions before matching: + * - the query string (`?…`) and fragment (`#…`) are dropped, + * - a leading `/` (or `./`) is irrelevant to the match, + * - an empty path or a directory request (trailing `/`) resolves to `index.html`. + * + * The match itself stays strict and content-addressed — there is no SPA "serve + * index.html for any unknown route" fallback here. That is a host/shell policy + * decision (it weakens the path→hash guarantee) and belongs in the shell, not in + * the protocol layer. + */ + +/** The implicit document served for the site root and for directory requests. */ +const val STATIC_SITE_INDEX = "index.html" + +/** + * Normalises a raw request path to the canonical form used for manifest matching: + * strips the query/fragment and any leading `/` or `./`, and expands a root or + * directory request to its `index.html` document. + */ +fun normalizeStaticPath(requestPath: String): String { + val withoutQuery = requestPath.substringBefore('?').substringBefore('#') + val trimmed = withoutQuery.removePrefix("./").removePrefix("/") + return when { + trimmed.isEmpty() -> STATIC_SITE_INDEX + trimmed.endsWith('/') -> trimmed + STATIC_SITE_INDEX + else -> trimmed + } +} + +/** Canonical form of a manifest-declared path, so `/app.js` and `app.js` compare equal. */ +private fun PathTag.canonicalPath() = path.removePrefix("./").removePrefix("/") + +/** + * Finds the [PathTag] that serves [requestPath], applying [normalizeStaticPath] to the + * request and to each declared path before comparing. Returns `null` when the path is + * not declared in the manifest. + */ +fun List.resolvePath(requestPath: String): PathTag? { + val target = normalizeStaticPath(requestPath) + return firstOrNull { it.canonicalPath() == target } +} + +/** + * Best-effort `Content-Type` for a manifest path, derived from its file extension. + * Blossom serves blobs untyped (content-addressed), so the host must label them; this + * covers the common web-runtime asset types and falls back to `application/octet-stream`. + */ +fun guessStaticContentType(path: String): String { + val ext = path.substringAfterLast('.', "").lowercase() + return when (ext) { + "html", "htm" -> "text/html; charset=utf-8" + "js", "mjs" -> "text/javascript; charset=utf-8" + "css" -> "text/css; charset=utf-8" + "json" -> "application/json; charset=utf-8" + "wasm" -> "application/wasm" + "svg" -> "image/svg+xml" + "png" -> "image/png" + "jpg", "jpeg" -> "image/jpeg" + "gif" -> "image/gif" + "webp" -> "image/webp" + "avif" -> "image/avif" + "ico" -> "image/x-icon" + "txt", "md" -> "text/plain; charset=utf-8" + "xml" -> "application/xml" + "woff2" -> "font/woff2" + "woff" -> "font/woff" + "ttf" -> "font/ttf" + "map" -> "application/json" + else -> "application/octet-stream" + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip5aStaticWebsites/resolver/StaticSiteResolver.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip5aStaticWebsites/resolver/StaticSiteResolver.kt new file mode 100644 index 0000000000..47d8ba8b2b --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip5aStaticWebsites/resolver/StaticSiteResolver.kt @@ -0,0 +1,151 @@ +/* + * 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.nip5aStaticWebsites.resolver + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip5aStaticWebsites.tags.PathTag +import com.vitorpamplona.quartz.utils.sha256.sha256 +import kotlin.coroutines.cancellation.CancellationException + +/** + * Fetches the raw bytes of a Blossom blob from an absolute URL, or returns `null` when + * the server is unreachable / responds with an error. Supplied by the host platform + * (e.g. an OkHttp-backed implementation in `commons`/`amethyst`) so that `quartz` carries + * no HTTP dependency and the resolver stays Kotlin-Multiplatform-pure. + */ +typealias BlobFetcher = suspend (url: String) -> ByteArray? + +/** Outcome of resolving a single request path against a NIP-5A static-website manifest. */ +sealed interface StaticSiteResolution { + /** + * The request path was declared in the manifest and a Blossom server returned a blob + * whose sha256 matched the declared [hash]. [bytes] are safe to render. + */ + data class Resolved( + val path: String, + val hash: HexKey, + val contentType: String, + val bytes: ByteArray, + val server: String, + ) : StaticSiteResolution { + // ByteArray needs structural equals/hashCode. + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is Resolved) return false + return path == other.path && + hash == other.hash && + contentType == other.contentType && + server == other.server && + bytes.contentEquals(other.bytes) + } + + override fun hashCode(): Int { + var result = path.hashCode() + result = 31 * result + hash.hashCode() + result = 31 * result + contentType.hashCode() + result = 31 * result + server.hashCode() + result = 31 * result + bytes.contentHashCode() + return result + } + } + + /** No `path` tag in the manifest matches the request path. */ + data object PathNotInManifest : StaticSiteResolution + + /** + * The path exists in the manifest but no listed server returned a blob whose hash + * matched [hash] — every candidate was unreachable, errored, or served tampered bytes. + */ + data class Unresolvable( + val hash: HexKey, + ) : StaticSiteResolution +} + +/** + * Resolves request paths for a NIP-5A static-website / napplet manifest into verified + * blob bytes, fetched over Blossom. + * + * The trust model is the whole point: the **manifest is the authority and the Blossom + * server is untrusted**. The signed manifest pins each path to a sha256; this resolver + * downloads the content-addressed blob from each listed server in order and accepts the + * first one whose recomputed sha256 matches the pin. A server that substitutes, corrupts, + * or truncates a blob fails [verify] and is silently skipped — it can withhold content but + * can never forge it. This is what lets a napplet shell run third-party code from an + * untrusted CDN behind a single signed, content-addressed manifest. + */ +object StaticSiteResolver { + /** True iff [blob]'s sha256 equals [expectedHash] (case-insensitive hex). */ + fun verify( + blob: ByteArray, + expectedHash: HexKey, + ): Boolean = sha256(blob).toHexKey().equals(expectedHash, ignoreCase = true) + + /** + * Ordered candidate Blossom URLs for [hash] across [servers]. Blossom addresses blobs + * by bare sha256 (`/`), so the path's extension is irrelevant here. + */ + fun candidateUrls( + servers: List, + hash: HexKey, + ): List = servers.map { "${it.trimEnd('/')}/$hash" } + + /** + * Resolves [requestPath] against the manifest's [paths] and [servers], fetching with + * [fetch] and verifying every downloaded blob's hash before returning it. + * + * @param paths the manifest's `path` tags (`event.paths()`). + * @param servers the manifest's `server` tags (`event.servers()`), tried in order. + * Additional fallbacks (e.g. the author's kind:10063 Blossom list) can + * be appended by the caller before invoking this function. + */ + suspend fun resolve( + requestPath: String, + paths: List, + servers: List, + fetch: BlobFetcher, + ): StaticSiteResolution { + val match = paths.resolvePath(requestPath) ?: return StaticSiteResolution.PathNotInManifest + + for (url in candidateUrls(servers, match.hash)) { + val bytes = + try { + fetch(url) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + null + } ?: continue + + if (verify(bytes, match.hash)) { + return StaticSiteResolution.Resolved( + path = match.path, + hash = match.hash, + contentType = guessStaticContentType(match.path), + bytes = bytes, + server = url.substringBeforeLast('/'), + ) + } + } + + return StaticSiteResolution.Unresolvable(match.hash) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip5aStaticWebsites/tags/XTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip5aStaticWebsites/tags/XTag.kt new file mode 100644 index 0000000000..e28eed859a --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip5aStaticWebsites/tags/XTag.kt @@ -0,0 +1,51 @@ +/* + * 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.nip5aStaticWebsites.tags + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +/** + * The NIP-5A aggregate-hash tag: `["x", "", "aggregate"]`. + * + * It pins the whole site/napplet manifest to a single content hash computed over + * all `path` tags (see [com.vitorpamplona.quartz.nip5aStaticWebsites.SiteAggregateHash]). + * The `"aggregate"` marker in position 2 distinguishes it from other `x` tags + * (e.g. NIP-94's bare `["x", ""]`). + */ +class XTag { + companion object { + const val TAG_NAME = "x" + const val AGGREGATE_MARKER = "aggregate" + + /** Returns the aggregate hash hex when [tag] is a well-formed aggregate `x` tag, else null. */ + fun parse(tag: Array): HexKey? { + ensure(tag.has(2)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + ensure(tag[2] == AGGREGATE_MARKER) { return null } + return tag[1] + } + + fun assemble(aggregateHash: HexKey) = arrayOf(TAG_NAME, aggregateHash, AGGREGATE_MARKER) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip5dNapplets/NamedNappletEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip5dNapplets/NamedNappletEvent.kt new file mode 100644 index 0000000000..349c42f605 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip5dNapplets/NamedNappletEvent.kt @@ -0,0 +1,87 @@ +/* + * 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.nip5dNapplets + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.BaseAddressableEvent +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip01Core.tags.dTag.dTag +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip50Search.SearchableEvent +import com.vitorpamplona.quartz.nip5aStaticWebsites.siteAggregateHash +import com.vitorpamplona.quartz.nip5aStaticWebsites.siteDescription +import com.vitorpamplona.quartz.nip5aStaticWebsites.sitePaths +import com.vitorpamplona.quartz.nip5aStaticWebsites.siteServers +import com.vitorpamplona.quartz.nip5aStaticWebsites.siteSource +import com.vitorpamplona.quartz.nip5aStaticWebsites.siteTitle +import com.vitorpamplona.quartz.nip5aStaticWebsites.tags.PathTag +import com.vitorpamplona.quartz.utils.TimeUtils + +/** + * NIP-5D **named napplet** (kind 35129) — an addressable manifest identified by a + * `d` tag, so one pubkey can publish many named napplets. Carries the NIP-5A tag + * set; the `x` aggregate hash is recommended and included by [build]. + */ +@Immutable +class NamedNappletEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : BaseAddressableEvent(id, pubKey, createdAt, KIND, tags, content, sig), + NappletManifest, + SearchableEvent { + override fun indexableContent() = listOfNotNull(title(), description()).joinToString("\n") + + fun identifier() = dTag() + + companion object { + const val KIND = 35129 + const val ALT_DESCRIPTION = "Named Napplet" + + fun build( + identifier: String, + paths: List, + servers: List = emptyList(), + requires: List = emptyList(), + title: String? = null, + description: String? = null, + source: String? = null, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, "", createdAt) { + alt(ALT_DESCRIPTION) + dTag(identifier) + sitePaths(paths) + siteAggregateHash(paths) + if (servers.isNotEmpty()) siteServers(servers) + if (requires.isNotEmpty()) nappletRequires(requires) + title?.let { siteTitle(it) } + description?.let { siteDescription(it) } + source?.let { siteSource(it) } + initializer() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip5dNapplets/NappletManifest.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip5dNapplets/NappletManifest.kt new file mode 100644 index 0000000000..ce0e9bbc77 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip5dNapplets/NappletManifest.kt @@ -0,0 +1,72 @@ +/* + * 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.nip5dNapplets + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArray +import com.vitorpamplona.quartz.nip5aStaticWebsites.SiteAggregateHash +import com.vitorpamplona.quartz.nip5aStaticWebsites.siteAggregateHash +import com.vitorpamplona.quartz.nip5aStaticWebsites.siteDescription +import com.vitorpamplona.quartz.nip5aStaticWebsites.sitePaths +import com.vitorpamplona.quartz.nip5aStaticWebsites.siteServers +import com.vitorpamplona.quartz.nip5aStaticWebsites.siteSource +import com.vitorpamplona.quartz.nip5aStaticWebsites.siteTitle +import com.vitorpamplona.quartz.nip5aStaticWebsites.tags.PathTag + +/** + * Common surface for the three NIP-5D napplet manifest kinds — snapshot + * ([NappletSnapshotEvent] / 5129), root ([RootNappletEvent] / 15129), and named + * ([NamedNappletEvent] / 35129). They carry the same NIP-5A tag set and differ + * only in replaceability and the `d` identifier, so all accessors live here and + * read off the event's [tags]. + */ +interface NappletManifest { + val tags: TagArray + + /** `path` tags mapping each absolute request path to its blob's sha256. */ + fun paths(): List = tags.sitePaths() + + /** `server` tags hinting which Blossom servers hold the blobs. */ + fun servers(): List = tags.siteServers() + + /** `requires` tags: bare NAP capability domains the napplet needs from the shell. */ + fun requires(): List = tags.nappletRequires() + + /** The aggregate hash declared in the `x` tag, or null when absent. */ + fun declaredAggregateHash(): HexKey? = tags.siteAggregateHash() + + fun title(): String? = tags.siteTitle() + + fun description(): String? = tags.siteDescription() + + fun source(): String? = tags.siteSource() + + /** The NIP-5A aggregate hash recomputed from this manifest's [paths]. */ + fun computeAggregateHash(): HexKey = SiteAggregateHash.compute(paths()) + + /** + * Verifies the declared `x` aggregate hash against the one recomputed from the + * `path` tags. Returns `true` when no `x` tag is present — per NIP-5D it is only + * enforced when carried. A runtime MUST still verify each blob's own sha256 + * separately (see `StaticSiteResolver`). + */ + fun verifyAggregate(): Boolean = SiteAggregateHash.verify(paths(), declaredAggregateHash()) +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip5dNapplets/NappletSnapshotEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip5dNapplets/NappletSnapshotEvent.kt new file mode 100644 index 0000000000..19aff15da0 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip5dNapplets/NappletSnapshotEvent.kt @@ -0,0 +1,82 @@ +/* + * 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.nip5dNapplets + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip50Search.SearchableEvent +import com.vitorpamplona.quartz.nip5aStaticWebsites.siteAggregateHash +import com.vitorpamplona.quartz.nip5aStaticWebsites.siteDescription +import com.vitorpamplona.quartz.nip5aStaticWebsites.sitePaths +import com.vitorpamplona.quartz.nip5aStaticWebsites.siteServers +import com.vitorpamplona.quartz.nip5aStaticWebsites.siteSource +import com.vitorpamplona.quartz.nip5aStaticWebsites.siteTitle +import com.vitorpamplona.quartz.nip5aStaticWebsites.tags.PathTag +import com.vitorpamplona.quartz.utils.TimeUtils + +/** + * NIP-5D napplet **snapshot** (kind 5129) — a regular, immutable event that pins + * one exact napplet build. Unlike the replaceable root/named manifests, a snapshot + * is permanent version history; it MUST carry the `x` aggregate hash. + */ +@Immutable +class NappletSnapshotEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : Event(id, pubKey, createdAt, KIND, tags, content, sig), + NappletManifest, + SearchableEvent { + override fun indexableContent() = listOfNotNull(title(), description()).joinToString("\n") + + companion object { + const val KIND = 5129 + const val ALT_DESCRIPTION = "Napplet snapshot" + + fun build( + paths: List, + servers: List = emptyList(), + requires: List = emptyList(), + title: String? = null, + description: String? = null, + source: String? = null, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, "", createdAt) { + alt(ALT_DESCRIPTION) + sitePaths(paths) + siteAggregateHash(paths) + if (servers.isNotEmpty()) siteServers(servers) + if (requires.isNotEmpty()) nappletRequires(requires) + title?.let { siteTitle(it) } + description?.let { siteDescription(it) } + source?.let { siteSource(it) } + initializer() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip5dNapplets/RootNappletEvent.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip5dNapplets/RootNappletEvent.kt new file mode 100644 index 0000000000..6efd223122 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip5dNapplets/RootNappletEvent.kt @@ -0,0 +1,82 @@ +/* + * 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.nip5dNapplets + +import androidx.compose.runtime.Immutable +import com.vitorpamplona.quartz.nip01Core.core.BaseReplaceableEvent +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate +import com.vitorpamplona.quartz.nip31Alts.alt +import com.vitorpamplona.quartz.nip50Search.SearchableEvent +import com.vitorpamplona.quartz.nip5aStaticWebsites.siteAggregateHash +import com.vitorpamplona.quartz.nip5aStaticWebsites.siteDescription +import com.vitorpamplona.quartz.nip5aStaticWebsites.sitePaths +import com.vitorpamplona.quartz.nip5aStaticWebsites.siteServers +import com.vitorpamplona.quartz.nip5aStaticWebsites.siteSource +import com.vitorpamplona.quartz.nip5aStaticWebsites.siteTitle +import com.vitorpamplona.quartz.nip5aStaticWebsites.tags.PathTag +import com.vitorpamplona.quartz.utils.TimeUtils + +/** + * NIP-5D **root napplet** (kind 15129) — the replaceable manifest for a pubkey's + * default napplet. One per author; newer events replace older ones. Carries the + * NIP-5A tag set; the `x` aggregate hash is recommended and included by [build]. + */ +@Immutable +class RootNappletEvent( + id: HexKey, + pubKey: HexKey, + createdAt: Long, + tags: Array>, + content: String, + sig: HexKey, +) : BaseReplaceableEvent(id, pubKey, createdAt, KIND, tags, content, sig), + NappletManifest, + SearchableEvent { + override fun indexableContent() = listOfNotNull(title(), description()).joinToString("\n") + + companion object { + const val KIND = 15129 + const val ALT_DESCRIPTION = "Napplet" + + fun build( + paths: List, + servers: List = emptyList(), + requires: List = emptyList(), + title: String? = null, + description: String? = null, + source: String? = null, + createdAt: Long = TimeUtils.now(), + initializer: TagArrayBuilder.() -> Unit = {}, + ) = eventTemplate(KIND, "", createdAt) { + alt(ALT_DESCRIPTION) + sitePaths(paths) + siteAggregateHash(paths) + if (servers.isNotEmpty()) siteServers(servers) + if (requires.isNotEmpty()) nappletRequires(requires) + title?.let { siteTitle(it) } + description?.let { siteDescription(it) } + source?.let { siteSource(it) } + initializer() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip5dNapplets/TagArrayBuilderExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip5dNapplets/TagArrayBuilderExt.kt new file mode 100644 index 0000000000..84657627b0 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip5dNapplets/TagArrayBuilderExt.kt @@ -0,0 +1,27 @@ +/* + * 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.nip5dNapplets + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder +import com.vitorpamplona.quartz.nip5dNapplets.tags.RequiresTag + +fun TagArrayBuilder.nappletRequires(napNames: List) = addAll(RequiresTag.assemble(napNames)) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip5dNapplets/TagArrayExt.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip5dNapplets/TagArrayExt.kt new file mode 100644 index 0000000000..25ee161f35 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip5dNapplets/TagArrayExt.kt @@ -0,0 +1,26 @@ +/* + * 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.nip5dNapplets + +import com.vitorpamplona.quartz.nip01Core.core.TagArray +import com.vitorpamplona.quartz.nip5dNapplets.tags.RequiresTag + +fun TagArray.nappletRequires() = mapNotNull(RequiresTag::parse) diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip5dNapplets/tags/RequiresTag.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip5dNapplets/tags/RequiresTag.kt new file mode 100644 index 0000000000..6dc4f61008 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip5dNapplets/tags/RequiresTag.kt @@ -0,0 +1,49 @@ +/* + * 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.nip5dNapplets.tags + +import com.vitorpamplona.quartz.nip01Core.core.has +import com.vitorpamplona.quartz.utils.ensure + +/** + * NIP-5D capability-requirement tag: `["requires", ""]`. + * + * Each value is a bare NAP domain the napplet needs from its shell — e.g. + * `relay`, `identity`, `storage` — never the `NAP-RELAY` spec name. A shell uses + * these to decide which capabilities to broker (and which to deny) before running + * the napplet. + */ +class RequiresTag { + companion object { + const val TAG_NAME = "requires" + + fun parse(tag: Array): String? { + ensure(tag.has(1)) { return null } + ensure(tag[0] == TAG_NAME) { return null } + ensure(tag[1].isNotEmpty()) { return null } + return tag[1] + } + + fun assemble(napName: String) = arrayOf(TAG_NAME, napName) + + fun assemble(napNames: List) = napNames.map { assemble(it) } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt index cdf987c70f..9c66762853 100644 --- a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/utils/EventFactory.kt @@ -197,6 +197,9 @@ import com.vitorpamplona.quartz.nip59Giftwrap.wraps.EphemeralGiftWrapEvent import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent import com.vitorpamplona.quartz.nip5aStaticWebsites.NamedSiteEvent import com.vitorpamplona.quartz.nip5aStaticWebsites.RootSiteEvent +import com.vitorpamplona.quartz.nip5dNapplets.NamedNappletEvent +import com.vitorpamplona.quartz.nip5dNapplets.NappletSnapshotEvent +import com.vitorpamplona.quartz.nip5dNapplets.RootNappletEvent import com.vitorpamplona.quartz.nip60Cashu.history.CashuSpendingHistoryEvent import com.vitorpamplona.quartz.nip60Cashu.quote.CashuMintQuoteEvent import com.vitorpamplona.quartz.nip60Cashu.token.CashuTokenEvent @@ -498,6 +501,9 @@ class EventFactory { TokenListEvent.KIND -> TokenListEvent(id, pubKey, createdAt, tags, content, sig) TokenRemovalEvent.KIND -> TokenRemovalEvent(id, pubKey, createdAt, tags, content, sig) NamedSiteEvent.KIND -> NamedSiteEvent(id, pubKey, createdAt, tags, content, sig) + NappletSnapshotEvent.KIND -> NappletSnapshotEvent(id, pubKey, createdAt, tags, content, sig) + RootNappletEvent.KIND -> RootNappletEvent(id, pubKey, createdAt, tags, content, sig) + NamedNappletEvent.KIND -> NamedNappletEvent(id, pubKey, createdAt, tags, content, sig) NNSEvent.KIND -> NNSEvent(id, pubKey, createdAt, tags, content, sig) NipTextEvent.KIND -> NipTextEvent(id, pubKey, createdAt, tags, content, sig) NutzapEvent.KIND -> NutzapEvent(id, pubKey, createdAt, tags, content, sig) diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip5aStaticWebsites/SiteAggregateHashTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip5aStaticWebsites/SiteAggregateHashTest.kt new file mode 100644 index 0000000000..4033af0139 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip5aStaticWebsites/SiteAggregateHashTest.kt @@ -0,0 +1,66 @@ +/* + * 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.nip5aStaticWebsites + +import com.vitorpamplona.quartz.nip5aStaticWebsites.tags.PathTag +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class SiteAggregateHashTest { + private val h1 = "11".repeat(32) + private val h2 = "22".repeat(32) + + @Test + fun matchesIndependentlyComputedVector() { + // Pinned with `printf '

/index.html\n

/app.js\n' | sha256sum` (h1 < h2, so sorted order). + val paths = listOf(PathTag("/index.html", h1), PathTag("/app.js", h2)) + assertEquals( + "2c1250d51fba528f4d8c3c98522ecd844f6dcd94bcf3ecc90f3219bbc4a23224", + SiteAggregateHash.compute(paths), + ) + } + + @Test + fun isIndependentOfInputOrder() { + val forward = SiteAggregateHash.compute(listOf(PathTag("/index.html", h1), PathTag("/app.js", h2))) + val reversed = SiteAggregateHash.compute(listOf(PathTag("/app.js", h2), PathTag("/index.html", h1))) + assertEquals(forward, reversed) + } + + @Test + fun verifyAcceptsNullAndMatchAndRejectsTamper() { + val paths = listOf(PathTag("/index.html", h1), PathTag("/app.js", h2)) + val aggregate = SiteAggregateHash.compute(paths) + + // No x tag declared -> nothing to enforce. + assertTrue(SiteAggregateHash.verify(paths, null)) + // Declared matches, case-insensitively. + assertTrue(SiteAggregateHash.verify(paths, aggregate)) + assertTrue(SiteAggregateHash.verify(paths, aggregate.uppercase())) + // Declared aggregate that doesn't match the paths is rejected. + assertFalse(SiteAggregateHash.verify(paths, h1)) + // Tampering a single path hash changes the recomputed aggregate -> mismatch. + val tampered = listOf(PathTag("/index.html", h2), PathTag("/app.js", h2)) + assertFalse(SiteAggregateHash.verify(tampered, aggregate)) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip5aStaticWebsites/resolver/StaticSiteResolverTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip5aStaticWebsites/resolver/StaticSiteResolverTest.kt new file mode 100644 index 0000000000..782115b8b9 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip5aStaticWebsites/resolver/StaticSiteResolverTest.kt @@ -0,0 +1,162 @@ +/* + * 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.nip5aStaticWebsites.resolver + +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip5aStaticWebsites.tags.PathTag +import com.vitorpamplona.quartz.utils.sha256.sha256 +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertTrue + +class StaticSiteResolverTest { + private fun bytes(s: String) = s.encodeToByteArray() + + private fun hashOf(s: String) = sha256(bytes(s)).toHexKey() + + @Test + fun normalizesRootAndDirectoryAndQueryPaths() { + assertEquals("index.html", normalizeStaticPath("")) + assertEquals("index.html", normalizeStaticPath("/")) + assertEquals("app.js", normalizeStaticPath("/app.js")) + assertEquals("app.js", normalizeStaticPath("./app.js")) + assertEquals("assets/app.js", normalizeStaticPath("/assets/app.js?v=2#top")) + assertEquals("docs/index.html", normalizeStaticPath("/docs/")) + } + + @Test + fun lookupMatchesRegardlessOfLeadingSlash() { + val paths = + listOf( + PathTag("/index.html", hashOf("home")), + PathTag("assets/app.js", hashOf("script")), + ) + + assertEquals(hashOf("home"), paths.resolvePath("/")?.hash) + assertEquals(hashOf("home"), paths.resolvePath("/index.html")?.hash) + assertEquals(hashOf("script"), paths.resolvePath("assets/app.js")?.hash) + assertEquals(null, paths.resolvePath("missing.js")) + } + + @Test + fun guessesWebContentTypes() { + assertEquals("text/html; charset=utf-8", guessStaticContentType("index.html")) + assertEquals("text/javascript; charset=utf-8", guessStaticContentType("app.mjs")) + assertEquals("application/wasm", guessStaticContentType("core.wasm")) + assertEquals("application/octet-stream", guessStaticContentType("blob.unknownext")) + } + + @Test + fun verifyAcceptsMatchingAndRejectsTamperedBytes() { + val good = bytes("napplet") + val hash = sha256(good).toHexKey() + + assertTrue(StaticSiteResolver.verify(good, hash)) + assertFalse(StaticSiteResolver.verify(bytes("evil"), hash)) + } + + @Test + fun resolvesFromTheFirstServerThatServesMatchingBytes() = + runTest { + val html = "home" + val paths = listOf(PathTag("/index.html", hashOf(html))) + + val resolution = + StaticSiteResolver.resolve( + requestPath = "/", + paths = paths, + servers = listOf("https://cdn.example.com", "https://backup.example.com"), + fetch = { url -> if (url.startsWith("https://cdn.example.com")) bytes(html) else null }, + ) + + val resolved = assertIs(resolution) + // Resolved.path echoes the manifest's declared path verbatim, not the normalized request. + assertEquals("/index.html", resolved.path) + assertEquals("https://cdn.example.com", resolved.server) + assertEquals("text/html; charset=utf-8", resolved.contentType) + assertEquals(html, resolved.bytes.decodeToString()) + } + + @Test + fun skipsAServerThatTampersWithTheBlobAndFallsThroughToAnHonestOne() = + runTest { + val html = "home" + val paths = listOf(PathTag("/index.html", hashOf(html))) + + var triedMalicious = false + val resolution = + StaticSiteResolver.resolve( + requestPath = "/", + paths = paths, + servers = listOf("https://evil.example.com", "https://honest.example.com"), + fetch = { url -> + if (url.startsWith("https://evil.example.com")) { + triedMalicious = true + bytes("injected malware") + } else { + bytes(html) + } + }, + ) + + val resolved = assertIs(resolution) + // The tampered server was contacted but its bytes were rejected by hash verification... + assertTrue(triedMalicious) + // ...and resolution fell through to the honest server's verified copy. + assertEquals("https://honest.example.com", resolved.server) + assertEquals(html, resolved.bytes.decodeToString()) + } + + @Test + fun reportsUnresolvableWhenEveryServerFailsVerification() = + runTest { + val paths = listOf(PathTag("/index.html", hashOf("real"))) + + val resolution = + StaticSiteResolver.resolve( + requestPath = "/", + paths = paths, + servers = listOf("https://a.example.com", "https://b.example.com"), + fetch = { _ -> bytes("tampered") }, + ) + + assertEquals(StaticSiteResolution.Unresolvable(hashOf("real")), resolution) + } + + @Test + fun reportsPathNotInManifestForUndeclaredPaths() = + runTest { + val paths = listOf(PathTag("/index.html", hashOf("home"))) + + val resolution = + StaticSiteResolver.resolve( + requestPath = "/secret.js", + paths = paths, + servers = listOf("https://a.example.com"), + fetch = { _ -> error("should not fetch for an undeclared path") }, + ) + + assertEquals(StaticSiteResolution.PathNotInManifest, resolution) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip5dNapplets/NappletEventTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip5dNapplets/NappletEventTest.kt new file mode 100644 index 0000000000..26c7b894eb --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/nip5dNapplets/NappletEventTest.kt @@ -0,0 +1,140 @@ +/* + * 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.nip5dNapplets + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate +import com.vitorpamplona.quartz.nip5aStaticWebsites.tags.PathTag +import com.vitorpamplona.quartz.utils.EventFactory +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class NappletEventTest { + private val zero = "00".repeat(32) + private val h1 = "11".repeat(32) + private val h2 = "22".repeat(32) + private val paths = listOf(PathTag("/index.html", h1), PathTag("/app.js", h2)) + private val servers = listOf("https://cdn.example.com") + private val requires = listOf("relay", "identity") + + private fun materialize( + template: EventTemplate, + factory: (String, String, Long, Array>, String, String) -> T, + ): T = factory(zero, zero, template.createdAt, template.tags, template.content, zero) + + @Test + fun namedNappletRoundTrips() { + val event = + materialize( + NamedNappletEvent.build( + identifier = "calculator", + paths = paths, + servers = servers, + requires = requires, + title = "Calc", + description = "a calculator", + source = "https://github.com/x/calc", + ), + ::NamedNappletEvent, + ) + + assertEquals(35129, event.kind) + assertEquals("calculator", event.identifier()) + assertEquals(paths.map { it.path }, event.paths().map { it.path }) + assertEquals(paths.map { it.hash }, event.paths().map { it.hash }) + assertEquals(servers, event.servers()) + assertEquals(requires, event.requires()) + assertEquals("Calc", event.title()) + assertEquals("a calculator", event.description()) + assertEquals("https://github.com/x/calc", event.source()) + + // build() stamps the x aggregate, and it verifies against the path tags. + assertNotNull(event.declaredAggregateHash()) + assertEquals(event.computeAggregateHash(), event.declaredAggregateHash()) + assertTrue(event.verifyAggregate()) + } + + @Test + fun rootNappletHasKind15129AndNoIdentifierNeeded() { + val event = materialize(RootNappletEvent.build(paths = paths), ::RootNappletEvent) + assertEquals(15129, event.kind) + assertTrue(event.verifyAggregate()) + } + + @Test + fun snapshotHasKind5129AndAlwaysCarriesAggregate() { + val event = materialize(NappletSnapshotEvent.build(paths = paths), ::NappletSnapshotEvent) + assertEquals(5129, event.kind) + assertNotNull(event.declaredAggregateHash()) + assertTrue(event.verifyAggregate()) + } + + @Test + fun tamperedPathBreaksAggregateVerification() { + // Build a valid manifest, then rewrite one path hash in the tags without + // touching the x tag — the recomputed aggregate no longer matches. + val template = NamedNappletEvent.build(identifier = "x", paths = paths) + val tamperedTags = + template.tags + .map { tag -> + if (tag.getOrNull(0) == PathTag.TAG_NAME && tag.getOrNull(1) == "/app.js") { + arrayOf(PathTag.TAG_NAME, "/app.js", h1) + } else { + tag + } + }.toTypedArray() + + val tampered = NamedNappletEvent(zero, zero, template.createdAt, tamperedTags, template.content, zero) + assertFalse(tampered.verifyAggregate()) + } + + @Test + fun eventFactoryRoutesAllThreeNappletKinds() { + val named = NamedNappletEvent.build(identifier = "x", paths = paths) + val root = RootNappletEvent.build(paths = paths) + val snapshot = NappletSnapshotEvent.build(paths = paths) + + assertIs( + EventFactory.create(zero, zero, named.createdAt, NamedNappletEvent.KIND, named.tags, named.content, zero), + ) + assertIs( + EventFactory.create(zero, zero, root.createdAt, RootNappletEvent.KIND, root.tags, root.content, zero), + ) + assertIs( + EventFactory.create(zero, zero, snapshot.createdAt, NappletSnapshotEvent.KIND, snapshot.tags, snapshot.content, zero), + ) + } + + @Test + fun emptyOptionalsAreOmitted() { + val event = materialize(RootNappletEvent.build(paths = paths), ::RootNappletEvent) + assertTrue(event.servers().isEmpty()) + assertTrue(event.requires().isEmpty()) + assertEquals(null, event.title()) + // Only path + x (+ alt) tags expected; the manifest still has its paths. + assertContentEquals(paths.map { it.hash }, event.paths().map { it.hash }) + } +}