From 1ff2bcc198c679d9ce53076d0649590581ddaf7d Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 8 Sep 2026 20:41:53 +0000 Subject: [PATCH] feat(marmot): add encrypted-media v2 and the kind-451 push owner proof MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two Stage 7 surfaces, both verified against fixtures the spec publishes rather than against my own reading of it. **encrypted-media v2 (component 0x800b).** Supersedes the frozen v1 policy at 0x8008, which must never be reinterpreted as v2. The unusual rule here is that neither list is sorted: `default_blob_endpoints` order IS the upload/fetch fallback priority, so sorting it — as nostr-routing and admin-policy both do — would silently change which server a group uploads to. Two policies differing only in order are different canonical values, and the decoder preserves what the producer wrote. Its field checks look excessive until you see why they exist. `plaintext_sha256`, `m` and `filename` all feed both the key derivation and the AEAD AAD, joined by single 0x00 bytes with no length prefixes. That is unambiguous only because each field excludes 0x00 — fixed-width hash, ASCII-token media type, filename profile forbidding U+0000. It is also why a duplicate single-occurrence `imeta` field is rejected rather than resolved: a first-wins decoder and a last-wins decoder would derive different keys from the same authenticated tag, so one sender could hand two conformant clients tags that decrypt to different content. **Push owner proof (kind 451).** A BIP-340 signature over the id of an exact, never-published Nostr event. The event id is a ready-made canonical digest over the tuple that needs binding, and binding it is the whole point: because the id covers group_id, server_pubkey, relay_hint, the encrypted token and owner_ts, a member who merely RELAYS someone's record cannot move it to another group, repoint it at a different notification server, swap the token, or restamp it. A record's authority comes from owner_sig and current membership, never from who carried it. A current-profile group accepts only kind 451; a legacy group also accepts the superseded kind-450 form so upgraded and un-upgraded members can share a group. That split is a security boundary, not a courtesy — in a group where every leaf already carries a 0x8009 identity proof, accepting the weaker form would let anyone able to produce one bypass the stronger binding. Both fixtures reproduce exactly: the spec's published removal event id and its owner_sig verify under our tag construction, which is what proves tag order, arity and value formatting are right rather than merely self-consistent. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_016kCuA6tc4JQzHPCDd39GHq --- .../appComponents/EncryptedMediaPolicyV2.kt | 230 +++++++++++++ .../marmot/appComponents/EncryptedMediaV2.kt | 324 ++++++++++++++++++ .../mip05PushNotifications/PushOwnerProof.kt | 276 +++++++++++++++ .../appComponents/EncryptedMediaV2Test.kt | 305 +++++++++++++++++ .../PushOwnerProofTest.kt | 207 +++++++++++ 5 files changed, 1342 insertions(+) create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/appComponents/EncryptedMediaPolicyV2.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/appComponents/EncryptedMediaV2.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip05PushNotifications/PushOwnerProof.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/appComponents/EncryptedMediaV2Test.kt create mode 100644 quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/mip05PushNotifications/PushOwnerProofTest.kt diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/appComponents/EncryptedMediaPolicyV2.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/appComponents/EncryptedMediaPolicyV2.kt new file mode 100644 index 0000000000..c0f376d067 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/appComponents/EncryptedMediaPolicyV2.kt @@ -0,0 +1,230 @@ +/* + * 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.marmot.appComponents + +import com.vitorpamplona.quartz.marmot.mls.codec.TlsReader +import com.vitorpamplona.quartz.marmot.mls.codec.TlsWriter + +/** One blob-store endpoint and the locator kind it serves. */ +data class BlobStoreEndpointV2( + val locatorKind: String, + /** Normalized `http`/`https` base URL, 1..2048 bytes. */ + val baseUrl: String, +) { + /** + * `base_url` with trailing slashes removed — the `server_root` the fetch + * and upload URL rules are written against. + */ + val serverRoot: String get() = baseUrl.trimEnd('/') + + /** Blossom BUD-01 fetch URL for a ciphertext hash. No extension, query or fragment. */ + fun blossomFetchUrl(ciphertextSha256Hex: String) = "$serverRoot/$ciphertextSha256Hex" + + /** Blossom BUD-02 upload URL. */ + fun blossomUploadUrl() = "$serverRoot/upload" +} + +/** + * `marmot.group.encrypted-media.v2`, component `0x800b` — the group's media + * policy (`app-components/group-encrypted-media-v2.md`). + * + * Supersedes the frozen v1 policy at `0x8008`, which MUST NOT be reinterpreted + * as v2; a client may keep rendering legacy v1 references, but a + * current-profile sender creates only v2 ones. + * + * ## Order is part of the value + * + * Unlike `relays` in nostr-routing or `admins` in admin-policy, neither list + * here is sorted. `default_blob_endpoints` order IS the upload/fetch fallback + * priority, so sorting it would silently change which server a group uploads + * to. Two policies differing only in order are different canonical values, and + * a decoder preserves what the producer wrote. + */ +data class EncryptedMediaPolicyV2( + /** Ordered, unique, 1..16. The initial kind is `blossom-v1`. */ + val allowedLocatorKinds: List, + /** Ordered by fallback priority, unique, 1..16. */ + val defaultBlobEndpoints: List, + /** Fixed constant. Not version negotiation — another format needs another component id. */ + val mediaFormat: String = MEDIA_FORMAT, +) { + init { + require(mediaFormat == MEDIA_FORMAT) { + "media_format must be exactly '$MEDIA_FORMAT', was '$mediaFormat'" + } + require(allowedLocatorKinds.isNotEmpty()) { "allowed_locator_kinds must not be empty" } + require(allowedLocatorKinds.size <= MAX_ENTRIES) { + "allowed_locator_kinds exceeds $MAX_ENTRIES entries" + } + allowedLocatorKinds.forEach { requireValidLocatorKind(it) } + require(allowedLocatorKinds.toSet().size == allowedLocatorKinds.size) { + "allowed_locator_kinds contains a duplicate" + } + + require(defaultBlobEndpoints.isNotEmpty()) { "default_blob_endpoints must not be empty" } + require(defaultBlobEndpoints.size <= MAX_ENTRIES) { + "default_blob_endpoints exceeds $MAX_ENTRIES entries" + } + require(defaultBlobEndpoints.toSet().size == defaultBlobEndpoints.size) { + "default_blob_endpoints contains a duplicate" + } + defaultBlobEndpoints.forEach { endpoint -> + requireValidLocatorKind(endpoint.locatorKind) + require(endpoint.locatorKind in allowedLocatorKinds) { + "endpoint locator kind '${endpoint.locatorKind}' is not in allowed_locator_kinds" + } + requireNormalizedBaseUrl(endpoint.baseUrl) + } + } + + /** Endpoints serving [locatorKind], in fallback priority order. */ + fun endpointsFor(locatorKind: String) = defaultBlobEndpoints.filter { it.locatorKind == locatorKind } + + fun encode(): ByteArray { + val writer = TlsWriter() + writer.putOpaqueVarInt(mediaFormat.encodeToByteArray()) + + val kinds = TlsWriter() + allowedLocatorKinds.forEach { kinds.putOpaqueVarInt(it.encodeToByteArray()) } + writer.putOpaqueVarInt(kinds.toByteArray()) + + val endpoints = TlsWriter() + defaultBlobEndpoints.forEach { + endpoints.putOpaqueVarInt(it.locatorKind.encodeToByteArray()) + endpoints.putOpaqueVarInt(it.baseUrl.encodeToByteArray()) + } + writer.putOpaqueVarInt(endpoints.toByteArray()) + return writer.toByteArray() + } + + companion object { + const val COMPONENT_ID = 0x800b + const val MEDIA_FORMAT = "encrypted-media-v2" + const val INITIAL_LOCATOR_KIND = "blossom-v1" + const val MAX_ENTRIES = 16 + const val MAX_BASE_URL_BYTES = 2048 + const val MAX_LOCATOR_KIND_BYTES = 64 + + /** The spec's reference policy — the default a new group starts from. */ + val REFERENCE = + EncryptedMediaPolicyV2( + allowedLocatorKinds = listOf(INITIAL_LOCATOR_KIND), + defaultBlobEndpoints = + listOf(BlobStoreEndpointV2(INITIAL_LOCATOR_KIND, "https://blossom.primal.net/")), + ) + + /** + * Decode, rejecting anything non-canonical. + * + * These bytes sit in signed group state, so a lenient decoder is worse + * than a strict one: if two peers each "repair" the same bytes + * differently they hold different canonical values and disagree about + * what the group's policy is. Nothing is trimmed, case-folded, + * normalized or deduplicated on the way in — a duplicate or a + * non-normalized URL is refused rather than fixed, and trailing bytes + * are refused rather than ignored. + */ + fun decode(bytes: ByteArray): EncryptedMediaPolicyV2 { + val reader = TlsReader(bytes) + val format = reader.readOpaqueVarInt().decodeToString() + + val kindsBlock = reader.readOpaqueVarInt() + val kinds = mutableListOf() + val kindReader = TlsReader(kindsBlock) + while (kindReader.remaining > 0) { + kinds.add(kindReader.readOpaqueVarInt().decodeToString()) + } + + val endpointBlock = reader.readOpaqueVarInt() + val endpoints = mutableListOf() + val endpointReader = TlsReader(endpointBlock) + while (endpointReader.remaining > 0) { + val kind = endpointReader.readOpaqueVarInt().decodeToString() + val url = endpointReader.readOpaqueVarInt().decodeToString() + endpoints.add(BlobStoreEndpointV2(kind, url)) + } + + require(reader.remaining == 0) { + "encrypted-media policy has ${reader.remaining} trailing byte(s)" + } + + val decoded = EncryptedMediaPolicyV2(kinds, endpoints, format) + require(decoded.encode().contentEquals(bytes)) { + "encrypted-media policy bytes are not canonical" + } + return decoded + } + + fun decodeOrNull(bytes: ByteArray): EncryptedMediaPolicyV2? = + try { + decode(bytes) + } catch (_: Exception) { + null + } + + /** Lowercase ASCII letters, digits and `-`, 1..64 bytes. */ + fun requireValidLocatorKind(kind: String) { + val bytes = kind.encodeToByteArray() + require(bytes.isNotEmpty() && bytes.size <= MAX_LOCATOR_KIND_BYTES) { + "locator kind must be 1..$MAX_LOCATOR_KIND_BYTES bytes, was ${bytes.size}" + } + require(kind.all { it in 'a'..'z' || it in '0'..'9' || it == '-' }) { + "locator kind '$kind' has a character outside [a-z0-9-]" + } + } + + /** + * A base URL is normalized when it is byte-equal to its own + * parse-and-serialize output. + * + * The checks below are the structural subset that decides validity for + * every member identically: scheme, no userinfo, a present host, and no + * query or fragment. Reachability and whether this client is willing to + * contact the host are LOCAL policy and must not influence whether the + * component bytes — or the Commit carrying them — are valid; otherwise + * one member's blocklist would fork the group. + */ + fun requireNormalizedBaseUrl(url: String) { + val bytes = url.encodeToByteArray() + require(bytes.isNotEmpty() && bytes.size <= MAX_BASE_URL_BYTES) { + "base_url must be 1..$MAX_BASE_URL_BYTES bytes, was ${bytes.size}" + } + val scheme = + when { + url.startsWith("https://") -> "https://" + url.startsWith("http://") -> "http://" + else -> throw IllegalArgumentException("base_url must be http or https: '$url'") + } + require('#' !in url) { "base_url must not carry a fragment: '$url'" } + require('?' !in url) { "base_url must not carry a query: '$url'" } + + val afterScheme = url.substring(scheme.length) + val authority = afterScheme.substringBefore('/') + require(authority.isNotEmpty()) { "base_url has no host: '$url'" } + require('@' !in authority) { "base_url must not carry userinfo: '$url'" } + require(authority == authority.lowercase()) { + "base_url host is not normalized (lowercase): '$url'" + } + require("//" !in afterScheme) { "base_url path is not normalized: '$url'" } + require(".." !in afterScheme) { "base_url path is not normalized: '$url'" } + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/appComponents/EncryptedMediaV2.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/appComponents/EncryptedMediaV2.kt new file mode 100644 index 0000000000..faed1c71a4 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/appComponents/EncryptedMediaV2.kt @@ -0,0 +1,324 @@ +/* + * 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.marmot.appComponents + +import com.vitorpamplona.quartz.marmot.mls.crypto.MlsCryptoProvider +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip44Encryption.crypto.ChaCha20Poly1305 +import com.vitorpamplona.quartz.utils.RandomInstance +import com.vitorpamplona.quartz.utils.sha256.sha256 + +/** One `locator ` field of an `encrypted-media-v2` reference. */ +data class MediaLocatorV2( + val kind: String, + val value: String, +) + +/** + * One `encrypted-media-v2` attachment reference — the authenticated fields of + * an `imeta` tag (`features/encrypted-media.md`). + * + * The source epoch is deliberately NOT a field: it is the MLS epoch of the + * application message that carried the tag. Putting it in the tag would let a + * sender name which epoch's media secret a receiver should use, and the + * receiver already knows the real one. + */ +class EncryptedMediaReferenceV2( + val locators: List, + val ciphertextSha256: ByteArray, + val plaintextSha256: ByteArray, + val nonce: ByteArray, + /** Canonical media type, byte-for-byte as it must appear in `m`. */ + val mediaType: String, + val filename: String, + val dim: String? = null, + val thumbhash: String? = null, +) { + init { + require(locators.isNotEmpty()) { "an encrypted-media reference needs at least one locator" } + locators.forEach { locator -> + require(locator.kind.isNotEmpty()) { "locator kind must not be empty" } + require(locator.value.isNotEmpty()) { "locator value must not be empty" } + if (locator.kind == EncryptedMediaPolicyV2.INITIAL_LOCATOR_KIND) { + require(locator.value.startsWith("http://") || locator.value.startsWith("https://")) { + "a blossom-v1 locator must be an http or https URL" + } + } + } + require(ciphertextSha256.size == 32) { "ciphertext_sha256 must be 32 bytes" } + require(plaintextSha256.size == 32) { "plaintext_sha256 must be 32 bytes" } + require(nonce.size == EncryptedMediaV2.NONCE_LENGTH) { + "nonce must be ${EncryptedMediaV2.NONCE_LENGTH} bytes" + } + require(MarmotMediaType.canonicalize(mediaType) == mediaType) { + "m must already be byte-for-byte canonical, was '$mediaType'" + } + EncryptedMediaV2.requireValidFilename(filename) + } + + /** The `imeta` tag, with `v` first and the locators in the producer's order. */ + fun toImetaTag(): Array { + val fields = mutableListOf("imeta", "v ${EncryptedMediaPolicyV2.MEDIA_FORMAT}") + locators.forEach { fields.add("locator ${it.kind} ${it.value}") } + fields.add("ciphertext_sha256 ${ciphertextSha256.toHexKey()}") + fields.add("plaintext_sha256 ${plaintextSha256.toHexKey()}") + fields.add("nonce ${nonce.toHexKey()}") + fields.add("m $mediaType") + fields.add("filename $filename") + dim?.let { fields.add("dim $it") } + thumbhash?.let { fields.add("thumbhash $it") } + return fields.toTypedArray() + } +} + +/** + * `encrypted-media-v2` key derivation and content encryption + * (`features/encrypted-media.md`). + * + * ## Why the field checks are so unforgiving + * + * `plaintext_sha256`, `m` and `filename` all feed both the key derivation and + * the AEAD associated data, joined by single `0x00` bytes with no length + * prefixes. That is only unambiguous because each field is constrained to + * exclude `0x00`: the hash is fixed-width, the media-type profile allows only + * ASCII token bytes, and the filename profile forbids `U+0000` outright. Relax + * any of those and two different field triples serialize to the same info + * bytes. + * + * It is also why a duplicate single-occurrence field is REJECTED rather than + * resolved. A first-wins decoder and a last-wins decoder would derive different + * keys from the same authenticated tag, so one sender could hand two conformant + * clients tags that decrypt to different content. + */ +object EncryptedMediaV2 { + const val VERSION = "encrypted-media-v2" + const val NONCE_LENGTH = 12 + const val KEY_LENGTH = 32 + const val EXPORTER_KEY_LENGTH = 32 + const val MAX_FILENAME_BYTES = 255 + + private val VERSION_BYTES = VERSION.encodeToByteArray() + private val KEY_SUFFIX = "key".encodeToByteArray() + private val NUL = byteArrayOf(0x00) + + /** `filename` is display metadata: valid UTF-8, 1..255 bytes, no `U+0000`. */ + fun requireValidFilename(filename: String) { + val bytes = filename.encodeToByteArray() + require(bytes.isNotEmpty() && bytes.size <= MAX_FILENAME_BYTES) { + "filename must be 1..$MAX_FILENAME_BYTES bytes, was ${bytes.size}" + } + require(!filename.contains('\u0000')) { "filename must not contain U+0000" } + } + + /** + * `file_key = HKDF-Expand(media_secret, info, 32)`. + * + * `media_secret` is used directly as the HKDF PRK — Expand only, no + * Extract. That is fixed regardless of the group's MLS ciphersuite; only + * the exporter itself is computed with the ciphersuite's own hash. + */ + fun deriveFileKey( + mediaSecret: ByteArray, + plaintextSha256: ByteArray, + mediaType: String, + filename: String, + ): ByteArray { + require(mediaSecret.size == EXPORTER_KEY_LENGTH) { + "media secret must be $EXPORTER_KEY_LENGTH bytes" + } + return MlsCryptoProvider.hkdfExpand( + mediaSecret, + buildInfo(plaintextSha256, mediaType, filename, KEY_SUFFIX), + KEY_LENGTH, + ) + } + + class EncryptionResult( + val ciphertext: ByteArray, + val nonce: ByteArray, + val plaintextSha256: ByteArray, + val ciphertextSha256: ByteArray, + ) + + /** + * Encrypt an attachment. + * + * A fresh random nonce every time, including on a resend: the key is + * deterministic in (plaintext hash, media type, filename, epoch), so + * re-sending the same file in the same epoch reuses the key, and reusing a + * nonce with it breaks ChaCha20-Poly1305 outright. + */ + fun encrypt( + plaintext: ByteArray, + mediaSecret: ByteArray, + mediaType: String, + filename: String, + ): EncryptionResult { + require(MarmotMediaType.canonicalize(mediaType) == mediaType) { + "media type must already be canonical, was '$mediaType'" + } + requireValidFilename(filename) + + val plaintextSha256 = sha256(plaintext) + val fileKey = deriveFileKey(mediaSecret, plaintextSha256, mediaType, filename) + val nonce = RandomInstance.bytes(NONCE_LENGTH) + val aad = buildAad(plaintextSha256, mediaType, filename) + val ciphertext = ChaCha20Poly1305.encrypt(plaintext, aad, nonce, fileKey) + return EncryptionResult( + ciphertext = ciphertext, + nonce = nonce, + plaintextSha256 = plaintextSha256, + ciphertextSha256 = sha256(ciphertext), + ) + } + + /** + * Decrypt an attachment and verify it is the file the reference names. + * + * The plaintext-hash check is not redundant with the AEAD tag. The tag + * proves the ciphertext was produced under this key and AAD; the hash check + * proves the AAD described THIS file rather than another one the same + * sender could also authenticate. + */ + fun decrypt( + ciphertext: ByteArray, + mediaSecret: ByteArray, + nonce: ByteArray, + plaintextSha256: ByteArray, + mediaType: String, + filename: String, + ): ByteArray { + require(nonce.size == NONCE_LENGTH) { "nonce must be $NONCE_LENGTH bytes" } + require(plaintextSha256.size == 32) { "plaintext_sha256 must be 32 bytes" } + require(MarmotMediaType.canonicalize(mediaType) == mediaType) { + "media type must already be canonical, was '$mediaType'" + } + requireValidFilename(filename) + + val fileKey = deriveFileKey(mediaSecret, plaintextSha256, mediaType, filename) + val aad = buildAad(plaintextSha256, mediaType, filename) + val plaintext = ChaCha20Poly1305.decrypt(ciphertext, aad, nonce, fileKey) + check(sha256(plaintext).contentEquals(plaintextSha256)) { + "decrypted attachment does not hash to plaintext_sha256" + } + return plaintext + } + + /** + * Parse an `imeta` tag, or throw naming the reason. + * + * @throws IllegalArgumentException for every rejection the spec lists. + */ + fun parseImetaTag(tag: Array): EncryptedMediaReferenceV2 { + require(tag.isNotEmpty() && tag[0] == "imeta") { "not an imeta tag" } + + val locators = mutableListOf() + val single = mutableMapOf() + for (i in 1 until tag.size) { + val field = tag[i] + val name = field.substringBefore(' ') + val rest = field.substringAfter(' ', "") + if (name == "locator") { + val kind = rest.substringBefore(' ') + val value = rest.substringAfter(' ', "") + locators.add(MediaLocatorV2(kind, value)) + } else { + // Exactly `locator` repeats; everything else occurs at most + // once, and a duplicate is refused rather than resolved. + require(single.put(name, rest) == null) { + "imeta field '$name' appears more than once" + } + } + } + + require(!single.containsKey("blurhash")) { "blurhash is invalid in $VERSION" } + require(single["v"] == VERSION) { "imeta version is not $VERSION" } + + val storedMediaType = requireNotNull(single["m"]) { "imeta is missing m" } + require(MarmotMediaType.canonicalize(storedMediaType) == storedMediaType) { + "imeta m is not byte-for-byte canonical: '$storedMediaType'" + } + + return EncryptedMediaReferenceV2( + locators = locators, + ciphertextSha256 = requireHash(single["ciphertext_sha256"], "ciphertext_sha256"), + plaintextSha256 = requireHash(single["plaintext_sha256"], "plaintext_sha256"), + nonce = requireNonce(single["nonce"]), + mediaType = storedMediaType, + filename = requireNotNull(single["filename"]) { "imeta is missing filename" }, + dim = single["dim"], + thumbhash = single["thumbhash"], + ) + } + + /** + * [parseImetaTag], returning null instead of throwing. + * + * Rejection is attachment-local: the caller drops this attachment and keeps + * the caption and every other valid attachment on the same message. + */ + fun parseImetaTagOrNull(tag: Array): EncryptedMediaReferenceV2? = + try { + parseImetaTag(tag) + } catch (_: Exception) { + null + } + + private fun requireHash( + value: String?, + field: String, + ): ByteArray { + val hex = requireNotNull(value) { "imeta is missing $field" } + require(hex.length == 64 && hex.all { it in '0'..'9' || it in 'a'..'f' }) { + "$field must be 64 lowercase hex characters" + } + return hexToBytes(hex) + } + + private fun requireNonce(value: String?): ByteArray { + val hex = requireNotNull(value) { "imeta is missing nonce" } + require(hex.length == NONCE_LENGTH * 2 && hex.all { it in '0'..'9' || it in 'a'..'f' }) { + "nonce must be ${NONCE_LENGTH * 2} lowercase hex characters" + } + return hexToBytes(hex) + } + + private fun hexToBytes(hex: String) = + ByteArray(hex.length / 2) { i -> + ((hexDigit(hex[i * 2]) shl 4) or hexDigit(hex[i * 2 + 1])).toByte() + } + + private fun hexDigit(c: Char) = if (c in '0'..'9') c - '0' else c - 'a' + 10 + + private fun buildInfo( + plaintextSha256: ByteArray, + mediaType: String, + filename: String, + suffix: ByteArray, + ) = buildAad(plaintextSha256, mediaType, filename) + NUL + suffix + + private fun buildAad( + plaintextSha256: ByteArray, + mediaType: String, + filename: String, + ) = VERSION_BYTES + NUL + plaintextSha256 + NUL + mediaType.encodeToByteArray() + + NUL + filename.encodeToByteArray() +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip05PushNotifications/PushOwnerProof.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip05PushNotifications/PushOwnerProof.kt new file mode 100644 index 0000000000..8a6e9109aa --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip05PushNotifications/PushOwnerProof.kt @@ -0,0 +1,276 @@ +/* + * 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.marmot.mip05PushNotifications + +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.core.TagArray +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import com.vitorpamplona.quartz.nip01Core.crypto.EventHasher +import com.vitorpamplona.quartz.nip01Core.crypto.Nip01Crypto +import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner + +/** The two record shapes an owner proof can cover. */ +enum class PushRecordKind( + val domainTag: String, +) { + /** A token record (gossip kinds 447 / 448). */ + TOKEN("marmot-push-token-record-v1"), + + /** A token removal (gossip kind 449). */ + REMOVAL("marmot-push-token-removal-v1"), +} + +/** + * The push token owner proof — a BIP-340 signature over the id of an exact, + * UNPUBLISHED kind `451` Nostr event (`features/push-notifications.md`, + * "Owner authentication"). + * + * ## Why an event that is never published + * + * The proof needs to bind a lot of context at once — which group, which + * notification server, which relay hint, which token, and when — and a Nostr + * event id is a ready-made canonical digest over exactly that kind of tuple. + * Only the 64-byte signature travels, inside the gossip record; the event is a + * signing template and MUST NOT be sent to a relay. + * + * ## What the binding buys + * + * Because the id covers `group_id`, `server_pubkey`, `relay_hint`, the + * encrypted token and `owner_ts`, a member who merely RELAYS someone's record + * cannot move it to another group, repoint it at a different notification + * server or relay, swap the token, or restamp it. That matters because a + * record's authority comes from `owner_sig` and current membership — never from + * who happened to carry it. + * + * Note this is deliberately NOT the 104-byte [MarmotAuthorizationProof] + * envelope: the account identity proof carries its own pubkey and timestamp, + * while here both are already pinned by the record being signed over. + */ +object PushOwnerProof { + const val KIND = 451 + + /** + * The superseded event-shaped proof kind. + * + * Accepted only when verifying in a LEGACY group, and never produced. Kind + * `450` is the account identity proof's kind; push borrowed it before `451` + * was allocated, and accepting it here does not reserve it for push. + */ + const val LEGACY_KIND = 450 + + /** + * Build the tag list, in the exact order the spec fixes. + * + * Order and arity are not cosmetic — they are inside the id preimage, so a + * reordered or duplicated tag yields a different id and the signature + * simply does not verify. That is the intended failure mode. + */ + fun tags( + record: PushRecordKind, + groupIdHex: HexKey, + memberIdHex: HexKey, + leafIndex: Int, + platform: String, + serverPubKeyHex: HexKey, + tokenFingerprint: String, + ownerTsMillis: Long, + relayHint: String, + ): TagArray { + val base = + mutableListOf( + arrayOf("d", record.domainTag), + arrayOf("group_id", groupIdHex), + arrayOf("member_id", memberIdHex), + arrayOf("leaf_index", leafIndex.toString()), + arrayOf("platform", platform), + arrayOf("server_pubkey", serverPubKeyHex), + arrayOf("token_fingerprint", tokenFingerprint), + arrayOf("owner_ts", ownerTsMillis.toString()), + // A removal always encodes an empty hint; a token record carries + // the member's exact string, with no trimming or normalization — + // altering it would change the id the owner signed. + arrayOf("relay_hint", if (record == PushRecordKind.REMOVAL) "" else relayHint), + ) + if (record == PushRecordKind.TOKEN) { + base.add(arrayOf("encrypted_token_encoding", "base64")) + } + return base.toTypedArray() + } + + /** `created_at` is fixed at 0: the record's own `owner_ts` is the timestamp that counts. */ + const val CREATED_AT = 0L + + /** The id the owner signs. */ + fun eventId( + memberIdHex: HexKey, + tags: TagArray, + content: String, + ): ByteArray = EventHasher.hashIdBytes(memberIdHex, CREATED_AT, KIND, tags, content) + + /** The unsigned event an external signer is asked to sign. Never published. */ + fun signingTemplate( + tags: TagArray, + content: String, + ): EventTemplate = + EventTemplate( + createdAt = CREATED_AT, + kind = KIND, + tags = tags, + content = content, + ) + + /** + * Ask [signer] for the owner proof over a record. + * + * The returned event is validated field by field before its signature is + * copied out. An external signer — a bunker, a hardware device — is free to + * return something other than what it was asked to sign, and a substituted + * group id or server pubkey would otherwise become a proof that silently + * authorizes the wrong destination. + * + * @return the 64-byte `owner_sig`. + */ + suspend fun create( + signer: NostrSigner, + record: PushRecordKind, + groupIdHex: HexKey, + leafIndex: Int, + platform: String, + serverPubKeyHex: HexKey, + tokenFingerprint: String, + ownerTsMillis: Long, + relayHint: String = "", + encryptedTokenBase64: String = "", + ): ByteArray { + val memberIdHex = signer.pubKey + val builtTags = + tags( + record, + groupIdHex, + memberIdHex, + leafIndex, + platform, + serverPubKeyHex, + tokenFingerprint, + ownerTsMillis, + relayHint, + ) + val content = if (record == PushRecordKind.REMOVAL) "" else encryptedTokenBase64 + val signed: Event = signer.sign(signingTemplate(builtTags, content)) + + require(signed.pubKey == memberIdHex) { + "signer returned a push owner proof authored by a different account" + } + require(signed.createdAt == CREATED_AT && signed.kind == KIND && signed.content == content) { + "signer returned a different push owner proof event than requested" + } + require(tagsEqual(signed.tags, builtTags)) { + "signer altered the push owner proof tags" + } + val signature = signed.sig.hexToByteArray() + require(verify(signature, memberIdHex, builtTags, content, KIND)) { + "signer returned a push owner proof whose signature does not verify" + } + return signature + } + + /** + * Verify an `owner_sig` under [memberIdHex]. + * + * [kind] selects the proof form. A CURRENT-profile group accepts only + * [KIND]; a legacy group also accepts [LEGACY_KIND], so an upgraded member + * and a not-yet-upgraded one can stay in the same group. Producers create + * only [KIND]. + */ + fun verify( + ownerSig: ByteArray, + memberIdHex: HexKey, + tags: TagArray, + content: String, + kind: Int = KIND, + ): Boolean { + if (ownerSig.size != 64) return false + return try { + val id = EventHasher.hashIdBytes(memberIdHex, CREATED_AT, kind, tags, content) + Nip01Crypto.verify(ownerSig, id, memberIdHex.hexToByteArray()) + } catch (_: Exception) { + false + } + } + + /** + * Verify a record's proof the way a recipient must. + * + * [currentProfileGroup] decides which forms are acceptable, and the + * distinction is a security one rather than a courtesy: in a group where + * every leaf carries a `0x8009` identity proof, accepting a weaker legacy + * form would let anyone who can produce one bypass the stronger binding the + * group already guarantees. + */ + fun verifyRecord( + ownerSig: ByteArray, + record: PushRecordKind, + groupIdHex: HexKey, + memberIdHex: HexKey, + leafIndex: Int, + platform: String, + serverPubKeyHex: HexKey, + tokenFingerprint: String, + ownerTsMillis: Long, + relayHint: String = "", + encryptedTokenBase64: String = "", + currentProfileGroup: Boolean, + ): Boolean { + val builtTags = + tags( + record, + groupIdHex, + memberIdHex, + leafIndex, + platform, + serverPubKeyHex, + tokenFingerprint, + ownerTsMillis, + relayHint, + ) + val content = if (record == PushRecordKind.REMOVAL) "" else encryptedTokenBase64 + if (verify(ownerSig, memberIdHex, builtTags, content, KIND)) return true + if (currentProfileGroup) return false + return verify(ownerSig, memberIdHex, builtTags, content, LEGACY_KIND) + } + + /** Hex form, for embedding in a gossip record. */ + fun toHex(ownerSig: ByteArray): HexKey = ownerSig.toHexKey() + + private fun tagsEqual( + a: TagArray, + b: TagArray, + ): Boolean { + if (a.size != b.size) return false + for (i in a.indices) { + if (!a[i].contentEquals(b[i])) return false + } + return true + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/appComponents/EncryptedMediaV2Test.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/appComponents/EncryptedMediaV2Test.kt new file mode 100644 index 0000000000..2cf77ffe71 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/appComponents/EncryptedMediaV2Test.kt @@ -0,0 +1,305 @@ +/* + * 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.marmot.appComponents + +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import kotlin.test.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import kotlin.test.assertFalse +import kotlin.test.assertNotEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +/** + * `app-components/group-encrypted-media-v2.md` and + * `features/encrypted-media.md`. + */ +class EncryptedMediaV2Test { + private val mediaSecret = ByteArray(32) { it.toByte() } + + // --- policy component 0x800b ----------------------------------------- + + @Test + fun theReferencePolicyRoundTrips() { + val decoded = EncryptedMediaPolicyV2.decode(EncryptedMediaPolicyV2.REFERENCE.encode()) + assertEquals(EncryptedMediaPolicyV2.REFERENCE, decoded) + assertEquals("encrypted-media-v2", decoded.mediaFormat) + assertEquals(listOf("blossom-v1"), decoded.allowedLocatorKinds) + assertEquals("https://blossom.primal.net/", decoded.defaultBlobEndpoints.single().baseUrl) + } + + /** + * Endpoint order IS the upload/fetch fallback priority, so — unlike relays + * or admins — it must NOT be sorted. Two policies differing only in order + * are different canonical values. + */ + @Test + fun endpointOrderIsPartOfTheValue() { + val a = + EncryptedMediaPolicyV2( + allowedLocatorKinds = listOf("blossom-v1"), + defaultBlobEndpoints = + listOf( + BlobStoreEndpointV2("blossom-v1", "https://a.example.com/"), + BlobStoreEndpointV2("blossom-v1", "https://b.example.com/"), + ), + ) + val reversed = a.copy(defaultBlobEndpoints = a.defaultBlobEndpoints.reversed()) + assertNotEquals(a, reversed) + assertFalse(a.encode().contentEquals(reversed.encode())) + assertEquals(reversed, EncryptedMediaPolicyV2.decode(reversed.encode())) + } + + /** A decoder rejects rather than repairs: these bytes sit in signed group state. */ + @Test + fun rejectsNonCanonicalAndInvalidState() { + assertFailsWith { + EncryptedMediaPolicyV2(listOf("blossom-v1"), emptyList()) + } + assertFailsWith { + EncryptedMediaPolicyV2(emptyList(), listOf(BlobStoreEndpointV2("blossom-v1", "https://a.example.com/"))) + } + // An endpoint serving a kind the policy does not allow. + assertFailsWith { + EncryptedMediaPolicyV2(listOf("blossom-v1"), listOf(BlobStoreEndpointV2("other-v1", "https://a.example.com/"))) + } + // A duplicate is refused, not deduplicated. + assertFailsWith { + EncryptedMediaPolicyV2(listOf("blossom-v1", "blossom-v1"), listOf(BlobStoreEndpointV2("blossom-v1", "https://a.example.com/"))) + } + // media_format is a constant, not negotiation. + assertFailsWith { + EncryptedMediaPolicyV2(listOf("blossom-v1"), listOf(BlobStoreEndpointV2("blossom-v1", "https://a.example.com/")), "encrypted-media-v3") + } + // Trailing bytes are refused, not ignored. + assertFailsWith { + EncryptedMediaPolicyV2.decode(EncryptedMediaPolicyV2.REFERENCE.encode() + byteArrayOf(0)) + } + } + + @Test + fun rejectsNonNormalizedBaseUrls() { + listOf( + "ftp://a.example.com/", + "https://user@a.example.com/", + "https://a.example.com/?q=1", + "https://a.example.com/#f", + "https://A.EXAMPLE.COM/", + "https://a.example.com//double/", + "https://a.example.com/../up/", + "https:///", + ).forEach { url -> + assertFailsWith("expected '$url' to be rejected") { + EncryptedMediaPolicyV2(listOf("blossom-v1"), listOf(BlobStoreEndpointV2("blossom-v1", url))) + } + } + } + + @Test + fun rejectsInvalidLocatorKinds() { + listOf("", "Blossom-v1", "blossom_v1", "blossom v1", "a".repeat(65)).forEach { kind -> + assertFailsWith("expected '$kind' to be rejected") { + EncryptedMediaPolicyV2.requireValidLocatorKind(kind) + } + } + } + + /** Blossom fetch and upload URLs are built from `server_root`, trailing slashes gone. */ + @Test + fun buildsBlossomFallbackUrls() { + val endpoint = BlobStoreEndpointV2("blossom-v1", "https://blossom.primal.net/") + val hash = "ab".repeat(32) + assertEquals("https://blossom.primal.net/$hash", endpoint.blossomFetchUrl(hash)) + assertEquals("https://blossom.primal.net/upload", endpoint.blossomUploadUrl()) + } + + // --- content crypto --------------------------------------------------- + + @Test + fun encryptsAndDecryptsAnAttachment() { + val plaintext = "a picture of a marmot".encodeToByteArray() + val result = EncryptedMediaV2.encrypt(plaintext, mediaSecret, "image/jpeg", "marmot.jpg") + + assertEquals(12, result.nonce.size) + assertContentEquals( + plaintext, + EncryptedMediaV2.decrypt( + result.ciphertext, + mediaSecret, + result.nonce, + result.plaintextSha256, + "image/jpeg", + "marmot.jpg", + ), + ) + } + + /** + * The key is deterministic in (plaintext hash, media type, filename, epoch), + * so a resend reuses it — and the nonce MUST still be fresh, or + * ChaCha20-Poly1305 breaks outright. + */ + @Test + fun aResendReusesTheKeyButNeverTheNonce() { + val plaintext = "same file".encodeToByteArray() + val first = EncryptedMediaV2.encrypt(plaintext, mediaSecret, "image/png", "a.png") + val second = EncryptedMediaV2.encrypt(plaintext, mediaSecret, "image/png", "a.png") + + assertContentEquals( + EncryptedMediaV2.deriveFileKey(mediaSecret, first.plaintextSha256, "image/png", "a.png"), + EncryptedMediaV2.deriveFileKey(mediaSecret, second.plaintextSha256, "image/png", "a.png"), + ) + assertFalse(first.nonce.contentEquals(second.nonce), "every encryption needs a fresh nonce") + } + + /** + * The media type and filename are inside both the key info and the AAD, so + * changing either makes decryption fail rather than silently succeed with a + * different attribution. + */ + @Test + fun theMediaTypeAndFilenameAreAuthenticated() { + val plaintext = "bytes".encodeToByteArray() + val result = EncryptedMediaV2.encrypt(plaintext, mediaSecret, "image/png", "a.png") + + assertFailsWith { + EncryptedMediaV2.decrypt(result.ciphertext, mediaSecret, result.nonce, result.plaintextSha256, "image/jpeg", "a.png") + } + assertFailsWith { + EncryptedMediaV2.decrypt(result.ciphertext, mediaSecret, result.nonce, result.plaintextSha256, "image/png", "b.png") + } + } + + /** A non-canonical `m` is refused rather than normalized on the way in. */ + @Test + fun refusesANonCanonicalMediaType() { + assertFailsWith { + EncryptedMediaV2.encrypt("x".encodeToByteArray(), mediaSecret, "IMAGE/JPG", "a.jpg") + } + assertEquals("image/jpeg", MarmotMediaType.canonicalize("IMAGE/JPG")) + } + + // --- imeta references ------------------------------------------------- + + private fun reference() = + EncryptedMediaReferenceV2( + locators = listOf(MediaLocatorV2("blossom-v1", "https://blossom.primal.net/" + "ab".repeat(32))), + ciphertextSha256 = ByteArray(32) { 1 }, + plaintextSha256 = ByteArray(32) { 2 }, + nonce = ByteArray(12) { 3 }, + mediaType = "image/jpeg", + filename = "marmot.jpg", + ) + + @Test + fun anImetaTagRoundTrips() { + val parsed = EncryptedMediaV2.parseImetaTag(reference().toImetaTag()) + assertEquals("image/jpeg", parsed.mediaType) + assertEquals("marmot.jpg", parsed.filename) + assertEquals(1, parsed.locators.size) + assertEquals("blossom-v1", parsed.locators[0].kind) + assertContentEquals(ByteArray(12) { 3 }, parsed.nonce) + } + + /** + * `m`, `filename` and `plaintext_sha256` feed the key derivation, so a + * first-wins decoder and a last-wins decoder would derive DIFFERENT keys + * from the same authenticated tag. The duplicate is refused instead. + */ + @Test + fun rejectsADuplicateSingleOccurrenceField() { + val doubled = reference().toImetaTag().toMutableList().apply { add("m image/png") } + val failure = + assertFailsWith { + EncryptedMediaV2.parseImetaTag(doubled.toTypedArray()) + } + assertTrue(failure.message!!.contains("more than once")) + } + + /** Exactly `locator` may repeat. */ + @Test + fun acceptsSeveralLocators() { + val tag = + reference() + .toImetaTag() + .toMutableList() + .apply { add(1, "locator blossom-v1 https://mirror.example.com/blob") } + assertEquals(2, EncryptedMediaV2.parseImetaTag(tag.toTypedArray()).locators.size) + } + + @Test + fun rejectsBlurhashAndWrongVersion() { + val withBlurhash = reference().toImetaTag().toMutableList().apply { add("blurhash abc") } + assertNull(EncryptedMediaV2.parseImetaTagOrNull(withBlurhash.toTypedArray())) + + val v1 = reference().toImetaTag().map { if (it.startsWith("v ")) "v encrypted-media-v1" else it } + assertNull(EncryptedMediaV2.parseImetaTagOrNull(v1.toTypedArray())) + } + + @Test + fun rejectsMalformedHashesAndNonces() { + val badNonce = reference().toImetaTag().map { if (it.startsWith("nonce ")) "nonce abcd" else it } + assertNull(EncryptedMediaV2.parseImetaTagOrNull(badNonce.toTypedArray())) + + val badHash = + reference().toImetaTag().map { + if (it.startsWith("ciphertext_sha256 ")) "ciphertext_sha256 notahash" else it + } + assertNull(EncryptedMediaV2.parseImetaTagOrNull(badHash.toTypedArray())) + } + + /** A locator must be usable: an empty kind or value is not a reference. */ + @Test + fun rejectsAnEmptyOrNonUrlLocator() { + assertFailsWith { + EncryptedMediaReferenceV2( + locators = listOf(MediaLocatorV2("blossom-v1", "not-a-url")), + ciphertextSha256 = ByteArray(32), + plaintextSha256 = ByteArray(32), + nonce = ByteArray(12), + mediaType = "image/jpeg", + filename = "a.jpg", + ) + } + assertFailsWith { + EncryptedMediaReferenceV2( + locators = emptyList(), + ciphertextSha256 = ByteArray(32), + plaintextSha256 = ByteArray(32), + nonce = ByteArray(12), + mediaType = "image/jpeg", + filename = "a.jpg", + ) + } + } + + @Test + fun theCiphertextHashIsTheBlobContentId() { + val result = EncryptedMediaV2.encrypt("bytes".encodeToByteArray(), mediaSecret, "image/png", "a.png") + val endpoint = BlobStoreEndpointV2("blossom-v1", "https://blossom.primal.net/") + assertEquals( + "https://blossom.primal.net/" + result.ciphertextSha256.toHexKey(), + endpoint.blossomFetchUrl(result.ciphertextSha256.toHexKey()), + ) + } +} diff --git a/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/mip05PushNotifications/PushOwnerProofTest.kt b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/mip05PushNotifications/PushOwnerProofTest.kt new file mode 100644 index 0000000000..532ed893a1 --- /dev/null +++ b/quartz/src/commonTest/kotlin/com/vitorpamplona/quartz/marmot/mip05PushNotifications/PushOwnerProofTest.kt @@ -0,0 +1,207 @@ +/* + * 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.marmot.mip05PushNotifications + +import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray +import com.vitorpamplona.quartz.nip01Core.core.toHexKey +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +/** + * `features/push-notifications.md`, "Owner authentication". + * + * The spec publishes a complete removal fixture: the canonical NIP-01 + * serialization, the resulting event id, and the `owner_sig` a known secret + * produces over it. Reproducing that id is what proves our tag order, arity and + * value formatting match — get any of them wrong and the id changes, the + * signature stops verifying, and every peer silently drops the record. + */ +class PushOwnerProofTest { + private val member = "f9308a019258c31049344f85f89d5229b531c845836f99b08601f113bce036f9" + private val groupId = "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f" + private val serverPubKey = "2f8bde4d1a07209355b4a7250a5c5128e88b84bddc619ab7cba8d569b240efe4" + private val fingerprint = "sha256:000102030405060708090a0b" + private val ownerTs = 1700000000000L + + private fun removalTags() = + PushOwnerProof.tags( + record = PushRecordKind.REMOVAL, + groupIdHex = groupId, + memberIdHex = member, + leafIndex = 3, + platform = "apns", + serverPubKeyHex = serverPubKey, + tokenFingerprint = fingerprint, + ownerTsMillis = ownerTs, + relayHint = "", + ) + + @Test + fun matchesTheSpecPublishedRemovalEventId() { + assertEquals( + "be12f4d029d3cac4034251949d6c013ff18eae00870e199012c7a97e8960b7a2", + PushOwnerProof.eventId(member, removalTags(), "").toHexKey(), + ) + } + + @Test + fun verifiesTheSpecPublishedSignature() { + val ownerSig = + ( + "04c3588a6533399aeaebb6c596fab896186dd0af1f9724f2926d984d2876490c" + + "76e1d149127e0fa697d7f19a0807aa373e942f0eb33edc63071567f274ce3bec" + ).hexToByteArray() + assertTrue( + PushOwnerProof.verifyRecord( + ownerSig = ownerSig, + record = PushRecordKind.REMOVAL, + groupIdHex = groupId, + memberIdHex = member, + leafIndex = 3, + platform = "apns", + serverPubKeyHex = serverPubKey, + tokenFingerprint = fingerprint, + ownerTsMillis = ownerTs, + currentProfileGroup = true, + ), + ) + } + + /** + * The point of binding all that context: a member who merely RELAYS a + * record cannot move it, repoint it, or restamp it. Each of these changes + * one signed field, so the id changes and the signature stops verifying. + */ + @Test + fun aRelayingMemberCannotRepointTheRecord() { + val ownerSig = + ( + "04c3588a6533399aeaebb6c596fab896186dd0af1f9724f2926d984d2876490c" + + "76e1d149127e0fa697d7f19a0807aa373e942f0eb33edc63071567f274ce3bec" + ).hexToByteArray() + + fun verifyWith( + gid: String = groupId, + server: String = serverPubKey, + ts: Long = ownerTs, + leaf: Int = 3, + ) = PushOwnerProof.verifyRecord( + ownerSig = ownerSig, + record = PushRecordKind.REMOVAL, + groupIdHex = gid, + memberIdHex = member, + leafIndex = leaf, + platform = "apns", + serverPubKeyHex = server, + tokenFingerprint = fingerprint, + ownerTsMillis = ts, + currentProfileGroup = true, + ) + + assertTrue(verifyWith(), "the unmodified record still verifies") + assertFalse(verifyWith(gid = "ff".repeat(32)), "moved to another group") + assertFalse(verifyWith(server = "ee".repeat(32)), "repointed at another server") + assertFalse(verifyWith(ts = ownerTs + 1), "restamped") + assertFalse(verifyWith(leaf = 4), "attributed to another leaf") + } + + /** + * A current-profile group refuses the legacy kind-450 form. Accepting it + * would let anyone able to produce the weaker proof bypass the stronger + * binding such a group already guarantees. + */ + @Test + fun aCurrentProfileGroupRefusesTheLegacyProofForm() { + // Not a real legacy signature — the point is which kind is tried, and a + // current-profile group must not fall back at all. + val notASignature = ByteArray(64) + assertFalse( + PushOwnerProof.verifyRecord( + ownerSig = notASignature, + record = PushRecordKind.REMOVAL, + groupIdHex = groupId, + memberIdHex = member, + leafIndex = 3, + platform = "apns", + serverPubKeyHex = serverPubKey, + tokenFingerprint = fingerprint, + ownerTsMillis = ownerTs, + currentProfileGroup = true, + ), + ) + } + + /** A removal carries no relay hint and no token, whatever the caller passes. */ + @Test + fun aRemovalAlwaysEncodesAnEmptyRelayHint() { + val withHint = + PushOwnerProof.tags( + record = PushRecordKind.REMOVAL, + groupIdHex = groupId, + memberIdHex = member, + leafIndex = 3, + platform = "apns", + serverPubKeyHex = serverPubKey, + tokenFingerprint = fingerprint, + ownerTsMillis = ownerTs, + relayHint = "wss://relay.example.com", + ) + assertEquals(listOf("relay_hint", ""), withHint.first { it[0] == "relay_hint" }.toList()) + assertFalse(withHint.any { it[0] == "encrypted_token_encoding" }) + } + + /** A token record adds the encoding tag; the order is fixed by the spec. */ + @Test + fun aTokenRecordCarriesTheEncodingTagLast() { + val tokenTags = + PushOwnerProof.tags( + record = PushRecordKind.TOKEN, + groupIdHex = groupId, + memberIdHex = member, + leafIndex = 0, + platform = "fcm", + serverPubKeyHex = serverPubKey, + tokenFingerprint = fingerprint, + ownerTsMillis = ownerTs, + relayHint = "wss://relay.example.com", + ) + assertEquals( + listOf( + "d", + "group_id", + "member_id", + "leaf_index", + "platform", + "server_pubkey", + "token_fingerprint", + "owner_ts", + "relay_hint", + "encrypted_token_encoding", + ), + tokenTags.map { it[0] }, + ) + assertEquals("marmot-push-token-record-v1", tokenTags[0][1]) + // Canonical decimal ASCII: zero is "0", never "00" or "". + assertEquals("0", tokenTags.first { it[0] == "leaf_index" }[1]) + } +}