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/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/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 }) + } +}