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