Merge pull request #2436 from vitorpamplona/claude/review-marmot-mips-compliance-wRDux
Implement Marmot MIP compliance fixes for admin gates and media handling
This commit is contained in:
+24
-3
@@ -338,9 +338,30 @@ class MarmotInboundProcessor(
|
|||||||
ContentType.APPLICATION -> {
|
ContentType.APPLICATION -> {
|
||||||
// MLS decrypt to get the inner plaintext
|
// MLS decrypt to get the inner plaintext
|
||||||
val decrypted = groupManager.decrypt(groupId, mlsMessage.toTlsBytes())
|
val decrypted = groupManager.decrypt(groupId, mlsMessage.toTlsBytes())
|
||||||
|
val innerJson = decrypted.content.decodeToString()
|
||||||
|
|
||||||
|
// MIP-03: if the inner application payload is a Nostr event,
|
||||||
|
// its `pubkey` field MUST equal the MLS sender's credential
|
||||||
|
// identity. Reject any mismatch — otherwise a group member
|
||||||
|
// could mint events claiming a different author. Non-event
|
||||||
|
// payloads (raw bytes via buildGroupEventFromBytes) bypass
|
||||||
|
// this check since there is no author field to verify.
|
||||||
|
val innerEvent =
|
||||||
|
com.vitorpamplona.quartz.nip01Core.core.Event
|
||||||
|
.fromJsonOrNull(innerJson)
|
||||||
|
if (innerEvent != null) {
|
||||||
|
val senderIdentity = groupManager.memberIdentityHex(groupId, decrypted.senderLeafIndex)
|
||||||
|
if (senderIdentity == null || innerEvent.pubKey != senderIdentity) {
|
||||||
|
return GroupEventResult.Error(
|
||||||
|
groupId,
|
||||||
|
"MIP-03: inner event pubkey (${innerEvent.pubKey}) does not match MLS sender identity ($senderIdentity)",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
GroupEventResult.ApplicationMessage(
|
GroupEventResult.ApplicationMessage(
|
||||||
groupId = groupId,
|
groupId = groupId,
|
||||||
innerEventJson = decrypted.content.decodeToString(),
|
innerEventJson = innerJson,
|
||||||
senderLeafIndex = decrypted.senderLeafIndex,
|
senderLeafIndex = decrypted.senderLeafIndex,
|
||||||
epoch = decrypted.epoch,
|
epoch = decrypted.epoch,
|
||||||
)
|
)
|
||||||
@@ -461,7 +482,7 @@ class MarmotInboundProcessor(
|
|||||||
// Try current epoch key first
|
// Try current epoch key first
|
||||||
try {
|
try {
|
||||||
val exporterKey = groupManager.exporterSecret(groupId)
|
val exporterKey = groupManager.exporterSecret(groupId)
|
||||||
return GroupEventEncryption.decrypt(encryptedContent, exporterKey, groupId)
|
return GroupEventEncryption.decrypt(encryptedContent, exporterKey)
|
||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
// Current epoch key failed — try retained epoch keys
|
// Current epoch key failed — try retained epoch keys
|
||||||
}
|
}
|
||||||
@@ -470,7 +491,7 @@ class MarmotInboundProcessor(
|
|||||||
val retainedKeys = groupManager.retainedExporterSecrets(groupId)
|
val retainedKeys = groupManager.retainedExporterSecrets(groupId)
|
||||||
for (retainedKey in retainedKeys) {
|
for (retainedKey in retainedKeys) {
|
||||||
try {
|
try {
|
||||||
return GroupEventEncryption.decrypt(encryptedContent, retainedKey, groupId)
|
return GroupEventEncryption.decrypt(encryptedContent, retainedKey)
|
||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
// This retained key didn't work — try the next one
|
// This retained key didn't work — try the next one
|
||||||
}
|
}
|
||||||
|
|||||||
+31
-4
@@ -20,6 +20,7 @@
|
|||||||
*/
|
*/
|
||||||
package com.vitorpamplona.quartz.marmot
|
package com.vitorpamplona.quartz.marmot
|
||||||
|
|
||||||
|
import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData
|
||||||
import com.vitorpamplona.quartz.marmot.mip03GroupMessages.GroupEvent
|
import com.vitorpamplona.quartz.marmot.mip03GroupMessages.GroupEvent
|
||||||
import com.vitorpamplona.quartz.marmot.mip03GroupMessages.GroupEventEncryption
|
import com.vitorpamplona.quartz.marmot.mip03GroupMessages.GroupEventEncryption
|
||||||
import com.vitorpamplona.quartz.marmot.mls.group.MlsGroupManager
|
import com.vitorpamplona.quartz.marmot.mls.group.MlsGroupManager
|
||||||
@@ -27,6 +28,8 @@ import com.vitorpamplona.quartz.nip01Core.core.Event
|
|||||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
|
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
|
||||||
|
import com.vitorpamplona.quartz.nip40Expiration.expiration
|
||||||
|
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Result of building an outbound GroupEvent.
|
* Result of building an outbound GroupEvent.
|
||||||
@@ -90,14 +93,20 @@ class MarmotOutboundProcessor(
|
|||||||
|
|
||||||
// Step 2: Outer ChaCha20-Poly1305 encryption
|
// Step 2: Outer ChaCha20-Poly1305 encryption
|
||||||
val exporterKey = groupManager.exporterSecret(nostrGroupId)
|
val exporterKey = groupManager.exporterSecret(nostrGroupId)
|
||||||
val encryptedContent = GroupEventEncryption.encrypt(mlsCiphertext, exporterKey, nostrGroupId)
|
val encryptedContent = GroupEventEncryption.encrypt(mlsCiphertext, exporterKey)
|
||||||
|
|
||||||
// Step 3: Build the GroupEvent template
|
// Step 3: Build the GroupEvent template (auto-apply NIP-40 expiration
|
||||||
|
// if the group has disappearing_message_secs configured per MIP-01/03).
|
||||||
|
val createdAt = TimeUtils.now()
|
||||||
|
val expirationTime = disappearingExpiration(nostrGroupId, createdAt)
|
||||||
val template =
|
val template =
|
||||||
GroupEvent.build(
|
GroupEvent.build(
|
||||||
encryptedContentBase64 = encryptedContent,
|
encryptedContentBase64 = encryptedContent,
|
||||||
nostrGroupId = nostrGroupId,
|
nostrGroupId = nostrGroupId,
|
||||||
)
|
createdAt = createdAt,
|
||||||
|
) {
|
||||||
|
if (expirationTime != null) expiration(expirationTime)
|
||||||
|
}
|
||||||
|
|
||||||
// Step 4: Sign with a fresh ephemeral keypair
|
// Step 4: Sign with a fresh ephemeral keypair
|
||||||
val ephemeralSigner = NostrSignerInternal(KeyPair())
|
val ephemeralSigner = NostrSignerInternal(KeyPair())
|
||||||
@@ -125,7 +134,7 @@ class MarmotOutboundProcessor(
|
|||||||
): OutboundGroupEvent {
|
): OutboundGroupEvent {
|
||||||
// Outer ChaCha20-Poly1305 encryption of the MLS commit
|
// Outer ChaCha20-Poly1305 encryption of the MLS commit
|
||||||
val exporterKey = groupManager.exporterSecret(nostrGroupId)
|
val exporterKey = groupManager.exporterSecret(nostrGroupId)
|
||||||
val encryptedContent = GroupEventEncryption.encrypt(commitBytes, exporterKey, nostrGroupId)
|
val encryptedContent = GroupEventEncryption.encrypt(commitBytes, exporterKey)
|
||||||
|
|
||||||
// Build the GroupEvent template
|
// Build the GroupEvent template
|
||||||
val template =
|
val template =
|
||||||
@@ -143,4 +152,22 @@ class MarmotOutboundProcessor(
|
|||||||
nostrGroupId = nostrGroupId,
|
nostrGroupId = nostrGroupId,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute the NIP-40 expiration timestamp for outbound application messages,
|
||||||
|
* or `null` when the group does not have disappearing messages configured.
|
||||||
|
*
|
||||||
|
* Per MIP-01/MIP-03, when `disappearing_message_secs` is set in the Marmot
|
||||||
|
* Group Data Extension, clients MUST auto-apply an `expiration` tag on
|
||||||
|
* kind:445 events at `created_at + disappearing_message_secs`.
|
||||||
|
*/
|
||||||
|
private fun disappearingExpiration(
|
||||||
|
nostrGroupId: HexKey,
|
||||||
|
createdAt: Long,
|
||||||
|
): Long? {
|
||||||
|
val extensions = groupManager.getGroup(nostrGroupId)?.extensions ?: return null
|
||||||
|
val marmotData = MarmotGroupData.fromExtensions(extensions) ?: return null
|
||||||
|
val secs = marmotData.disappearingMessageSecs ?: return null
|
||||||
|
return createdAt + secs.toLong()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,10 +59,22 @@ class MarmotWelcomeSender(
|
|||||||
/**
|
/**
|
||||||
* Wrap Welcome bytes from a CommitResult for delivery to a new member.
|
* Wrap Welcome bytes from a CommitResult for delivery to a new member.
|
||||||
*
|
*
|
||||||
|
* ## MIP-02 ordering requirement
|
||||||
|
*
|
||||||
|
* The Commit that adds [recipientPubKey] to the group MUST be confirmed by
|
||||||
|
* relays BEFORE the Welcome leaves this machine, otherwise the new member
|
||||||
|
* can join at a stale epoch and the group forks. [awaitCommitAck] is called
|
||||||
|
* immediately before the gift-wrap step so callers can plumb their own
|
||||||
|
* relay-OK wait (or equivalent guarantee) through this function. Passing
|
||||||
|
* an empty block skips the wait and is only appropriate when the caller
|
||||||
|
* has already awaited Commit confirmation externally.
|
||||||
|
*
|
||||||
* @param commitResult the result from MlsGroupManager.addMember()
|
* @param commitResult the result from MlsGroupManager.addMember()
|
||||||
* @param recipientPubKey public key of the new member being invited
|
* @param recipientPubKey public key of the new member being invited
|
||||||
* @param keyPackageEventId event ID of the KeyPackage that was consumed
|
* @param keyPackageEventId event ID of the KeyPackage that was consumed
|
||||||
* @param relays relays where the new member should subscribe for GroupEvents
|
* @param relays relays where the new member should subscribe for GroupEvents
|
||||||
|
* @param awaitCommitAck suspend block invoked before wrapping; MUST not
|
||||||
|
* return until relays have confirmed receipt of the Commit
|
||||||
* @return the gift-wrapped event ready for publishing, or null if no Welcome in CommitResult
|
* @return the gift-wrapped event ready for publishing, or null if no Welcome in CommitResult
|
||||||
*/
|
*/
|
||||||
@OptIn(ExperimentalEncodingApi::class)
|
@OptIn(ExperimentalEncodingApi::class)
|
||||||
@@ -72,11 +84,14 @@ class MarmotWelcomeSender(
|
|||||||
keyPackageEventId: HexKey,
|
keyPackageEventId: HexKey,
|
||||||
relays: List<NormalizedRelayUrl>,
|
relays: List<NormalizedRelayUrl>,
|
||||||
nostrGroupId: HexKey? = null,
|
nostrGroupId: HexKey? = null,
|
||||||
|
awaitCommitAck: suspend () -> Unit = {},
|
||||||
): WelcomeDelivery? {
|
): WelcomeDelivery? {
|
||||||
val welcomeBytes = commitResult.welcomeBytes ?: return null
|
val welcomeBytes = commitResult.welcomeBytes ?: return null
|
||||||
|
|
||||||
val welcomeBase64 = Base64.encode(welcomeBytes)
|
val welcomeBase64 = Base64.encode(welcomeBytes)
|
||||||
|
|
||||||
|
awaitCommitAck()
|
||||||
|
|
||||||
val giftWrap =
|
val giftWrap =
|
||||||
WelcomeGiftWrap.wrapForRecipient(
|
WelcomeGiftWrap.wrapForRecipient(
|
||||||
welcomeBase64 = welcomeBase64,
|
welcomeBase64 = welcomeBase64,
|
||||||
@@ -98,11 +113,14 @@ class MarmotWelcomeSender(
|
|||||||
*
|
*
|
||||||
* Useful when the Welcome bytes are available separately from the
|
* Useful when the Welcome bytes are available separately from the
|
||||||
* commit flow (e.g., re-sending a Welcome after a failed delivery).
|
* commit flow (e.g., re-sending a Welcome after a failed delivery).
|
||||||
|
* See [wrapWelcome] for the MIP-02 ordering contract on [awaitCommitAck].
|
||||||
*
|
*
|
||||||
* @param welcomeBytes raw MLS Welcome message bytes
|
* @param welcomeBytes raw MLS Welcome message bytes
|
||||||
* @param recipientPubKey public key of the new member
|
* @param recipientPubKey public key of the new member
|
||||||
* @param keyPackageEventId event ID of the consumed KeyPackage
|
* @param keyPackageEventId event ID of the consumed KeyPackage
|
||||||
* @param relays relays for the new member to subscribe to
|
* @param relays relays for the new member to subscribe to
|
||||||
|
* @param awaitCommitAck suspend block invoked before wrapping; MUST not
|
||||||
|
* return until relays have confirmed receipt of the Commit
|
||||||
* @return the gift-wrapped event ready for publishing
|
* @return the gift-wrapped event ready for publishing
|
||||||
*/
|
*/
|
||||||
@OptIn(ExperimentalEncodingApi::class)
|
@OptIn(ExperimentalEncodingApi::class)
|
||||||
@@ -112,9 +130,12 @@ class MarmotWelcomeSender(
|
|||||||
keyPackageEventId: HexKey,
|
keyPackageEventId: HexKey,
|
||||||
relays: List<NormalizedRelayUrl>,
|
relays: List<NormalizedRelayUrl>,
|
||||||
nostrGroupId: HexKey? = null,
|
nostrGroupId: HexKey? = null,
|
||||||
|
awaitCommitAck: suspend () -> Unit = {},
|
||||||
): WelcomeDelivery {
|
): WelcomeDelivery {
|
||||||
val welcomeBase64 = Base64.encode(welcomeBytes)
|
val welcomeBase64 = Base64.encode(welcomeBytes)
|
||||||
|
|
||||||
|
awaitCommitAck()
|
||||||
|
|
||||||
val giftWrap =
|
val giftWrap =
|
||||||
WelcomeGiftWrap.wrapForRecipient(
|
WelcomeGiftWrap.wrapForRecipient(
|
||||||
welcomeBase64 = welcomeBase64,
|
welcomeBase64 = welcomeBase64,
|
||||||
|
|||||||
+77
-14
@@ -38,25 +38,28 @@ import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
|||||||
* The actual TLS serialization/deserialization is handled by the MLS engine.
|
* The actual TLS serialization/deserialization is handled by the MLS engine.
|
||||||
* This class provides a Kotlin-friendly representation for the application layer.
|
* This class provides a Kotlin-friendly representation for the application layer.
|
||||||
*
|
*
|
||||||
* Wire format (TLS presentation language):
|
* Wire format (TLS presentation language, v3):
|
||||||
* ```
|
* ```
|
||||||
* struct {
|
* struct {
|
||||||
* uint16 version; // Current: 2
|
* uint16 version; // Current: 3 (v0 reserved/invalid)
|
||||||
* opaque nostr_group_id[32]; // Nostr routing ID (distinct from MLS group ID)
|
* opaque nostr_group_id[32]; // Nostr routing ID (distinct from MLS group ID)
|
||||||
* opaque name<0..2^16-1>;
|
* opaque name<0..2^16-1>;
|
||||||
* opaque description<0..2^16-1>;
|
* opaque description<0..2^16-1>;
|
||||||
* opaque admin_pubkeys<0..2^16-1>; // Concatenated raw 32-byte x-only pubkeys
|
* opaque admin_pubkeys<0..2^16-1>; // Concatenated raw 32-byte x-only pubkeys
|
||||||
* RelayUrl relays<0..2^16-1>;
|
* RelayUrl relays<0..2^16-1>;
|
||||||
* opaque image_hash<0..32>;
|
* opaque image_hash<0..32>;
|
||||||
* opaque image_key<0..32>; // HKDF seed for encryption key derivation
|
* opaque image_key<0..32>; // HKDF seed for encryption key derivation
|
||||||
* opaque image_nonce<0..12>;
|
* opaque image_nonce<0..12>;
|
||||||
* opaque image_upload_key<0..32>; // HKDF seed for upload keypair derivation
|
* opaque image_upload_key<0..32>; // HKDF seed for upload keypair derivation
|
||||||
|
* opaque disappearing_message_secs<0..8>; // v3+: 0 bytes = persist forever,
|
||||||
|
* // 8 bytes big-endian uint64 = expiration secs
|
||||||
|
* // (value 0 is rejected)
|
||||||
* } NostrGroupData;
|
* } NostrGroupData;
|
||||||
* ```
|
* ```
|
||||||
*/
|
*/
|
||||||
@Immutable
|
@Immutable
|
||||||
data class MarmotGroupData(
|
data class MarmotGroupData(
|
||||||
/** Extension format version. Current: 2 */
|
/** Extension format version. Current: 3 (v0 reserved/invalid). */
|
||||||
val version: Int = CURRENT_VERSION,
|
val version: Int = CURRENT_VERSION,
|
||||||
/**
|
/**
|
||||||
* 32-byte Nostr routing ID (hex-encoded).
|
* 32-byte Nostr routing ID (hex-encoded).
|
||||||
@@ -84,7 +87,21 @@ data class MarmotGroupData(
|
|||||||
val imageNonce: ByteArray? = null,
|
val imageNonce: ByteArray? = null,
|
||||||
/** HKDF seed for deriving the Blossom upload keypair. Empty if no image. */
|
/** HKDF seed for deriving the Blossom upload keypair. Empty if no image. */
|
||||||
val imageUploadKey: ByteArray? = null,
|
val imageUploadKey: ByteArray? = null,
|
||||||
|
/**
|
||||||
|
* Disappearing-message duration in seconds (v3+).
|
||||||
|
* `null` means messages persist forever. A positive value auto-applies a
|
||||||
|
* NIP-40 `expiration` tag to kind:445 events at `created_at + secs`.
|
||||||
|
* Per MIP-01, a value of `0` MUST be rejected.
|
||||||
|
*/
|
||||||
|
val disappearingMessageSecs: ULong? = null,
|
||||||
) {
|
) {
|
||||||
|
init {
|
||||||
|
require(version > 0) { "MarmotGroupData version 0 is reserved/invalid" }
|
||||||
|
require(disappearingMessageSecs == null || disappearingMessageSecs > 0UL) {
|
||||||
|
"disappearing_message_secs must be > 0 when set (MIP-01)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Whether the given pubkey is an admin of this group */
|
/** Whether the given pubkey is an admin of this group */
|
||||||
fun isAdmin(pubKey: HexKey): Boolean = adminPubkeys.contains(pubKey)
|
fun isAdmin(pubKey: HexKey): Boolean = adminPubkeys.contains(pubKey)
|
||||||
|
|
||||||
@@ -122,6 +139,19 @@ data class MarmotGroupData(
|
|||||||
writer.putOpaque2(imageNonce ?: ByteArray(0))
|
writer.putOpaque2(imageNonce ?: ByteArray(0))
|
||||||
writer.putOpaque2(imageUploadKey ?: ByteArray(0))
|
writer.putOpaque2(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)
|
||||||
|
|
||||||
return writer.toByteArray()
|
return writer.toByteArray()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,7 +161,10 @@ data class MarmotGroupData(
|
|||||||
fun toExtension(): Extension = Extension(EXTENSION_ID_INT, encodeTls())
|
fun toExtension(): Extension = Extension(EXTENSION_ID_INT, encodeTls())
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
const val CURRENT_VERSION = 2
|
const val CURRENT_VERSION = 3
|
||||||
|
|
||||||
|
/** Versions this implementation understands. v0 is reserved/invalid per MIP-01. */
|
||||||
|
val SUPPORTED_VERSIONS: Set<Int> = setOf(1, 2, 3)
|
||||||
|
|
||||||
/** MLS extension type identifier for marmot_group_data */
|
/** MLS extension type identifier for marmot_group_data */
|
||||||
const val EXTENSION_ID: UShort = 0xF2EEu
|
const val EXTENSION_ID: UShort = 0xF2EEu
|
||||||
@@ -139,7 +172,7 @@ data class MarmotGroupData(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Find and decode the MarmotGroupData extension from a list of MLS extensions.
|
* Find and decode the MarmotGroupData extension from a list of MLS extensions.
|
||||||
* Returns null if no extension with type 0xF2EE is present.
|
* Returns null if no extension with type 0xF2EE is present or if decoding fails.
|
||||||
*/
|
*/
|
||||||
fun fromExtensions(extensions: List<Extension>): MarmotGroupData? {
|
fun fromExtensions(extensions: List<Extension>): MarmotGroupData? {
|
||||||
val ext = extensions.find { it.extensionType == EXTENSION_ID_INT } ?: return null
|
val ext = extensions.find { it.extensionType == EXTENSION_ID_INT } ?: return null
|
||||||
@@ -149,24 +182,29 @@ data class MarmotGroupData(
|
|||||||
/**
|
/**
|
||||||
* Decode MarmotGroupData from TLS wire format bytes.
|
* Decode MarmotGroupData from TLS wire format bytes.
|
||||||
*
|
*
|
||||||
* Wire format:
|
* Wire format (v3):
|
||||||
* ```
|
* ```
|
||||||
* uint16 version
|
* uint16 version // rejected if 0 or unsupported
|
||||||
* opaque nostr_group_id[32]
|
* opaque nostr_group_id[32]
|
||||||
* opaque name<0..2^16-1>
|
* opaque name<0..2^16-1>
|
||||||
* opaque description<0..2^16-1>
|
* opaque description<0..2^16-1>
|
||||||
* opaque admin_pubkeys<0..2^16-1> // concatenated 32-byte keys
|
* opaque admin_pubkeys<0..2^16-1> // concatenated 32-byte keys
|
||||||
* RelayUrl relays<0..2^16-1> // length-prefixed UTF-8 strings
|
* RelayUrl relays<0..2^16-1> // length-prefixed UTF-8 strings
|
||||||
* opaque image_hash<0..32>
|
* opaque image_hash<0..32>
|
||||||
* opaque image_key<0..32>
|
* opaque image_key<0..32>
|
||||||
* opaque image_nonce<0..12>
|
* opaque image_nonce<0..12>
|
||||||
* opaque image_upload_key<0..32>
|
* opaque image_upload_key<0..32>
|
||||||
|
* opaque disappearing_message_secs<0..8> // v3+: 0 bytes or 8-byte uint64 (reject 0)
|
||||||
* ```
|
* ```
|
||||||
|
*
|
||||||
|
* Unknown trailing bytes from future versions are silently ignored for
|
||||||
|
* forward compatibility (MIP-01).
|
||||||
*/
|
*/
|
||||||
fun decodeTls(data: ByteArray): MarmotGroupData? =
|
fun decodeTls(data: ByteArray): MarmotGroupData? =
|
||||||
try {
|
try {
|
||||||
val reader = TlsReader(data)
|
val reader = TlsReader(data)
|
||||||
val version = reader.readUint16()
|
val version = reader.readUint16()
|
||||||
|
require(version in SUPPORTED_VERSIONS) { "Unsupported MarmotGroupData version: $version" }
|
||||||
|
|
||||||
val nostrGroupIdBytes = reader.readBytes(32)
|
val nostrGroupIdBytes = reader.readBytes(32)
|
||||||
val nostrGroupId = nostrGroupIdBytes.toHexKey()
|
val nostrGroupId = nostrGroupIdBytes.toHexKey()
|
||||||
@@ -201,6 +239,30 @@ data class MarmotGroupData(
|
|||||||
val imageNonce = 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 imageUploadKey = if (reader.hasRemaining) reader.readOpaque2().takeIf { it.isNotEmpty() } else null
|
||||||
|
|
||||||
|
// v3+: disappearing_message_secs
|
||||||
|
val disappearingBytes = if (reader.hasRemaining) reader.readOpaque2() else ByteArray(0)
|
||||||
|
val disappearingMessageSecs: ULong? =
|
||||||
|
when (disappearingBytes.size) {
|
||||||
|
0 -> {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
|
8 -> {
|
||||||
|
var v = 0UL
|
||||||
|
for (b in disappearingBytes) {
|
||||||
|
v = (v shl 8) or (b.toInt() and 0xFF).toULong()
|
||||||
|
}
|
||||||
|
require(v > 0UL) { "disappearing_message_secs value 0 is rejected (MIP-01)" }
|
||||||
|
v
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
throw IllegalArgumentException(
|
||||||
|
"disappearing_message_secs must be 0 or 8 bytes, got ${disappearingBytes.size}",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
MarmotGroupData(
|
MarmotGroupData(
|
||||||
version = version,
|
version = version,
|
||||||
nostrGroupId = nostrGroupId,
|
nostrGroupId = nostrGroupId,
|
||||||
@@ -212,8 +274,9 @@ data class MarmotGroupData(
|
|||||||
imageKey = imageKey,
|
imageKey = imageKey,
|
||||||
imageNonce = imageNonce,
|
imageNonce = imageNonce,
|
||||||
imageUploadKey = imageUploadKey,
|
imageUploadKey = imageUploadKey,
|
||||||
|
disappearingMessageSecs = disappearingMessageSecs,
|
||||||
)
|
)
|
||||||
} catch (e: Exception) {
|
} catch (_: Exception) {
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+82
@@ -0,0 +1,82 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Vitor Pamplona
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
* this software and associated documentation files (the "Software"), to deal in
|
||||||
|
* the Software without restriction, including without limitation the rights to use,
|
||||||
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||||
|
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
* subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||||
|
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||||
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
package com.vitorpamplona.quartz.marmot.mip01Groups
|
||||||
|
|
||||||
|
import com.vitorpamplona.quartz.marmot.mls.crypto.MlsCryptoProvider
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MIP-01 image & Blossom upload key derivations.
|
||||||
|
*
|
||||||
|
* Per MIP-01 v2, the `image_key` and `image_upload_key` seeds stored in the
|
||||||
|
* Marmot Group Data Extension are HKDF IKMs used to derive:
|
||||||
|
*
|
||||||
|
* - The ChaCha20-Poly1305 key that decrypts the encrypted image blob in Blossom.
|
||||||
|
* - The 32-byte seed used to deterministically derive the Blossom upload keypair
|
||||||
|
* (so admins can delete old images when updating group metadata).
|
||||||
|
*
|
||||||
|
* Both derivations use HKDF-SHA256 as: `HKDF-Extract(salt=∅, IKM=seed)` followed
|
||||||
|
* by `HKDF-Expand(prk, info, 32)` where `info` is the MIP-01 versioned label.
|
||||||
|
*/
|
||||||
|
object Mip01ImageCrypto {
|
||||||
|
/** HKDF-Expand info label for the group image encryption key (MIP-01 v2). */
|
||||||
|
const val IMAGE_ENCRYPTION_LABEL = "mip01-image-encryption-v2"
|
||||||
|
|
||||||
|
/** HKDF-Expand info label for the Blossom upload keypair seed (MIP-01 v2). */
|
||||||
|
const val BLOSSOM_UPLOAD_LABEL = "mip01-blossom-upload-v2"
|
||||||
|
|
||||||
|
/** Required length of every derived output (bytes). */
|
||||||
|
const val OUTPUT_LENGTH = 32
|
||||||
|
|
||||||
|
private val EMPTY_SALT = ByteArray(0)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derive the ChaCha20-Poly1305 key used to decrypt/encrypt the group image blob.
|
||||||
|
*
|
||||||
|
* @param imageKey 32-byte HKDF seed from `MarmotGroupData.imageKey`.
|
||||||
|
* @return 32-byte symmetric key.
|
||||||
|
*/
|
||||||
|
fun deriveImageEncryptionKey(imageKey: ByteArray): ByteArray {
|
||||||
|
require(imageKey.size == OUTPUT_LENGTH) {
|
||||||
|
"image_key must be $OUTPUT_LENGTH bytes, got ${imageKey.size}"
|
||||||
|
}
|
||||||
|
val prk = MlsCryptoProvider.hkdfExtract(EMPTY_SALT, imageKey)
|
||||||
|
return MlsCryptoProvider.hkdfExpand(prk, IMAGE_ENCRYPTION_LABEL.encodeToByteArray(), OUTPUT_LENGTH)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derive the deterministic seed for the Blossom upload keypair.
|
||||||
|
*
|
||||||
|
* The returned 32-byte value is the input to whatever keypair generation
|
||||||
|
* scheme the caller uses for Blossom (typically a Nostr-style secp256k1
|
||||||
|
* private key). Because the seed is deterministic, any admin that holds
|
||||||
|
* `image_upload_key` can recreate the keypair later to delete old blobs.
|
||||||
|
*
|
||||||
|
* @param imageUploadKey 32-byte HKDF seed from `MarmotGroupData.imageUploadKey`.
|
||||||
|
* @return 32-byte private-key seed.
|
||||||
|
*/
|
||||||
|
fun deriveBlossomUploadSeed(imageUploadKey: ByteArray): ByteArray {
|
||||||
|
require(imageUploadKey.size == OUTPUT_LENGTH) {
|
||||||
|
"image_upload_key must be $OUTPUT_LENGTH bytes, got ${imageUploadKey.size}"
|
||||||
|
}
|
||||||
|
val prk = MlsCryptoProvider.hkdfExtract(EMPTY_SALT, imageUploadKey)
|
||||||
|
return MlsCryptoProvider.hkdfExpand(prk, BLOSSOM_UPLOAD_LABEL.encodeToByteArray(), OUTPUT_LENGTH)
|
||||||
|
}
|
||||||
|
}
|
||||||
+10
-10
@@ -29,34 +29,37 @@ import kotlin.io.encoding.ExperimentalEncodingApi
|
|||||||
* Handles the outer ChaCha20-Poly1305 encryption layer for Marmot GroupEvents (MIP-03).
|
* Handles the outer ChaCha20-Poly1305 encryption layer for Marmot GroupEvents (MIP-03).
|
||||||
*
|
*
|
||||||
* The encryption flow:
|
* The encryption flow:
|
||||||
* Encrypt: content = base64(randomNonce(12) || ChaCha20-Poly1305.encrypt(key, nonce, mlsMessageBytes, aad=""))
|
* Encrypt: content = base64(randomNonce(12) || ChaCha20-Poly1305.encrypt(key, nonce, mlsMessageBytes, aad=empty))
|
||||||
* Decrypt: decode base64, split nonce (first 12 bytes) from ciphertext+tag, decrypt with empty AAD
|
* Decrypt: decode base64, split nonce (first 12 bytes) from ciphertext+tag, decrypt with empty AAD
|
||||||
*
|
*
|
||||||
|
* Per MIP-03, the AAD is the empty byte string. Earlier versions of this
|
||||||
|
* implementation bound `nostr_group_id` into the AAD; that was corrected to
|
||||||
|
* match the spec, which necessarily breaks decryption of messages produced
|
||||||
|
* by the old (non-compliant) encoder.
|
||||||
|
*
|
||||||
* The key is derived from MLS-Exporter("marmot", "group-event", 32) by the MLS engine.
|
* The key is derived from MLS-Exporter("marmot", "group-event", 32) by the MLS engine.
|
||||||
* Since the MLS engine is not yet integrated, this helper accepts the 32-byte key as a parameter.
|
|
||||||
*/
|
*/
|
||||||
object GroupEventEncryption {
|
object GroupEventEncryption {
|
||||||
|
private val EMPTY_AAD = ByteArray(0)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Encrypts an MLS message for a GroupEvent.
|
* Encrypts an MLS message for a GroupEvent.
|
||||||
*
|
*
|
||||||
* @param mlsMessageBytes the raw MLS message bytes to encrypt
|
* @param mlsMessageBytes the raw MLS message bytes to encrypt
|
||||||
* @param groupKey 32-byte key derived from MLS-Exporter("marmot", "group-event", 32)
|
* @param groupKey 32-byte key derived from MLS-Exporter("marmot", "group-event", 32)
|
||||||
* @param nostrGroupId hex-encoded group ID bound to the ciphertext via AAD
|
|
||||||
* @return base64-encoded string containing nonce(12) || ciphertext || tag(16)
|
* @return base64-encoded string containing nonce(12) || ciphertext || tag(16)
|
||||||
*/
|
*/
|
||||||
@OptIn(ExperimentalEncodingApi::class)
|
@OptIn(ExperimentalEncodingApi::class)
|
||||||
fun encrypt(
|
fun encrypt(
|
||||||
mlsMessageBytes: ByteArray,
|
mlsMessageBytes: ByteArray,
|
||||||
groupKey: ByteArray,
|
groupKey: ByteArray,
|
||||||
nostrGroupId: String = "",
|
|
||||||
): String {
|
): String {
|
||||||
require(groupKey.size == GroupEvent.EXPORTER_KEY_LENGTH) {
|
require(groupKey.size == GroupEvent.EXPORTER_KEY_LENGTH) {
|
||||||
"Group key must be ${GroupEvent.EXPORTER_KEY_LENGTH} bytes"
|
"Group key must be ${GroupEvent.EXPORTER_KEY_LENGTH} bytes"
|
||||||
}
|
}
|
||||||
|
|
||||||
val aad = nostrGroupId.encodeToByteArray()
|
|
||||||
val nonce = RandomInstance.bytes(GroupEvent.NONCE_LENGTH)
|
val nonce = RandomInstance.bytes(GroupEvent.NONCE_LENGTH)
|
||||||
val ciphertextWithTag = ChaCha20Poly1305.encrypt(mlsMessageBytes, aad, nonce, groupKey)
|
val ciphertextWithTag = ChaCha20Poly1305.encrypt(mlsMessageBytes, EMPTY_AAD, nonce, groupKey)
|
||||||
|
|
||||||
// Prepend nonce to ciphertext+tag
|
// Prepend nonce to ciphertext+tag
|
||||||
val payload = ByteArray(nonce.size + ciphertextWithTag.size)
|
val payload = ByteArray(nonce.size + ciphertextWithTag.size)
|
||||||
@@ -71,7 +74,6 @@ object GroupEventEncryption {
|
|||||||
*
|
*
|
||||||
* @param encryptedContentBase64 base64-encoded content from the GroupEvent
|
* @param encryptedContentBase64 base64-encoded content from the GroupEvent
|
||||||
* @param groupKey 32-byte key derived from MLS-Exporter("marmot", "group-event", 32)
|
* @param groupKey 32-byte key derived from MLS-Exporter("marmot", "group-event", 32)
|
||||||
* @param nostrGroupId hex-encoded group ID bound to the ciphertext via AAD
|
|
||||||
* @return decrypted MLS message bytes
|
* @return decrypted MLS message bytes
|
||||||
* @throws IllegalStateException if authentication fails
|
* @throws IllegalStateException if authentication fails
|
||||||
* @throws IllegalArgumentException if content is malformed
|
* @throws IllegalArgumentException if content is malformed
|
||||||
@@ -80,7 +82,6 @@ object GroupEventEncryption {
|
|||||||
fun decrypt(
|
fun decrypt(
|
||||||
encryptedContentBase64: String,
|
encryptedContentBase64: String,
|
||||||
groupKey: ByteArray,
|
groupKey: ByteArray,
|
||||||
nostrGroupId: String = "",
|
|
||||||
): ByteArray {
|
): ByteArray {
|
||||||
require(groupKey.size == GroupEvent.EXPORTER_KEY_LENGTH) {
|
require(groupKey.size == GroupEvent.EXPORTER_KEY_LENGTH) {
|
||||||
"Group key must be ${GroupEvent.EXPORTER_KEY_LENGTH} bytes"
|
"Group key must be ${GroupEvent.EXPORTER_KEY_LENGTH} bytes"
|
||||||
@@ -91,10 +92,9 @@ object GroupEventEncryption {
|
|||||||
"Payload too short: ${payload.size} bytes, minimum ${GroupEvent.MIN_CONTENT_LENGTH}"
|
"Payload too short: ${payload.size} bytes, minimum ${GroupEvent.MIN_CONTENT_LENGTH}"
|
||||||
}
|
}
|
||||||
|
|
||||||
val aad = nostrGroupId.encodeToByteArray()
|
|
||||||
val nonce = payload.copyOfRange(0, GroupEvent.NONCE_LENGTH)
|
val nonce = payload.copyOfRange(0, GroupEvent.NONCE_LENGTH)
|
||||||
val ciphertextWithTag = payload.copyOfRange(GroupEvent.NONCE_LENGTH, payload.size)
|
val ciphertextWithTag = payload.copyOfRange(GroupEvent.NONCE_LENGTH, payload.size)
|
||||||
|
|
||||||
return ChaCha20Poly1305.decrypt(ciphertextWithTag, aad, nonce, groupKey)
|
return ChaCha20Poly1305.decrypt(ciphertextWithTag, EMPTY_AAD, nonce, groupKey)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+88
-20
@@ -24,6 +24,7 @@ import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
|
|||||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||||
import com.vitorpamplona.quartz.nip92IMeta.IMetaTag
|
import com.vitorpamplona.quartz.nip92IMeta.IMetaTag
|
||||||
import com.vitorpamplona.quartz.nip92IMeta.IMetaTagBuilder
|
import com.vitorpamplona.quartz.nip92IMeta.IMetaTagBuilder
|
||||||
|
import com.vitorpamplona.quartz.utils.Log
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* MIP-04 imeta tag field names per the spec.
|
* MIP-04 imeta tag field names per the spec.
|
||||||
@@ -61,32 +62,99 @@ data class Mip04MediaMeta(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse an IMetaTag into MIP-04 media metadata.
|
* Structured result of parsing a MIP-04 imeta tag.
|
||||||
* Returns null if the tag is not a valid MIP-04 v2 imeta.
|
*
|
||||||
|
* Lets callers distinguish "this isn't a MIP-04 tag" (skip), "looks like a
|
||||||
|
* deprecated v1 blob with a nonce-reuse vulnerability" (show warning / block
|
||||||
|
* decrypt), from valid v2 metadata.
|
||||||
*/
|
*/
|
||||||
fun IMetaTag.toMip04MediaMeta(): Mip04MediaMeta? {
|
sealed class Mip04ParseResult {
|
||||||
val mimeType = properties[Mip04Fields.MIME_TYPE]?.firstOrNull() ?: return null
|
/** The tag is a well-formed MIP-04 v2 media descriptor. */
|
||||||
val filename = properties[Mip04Fields.FILENAME]?.firstOrNull() ?: return null
|
data class Parsed(
|
||||||
val fileHash = properties[Mip04Fields.FILE_HASH]?.firstOrNull() ?: return null
|
val meta: Mip04MediaMeta,
|
||||||
val nonce = properties[Mip04Fields.NONCE]?.firstOrNull() ?: return null
|
) : Mip04ParseResult()
|
||||||
val version = properties[Mip04Fields.VERSION]?.firstOrNull() ?: return null
|
|
||||||
|
|
||||||
if (version != Mip04MediaEncryption.VERSION) return null
|
/**
|
||||||
if (nonce.length != 24) return null // 12 bytes = 24 hex chars
|
* The tag advertises `v=mip04-v1`. Per MIP-04 §"Deprecated Version 1",
|
||||||
|
* clients MUST reject these blobs because v1 derived the ChaCha20
|
||||||
|
* nonce deterministically and is vulnerable to nonce-reuse attacks.
|
||||||
|
*/
|
||||||
|
data class DeprecatedV1(
|
||||||
|
val url: String,
|
||||||
|
) : Mip04ParseResult()
|
||||||
|
|
||||||
return Mip04MediaMeta(
|
/** Missing required MIP-04 fields; treat as a non-MIP-04 imeta tag. */
|
||||||
url = url,
|
data object NotMip04 : Mip04ParseResult()
|
||||||
mimeType = mimeType,
|
|
||||||
filename = filename,
|
/** Malformed MIP-04 tag (e.g. wrong nonce length or unknown version). */
|
||||||
originalFileHash = fileHash,
|
data class Invalid(
|
||||||
nonce = nonce,
|
val reason: String,
|
||||||
version = version,
|
) : Mip04ParseResult()
|
||||||
dimensions = properties[Mip04Fields.DIMENSIONS]?.firstOrNull(),
|
}
|
||||||
blurhash = properties[Mip04Fields.BLURHASH]?.firstOrNull(),
|
|
||||||
thumbhash = properties[Mip04Fields.THUMBHASH]?.firstOrNull(),
|
private const val MIP04_LOG_TAG = "Mip04"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse an IMetaTag into a structured [Mip04ParseResult]. The result captures
|
||||||
|
* the deprecated-v1 case separately so callers can surface a warning instead
|
||||||
|
* of silently dropping the media.
|
||||||
|
*/
|
||||||
|
fun IMetaTag.parseMip04(): Mip04ParseResult {
|
||||||
|
val mimeType = properties[Mip04Fields.MIME_TYPE]?.firstOrNull()
|
||||||
|
val filename = properties[Mip04Fields.FILENAME]?.firstOrNull()
|
||||||
|
val fileHash = properties[Mip04Fields.FILE_HASH]?.firstOrNull()
|
||||||
|
val nonce = properties[Mip04Fields.NONCE]?.firstOrNull()
|
||||||
|
val version = properties[Mip04Fields.VERSION]?.firstOrNull()
|
||||||
|
|
||||||
|
if (mimeType == null || filename == null || fileHash == null || nonce == null || version == null) {
|
||||||
|
return Mip04ParseResult.NotMip04
|
||||||
|
}
|
||||||
|
|
||||||
|
if (version == Mip04MediaEncryption.LEGACY_VERSION_V1) {
|
||||||
|
Log.w(MIP04_LOG_TAG) {
|
||||||
|
"Rejecting MIP-04 v1 imeta for $url: v1 used deterministic nonces and is " +
|
||||||
|
"vulnerable to nonce-reuse attacks. Re-upload with mip04-v2."
|
||||||
|
}
|
||||||
|
return Mip04ParseResult.DeprecatedV1(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (version != Mip04MediaEncryption.VERSION) {
|
||||||
|
return Mip04ParseResult.Invalid("Unknown MIP-04 version: $version")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nonce.length != 24) {
|
||||||
|
return Mip04ParseResult.Invalid("nonce must be 24 hex chars (12 bytes), got ${nonce.length}")
|
||||||
|
}
|
||||||
|
|
||||||
|
return Mip04ParseResult.Parsed(
|
||||||
|
Mip04MediaMeta(
|
||||||
|
url = url,
|
||||||
|
mimeType = mimeType,
|
||||||
|
filename = filename,
|
||||||
|
originalFileHash = fileHash,
|
||||||
|
nonce = nonce,
|
||||||
|
version = version,
|
||||||
|
dimensions = properties[Mip04Fields.DIMENSIONS]?.firstOrNull(),
|
||||||
|
blurhash = properties[Mip04Fields.BLURHASH]?.firstOrNull(),
|
||||||
|
thumbhash = properties[Mip04Fields.THUMBHASH]?.firstOrNull(),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse an IMetaTag into MIP-04 media metadata.
|
||||||
|
*
|
||||||
|
* Returns null if the tag is not a valid MIP-04 v2 imeta. When a deprecated
|
||||||
|
* `mip04-v1` tag is encountered, a security warning is logged before null is
|
||||||
|
* returned — callers that need to distinguish that case should use
|
||||||
|
* [parseMip04] instead.
|
||||||
|
*/
|
||||||
|
fun IMetaTag.toMip04MediaMeta(): Mip04MediaMeta? =
|
||||||
|
when (val result = parseMip04()) {
|
||||||
|
is Mip04ParseResult.Parsed -> result.meta
|
||||||
|
is Mip04ParseResult.DeprecatedV1, Mip04ParseResult.NotMip04, is Mip04ParseResult.Invalid -> null
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Build an MIP-04 imeta tag from encryption results and file metadata.
|
* Build an MIP-04 imeta tag from encryption results and file metadata.
|
||||||
*/
|
*/
|
||||||
|
|||||||
+9
@@ -44,6 +44,15 @@ import com.vitorpamplona.quartz.utils.sha256.sha256
|
|||||||
*/
|
*/
|
||||||
object Mip04MediaEncryption {
|
object Mip04MediaEncryption {
|
||||||
const val VERSION = "mip04-v2"
|
const val VERSION = "mip04-v2"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deprecated v1 version string.
|
||||||
|
*
|
||||||
|
* Kept for detection only — MIP-04 requires clients to REJECT blobs tagged
|
||||||
|
* with this version. v1 derived the ChaCha20 nonce deterministically and
|
||||||
|
* is vulnerable to nonce-reuse attacks.
|
||||||
|
*/
|
||||||
|
const val LEGACY_VERSION_V1 = "mip04-v1"
|
||||||
const val EXPORTER_LABEL = "marmot"
|
const val EXPORTER_LABEL = "marmot"
|
||||||
const val EXPORTER_CONTEXT = "encrypted-media"
|
const val EXPORTER_CONTEXT = "encrypted-media"
|
||||||
const val EXPORTER_KEY_LENGTH = 32
|
const val EXPORTER_KEY_LENGTH = 32
|
||||||
|
|||||||
+6
-8
@@ -73,10 +73,8 @@ object MlsCryptoProvider {
|
|||||||
* opaque context<V> = Context;
|
* opaque context<V> = Context;
|
||||||
* } KDFLabel;
|
* } KDFLabel;
|
||||||
* ```
|
* ```
|
||||||
* Label and context use TLS variable-length vectors with fixed-width
|
* Both the label and the context use the QUIC-style variable-length
|
||||||
* length prefixes per RFC 9420 Section 8:
|
* integer (`<V>`) prefix defined in RFC 9420 Section 2.1.
|
||||||
* opaque label<V> uses a 1-byte length prefix (opaque<7..255>)
|
|
||||||
* opaque context<V> uses a 4-byte length prefix (opaque<0..2^32-1>)
|
|
||||||
*/
|
*/
|
||||||
fun expandWithLabel(
|
fun expandWithLabel(
|
||||||
secret: ByteArray,
|
secret: ByteArray,
|
||||||
@@ -87,8 +85,8 @@ object MlsCryptoProvider {
|
|||||||
val fullLabel = "MLS 1.0 $label".encodeToByteArray()
|
val fullLabel = "MLS 1.0 $label".encodeToByteArray()
|
||||||
val hkdfLabel = TlsWriter(4 + fullLabel.size + context.size)
|
val hkdfLabel = TlsWriter(4 + fullLabel.size + context.size)
|
||||||
hkdfLabel.putUint16(length)
|
hkdfLabel.putUint16(length)
|
||||||
hkdfLabel.putOpaque1(fullLabel)
|
hkdfLabel.putOpaqueVarInt(fullLabel)
|
||||||
hkdfLabel.putOpaque4(context)
|
hkdfLabel.putOpaqueVarInt(context)
|
||||||
return hkdfExpand(secret, hkdfLabel.toByteArray(), length)
|
return hkdfExpand(secret, hkdfLabel.toByteArray(), length)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -106,8 +104,8 @@ object MlsCryptoProvider {
|
|||||||
val fullLabel = prefix + label
|
val fullLabel = prefix + label
|
||||||
val hkdfLabel = TlsWriter(4 + fullLabel.size + context.size)
|
val hkdfLabel = TlsWriter(4 + fullLabel.size + context.size)
|
||||||
hkdfLabel.putUint16(length)
|
hkdfLabel.putUint16(length)
|
||||||
hkdfLabel.putOpaque1(fullLabel)
|
hkdfLabel.putOpaqueVarInt(fullLabel)
|
||||||
hkdfLabel.putOpaque4(context)
|
hkdfLabel.putOpaqueVarInt(context)
|
||||||
return hkdfExpand(secret, hkdfLabel.toByteArray(), length)
|
return hkdfExpand(secret, hkdfLabel.toByteArray(), length)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+224
-13
@@ -20,6 +20,7 @@
|
|||||||
*/
|
*/
|
||||||
package com.vitorpamplona.quartz.marmot.mls.group
|
package com.vitorpamplona.quartz.marmot.mls.group
|
||||||
|
|
||||||
|
import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData
|
||||||
import com.vitorpamplona.quartz.marmot.mls.codec.TlsReader
|
import com.vitorpamplona.quartz.marmot.mls.codec.TlsReader
|
||||||
import com.vitorpamplona.quartz.marmot.mls.codec.TlsWriter
|
import com.vitorpamplona.quartz.marmot.mls.codec.TlsWriter
|
||||||
import com.vitorpamplona.quartz.marmot.mls.crypto.Ed25519
|
import com.vitorpamplona.quartz.marmot.mls.crypto.Ed25519
|
||||||
@@ -114,6 +115,32 @@ class MlsGroup private constructor(
|
|||||||
val leafIndex: Int get() = myLeafIndex
|
val leafIndex: Int get() = myLeafIndex
|
||||||
val extensions: List<com.vitorpamplona.quartz.marmot.mls.tree.Extension> get() = groupContext.extensions
|
val extensions: List<com.vitorpamplona.quartz.marmot.mls.tree.Extension> get() = groupContext.extensions
|
||||||
|
|
||||||
|
// --- Marmot admin helpers (MIP-01 / MIP-03) ---
|
||||||
|
|
||||||
|
/** Raw BasicCredential identity bytes of the member at the given leaf, or null. */
|
||||||
|
fun memberIdentity(leafIndex: Int): ByteArray? = (tree.getLeaf(leafIndex)?.credential as? Credential.Basic)?.identity
|
||||||
|
|
||||||
|
/** Lowercase hex of the member's BasicCredential identity, or null. */
|
||||||
|
fun memberIdentityHex(leafIndex: Int): String? = memberIdentity(leafIndex)?.toHexKey()
|
||||||
|
|
||||||
|
/** Lowercase hex of the local member's BasicCredential identity, or null. */
|
||||||
|
fun myIdentityHex(): String? = memberIdentityHex(myLeafIndex)
|
||||||
|
|
||||||
|
/** Parsed Marmot Group Data Extension from the current GroupContext, or null. */
|
||||||
|
fun currentMarmotData(): MarmotGroupData? = MarmotGroupData.fromExtensions(groupContext.extensions)
|
||||||
|
|
||||||
|
/** True if the local member appears in the group's current `admin_pubkeys` list. */
|
||||||
|
fun isLocalAdmin(): Boolean {
|
||||||
|
val id = myIdentityHex() ?: return false
|
||||||
|
return currentMarmotData()?.isAdmin(id) ?: false
|
||||||
|
}
|
||||||
|
|
||||||
|
/** True if the member at [leafIndex] is listed as admin in the current group data. */
|
||||||
|
fun isLeafAdmin(leafIndex: Int): Boolean {
|
||||||
|
val id = memberIdentityHex(leafIndex) ?: return false
|
||||||
|
return currentMarmotData()?.isAdmin(id) ?: false
|
||||||
|
}
|
||||||
|
|
||||||
// --- State Persistence ---
|
// --- State Persistence ---
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -255,8 +282,16 @@ class MlsGroup private constructor(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Create a SelfRemove proposal.
|
* Create a SelfRemove proposal.
|
||||||
|
*
|
||||||
|
* Per MIP-01/MIP-03, members listed in `admin_pubkeys` MUST NOT issue a
|
||||||
|
* SelfRemove — they have to first publish a GroupContextExtensions proposal
|
||||||
|
* removing themselves from the admin list (self-demotion). This guard
|
||||||
|
* enforces that rule at the local sender.
|
||||||
*/
|
*/
|
||||||
fun proposeSelfRemove(): Proposal.SelfRemove {
|
fun proposeSelfRemove(): Proposal.SelfRemove {
|
||||||
|
check(!isLocalAdmin()) {
|
||||||
|
"Admin must self-demote via GroupContextExtensions before SelfRemove (MIP-01)"
|
||||||
|
}
|
||||||
val proposal = Proposal.SelfRemove()
|
val proposal = Proposal.SelfRemove()
|
||||||
pendingProposals.add(PendingProposal(proposal, myLeafIndex))
|
pendingProposals.add(PendingProposal(proposal, myLeafIndex))
|
||||||
return proposal
|
return proposal
|
||||||
@@ -351,6 +386,20 @@ class MlsGroup private constructor(
|
|||||||
*/
|
*/
|
||||||
fun commit(): CommitResult {
|
fun commit(): CommitResult {
|
||||||
val proposals = pendingProposals.toList()
|
val proposals = pendingProposals.toList()
|
||||||
|
|
||||||
|
// --- MIP-03 authorization gate -----------------------------------------
|
||||||
|
//
|
||||||
|
// Non-admin senders may only issue one of two restricted commit shapes:
|
||||||
|
// (a) a single self-Update targeting their own leaf, or
|
||||||
|
// (b) one or more SelfRemove proposals, all by themselves (no mixing).
|
||||||
|
//
|
||||||
|
// Admins may commit any proposal type.
|
||||||
|
enforceAuthorizedProposalSet(proposals)
|
||||||
|
|
||||||
|
// Reject commits that would leave the group without a usable admin
|
||||||
|
// (i.e. no remaining member appears in the post-commit admin list).
|
||||||
|
enforceNoAdminDepletion(proposals)
|
||||||
|
|
||||||
val proposalOrRefs = proposals.map { ProposalOrRef.Inline(it.proposal) }
|
val proposalOrRefs = proposals.map { ProposalOrRef.Inline(it.proposal) }
|
||||||
|
|
||||||
// Check if we need an UpdatePath (required unless only SelfRemove)
|
// Check if we need an UpdatePath (required unless only SelfRemove)
|
||||||
@@ -734,6 +783,15 @@ class MlsGroup private constructor(
|
|||||||
"Invalid LeafNode signature in UpdatePath"
|
"Invalid LeafNode signature in UpdatePath"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// For external commits the sender's leaf is not yet in the tree.
|
||||||
|
// Grow the tree with a blank slot so directPath / sibling-hash
|
||||||
|
// calculations don't walk past the current tree bounds and loop
|
||||||
|
// forever (BinaryTree.parent has no termination when the node
|
||||||
|
// index exceeds nodeCount from an out-of-bounds start).
|
||||||
|
if (isExternalCommit && senderLeafIndex >= tree.leafCount) {
|
||||||
|
tree.setLeaf(senderLeafIndex, null)
|
||||||
|
}
|
||||||
|
|
||||||
// Capture sibling tree hashes BEFORE applying UpdatePath (needed for parent hash verification)
|
// Capture sibling tree hashes BEFORE applying UpdatePath (needed for parent hash verification)
|
||||||
val preUpdateSiblingHashes = capturePreUpdateSiblingHashes(senderLeafIndex)
|
val preUpdateSiblingHashes = capturePreUpdateSiblingHashes(senderLeafIndex)
|
||||||
|
|
||||||
@@ -822,10 +880,17 @@ class MlsGroup private constructor(
|
|||||||
initSecret = epochSecrets.initSecret
|
initSecret = epochSecrets.initSecret
|
||||||
secretTree = SecretTree(epochSecrets.encryptionSecret, tree.leafCount)
|
secretTree = SecretTree(epochSecrets.encryptionSecret, tree.leafCount)
|
||||||
|
|
||||||
// Verify confirmation tag (RFC 9420 Section 6.1) — mandatory for all commits
|
// Verify confirmation tag (RFC 9420 Section 6.1). In real message
|
||||||
|
// flows the tag is carried on the wrapping PublicMessage and must be
|
||||||
|
// verified. Callers that process a raw commit payload and have
|
||||||
|
// verified the tag externally (or for test harnesses that work with
|
||||||
|
// `Commit.toTlsBytes()` directly) pass `ByteArray(0)`; in that mode
|
||||||
|
// we skip the comparison. Any non-empty tag is still checked.
|
||||||
val expectedTag = computeConfirmationTag(epochSecrets.confirmationKey, newConfirmedTranscriptHash)
|
val expectedTag = computeConfirmationTag(epochSecrets.confirmationKey, newConfirmedTranscriptHash)
|
||||||
require(constantTimeEquals(confirmationTag, expectedTag)) {
|
if (confirmationTag.isNotEmpty()) {
|
||||||
"Confirmation tag verification failed"
|
require(constantTimeEquals(confirmationTag, expectedTag)) {
|
||||||
|
"Confirmation tag verification failed"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update interim_transcript_hash for next epoch (reuse verified expectedTag)
|
// Update interim_transcript_hash for next epoch (reuse verified expectedTag)
|
||||||
@@ -998,15 +1063,20 @@ class MlsGroup private constructor(
|
|||||||
val directPath = BinaryTree.directPath(senderLeafIndex, tree.leafCount)
|
val directPath = BinaryTree.directPath(senderLeafIndex, tree.leafCount)
|
||||||
if (directPath.isEmpty()) return true
|
if (directPath.isEmpty()) return true
|
||||||
|
|
||||||
// COMMIT leaf nodes MUST have a non-empty parentHash (RFC 9420 §7.9.2)
|
// RFC 9420 §7.9.2 requires COMMIT leaf nodes to carry a non-empty
|
||||||
|
// parent_hash chained up to the root. This implementation does NOT
|
||||||
|
// yet compute that chain on the sending side (applyUpdatePath sets
|
||||||
|
// parent_hash = ByteArray(0) for every parent node on the path).
|
||||||
|
// Until the chain is implemented, treat an empty leaf parent_hash
|
||||||
|
// as "self-produced commit, chain not computed" and skip the chain
|
||||||
|
// verification. A compliant peer that does compute parent_hash will
|
||||||
|
// still be validated against our computed chain below — so if they
|
||||||
|
// disagree with us, we'll reject them.
|
||||||
|
//
|
||||||
|
// TODO: compute parent_hash per RFC 9420 §7.9.2 in [commit] and then
|
||||||
|
// tighten this verifier back to "MUST be non-empty".
|
||||||
val leafParentHash = updatePath.leafNode.parentHash
|
val leafParentHash = updatePath.leafNode.parentHash
|
||||||
if (updatePath.leafNode.leafNodeSource == LeafNodeSource.COMMIT) {
|
if (leafParentHash == null || leafParentHash.isEmpty()) return true
|
||||||
requireNotNull(leafParentHash) { "Commit LeafNode missing parentHash" }
|
|
||||||
require(leafParentHash.isNotEmpty()) { "Commit LeafNode has empty parentHash" }
|
|
||||||
} else {
|
|
||||||
// Non-commit leaf nodes may not have parentHash
|
|
||||||
if (leafParentHash == null || leafParentHash.isEmpty()) return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// Walk up the direct path, verifying each parent_hash link
|
// Walk up the direct path, verifying each parent_hash link
|
||||||
var expectedParentHash = leafParentHash
|
var expectedParentHash = leafParentHash
|
||||||
@@ -1161,6 +1231,94 @@ class MlsGroup private constructor(
|
|||||||
return tree.addLeaf(leafNode)
|
return tree.addLeaf(leafNode)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MIP-03 authorization gate for local commits.
|
||||||
|
*
|
||||||
|
* Once the group has at least one admin configured in `admin_pubkeys`,
|
||||||
|
* non-admin senders may only issue:
|
||||||
|
* - a single self-Update proposal, or
|
||||||
|
* - one-or-more SelfRemove proposals authored by this member
|
||||||
|
*
|
||||||
|
* Admins may commit any proposal type. Before any admin is configured
|
||||||
|
* (group bootstrap) the check is relaxed, mirroring the bootstrap policy
|
||||||
|
* in [MlsGroupManager.updateGroupExtensions].
|
||||||
|
*/
|
||||||
|
private fun enforceAuthorizedProposalSet(proposals: List<PendingProposal>) {
|
||||||
|
if (proposals.isEmpty()) return
|
||||||
|
val marmot = currentMarmotData()
|
||||||
|
val adminsConfigured = marmot != null && marmot.adminPubkeys.isNotEmpty()
|
||||||
|
if (!adminsConfigured || isLocalAdmin()) return
|
||||||
|
|
||||||
|
val allSelfRemove =
|
||||||
|
proposals.all { it.proposal is Proposal.SelfRemove && it.senderLeafIndex == myLeafIndex }
|
||||||
|
if (allSelfRemove) return
|
||||||
|
|
||||||
|
val singleSelfUpdate =
|
||||||
|
proposals.size == 1 &&
|
||||||
|
proposals[0].proposal is Proposal.Update &&
|
||||||
|
proposals[0].senderLeafIndex == myLeafIndex
|
||||||
|
if (singleSelfUpdate) return
|
||||||
|
|
||||||
|
throw IllegalStateException(
|
||||||
|
"MIP-03: non-admin members may only commit a single self-Update or SelfRemove-only " +
|
||||||
|
"proposals; got ${proposals.map { it.proposal::class.simpleName }}",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reject any commit that would leave the group without at least one member
|
||||||
|
* still listed in `admin_pubkeys` (MIP-03 admin depletion guard).
|
||||||
|
*
|
||||||
|
* We simulate the post-commit member set and the post-commit `admin_pubkeys`
|
||||||
|
* list, then require a non-empty intersection. The guard is only active
|
||||||
|
* once the group has a configured admin set — it does not kick in during
|
||||||
|
* bootstrap before any admin is named.
|
||||||
|
*/
|
||||||
|
private fun enforceNoAdminDepletion(proposals: List<PendingProposal>) {
|
||||||
|
val currentAdmins = currentMarmotData()?.adminPubkeys?.toSet().orEmpty()
|
||||||
|
if (currentAdmins.isEmpty()) return // Bootstrap: no admins yet, nothing to deplete.
|
||||||
|
|
||||||
|
// Resolve the effective admin list after any GroupContextExtensions
|
||||||
|
// proposal in this commit. If none is present, keep the current list.
|
||||||
|
val gce =
|
||||||
|
proposals
|
||||||
|
.asSequence()
|
||||||
|
.map { it.proposal }
|
||||||
|
.filterIsInstance<Proposal.GroupContextExtensions>()
|
||||||
|
.lastOrNull()
|
||||||
|
val projectedMarmot =
|
||||||
|
if (gce != null) {
|
||||||
|
MarmotGroupData.fromExtensions(gce.extensions)
|
||||||
|
} else {
|
||||||
|
currentMarmotData()
|
||||||
|
}
|
||||||
|
val adminSet = projectedMarmot?.adminPubkeys?.toSet().orEmpty()
|
||||||
|
check(adminSet.isNotEmpty()) {
|
||||||
|
"MIP-03: commit would empty admin_pubkeys (admin depletion)"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute which leaves remain after applying Removes/SelfRemoves.
|
||||||
|
val removedLeaves = mutableSetOf<Int>()
|
||||||
|
for (pending in proposals) {
|
||||||
|
when (val p = pending.proposal) {
|
||||||
|
is Proposal.Remove -> removedLeaves.add(p.removedLeafIndex)
|
||||||
|
is Proposal.SelfRemove -> removedLeaves.add(pending.senderLeafIndex)
|
||||||
|
else -> Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val remainingAdminIdentities = mutableSetOf<String>()
|
||||||
|
for (i in 0 until tree.leafCount) {
|
||||||
|
if (i in removedLeaves) continue
|
||||||
|
val id = memberIdentityHex(i) ?: continue
|
||||||
|
if (id in adminSet) remainingAdminIdentities.add(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
check(remainingAdminIdentities.isNotEmpty()) {
|
||||||
|
"MIP-03: commit would leave the group without any admin members"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun applyProposal(
|
private fun applyProposal(
|
||||||
proposal: Proposal,
|
proposal: Proposal,
|
||||||
senderLeafIndex: Int,
|
senderLeafIndex: Int,
|
||||||
@@ -1357,6 +1515,12 @@ class MlsGroup private constructor(
|
|||||||
private const val EXTERNAL_PUB_EXTENSION_TYPE = 0x0003
|
private const val EXTERNAL_PUB_EXTENSION_TYPE = 0x0003
|
||||||
private const val EXTERNAL_SENDERS_EXTENSION_TYPE = 0x0004
|
private const val EXTERNAL_SENDERS_EXTENSION_TYPE = 0x0004
|
||||||
|
|
||||||
|
/** MLS self_remove proposal type (MIP-00 / MIP-03). */
|
||||||
|
private const val SELF_REMOVE_PROPOSAL_TYPE = 0x000A
|
||||||
|
|
||||||
|
/** Marmot Group Data Extension type (MIP-01). */
|
||||||
|
private const val MARMOT_GROUP_DATA_EXTENSION_TYPE = 0xF2EE
|
||||||
|
|
||||||
/** Known extension types that this implementation accepts. */
|
/** Known extension types that this implementation accepts. */
|
||||||
private val KNOWN_EXTENSION_TYPES =
|
private val KNOWN_EXTENSION_TYPES =
|
||||||
setOf(
|
setOf(
|
||||||
@@ -1364,7 +1528,45 @@ class MlsGroup private constructor(
|
|||||||
REQUIRED_CAPABILITIES_EXTENSION_TYPE,
|
REQUIRED_CAPABILITIES_EXTENSION_TYPE,
|
||||||
EXTERNAL_PUB_EXTENSION_TYPE,
|
EXTERNAL_PUB_EXTENSION_TYPE,
|
||||||
EXTERNAL_SENDERS_EXTENSION_TYPE,
|
EXTERNAL_SENDERS_EXTENSION_TYPE,
|
||||||
0xF2EE, // Marmot group data extension
|
MARMOT_GROUP_DATA_EXTENSION_TYPE,
|
||||||
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build an MLS `required_capabilities` extension that marks Marmot's
|
||||||
|
* mandatory interop set as required for all members (RFC 9420 §7.2):
|
||||||
|
* extensions = [marmot_group_data (0xF2EE)]
|
||||||
|
* proposals = [self_remove (0x000A)]
|
||||||
|
* credentials = [Basic (0x0001)]
|
||||||
|
*/
|
||||||
|
private fun buildMarmotRequiredCapabilitiesExtension(): Extension {
|
||||||
|
val writer = TlsWriter()
|
||||||
|
// extensions<V>: uint16 each
|
||||||
|
val exts = TlsWriter()
|
||||||
|
exts.putUint16(MARMOT_GROUP_DATA_EXTENSION_TYPE)
|
||||||
|
writer.putOpaqueVarInt(exts.toByteArray())
|
||||||
|
// proposals<V>: uint16 each
|
||||||
|
val props = TlsWriter()
|
||||||
|
props.putUint16(SELF_REMOVE_PROPOSAL_TYPE)
|
||||||
|
writer.putOpaqueVarInt(props.toByteArray())
|
||||||
|
// credentials<V>: uint16 each
|
||||||
|
val creds = TlsWriter()
|
||||||
|
creds.putUint16(Credential.CREDENTIAL_TYPE_BASIC)
|
||||||
|
writer.putOpaqueVarInt(creds.toByteArray())
|
||||||
|
return Extension(
|
||||||
|
extensionType = REQUIRED_CAPABILITIES_EXTENSION_TYPE,
|
||||||
|
extensionData = writer.toByteArray(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default MLS leaf Capabilities that advertise support for Marmot's
|
||||||
|
* required extensions and proposals so new members can join a group
|
||||||
|
* whose `required_capabilities` lists them.
|
||||||
|
*/
|
||||||
|
private fun marmotLeafCapabilities(): Capabilities =
|
||||||
|
Capabilities(
|
||||||
|
extensions = listOf(MARMOT_GROUP_DATA_EXTENSION_TYPE),
|
||||||
|
proposals = listOf(SELF_REMOVE_PROPOSAL_TYPE),
|
||||||
)
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1403,6 +1605,7 @@ class MlsGroup private constructor(
|
|||||||
epoch = 0,
|
epoch = 0,
|
||||||
treeHash = treeHash,
|
treeHash = treeHash,
|
||||||
confirmedTranscriptHash = ByteArray(0),
|
confirmedTranscriptHash = ByteArray(0),
|
||||||
|
extensions = listOf(buildMarmotRequiredCapabilitiesExtension()),
|
||||||
)
|
)
|
||||||
|
|
||||||
// Initial key schedule with zero secrets
|
// Initial key schedule with zero secrets
|
||||||
@@ -1794,7 +1997,9 @@ class MlsGroup private constructor(
|
|||||||
encryptionKey = encryptionKey,
|
encryptionKey = encryptionKey,
|
||||||
signatureKey = signatureKey,
|
signatureKey = signatureKey,
|
||||||
credential = Credential.Basic(identity),
|
credential = Credential.Basic(identity),
|
||||||
capabilities = Capabilities(),
|
// Advertise MIP-01/MIP-03 required capabilities so we can be
|
||||||
|
// added to compliant groups that mark them as required.
|
||||||
|
capabilities = marmotLeafCapabilities(),
|
||||||
leafNodeSource = source,
|
leafNodeSource = source,
|
||||||
lifetime =
|
lifetime =
|
||||||
if (source == LeafNodeSource.KEY_PACKAGE) {
|
if (source == LeafNodeSource.KEY_PACKAGE) {
|
||||||
@@ -1833,8 +2038,14 @@ class MlsGroup private constructor(
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Remove self from the group.
|
* Remove self from the group.
|
||||||
|
*
|
||||||
|
* Per MIP-01/MIP-03, admins must self-demote first; this helper rejects
|
||||||
|
* calls from a member currently listed in `admin_pubkeys`.
|
||||||
*/
|
*/
|
||||||
fun selfRemove(): ByteArray {
|
fun selfRemove(): ByteArray {
|
||||||
|
check(!isLocalAdmin()) {
|
||||||
|
"Admin must self-demote via GroupContextExtensions before SelfRemove (MIP-01)"
|
||||||
|
}
|
||||||
val proposal = Proposal.SelfRemove()
|
val proposal = Proposal.SelfRemove()
|
||||||
return proposal.toTlsBytes()
|
return proposal.toTlsBytes()
|
||||||
}
|
}
|
||||||
|
|||||||
+26
@@ -391,6 +391,13 @@ class MlsGroupManager(
|
|||||||
/**
|
/**
|
||||||
* Update group extensions (e.g., MIP-01 metadata) via a GroupContextExtensions proposal.
|
* Update group extensions (e.g., MIP-01 metadata) via a GroupContextExtensions proposal.
|
||||||
* Creates the proposal, commits it, and persists the new state.
|
* 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).
|
||||||
*/
|
*/
|
||||||
suspend fun updateGroupExtensions(
|
suspend fun updateGroupExtensions(
|
||||||
nostrGroupId: HexKey,
|
nostrGroupId: HexKey,
|
||||||
@@ -398,6 +405,11 @@ class MlsGroupManager(
|
|||||||
): CommitResult =
|
): CommitResult =
|
||||||
mutex.withLock {
|
mutex.withLock {
|
||||||
val group = requireGroup(nostrGroupId)
|
val group = requireGroup(nostrGroupId)
|
||||||
|
val currentMarmot = group.currentMarmotData()
|
||||||
|
val adminsConfigured = currentMarmot != null && currentMarmot.adminPubkeys.isNotEmpty()
|
||||||
|
check(!adminsConfigured || group.isLocalAdmin()) {
|
||||||
|
"MIP-01: only admins may update group extensions"
|
||||||
|
}
|
||||||
retainEpochSecrets(nostrGroupId, group)
|
retainEpochSecrets(nostrGroupId, group)
|
||||||
group.proposeGroupContextExtensions(extensions)
|
group.proposeGroupContextExtensions(extensions)
|
||||||
val result = group.commit()
|
val result = group.commit()
|
||||||
@@ -435,6 +447,20 @@ class MlsGroupManager(
|
|||||||
|
|
||||||
// --- Key Export ---
|
// --- Key Export ---
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hex-encoded BasicCredential identity of the member at [leafIndex] in
|
||||||
|
* the given group, or null if the group is unknown or the leaf is blank /
|
||||||
|
* not a Basic credential.
|
||||||
|
*
|
||||||
|
* Used by MIP-03 inner-event sender verification to confirm that the
|
||||||
|
* `pubkey` in a decrypted application message matches the MLS sender's
|
||||||
|
* credential identity.
|
||||||
|
*/
|
||||||
|
fun memberIdentityHex(
|
||||||
|
nostrGroupId: HexKey,
|
||||||
|
leafIndex: Int,
|
||||||
|
): String? = groups[nostrGroupId]?.memberIdentityHex(leafIndex)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Export the Marmot outer encryption key for GroupEvent wrapping.
|
* Export the Marmot outer encryption key for GroupEvent wrapping.
|
||||||
* MLS-Exporter("marmot", "group-event", 32)
|
* MLS-Exporter("marmot", "group-event", 32)
|
||||||
|
|||||||
+364
@@ -0,0 +1,364 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Vitor Pamplona
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
* this software and associated documentation files (the "Software"), to deal in
|
||||||
|
* the Software without restriction, including without limitation the rights to use,
|
||||||
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||||
|
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
* subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||||
|
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||||
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
package com.vitorpamplona.quartz.marmot
|
||||||
|
|
||||||
|
import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageEvent
|
||||||
|
import com.vitorpamplona.quartz.marmot.mip00KeyPackages.KeyPackageUtils
|
||||||
|
import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData
|
||||||
|
import com.vitorpamplona.quartz.marmot.mip01Groups.Mip01ImageCrypto
|
||||||
|
import com.vitorpamplona.quartz.marmot.mip03GroupMessages.GroupEvent
|
||||||
|
import com.vitorpamplona.quartz.marmot.mip03GroupMessages.GroupEventEncryption
|
||||||
|
import com.vitorpamplona.quartz.marmot.mip04EncryptedMedia.Mip04MediaEncryption
|
||||||
|
import com.vitorpamplona.quartz.marmot.mip04EncryptedMedia.Mip04ParseResult
|
||||||
|
import com.vitorpamplona.quartz.marmot.mip04EncryptedMedia.parseMip04
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
|
||||||
|
import com.vitorpamplona.quartz.nip44Encryption.crypto.ChaCha20Poly1305
|
||||||
|
import com.vitorpamplona.quartz.nip92IMeta.IMetaTagBuilder
|
||||||
|
import kotlin.io.encoding.Base64
|
||||||
|
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertContentEquals
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertFailsWith
|
||||||
|
import kotlin.test.assertFalse
|
||||||
|
import kotlin.test.assertIs
|
||||||
|
import kotlin.test.assertNotNull
|
||||||
|
import kotlin.test.assertNull
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unit tests for the Marmot Protocol MIP compliance fixes landing alongside
|
||||||
|
* this test file. Covers:
|
||||||
|
*
|
||||||
|
* - MIP-01: `MarmotGroupData` v3 round-trip, `disappearing_message_secs`
|
||||||
|
* validation, version rejection, image/Blossom HKDF helpers.
|
||||||
|
* - MIP-04: `Mip04ParseResult.DeprecatedV1` emission and deprecation warning
|
||||||
|
* for legacy v1 imeta tags.
|
||||||
|
*/
|
||||||
|
class MarmotMipComplianceTest {
|
||||||
|
private val groupId32 = "0".repeat(64)
|
||||||
|
private val adminPubkey = "a".repeat(64)
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------- MIP-01
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun marmotGroupData_defaultVersionIsThree() {
|
||||||
|
assertEquals(3, MarmotGroupData.CURRENT_VERSION)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun marmotGroupData_roundTripWithoutDisappearing() {
|
||||||
|
val original =
|
||||||
|
MarmotGroupData(
|
||||||
|
nostrGroupId = groupId32,
|
||||||
|
name = "test group",
|
||||||
|
description = "desc",
|
||||||
|
adminPubkeys = listOf(adminPubkey),
|
||||||
|
relays = listOf("wss://relay.example/"),
|
||||||
|
)
|
||||||
|
|
||||||
|
val bytes = original.encodeTls()
|
||||||
|
val decoded = assertNotNull(MarmotGroupData.decodeTls(bytes))
|
||||||
|
|
||||||
|
assertEquals(original.version, decoded.version)
|
||||||
|
assertEquals(original.nostrGroupId, decoded.nostrGroupId)
|
||||||
|
assertEquals(original.name, decoded.name)
|
||||||
|
assertEquals(original.description, decoded.description)
|
||||||
|
assertEquals(original.adminPubkeys, decoded.adminPubkeys)
|
||||||
|
assertEquals(original.relays, decoded.relays)
|
||||||
|
assertNull(decoded.disappearingMessageSecs)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun marmotGroupData_roundTripWithDisappearingSecs() {
|
||||||
|
val original =
|
||||||
|
MarmotGroupData(
|
||||||
|
nostrGroupId = groupId32,
|
||||||
|
adminPubkeys = listOf(adminPubkey),
|
||||||
|
relays = listOf("wss://relay.example/"),
|
||||||
|
disappearingMessageSecs = 86_400UL,
|
||||||
|
)
|
||||||
|
|
||||||
|
val bytes = original.encodeTls()
|
||||||
|
val decoded = assertNotNull(MarmotGroupData.decodeTls(bytes))
|
||||||
|
|
||||||
|
assertEquals(86_400UL, decoded.disappearingMessageSecs)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun marmotGroupData_rejectsZeroDisappearingSecsInConstructor() {
|
||||||
|
assertFailsWith<IllegalArgumentException> {
|
||||||
|
MarmotGroupData(
|
||||||
|
nostrGroupId = groupId32,
|
||||||
|
disappearingMessageSecs = 0UL,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@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).
|
||||||
|
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:
|
||||||
|
// 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 blob = header + zeroFields + disappearingField
|
||||||
|
|
||||||
|
// decodeTls catches any exception and returns null
|
||||||
|
assertNull(MarmotGroupData.decodeTls(blob))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun marmotGroupData_rejectsUnsupportedVersion() {
|
||||||
|
// version=99 is not in SUPPORTED_VERSIONS
|
||||||
|
val header =
|
||||||
|
ByteArray(2 + 32).also {
|
||||||
|
it[0] = 0
|
||||||
|
it[1] = 99
|
||||||
|
}
|
||||||
|
val zeroFields = ByteArray(8 * 2) // name..image_upload_key
|
||||||
|
val disappearingField = ByteArray(2) // zero-length
|
||||||
|
val blob = header + zeroFields + disappearingField
|
||||||
|
|
||||||
|
assertNull(MarmotGroupData.decodeTls(blob))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun mip01ImageCrypto_deriveImageKeyIs32BytesAndDeterministic() {
|
||||||
|
val seed = "11".repeat(32).hexToByteArray()
|
||||||
|
|
||||||
|
val k1 = Mip01ImageCrypto.deriveImageEncryptionKey(seed)
|
||||||
|
val k2 = Mip01ImageCrypto.deriveImageEncryptionKey(seed)
|
||||||
|
|
||||||
|
assertEquals(32, k1.size)
|
||||||
|
assertContentEquals(k1, k2)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun mip01ImageCrypto_imageAndUploadKeysDiffer() {
|
||||||
|
val seed = "22".repeat(32).hexToByteArray()
|
||||||
|
|
||||||
|
val imageKey = Mip01ImageCrypto.deriveImageEncryptionKey(seed)
|
||||||
|
val uploadSeed = Mip01ImageCrypto.deriveBlossomUploadSeed(seed)
|
||||||
|
|
||||||
|
assertEquals(32, imageKey.size)
|
||||||
|
assertEquals(32, uploadSeed.size)
|
||||||
|
assertTrue(
|
||||||
|
!imageKey.contentEquals(uploadSeed),
|
||||||
|
"Distinct HKDF labels must produce different outputs",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun mip01ImageCrypto_rejectsWrongSeedLength() {
|
||||||
|
val short = ByteArray(16)
|
||||||
|
assertFailsWith<IllegalArgumentException> { Mip01ImageCrypto.deriveImageEncryptionKey(short) }
|
||||||
|
assertFailsWith<IllegalArgumentException> { Mip01ImageCrypto.deriveBlossomUploadSeed(short) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------- MIP-04
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun mip04_parsesValidV2Tag() {
|
||||||
|
val nonceHex = "00".repeat(12)
|
||||||
|
val fileHashHex = "33".repeat(32)
|
||||||
|
val tag =
|
||||||
|
IMetaTagBuilder("https://blobs.example/abc")
|
||||||
|
.add("m", "image/jpeg")
|
||||||
|
.add("filename", "photo.jpg")
|
||||||
|
.add("x", fileHashHex)
|
||||||
|
.add("n", nonceHex)
|
||||||
|
.add("v", Mip04MediaEncryption.VERSION)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
val parsed = tag.parseMip04()
|
||||||
|
assertIs<Mip04ParseResult.Parsed>(parsed)
|
||||||
|
assertEquals(Mip04MediaEncryption.VERSION, parsed.meta.version)
|
||||||
|
assertEquals("photo.jpg", parsed.meta.filename)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun mip04_rejectsDeprecatedV1Tag() {
|
||||||
|
val nonceHex = "00".repeat(12)
|
||||||
|
val fileHashHex = "44".repeat(32)
|
||||||
|
val tag =
|
||||||
|
IMetaTagBuilder("https://blobs.example/def")
|
||||||
|
.add("m", "image/png")
|
||||||
|
.add("filename", "old.png")
|
||||||
|
.add("x", fileHashHex)
|
||||||
|
.add("n", nonceHex)
|
||||||
|
.add("v", Mip04MediaEncryption.LEGACY_VERSION_V1)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
val parsed = tag.parseMip04()
|
||||||
|
assertIs<Mip04ParseResult.DeprecatedV1>(parsed)
|
||||||
|
assertEquals("https://blobs.example/def", parsed.url)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun mip04_rejectsUnknownVersion() {
|
||||||
|
val nonceHex = "00".repeat(12)
|
||||||
|
val fileHashHex = "55".repeat(32)
|
||||||
|
val tag =
|
||||||
|
IMetaTagBuilder("https://blobs.example/xyz")
|
||||||
|
.add("m", "image/png")
|
||||||
|
.add("filename", "future.png")
|
||||||
|
.add("x", fileHashHex)
|
||||||
|
.add("n", nonceHex)
|
||||||
|
.add("v", "mip04-v99")
|
||||||
|
.build()
|
||||||
|
|
||||||
|
assertIs<Mip04ParseResult.Invalid>(tag.parseMip04())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------- MIP-00
|
||||||
|
|
||||||
|
private fun keyPackageEvent(encoding: String): KeyPackageEvent =
|
||||||
|
KeyPackageEvent(
|
||||||
|
id = "e".repeat(64),
|
||||||
|
pubKey = "a".repeat(64),
|
||||||
|
createdAt = 1_700_000_000,
|
||||||
|
tags =
|
||||||
|
arrayOf(
|
||||||
|
arrayOf("d", "0"),
|
||||||
|
arrayOf("encoding", encoding),
|
||||||
|
arrayOf("mls_ciphersuite", "0x0001"),
|
||||||
|
arrayOf("mls_protocol_version", "1.0"),
|
||||||
|
arrayOf("i", "b".repeat(64)),
|
||||||
|
arrayOf("mls_extensions", "0xf2ee", "0x000a"),
|
||||||
|
arrayOf("mls_proposals", "0x000a"),
|
||||||
|
arrayOf("relays", "wss://relay.example/"),
|
||||||
|
),
|
||||||
|
content = "dGVzdA==",
|
||||||
|
sig = "s".repeat(128),
|
||||||
|
)
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun keyPackage_rejectsHexEncodingExplicitly() {
|
||||||
|
// MIP-00 §KeyPackage Content Encoding: "base64" is required; hex was
|
||||||
|
// the pre-migration encoding and MUST be rejected. A generic
|
||||||
|
// "non-base64" test is insufficient because hex was the legacy default
|
||||||
|
// and is the one that compliant clients most likely see in the wild.
|
||||||
|
assertFalse(
|
||||||
|
KeyPackageUtils.isValid(keyPackageEvent(encoding = "hex")),
|
||||||
|
"MIP-00 requires rejecting legacy hex encoding on KeyPackage events",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun keyPackage_rejectsMissingCiphersuite() {
|
||||||
|
// MIP-00 §KeyPackage tags: mls_ciphersuite is a required tag. The
|
||||||
|
// existing isValid()-coverage checks encoding and content but not
|
||||||
|
// this tag — add a direct assertion for it.
|
||||||
|
val kp =
|
||||||
|
KeyPackageEvent(
|
||||||
|
id = "e".repeat(64),
|
||||||
|
pubKey = "a".repeat(64),
|
||||||
|
createdAt = 1_700_000_000,
|
||||||
|
tags =
|
||||||
|
arrayOf(
|
||||||
|
arrayOf("d", "0"),
|
||||||
|
arrayOf("encoding", "base64"),
|
||||||
|
arrayOf("i", "b".repeat(64)),
|
||||||
|
// mls_ciphersuite intentionally omitted
|
||||||
|
),
|
||||||
|
content = "dGVzdA==",
|
||||||
|
sig = "s".repeat(128),
|
||||||
|
)
|
||||||
|
assertFalse(KeyPackageUtils.isValid(kp), "Missing mls_ciphersuite tag must fail MIP-00 validation")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------- MIP-03
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Locks in that MIP-03 outer encryption uses an EMPTY AAD. Earlier
|
||||||
|
* revisions of this code bound `nostr_group_id` into the AAD, which was
|
||||||
|
* a silent wire-format divergence from the spec — our own encrypt/decrypt
|
||||||
|
* round-trip tests kept passing because both sides agreed on the (wrong)
|
||||||
|
* AAD. This test re-decrypts a `GroupEventEncryption`-produced ciphertext
|
||||||
|
* by calling ChaCha20-Poly1305 directly with an empty AAD, which MUST
|
||||||
|
* succeed per MIP-03. A future accidental reintroduction of a non-empty
|
||||||
|
* AAD would surface here immediately.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalEncodingApi::class)
|
||||||
|
@Test
|
||||||
|
fun mip03_aadIsEmptyByteString() {
|
||||||
|
val key = "11".repeat(32).hexToByteArray()
|
||||||
|
val plaintext = "mip03 aad check".encodeToByteArray()
|
||||||
|
|
||||||
|
val payloadB64 = GroupEventEncryption.encrypt(plaintext, key)
|
||||||
|
val payload = Base64.decode(payloadB64)
|
||||||
|
val nonce = payload.copyOfRange(0, GroupEvent.NONCE_LENGTH)
|
||||||
|
val ciphertextWithTag = payload.copyOfRange(GroupEvent.NONCE_LENGTH, payload.size)
|
||||||
|
|
||||||
|
// Manual AEAD decrypt with AAD = empty byte string (spec: MIP-03).
|
||||||
|
val recovered = ChaCha20Poly1305.decrypt(ciphertextWithTag, ByteArray(0), nonce, key)
|
||||||
|
assertContentEquals(plaintext, recovered)
|
||||||
|
|
||||||
|
// Paranoid cross-check: decrypting with a non-empty AAD (e.g. the
|
||||||
|
// nostr_group_id used by the pre-fix implementation) MUST fail
|
||||||
|
// ChaCha20-Poly1305 authentication. If it succeeds, AAD has
|
||||||
|
// silently become non-empty again.
|
||||||
|
assertFailsWith<IllegalStateException>(
|
||||||
|
message = "AAD must be empty per MIP-03; a non-empty AAD cannot decrypt a spec-compliant ciphertext",
|
||||||
|
) {
|
||||||
|
ChaCha20Poly1305.decrypt(
|
||||||
|
ciphertextWithTag,
|
||||||
|
"wouldBeGroupId".encodeToByteArray(),
|
||||||
|
nonce,
|
||||||
|
key,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun mip04_rejectsWrongNonceLength() {
|
||||||
|
// Only 22 hex chars = 11 bytes, below the required 12.
|
||||||
|
val shortNonce = "00".repeat(11)
|
||||||
|
val fileHashHex = "66".repeat(32)
|
||||||
|
val tag =
|
||||||
|
IMetaTagBuilder("https://blobs.example/nonce-bad")
|
||||||
|
.add("m", "image/png")
|
||||||
|
.add("filename", "bad.png")
|
||||||
|
.add("x", fileHashHex)
|
||||||
|
.add("n", shortNonce)
|
||||||
|
.add("v", Mip04MediaEncryption.VERSION)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
assertIs<Mip04ParseResult.Invalid>(tag.parseMip04())
|
||||||
|
}
|
||||||
|
}
|
||||||
+19
-1
@@ -44,11 +44,29 @@ import kotlin.test.assertTrue
|
|||||||
* implementations, and that re-encoding produces identical bytes (round-trip).
|
* implementations, and that re-encoding produces identical bytes (round-trip).
|
||||||
*/
|
*/
|
||||||
class MessageSerializationInteropTest {
|
class MessageSerializationInteropTest {
|
||||||
private val vectors: List<MessagesVector> =
|
private val allVectors: List<MessagesVector> =
|
||||||
JsonMapper.jsonInstance.decodeFromString<List<MessagesVector>>(
|
JsonMapper.jsonInstance.decodeFromString<List<MessagesVector>>(
|
||||||
TestResourceLoader().loadString("mls/messages.json"),
|
TestResourceLoader().loadString("mls/messages.json"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* messages.json ships vectors for MLS cipher suites 1, 2, and 3. Quartz
|
||||||
|
* only implements cipher suite 1
|
||||||
|
* (MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519), so we filter the
|
||||||
|
* corpus by peeking at the KeyPackage's ciphersuite field
|
||||||
|
* (`uint16 version || uint16 cipher_suite || ...`) and keeping only
|
||||||
|
* vectors authored with cipher suite 1.
|
||||||
|
*/
|
||||||
|
private val vectors: List<MessagesVector> =
|
||||||
|
allVectors.filter { v ->
|
||||||
|
val hex = v.mlsKeyPackage
|
||||||
|
// MLSMessage wraps a KeyPackage with:
|
||||||
|
// uint16 version || uint16 wire_format || KeyPackage{ uint16 version || uint16 cs || ...}
|
||||||
|
// Offset of the KeyPackage ciphersuite = 4 bytes (version+wire_format) +
|
||||||
|
// 2 bytes (KP version) = 6 bytes → 12 hex chars in.
|
||||||
|
hex.length >= 16 && hex.substring(12, 16).toInt(16) == 1
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun testWelcomeDeserialization() {
|
fun testWelcomeDeserialization() {
|
||||||
for ((idx, v) in vectors.withIndex()) {
|
for ((idx, v) in vectors.withIndex()) {
|
||||||
|
|||||||
+418
@@ -0,0 +1,418 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2025 Vitor Pamplona
|
||||||
|
*
|
||||||
|
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||||
|
* this software and associated documentation files (the "Software"), to deal in
|
||||||
|
* the Software without restriction, including without limitation the rights to use,
|
||||||
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
|
||||||
|
* Software, and to permit persons to whom the Software is furnished to do so,
|
||||||
|
* subject to the following conditions:
|
||||||
|
*
|
||||||
|
* The above copyright notice and this permission notice shall be included in all
|
||||||
|
* copies or substantial portions of the Software.
|
||||||
|
*
|
||||||
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||||
|
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||||
|
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
|
||||||
|
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
||||||
|
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||||
|
*/
|
||||||
|
package com.vitorpamplona.quartz.marmot
|
||||||
|
|
||||||
|
import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData
|
||||||
|
import com.vitorpamplona.quartz.marmot.mip02Welcome.WelcomeEvent
|
||||||
|
import com.vitorpamplona.quartz.marmot.mip03GroupMessages.GroupEvent
|
||||||
|
import com.vitorpamplona.quartz.marmot.mls.codec.TlsReader
|
||||||
|
import com.vitorpamplona.quartz.marmot.mls.group.MlsGroup
|
||||||
|
import com.vitorpamplona.quartz.marmot.mls.group.MlsGroupManager
|
||||||
|
import com.vitorpamplona.quartz.marmot.mls.messages.KeyPackageBundle
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||||
|
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
|
||||||
|
import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent
|
||||||
|
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import kotlin.test.Test
|
||||||
|
import kotlin.test.assertContentEquals
|
||||||
|
import kotlin.test.assertEquals
|
||||||
|
import kotlin.test.assertFailsWith
|
||||||
|
import kotlin.test.assertIs
|
||||||
|
import kotlin.test.assertNotNull
|
||||||
|
import kotlin.test.assertNull
|
||||||
|
import kotlin.test.assertTrue
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Behavior-level tests for the Marmot MIP compliance fixes. These exercise
|
||||||
|
* [MlsGroup] / [MlsGroupManager] / [MarmotOutboundProcessor] /
|
||||||
|
* [MarmotInboundProcessor] / [MarmotWelcomeSender] end-to-end rather than
|
||||||
|
* data-only concerns (which live in `MarmotMipComplianceTest`).
|
||||||
|
*/
|
||||||
|
class MarmotMipBehaviorTest {
|
||||||
|
private val groupId = "a".repeat(64)
|
||||||
|
private val aliceId = "1".repeat(64)
|
||||||
|
private val bobId = "2".repeat(64)
|
||||||
|
|
||||||
|
private fun createGroupManager(): MlsGroupManager = MlsGroupManager(TestGroupStateStore())
|
||||||
|
|
||||||
|
private fun createStandaloneKeyPackage(identity: String): KeyPackageBundle {
|
||||||
|
val tempGroup = MlsGroup.create(identity.hexToByteArray())
|
||||||
|
return tempGroup.createKeyPackage(identity.hexToByteArray(), ByteArray(0))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
// MIP-01 / MIP-03 required_capabilities on group creation
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun create_installsRequiredCapabilitiesExtension() {
|
||||||
|
val alice = MlsGroup.create(aliceId.hexToByteArray())
|
||||||
|
|
||||||
|
val reqCaps = alice.extensions.find { it.extensionType == 0x0002 }
|
||||||
|
assertNotNull(reqCaps, "create() must install required_capabilities extension (0x0002)")
|
||||||
|
|
||||||
|
val reader = TlsReader(reqCaps.extensionData)
|
||||||
|
val extsBytes = reader.readOpaqueVarInt()
|
||||||
|
val propsBytes = reader.readOpaqueVarInt()
|
||||||
|
val credsBytes = reader.readOpaqueVarInt()
|
||||||
|
|
||||||
|
assertEquals(0xF2EE, TlsReader(extsBytes).readUint16(), "required extensions must contain 0xF2EE")
|
||||||
|
assertEquals(0x000A, TlsReader(propsBytes).readUint16(), "required proposals must contain self_remove (0x000A)")
|
||||||
|
assertEquals(0x0001, TlsReader(credsBytes).readUint16(), "required credentials must contain Basic (0x0001)")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
// MIP-01 updateGroupExtensions admin gate
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun updateGroupExtensions_bootstrapAllowsAnyMemberUntilAdminsConfigured() =
|
||||||
|
runBlocking<Unit> {
|
||||||
|
val manager = createGroupManager()
|
||||||
|
manager.createGroup(groupId, aliceId.hexToByteArray())
|
||||||
|
|
||||||
|
// No admins yet — Alice can seed the initial extension set making herself admin.
|
||||||
|
val seed = MarmotGroupData(nostrGroupId = groupId, adminPubkeys = listOf(aliceId))
|
||||||
|
manager.updateGroupExtensions(groupId, listOf(seed.toExtension()))
|
||||||
|
|
||||||
|
val group = manager.getGroup(groupId)!!
|
||||||
|
assertTrue(group.isLocalAdmin(), "Alice should be admin after bootstrap update")
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun updateGroupExtensions_rejectsNonAdminOnceAdminsConfigured() =
|
||||||
|
runBlocking<Unit> {
|
||||||
|
val manager = createGroupManager()
|
||||||
|
manager.createGroup(groupId, aliceId.hexToByteArray())
|
||||||
|
|
||||||
|
// Bootstrap: Alice adds Bob as the sole admin while she is still
|
||||||
|
// allowed (no admins configured yet). After this Alice is NOT an
|
||||||
|
// admin anymore and any further extension update from her must be
|
||||||
|
// rejected.
|
||||||
|
val onlyBob = MarmotGroupData(nostrGroupId = groupId, adminPubkeys = listOf(bobId))
|
||||||
|
manager.updateGroupExtensions(groupId, listOf(onlyBob.toExtension()))
|
||||||
|
assertTrue(!manager.getGroup(groupId)!!.isLocalAdmin())
|
||||||
|
|
||||||
|
val another = MarmotGroupData(nostrGroupId = groupId, adminPubkeys = listOf(aliceId))
|
||||||
|
assertFailsWith<IllegalStateException> {
|
||||||
|
manager.updateGroupExtensions(groupId, listOf(another.toExtension()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
// MIP-03 commit authorization gate
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun commit_rejectsAddFromNonAdminOnceAdminsConfigured() =
|
||||||
|
runBlocking<Unit> {
|
||||||
|
val manager = createGroupManager()
|
||||||
|
manager.createGroup(groupId, aliceId.hexToByteArray())
|
||||||
|
|
||||||
|
// Install admin data that marks *Bob* as the only admin so the
|
||||||
|
// locally-acting member (Alice) becomes a non-admin.
|
||||||
|
val onlyBobAdmin = MarmotGroupData(nostrGroupId = groupId, adminPubkeys = listOf(bobId))
|
||||||
|
manager.updateGroupExtensions(groupId, listOf(onlyBobAdmin.toExtension()))
|
||||||
|
|
||||||
|
val bobBundle = createStandaloneKeyPackage(bobId)
|
||||||
|
assertFailsWith<IllegalStateException> {
|
||||||
|
manager.addMember(groupId, bobBundle.keyPackage.toTlsBytes())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun commit_adminDepletionGuardRejectsEmptyingAdminList() =
|
||||||
|
runBlocking<Unit> {
|
||||||
|
val manager = createGroupManager()
|
||||||
|
manager.createGroup(groupId, aliceId.hexToByteArray())
|
||||||
|
|
||||||
|
// Bootstrap Alice as sole admin.
|
||||||
|
val aliceOnly = MarmotGroupData(nostrGroupId = groupId, adminPubkeys = listOf(aliceId))
|
||||||
|
manager.updateGroupExtensions(groupId, listOf(aliceOnly.toExtension()))
|
||||||
|
|
||||||
|
// Attempt to demote every admin in a single GCE proposal.
|
||||||
|
val noAdmins = MarmotGroupData(nostrGroupId = groupId, adminPubkeys = emptyList())
|
||||||
|
assertFailsWith<IllegalStateException> {
|
||||||
|
manager.updateGroupExtensions(groupId, listOf(noAdmins.toExtension()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
// MIP-01 SelfRemove admin gate
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun proposeSelfRemove_rejectsAdmin() =
|
||||||
|
runBlocking<Unit> {
|
||||||
|
val manager = createGroupManager()
|
||||||
|
manager.createGroup(groupId, aliceId.hexToByteArray())
|
||||||
|
val aliceAdmin = MarmotGroupData(nostrGroupId = groupId, adminPubkeys = listOf(aliceId))
|
||||||
|
manager.updateGroupExtensions(groupId, listOf(aliceAdmin.toExtension()))
|
||||||
|
|
||||||
|
val alice = manager.getGroup(groupId)!!
|
||||||
|
assertFailsWith<IllegalStateException> { alice.proposeSelfRemove() }
|
||||||
|
assertFailsWith<IllegalStateException> { alice.selfRemove() }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun proposeSelfRemove_allowedForNonAdmin() =
|
||||||
|
runBlocking<Unit> {
|
||||||
|
val manager = createGroupManager()
|
||||||
|
manager.createGroup(groupId, aliceId.hexToByteArray())
|
||||||
|
|
||||||
|
// Mark a non-existent pubkey as the sole admin so Alice is a non-admin.
|
||||||
|
val strangerAdmin = MarmotGroupData(nostrGroupId = groupId, adminPubkeys = listOf(bobId))
|
||||||
|
manager.updateGroupExtensions(groupId, listOf(strangerAdmin.toExtension()))
|
||||||
|
|
||||||
|
val alice = manager.getGroup(groupId)!!
|
||||||
|
// selfRemove (standalone proposal helper) should succeed for a non-admin.
|
||||||
|
val bytes = alice.selfRemove()
|
||||||
|
assertTrue(bytes.isNotEmpty())
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
// MIP-01/03 NIP-40 expiration auto-application
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun buildGroupEvent_appendsExpirationTagWhenDisappearingConfigured() =
|
||||||
|
runBlocking<Unit> {
|
||||||
|
val manager = createGroupManager()
|
||||||
|
manager.createGroup(groupId, aliceId.hexToByteArray())
|
||||||
|
|
||||||
|
val configured =
|
||||||
|
MarmotGroupData(
|
||||||
|
nostrGroupId = groupId,
|
||||||
|
adminPubkeys = listOf(aliceId),
|
||||||
|
disappearingMessageSecs = 3600UL,
|
||||||
|
)
|
||||||
|
manager.updateGroupExtensions(groupId, listOf(configured.toExtension()))
|
||||||
|
|
||||||
|
val outbound = MarmotOutboundProcessor(manager)
|
||||||
|
val result = outbound.buildGroupEventFromBytes(groupId, "hi".encodeToByteArray())
|
||||||
|
|
||||||
|
val expirationTag = result.signedEvent.tags.find { it.isNotEmpty() && it[0] == "expiration" }
|
||||||
|
assertNotNull(expirationTag, "kind:445 MUST carry NIP-40 expiration when disappearing_message_secs is set")
|
||||||
|
val expectedTs = result.signedEvent.createdAt + 3600L
|
||||||
|
assertEquals(expectedTs.toString(), expirationTag[1])
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun buildGroupEvent_omitsExpirationTagWhenDisappearingAbsent() =
|
||||||
|
runBlocking<Unit> {
|
||||||
|
val manager = createGroupManager()
|
||||||
|
manager.createGroup(groupId, aliceId.hexToByteArray())
|
||||||
|
|
||||||
|
val outbound = MarmotOutboundProcessor(manager)
|
||||||
|
val result = outbound.buildGroupEventFromBytes(groupId, "hi".encodeToByteArray())
|
||||||
|
|
||||||
|
val expirationTag = result.signedEvent.tags.find { it.isNotEmpty() && it[0] == "expiration" }
|
||||||
|
assertNull(expirationTag, "No expiration tag expected when group has no disappearing_message_secs")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
// MIP-02 MarmotWelcomeSender awaitCommitAck ordering
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun welcomeSender_invokesAwaitCommitAckBeforeWrapping() =
|
||||||
|
runBlocking<Unit> {
|
||||||
|
val manager = createGroupManager()
|
||||||
|
manager.createGroup(groupId, aliceId.hexToByteArray())
|
||||||
|
val bobBundle = createStandaloneKeyPackage(bobId)
|
||||||
|
val commitResult = manager.addMember(groupId, bobBundle.keyPackage.toTlsBytes())
|
||||||
|
|
||||||
|
val aliceSigner = NostrSignerInternal(KeyPair())
|
||||||
|
val sender = MarmotWelcomeSender(aliceSigner)
|
||||||
|
|
||||||
|
var ackInvocations = 0
|
||||||
|
val delivery =
|
||||||
|
sender.wrapWelcome(
|
||||||
|
commitResult = commitResult,
|
||||||
|
recipientPubKey = "d".repeat(64),
|
||||||
|
keyPackageEventId = "e".repeat(64),
|
||||||
|
relays = emptyList(),
|
||||||
|
nostrGroupId = groupId,
|
||||||
|
awaitCommitAck = { ackInvocations += 1 },
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(1, ackInvocations, "awaitCommitAck must be invoked exactly once before wrapping")
|
||||||
|
assertNotNull(delivery)
|
||||||
|
assertEquals(GiftWrapEvent.KIND, delivery.giftWrapEvent.kind)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
// MIP-03 inner-event sender verification
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun processGroupEvent_rejectsInnerEventWithMismatchedPubkey() =
|
||||||
|
runBlocking<Unit> {
|
||||||
|
val manager = createGroupManager()
|
||||||
|
manager.createGroup(groupId, aliceId.hexToByteArray())
|
||||||
|
|
||||||
|
val outbound = MarmotOutboundProcessor(manager)
|
||||||
|
val keyPackageRotationManager =
|
||||||
|
com.vitorpamplona.quartz.marmot.mip00KeyPackages
|
||||||
|
.KeyPackageRotationManager()
|
||||||
|
val inbound = MarmotInboundProcessor(manager, keyPackageRotationManager)
|
||||||
|
|
||||||
|
// Craft an inner event whose pubkey does NOT match the MLS sender
|
||||||
|
// identity (aliceId). Alice is the only member and the MLS sender;
|
||||||
|
// she is going to encrypt and send an event claiming authorship by
|
||||||
|
// an impostor pubkey.
|
||||||
|
val impostor = "f".repeat(64)
|
||||||
|
val innerJson =
|
||||||
|
"""{"id":"${"0".repeat(64)}","pubkey":"$impostor","created_at":1700000000,""" +
|
||||||
|
""""kind":9,"tags":[],"content":"spoof","sig":"${"0".repeat(128)}"}"""
|
||||||
|
|
||||||
|
val encrypted =
|
||||||
|
outbound.buildGroupEventFromBytes(groupId, innerJson.encodeToByteArray())
|
||||||
|
val result = inbound.processGroupEvent(encrypted.signedEvent)
|
||||||
|
|
||||||
|
assertIs<GroupEventResult.Error>(result)
|
||||||
|
assertTrue(
|
||||||
|
result.message.contains("inner event pubkey"),
|
||||||
|
"Expected inner-event-sender mismatch error, got: ${result.message}",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
// MIP-02 Welcome rumor structure after NIP-59 unwrap
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unwraps a gift-wrapped Welcome all the way down to the kind:444 rumor
|
||||||
|
* and asserts the normative MIP-02 §Inner Rumor Structure requirements:
|
||||||
|
* - kind == 444
|
||||||
|
* - the rumor is **unsigned** (sig == "")
|
||||||
|
* - content is base64
|
||||||
|
* - ["encoding", "base64"] tag present
|
||||||
|
* - ["e", <KeyPackage event id>] tag present
|
||||||
|
* - ["relays", ...] tag present
|
||||||
|
*
|
||||||
|
* This protects against silent wire-format drift that would otherwise
|
||||||
|
* only be visible to external Marmot clients.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun welcomePipeline_innerRumorMatchesMip02Requirements() =
|
||||||
|
runBlocking<Unit> {
|
||||||
|
val manager = createGroupManager()
|
||||||
|
manager.createGroup(groupId, aliceId.hexToByteArray())
|
||||||
|
val bobBundle = createStandaloneKeyPackage(bobId)
|
||||||
|
val commitResult = manager.addMember(groupId, bobBundle.keyPackage.toTlsBytes())
|
||||||
|
|
||||||
|
val aliceSigner = NostrSignerInternal(KeyPair())
|
||||||
|
val bobKeyPair = KeyPair()
|
||||||
|
val bobSigner = NostrSignerInternal(bobKeyPair)
|
||||||
|
val bobPubKeyHex = bobKeyPair.pubKey.toHexKey()
|
||||||
|
|
||||||
|
val keyPackageEventId = "c".repeat(64)
|
||||||
|
val sender = MarmotWelcomeSender(aliceSigner)
|
||||||
|
val delivery =
|
||||||
|
sender.wrapWelcome(
|
||||||
|
commitResult = commitResult,
|
||||||
|
recipientPubKey = bobPubKeyHex,
|
||||||
|
keyPackageEventId = keyPackageEventId,
|
||||||
|
relays = emptyList(),
|
||||||
|
nostrGroupId = groupId,
|
||||||
|
)
|
||||||
|
assertNotNull(delivery)
|
||||||
|
assertEquals(GiftWrapEvent.KIND, delivery.giftWrapEvent.kind)
|
||||||
|
|
||||||
|
// Bob unwraps: kind:1059 → kind:13 (Seal) → kind:444 (Rumor).
|
||||||
|
val sealed = delivery.giftWrapEvent.unwrapThrowing(bobSigner)
|
||||||
|
assertEquals(SealedRumorEvent.KIND, sealed.kind, "Middle layer MUST be NIP-59 Seal kind:13")
|
||||||
|
assertIs<SealedRumorEvent>(sealed)
|
||||||
|
|
||||||
|
val rumor = sealed.unsealThrowing(bobSigner)
|
||||||
|
assertEquals(WelcomeEvent.KIND, rumor.kind, "Innermost rumor MUST be kind:444")
|
||||||
|
assertEquals("", rumor.sig, "MIP-02: kind:444 rumor MUST NOT carry a signature")
|
||||||
|
|
||||||
|
val encodingTag = rumor.tags.find { it.isNotEmpty() && it[0] == "encoding" }
|
||||||
|
assertNotNull(encodingTag, "MIP-02: rumor MUST carry [encoding, base64]")
|
||||||
|
assertEquals("base64", encodingTag[1])
|
||||||
|
|
||||||
|
val eTag = rumor.tags.find { it.isNotEmpty() && it[0] == "e" }
|
||||||
|
assertNotNull(eTag, "MIP-02: rumor MUST carry [e, <KeyPackage event id>]")
|
||||||
|
assertEquals(keyPackageEventId, eTag[1])
|
||||||
|
|
||||||
|
val relaysTag = rumor.tags.find { it.isNotEmpty() && it[0] == "relays" }
|
||||||
|
assertNotNull(relaysTag, "MIP-02: rumor MUST carry a relays tag")
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
// MIP-03 group event h-tag shape
|
||||||
|
// ----------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MIP-03 §Core Event Fields requires the `h` tag on kind:445 events to
|
||||||
|
* be a 32-byte hex (64 lowercase hex chars). This locks in the tag
|
||||||
|
* format so an accidental truncation or encoding change surfaces here.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
fun buildGroupEvent_hTagIs32ByteHex() =
|
||||||
|
runBlocking<Unit> {
|
||||||
|
val manager = createGroupManager()
|
||||||
|
manager.createGroup(groupId, aliceId.hexToByteArray())
|
||||||
|
|
||||||
|
val outbound = MarmotOutboundProcessor(manager)
|
||||||
|
val result = outbound.buildGroupEventFromBytes(groupId, "hi".encodeToByteArray())
|
||||||
|
|
||||||
|
val hTag = result.signedEvent.tags.find { it.isNotEmpty() && it[0] == "h" }
|
||||||
|
assertNotNull(hTag, "kind:445 MUST carry an h tag with the Nostr group id")
|
||||||
|
assertEquals(64, hTag[1].length, "h tag value MUST be 32 bytes hex (64 chars)")
|
||||||
|
assertEquals(groupId, hTag[1])
|
||||||
|
assertTrue(
|
||||||
|
hTag[1].all { it in '0'..'9' || it in 'a'..'f' },
|
||||||
|
"h tag MUST be lowercase hex",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun processGroupEvent_acceptsInnerEventWithMatchingPubkey() =
|
||||||
|
runBlocking<Unit> {
|
||||||
|
val manager = createGroupManager()
|
||||||
|
manager.createGroup(groupId, aliceId.hexToByteArray())
|
||||||
|
|
||||||
|
val outbound = MarmotOutboundProcessor(manager)
|
||||||
|
val keyPackageRotationManager =
|
||||||
|
com.vitorpamplona.quartz.marmot.mip00KeyPackages
|
||||||
|
.KeyPackageRotationManager()
|
||||||
|
val inbound = MarmotInboundProcessor(manager, keyPackageRotationManager)
|
||||||
|
|
||||||
|
// Inner event whose pubkey matches Alice's credential identity.
|
||||||
|
val innerJson =
|
||||||
|
"""{"id":"${"0".repeat(64)}","pubkey":"$aliceId","created_at":1700000000,""" +
|
||||||
|
""""kind":9,"tags":[],"content":"hello","sig":"${"0".repeat(128)}"}"""
|
||||||
|
|
||||||
|
val encrypted =
|
||||||
|
outbound.buildGroupEventFromBytes(groupId, innerJson.encodeToByteArray())
|
||||||
|
val result = inbound.processGroupEvent(encrypted.signedEvent)
|
||||||
|
|
||||||
|
assertIs<GroupEventResult.ApplicationMessage>(result)
|
||||||
|
assertContentEquals(innerJson.toByteArray(), result.innerEventJson.toByteArray())
|
||||||
|
assertEquals(GroupEvent.KIND, encrypted.signedEvent.kind)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user