Merge latest main into emoji feature branch

# Conflicts:
#	amethyst/src/main/java/com/vitorpamplona/amethyst/model/Account.kt
#	amethyst/src/main/java/com/vitorpamplona/amethyst/model/AccountSettings.kt
#	amethyst/src/main/java/com/vitorpamplona/amethyst/service/relayClient/reqCommand/RelaySubscriptionsCoordinator.kt
#	amethyst/src/main/java/com/vitorpamplona/amethyst/ui/feeds/RememberForeverStates.kt
#	amethyst/src/main/java/com/vitorpamplona/amethyst/ui/screen/loggedIn/AccountFeedContentStates.kt
This commit is contained in:
Claude
2026-04-21 13:24:03 +00:00
98 changed files with 5869 additions and 407 deletions
@@ -79,6 +79,21 @@ sealed class GroupEventResult {
val groupId: HexKey,
) : GroupEventResult()
/**
* The outer ChaCha20-Poly1305 layer could not be decrypted with the
* current-epoch exporter key or any retained prior-epoch key.
*
* This is the expected outcome whenever a member receives a kind:445
* from an epoch before they joined (via Welcome). Per MLS forward
* secrecy, the new member never held those keys, so the bytes are
* unreadable to them — and that is by design, not an error. Callers
* should surface this at DEBUG, not WARN.
*/
data class UndecryptableOuterLayer(
val groupId: HexKey,
val retainedEpochCount: Int,
) : GroupEventResult()
/**
* The event could not be processed.
*/
@@ -101,6 +116,20 @@ sealed class WelcomeResult {
val needsKeyPackageRotation: Boolean,
) : WelcomeResult()
/**
* The Welcome was for a group we're already a member of — benign replay.
*
* Happens after an app restart: the gift-wrapped Welcome (kind:1059) is
* still sitting on the relay and gets redelivered, but the KeyPackage
* bundle it referenced was already consumed and marked. Rather than
* logging a noisy "No matching KeyPackageBundle" error, we detect the
* replay up front by checking `groupManager.isMember(hintNostrGroupId)`
* and return this result. Callers should log at DEBUG.
*/
data class AlreadyJoined(
val nostrGroupId: HexKey,
) : WelcomeResult()
/**
* The Welcome could not be processed.
*/
@@ -176,15 +205,25 @@ class MarmotInboundProcessor(
val result =
try {
// Step 1: Outer ChaCha20-Poly1305 decryption
val mlsBytes = decryptOuterLayer(groupId, groupEvent.encryptedContent())
val mlsBytes = tryDecryptOuterLayer(groupId, groupEvent.encryptedContent())
if (mlsBytes == null) {
// Expected when this kind:445 was encrypted with an epoch
// key that predates our join (classical MLS forward
// secrecy), or when the sender's epoch has drifted. Not
// an error — callers should log at DEBUG.
GroupEventResult.UndecryptableOuterLayer(
groupId,
retainedEpochCount = groupManager.retainedExporterSecrets(groupId).size,
)
} else {
// Step 2: Parse the MLS message
val mlsMessage = MlsMessage.decodeTls(TlsReader(mlsBytes))
// Step 2: Parse the MLS message
val mlsMessage = MlsMessage.decodeTls(TlsReader(mlsBytes))
when (mlsMessage.wireFormat) {
WireFormat.PRIVATE_MESSAGE -> processPrivateMessage(groupId, mlsMessage, groupEvent)
WireFormat.PUBLIC_MESSAGE -> processPublicMessage(groupId, mlsMessage, groupEvent)
else -> GroupEventResult.Error(groupId, "Unexpected wire format: ${mlsMessage.wireFormat}")
when (mlsMessage.wireFormat) {
WireFormat.PRIVATE_MESSAGE -> processPrivateMessage(groupId, mlsMessage, groupEvent)
WireFormat.PUBLIC_MESSAGE -> processPublicMessage(groupId, mlsMessage, groupEvent)
else -> GroupEventResult.Error(groupId, "Unexpected wire format: ${mlsMessage.wireFormat}")
}
}
} catch (e: Exception) {
GroupEventResult.Error(groupId, "Failed to process GroupEvent: ${e.message}", e)
@@ -245,6 +284,22 @@ class MarmotInboundProcessor(
"MarmotInboundProcessor.processWelcome: welcomeBytes=${welcomeBytes.size}B looking up KeyPackage by ref=${keyPackageEventId.take(8)}"
}
// Short-circuit if we're already a member of the group the
// Welcome is inviting us to. Happens every time the app
// restarts: the gift-wrapped kind:1059 is still on the relay
// and gets redelivered, but the KeyPackage bundle it
// referenced was already consumed + marked during the first
// processing. Without this check the fallthrough below would
// log a noisy "No matching KeyPackageBundle" warning for what
// is actually a benign replay.
if (hintNostrGroupId != null && groupManager.isMember(hintNostrGroupId)) {
com.vitorpamplona.quartz.utils.Log
.d("MarmotDbg") {
"MarmotInboundProcessor.processWelcome: already a member of group=${hintNostrGroupId.take(8)}… — treating Welcome as replay"
}
return WelcomeResult.AlreadyJoined(hintNostrGroupId)
}
// Find the KeyPackageBundle that was consumed.
//
// The Welcome's "e" tag carries the *Nostr event id* of the
@@ -287,6 +342,31 @@ class MarmotInboundProcessor(
WelcomeResult.Error("Failed to process Welcome: ${e.message}", e)
}
/**
* Mark a kind:445 event id as already processed so that a later relay
* echo of the same event is treated as a [GroupEventResult.Duplicate]
* instead of being re-applied.
*
* Callers should invoke this right after publishing a commit (e.g. from
* [com.vitorpamplona.amethyst.commons.marmot.MarmotManager.addMember])
* because `group.addMember` / `group.commit` have already advanced the
* local epoch. Reprocessing the same commit bytes would otherwise fail
* with a confirmation-tag / transcript mismatch.
*/
suspend fun markEventProcessed(eventId: HexKey) {
processedIdsMutex.withLock {
processedEventIds.add(eventId)
if (processedEventIds.size > MAX_PROCESSED_IDS) {
val iterator = processedEventIds.iterator()
val toRemove = processedEventIds.size - MAX_PROCESSED_IDS
repeat(toRemove) {
iterator.next()
iterator.remove()
}
}
}
}
/**
* Resolve any pending commit conflicts for a given epoch.
*
@@ -421,7 +501,12 @@ class MarmotInboundProcessor(
commitEvent: GroupEvent,
): GroupEventResult =
try {
val mlsBytes = decryptOuterLayer(groupId, commitEvent.encryptedContent())
val mlsBytes =
tryDecryptOuterLayer(groupId, commitEvent.encryptedContent())
?: return GroupEventResult.UndecryptableOuterLayer(
groupId,
retainedEpochCount = groupManager.retainedExporterSecrets(groupId).size,
)
val mlsMessage = MlsMessage.decodeTls(TlsReader(mlsBytes))
when (mlsMessage.wireFormat) {
@@ -442,17 +527,44 @@ class MarmotInboundProcessor(
WireFormat.PUBLIC_MESSAGE -> {
val pubMsg = PublicMessage.decodeTls(TlsReader(mlsMessage.payload))
val tag = pubMsg.confirmationTag
if (tag == null) {
GroupEventResult.Error(groupId, "PublicMessage commit missing confirmation_tag")
} else {
groupManager.processCommit(
nostrGroupId = groupId,
commitBytes = pubMsg.content,
senderLeafIndex = pubMsg.sender.leafIndex,
confirmationTag = tag,
)
val group = groupManager.getGroup(groupId)
GroupEventResult.CommitProcessed(groupId, group?.epoch ?: 0)
val currentEpoch = groupManager.getGroup(groupId)?.epoch
when {
tag == null -> {
GroupEventResult.Error(groupId, "PublicMessage commit missing confirmation_tag")
}
// Reject commits that are not for our current epoch.
// Happens most commonly when our own already-applied
// commit is echoed back from the relay after an app
// restart (the in-memory dedup set is cleared), and
// the outer layer decrypts via a retained epoch key.
// Calling `processCommit` on a past-epoch commit
// partially mutates tree / groupContext / epochSecrets
// before throwing on the confirmation-tag check,
// leaving the local state diverged from every other
// member's — they then can't decrypt anything we
// send next.
currentEpoch != null && pubMsg.epoch < currentEpoch -> {
GroupEventResult.Duplicate(groupId)
}
currentEpoch != null && pubMsg.epoch > currentEpoch -> {
GroupEventResult.Error(
groupId,
"Commit epoch ${pubMsg.epoch} is ahead of local epoch $currentEpoch; ignoring",
)
}
else -> {
groupManager.processCommit(
nostrGroupId = groupId,
commitBytes = pubMsg.content,
senderLeafIndex = pubMsg.sender.leafIndex,
confirmationTag = tag,
)
val group = groupManager.getGroup(groupId)
GroupEventResult.CommitProcessed(groupId, group?.epoch ?: 0)
}
}
}
@@ -470,11 +582,17 @@ class MarmotInboundProcessor(
*
* After a commit advances the epoch, late-arriving messages encrypted
* with the previous epoch's exporter key would fail without this fallback.
*
* Returns null when neither the current epoch key nor any retained key
* decrypts. This happens normally for commits/application messages from
* epochs that predate our join (we never held those keys), so callers
* should treat null as an expected "nothing to do here" outcome and log
* at DEBUG, not as an error.
*/
private fun decryptOuterLayer(
private fun tryDecryptOuterLayer(
groupId: HexKey,
encryptedContent: String,
): ByteArray {
): ByteArray? {
// Try current epoch key first
try {
val exporterKey = groupManager.exporterSecret(groupId)
@@ -493,9 +611,6 @@ class MarmotInboundProcessor(
}
}
// All keys exhausted — throw to let callers produce an error result
throw IllegalStateException(
"Outer decryption failed with current and ${retainedKeys.size} retained epoch key(s)",
)
return null
}
}
@@ -121,20 +121,29 @@ class MarmotOutboundProcessor(
/**
* Build a GroupEvent carrying a Commit for publishing.
*
* Used after MlsGroupManager.commit() or addMember()/removeMember().
* The commit bytes are already MLS-formatted.
* Used after MlsGroupManager.stageAddMember()/stageRemoveMember()/etc.
* The commit bytes are already MLS-formatted (PublicMessage envelope).
*
* @param nostrGroupId the Nostr group ID
* @param commitBytes the raw MLS commit bytes from CommitResult
* @param commitBytes the framed MLS commit bytes from [com.vitorpamplona.quartz.marmot.mls.group.StagedCommit.framedCommitBytes]
* @param exporterKey optional explicit outer-encryption key. Callers
* publishing a Commit MUST pass the **pre-commit** exporter secret
* (from [com.vitorpamplona.quartz.marmot.mls.group.StagedCommit.preCommitExporterSecret])
* so that other existing members at epoch N can decrypt and process
* the commit. If null, falls back to the current epoch's exporter
* secret — which is only correct when the commit has *not* been
* applied locally yet (i.e. this call is made before
* [com.vitorpamplona.quartz.marmot.mls.group.MlsGroup.mergeStagedCommit]).
* @return the signed GroupEvent ready for relay publishing
*/
suspend fun buildCommitEvent(
nostrGroupId: HexKey,
commitBytes: ByteArray,
exporterKey: ByteArray? = null,
): OutboundGroupEvent {
// Outer ChaCha20-Poly1305 encryption of the MLS commit
val exporterKey = groupManager.exporterSecret(nostrGroupId)
val encryptedContent = GroupEventEncryption.encrypt(commitBytes, exporterKey)
val outerKey = exporterKey ?: groupManager.exporterSecret(nostrGroupId)
val encryptedContent = GroupEventEncryption.encrypt(commitBytes, outerKey)
// Build the GroupEvent template
val template =
@@ -21,12 +21,19 @@
package com.vitorpamplona.quartz.marmot.mip00KeyPackages
import com.vitorpamplona.quartz.marmot.mip00KeyPackages.tags.EncodingTag
import com.vitorpamplona.quartz.marmot.mip00KeyPackages.tags.MlsCiphersuiteTag
import com.vitorpamplona.quartz.marmot.mip00KeyPackages.tags.MlsProtocolVersionTag
import com.vitorpamplona.quartz.marmot.mls.codec.TlsReader
import com.vitorpamplona.quartz.marmot.mls.crypto.MlsCryptoProvider
import com.vitorpamplona.quartz.marmot.mls.messages.MlsKeyPackage
import com.vitorpamplona.quartz.marmot.mls.tree.Credential
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
import kotlin.io.encoding.Base64
import kotlin.io.encoding.ExperimentalEncodingApi
/**
* Utility functions for KeyPackage lifecycle management (MIP-00).
@@ -88,12 +95,91 @@ object KeyPackageUtils {
/**
* Validates a KeyPackage event has required fields and proper encoding.
*
* Performs the same strict tag-level MIP-00 checks used by MDK so that
* malformed or adversarial events are rejected at parse time:
* - `d` tag is exactly 64 lowercase hex characters (32-byte slot ID)
* - `mls_protocol_version` is exactly "1.0"
* - `mls_ciphersuite` is exactly "0x0001"
* - `mls_extensions` contains both "0xf2ee" (NostrGroupData) and
* "0x000a" (LastResort)
* - `mls_proposals` contains "0x000a" (SelfRemove)
* - `encoding` is "base64" and content is non-empty
* - `i` (keyPackageRef) tag is non-empty
*
* For deep cryptographic checks (KeyPackageRef hash match, credential
* identity == event.pubkey) call [isCryptographicallyValid].
*/
fun isValid(event: KeyPackageEvent): Boolean =
event.encoding() == EncodingTag.BASE64 &&
event.content.isNotEmpty() &&
!event.keyPackageRef().isNullOrEmpty() &&
!event.mlsCiphersuite().isNullOrEmpty()
fun isValid(event: KeyPackageEvent): Boolean {
// d tag: exactly 64 hex chars per MIP-00
val dTag = event.dTag()
if (dTag.length != 64 || !dTag.all { it.isHexChar() }) return false
// mls_protocol_version == "1.0"
if (event.mlsProtocolVersion() != MlsProtocolVersionTag.CURRENT_VERSION) return false
// mls_ciphersuite == "0x0001"
if (event.mlsCiphersuite() != MlsCiphersuiteTag.DEFAULT_CIPHERSUITE) return false
// mls_extensions MUST include both 0xf2ee and 0x000a
val extensions = event.mlsExtensions()?.map { it.lowercase() }?.toSet() ?: return false
if (!extensions.contains("0xf2ee") || !extensions.contains("0x000a")) return false
// mls_proposals MUST include 0x000a (SelfRemove)
val proposals = event.mlsProposals()?.map { it.lowercase() }?.toSet() ?: return false
if (!proposals.contains("0x000a")) return false
// encoding MUST be base64 and content non-empty
if (event.encoding() != EncodingTag.BASE64) return false
if (event.content.isEmpty()) return false
// i (KeyPackageRef) tag MUST be present
if (event.keyPackageRef().isNullOrEmpty()) return false
return true
}
/**
* Deep MIP-00 validation: decodes the KeyPackage content and verifies:
* - `i` tag matches the computed `KeyPackageRef` (RFC 9420 §5.2)
* - Credential identity (BasicCredential) equals the event's `pubkey`
* (32-byte x-only Nostr pubkey)
* - KeyPackage signature over KeyPackageTBS is valid
*
* Returns true only if [isValid] also holds and every cryptographic check
* passes. Requires [isValid] to be true as a precondition — it is called
* internally.
*/
@OptIn(ExperimentalEncodingApi::class)
fun isCryptographicallyValid(event: KeyPackageEvent): Boolean {
if (!isValid(event)) return false
val iTag = event.keyPackageRef() ?: return false
val keyPackage =
try {
val bytes = Base64.decode(event.content)
MlsKeyPackage.decodeTls(TlsReader(bytes))
} catch (_: Throwable) {
return false
}
// i tag MUST equal the computed KeyPackageRef
if (keyPackage.reference().toHexKey() != iTag.lowercase()) return false
// Credential identity MUST equal the event's pubkey (32-byte x-only).
// MIP-00 requires BasicCredential with the raw 32-byte Nostr pubkey.
val credential = keyPackage.leafNode.credential
if (credential !is Credential.Basic) return false
if (credential.identity.size != 32) return false
if (credential.identity.toHexKey().lowercase() != event.pubKey.lowercase()) return false
// KeyPackage signature MUST verify against the LeafNode's signatureKey.
if (!keyPackage.verifySignature()) return false
return true
}
private fun Char.isHexChar(): Boolean = this in '0'..'9' || this in 'a'..'f' || this in 'A'..'F'
/**
* Builds a rotated KeyPackage for the same d-tag slot.
@@ -100,6 +100,9 @@ data class MarmotGroupData(
require(disappearingMessageSecs == null || disappearingMessageSecs > 0UL) {
"disappearing_message_secs must be > 0 when set (MIP-01)"
}
require(adminPubkeys.size == adminPubkeys.toSet().size) {
"MarmotGroupData.admin_pubkeys MUST NOT contain duplicates (MIP-01)"
}
}
/** Whether the given pubkey is an admin of this group */
@@ -111,46 +114,58 @@ data class MarmotGroupData(
/**
* Encode this MarmotGroupData to TLS wire format bytes.
* Mirrors the [decodeTls] format.
*
* Per MIP-01, all variable-length vectors use QUIC-style variable-length integer
* (VarInt) length prefixes, as implemented by the Rust `tls_codec` crate (v0.4+):
* - lengths 0..63 → 1 byte (high bits 00)
* - lengths 64..16383 → 2 bytes (high bits 01)
* - lengths 16384+ → 4 bytes (high bits 10)
*/
fun encodeTls(): ByteArray {
val writer = TlsWriter()
writer.putUint16(version)
writer.putBytes(nostrGroupId.hexToByteArray())
writer.putOpaque2(name.encodeToByteArray())
writer.putOpaque2(description.encodeToByteArray())
writer.putOpaqueVarInt(name.encodeToByteArray())
writer.putOpaqueVarInt(description.encodeToByteArray())
// Admin pubkeys: concatenated 32-byte keys within a length-prefixed block
// admin_pubkeys: Vec<[u8;32]> — outer VarInt covers total bytes, each 32-byte
// key is fixed-size with no inner length prefix.
val adminBytes = ByteArray(adminPubkeys.size * 32)
adminPubkeys.forEachIndexed { index, key ->
key.hexToByteArray().copyInto(adminBytes, index * 32)
}
writer.putOpaque2(adminBytes)
writer.putOpaqueVarInt(adminBytes)
// Relays: length-prefixed block of length-prefixed UTF-8 strings
// relays: Vec<Vec<u8>> — outer VarInt covers total bytes, each inner relay
// string is VarInt-length-prefixed UTF-8.
val relayWriter = TlsWriter()
for (relay in relays) {
relayWriter.putOpaque2(relay.encodeToByteArray())
relayWriter.putOpaqueVarInt(relay.encodeToByteArray())
}
writer.putOpaque2(relayWriter.toByteArray())
writer.putOpaqueVarInt(relayWriter.toByteArray())
// Optional image fields
writer.putOpaque2(imageHash?.hexToByteArray() ?: ByteArray(0))
writer.putOpaque2(imageKey ?: ByteArray(0))
writer.putOpaque2(imageNonce ?: ByteArray(0))
writer.putOpaque2(imageUploadKey ?: ByteArray(0))
// Optional image fields — empty Vec<u8> encodes as a single zero byte (VarInt(0)).
writer.putOpaqueVarInt(imageHash?.hexToByteArray() ?: ByteArray(0))
writer.putOpaqueVarInt(imageKey ?: ByteArray(0))
writer.putOpaqueVarInt(imageNonce ?: ByteArray(0))
writer.putOpaqueVarInt(imageUploadKey ?: ByteArray(0))
// v3+: disappearing_message_secs (0 bytes = none, 8 bytes big-endian uint64 = secs)
val disappearingBytes =
disappearingMessageSecs?.let { secs ->
val out = ByteArray(8)
var v = secs.toLong()
for (i in 7 downTo 0) {
out[i] = (v and 0xFF).toByte()
v = v ushr 8
}
out
} ?: ByteArray(0)
writer.putOpaque2(disappearingBytes)
// v3+: disappearing_message_secs (0 bytes = none, 8 bytes big-endian uint64 = secs).
// Only emitted for version ≥ 3; v1/v2 have no such field, so omitting it keeps
// the wire format byte-for-byte compatible with older implementations (MDK v2).
if (version >= 3) {
val disappearingBytes =
disappearingMessageSecs?.let { secs ->
val out = ByteArray(8)
var v = secs.toLong()
for (i in 7 downTo 0) {
out[i] = (v and 0xFF).toByte()
v = v ushr 8
}
out
} ?: ByteArray(0)
writer.putOpaqueVarInt(disappearingBytes)
}
return writer.toByteArray()
}
@@ -182,19 +197,22 @@ data class MarmotGroupData(
/**
* Decode MarmotGroupData from TLS wire format bytes.
*
* Per MIP-01, all variable-length vectors use QUIC-style VarInt length prefixes
* (`tls_codec` v0.4+). The TLS comment syntax below uses `<V>` to denote VarInt.
*
* Wire format (v3):
* ```
* uint16 version // rejected if 0 or unsupported
* opaque nostr_group_id[32]
* opaque name<0..2^16-1>
* opaque description<0..2^16-1>
* opaque admin_pubkeys<0..2^16-1> // concatenated 32-byte keys
* RelayUrl relays<0..2^16-1> // length-prefixed UTF-8 strings
* opaque image_hash<0..32>
* opaque image_key<0..32>
* opaque image_nonce<0..12>
* opaque image_upload_key<0..32>
* opaque disappearing_message_secs<0..8> // v3+: 0 bytes or 8-byte uint64 (reject 0)
* opaque name<V>
* opaque description<V>
* opaque admin_pubkeys<V> // concatenated 32-byte keys
* RelayUrl relays<V> // VarInt-length-prefixed UTF-8 strings
* opaque image_hash<V>
* opaque image_key<V>
* opaque image_nonce<V>
* opaque image_upload_key<V>
* opaque disappearing_message_secs<V> // v3+: 0 bytes or 8-byte uint64 (reject 0)
* ```
*
* Unknown trailing bytes from future versions are silently ignored for
@@ -209,14 +227,14 @@ data class MarmotGroupData(
val nostrGroupIdBytes = reader.readBytes(32)
val nostrGroupId = nostrGroupIdBytes.toHexKey()
val nameBytes = reader.readOpaque2()
val nameBytes = reader.readOpaqueVarInt()
val name = nameBytes.decodeToString()
val descriptionBytes = reader.readOpaque2()
val descriptionBytes = reader.readOpaqueVarInt()
val description = descriptionBytes.decodeToString()
// Admin pubkeys: concatenated 32-byte keys within a length-prefixed block
val adminBlock = reader.readOpaque2()
// Admin pubkeys: concatenated 32-byte keys within a VarInt-prefixed block
val adminBlock = reader.readOpaqueVarInt()
val adminPubkeys = mutableListOf<HexKey>()
var i = 0
while (i + 32 <= adminBlock.size) {
@@ -224,23 +242,23 @@ data class MarmotGroupData(
i += 32
}
// Relays: length-prefixed block of length-prefixed UTF-8 strings
val relaysBlock = reader.readOpaque2()
// Relays: VarInt-prefixed block of VarInt-prefixed UTF-8 strings
val relaysBlock = reader.readOpaqueVarInt()
val relays = mutableListOf<String>()
val relayReader = TlsReader(relaysBlock)
while (relayReader.hasRemaining) {
val relayBytes = relayReader.readOpaque2()
val relayBytes = relayReader.readOpaqueVarInt()
relays.add(relayBytes.decodeToString())
}
// Optional fields — read if remaining
val imageHash = if (reader.hasRemaining) reader.readOpaque2().takeIf { it.isNotEmpty() }?.toHexKey() else null
val imageKey = if (reader.hasRemaining) reader.readOpaque2().takeIf { it.isNotEmpty() } else null
val imageNonce = if (reader.hasRemaining) reader.readOpaque2().takeIf { it.isNotEmpty() } else null
val imageUploadKey = if (reader.hasRemaining) reader.readOpaque2().takeIf { it.isNotEmpty() } else null
val imageHash = if (reader.hasRemaining) reader.readOpaqueVarInt().takeIf { it.isNotEmpty() }?.toHexKey() else null
val imageKey = if (reader.hasRemaining) reader.readOpaqueVarInt().takeIf { it.isNotEmpty() } else null
val imageNonce = if (reader.hasRemaining) reader.readOpaqueVarInt().takeIf { it.isNotEmpty() } else null
val imageUploadKey = if (reader.hasRemaining) reader.readOpaqueVarInt().takeIf { it.isNotEmpty() } else null
// v3+: disappearing_message_secs
val disappearingBytes = if (reader.hasRemaining) reader.readOpaque2() else ByteArray(0)
val disappearingBytes = if (reader.hasRemaining) reader.readOpaqueVarInt() else ByteArray(0)
val disappearingMessageSecs: ULong? =
when (disappearingBytes.size) {
0 -> {
@@ -24,6 +24,7 @@ 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.rumors.RumorAssembler
import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
import com.vitorpamplona.quartz.utils.TimeUtils
@@ -60,7 +61,9 @@ object WelcomeGiftWrap {
nostrGroupId: HexKey? = null,
createdAt: Long = TimeUtils.now(),
): GiftWrapEvent {
// Step 1: Build the WelcomeEvent template and sign it
// Step 1: Build the WelcomeEvent template directly as an unsigned rumor.
// Per NIP-59 rumors MUST have an empty sig field, so we skip the outer
// signature entirely and let the SealedRumorEvent carry authorship.
val welcomeTemplate =
WelcomeEvent.build(
welcomeBase64 = welcomeBase64,
@@ -69,10 +72,14 @@ object WelcomeGiftWrap {
nostrGroupId = nostrGroupId,
createdAt = createdAt,
)
val welcomeEvent: WelcomeEvent = signer.sign(welcomeTemplate)
val welcomeRumor: WelcomeEvent =
RumorAssembler.assembleRumor(
pubKey = signer.pubKey,
ev = welcomeTemplate,
)
// Step 2: Create a Rumor from the signed event and seal it (kind:13)
val rumor = Rumor.create(welcomeEvent)
// Step 2: Create a Rumor from the unsigned event and seal it (kind:13)
val rumor = Rumor.create(welcomeRumor)
val sealedRumor =
SealedRumorEvent.create(
rumor = rumor,
@@ -25,30 +25,29 @@ 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)
* EncryptedToken format (MUST be exactly 1084 bytes per MIP-05):
* ephemeral_pubkey(32) || nonce(12) || ciphertext(1040 = 1024 plaintext + 16 tag)
*
* Token payload (220 bytes, padded):
* platform(1) || token_length(2 BE) || device_token(N) || random_padding(220-3-N)
* Token plaintext (MUST be exactly 1024 bytes per MIP-05):
* platform(1) || token_length(2 BE) || device_token(N) || random_padding(1024-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="")
* Key derivation (MIP-05 §"Key Derivation"):
* 1. ECDH: shared_x = secp256k1_ecdh(ephemeral_privkey, server_pubkey) — raw 32-byte x
* 2. PRK = HKDF-Extract(salt="mip05-v1", IKM=shared_x)
* 3. encryption_key = HKDF-Expand(PRK, info="mip05-token-encryption", 32)
* 4. Encrypt padded plaintext with ChaCha20-Poly1305(key, nonce, plaintext, aad="")
*
* Platform values: 0x01 = APNs, 0x02 = FCM
*/
object TokenEncryption {
private const val PADDED_PAYLOAD_SIZE = 220
/** Token plaintext MUST be exactly 1024 bytes per MIP-05. */
private const val PADDED_PAYLOAD_SIZE = 1024
private const val NONCE_SIZE = 12
private const val PUBKEY_SIZE = 32
private const val HEADER_SIZE = 3 // platform(1) + token_length(2)
@@ -97,9 +96,9 @@ object TokenEncryption {
// Extract the 32-byte x-only public key by dropping the SEC1 prefix byte
val ephemeralPubKey = compressedPubKey.copyOfRange(1, 33)
// ECDH: shared_x = sha256(ephemeral_privkey * server_pubkey)
val sharedPoint = Secp256k1Instance.pubKeyTweakMulCompact(serverPubKey, ephemeralPrivKey)
val sharedX = sha256(sharedPoint)
// ECDH: shared_x = secp256k1_ecdh(ephemeral_privkey, server_pubkey) — raw 32-byte x
// per MIP-05. Do NOT hash; HKDF-Extract will mix the salt.
val sharedX = Secp256k1Instance.pubKeyTweakMulCompact(serverPubKey, ephemeralPrivKey)
// HKDF-Extract then Expand to get encryption key
val encryptionKey = hkdfDeriveKey(sharedX)
@@ -108,7 +107,7 @@ object TokenEncryption {
val nonce = RandomInstance.bytes(NONCE_SIZE)
val ciphertextWithTag = ChaCha20Poly1305.encrypt(payload, EMPTY_AAD, nonce, encryptionKey)
// Assemble: ephemeral_pubkey(32) || nonce(12) || ciphertext+tag(236)
// Assemble: ephemeral_pubkey(32) || nonce(12) || ciphertext+tag(1040)
val result = ByteArray(TokenTag.ENCRYPTED_TOKEN_SIZE)
ephemeralPubKey.copyInto(result, 0)
nonce.copyInto(result, PUBKEY_SIZE)
@@ -140,9 +139,9 @@ object TokenEncryption {
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)
// ECDH: shared_x = secp256k1_ecdh(server_privkey, ephemeral_pubkey) — raw 32-byte x
// per MIP-05.
val sharedX = Secp256k1Instance.pubKeyTweakMulCompact(ephemeralPubKey, serverPrivKey)
// Derive encryption key
val encryptionKey = hkdfDeriveKey(sharedX)
@@ -23,7 +23,6 @@ package com.vitorpamplona.quartz.marmot.mip05PushNotifications
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
import com.vitorpamplona.quartz.utils.TimeUtils
@@ -33,8 +32,10 @@ import com.vitorpamplona.quartz.utils.TimeUtils
* Unsigned application message sent inside a GroupEvent (kind:445) when a device
* leaves a group or wants to disable push notifications.
*
* Has no tags — the MLS leaf index is implicit from the MLS sender identity.
* Receiving clients MUST remove the token for the identified leaf.
* Per MIP-05 this event MUST have **no tags**. The MLS leaf index is implicit
* from the MLS sender identity; receiving clients MUST remove the token for
* the identified leaf. Adding extra tags could leak metadata or be rejected
* by strict MIP-05 validators (e.g. the MDK reference).
*
* MUST remain unsigned (no sig field) per MIP-03 security requirements.
*/
@@ -50,11 +51,6 @@ class TokenRemovalEvent(
companion object {
const val KIND = 449
fun build(
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<TokenRemovalEvent>.() -> Unit = {},
) = eventTemplate(KIND, "", createdAt) {
initializer()
}
fun build(createdAt: Long = TimeUtils.now()) = eventTemplate<TokenRemovalEvent>(KIND, "", createdAt)
}
}
@@ -38,7 +38,7 @@ import com.vitorpamplona.quartz.utils.ensure
*/
@Immutable
data class TokenTagData(
/** Base64-encoded EncryptedToken (280 bytes when decoded) */
/** Base64-encoded EncryptedToken (1084 bytes when decoded per MIP-05) */
val encryptedToken: String,
/** Hex-encoded notification server public key */
val serverPubKey: HexKey,
@@ -52,8 +52,11 @@ class TokenTag {
companion object {
const val TAG_NAME = "token"
/** Expected decoded size of an EncryptedToken */
const val ENCRYPTED_TOKEN_SIZE = 280
/**
* Expected decoded size of an EncryptedToken per MIP-05:
* ephemeral_pubkey(32) || nonce(12) || ciphertext(1024 + 16 tag) = 1084 bytes.
*/
const val ENCRYPTED_TOKEN_SIZE = 1084
fun parse(tag: Array<String>): TokenTagData? {
ensure(tag.has(3) && tag[0] == TAG_NAME) { return null }
@@ -23,6 +23,8 @@ package com.vitorpamplona.quartz.marmot.mls.framing
import com.vitorpamplona.quartz.marmot.mls.codec.TlsReader
import com.vitorpamplona.quartz.marmot.mls.codec.TlsSerializable
import com.vitorpamplona.quartz.marmot.mls.codec.TlsWriter
import com.vitorpamplona.quartz.marmot.mls.messages.Commit
import com.vitorpamplona.quartz.marmot.mls.messages.Proposal
/**
* MLS MLSMessage (RFC 9420 Section 6).
@@ -161,7 +163,17 @@ data class PublicMessage(
encodeSender(writer, sender)
writer.putOpaqueVarInt(authenticatedData)
writer.putUint8(contentType.value)
writer.putBytes(content)
// RFC 9420 §6 FramedContent.content is a type-dependent body:
// case application: opaque application_data<V>
// case proposal: Proposal proposal (struct, no outer length prefix)
// case commit: Commit commit (struct, no outer length prefix)
// `content` holds the already-serialized body for proposal/commit, and
// the raw application bytes for application.
when (contentType) {
ContentType.APPLICATION -> writer.putOpaqueVarInt(content)
ContentType.PROPOSAL, ContentType.COMMIT -> writer.putBytes(content)
}
// FramedContentAuthData
writer.putOpaqueVarInt(signature)
@@ -191,9 +203,30 @@ data class PublicMessage(
val authenticatedData = reader.readOpaqueVarInt()
val contentType = ContentType.fromValue(reader.readUint8())
// Content is variable based on content_type, read remaining content
// For now, read as opaque
val content = reader.readOpaqueVarInt()
// RFC 9420 §6 FramedContent.content body varies by content_type.
// For PROPOSAL/COMMIT we decode the inner struct to advance the reader
// and then re-serialize back to bytes so the invariant
// "content holds the serialized body" holds for all variants.
val content: ByteArray =
when (contentType) {
ContentType.APPLICATION -> {
reader.readOpaqueVarInt()
}
ContentType.PROPOSAL -> {
val proposal = Proposal.decodeTls(reader)
val w = TlsWriter()
proposal.encodeTls(w)
w.toByteArray()
}
ContentType.COMMIT -> {
val commit = Commit.decodeTls(reader)
val w = TlsWriter()
commit.encodeTls(w)
w.toByteArray()
}
}
val signature = reader.readOpaqueVarInt()
val confirmationTag =
@@ -31,6 +31,9 @@ import com.vitorpamplona.quartz.marmot.mls.crypto.X25519
import com.vitorpamplona.quartz.marmot.mls.framing.ContentType
import com.vitorpamplona.quartz.marmot.mls.framing.MlsMessage
import com.vitorpamplona.quartz.marmot.mls.framing.PrivateMessage
import com.vitorpamplona.quartz.marmot.mls.framing.PublicMessage
import com.vitorpamplona.quartz.marmot.mls.framing.Sender
import com.vitorpamplona.quartz.marmot.mls.framing.SenderType
import com.vitorpamplona.quartz.marmot.mls.framing.WireFormat
import com.vitorpamplona.quartz.marmot.mls.messages.Commit
import com.vitorpamplona.quartz.marmot.mls.messages.CommitResult
@@ -400,6 +403,13 @@ class MlsGroup private constructor(
// (i.e. no remaining member appears in the post-commit admin list).
enforceNoAdminDepletion(proposals)
// Capture the pre-commit exporter secret BEFORE any mutation.
// Publishers of the outbound kind:445 MUST outer-encrypt with this
// key (epoch N) so that other existing members at epoch N can decrypt
// and process the commit. See CommitResult.preCommitExporterSecret.
val preCommitExporterSecret =
exporterSecret("marmot", "group-event".encodeToByteArray(), 32)
val proposalOrRefs = proposals.map { ProposalOrRef.Inline(it.proposal) }
// Check if we need an UpdatePath (required unless only SelfRemove)
@@ -502,7 +512,10 @@ class MlsGroup private constructor(
val newConfirmedTranscriptHash = MlsCryptoProvider.hash(confirmedInput.toByteArray())
val newTreeHash = tree.treeHash()
val newEpoch = groupContext.epoch + 1
val oldEpoch = groupContext.epoch
val preCommitGroupId = groupContext.groupId
val committerLeafIndex = myLeafIndex
val newEpoch = oldEpoch + 1
groupContext =
groupContext.copy(
@@ -544,7 +557,21 @@ class MlsGroup private constructor(
sentKeys.clear()
val commitBytes = commit.toTlsBytes()
return CommitResult(commitBytes, welcomeBytes, null)
val framedCommitBytes =
framePublicMessageCommit(
groupId = preCommitGroupId,
epoch = oldEpoch,
senderLeafIndex = committerLeafIndex,
commitBytes = commitBytes,
confirmationTag = confirmationTag,
)
return CommitResult(
commitBytes = commitBytes,
welcomeBytes = welcomeBytes,
groupInfoBytes = null,
framedCommitBytes = framedCommitBytes,
preCommitExporterSecret = preCommitExporterSecret,
)
}
// --- Message Encryption ---
@@ -1475,6 +1502,38 @@ class MlsGroup private constructor(
private const val REUSE_GUARD_LENGTH = 4
private const val RATCHET_TREE_EXTENSION_TYPE = 0x0001
/**
* Wrap a raw [Commit] (as [commitBytes]) in an MlsMessage(PublicMessage(...))
* envelope so it can be published on the wire (RFC 9420 §6 / §6.2).
*
* The receiver uses the sender's leaf index and the confirmation_tag from
* the [PublicMessage] header to drive [MlsGroup.processCommit]. The
* `signature` and `membership_tag` opaque fields are intentionally empty —
* the current implementation does not verify them on inbound commits,
* but the TLS structure must still be present so decoding succeeds.
*/
internal fun framePublicMessageCommit(
groupId: ByteArray,
epoch: Long,
senderLeafIndex: Int,
commitBytes: ByteArray,
confirmationTag: ByteArray,
): ByteArray {
val publicMessage =
PublicMessage(
groupId = groupId,
epoch = epoch,
sender = Sender(SenderType.MEMBER, senderLeafIndex),
authenticatedData = ByteArray(0),
contentType = ContentType.COMMIT,
content = commitBytes,
signature = ByteArray(0),
confirmationTag = confirmationTag,
membershipTag = ByteArray(0),
)
return MlsMessage.fromPublicMessage(publicMessage).toTlsBytes()
}
/**
* Build ConfirmedTranscriptHashInput (RFC 9420 Section 8.2) — static version
* usable from both instance methods and companion object factory methods.
@@ -2020,7 +2079,9 @@ class MlsGroup private constructor(
/**
* Add a member to the group by their KeyPackage.
* Creates and applies a Commit with an Add proposal.
* Creates and applies a Commit with an Add proposal. The resulting
* [CommitResult.preCommitExporterSecret] is the key the outer kind:445
* MUST be encrypted with (RFC 9420 §12.4 + MDK parity).
*/
fun addMember(keyPackageBytes: ByteArray): CommitResult {
proposeAdd(keyPackageBytes)
@@ -2029,7 +2090,8 @@ class MlsGroup private constructor(
/**
* Remove a member from the group.
* Creates and applies a Commit with a Remove proposal.
* Creates and applies a Commit with a Remove proposal. See [addMember]
* for the pre-commit exporter key contract on the returned [CommitResult].
*/
fun removeMember(targetLeafIndex: Int): CommitResult {
proposeRemove(targetLeafIndex)
@@ -79,8 +79,11 @@ import kotlinx.coroutines.sync.withLock
* ## Cross-Implementation Notes
*
* This manager uses ciphersuite 0x0001 (MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519).
* The epoch secret retention window is [EPOCH_RETENTION_WINDOW] = 2, meaning secrets
* for the current and previous epoch are kept for late-message decryption.
* The epoch secret retention window is [EPOCH_RETENTION_WINDOW] = 5, matching the
* `DEFAULT_EPOCH_LOOKBACK` used by the MDK reference implementation so that late-
* arriving MIP-03 GroupEvents (and MLS application messages) whose outer ChaCha20-
* Poly1305 key was derived from a prior epoch's exporter secret can still be
* decrypted after a Commit advances the group.
*
* Thread safety: All suspending mutation methods are guarded by a [Mutex]
* to prevent concurrent state corruption. Non-suspending read methods
@@ -264,11 +267,9 @@ class MlsGroupManager(
suspend fun commit(nostrGroupId: HexKey): CommitResult =
mutex.withLock {
val group = requireGroup(nostrGroupId)
// Retain current epoch secrets before transition
retainEpochSecrets(nostrGroupId, group)
val retainedBefore = group.retainedSecrets()
val result = group.commit()
pushRetainedEpoch(nostrGroupId, retainedBefore)
persistGroup(nostrGroupId)
result
}
@@ -289,10 +290,16 @@ class MlsGroupManager(
) = mutex.withLock {
val group = requireGroup(nostrGroupId)
// Retain current epoch secrets before transition
retainEpochSecrets(nostrGroupId, group)
// Capture the outgoing epoch's secrets BEFORE advancing, but only
// commit them to the retention window once processCommit succeeds —
// otherwise a failed commit (e.g. "Duplicate encryption key" on an
// add-me relay echo) would pollute the window with a duplicate of
// the current epoch key, wasting the finite retention slots.
val retainedBefore = group.retainedSecrets()
group.processCommit(commitBytes, senderLeafIndex, confirmationTag)
pushRetainedEpoch(nostrGroupId, retainedBefore)
persistGroup(nostrGroupId)
}
@@ -356,6 +363,12 @@ class MlsGroupManager(
/**
* Add a member and create a Commit.
*
* The returned [CommitResult.preCommitExporterSecret] is the key the
* outbound kind:445 MUST be outer-encrypted with (RFC 9420 §12.4 + MDK
* parity). The local group state advances to the new epoch before this
* function returns; publishers get one shot at the correct pre-commit
* key via the [CommitResult] payload.
*/
suspend fun addMember(
nostrGroupId: HexKey,
@@ -363,14 +376,16 @@ class MlsGroupManager(
): CommitResult =
mutex.withLock {
val group = requireGroup(nostrGroupId)
retainEpochSecrets(nostrGroupId, group)
val retainedBefore = group.retainedSecrets()
val result = group.addMember(keyPackageBytes)
pushRetainedEpoch(nostrGroupId, retainedBefore)
persistGroup(nostrGroupId)
result
}
/**
* Remove a member and create a Commit.
* Remove a member and create a Commit. See [addMember] for the
* pre-commit exporter key contract on the returned [CommitResult].
*/
suspend fun removeMember(
nostrGroupId: HexKey,
@@ -378,8 +393,9 @@ class MlsGroupManager(
): CommitResult =
mutex.withLock {
val group = requireGroup(nostrGroupId)
retainEpochSecrets(nostrGroupId, group)
val retainedBefore = group.retainedSecrets()
val result = group.removeMember(targetLeafIndex)
pushRetainedEpoch(nostrGroupId, retainedBefore)
persistGroup(nostrGroupId)
result
}
@@ -388,31 +404,22 @@ class MlsGroupManager(
* Rotate the signing key within a group and commit.
*
* Per MIP-00, members SHOULD self-update within 24 hours of joining.
* This creates an Update proposal with a fresh signing key and commits it.
*
* @param nostrGroupId hex-encoded Nostr group ID
* @return the [CommitResult] to publish
* See [addMember] for the pre-commit exporter key contract.
*/
suspend fun rotateSigningKey(nostrGroupId: HexKey): CommitResult =
mutex.withLock {
val group = requireGroup(nostrGroupId)
retainEpochSecrets(nostrGroupId, group)
val retainedBefore = group.retainedSecrets()
group.proposeSigningKeyRotation()
val result = group.commit()
pushRetainedEpoch(nostrGroupId, retainedBefore)
persistGroup(nostrGroupId)
result
}
/**
* Update group extensions (e.g., MIP-01 metadata) via a GroupContextExtensions proposal.
* Creates the proposal, commits it, and persists the new state.
*
* Authorization (MIP-01): extension updates require admin privileges once
* the group has at least one admin. During bootstrap before any
* `admin_pubkeys` are configured any member may seed the initial
* extension set (e.g. the creator installing the first
* [com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData] with
* themselves as admin).
* See [addMember] for the pre-commit exporter key contract.
*/
suspend fun updateGroupExtensions(
nostrGroupId: HexKey,
@@ -425,9 +432,10 @@ class MlsGroupManager(
check(!adminsConfigured || group.isLocalAdmin()) {
"MIP-01: only admins may update group extensions"
}
retainEpochSecrets(nostrGroupId, group)
val retainedBefore = group.retainedSecrets()
group.proposeGroupContextExtensions(extensions)
val result = group.commit()
pushRetainedEpoch(nostrGroupId, retainedBefore)
persistGroup(nostrGroupId)
result
}
@@ -561,12 +569,18 @@ class MlsGroupManager(
}
}
private fun retainEpochSecrets(
/**
* Push a previously-captured [RetainedEpochSecrets] into the bounded
* retention window. Call after the epoch advance has been applied
* successfully so that failed commits don't pollute the window with
* duplicate current-epoch keys.
*/
private fun pushRetainedEpoch(
nostrGroupId: HexKey,
group: MlsGroup,
retainedBefore: RetainedEpochSecrets,
) {
val retained = retainedEpochs.getOrPut(nostrGroupId) { mutableListOf() }
retained.add(group.retainedSecrets())
retained.add(retainedBefore)
// Trim to retention window (keep only the most recent N-1 epochs)
while (retained.size > EPOCH_RETENTION_WINDOW) {
@@ -662,9 +676,11 @@ class MlsGroupManager(
/**
* Number of past epochs to retain for late-arriving message decryption.
* MLS forward secrecy guarantees mean we want to limit this window.
* Matches MDK's `DEFAULT_EPOCH_LOOKBACK` so a message encrypted under
* the prior N epochs' exporter secrets can still be decrypted after a
* Commit advances the group. Capped for forward-secrecy reasons.
*/
const val EPOCH_RETENTION_WINDOW = 2
const val EPOCH_RETENTION_WINDOW = 5
/** Size of reuse_guard in PrivateMessage (RFC 9420 §6.3.1) */
private const val REUSE_GUARD_LENGTH = 4
@@ -91,11 +91,34 @@ data class UpdatePath(
/**
* Result of creating a Commit: the MLS messages to distribute.
*
* [commitBytes] is the raw TLS-encoded [Commit] struct (RFC 9420 §12.4), useful
* for unit tests and the [com.vitorpamplona.quartz.marmot.mls.group.MlsGroup.processCommit]
* entry point. For on-the-wire distribution, callers MUST publish
* [framedCommitBytes] (the MlsMessage(PublicMessage(FramedContent(commit))) envelope)
* so that receivers can parse the sender's leaf index and confirmation tag.
*/
data class CommitResult(
val commitBytes: ByteArray,
val welcomeBytes: ByteArray?,
val groupInfoBytes: ByteArray?,
/**
* Fully-framed commit ready for the MIP-03 outer ChaCha20 encryption.
* Wire format: MlsMessage(version=mls10, wireFormat=mls_public_message, payload=PublicMessage(...)).
*/
val framedCommitBytes: ByteArray = commitBytes,
/**
* `MLS-Exporter("marmot", "group-event", 32)` evaluated at the **pre-commit**
* epoch (N) the key the group had when this commit was computed, before
* local state advanced to N+1. Publishers of the kind:445 MUST ChaCha20-wrap
* the commit with this key (RFC 9420 §12.4 and MDK parity). Using the
* post-commit (N+1) key makes the commit unreadable to existing members
* still at epoch N the exact scenario that caused Eden to stall at
* epoch 1 and never decrypt any of David's subsequent messages.
*
* Empty by default for the test-only entry points that don't need it.
*/
val preCommitExporterSecret: ByteArray = ByteArray(0),
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
@@ -47,9 +47,6 @@ class SecretTree(
private val encryptionSecret: ByteArray,
private val leafCount: Int,
) {
/** Cached tree node secrets, computed lazily */
private val treeSecrets = mutableMapOf<Int, ByteArray>()
/** Per-sender ratchet state: (handshake generation, handshake secret, app generation, app secret) */
private val senderState = mutableMapOf<Int, SenderRatchetState>()
@@ -72,11 +69,6 @@ class SecretTree(
const val MAX_CONSUMED_GENERATIONS_PER_SENDER = 1000
}
init {
// Seed the root
treeSecrets[BinaryTree.root(leafCount)] = encryptionSecret
}
/**
* Get the next (key, nonce) for application messages from a given sender.
*/
@@ -252,36 +244,51 @@ class SecretTree(
}
/**
* Derive the leaf secret from the encryption secret using the tree structure.
* Derive the leaf secret from the encryption secret by walking DOWN the
* binary tree from the root to the target leaf (RFC 9420 §9).
*
* At each step we pick left or right based on which subtree contains the
* target. In an MLS left-balanced tree the left-subtree node indices are
* always strictly less than the current node, and the right-subtree
* indices strictly greater so the direction is simply
* `targetNode < currentNode`.
*
* This also correctly handles non-power-of-2 leaf counts (3, 5, 6, 7, 9
* ) where the rightmost leaves live beneath "virtual" intermediate
* nodes that are beyond `nodeCount`. Walking down still succeeds because
* [BinaryTree.left] / [BinaryTree.right] give the correct descendants
* even for virtual nodes, and the target leaf is reachable via a chain
* of left/right steps that mixes real and virtual intermediates.
*
* The previous implementation derived the target's secret from
* `BinaryTree.parent(target)` which, for leaves on the right edge of a
* non-full tree, returned a high ancestor (often the root) that is NOT
* the target's direct parent. Its `left()` and `right()` then pointed
* at different nodes entirely, the target stayed un-cached, and the
* follow-up `return treeSecrets[nodeIndex]!!` either threw NPE (on
* JVM) or when repeatedly re-entered recursed into
* [getNodeSecret] until the stack was exhausted (on ART).
*/
private fun getLeafSecret(leafIndex: Int): ByteArray {
val nodeIndex = BinaryTree.leafToNode(leafIndex)
return getNodeSecret(nodeIndex)
}
val targetNode = BinaryTree.leafToNode(leafIndex)
val rootIdx = BinaryTree.root(leafCount)
var currentSecret = encryptionSecret
var currentNode = rootIdx
/**
* Recursively derive a node's secret from its parent in the secret tree.
*/
private fun getNodeSecret(nodeIndex: Int): ByteArray {
treeSecrets[nodeIndex]?.let { return it }
while (currentNode != targetNode) {
val goLeft = targetNode < currentNode
val label = if (goLeft) "left" else "right"
currentSecret =
MlsCryptoProvider.expandWithLabel(
currentSecret,
"tree",
label.encodeToByteArray(),
MlsCryptoProvider.HASH_OUTPUT_LENGTH,
)
currentNode = if (goLeft) BinaryTree.left(currentNode) else BinaryTree.right(currentNode)
}
val parentIdx = BinaryTree.parent(nodeIndex, BinaryTree.nodeCount(leafCount))
val parentSecret = getNodeSecret(parentIdx)
// Derive left and right children secrets
val leftIdx = BinaryTree.left(parentIdx)
val rightIdx = BinaryTree.right(parentIdx)
val leftSecret = MlsCryptoProvider.expandWithLabel(parentSecret, "tree", "left".encodeToByteArray(), MlsCryptoProvider.HASH_OUTPUT_LENGTH)
val rightSecret = MlsCryptoProvider.expandWithLabel(parentSecret, "tree", "right".encodeToByteArray(), MlsCryptoProvider.HASH_OUTPUT_LENGTH)
treeSecrets[leftIdx] = leftSecret
treeSecrets[rightIdx] = rightSecret
// Clear parent secret for forward secrecy
treeSecrets.remove(parentIdx)
return treeSecrets[nodeIndex]!!
return currentSecret
}
}
@@ -0,0 +1,114 @@
/*
* 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.nip53LiveActivities.clip
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider
import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle
import com.vitorpamplona.quartz.nip01Core.hints.PubKeyHintProvider
import com.vitorpamplona.quartz.nip01Core.hints.types.AddressHint
import com.vitorpamplona.quartz.nip01Core.hints.types.PubKeyHint
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag
import com.vitorpamplona.quartz.nip01Core.tags.aTag.aTag
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
import com.vitorpamplona.quartz.nip01Core.tags.references.ReferenceTag
import com.vitorpamplona.quartz.nip23LongContent.tags.TitleTag
import com.vitorpamplona.quartz.nip31Alts.AltTag
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent
import com.vitorpamplona.quartz.utils.TimeUtils
/**
* NIP-53 live stream clip (zap.stream convention, kind 1313).
*
* A clip is a standalone highlight produced from an ongoing or past live stream.
* The event carries:
* - `a` -> the source stream address (kind 30311)
* - `p` -> the stream host's pubkey
* - `r` -> direct playable video URL (MP4/HLS)
* - `title` -> clip title
* - `alt` -> NIP-31 fallback text
*
* `content` is an optional free-text caption.
*/
@Immutable
class LiveActivitiesClipEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : Event(id, pubKey, createdAt, KIND, tags, content, sig),
AddressHintProvider,
PubKeyHintProvider {
override fun addressHints(): List<AddressHint> = tags.mapNotNull(ATag::parseAsHint)
override fun linkedAddressIds(): List<String> = tags.mapNotNull(ATag::parseAddressId)
override fun pubKeyHints(): List<PubKeyHint> = tags.mapNotNull(PTag::parseAsHint)
override fun linkedPubKeys(): List<HexKey> = tags.mapNotNull(PTag::parseKey)
fun activity(): ATag? =
tags
.asSequence()
.mapNotNull(ATag::parse)
.firstOrNull { it.kind == LiveActivitiesEvent.KIND }
fun activityAddress(): Address? = activity()?.let { Address(it.kind, it.pubKeyHex, it.dTag) }
fun host(): HexKey? = tags.firstNotNullOfOrNull(PTag::parseKey)
fun videoUrl(): String? = tags.firstNotNullOfOrNull(ReferenceTag::parse)
fun title(): String? = tags.firstNotNullOfOrNull(TitleTag::parse)
companion object {
const val KIND = 1313
const val ALT = "Live activity clip"
/**
* Builds an event template for a clip. Typically published by the clip-authoring backend
* on behalf of a viewer, but can also be published directly by a client.
*/
fun build(
activity: EventHintBundle<LiveActivitiesEvent>,
videoUrl: String,
title: String,
host: HexKey = activity.event.pubKey,
caption: String = "",
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<LiveActivitiesClipEvent>.() -> Unit = {},
) = eventTemplate(KIND, caption, createdAt) {
aTag(ATag(activity.event.kind, activity.event.pubKey, activity.event.dTag(), activity.relay))
add(PTag.assemble(host, null))
add(ReferenceTag.assemble(videoUrl))
add(TitleTag.assemble(title))
add(AltTag.assemble(ALT))
initializer()
}
}
}
@@ -0,0 +1,115 @@
/*
* 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.nip53LiveActivities.raid
import androidx.compose.runtime.Immutable
import com.vitorpamplona.quartz.nip01Core.core.Address
import com.vitorpamplona.quartz.nip01Core.core.Event
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.hints.AddressHintProvider
import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle
import com.vitorpamplona.quartz.nip01Core.hints.types.AddressHint
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
import com.vitorpamplona.quartz.nip01Core.tags.aTag.ATag
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent
import com.vitorpamplona.quartz.utils.TimeUtils
/**
* NIP-53 live stream raid (zap.stream convention, kind 1312).
*
* A raid is authored by the source streamer to redirect viewers to another live
* stream. The event carries two `a` tags referencing NIP-53 Live Activities
* (kind 30311) differentiated by NIP-10-style markers at position 3:
* - "root" -> the source stream (the raid is being sent FROM)
* - "mention" -> the target stream (the raid is being sent TO)
*
* The `content` is a free-text raid message from the source streamer.
*/
@Immutable
class LiveActivitiesRaidEvent(
id: HexKey,
pubKey: HexKey,
createdAt: Long,
tags: Array<Array<String>>,
content: String,
sig: HexKey,
) : Event(id, pubKey, createdAt, KIND, tags, content, sig),
AddressHintProvider {
override fun addressHints(): List<AddressHint> = tags.mapNotNull(ATag::parseAsHint)
override fun linkedAddressIds(): List<String> = tags.mapNotNull(ATag::parseAddressId)
fun fromActivity(): ATag? = findActivity(MARKER_ROOT)
fun toActivity(): ATag? = findActivity(MARKER_MENTION)
fun fromAddress(): Address? = fromActivity()?.let { Address(it.kind, it.pubKeyHex, it.dTag) }
fun toAddress(): Address? = toActivity()?.let { Address(it.kind, it.pubKeyHex, it.dTag) }
private fun findActivity(marker: String): ATag? =
tags
.asSequence()
.filter { it.size > 3 && it[0] == ATag.TAG_NAME && it[3] == marker }
.mapNotNull(ATag::parse)
.firstOrNull { it.kind == LiveActivitiesEvent.KIND }
companion object {
const val KIND = 1312
const val ALT = "Live activity raid"
const val MARKER_ROOT = "root"
const val MARKER_MENTION = "mention"
/**
* Builds an event template for a raid. Sender must be the host of the `from` stream.
*
* @param from source stream (the one currently live that is being ended/redirected)
* @param to target stream (where viewers should be redirected)
* @param message raid announcement text
*/
fun build(
from: EventHintBundle<LiveActivitiesEvent>,
to: EventHintBundle<LiveActivitiesEvent>,
message: String = "",
createdAt: Long = TimeUtils.now(),
initializer: TagArrayBuilder<LiveActivitiesRaidEvent>.() -> Unit = {},
) = eventTemplate(KIND, message, createdAt) {
addMarkedATag(from, MARKER_ROOT)
addMarkedATag(to, MARKER_MENTION)
initializer()
}
private fun TagArrayBuilder<LiveActivitiesRaidEvent>.addMarkedATag(
bundle: EventHintBundle<LiveActivitiesEvent>,
marker: String,
) {
val relayUrl = bundle.relay?.url.orEmpty()
val addressId =
Address.assemble(
LiveActivitiesEvent.KIND,
bundle.event.pubKey,
bundle.event.dTag(),
)
add(arrayOf(ATag.TAG_NAME, addressId, relayUrl, marker))
}
}
}
@@ -117,6 +117,12 @@ class LiveActivitiesEvent(
fun pinned() = tags.mapNotNull(PinnedEventTag::parse)
/**
* zap.stream convention: a NIP-75 zap goal (kind 9041) is attached to a live stream
* via a flat tag `["goal", "<hex event id>"]` on the 30311 event.
*/
fun goalEventId(): HexKey? = tags.firstOrNull { it.size > 1 && it[0] == GOAL_TAG && it[1].isNotEmpty() }?.get(1)
fun checkStatus(eventStatus: StatusTag.STATUS?): StatusTag.STATUS? =
if (eventStatus == StatusTag.STATUS.LIVE && createdAt < TimeUtils.eightHoursAgo()) {
StatusTag.STATUS.ENDED
@@ -139,6 +145,7 @@ class LiveActivitiesEvent(
companion object {
const val KIND = 30311
const val ALT = "Live activity event"
const val GOAL_TAG = "goal"
suspend fun create(
signer: NostrSigner,
@@ -26,6 +26,7 @@ import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip01Core.hints.EventHintBundle
import com.vitorpamplona.quartz.nip01Core.tags.aTag.toATag
import com.vitorpamplona.quartz.nip01Core.tags.events.toETagArray
import com.vitorpamplona.quartz.nip01Core.tags.people.PTag
import com.vitorpamplona.quartz.nip72ModCommunities.definition.CommunityDefinitionEvent
fun TagArrayBuilder<CommunityPostApprovalEvent>.community(event: EventHintBundle<CommunityDefinitionEvent>) = add(event.toATag().toATagArray())
@@ -37,6 +38,4 @@ fun TagArrayBuilder<CommunityPostApprovalEvent>.approved(event: EventHintBundle<
}
}
fun TagArrayBuilder<CommunityPostApprovalEvent>.notifyAuthor(event: EventHintBundle<Event>) {
add(event.toETagArray())
}
fun TagArrayBuilder<CommunityPostApprovalEvent>.notifyAuthor(event: EventHintBundle<Event>) = add(PTag.assemble(event.event.pubKey, event.authorHomeRelay))
@@ -21,18 +21,22 @@
package com.vitorpamplona.quartz.nip72ModCommunities.definition
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
import com.vitorpamplona.quartz.nip23LongContent.tags.ImageTag
import com.vitorpamplona.quartz.nip72ModCommunities.definition.tags.DescriptionTag
import com.vitorpamplona.quartz.nip72ModCommunities.definition.tags.ImageTag
import com.vitorpamplona.quartz.nip72ModCommunities.definition.tags.ModeratorTag
import com.vitorpamplona.quartz.nip72ModCommunities.definition.tags.NameTag
import com.vitorpamplona.quartz.nip72ModCommunities.definition.tags.RelayTag
import com.vitorpamplona.quartz.nip72ModCommunities.definition.tags.RulesTag
import com.vitorpamplona.quartz.nip94FileMetadata.tags.DimensionTag
fun TagArrayBuilder<CommunityDefinitionEvent>.name(name: String) = addUnique(NameTag.assemble(name))
fun TagArrayBuilder<CommunityDefinitionEvent>.description(description: String) = addUnique(DescriptionTag.assemble(description))
fun TagArrayBuilder<CommunityDefinitionEvent>.image(webUrl: String) = addUnique(ImageTag.assemble(webUrl))
fun TagArrayBuilder<CommunityDefinitionEvent>.image(
webUrl: String,
dimensions: DimensionTag? = null,
) = addUnique(ImageTag.assemble(webUrl, dimensions))
fun TagArrayBuilder<CommunityDefinitionEvent>.rules(rules: String) = addUnique(RulesTag.assemble(rules))
@@ -35,6 +35,10 @@ class RelayTag(
companion object {
const val TAG_NAME = "relay"
const val MARKER_AUTHOR = "author"
const val MARKER_REQUESTS = "requests"
const val MARKER_APPROVALS = "approvals"
fun parse(tag: Array<String>): RelayTag? {
ensure(tag.has(1)) { return null }
ensure(tag[0] == TAG_NAME) { return null }
@@ -165,9 +165,11 @@ import com.vitorpamplona.quartz.nip52Calendar.appt.time.CalendarTimeSlotEvent
import com.vitorpamplona.quartz.nip52Calendar.calendar.CalendarEvent
import com.vitorpamplona.quartz.nip52Calendar.rsvp.CalendarRSVPEvent
import com.vitorpamplona.quartz.nip53LiveActivities.chat.LiveActivitiesChatMessageEvent
import com.vitorpamplona.quartz.nip53LiveActivities.clip.LiveActivitiesClipEvent
import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingRoomEvent
import com.vitorpamplona.quartz.nip53LiveActivities.meetingSpaces.MeetingSpaceEvent
import com.vitorpamplona.quartz.nip53LiveActivities.presence.MeetingRoomPresenceEvent
import com.vitorpamplona.quartz.nip53LiveActivities.raid.LiveActivitiesRaidEvent
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent
import com.vitorpamplona.quartz.nip54Wiki.WikiNoteEvent
import com.vitorpamplona.quartz.nip56Reports.ReportEvent
@@ -436,7 +438,9 @@ class EventFactory {
KindMuteSetEvent.KIND -> KindMuteSetEvent(id, pubKey, createdAt, tags, content, sig)
LabeledBookmarkListEvent.KIND -> LabeledBookmarkListEvent(id, pubKey, createdAt, tags, content, sig)
LiveActivitiesChatMessageEvent.KIND -> LiveActivitiesChatMessageEvent(id, pubKey, createdAt, tags, content, sig)
LiveActivitiesClipEvent.KIND -> LiveActivitiesClipEvent(id, pubKey, createdAt, tags, content, sig)
LiveActivitiesEvent.KIND -> LiveActivitiesEvent(id, pubKey, createdAt, tags, content, sig)
LiveActivitiesRaidEvent.KIND -> LiveActivitiesRaidEvent(id, pubKey, createdAt, tags, content, sig)
LnZapEvent.KIND -> LnZapEvent(id, pubKey, createdAt, tags, content, sig)
LnZapPaymentRequestEvent.KIND -> LnZapPaymentRequestEvent(id, pubKey, createdAt, tags, content, sig)
LnZapPaymentResponseEvent.KIND -> LnZapPaymentResponseEvent(id, pubKey, createdAt, tags, content, sig)
@@ -37,8 +37,20 @@ class KeyPackageUtilsTest {
private val testPubKey = "a".repeat(64)
private val testRef = "b".repeat(64)
/**
* Per MIP-00 the `d` tag MUST be 64 lowercase hex characters
* (a random 32-byte slot ID). The strict [KeyPackageUtils.isValid] enforces
* this on the parse side, so test fixtures need realistic 64-char hex
* slot IDs even when we only care about the selection logic.
*/
private fun slot(label: Int): String = "0".repeat(63) + label.toString(16)
private val slot0 = slot(0)
private val slot1 = slot(1)
private val slotLastResort = "f".repeat(64)
private fun makeKeyPackageEvent(
dTag: String = "0",
dTag: String = slot0,
createdAt: Long = 1000,
encoding: String = "base64",
ciphersuite: String = "0x0001",
@@ -105,8 +117,8 @@ class KeyPackageUtilsTest {
@Test
fun testSelectBest_PrefersNewest() {
val old = makeKeyPackageEvent(dTag = "0", createdAt = 1000)
val newer = makeKeyPackageEvent(dTag = "1", createdAt = 2000)
val old = makeKeyPackageEvent(dTag = slot0, createdAt = 1000)
val newer = makeKeyPackageEvent(dTag = slot1, createdAt = 2000)
val best = KeyPackageUtils.selectBest(listOf(old, newer))
assertNotNull(best)
@@ -115,33 +127,33 @@ class KeyPackageUtilsTest {
@Test
fun testSelectBest_PrefersNonLastResort() {
val lastResort = makeKeyPackageEvent(dTag = "lr", createdAt = 3000)
val regular = makeKeyPackageEvent(dTag = "0", createdAt = 1000)
val lastResort = makeKeyPackageEvent(dTag = slotLastResort, createdAt = 3000)
val regular = makeKeyPackageEvent(dTag = slot0, createdAt = 1000)
// Even though lastResort is newer, regular is preferred
val best = KeyPackageUtils.selectBest(listOf(lastResort, regular), lastResortDTag = "lr")
val best = KeyPackageUtils.selectBest(listOf(lastResort, regular), lastResortDTag = slotLastResort)
assertNotNull(best)
assertEquals("0", best.dTag())
assertEquals(slot0, best.dTag())
}
@Test
fun testSelectBest_FallsBackToLastResort() {
val lastResort = makeKeyPackageEvent(dTag = "lr", createdAt = 3000)
val lastResort = makeKeyPackageEvent(dTag = slotLastResort, createdAt = 3000)
// Only last-resort available
val best = KeyPackageUtils.selectBest(listOf(lastResort), lastResortDTag = "lr")
val best = KeyPackageUtils.selectBest(listOf(lastResort), lastResortDTag = slotLastResort)
assertNotNull(best)
assertEquals("lr", best.dTag())
assertEquals(slotLastResort, best.dTag())
}
@Test
fun testSelectBest_FiltersOutInvalid() {
val invalid = makeKeyPackageEvent(dTag = "0", encoding = "raw")
val valid = makeKeyPackageEvent(dTag = "1", createdAt = 500)
val invalid = makeKeyPackageEvent(dTag = slot0, encoding = "raw")
val valid = makeKeyPackageEvent(dTag = slot1, createdAt = 500)
val best = KeyPackageUtils.selectBest(listOf(invalid, valid))
assertNotNull(best)
assertEquals("1", best.dTag())
assertEquals(slot1, best.dTag())
}
@Test
@@ -159,7 +171,7 @@ class KeyPackageUtilsTest {
val template =
KeyPackageUtils.buildRotation(
newKeyPackageBase64 = "bmV3IGtleXBhY2thZ2U=",
dTagSlot = "0",
dTagSlot = slot0,
newKeyPackageRef = testRef,
relays = emptyList(),
)
@@ -115,27 +115,24 @@ class MarmotMipComplianceTest {
@Test
fun marmotGroupData_rejectsZeroDisappearingSecsOnDecode() {
// Hand-crafted TLS blob: version=3, group_id=32x0, empty opaque2 for
// name/description/admins/relays/images, then disappearing_message_secs
// = 8 bytes of zero (invalid).
// Hand-crafted TLS blob (MIP-01 QUIC VarInt length prefixes):
// uint16 version=3 | opaque group_id[32] | 8x empty VarInt(0) fields
// (name..image_upload_key) | disappearing_message_secs = VarInt(8) + 8
// zero bytes (invalid per MIP-01).
val header =
ByteArray(2 + 32) {
// version + groupId
when (it) {
0 -> 0
1 -> 3
// version=3
else -> 0
}
}
// 8x opaque2 fields of length 0, each encoded as two zero bytes:
// 8 empty VarInt-prefixed opaque fields, each a single 0x00 byte:
// name, description, admin_pubkeys, relays, image_hash, image_key,
// image_nonce, image_upload_key
val zeroFields = ByteArray(8 * 2) // all zeros
// disappearing_message_secs opaque2 with 8 zero bytes
val disappearingField = ByteArray(2 + 8).also { it[1] = 8 }
val zeroFields = ByteArray(8) // all 0x00
// disappearing_message_secs: VarInt(8) = 0x08, then 8 zero bytes
val disappearingField = ByteArray(1 + 8).also { it[0] = 0x08 }
val blob = header + zeroFields + disappearingField
// decodeTls catches any exception and returns null
@@ -150,13 +147,124 @@ class MarmotMipComplianceTest {
it[0] = 0
it[1] = 99
}
val zeroFields = ByteArray(8 * 2) // name..image_upload_key
val disappearingField = ByteArray(2) // zero-length
val zeroFields = ByteArray(8) // 8x VarInt(0) for name..image_upload_key
val disappearingField = ByteArray(1) // VarInt(0) — zero-length field
val blob = header + zeroFields + disappearingField
assertNull(MarmotGroupData.decodeTls(blob))
}
@Test
fun marmotGroupData_rejectsDuplicateAdminPubkeys() {
// MIP-01: admin_pubkeys MUST NOT contain duplicate keys.
assertFailsWith<IllegalArgumentException> {
MarmotGroupData(
nostrGroupId = groupId32,
adminPubkeys = listOf(adminPubkey, adminPubkey),
)
}
}
// --- MIP-01 byte-level interop fixtures (MDK reference) ----------------
//
// These fixtures were produced by serializing the identical struct via the
// Rust `tls_codec` 0.4 crate used by MDK (see commit message for the
// generator). They pin Amethyst's v2 encoder output byte-for-byte against
// the MDK reference, so any future regression in VarInt framing surfaces
// immediately.
//
// Fixtures are v2 (no `disappearing_message_secs` field) because MDK's
// current `CURRENT_VERSION = 2` reference has no v3 support yet.
private fun mdkFixtureA(): ByteArray =
(
// version=2 + 32 bytes of group_id (all zero)
"0002" +
"00".repeat(32) +
// name=empty, description=empty (VarInt(0) = single 0x00)
"0000" +
// admin_pubkeys: VarInt(32) = 0x20, then one 32-byte key of 0xAA
"20" + "aa".repeat(32) +
// relays: outer VarInt(21) = 0x15; inner VarInt(20) = 0x14 +
// "wss://relay.example/" (20 bytes)
"15" + "14" + "7773733a2f2f72656c61792e6578616d706c652f" +
// image_hash, image_key, image_nonce, image_upload_key — all empty
"00000000"
).hexToByteArray()
private fun mdkFixtureB(): ByteArray =
(
"0002" +
"11".repeat(32) +
// name: VarInt(8)=0x08 + "Amethyst"
"08" + "416d657468797374" +
// description: VarInt(10)=0x0a + "Test group"
"0a" + "546573742067726f7570" +
// admin_pubkeys: outer VarInt(64) — 64 = 0x40, two-byte VarInt
// prefix "40 40" (high bits 01, value 0x0040) + 2×32 bytes
"4040" + "bb".repeat(32) + "cc".repeat(32) +
// relays outer VarInt(44) = 0x2c, then two inner relays each
// VarInt(21) + 21-byte URL
"2c" +
"15" + "7773733a2f2f72656c6179312e6578616d706c652f" +
"15" + "7773733a2f2f72656c6179322e6578616d706c652f" +
// image_* all empty
"00000000"
).hexToByteArray()
@Test
fun marmotGroupData_encodesFixtureAByteForByteVsMdk() {
// Encode an Amethyst MarmotGroupData with the same inputs and assert the
// bytes match MDK's tls_codec 0.4 output exactly.
val data =
MarmotGroupData(
version = 2,
nostrGroupId = "00".repeat(32),
name = "",
description = "",
adminPubkeys = listOf("aa".repeat(32)),
relays = listOf("wss://relay.example/"),
)
assertContentEquals(mdkFixtureA(), data.encodeTls())
}
@Test
fun marmotGroupData_encodesFixtureBByteForByteVsMdk() {
val data =
MarmotGroupData(
version = 2,
nostrGroupId = "11".repeat(32),
name = "Amethyst",
description = "Test group",
adminPubkeys = listOf("bb".repeat(32), "cc".repeat(32)),
relays = listOf("wss://relay1.example/", "wss://relay2.example/"),
)
assertContentEquals(mdkFixtureB(), data.encodeTls())
}
@Test
fun marmotGroupData_decodesMdkFixtureA() {
val decoded = assertNotNull(MarmotGroupData.decodeTls(mdkFixtureA()))
assertEquals(2, decoded.version)
assertEquals("00".repeat(32), decoded.nostrGroupId)
assertEquals("", decoded.name)
assertEquals("", decoded.description)
assertEquals(listOf("aa".repeat(32)), decoded.adminPubkeys)
assertEquals(listOf("wss://relay.example/"), decoded.relays)
assertNull(decoded.disappearingMessageSecs)
}
@Test
fun marmotGroupData_decodesMdkFixtureB() {
val decoded = assertNotNull(MarmotGroupData.decodeTls(mdkFixtureB()))
assertEquals(2, decoded.version)
assertEquals("Amethyst", decoded.name)
assertEquals("Test group", decoded.description)
assertEquals(listOf("bb".repeat(32), "cc".repeat(32)), decoded.adminPubkeys)
assertEquals(listOf("wss://relay1.example/", "wss://relay2.example/"), decoded.relays)
}
@Test
fun mip01ImageCrypto_deriveImageKeyIs32BytesAndDeterministic() {
val seed = "11".repeat(32).hexToByteArray()
@@ -0,0 +1,85 @@
/*
* 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.nip53LiveActivities.clip
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
class LiveActivitiesClipEventTest {
private val host = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
private val viewerAuthor = "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"
private val dummySig = "0".repeat(128)
@Test
fun parsesZapStreamShapedClip() {
val event =
LiveActivitiesClipEvent(
id = "2".repeat(64),
pubKey = viewerAuthor,
createdAt = 1_700_000_000L,
tags =
arrayOf(
arrayOf("a", "${LiveActivitiesEvent.KIND}:$host:stream-d", "wss://relay.example"),
arrayOf("p", host),
arrayOf("r", "https://cdn.example/clip.mp4"),
arrayOf("title", "Nice moment"),
arrayOf("alt", "Live stream clip"),
),
content = "Check this out",
sig = dummySig,
)
val activity = assertNotNull(event.activity())
assertEquals(LiveActivitiesEvent.KIND, activity.kind)
assertEquals(host, activity.pubKeyHex)
assertEquals("stream-d", activity.dTag)
assertEquals(host, event.host())
assertEquals("https://cdn.example/clip.mp4", event.videoUrl())
assertEquals("Nice moment", event.title())
assertEquals("Check this out", event.content)
}
@Test
fun ignoresClipLackingStreamReference() {
val event =
LiveActivitiesClipEvent(
id = "2".repeat(64),
pubKey = viewerAuthor,
createdAt = 1_700_000_000L,
tags =
arrayOf(
arrayOf("p", host),
arrayOf("r", "https://cdn.example/clip.mp4"),
arrayOf("title", "Nice moment"),
),
content = "",
sig = dummySig,
)
assertNull(event.activity())
assertNull(event.activityAddress())
assertEquals("https://cdn.example/clip.mp4", event.videoUrl())
}
}
@@ -0,0 +1,85 @@
/*
* 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.nip53LiveActivities.raid
import com.vitorpamplona.quartz.nip53LiveActivities.streaming.LiveActivitiesEvent
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
class LiveActivitiesRaidEventTest {
private val sourceHost = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
private val targetHost = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"
private val author = "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"
private val dummySig = "0".repeat(128)
@Test
fun parsesRootAndMentionAddresses() {
val event =
LiveActivitiesRaidEvent(
id = "1".repeat(64),
pubKey = author,
createdAt = 1_700_000_000L,
tags =
arrayOf(
arrayOf("a", "${LiveActivitiesEvent.KIND}:$sourceHost:source-d", "wss://relay.example", "root"),
arrayOf("a", "${LiveActivitiesEvent.KIND}:$targetHost:target-d", "", "mention"),
),
content = "Heading over to stream!",
sig = dummySig,
)
val from = assertNotNull(event.fromAddress())
assertEquals(LiveActivitiesEvent.KIND, from.kind)
assertEquals(sourceHost, from.pubKeyHex)
assertEquals("source-d", from.dTag)
val to = assertNotNull(event.toAddress())
assertEquals(LiveActivitiesEvent.KIND, to.kind)
assertEquals(targetHost, to.pubKeyHex)
assertEquals("target-d", to.dTag)
}
@Test
fun ignoresUnmarkedOrWrongKindATags() {
val event =
LiveActivitiesRaidEvent(
id = "1".repeat(64),
pubKey = author,
createdAt = 1_700_000_000L,
tags =
arrayOf(
// Wrong marker
arrayOf("a", "${LiveActivitiesEvent.KIND}:$sourceHost:x", "", "reply"),
// Wrong kind
arrayOf("a", "30023:$sourceHost:article", "", "root"),
// No marker at all
arrayOf("a", "${LiveActivitiesEvent.KIND}:$sourceHost:y"),
),
content = "",
sig = dummySig,
)
assertNull(event.fromAddress())
assertNull(event.toAddress())
}
}
@@ -0,0 +1,85 @@
/*
* 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.nip53LiveActivities.streaming
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull
class LiveActivitiesEventGoalTagTest {
private val host = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"
private val goalId = "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"
private val dummySig = "0".repeat(128)
@Test
fun readsZapStreamGoalTag() {
val event =
LiveActivitiesEvent(
id = "3".repeat(64),
pubKey = host,
createdAt = 1_700_000_000L,
tags =
arrayOf(
arrayOf("d", "stream-d"),
arrayOf("title", "My stream"),
arrayOf("goal", goalId),
),
content = "",
sig = dummySig,
)
assertEquals(goalId, event.goalEventId())
}
@Test
fun returnsNullWhenNoGoalTag() {
val event =
LiveActivitiesEvent(
id = "3".repeat(64),
pubKey = host,
createdAt = 1_700_000_000L,
tags = arrayOf(arrayOf("d", "stream-d")),
content = "",
sig = dummySig,
)
assertNull(event.goalEventId())
}
@Test
fun returnsNullWhenGoalTagEmpty() {
val event =
LiveActivitiesEvent(
id = "3".repeat(64),
pubKey = host,
createdAt = 1_700_000_000L,
tags =
arrayOf(
arrayOf("d", "stream-d"),
arrayOf("goal", ""),
),
content = "",
sig = dummySig,
)
assertNull(event.goalEventId())
}
}
@@ -25,6 +25,7 @@ import com.vitorpamplona.quartz.marmot.mip03GroupMessages.GroupEvent
import com.vitorpamplona.quartz.marmot.mip03GroupMessages.GroupEventEncryption
import com.vitorpamplona.quartz.marmot.mls.group.MlsGroupManager
import com.vitorpamplona.quartz.marmot.mls.group.MlsGroupStateStore
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
import kotlinx.coroutines.runBlocking
@@ -225,6 +226,87 @@ class MarmotPipelineTest {
}
}
@Test
fun testAddMemberCommitIsFramedAsPublicMessage() {
// Regression: addMember's outbound commit used to carry the raw Commit TLS
// bytes instead of an MlsMessage(PublicMessage(commit)) envelope, which
// caused receivers to fail parsing with "Unsupported MLS version: …"
// when the first two bytes of the Commit struct were read as the
// MlsMessage version field.
runBlocking {
val manager = createGroupManager()
manager.createGroup(groupId, "alice".encodeToByteArray())
val group = manager.getGroup(groupId)!!
val bobBundle = group.createKeyPackage("bob".encodeToByteArray(), ByteArray(0))
val commitResult = manager.addMember(groupId, bobBundle.keyPackage.toTlsBytes())
// The framedCommitBytes must decode as an MlsMessage(PublicMessage(commit))
val framed = commitResult.framedCommitBytes
val mlsMessage =
com.vitorpamplona.quartz.marmot.mls.framing.MlsMessage
.decodeTls(
com.vitorpamplona.quartz.marmot.mls.codec
.TlsReader(framed),
)
assertEquals(
com.vitorpamplona.quartz.marmot.mls.framing.WireFormat.PUBLIC_MESSAGE,
mlsMessage.wireFormat,
)
val publicMessage =
com.vitorpamplona.quartz.marmot.mls.framing.PublicMessage
.decodeTls(
com.vitorpamplona.quartz.marmot.mls.codec
.TlsReader(mlsMessage.payload),
)
assertEquals(
com.vitorpamplona.quartz.marmot.mls.framing.ContentType.COMMIT,
publicMessage.contentType,
)
assertEquals(
com.vitorpamplona.quartz.marmot.mls.framing.SenderType.MEMBER,
publicMessage.sender.senderType,
)
assertNotNull(publicMessage.confirmationTag, "confirmation_tag must be present on a commit")
// The PublicMessage.content carries the raw Commit struct.
kotlin.test.assertContentEquals(commitResult.commitBytes, publicMessage.content)
}
}
@Test
fun testAddMemberCommitEventDecryptsToFramedMlsMessage() {
// End-to-end variant: the kind:445 content returned by buildCommitEvent,
// when ChaCha20-Poly1305 decrypted with the current exporter key, must
// yield an MlsMessage whose wire format is PUBLIC_MESSAGE (not a raw
// Commit struct).
runBlocking {
val manager = createGroupManager()
manager.createGroup(groupId, "alice".encodeToByteArray())
val group = manager.getGroup(groupId)!!
val bobBundle = group.createKeyPackage("bob".encodeToByteArray(), ByteArray(0))
val commitResult = manager.addMember(groupId, bobBundle.keyPackage.toTlsBytes())
val outbound = MarmotOutboundProcessor(manager)
val outboundResult = outbound.buildCommitEvent(groupId, commitResult.framedCommitBytes)
val event = outboundResult.signedEvent
val exporterKey = manager.exporterSecret(groupId)
val mlsBytes = GroupEventEncryption.decrypt(event.content, exporterKey)
val mlsMessage =
com.vitorpamplona.quartz.marmot.mls.framing.MlsMessage
.decodeTls(
com.vitorpamplona.quartz.marmot.mls.codec
.TlsReader(mlsBytes),
)
assertEquals(
com.vitorpamplona.quartz.marmot.mls.framing.WireFormat.PUBLIC_MESSAGE,
mlsMessage.wireFormat,
)
}
}
@Test
fun testSubscriptionManagerSyncWithGroupManager() {
runBlocking {
@@ -346,6 +428,450 @@ class MarmotPipelineTest {
}
}
@Test
fun testCreatorDoesNotDoubleApplyOwnCommitAfterRestart() {
// Root cause: after David restarts, his own add-member kind:445 that
// got echoed back from the relay is no longer marked as "already
// processed" (the dedup set lives in memory and isn't persisted).
// The inbound path then outer-decrypts it via a retained epoch key
// and feeds the commit into group.processCommit — which is NOT
// atomic, so it partially advances David's local state before failing
// with "Confirmation tag verification failed" (the tag was computed
// at the original epoch; David is already two epochs past).
//
// After this partial apply, David's epoch / tree / exporter_secret
// drift forward by one epoch while Eden and Fred stay put. Any
// subsequent message David sends uses a key nobody else has, and
// they log "Outer decryption failed with current and N retained
// epoch key(s)".
runBlocking {
val davidStore = TestGroupStateStore()
val davidMgr = MlsGroupManager(davidStore)
val edenMgr = MlsGroupManager(TestGroupStateStore())
val fredMgr = MlsGroupManager(TestGroupStateStore())
val davidId = ByteArray(32) { 0xD1.toByte() }
val edenId = ByteArray(32) { 0xE2.toByte() }
val fredId = ByteArray(32) { 0xF3.toByte() }
davidMgr.createGroup(groupId, davidId)
davidMgr.updateGroupExtensions(
groupId,
listOf(
com.vitorpamplona.quartz.marmot.mip01Groups
.MarmotGroupData(
nostrGroupId = groupId,
adminPubkeys = listOf(davidId.toHexKey()),
).toExtension(),
),
)
val davidGroup = davidMgr.getGroup(groupId)!!
val edenBundle = davidGroup.createKeyPackage(edenId, ByteArray(0))
val fredBundle = davidGroup.createKeyPackage(fredId, ByteArray(0))
val addEden = davidMgr.addMember(groupId, edenBundle.keyPackage.toTlsBytes())
edenMgr.processWelcome(addEden.welcomeBytes!!, edenBundle)
val addFred = davidMgr.addMember(groupId, fredBundle.keyPackage.toTlsBytes())
fredMgr.processWelcome(addFred.welcomeBytes!!, fredBundle)
edenMgr.processCommit(
groupId,
addFred.commitBytes,
davidMgr.getGroup(groupId)!!.leafIndex,
ByteArray(0),
)
val preRestartEpoch = davidMgr.getGroup(groupId)!!.epoch
val preRestartExporter = davidMgr.exporterSecret(groupId)
// Simulate David's app restart.
val davidMgr2 = MlsGroupManager(davidStore)
davidMgr2.restoreAll()
// Now replay David's own echoed add-Eden commit that's still on
// the relay. After restart, the inbound dedup set is empty so
// the inbound pipeline does the full outer-decrypt via retained
// keys + processCommit on an already-applied commit.
val inbound =
com.vitorpamplona.quartz.marmot
.MarmotInboundProcessor(
groupManager = davidMgr2,
keyPackageRotationManager =
com.vitorpamplona.quartz.marmot.mip00KeyPackages
.KeyPackageRotationManager(),
)
val outbound = MarmotOutboundProcessor(davidMgr2)
// Re-encrypt the add-Eden framed commit with the pre-commit key
// so it looks exactly like what the relay would re-deliver.
val echoed =
outbound.buildCommitEvent(
nostrGroupId = groupId,
commitBytes = addEden.framedCommitBytes,
exporterKey = addEden.preCommitExporterSecret,
)
val result = inbound.processGroupEvent(echoed.signedEvent)
assertIs<GroupEventResult.Duplicate>(
result,
"echoed already-applied commit must be treated as a no-op Duplicate",
)
val postEchoEpoch = davidMgr2.getGroup(groupId)!!.epoch
val postEchoExporter = davidMgr2.exporterSecret(groupId)
assertEquals(
preRestartEpoch,
postEchoEpoch,
"processing our own already-applied commit echo must NOT advance the local epoch",
)
kotlin.test.assertContentEquals(
preRestartExporter,
postEchoExporter,
"processing our own already-applied commit echo must NOT change the exporter secret",
)
}
}
@Test
fun testCreatorCanSendMessageAfterRestart() {
// Reproduces production symptom: David creates a group, adds Eden and
// Fred, sends messages — all fine. After David restarts the app
// (simulated here by save-state + fresh MlsGroupManager + restoreAll),
// his outbound kind:445 outer ChaCha20 layer suddenly uses a
// different key than Eden/Fred, and they fail with
// "Outer decryption failed with current and N retained epoch key(s)".
runBlocking {
val davidStore = TestGroupStateStore()
val davidMgr = MlsGroupManager(davidStore)
val edenMgr = MlsGroupManager(TestGroupStateStore())
val fredMgr = MlsGroupManager(TestGroupStateStore())
val davidId = ByteArray(32) { 0xD1.toByte() }
val edenId = ByteArray(32) { 0xE2.toByte() }
val fredId = ByteArray(32) { 0xF3.toByte() }
davidMgr.createGroup(groupId, davidId)
davidMgr.updateGroupExtensions(
nostrGroupId = groupId,
extensions =
listOf(
com.vitorpamplona.quartz.marmot.mip01Groups
.MarmotGroupData(
nostrGroupId = groupId,
adminPubkeys = listOf(davidId.toHexKey()),
).toExtension(),
),
)
val davidGroup = davidMgr.getGroup(groupId)!!
val edenBundle = davidGroup.createKeyPackage(edenId, ByteArray(0))
val fredBundle = davidGroup.createKeyPackage(fredId, ByteArray(0))
// David adds Eden.
val addEden = davidMgr.addMember(groupId, edenBundle.keyPackage.toTlsBytes())
edenMgr.processWelcome(addEden.welcomeBytes!!, edenBundle)
// David adds Fred; Eden processes Alice's add-Fred commit.
val addFred = davidMgr.addMember(groupId, fredBundle.keyPackage.toTlsBytes())
fredMgr.processWelcome(addFred.welcomeBytes!!, fredBundle)
edenMgr.processCommit(
nostrGroupId = groupId,
commitBytes = addFred.commitBytes,
senderLeafIndex = davidMgr.getGroup(groupId)!!.leafIndex,
confirmationTag = ByteArray(0),
)
// Pre-restart sanity: all three share the same outer exporter key.
val preRestartDavidKey = davidMgr.exporterSecret(groupId)
kotlin.test.assertContentEquals(
preRestartDavidKey,
edenMgr.exporterSecret(groupId),
"Eden's exporter key should match David's before restart",
)
kotlin.test.assertContentEquals(
preRestartDavidKey,
fredMgr.exporterSecret(groupId),
"Fred's exporter key should match David's before restart",
)
// Simulate David's app restart: drop his in-memory manager and
// rebuild it from the persisted store.
val davidMgr2 = MlsGroupManager(davidStore)
davidMgr2.restoreAll()
val postRestartDavidKey = davidMgr2.exporterSecret(groupId)
kotlin.test.assertContentEquals(
preRestartDavidKey,
postRestartDavidKey,
"David's exporter key must survive a save+restore round-trip; " +
"otherwise Eden/Fred can't decrypt his next outer layer.",
)
// David sends a message post-restart; Eden and Fred must decrypt.
val outbound = MarmotOutboundProcessor(davidMgr2)
val msg = "hi after restart"
val postRestartEvent =
outbound.buildGroupEventFromBytes(groupId, msg.encodeToByteArray())
val edenKey = edenMgr.exporterSecret(groupId)
val mlsBytesEden = GroupEventEncryption.decrypt(postRestartEvent.signedEvent.content, edenKey)
val edenDecrypted = edenMgr.decrypt(groupId, mlsBytesEden)
assertEquals(msg, edenDecrypted.content.decodeToString())
}
}
@Test
fun testFredEncryptsAfterJoiningThreeMemberGroup() {
// Reproduces the production StackOverflowError: the last-joined member
// (Fred at leafIndex=2 in a 3-member group) attempts to encrypt his
// first message and the SecretTree.getNodeSecret recursion never
// terminates.
runBlocking {
val aliceMgr = createGroupManager()
val bobMgr = createGroupManager()
val fredMgr = createGroupManager()
val aliceIdBytes = ByteArray(32) { 0xA1.toByte() }
aliceMgr.createGroup(groupId, aliceIdBytes)
aliceMgr.updateGroupExtensions(
nostrGroupId = groupId,
extensions =
listOf(
com.vitorpamplona.quartz.marmot.mip01Groups
.MarmotGroupData(
nostrGroupId = groupId,
adminPubkeys = listOf(aliceIdBytes.toHexKey()),
).toExtension(),
),
)
val aliceGroup = aliceMgr.getGroup(groupId)!!
val bobBundle = aliceGroup.createKeyPackage(ByteArray(32) { 0xB2.toByte() }, ByteArray(0))
val fredBundle = aliceGroup.createKeyPackage(ByteArray(32) { 0xF3.toByte() }, ByteArray(0))
// Alice adds Bob.
val addBob = aliceMgr.addMember(groupId, bobBundle.keyPackage.toTlsBytes())
bobMgr.processWelcome(addBob.welcomeBytes!!, bobBundle)
// Alice adds Fred (he joins at leafIndex=2, in a 3-leaf tree).
val addFred = aliceMgr.addMember(groupId, fredBundle.keyPackage.toTlsBytes())
fredMgr.processWelcome(addFred.welcomeBytes!!, fredBundle)
// Bob (existing member at the pre-add-Fred epoch) receives and
// applies Alice's add-Fred commit to reach the same epoch as
// Alice + Fred. Without this, Bob can't decrypt Fred's subsequent
// application messages.
bobMgr.processCommit(
nostrGroupId = groupId,
commitBytes = addFred.commitBytes,
senderLeafIndex = aliceMgr.getGroup(groupId)!!.leafIndex,
confirmationTag = ByteArray(0),
)
// Sanity: Fred is at leafIndex=2, leafCount=3.
assertEquals(2, fredMgr.getGroup(groupId)!!.leafIndex)
// This is where production crashed with StackOverflowError.
val fredMsg = "hi from fred"
val fredCiphertext = fredMgr.encrypt(groupId, fredMsg.encodeToByteArray())
// And Alice & Bob must be able to decrypt it.
val aliceDecrypted = aliceMgr.decrypt(groupId, fredCiphertext)
assertEquals(fredMsg, aliceDecrypted.content.decodeToString())
val bobDecrypted = bobMgr.decrypt(groupId, fredCiphertext)
assertEquals(fredMsg, bobDecrypted.content.decodeToString())
}
}
@Test
fun testAddMemberExposesPreCommitExporterSecret() {
// Regression: before the fix, MarmotManager.addMember outer-encrypted
// the kind:445 commit with the POST-commit (epoch N+1) exporter key,
// which meant existing members at epoch N couldn't decrypt it. The
// fix captures the pre-commit key inside MlsGroup.commit() and ships
// it back via CommitResult.preCommitExporterSecret so MarmotManager
// can pass it to the outbound builder.
runBlocking {
val manager = createGroupManager()
manager.createGroup(groupId, "alice".encodeToByteArray())
val preEpochExporter = manager.exporterSecret(groupId)
val preEpoch = manager.getGroup(groupId)!!.epoch
val bobBundle =
manager
.getGroup(groupId)!!
.createKeyPackage("bob".encodeToByteArray(), ByteArray(0))
val result = manager.addMember(groupId, bobBundle.keyPackage.toTlsBytes())
// Local state advanced to N+1.
assertEquals(preEpoch + 1, manager.getGroup(groupId)!!.epoch)
// But the returned pre-commit exporter secret matches what the
// group held BEFORE advancing — the key existing members still at
// epoch N are holding.
kotlin.test.assertContentEquals(
preEpochExporter,
result.preCommitExporterSecret,
)
// The post-commit exporter (what groupManager.exporterSecret now
// returns) MUST differ — otherwise we haven't actually rotated.
kotlin.test.assertFalse(
manager.exporterSecret(groupId).contentEquals(preEpochExporter),
"post-commit exporter key must differ from the pre-commit one",
)
assertNotNull(result.welcomeBytes)
}
}
@Test
fun testCreatorCanAddTwoMembersAndAllDecryptFollowingMessage() {
// End-to-end regression for the David/Eden/Fred scenario: a group
// creator (Alice) adds two members (Bob then Carol) in sequence and
// then publishes an application message. Both members MUST be able to
// decrypt the message. Before the stage/merge fix, the add-Carol
// commit was outer-encrypted with the post-commit (epoch 2) key,
// leaving Bob stuck at epoch 1 and unable to decrypt any of Alice's
// subsequent application messages.
runBlocking {
val aliceMgr = createGroupManager()
val bobMgr = createGroupManager()
val carolMgr = createGroupManager()
// Use 32-byte identities so they fit the MarmotGroupData
// admin_pubkeys 32-byte slots.
val aliceIdBytes = ByteArray(32) { 0xA1.toByte() }
val bobIdBytes = ByteArray(32) { 0xB2.toByte() }
val carolIdBytes = ByteArray(32) { 0xC3.toByte() }
aliceMgr.createGroup(groupId, aliceIdBytes)
// Install the Marmot group data extension so the Welcome carries
// the NostrGroupData (MlsGroupManager.processWelcome requires it).
aliceMgr.updateGroupExtensions(
nostrGroupId = groupId,
extensions =
listOf(
com.vitorpamplona.quartz.marmot.mip01Groups
.MarmotGroupData(
nostrGroupId = groupId,
adminPubkeys = listOf(aliceIdBytes.toHexKey()),
).toExtension(),
),
)
val aliceGroup = aliceMgr.getGroup(groupId)!!
// KeyPackages are just MLS artifacts carrying identity + init key;
// createKeyPackage on any group instance produces a usable bundle.
val bobBundle = aliceGroup.createKeyPackage(bobIdBytes, ByteArray(0))
val carolBundle = aliceGroup.createKeyPackage(carolIdBytes, ByteArray(0))
val outbound = MarmotOutboundProcessor(aliceMgr)
// --- Step 1: Alice adds Bob. CommitResult carries the pre-commit
// exporter secret so buildCommitEvent can outer-encrypt with
// the epoch-N (pre-Bob) key that Bob-less Alice held. ---
val addBobResult = aliceMgr.addMember(groupId, bobBundle.keyPackage.toTlsBytes())
val addBobCommitEvent =
outbound.buildCommitEvent(
nostrGroupId = groupId,
commitBytes = addBobResult.framedCommitBytes,
exporterKey = addBobResult.preCommitExporterSecret,
)
// Bob joins via Welcome (emulated: we hand him the Welcome bytes).
bobMgr.processWelcome(addBobResult.welcomeBytes!!, bobBundle)
val epochAfterAddBob = aliceMgr.getGroup(groupId)!!.epoch
assertEquals(epochAfterAddBob, bobMgr.getGroup(groupId)!!.epoch)
// --- Step 2: Alice adds Carol. addCarolResult.preCommitExporterSecret
// is the epoch-N (2-member) key that Bob still holds. ---
val addCarolResult = aliceMgr.addMember(groupId, carolBundle.keyPackage.toTlsBytes())
val addCarolCommitEvent =
outbound.buildCommitEvent(
nostrGroupId = groupId,
commitBytes = addCarolResult.framedCommitBytes,
exporterKey = addCarolResult.preCommitExporterSecret,
)
// Carol joins via Welcome at the new epoch.
carolMgr.processWelcome(addCarolResult.welcomeBytes!!, carolBundle)
// *** The key assertion ***: Bob (still at epoch 1) receives the
// add-Carol kind:445 and must be able to:
// (a) outer-decrypt using his current (epoch 1) exporter key,
// (b) parse the MlsMessage → PublicMessage → COMMIT,
// (c) apply the commit and advance to epoch 2.
val bobExporterAtE1 = bobMgr.exporterSecret(groupId)
kotlin.test.assertContentEquals(
bobExporterAtE1,
addCarolResult.preCommitExporterSecret,
"Bob's current exporter (pre-add-Carol) must match the " +
"pre-commit key used to outer-encrypt the add-Carol commit; " +
"otherwise Bob cannot decrypt and will be stuck on the old epoch.",
)
val decryptedMlsBytes =
GroupEventEncryption.decrypt(
addCarolCommitEvent.signedEvent.content,
bobExporterAtE1,
)
val mlsMessage =
com.vitorpamplona.quartz.marmot.mls.framing.MlsMessage
.decodeTls(
com.vitorpamplona.quartz.marmot.mls.codec
.TlsReader(decryptedMlsBytes),
)
val publicMessage =
com.vitorpamplona.quartz.marmot.mls.framing.PublicMessage
.decodeTls(
com.vitorpamplona.quartz.marmot.mls.codec
.TlsReader(mlsMessage.payload),
)
bobMgr.processCommit(
nostrGroupId = groupId,
commitBytes = publicMessage.content,
senderLeafIndex = publicMessage.sender.leafIndex,
confirmationTag = publicMessage.confirmationTag!!,
)
val epochAfterAddCarol = aliceMgr.getGroup(groupId)!!.epoch
assertEquals(epochAfterAddBob + 1, epochAfterAddCarol)
assertEquals(epochAfterAddCarol, bobMgr.getGroup(groupId)!!.epoch)
assertEquals(epochAfterAddCarol, carolMgr.getGroup(groupId)!!.epoch)
// --- Step 3: Alice sends an application message. Both members must decrypt. ---
val msg = "Hi from Alice"
val appEvent =
outbound.buildGroupEventFromBytes(groupId, msg.encodeToByteArray())
val bobKey = bobMgr.exporterSecret(groupId)
val carolKey = carolMgr.exporterSecret(groupId)
val aliceKey = aliceMgr.exporterSecret(groupId)
kotlin.test.assertContentEquals(aliceKey, bobKey, "Bob must share Alice's epoch-2 key")
kotlin.test.assertContentEquals(aliceKey, carolKey, "Carol must share Alice's epoch-2 key")
val bobMlsBytes = GroupEventEncryption.decrypt(appEvent.signedEvent.content, bobKey)
val bobDecrypted = bobMgr.decrypt(groupId, bobMlsBytes)
assertEquals(msg, bobDecrypted.content.decodeToString())
val carolMlsBytes = GroupEventEncryption.decrypt(appEvent.signedEvent.content, carolKey)
val carolDecrypted = carolMgr.decrypt(groupId, carolMlsBytes)
assertEquals(msg, carolDecrypted.content.decodeToString())
// --- Alice also receives her own echo (as the app does via the
// relay round-trip); this exercises the secretTree-based decrypt
// path with myLeafIndex != sender or when sentKeys misses.
val aliceMlsBytes = GroupEventEncryption.decrypt(appEvent.signedEvent.content, aliceKey)
val aliceDecrypted = aliceMgr.decrypt(groupId, aliceMlsBytes)
assertEquals(msg, aliceDecrypted.content.decodeToString())
// Send a second and third message to stress the secretTree ratchet
// and ensure getNodeSecret stays terminating across generations.
for (i in 1..3) {
val m = "msg-$i"
val ev = outbound.buildGroupEventFromBytes(groupId, m.encodeToByteArray())
val keyNow = aliceMgr.exporterSecret(groupId)
val bytes = GroupEventEncryption.decrypt(ev.signedEvent.content, keyNow)
val dec = bobMgr.decrypt(groupId, bytes)
assertEquals(m, dec.content.decodeToString())
}
}
}
@Test
fun testFullRoundtripEncryptDecrypt() {
runBlocking {
@@ -225,9 +225,11 @@ class MlsGroupEdgeCaseTest {
// 6. Multiple epochs of encrypt/decrypt
// -----------------------------------------------------------------------
// BUG: processCommit key derivation diverges — commit_secret decryption from
// UpdatePath does not correctly derive matching epoch secrets between commit()
// and processCommit(). See MlsGroupLifecycleTest.testThreeMemberGroup_SequentialAdditions.
// BUG: same empty-commit (no proposals, pure UpdatePath) divergence as
// MlsGroupLifecycleTest.testEmptyCommit_AdvancesEpoch. The
// SecretTree.getNodeSecret non-full-tree fix doesn't address this — after
// 5 empty commits Alice and Bob's ratchet secrets diverge and AEAD
// decryption fails with "Tag mismatch". Tracked separately.
@Ignore
@Test
fun testMultipleEpochTransitions_EncryptDecryptStillWorks() {
@@ -242,7 +244,6 @@ class MlsGroupEdgeCaseTest {
bob.processCommit(commitResult.commitBytes, alice.leafIndex, ByteArray(0))
}
assertEquals(7L, alice.epoch) // epoch 0 + addMember(1) + 5 commits = 6... wait
// epoch 0 (create) -> epoch 1 (add bob) -> 5 empty commits = epoch 6
assertEquals(6L, alice.epoch)
assertEquals(alice.epoch, bob.epoch)
@@ -176,7 +176,6 @@ class MlsGroupLifecycleTest {
// because the commit_secret decryption from the UpdatePath does not
// correctly walk the ratchet tree to find the common ancestor's path secret.
// This causes AEAD decryption failures on cross-member messages.
@Ignore
@Test
fun testThreeMemberGroup_SequentialAdditions() {
// Alice creates the group
@@ -243,7 +242,6 @@ class MlsGroupLifecycleTest {
// -----------------------------------------------------------------------
// BUG: processCommit key derivation diverges — see testThreeMemberGroup_SequentialAdditions
@Ignore
@Test
fun testExternalJoin_ZaraJoinsViaGroupInfo() {
val alice = MlsGroup.create("alice".encodeToByteArray())
@@ -266,7 +264,6 @@ class MlsGroupLifecycleTest {
}
// BUG: processCommit key derivation diverges — see testThreeMemberGroup_SequentialAdditions
@Ignore
@Test
fun testExternalJoin_ExporterSecretsAgree() {
val alice = MlsGroup.create("alice".encodeToByteArray())
@@ -312,7 +309,6 @@ class MlsGroupLifecycleTest {
// -----------------------------------------------------------------------
// BUG: processCommit key derivation diverges — see testThreeMemberGroup_SequentialAdditions
@Ignore
@Test
fun testSigningKeyRotation_EpochAdvances() {
val alice = MlsGroup.create("alice".encodeToByteArray())
@@ -342,7 +338,6 @@ class MlsGroupLifecycleTest {
}
// BUG: processCommit key derivation diverges — see testThreeMemberGroup_SequentialAdditions
@Ignore
@Test
fun testEncryptDecryptAfterSigningKeyRotation() {
val alice = MlsGroup.create("alice".encodeToByteArray())
@@ -412,7 +407,6 @@ class MlsGroupLifecycleTest {
// -----------------------------------------------------------------------
// BUG: processCommit key derivation diverges — see testThreeMemberGroup_SequentialAdditions
@Ignore
@Test
fun testPskProposal_EpochAdvancesWithPsk() {
val alice = MlsGroup.create("alice".encodeToByteArray())
@@ -472,7 +466,11 @@ class MlsGroupLifecycleTest {
// 12. Empty commit (no proposals, just UpdatePath for forward secrecy)
// -----------------------------------------------------------------------
// BUG: processCommit key derivation diverges — see testThreeMemberGroup_SequentialAdditions
// BUG: empty-commit (no proposals, pure UpdatePath) diverges Alice's and
// Bob's exporter secrets after processCommit. Unrelated to the SecretTree
// non-full-tree fix — the other @Ignored "processCommit diverges" tests in
// this file and MlsGroupEdgeCaseTest now pass, but this one still fails.
// Tracked separately.
@Ignore
@Test
fun testEmptyCommit_AdvancesEpoch() {