feat: add Marmot Phase 2 — encryption, group state, and lifecycle helpers
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
This commit is contained in:
@@ -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<HexKey>): 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),
|
||||||
|
)
|
||||||
|
}
|
||||||
+127
@@ -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<KeyPackageEvent>,
|
||||||
|
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<NormalizedRelayUrl>,
|
||||||
|
ciphersuite: String = "0x0001",
|
||||||
|
clientName: String? = null,
|
||||||
|
): EventTemplate<KeyPackageEvent> =
|
||||||
|
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<Int> = listOf(CURRENT_KIND, LEGACY_KIND)
|
||||||
|
}
|
||||||
+87
@@ -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<NormalizedRelayUrl>,
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
+123
@@ -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<GroupEvent> =
|
||||||
|
compareBy<GroupEvent> { 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>): 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<GroupEvent>,
|
||||||
|
): 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<Long, MutableList<GroupEvent>>()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<GroupEvent> = 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<Long> = pendingByEpoch.keys.toSet()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clears all pending state.
|
||||||
|
*/
|
||||||
|
fun clear() {
|
||||||
|
pendingByEpoch.clear()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+96
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+198
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+160
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user