From 6cbd56c53de46a67b7d1b064bcfd34c16f2d6dac Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 3 Apr 2026 15:23:31 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20add=20Marmot=20Phase=202=20=E2=80=94=20?= =?UTF-8?q?encryption,=20group=20state,=20and=20lifecycle=20helpers?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements encryption/decryption, gift-wrap flows, and utility logic for the Marmot MLS-over-Nostr protocol, building on the Phase 1 event defs. New files: - ChaCha20Poly1305: standard AEAD (12-byte nonce) for MIP-03 outer layer - GroupEventEncryption: encrypt/decrypt GroupEvent content with MLS key - WelcomeGiftWrap: NIP-59 gift-wrap pipeline for Welcome messages - KeyPackageUtils: selection policy, rotation, migration helpers - CommitOrdering: deterministic conflict resolution for MLS commits - MarmotFilters: relay subscription filter builders for all Marmot events - TokenEncryption: ECDH + HKDF + ChaCha20 token encryption for MIP-05 https://claude.ai/code/session_01Ee5wmBAXwN46AJYUGYA9RQ --- .../quartz/marmot/MarmotFilters.kt | 141 +++++++++++++ .../mip00KeyPackages/KeyPackageUtils.kt | 127 +++++++++++ .../marmot/mip02Welcome/WelcomeGiftWrap.kt | 87 ++++++++ .../mip03GroupMessages/CommitOrdering.kt | 123 +++++++++++ .../GroupEventEncryption.kt | 96 +++++++++ .../mip05PushNotifications/TokenEncryption.kt | 198 ++++++++++++++++++ .../crypto/ChaCha20Poly1305.kt | 160 ++++++++++++++ 7 files changed, 932 insertions(+) create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/MarmotFilters.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip00KeyPackages/KeyPackageUtils.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip02Welcome/WelcomeGiftWrap.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip03GroupMessages/CommitOrdering.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip03GroupMessages/GroupEventEncryption.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip05PushNotifications/TokenEncryption.kt create mode 100644 quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip44Encryption/crypto/ChaCha20Poly1305.kt diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/MarmotFilters.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/MarmotFilters.kt new file mode 100644 index 000000000..75cf73c47 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/MarmotFilters.kt @@ -0,0 +1,141 @@ +/* + * 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 + +import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageEvent +import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageUtils +import com.vitorpamplona.quartz.marmot.mip03GroupMessages.GroupEvent +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.filters.Filter +import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent + +/** + * Relay subscription filter builders for Marmot protocol events. + * + * Provides pre-configured [Filter] instances for subscribing to the various + * Marmot event types on Nostr relays. + */ +object MarmotFilters { + /** + * Filter for KeyPackages by author (kind:30443). + * Used to discover a user's available KeyPackages for group invitations. + * + * {kinds: [30443], authors: [pubkey]} + */ + fun keyPackagesByAuthor(pubkey: HexKey): Filter = + Filter( + kinds = listOf(KeyPackageEvent.KIND), + authors = listOf(pubkey), + ) + + /** + * Filter for KeyPackages by multiple authors. + * Used when inviting multiple users to a group at once. + * + * {kinds: [30443], authors: [pubkey1, pubkey2, ...]} + */ + fun keyPackagesByAuthors(pubkeys: List): Filter = + Filter( + kinds = listOf(KeyPackageEvent.KIND), + authors = pubkeys, + ) + + /** + * Filter for a specific KeyPackage by its ref (kind:30443, #i tag). + * Used to look up a specific KeyPackage by its KeyPackageRef hash. + * + * {kinds: [30443], #i: [keyPackageRef]} + */ + fun keyPackageByRef(keyPackageRef: HexKey): Filter = + Filter( + kinds = listOf(KeyPackageEvent.KIND), + tags = mapOf("i" to listOf(keyPackageRef)), + ) + + /** + * Filter for GroupEvents by group ID (kind:445, #h tag). + * Used to subscribe to all messages and commits for a specific group. + * + * {kinds: [445], #h: [nostrGroupId]} + */ + fun groupEventsByGroupId(nostrGroupId: HexKey): Filter = + Filter( + kinds = listOf(GroupEvent.KIND), + tags = mapOf("h" to listOf(nostrGroupId)), + ) + + /** + * Filter for GroupEvents by group ID with a time range. + * Used to catch up on missed messages since a given timestamp. + * + * {kinds: [445], #h: [nostrGroupId], since: timestamp} + */ + fun groupEventsByGroupIdSince( + nostrGroupId: HexKey, + since: Long, + ): Filter = + Filter( + kinds = listOf(GroupEvent.KIND), + tags = mapOf("h" to listOf(nostrGroupId)), + since = since, + ) + + /** + * Filter for NIP-59 gift wraps addressed to a user (kind:1059). + * Welcome messages (kind:444) are delivered inside gift wraps. + * This reuses the standard NIP-59 subscription pattern. + * + * {kinds: [1059], #p: [recipientPubKey]} + */ + fun giftWrapsForUser(recipientPubKey: HexKey): Filter = + Filter( + kinds = listOf(GiftWrapEvent.KIND), + tags = mapOf("p" to listOf(recipientPubKey)), + ) + + /** + * Filter for NIP-59 gift wraps since a given timestamp. + * Used to catch up on missed Welcome messages. + * + * {kinds: [1059], #p: [recipientPubKey], since: timestamp} + */ + fun giftWrapsForUserSince( + recipientPubKey: HexKey, + since: Long, + ): Filter = + Filter( + kinds = listOf(GiftWrapEvent.KIND), + tags = mapOf("p" to listOf(recipientPubKey)), + since = since, + ) + + /** + * Filter for KeyPackages during migration (both kind:443 and kind:30443). + * Used during the transition period from legacy to addressable KeyPackages. + * + * {kinds: [30443, 443], authors: [pubkey]} + */ + fun keyPackagesMigration(pubkey: HexKey): Filter = + Filter( + kinds = KeyPackageUtils.migrationKinds(), + authors = listOf(pubkey), + ) +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip00KeyPackages/KeyPackageUtils.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip00KeyPackages/KeyPackageUtils.kt new file mode 100644 index 000000000..c4522057c --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip00KeyPackages/KeyPackageUtils.kt @@ -0,0 +1,127 @@ +/* + * 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.mip00KeyPackages + +import com.vitorpamplona.quartz.marmot.mip00KeyPackages.tags.EncodingTag +import com.vitorpamplona.quartz.nip01Core.core.Event +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate + +/** + * Utility functions for KeyPackage lifecycle management (MIP-00). + * + * Covers: + * - Selection policy: prefer non-last-resort, highest created_at, validate encoding + * - Rotation: publish new kind:30443 under same d-tag after joining a group + * - Migration: support both kind:443 (legacy) and kind:30443 (addressable) during transition + */ +object KeyPackageUtils { + /** Legacy non-addressable KeyPackage kind (pre-migration) */ + const val LEGACY_KIND = 443 + + /** Current addressable KeyPackage kind */ + const val CURRENT_KIND = KeyPackageEvent.KIND // 30443 + + /** Default d-tag slot for primary KeyPackage */ + const val PRIMARY_SLOT = "0" + + /** Maximum number of d-tag slots a user should maintain */ + const val MAX_SLOTS = 10 + + /** + * Selects the best KeyPackage from a set of candidates for a given user. + * + * Selection policy (MIP-00): + * 1. Filter out invalid KeyPackages (bad encoding, missing required fields) + * 2. Prefer non-last-resort KeyPackages (those with multiple slots available) + * 3. Among valid candidates, prefer highest created_at (most recent) + * + * @param candidates list of KeyPackageEvents to choose from + * @param lastResortDTag d-tag of the user's last-resort KeyPackage (if known) + * @return the best KeyPackage, or null if no valid candidates + */ + fun selectBest( + candidates: List, + lastResortDTag: String? = null, + ): KeyPackageEvent? { + val valid = candidates.filter { isValid(it) } + if (valid.isEmpty()) return null + + // Prefer non-last-resort KeyPackages + val nonLastResort = valid.filter { it.dTag() != lastResortDTag } + val pool = nonLastResort.ifEmpty { valid } + + // Select the most recent + return pool.maxByOrNull { it.createdAt } + } + + /** + * Validates a KeyPackage event has required fields and proper encoding. + */ + fun isValid(event: KeyPackageEvent): Boolean = + event.encoding() == EncodingTag.BASE64 && + event.content.isNotEmpty() && + !event.keyPackageRef().isNullOrEmpty() && + !event.mlsCiphersuite().isNullOrEmpty() + + /** + * Builds a rotated KeyPackage for the same d-tag slot. + * + * After joining a group (processing a Welcome), a member MUST rotate their + * KeyPackage to prevent reuse of the consumed init_key material. + * + * @param newKeyPackageBase64 the new MLS KeyPackage content + * @param dTagSlot the d-tag slot to rotate (reuses the same slot) + * @param newKeyPackageRef the KeyPackageRef of the new KeyPackage + * @param relays relay URLs where this KeyPackage should be published + * @param ciphersuite MLS ciphersuite identifier + * @param clientName optional client name + * @return a new KeyPackageEvent that replaces the old one at the same d-tag + */ + fun buildRotation( + newKeyPackageBase64: String, + dTagSlot: String, + newKeyPackageRef: HexKey, + relays: List, + ciphersuite: String = "0x0001", + clientName: String? = null, + ): EventTemplate = + KeyPackageEvent.build( + keyPackageBase64 = newKeyPackageBase64, + dTagSlot = dTagSlot, + keyPackageRef = newKeyPackageRef, + relays = relays, + ciphersuite = ciphersuite, + clientName = clientName, + ) + + /** + * Checks whether an event is a KeyPackage (either legacy kind:443 or current kind:30443). + * Used during the migration period. + */ + fun isKeyPackageKind(event: Event): Boolean = event.kind == CURRENT_KIND || event.kind == LEGACY_KIND + + /** + * Returns the list of kinds to query during migration (both legacy and current). + */ + fun migrationKinds(): List = listOf(CURRENT_KIND, LEGACY_KIND) +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip02Welcome/WelcomeGiftWrap.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip02Welcome/WelcomeGiftWrap.kt new file mode 100644 index 000000000..41c56b8e1 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip02Welcome/WelcomeGiftWrap.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.marmot.mip02Welcome + +import com.vitorpamplona.quartz.nip01Core.core.HexKey +import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl +import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner +import com.vitorpamplona.quartz.nip59Giftwrap.rumors.Rumor +import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent +import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent +import com.vitorpamplona.quartz.utils.TimeUtils + +/** + * Composes the NIP-59 gift wrap pipeline for Marmot Welcome messages (MIP-02). + * + * The delivery flow: + * WelcomeEvent (unsigned rumor, kind:444) + * → SealedRumorEvent (kind:13, encrypted with sender's key) + * → GiftWrapEvent (kind:1059, encrypted with ephemeral key to recipient) + * + * CRITICAL: The Commit that adds the new member MUST be confirmed by relays + * BEFORE calling [wrapForRecipient], to prevent MLS state forks. + */ +object WelcomeGiftWrap { + /** + * Wraps a WelcomeEvent through the full NIP-59 gift wrap pipeline. + * + * @param welcomeBase64 base64-encoded MLS Welcome message + * @param keyPackageEventId event ID of the KeyPackage consumed for this invitation + * @param relays relays where the new member should look for GroupEvents + * @param recipientPubKey public key of the new member being invited + * @param signer the sender's signer (used to seal the rumor) + * @param createdAt timestamp for the welcome event (defaults to now) + * @return GiftWrapEvent ready to publish to relays + */ + suspend fun wrapForRecipient( + welcomeBase64: String, + keyPackageEventId: HexKey, + relays: List, + recipientPubKey: HexKey, + signer: NostrSigner, + createdAt: Long = TimeUtils.now(), + ): GiftWrapEvent { + // Step 1: Build the WelcomeEvent template and sign it + val welcomeTemplate = + WelcomeEvent.build( + welcomeBase64 = welcomeBase64, + keyPackageEventId = keyPackageEventId, + relays = relays, + createdAt = createdAt, + ) + val welcomeEvent: WelcomeEvent = signer.sign(welcomeTemplate) + + // Step 2: Create a Rumor from the signed event and seal it (kind:13) + val rumor = Rumor.create(welcomeEvent) + val sealedRumor = + SealedRumorEvent.create( + rumor = rumor, + encryptTo = recipientPubKey, + signer = signer, + ) + + // Step 3: Gift wrap (kind:1059) with an ephemeral key to the recipient + return GiftWrapEvent.create( + event = sealedRumor, + recipientPubKey = recipientPubKey, + ) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip03GroupMessages/CommitOrdering.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip03GroupMessages/CommitOrdering.kt new file mode 100644 index 000000000..614979a78 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip03GroupMessages/CommitOrdering.kt @@ -0,0 +1,123 @@ +/* + * 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.mip03GroupMessages + +/** + * Deterministic commit conflict resolution for MLS over Nostr (MIP-03). + * + * When multiple members submit Commits for the same epoch, exactly one must win: + * 1. Lowest created_at timestamp wins + * 2. If timestamps are equal, lexicographically smallest event id wins + * 3. All other competing Commits for that epoch are discarded + * + * This ensures all group members converge on the same group state without + * requiring a central coordinator. + */ +object CommitOrdering { + /** + * Comparator for ordering GroupEvents deterministically. + * The "winner" (lowest value) should be applied; all others are discarded. + */ + val comparator: Comparator = + compareBy { it.createdAt } + .thenBy { it.id } + + /** + * Selects the winning Commit from a set of competing Commits for the same epoch. + * + * @param commits competing Commits for the same MLS epoch + * @return the winning Commit that should be applied, or null if empty + */ + fun selectWinner(commits: List): GroupEvent? { + if (commits.isEmpty()) return null + return commits.minWithOrNull(comparator) + } + + /** + * Determines if a given commit is the winner among competing commits. + * + * @param candidate the commit to check + * @param competitors all competing commits for the same epoch (including candidate) + * @return true if candidate is the winning commit + */ + fun isWinner( + candidate: GroupEvent, + competitors: List, + ): Boolean { + val winner = selectWinner(competitors) ?: return false + return winner.id == candidate.id + } + + /** + * Tracks pending commits per epoch and resolves conflicts. + * + * Accumulate commits as they arrive from relays, then call [resolve] + * to determine which commit wins for each epoch. + */ + class EpochCommitTracker { + private val pendingByEpoch = mutableMapOf>() + + /** + * Adds a commit for a given epoch. + * + * @param epoch the MLS epoch number this commit targets + * @param commit the GroupEvent containing the commit + */ + fun addCommit( + epoch: Long, + commit: GroupEvent, + ) { + pendingByEpoch.getOrPut(epoch) { mutableListOf() }.add(commit) + } + + /** + * Returns pending commits for a specific epoch. + */ + fun pendingForEpoch(epoch: Long): List = pendingByEpoch[epoch] ?: emptyList() + + /** + * Resolves the winning commit for a specific epoch. + * + * @param epoch the MLS epoch to resolve + * @return the winning commit, or null if no commits exist for this epoch + */ + fun resolve(epoch: Long): GroupEvent? = selectWinner(pendingByEpoch[epoch] ?: emptyList()) + + /** + * Clears pending commits for an epoch after it has been resolved. + */ + fun clearEpoch(epoch: Long) { + pendingByEpoch.remove(epoch) + } + + /** + * Returns all epochs that have pending commits. + */ + fun pendingEpochs(): Set = pendingByEpoch.keys.toSet() + + /** + * Clears all pending state. + */ + fun clear() { + pendingByEpoch.clear() + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip03GroupMessages/GroupEventEncryption.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip03GroupMessages/GroupEventEncryption.kt new file mode 100644 index 000000000..70794850a --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip03GroupMessages/GroupEventEncryption.kt @@ -0,0 +1,96 @@ +/* + * 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.mip03GroupMessages + +import com.vitorpamplona.quartz.nip44Encryption.crypto.ChaCha20Poly1305 +import com.vitorpamplona.quartz.utils.RandomInstance +import kotlin.io.encoding.Base64 +import kotlin.io.encoding.ExperimentalEncodingApi + +/** + * Handles the outer ChaCha20-Poly1305 encryption layer for Marmot GroupEvents (MIP-03). + * + * The encryption flow: + * Encrypt: content = base64(randomNonce(12) || ChaCha20-Poly1305.encrypt(key, nonce, mlsMessageBytes, aad="")) + * Decrypt: decode base64, split nonce (first 12 bytes) from ciphertext+tag, decrypt with empty AAD + * + * The key is derived from MLS-Exporter("marmot", "group-event", 32) by the MLS engine. + * Since the MLS engine is not yet integrated, this helper accepts the 32-byte key as a parameter. + */ +object GroupEventEncryption { + private val EMPTY_AAD = ByteArray(0) + + /** + * Encrypts an MLS message for a GroupEvent. + * + * @param mlsMessageBytes the raw MLS message bytes to encrypt + * @param groupKey 32-byte key derived from MLS-Exporter("marmot", "group-event", 32) + * @return base64-encoded string containing nonce(12) || ciphertext || tag(16) + */ + @OptIn(ExperimentalEncodingApi::class) + fun encrypt( + mlsMessageBytes: ByteArray, + groupKey: ByteArray, + ): String { + require(groupKey.size == GroupEvent.EXPORTER_KEY_LENGTH) { + "Group key must be ${GroupEvent.EXPORTER_KEY_LENGTH} bytes" + } + + val nonce = RandomInstance.bytes(GroupEvent.NONCE_LENGTH) + val ciphertextWithTag = ChaCha20Poly1305.encrypt(mlsMessageBytes, EMPTY_AAD, nonce, groupKey) + + // Prepend nonce to ciphertext+tag + val payload = ByteArray(nonce.size + ciphertextWithTag.size) + nonce.copyInto(payload) + ciphertextWithTag.copyInto(payload, nonce.size) + + return Base64.encode(payload) + } + + /** + * Decrypts a GroupEvent's encrypted content. + * + * @param encryptedContentBase64 base64-encoded content from the GroupEvent + * @param groupKey 32-byte key derived from MLS-Exporter("marmot", "group-event", 32) + * @return decrypted MLS message bytes + * @throws IllegalStateException if authentication fails + * @throws IllegalArgumentException if content is malformed + */ + @OptIn(ExperimentalEncodingApi::class) + fun decrypt( + encryptedContentBase64: String, + groupKey: ByteArray, + ): ByteArray { + require(groupKey.size == GroupEvent.EXPORTER_KEY_LENGTH) { + "Group key must be ${GroupEvent.EXPORTER_KEY_LENGTH} bytes" + } + + val payload = Base64.decode(encryptedContentBase64) + require(payload.size >= GroupEvent.MIN_CONTENT_LENGTH) { + "Payload too short: ${payload.size} bytes, minimum ${GroupEvent.MIN_CONTENT_LENGTH}" + } + + val nonce = payload.copyOfRange(0, GroupEvent.NONCE_LENGTH) + val ciphertextWithTag = payload.copyOfRange(GroupEvent.NONCE_LENGTH, payload.size) + + return ChaCha20Poly1305.decrypt(ciphertextWithTag, EMPTY_AAD, nonce, groupKey) + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip05PushNotifications/TokenEncryption.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip05PushNotifications/TokenEncryption.kt new file mode 100644 index 000000000..1229021e6 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/marmot/mip05PushNotifications/TokenEncryption.kt @@ -0,0 +1,198 @@ +/* + * 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.marmot.mip05PushNotifications.tags.TokenTag +import com.vitorpamplona.quartz.nip44Encryption.crypto.ChaCha20Poly1305 +import com.vitorpamplona.quartz.utils.RandomInstance +import com.vitorpamplona.quartz.utils.Secp256k1Instance +import com.vitorpamplona.quartz.utils.mac.MacInstance +import com.vitorpamplona.quartz.utils.sha256.sha256 +import kotlin.io.encoding.Base64 +import kotlin.io.encoding.ExperimentalEncodingApi + +/** + * Handles EncryptedToken creation and decryption for Marmot push notifications (MIP-05). + * + * EncryptedToken format (280 bytes total): + * ephemeral_pubkey(32) || nonce(12) || ciphertext(236 = 220 plaintext + 16 tag) + * + * Token payload (220 bytes, padded): + * platform(1) || token_length(2 BE) || device_token(N) || random_padding(220-3-N) + * + * Key derivation: + * 1. ECDH: shared_point = ephemeral_privkey * server_pubkey + * 2. shared_x = sha256(shared_point) (x-coordinate as shared secret) + * 3. PRK = HKDF-Extract(salt="mip05-v1", IKM=shared_x) + * 4. encryption_key = HKDF-Expand(PRK, info="mip05-token-encryption", 32) + * 5. Encrypt padded payload with ChaCha20-Poly1305(key, nonce, payload, aad="") + * + * Platform values: 0x01 = APNs, 0x02 = FCM + */ +object TokenEncryption { + private const val PADDED_PAYLOAD_SIZE = 220 + private const val NONCE_SIZE = 12 + private const val PUBKEY_SIZE = 32 + private const val HEADER_SIZE = 3 // platform(1) + token_length(2) + private const val MAX_TOKEN_SIZE = PADDED_PAYLOAD_SIZE - HEADER_SIZE + + private val HKDF_SALT = "mip05-v1".encodeToByteArray() + private val HKDF_INFO = "mip05-token-encryption".encodeToByteArray() + private val EMPTY_AAD = ByteArray(0) + + const val PLATFORM_APNS: Byte = 0x01 + const val PLATFORM_FCM: Byte = 0x02 + + /** + * Encrypts a device token for a notification server. + * + * @param platform platform identifier (PLATFORM_APNS or PLATFORM_FCM) + * @param deviceToken raw device token bytes + * @param serverPubKey 32-byte notification server public key + * @return base64-encoded EncryptedToken (280 bytes when decoded) + */ + @OptIn(ExperimentalEncodingApi::class) + fun encrypt( + platform: Byte, + deviceToken: ByteArray, + serverPubKey: ByteArray, + ): String { + require(deviceToken.size <= MAX_TOKEN_SIZE) { + "Device token too large: ${deviceToken.size} bytes, max $MAX_TOKEN_SIZE" + } + require(serverPubKey.size == PUBKEY_SIZE) { "Server pubkey must be $PUBKEY_SIZE bytes" } + + // Build padded payload: platform(1) || token_length(2 BE) || token || random_padding + val payload = ByteArray(PADDED_PAYLOAD_SIZE) + payload[0] = platform + payload[1] = (deviceToken.size ushr 8 and 0xFF).toByte() + payload[2] = (deviceToken.size and 0xFF).toByte() + deviceToken.copyInto(payload, HEADER_SIZE) + // Fill remaining bytes with random padding + val paddingStart = HEADER_SIZE + deviceToken.size + val padding = RandomInstance.bytes(PADDED_PAYLOAD_SIZE - paddingStart) + padding.copyInto(payload, paddingStart) + + // Generate ephemeral keypair for ECDH + val ephemeralPrivKey = RandomInstance.bytes(32) + val ephemeralPubKey = Secp256k1Instance.compressedPubKeyFor(ephemeralPrivKey) + + // ECDH: shared_x = sha256(ephemeral_privkey * server_pubkey) + val sharedPoint = Secp256k1Instance.pubKeyTweakMulCompact(serverPubKey, ephemeralPrivKey) + val sharedX = sha256(sharedPoint) + + // HKDF-Extract then Expand to get encryption key + val encryptionKey = hkdfDeriveKey(sharedX) + + // Encrypt with ChaCha20-Poly1305 + val nonce = RandomInstance.bytes(NONCE_SIZE) + val ciphertextWithTag = ChaCha20Poly1305.encrypt(payload, EMPTY_AAD, nonce, encryptionKey) + + // Assemble: ephemeral_pubkey(32) || nonce(12) || ciphertext+tag(236) + val result = ByteArray(TokenTag.ENCRYPTED_TOKEN_SIZE) + ephemeralPubKey.copyInto(result, 0) + nonce.copyInto(result, PUBKEY_SIZE) + ciphertextWithTag.copyInto(result, PUBKEY_SIZE + NONCE_SIZE) + + return Base64.encode(result) + } + + /** + * Decrypts an EncryptedToken using the notification server's private key. + * + * @param encryptedTokenBase64 base64-encoded EncryptedToken + * @param serverPrivKey 32-byte notification server private key + * @return decoded token info + * @throws IllegalStateException if authentication fails + */ + @OptIn(ExperimentalEncodingApi::class) + fun decrypt( + encryptedTokenBase64: String, + serverPrivKey: ByteArray, + ): DecryptedToken { + val data = Base64.decode(encryptedTokenBase64) + require(data.size == TokenTag.ENCRYPTED_TOKEN_SIZE) { + "EncryptedToken must be ${TokenTag.ENCRYPTED_TOKEN_SIZE} bytes, got ${data.size}" + } + + // Parse components + val ephemeralPubKey = data.copyOfRange(0, PUBKEY_SIZE) + val nonce = data.copyOfRange(PUBKEY_SIZE, PUBKEY_SIZE + NONCE_SIZE) + val ciphertextWithTag = data.copyOfRange(PUBKEY_SIZE + NONCE_SIZE, data.size) + + // ECDH: shared_x = sha256(server_privkey * ephemeral_pubkey) + val sharedPoint = Secp256k1Instance.pubKeyTweakMulCompact(ephemeralPubKey, serverPrivKey) + val sharedX = sha256(sharedPoint) + + // Derive encryption key + val encryptionKey = hkdfDeriveKey(sharedX) + + // Decrypt + val payload = ChaCha20Poly1305.decrypt(ciphertextWithTag, EMPTY_AAD, nonce, encryptionKey) + + // Parse payload: platform(1) || token_length(2 BE) || token || padding + val platform = payload[0] + val tokenLength = ((payload[1].toInt() and 0xFF) shl 8) or (payload[2].toInt() and 0xFF) + require(tokenLength in 0..MAX_TOKEN_SIZE) { "Invalid token length: $tokenLength" } + + val deviceToken = payload.copyOfRange(HEADER_SIZE, HEADER_SIZE + tokenLength) + + return DecryptedToken(platform, deviceToken) + } + + /** + * HKDF-Extract(salt="mip05-v1", IKM=sharedX) then HKDF-Expand(PRK, info="mip05-token-encryption", 32). + */ + private fun hkdfDeriveKey(sharedX: ByteArray): ByteArray { + // HKDF-Extract: PRK = HMAC-SHA256(salt, IKM) + val extractMac = MacInstance("HmacSHA256", HKDF_SALT) + extractMac.update(sharedX) + val prk = extractMac.doFinal() + + // HKDF-Expand: T(1) = HMAC-SHA256(PRK, info || 0x01), take first 32 bytes + val expandMac = MacInstance("HmacSHA256", prk) + expandMac.update(HKDF_INFO) + expandMac.update(0x01.toByte()) + return expandMac.doFinal() + } + + /** + * Result of decrypting an EncryptedToken. + */ + data class DecryptedToken( + /** Platform identifier: [PLATFORM_APNS] or [PLATFORM_FCM] */ + val platform: Byte, + /** Raw device token bytes */ + val deviceToken: ByteArray, + ) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is DecryptedToken) return false + return platform == other.platform && deviceToken.contentEquals(other.deviceToken) + } + + override fun hashCode(): Int { + var result = platform.toInt() + result = 31 * result + deviceToken.contentHashCode() + return result + } + } +} diff --git a/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip44Encryption/crypto/ChaCha20Poly1305.kt b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip44Encryption/crypto/ChaCha20Poly1305.kt new file mode 100644 index 000000000..241ea83f6 --- /dev/null +++ b/quartz/src/commonMain/kotlin/com/vitorpamplona/quartz/nip44Encryption/crypto/ChaCha20Poly1305.kt @@ -0,0 +1,160 @@ +/* + * 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.nip44Encryption.crypto + +/** + * Standard ChaCha20-Poly1305 AEAD (RFC 8439) with 12-byte nonces. + * + * Used by the Marmot protocol (MIP-03) for outer encryption of GroupEvents. + * Unlike [XChaCha20Poly1305] which uses 24-byte nonces via HChaCha20, + * this operates directly with the IETF ChaCha20 variant. + * + * Construction: + * 1. Generate Poly1305 OTK from ChaCha20 block 0 + * 2. Encrypt with ChaCha20 starting at counter 1 + * 3. Compute Poly1305 tag over: pad16(ad) || pad16(ciphertext) || len(ad) || len(ct) + */ +object ChaCha20Poly1305 { + private const val TAG_SIZE = 16 + + /** + * AEAD encrypt. + * + * @param plaintext message to encrypt + * @param ad associated data (authenticated but not encrypted) + * @param nonce 12-byte nonce + * @param key 32-byte key + * @return ciphertext || 16-byte tag + */ + fun encrypt( + plaintext: ByteArray, + ad: ByteArray, + nonce: ByteArray, + key: ByteArray, + ): ByteArray { + require(nonce.size == 12) { "Nonce must be 12 bytes" } + require(key.size == 32) { "Key must be 32 bytes" } + + // Step 1: Generate Poly1305 one-time key + val polyKey = ChaCha20Core.chaCha20PolyKey(key, nonce) + + // Step 2: Encrypt plaintext with counter starting at 1 + val ciphertext = ChaCha20Core.chaCha20Xor(plaintext, key, nonce, counter = 1) + + // Step 3: Compute tag + val tag = computeTag(polyKey, ad, ciphertext) + + // Step 4: Return ciphertext || tag + val result = ByteArray(ciphertext.size + TAG_SIZE) + ciphertext.copyInto(result) + tag.copyInto(result, ciphertext.size) + return result + } + + /** + * AEAD decrypt. + * + * @param ciphertextWithTag ciphertext || 16-byte tag + * @param ad associated data + * @param nonce 12-byte nonce + * @param key 32-byte key + * @return plaintext, or throws on authentication failure + */ + fun decrypt( + ciphertextWithTag: ByteArray, + ad: ByteArray, + nonce: ByteArray, + key: ByteArray, + ): ByteArray { + require(nonce.size == 12) { "Nonce must be 12 bytes" } + require(key.size == 32) { "Key must be 32 bytes" } + require(ciphertextWithTag.size >= TAG_SIZE) { "Ciphertext too short" } + + val ctLen = ciphertextWithTag.size - TAG_SIZE + val ciphertext = ciphertextWithTag.copyOfRange(0, ctLen) + val receivedTag = ciphertextWithTag.copyOfRange(ctLen, ciphertextWithTag.size) + + // Step 1: Generate Poly1305 one-time key + val polyKey = ChaCha20Core.chaCha20PolyKey(key, nonce) + + // Step 2: Verify tag + val expectedTag = computeTag(polyKey, ad, ciphertext) + check(constantTimeEquals(receivedTag, expectedTag)) { "Authentication failed" } + + // Step 3: Decrypt + return ChaCha20Core.chaCha20Xor(ciphertext, key, nonce, counter = 1) + } + + /** + * Compute Poly1305 tag over the AEAD construction: + * pad16(ad) || pad16(ciphertext) || len(ad) as 8-byte LE || len(ct) as 8-byte LE + */ + private fun computeTag( + polyKey: ByteArray, + ad: ByteArray, + ciphertext: ByteArray, + ): ByteArray { + val adPadLen = if (ad.size % 16 == 0) 0 else 16 - (ad.size % 16) + val ctPadLen = if (ciphertext.size % 16 == 0) 0 else 16 - (ciphertext.size % 16) + + val macData = ByteArray(ad.size + adPadLen + ciphertext.size + ctPadLen + 16) + var offset = 0 + + ad.copyInto(macData, offset) + offset += ad.size + adPadLen + + ciphertext.copyInto(macData, offset) + offset += ciphertext.size + ctPadLen + + longToLittleEndian(ad.size.toLong(), macData, offset) + offset += 8 + longToLittleEndian(ciphertext.size.toLong(), macData, offset) + + return Poly1305.mac(macData, polyKey) + } + + private fun constantTimeEquals( + a: ByteArray, + b: ByteArray, + ): Boolean { + if (a.size != b.size) return false + var result = 0 + for (i in a.indices) { + result = result or (a[i].toInt() xor b[i].toInt()) + } + return result == 0 + } + + private fun longToLittleEndian( + value: Long, + output: ByteArray, + offset: Int, + ) { + output[offset] = (value and 0xFF).toByte() + output[offset + 1] = (value ushr 8 and 0xFF).toByte() + output[offset + 2] = (value ushr 16 and 0xFF).toByte() + output[offset + 3] = (value ushr 24 and 0xFF).toByte() + output[offset + 4] = (value ushr 32 and 0xFF).toByte() + output[offset + 5] = (value ushr 40 and 0xFF).toByte() + output[offset + 6] = (value ushr 48 and 0xFF).toByte() + output[offset + 7] = (value ushr 56 and 0xFF).toByte() + } +}