mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-06 11:48:24 +00:00
Merge pull request #2101 from vitorpamplona/claude/marmot-encryption-group-state-3hXJ4
Add Marmot protocol support with MIP-00, MIP-02, MIP-03, MIP-05 implementations
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()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
/*
|
||||
* 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.mip03GroupMessages.CommitOrdering
|
||||
import com.vitorpamplona.quartz.marmot.mip03GroupMessages.GroupEvent
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* Tests for deterministic commit conflict resolution (MIP-03).
|
||||
*/
|
||||
class CommitOrderingTest {
|
||||
private val groupId = "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"
|
||||
|
||||
private fun makeGroupEvent(
|
||||
id: String,
|
||||
createdAt: Long,
|
||||
): GroupEvent =
|
||||
GroupEvent(
|
||||
id = id.padEnd(64, '0'),
|
||||
pubKey = "a".repeat(64),
|
||||
createdAt = createdAt,
|
||||
tags = arrayOf(arrayOf("h", groupId)),
|
||||
content = "encrypted",
|
||||
sig = "s".repeat(128),
|
||||
)
|
||||
|
||||
// ===== selectWinner =====
|
||||
|
||||
@Test
|
||||
fun testSelectWinner_EmptyList() {
|
||||
assertNull(CommitOrdering.selectWinner(emptyList()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testSelectWinner_SingleCommit() {
|
||||
val commit = makeGroupEvent("aaa", 1000)
|
||||
assertEquals(commit, CommitOrdering.selectWinner(listOf(commit)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testSelectWinner_LowestTimestampWins() {
|
||||
val early = makeGroupEvent("bbb", 1000)
|
||||
val late = makeGroupEvent("aaa", 2000)
|
||||
|
||||
// Early wins even though its id is "larger"
|
||||
assertEquals(early, CommitOrdering.selectWinner(listOf(late, early)))
|
||||
assertEquals(early, CommitOrdering.selectWinner(listOf(early, late)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testSelectWinner_SameTimestamp_SmallestIdWins() {
|
||||
val smallId = makeGroupEvent("111", 1000) // id starts with 1
|
||||
val largeId = makeGroupEvent("fff", 1000) // id starts with f
|
||||
|
||||
assertEquals(smallId, CommitOrdering.selectWinner(listOf(largeId, smallId)))
|
||||
assertEquals(smallId, CommitOrdering.selectWinner(listOf(smallId, largeId)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testSelectWinner_ThreeCompetitors() {
|
||||
val a = makeGroupEvent("ccc", 1000)
|
||||
val b = makeGroupEvent("aaa", 1000) // Same timestamp, smallest id
|
||||
val c = makeGroupEvent("bbb", 999) // Earliest timestamp
|
||||
|
||||
// c wins (earliest timestamp)
|
||||
assertEquals(c, CommitOrdering.selectWinner(listOf(a, b, c)))
|
||||
}
|
||||
|
||||
// ===== isWinner =====
|
||||
|
||||
@Test
|
||||
fun testIsWinner() {
|
||||
val winner = makeGroupEvent("aaa", 999)
|
||||
val loser = makeGroupEvent("bbb", 1000)
|
||||
val competitors = listOf(winner, loser)
|
||||
|
||||
assertTrue(CommitOrdering.isWinner(winner, competitors))
|
||||
assertFalse(CommitOrdering.isWinner(loser, competitors))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testIsWinner_EmptyCompetitors() {
|
||||
val commit = makeGroupEvent("aaa", 1000)
|
||||
assertFalse(CommitOrdering.isWinner(commit, emptyList()))
|
||||
}
|
||||
|
||||
// ===== comparator ordering =====
|
||||
|
||||
@Test
|
||||
fun testComparatorSortsCorrectly() {
|
||||
val events =
|
||||
listOf(
|
||||
makeGroupEvent("ccc", 3000),
|
||||
makeGroupEvent("aaa", 1000),
|
||||
makeGroupEvent("bbb", 1000),
|
||||
makeGroupEvent("ddd", 2000),
|
||||
)
|
||||
|
||||
val sorted = events.sortedWith(CommitOrdering.comparator)
|
||||
|
||||
// 1. aaa@1000 (lowest timestamp, then smallest id)
|
||||
// 2. bbb@1000 (same timestamp, next id)
|
||||
// 3. ddd@2000
|
||||
// 4. ccc@3000
|
||||
assertEquals("aaa", sorted[0].id.take(3))
|
||||
assertEquals("bbb", sorted[1].id.take(3))
|
||||
assertEquals("ddd", sorted[2].id.take(3))
|
||||
assertEquals("ccc", sorted[3].id.take(3))
|
||||
}
|
||||
|
||||
// ===== EpochCommitTracker =====
|
||||
|
||||
@Test
|
||||
fun testEpochCommitTracker_Basic() {
|
||||
val tracker = CommitOrdering.EpochCommitTracker()
|
||||
val epoch1Commit1 = makeGroupEvent("bbb", 1000)
|
||||
val epoch1Commit2 = makeGroupEvent("aaa", 1001)
|
||||
|
||||
tracker.addCommit(1L, epoch1Commit1)
|
||||
tracker.addCommit(1L, epoch1Commit2)
|
||||
|
||||
assertEquals(2, tracker.pendingForEpoch(1L).size)
|
||||
assertEquals(0, tracker.pendingForEpoch(2L).size)
|
||||
|
||||
// Resolve: epoch1Commit1 wins (earlier timestamp)
|
||||
val winner = tracker.resolve(1L)
|
||||
assertEquals(epoch1Commit1, winner)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testEpochCommitTracker_MultipleEpochs() {
|
||||
val tracker = CommitOrdering.EpochCommitTracker()
|
||||
val e1 = makeGroupEvent("aaa", 1000)
|
||||
val e2 = makeGroupEvent("bbb", 2000)
|
||||
|
||||
tracker.addCommit(1L, e1)
|
||||
tracker.addCommit(2L, e2)
|
||||
|
||||
assertEquals(setOf(1L, 2L), tracker.pendingEpochs())
|
||||
|
||||
tracker.clearEpoch(1L)
|
||||
assertEquals(setOf(2L), tracker.pendingEpochs())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testEpochCommitTracker_ClearAll() {
|
||||
val tracker = CommitOrdering.EpochCommitTracker()
|
||||
tracker.addCommit(1L, makeGroupEvent("aaa", 1000))
|
||||
tracker.addCommit(2L, makeGroupEvent("bbb", 2000))
|
||||
|
||||
tracker.clear()
|
||||
|
||||
assertTrue(tracker.pendingEpochs().isEmpty())
|
||||
assertNull(tracker.resolve(1L))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testEpochCommitTracker_ResolveEmpty() {
|
||||
val tracker = CommitOrdering.EpochCommitTracker()
|
||||
assertNull(tracker.resolve(999L))
|
||||
}
|
||||
}
|
||||
+162
@@ -0,0 +1,162 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Vitor Pamplona
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to use,
|
||||
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
package com.vitorpamplona.quartz.marmot
|
||||
|
||||
import com.vitorpamplona.quartz.marmot.mip03GroupMessages.GroupEvent
|
||||
import com.vitorpamplona.quartz.marmot.mip03GroupMessages.GroupEventEncryption
|
||||
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
|
||||
import kotlin.io.encoding.Base64
|
||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertContentEquals
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFailsWith
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* Tests for Marmot GroupEvent encryption/decryption (MIP-03).
|
||||
*/
|
||||
class GroupEventEncryptionTest {
|
||||
private fun hex(s: String): ByteArray = s.replace(" ", "").hexToByteArray()
|
||||
|
||||
// Simulate a 32-byte MLS exporter-derived key
|
||||
private val testGroupKey = hex("000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f")
|
||||
|
||||
@Test
|
||||
fun testRoundTrip() {
|
||||
val mlsMessage = "Hello MLS Group!".encodeToByteArray()
|
||||
|
||||
val encrypted = GroupEventEncryption.encrypt(mlsMessage, testGroupKey)
|
||||
val decrypted = GroupEventEncryption.decrypt(encrypted, testGroupKey)
|
||||
|
||||
assertContentEquals(mlsMessage, decrypted)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testRoundTrip_EmptyMessage() {
|
||||
val mlsMessage = ByteArray(0)
|
||||
|
||||
val encrypted = GroupEventEncryption.encrypt(mlsMessage, testGroupKey)
|
||||
val decrypted = GroupEventEncryption.decrypt(encrypted, testGroupKey)
|
||||
|
||||
assertContentEquals(mlsMessage, decrypted)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testRoundTrip_LargeMessage() {
|
||||
// Simulate a large MLS commit message
|
||||
val mlsMessage = ByteArray(4096) { (it % 256).toByte() }
|
||||
|
||||
val encrypted = GroupEventEncryption.encrypt(mlsMessage, testGroupKey)
|
||||
val decrypted = GroupEventEncryption.decrypt(encrypted, testGroupKey)
|
||||
|
||||
assertContentEquals(mlsMessage, decrypted)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalEncodingApi::class)
|
||||
@Test
|
||||
fun testEncryptedFormat() {
|
||||
val mlsMessage = "test content".encodeToByteArray()
|
||||
|
||||
val encryptedBase64 = GroupEventEncryption.encrypt(mlsMessage, testGroupKey)
|
||||
val decoded = Base64.decode(encryptedBase64)
|
||||
|
||||
// Should be: nonce(12) + ciphertext(messageLen) + tag(16)
|
||||
val expectedSize = GroupEvent.NONCE_LENGTH + mlsMessage.size + GroupEvent.AUTH_TAG_LENGTH
|
||||
assertEquals(expectedSize, decoded.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testDifferentEncryptionsProduceDifferentCiphertext() {
|
||||
val mlsMessage = "same plaintext".encodeToByteArray()
|
||||
|
||||
val encrypted1 = GroupEventEncryption.encrypt(mlsMessage, testGroupKey)
|
||||
val encrypted2 = GroupEventEncryption.encrypt(mlsMessage, testGroupKey)
|
||||
|
||||
// Each encryption uses a random nonce, so outputs must differ
|
||||
assertTrue(encrypted1 != encrypted2)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testWrongKeyFails() {
|
||||
val mlsMessage = "secret message".encodeToByteArray()
|
||||
val wrongKey = hex("ff0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f")
|
||||
|
||||
val encrypted = GroupEventEncryption.encrypt(mlsMessage, testGroupKey)
|
||||
|
||||
assertFailsWith<IllegalStateException> {
|
||||
GroupEventEncryption.decrypt(encrypted, wrongKey)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testInvalidKeyLength() {
|
||||
val mlsMessage = "test".encodeToByteArray()
|
||||
val shortKey = ByteArray(16)
|
||||
|
||||
assertFailsWith<IllegalArgumentException> {
|
||||
GroupEventEncryption.encrypt(mlsMessage, shortKey)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalEncodingApi::class)
|
||||
@Test
|
||||
fun testTruncatedPayloadFails() {
|
||||
val tooShort = Base64.encode(ByteArray(10)) // Less than MIN_CONTENT_LENGTH
|
||||
|
||||
assertFailsWith<IllegalArgumentException> {
|
||||
GroupEventEncryption.decrypt(tooShort, testGroupKey)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalEncodingApi::class)
|
||||
@Test
|
||||
fun testTamperedContentFails() {
|
||||
val mlsMessage = "tamper test".encodeToByteArray()
|
||||
|
||||
val encryptedBase64 = GroupEventEncryption.encrypt(mlsMessage, testGroupKey)
|
||||
val decoded = Base64.decode(encryptedBase64)
|
||||
|
||||
// Tamper with a ciphertext byte (after the 12-byte nonce)
|
||||
decoded[GroupEvent.NONCE_LENGTH] = (decoded[GroupEvent.NONCE_LENGTH].toInt() xor 0xFF).toByte()
|
||||
val tamperedBase64 = Base64.encode(decoded)
|
||||
|
||||
assertFailsWith<IllegalStateException> {
|
||||
GroupEventEncryption.decrypt(tamperedBase64, testGroupKey)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testIntegrationWithGroupEventBuild() {
|
||||
val mlsMessage = "MLS application message".encodeToByteArray()
|
||||
val groupId = "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"
|
||||
|
||||
val encryptedContent = GroupEventEncryption.encrypt(mlsMessage, testGroupKey)
|
||||
val groupEvent = GroupEvent.build(encryptedContent, groupId)
|
||||
|
||||
assertEquals(GroupEvent.KIND, groupEvent.kind)
|
||||
assertEquals(encryptedContent, groupEvent.content)
|
||||
|
||||
// Decrypt from the event content
|
||||
val decrypted = GroupEventEncryption.decrypt(groupEvent.content, testGroupKey)
|
||||
assertContentEquals(mlsMessage, decrypted)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
/*
|
||||
* 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.nip01Core.core.Event
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* Tests for KeyPackage lifecycle helpers (MIP-00).
|
||||
*/
|
||||
class KeyPackageUtilsTest {
|
||||
private val testPubKey = "a".repeat(64)
|
||||
private val testRef = "b".repeat(64)
|
||||
|
||||
private fun makeKeyPackageEvent(
|
||||
dTag: String = "0",
|
||||
createdAt: Long = 1000,
|
||||
encoding: String = "base64",
|
||||
ciphersuite: String = "0x0001",
|
||||
keyPackageRef: String = testRef,
|
||||
content: String = "dGVzdA==", // base64("test")
|
||||
): KeyPackageEvent =
|
||||
KeyPackageEvent(
|
||||
id = "e".repeat(64),
|
||||
pubKey = testPubKey,
|
||||
createdAt = createdAt,
|
||||
tags =
|
||||
arrayOf(
|
||||
arrayOf("d", dTag),
|
||||
arrayOf("encoding", encoding),
|
||||
arrayOf("mls_ciphersuite", ciphersuite),
|
||||
arrayOf("i", keyPackageRef),
|
||||
arrayOf("mls_protocol_version", "1.0"),
|
||||
arrayOf("mls_extensions", "0xf2ee", "0x000a"),
|
||||
arrayOf("mls_proposals", "0x000a"),
|
||||
arrayOf("relays", "wss://relay.example.com"),
|
||||
),
|
||||
content = content,
|
||||
sig = "s".repeat(128),
|
||||
)
|
||||
|
||||
// ===== isValid =====
|
||||
|
||||
@Test
|
||||
fun testIsValid_ValidKeyPackage() {
|
||||
val kp = makeKeyPackageEvent()
|
||||
assertTrue(KeyPackageUtils.isValid(kp))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testIsValid_WrongEncoding() {
|
||||
val kp = makeKeyPackageEvent(encoding = "raw")
|
||||
assertFalse(KeyPackageUtils.isValid(kp))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testIsValid_EmptyContent() {
|
||||
val kp = makeKeyPackageEvent(content = "")
|
||||
assertFalse(KeyPackageUtils.isValid(kp))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testIsValid_MissingRef() {
|
||||
val kp = makeKeyPackageEvent(keyPackageRef = "")
|
||||
assertFalse(KeyPackageUtils.isValid(kp))
|
||||
}
|
||||
|
||||
// ===== selectBest =====
|
||||
|
||||
@Test
|
||||
fun testSelectBest_EmptyList() {
|
||||
assertNull(KeyPackageUtils.selectBest(emptyList()))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testSelectBest_SingleValid() {
|
||||
val kp = makeKeyPackageEvent()
|
||||
assertEquals(kp, KeyPackageUtils.selectBest(listOf(kp)))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testSelectBest_PrefersNewest() {
|
||||
val old = makeKeyPackageEvent(dTag = "0", createdAt = 1000)
|
||||
val newer = makeKeyPackageEvent(dTag = "1", createdAt = 2000)
|
||||
|
||||
val best = KeyPackageUtils.selectBest(listOf(old, newer))
|
||||
assertNotNull(best)
|
||||
assertEquals(2000, best.createdAt)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testSelectBest_PrefersNonLastResort() {
|
||||
val lastResort = makeKeyPackageEvent(dTag = "lr", createdAt = 3000)
|
||||
val regular = makeKeyPackageEvent(dTag = "0", createdAt = 1000)
|
||||
|
||||
// Even though lastResort is newer, regular is preferred
|
||||
val best = KeyPackageUtils.selectBest(listOf(lastResort, regular), lastResortDTag = "lr")
|
||||
assertNotNull(best)
|
||||
assertEquals("0", best.dTag())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testSelectBest_FallsBackToLastResort() {
|
||||
val lastResort = makeKeyPackageEvent(dTag = "lr", createdAt = 3000)
|
||||
|
||||
// Only last-resort available
|
||||
val best = KeyPackageUtils.selectBest(listOf(lastResort), lastResortDTag = "lr")
|
||||
assertNotNull(best)
|
||||
assertEquals("lr", best.dTag())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testSelectBest_FiltersOutInvalid() {
|
||||
val invalid = makeKeyPackageEvent(dTag = "0", encoding = "raw")
|
||||
val valid = makeKeyPackageEvent(dTag = "1", createdAt = 500)
|
||||
|
||||
val best = KeyPackageUtils.selectBest(listOf(invalid, valid))
|
||||
assertNotNull(best)
|
||||
assertEquals("1", best.dTag())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testSelectBest_AllInvalid() {
|
||||
val invalid1 = makeKeyPackageEvent(encoding = "raw")
|
||||
val invalid2 = makeKeyPackageEvent(content = "")
|
||||
|
||||
assertNull(KeyPackageUtils.selectBest(listOf(invalid1, invalid2)))
|
||||
}
|
||||
|
||||
// ===== buildRotation =====
|
||||
|
||||
@Test
|
||||
fun testBuildRotation() {
|
||||
val template =
|
||||
KeyPackageUtils.buildRotation(
|
||||
newKeyPackageBase64 = "bmV3IGtleXBhY2thZ2U=",
|
||||
dTagSlot = "0",
|
||||
newKeyPackageRef = testRef,
|
||||
relays = emptyList(),
|
||||
)
|
||||
|
||||
assertEquals(KeyPackageEvent.KIND, template.kind)
|
||||
assertEquals("bmV3IGtleXBhY2thZ2U=", template.content)
|
||||
}
|
||||
|
||||
// ===== migration helpers =====
|
||||
|
||||
@Test
|
||||
fun testIsKeyPackageKind() {
|
||||
val addressable =
|
||||
Event(
|
||||
id = "e".repeat(64),
|
||||
pubKey = testPubKey,
|
||||
createdAt = 1000,
|
||||
kind = 30443,
|
||||
tags = emptyArray(),
|
||||
content = "",
|
||||
sig = "",
|
||||
)
|
||||
val legacy =
|
||||
Event(
|
||||
id = "e".repeat(64),
|
||||
pubKey = testPubKey,
|
||||
createdAt = 1000,
|
||||
kind = 443,
|
||||
tags = emptyArray(),
|
||||
content = "",
|
||||
sig = "",
|
||||
)
|
||||
val other =
|
||||
Event(
|
||||
id = "e".repeat(64),
|
||||
pubKey = testPubKey,
|
||||
createdAt = 1000,
|
||||
kind = 1,
|
||||
tags = emptyArray(),
|
||||
content = "",
|
||||
sig = "",
|
||||
)
|
||||
|
||||
assertTrue(KeyPackageUtils.isKeyPackageKind(addressable))
|
||||
assertTrue(KeyPackageUtils.isKeyPackageKind(legacy))
|
||||
assertFalse(KeyPackageUtils.isKeyPackageKind(other))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testMigrationKinds() {
|
||||
val kinds = KeyPackageUtils.migrationKinds()
|
||||
assertTrue(kinds.contains(30443))
|
||||
assertTrue(kinds.contains(443))
|
||||
assertEquals(2, kinds.size)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
/*
|
||||
* 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.mip03GroupMessages.GroupEvent
|
||||
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* Tests for Marmot relay subscription filter builders.
|
||||
*/
|
||||
class MarmotFiltersTest {
|
||||
private val testPubKey = "a".repeat(64)
|
||||
private val testGroupId = "b".repeat(64)
|
||||
private val testRef = "c".repeat(64)
|
||||
|
||||
@Test
|
||||
fun testKeyPackagesByAuthor() {
|
||||
val filter = MarmotFilters.keyPackagesByAuthor(testPubKey)
|
||||
|
||||
assertEquals(listOf(KeyPackageEvent.KIND), filter.kinds)
|
||||
assertEquals(listOf(testPubKey), filter.authors)
|
||||
assertNull(filter.tags)
|
||||
assertNull(filter.since)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testKeyPackagesByAuthors() {
|
||||
val pubkeys = listOf("a".repeat(64), "b".repeat(64))
|
||||
val filter = MarmotFilters.keyPackagesByAuthors(pubkeys)
|
||||
|
||||
assertEquals(listOf(KeyPackageEvent.KIND), filter.kinds)
|
||||
assertEquals(pubkeys, filter.authors)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testKeyPackageByRef() {
|
||||
val filter = MarmotFilters.keyPackageByRef(testRef)
|
||||
|
||||
assertEquals(listOf(KeyPackageEvent.KIND), filter.kinds)
|
||||
assertNull(filter.authors)
|
||||
val tags = filter.tags
|
||||
assertNotNull(tags)
|
||||
assertEquals(listOf(testRef), tags["i"])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testGroupEventsByGroupId() {
|
||||
val filter = MarmotFilters.groupEventsByGroupId(testGroupId)
|
||||
|
||||
assertEquals(listOf(GroupEvent.KIND), filter.kinds)
|
||||
val tags = filter.tags
|
||||
assertNotNull(tags)
|
||||
assertEquals(listOf(testGroupId), tags["h"])
|
||||
assertNull(filter.since)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testGroupEventsByGroupIdSince() {
|
||||
val since = 1700000000L
|
||||
val filter = MarmotFilters.groupEventsByGroupIdSince(testGroupId, since)
|
||||
|
||||
assertEquals(listOf(GroupEvent.KIND), filter.kinds)
|
||||
val tags = filter.tags
|
||||
assertNotNull(tags)
|
||||
assertEquals(listOf(testGroupId), tags["h"])
|
||||
assertEquals(since, filter.since)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testGiftWrapsForUser() {
|
||||
val filter = MarmotFilters.giftWrapsForUser(testPubKey)
|
||||
|
||||
assertEquals(listOf(GiftWrapEvent.KIND), filter.kinds)
|
||||
val tags = filter.tags
|
||||
assertNotNull(tags)
|
||||
assertEquals(listOf(testPubKey), tags["p"])
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testGiftWrapsForUserSince() {
|
||||
val since = 1700000000L
|
||||
val filter = MarmotFilters.giftWrapsForUserSince(testPubKey, since)
|
||||
|
||||
assertEquals(listOf(GiftWrapEvent.KIND), filter.kinds)
|
||||
val tags = filter.tags
|
||||
assertNotNull(tags)
|
||||
assertEquals(listOf(testPubKey), tags["p"])
|
||||
assertEquals(since, filter.since)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testKeyPackagesMigration() {
|
||||
val filter = MarmotFilters.keyPackagesMigration(testPubKey)
|
||||
|
||||
val kinds = filter.kinds
|
||||
assertNotNull(kinds)
|
||||
assertTrue(kinds.contains(KeyPackageEvent.KIND))
|
||||
assertTrue(kinds.contains(443))
|
||||
assertEquals(listOf(testPubKey), filter.authors)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testFiltersAreNotEmpty() {
|
||||
// None of the filter builders should produce empty filters
|
||||
val filters =
|
||||
listOf(
|
||||
MarmotFilters.keyPackagesByAuthor(testPubKey),
|
||||
MarmotFilters.keyPackageByRef(testRef),
|
||||
MarmotFilters.groupEventsByGroupId(testGroupId),
|
||||
MarmotFilters.giftWrapsForUser(testPubKey),
|
||||
MarmotFilters.keyPackagesMigration(testPubKey),
|
||||
)
|
||||
|
||||
filters.forEach { filter ->
|
||||
assertTrue(!filter.isEmpty(), "Filter should not be empty: $filter")
|
||||
}
|
||||
}
|
||||
}
|
||||
+214
@@ -0,0 +1,214 @@
|
||||
/*
|
||||
* 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
|
||||
|
||||
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertContentEquals
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFailsWith
|
||||
|
||||
/**
|
||||
* Tests for standard ChaCha20-Poly1305 AEAD (RFC 8439) with 12-byte nonces.
|
||||
* Includes the official RFC 8439 §2.8.2 test vector and round-trip tests.
|
||||
*/
|
||||
class ChaCha20Poly1305Test {
|
||||
private fun hex(s: String): ByteArray = s.replace(" ", "").hexToByteArray()
|
||||
|
||||
// ===== RFC 8439 §2.8.2: AEAD_CHACHA20_POLY1305 Test Vector =====
|
||||
@Test
|
||||
fun testEncrypt_RFC8439() {
|
||||
val key = hex("808182838485868788898a8b8c8d8e8f909192939495969798999a9b9c9d9e9f")
|
||||
val nonce = hex("070000004041424344454647")
|
||||
val ad = hex("50515253c0c1c2c3c4c5c6c7")
|
||||
val plaintext =
|
||||
"Ladies and Gentlemen of the class of '99: If I could offer you only one tip for the future, sunscreen would be it."
|
||||
.encodeToByteArray()
|
||||
|
||||
val result = ChaCha20Poly1305.encrypt(plaintext, ad, nonce, key)
|
||||
|
||||
val expectedCiphertext =
|
||||
hex(
|
||||
"d31a8d34648e60db7b86afbc53ef7ec2" +
|
||||
"a4aded51296e08fea9e2b5a736ee62d6" +
|
||||
"3dbea45e8ca9671282fafb69da92728b" +
|
||||
"1a71de0a9e060b2905d6a5b67ecd3b36" +
|
||||
"92ddbd7f2d778b8c9803aee328091b58" +
|
||||
"fab324e4fad675945585808b4831d7bc" +
|
||||
"3ff4def08e4b7a9de576d26586cec64b" +
|
||||
"6116",
|
||||
)
|
||||
val expectedTag = hex("1ae10b594f09e26a7e902ecbd0600691")
|
||||
|
||||
val ciphertext = result.copyOfRange(0, result.size - 16)
|
||||
val tag = result.copyOfRange(result.size - 16, result.size)
|
||||
|
||||
assertContentEquals(expectedCiphertext, ciphertext)
|
||||
assertContentEquals(expectedTag, tag)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testDecrypt_RFC8439() {
|
||||
val key = hex("808182838485868788898a8b8c8d8e8f909192939495969798999a9b9c9d9e9f")
|
||||
val nonce = hex("070000004041424344454647")
|
||||
val ad = hex("50515253c0c1c2c3c4c5c6c7")
|
||||
val ciphertextWithTag =
|
||||
hex(
|
||||
"d31a8d34648e60db7b86afbc53ef7ec2" +
|
||||
"a4aded51296e08fea9e2b5a736ee62d6" +
|
||||
"3dbea45e8ca9671282fafb69da92728b" +
|
||||
"1a71de0a9e060b2905d6a5b67ecd3b36" +
|
||||
"92ddbd7f2d778b8c9803aee328091b58" +
|
||||
"fab324e4fad675945585808b4831d7bc" +
|
||||
"3ff4def08e4b7a9de576d26586cec64b" +
|
||||
"6116" +
|
||||
"1ae10b594f09e26a7e902ecbd0600691",
|
||||
)
|
||||
|
||||
val plaintext = ChaCha20Poly1305.decrypt(ciphertextWithTag, ad, nonce, key)
|
||||
|
||||
val expected =
|
||||
"Ladies and Gentlemen of the class of '99: If I could offer you only one tip for the future, sunscreen would be it."
|
||||
assertEquals(expected, plaintext.decodeToString())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testRoundTrip() {
|
||||
val key = hex("000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f")
|
||||
val nonce = hex("000000000000004a00000000")
|
||||
val ad = hex("feedfacedeadbeef")
|
||||
val plaintext = "Hello, Nostr! Round-trip test with 12-byte nonce.".encodeToByteArray()
|
||||
|
||||
val encrypted = ChaCha20Poly1305.encrypt(plaintext, ad, nonce, key)
|
||||
val decrypted = ChaCha20Poly1305.decrypt(encrypted, ad, nonce, key)
|
||||
|
||||
assertContentEquals(plaintext, decrypted)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testRoundTrip_EmptyAD() {
|
||||
val key = hex("000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f")
|
||||
val nonce = hex("000102030405060708090a0b")
|
||||
val ad = ByteArray(0)
|
||||
val plaintext = "Empty AAD test - used by Marmot GroupEvents".encodeToByteArray()
|
||||
|
||||
val encrypted = ChaCha20Poly1305.encrypt(plaintext, ad, nonce, key)
|
||||
val decrypted = ChaCha20Poly1305.decrypt(encrypted, ad, nonce, key)
|
||||
|
||||
assertContentEquals(plaintext, decrypted)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testRoundTrip_EmptyPlaintext() {
|
||||
val key = hex("000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f")
|
||||
val nonce = hex("000102030405060708090a0b")
|
||||
val ad = ByteArray(0)
|
||||
val plaintext = ByteArray(0)
|
||||
|
||||
val encrypted = ChaCha20Poly1305.encrypt(plaintext, ad, nonce, key)
|
||||
assertEquals(16, encrypted.size) // Just the 16-byte tag
|
||||
val decrypted = ChaCha20Poly1305.decrypt(encrypted, ad, nonce, key)
|
||||
assertContentEquals(plaintext, decrypted)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testTamperedCiphertext() {
|
||||
val key = hex("000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f")
|
||||
val nonce = hex("000102030405060708090a0b")
|
||||
val ad = ByteArray(0)
|
||||
val plaintext = "Tamper detection test".encodeToByteArray()
|
||||
|
||||
val encrypted = ChaCha20Poly1305.encrypt(plaintext, ad, nonce, key)
|
||||
encrypted[0] = (encrypted[0].toInt() xor 1).toByte()
|
||||
|
||||
assertFailsWith<IllegalStateException> {
|
||||
ChaCha20Poly1305.decrypt(encrypted, ad, nonce, key)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testTamperedTag() {
|
||||
val key = hex("000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f")
|
||||
val nonce = hex("000102030405060708090a0b")
|
||||
val ad = ByteArray(0)
|
||||
val plaintext = "Tag tamper test".encodeToByteArray()
|
||||
|
||||
val encrypted = ChaCha20Poly1305.encrypt(plaintext, ad, nonce, key)
|
||||
// Flip a bit in the last byte (part of the tag)
|
||||
encrypted[encrypted.size - 1] = (encrypted[encrypted.size - 1].toInt() xor 1).toByte()
|
||||
|
||||
assertFailsWith<IllegalStateException> {
|
||||
ChaCha20Poly1305.decrypt(encrypted, ad, nonce, key)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testWrongKey() {
|
||||
val key1 = hex("000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f")
|
||||
val key2 = hex("ff0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f")
|
||||
val nonce = hex("000102030405060708090a0b")
|
||||
val ad = ByteArray(0)
|
||||
val plaintext = "Wrong key test".encodeToByteArray()
|
||||
|
||||
val encrypted = ChaCha20Poly1305.encrypt(plaintext, ad, nonce, key1)
|
||||
|
||||
assertFailsWith<IllegalStateException> {
|
||||
ChaCha20Poly1305.decrypt(encrypted, ad, nonce, key2)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testInvalidNonceLength() {
|
||||
val key = hex("000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f")
|
||||
val badNonce = hex("0102030405060708") // 8 bytes instead of 12
|
||||
val plaintext = "test".encodeToByteArray()
|
||||
|
||||
assertFailsWith<IllegalArgumentException> {
|
||||
ChaCha20Poly1305.encrypt(plaintext, ByteArray(0), badNonce, key)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testInvalidKeyLength() {
|
||||
val badKey = hex("0102030405060708090a0b0c0d0e0f10") // 16 bytes instead of 32
|
||||
val nonce = hex("000102030405060708090a0b")
|
||||
val plaintext = "test".encodeToByteArray()
|
||||
|
||||
assertFailsWith<IllegalArgumentException> {
|
||||
ChaCha20Poly1305.encrypt(plaintext, ByteArray(0), nonce, badKey)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testLargeMessage() {
|
||||
val key = hex("000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f")
|
||||
val nonce = hex("000102030405060708090a0b")
|
||||
val ad = ByteArray(0)
|
||||
// Test with a message larger than a single ChaCha20 block (64 bytes)
|
||||
val plaintext = ByteArray(1000) { (it % 256).toByte() }
|
||||
|
||||
val encrypted = ChaCha20Poly1305.encrypt(plaintext, ad, nonce, key)
|
||||
assertEquals(1000 + 16, encrypted.size)
|
||||
val decrypted = ChaCha20Poly1305.decrypt(encrypted, ad, nonce, key)
|
||||
|
||||
assertContentEquals(plaintext, decrypted)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user