mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-10-05 19:28:25 +00:00
Merge pull request #2462 from vitorpamplona/claude/marmot-interoperability-check-vR5ws
MIP-01/MIP-05 compliance: VarInt encoding, validation, and MDK interop
This commit is contained in:
+91
-5
@@ -21,12 +21,19 @@
|
||||
package com.vitorpamplona.quartz.marmot.mip00KeyPackages
|
||||
|
||||
import com.vitorpamplona.quartz.marmot.mip00KeyPackages.tags.EncodingTag
|
||||
import com.vitorpamplona.quartz.marmot.mip00KeyPackages.tags.MlsCiphersuiteTag
|
||||
import com.vitorpamplona.quartz.marmot.mip00KeyPackages.tags.MlsProtocolVersionTag
|
||||
import com.vitorpamplona.quartz.marmot.mls.codec.TlsReader
|
||||
import com.vitorpamplona.quartz.marmot.mls.crypto.MlsCryptoProvider
|
||||
import com.vitorpamplona.quartz.marmot.mls.messages.MlsKeyPackage
|
||||
import com.vitorpamplona.quartz.marmot.mls.tree.Credential
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.EventTemplate
|
||||
import kotlin.io.encoding.Base64
|
||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
|
||||
/**
|
||||
* Utility functions for KeyPackage lifecycle management (MIP-00).
|
||||
@@ -88,12 +95,91 @@ object KeyPackageUtils {
|
||||
|
||||
/**
|
||||
* Validates a KeyPackage event has required fields and proper encoding.
|
||||
*
|
||||
* Performs the same strict tag-level MIP-00 checks used by MDK so that
|
||||
* malformed or adversarial events are rejected at parse time:
|
||||
* - `d` tag is exactly 64 lowercase hex characters (32-byte slot ID)
|
||||
* - `mls_protocol_version` is exactly "1.0"
|
||||
* - `mls_ciphersuite` is exactly "0x0001"
|
||||
* - `mls_extensions` contains both "0xf2ee" (NostrGroupData) and
|
||||
* "0x000a" (LastResort)
|
||||
* - `mls_proposals` contains "0x000a" (SelfRemove)
|
||||
* - `encoding` is "base64" and content is non-empty
|
||||
* - `i` (keyPackageRef) tag is non-empty
|
||||
*
|
||||
* For deep cryptographic checks (KeyPackageRef hash match, credential
|
||||
* identity == event.pubkey) call [isCryptographicallyValid].
|
||||
*/
|
||||
fun isValid(event: KeyPackageEvent): Boolean =
|
||||
event.encoding() == EncodingTag.BASE64 &&
|
||||
event.content.isNotEmpty() &&
|
||||
!event.keyPackageRef().isNullOrEmpty() &&
|
||||
!event.mlsCiphersuite().isNullOrEmpty()
|
||||
fun isValid(event: KeyPackageEvent): Boolean {
|
||||
// d tag: exactly 64 hex chars per MIP-00
|
||||
val dTag = event.dTag()
|
||||
if (dTag.length != 64 || !dTag.all { it.isHexChar() }) return false
|
||||
|
||||
// mls_protocol_version == "1.0"
|
||||
if (event.mlsProtocolVersion() != MlsProtocolVersionTag.CURRENT_VERSION) return false
|
||||
|
||||
// mls_ciphersuite == "0x0001"
|
||||
if (event.mlsCiphersuite() != MlsCiphersuiteTag.DEFAULT_CIPHERSUITE) return false
|
||||
|
||||
// mls_extensions MUST include both 0xf2ee and 0x000a
|
||||
val extensions = event.mlsExtensions()?.map { it.lowercase() }?.toSet() ?: return false
|
||||
if (!extensions.contains("0xf2ee") || !extensions.contains("0x000a")) return false
|
||||
|
||||
// mls_proposals MUST include 0x000a (SelfRemove)
|
||||
val proposals = event.mlsProposals()?.map { it.lowercase() }?.toSet() ?: return false
|
||||
if (!proposals.contains("0x000a")) return false
|
||||
|
||||
// encoding MUST be base64 and content non-empty
|
||||
if (event.encoding() != EncodingTag.BASE64) return false
|
||||
if (event.content.isEmpty()) return false
|
||||
|
||||
// i (KeyPackageRef) tag MUST be present
|
||||
if (event.keyPackageRef().isNullOrEmpty()) return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Deep MIP-00 validation: decodes the KeyPackage content and verifies:
|
||||
* - `i` tag matches the computed `KeyPackageRef` (RFC 9420 §5.2)
|
||||
* - Credential identity (BasicCredential) equals the event's `pubkey`
|
||||
* (32-byte x-only Nostr pubkey)
|
||||
* - KeyPackage signature over KeyPackageTBS is valid
|
||||
*
|
||||
* Returns true only if [isValid] also holds and every cryptographic check
|
||||
* passes. Requires [isValid] to be true as a precondition — it is called
|
||||
* internally.
|
||||
*/
|
||||
@OptIn(ExperimentalEncodingApi::class)
|
||||
fun isCryptographicallyValid(event: KeyPackageEvent): Boolean {
|
||||
if (!isValid(event)) return false
|
||||
|
||||
val iTag = event.keyPackageRef() ?: return false
|
||||
val keyPackage =
|
||||
try {
|
||||
val bytes = Base64.decode(event.content)
|
||||
MlsKeyPackage.decodeTls(TlsReader(bytes))
|
||||
} catch (_: Throwable) {
|
||||
return false
|
||||
}
|
||||
|
||||
// i tag MUST equal the computed KeyPackageRef
|
||||
if (keyPackage.reference().toHexKey() != iTag.lowercase()) return false
|
||||
|
||||
// Credential identity MUST equal the event's pubkey (32-byte x-only).
|
||||
// MIP-00 requires BasicCredential with the raw 32-byte Nostr pubkey.
|
||||
val credential = keyPackage.leafNode.credential
|
||||
if (credential !is Credential.Basic) return false
|
||||
if (credential.identity.size != 32) return false
|
||||
if (credential.identity.toHexKey().lowercase() != event.pubKey.lowercase()) return false
|
||||
|
||||
// KeyPackage signature MUST verify against the LeafNode's signatureKey.
|
||||
if (!keyPackage.verifySignature()) return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
private fun Char.isHexChar(): Boolean = this in '0'..'9' || this in 'a'..'f' || this in 'A'..'F'
|
||||
|
||||
/**
|
||||
* Builds a rotated KeyPackage for the same d-tag slot.
|
||||
|
||||
+63
-45
@@ -100,6 +100,9 @@ data class MarmotGroupData(
|
||||
require(disappearingMessageSecs == null || disappearingMessageSecs > 0UL) {
|
||||
"disappearing_message_secs must be > 0 when set (MIP-01)"
|
||||
}
|
||||
require(adminPubkeys.size == adminPubkeys.toSet().size) {
|
||||
"MarmotGroupData.admin_pubkeys MUST NOT contain duplicates (MIP-01)"
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether the given pubkey is an admin of this group */
|
||||
@@ -111,46 +114,58 @@ data class MarmotGroupData(
|
||||
/**
|
||||
* Encode this MarmotGroupData to TLS wire format bytes.
|
||||
* Mirrors the [decodeTls] format.
|
||||
*
|
||||
* Per MIP-01, all variable-length vectors use QUIC-style variable-length integer
|
||||
* (VarInt) length prefixes, as implemented by the Rust `tls_codec` crate (v0.4+):
|
||||
* - lengths 0..63 → 1 byte (high bits 00)
|
||||
* - lengths 64..16383 → 2 bytes (high bits 01)
|
||||
* - lengths 16384+ → 4 bytes (high bits 10)
|
||||
*/
|
||||
fun encodeTls(): ByteArray {
|
||||
val writer = TlsWriter()
|
||||
writer.putUint16(version)
|
||||
writer.putBytes(nostrGroupId.hexToByteArray())
|
||||
writer.putOpaque2(name.encodeToByteArray())
|
||||
writer.putOpaque2(description.encodeToByteArray())
|
||||
writer.putOpaqueVarInt(name.encodeToByteArray())
|
||||
writer.putOpaqueVarInt(description.encodeToByteArray())
|
||||
|
||||
// Admin pubkeys: concatenated 32-byte keys within a length-prefixed block
|
||||
// admin_pubkeys: Vec<[u8;32]> — outer VarInt covers total bytes, each 32-byte
|
||||
// key is fixed-size with no inner length prefix.
|
||||
val adminBytes = ByteArray(adminPubkeys.size * 32)
|
||||
adminPubkeys.forEachIndexed { index, key ->
|
||||
key.hexToByteArray().copyInto(adminBytes, index * 32)
|
||||
}
|
||||
writer.putOpaque2(adminBytes)
|
||||
writer.putOpaqueVarInt(adminBytes)
|
||||
|
||||
// Relays: length-prefixed block of length-prefixed UTF-8 strings
|
||||
// relays: Vec<Vec<u8>> — outer VarInt covers total bytes, each inner relay
|
||||
// string is VarInt-length-prefixed UTF-8.
|
||||
val relayWriter = TlsWriter()
|
||||
for (relay in relays) {
|
||||
relayWriter.putOpaque2(relay.encodeToByteArray())
|
||||
relayWriter.putOpaqueVarInt(relay.encodeToByteArray())
|
||||
}
|
||||
writer.putOpaque2(relayWriter.toByteArray())
|
||||
writer.putOpaqueVarInt(relayWriter.toByteArray())
|
||||
|
||||
// Optional image fields
|
||||
writer.putOpaque2(imageHash?.hexToByteArray() ?: ByteArray(0))
|
||||
writer.putOpaque2(imageKey ?: ByteArray(0))
|
||||
writer.putOpaque2(imageNonce ?: ByteArray(0))
|
||||
writer.putOpaque2(imageUploadKey ?: ByteArray(0))
|
||||
// Optional image fields — empty Vec<u8> encodes as a single zero byte (VarInt(0)).
|
||||
writer.putOpaqueVarInt(imageHash?.hexToByteArray() ?: ByteArray(0))
|
||||
writer.putOpaqueVarInt(imageKey ?: ByteArray(0))
|
||||
writer.putOpaqueVarInt(imageNonce ?: ByteArray(0))
|
||||
writer.putOpaqueVarInt(imageUploadKey ?: ByteArray(0))
|
||||
|
||||
// v3+: disappearing_message_secs (0 bytes = none, 8 bytes big-endian uint64 = secs)
|
||||
val disappearingBytes =
|
||||
disappearingMessageSecs?.let { secs ->
|
||||
val out = ByteArray(8)
|
||||
var v = secs.toLong()
|
||||
for (i in 7 downTo 0) {
|
||||
out[i] = (v and 0xFF).toByte()
|
||||
v = v ushr 8
|
||||
}
|
||||
out
|
||||
} ?: ByteArray(0)
|
||||
writer.putOpaque2(disappearingBytes)
|
||||
// v3+: disappearing_message_secs (0 bytes = none, 8 bytes big-endian uint64 = secs).
|
||||
// Only emitted for version ≥ 3; v1/v2 have no such field, so omitting it keeps
|
||||
// the wire format byte-for-byte compatible with older implementations (MDK v2).
|
||||
if (version >= 3) {
|
||||
val disappearingBytes =
|
||||
disappearingMessageSecs?.let { secs ->
|
||||
val out = ByteArray(8)
|
||||
var v = secs.toLong()
|
||||
for (i in 7 downTo 0) {
|
||||
out[i] = (v and 0xFF).toByte()
|
||||
v = v ushr 8
|
||||
}
|
||||
out
|
||||
} ?: ByteArray(0)
|
||||
writer.putOpaqueVarInt(disappearingBytes)
|
||||
}
|
||||
|
||||
return writer.toByteArray()
|
||||
}
|
||||
@@ -182,19 +197,22 @@ data class MarmotGroupData(
|
||||
/**
|
||||
* Decode MarmotGroupData from TLS wire format bytes.
|
||||
*
|
||||
* Per MIP-01, all variable-length vectors use QUIC-style VarInt length prefixes
|
||||
* (`tls_codec` v0.4+). The TLS comment syntax below uses `<V>` to denote VarInt.
|
||||
*
|
||||
* Wire format (v3):
|
||||
* ```
|
||||
* uint16 version // rejected if 0 or unsupported
|
||||
* opaque nostr_group_id[32]
|
||||
* opaque name<0..2^16-1>
|
||||
* opaque description<0..2^16-1>
|
||||
* opaque admin_pubkeys<0..2^16-1> // concatenated 32-byte keys
|
||||
* RelayUrl relays<0..2^16-1> // length-prefixed UTF-8 strings
|
||||
* opaque image_hash<0..32>
|
||||
* opaque image_key<0..32>
|
||||
* opaque image_nonce<0..12>
|
||||
* opaque image_upload_key<0..32>
|
||||
* opaque disappearing_message_secs<0..8> // v3+: 0 bytes or 8-byte uint64 (reject 0)
|
||||
* opaque name<V>
|
||||
* opaque description<V>
|
||||
* opaque admin_pubkeys<V> // concatenated 32-byte keys
|
||||
* RelayUrl relays<V> // VarInt-length-prefixed UTF-8 strings
|
||||
* opaque image_hash<V>
|
||||
* opaque image_key<V>
|
||||
* opaque image_nonce<V>
|
||||
* opaque image_upload_key<V>
|
||||
* opaque disappearing_message_secs<V> // v3+: 0 bytes or 8-byte uint64 (reject 0)
|
||||
* ```
|
||||
*
|
||||
* Unknown trailing bytes from future versions are silently ignored for
|
||||
@@ -209,14 +227,14 @@ data class MarmotGroupData(
|
||||
val nostrGroupIdBytes = reader.readBytes(32)
|
||||
val nostrGroupId = nostrGroupIdBytes.toHexKey()
|
||||
|
||||
val nameBytes = reader.readOpaque2()
|
||||
val nameBytes = reader.readOpaqueVarInt()
|
||||
val name = nameBytes.decodeToString()
|
||||
|
||||
val descriptionBytes = reader.readOpaque2()
|
||||
val descriptionBytes = reader.readOpaqueVarInt()
|
||||
val description = descriptionBytes.decodeToString()
|
||||
|
||||
// Admin pubkeys: concatenated 32-byte keys within a length-prefixed block
|
||||
val adminBlock = reader.readOpaque2()
|
||||
// Admin pubkeys: concatenated 32-byte keys within a VarInt-prefixed block
|
||||
val adminBlock = reader.readOpaqueVarInt()
|
||||
val adminPubkeys = mutableListOf<HexKey>()
|
||||
var i = 0
|
||||
while (i + 32 <= adminBlock.size) {
|
||||
@@ -224,23 +242,23 @@ data class MarmotGroupData(
|
||||
i += 32
|
||||
}
|
||||
|
||||
// Relays: length-prefixed block of length-prefixed UTF-8 strings
|
||||
val relaysBlock = reader.readOpaque2()
|
||||
// Relays: VarInt-prefixed block of VarInt-prefixed UTF-8 strings
|
||||
val relaysBlock = reader.readOpaqueVarInt()
|
||||
val relays = mutableListOf<String>()
|
||||
val relayReader = TlsReader(relaysBlock)
|
||||
while (relayReader.hasRemaining) {
|
||||
val relayBytes = relayReader.readOpaque2()
|
||||
val relayBytes = relayReader.readOpaqueVarInt()
|
||||
relays.add(relayBytes.decodeToString())
|
||||
}
|
||||
|
||||
// Optional fields — read if remaining
|
||||
val imageHash = if (reader.hasRemaining) reader.readOpaque2().takeIf { it.isNotEmpty() }?.toHexKey() else null
|
||||
val imageKey = if (reader.hasRemaining) reader.readOpaque2().takeIf { it.isNotEmpty() } else null
|
||||
val imageNonce = if (reader.hasRemaining) reader.readOpaque2().takeIf { it.isNotEmpty() } else null
|
||||
val imageUploadKey = if (reader.hasRemaining) reader.readOpaque2().takeIf { it.isNotEmpty() } else null
|
||||
val imageHash = if (reader.hasRemaining) reader.readOpaqueVarInt().takeIf { it.isNotEmpty() }?.toHexKey() else null
|
||||
val imageKey = if (reader.hasRemaining) reader.readOpaqueVarInt().takeIf { it.isNotEmpty() } else null
|
||||
val imageNonce = if (reader.hasRemaining) reader.readOpaqueVarInt().takeIf { it.isNotEmpty() } else null
|
||||
val imageUploadKey = if (reader.hasRemaining) reader.readOpaqueVarInt().takeIf { it.isNotEmpty() } else null
|
||||
|
||||
// v3+: disappearing_message_secs
|
||||
val disappearingBytes = if (reader.hasRemaining) reader.readOpaque2() else ByteArray(0)
|
||||
val disappearingBytes = if (reader.hasRemaining) reader.readOpaqueVarInt() else ByteArray(0)
|
||||
val disappearingMessageSecs: ULong? =
|
||||
when (disappearingBytes.size) {
|
||||
0 -> {
|
||||
|
||||
+11
-4
@@ -24,6 +24,7 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
|
||||
import com.vitorpamplona.quartz.nip59Giftwrap.rumors.Rumor
|
||||
import com.vitorpamplona.quartz.nip59Giftwrap.rumors.RumorAssembler
|
||||
import com.vitorpamplona.quartz.nip59Giftwrap.seals.SealedRumorEvent
|
||||
import com.vitorpamplona.quartz.nip59Giftwrap.wraps.GiftWrapEvent
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
@@ -60,7 +61,9 @@ object WelcomeGiftWrap {
|
||||
nostrGroupId: HexKey? = null,
|
||||
createdAt: Long = TimeUtils.now(),
|
||||
): GiftWrapEvent {
|
||||
// Step 1: Build the WelcomeEvent template and sign it
|
||||
// Step 1: Build the WelcomeEvent template directly as an unsigned rumor.
|
||||
// Per NIP-59 rumors MUST have an empty sig field, so we skip the outer
|
||||
// signature entirely and let the SealedRumorEvent carry authorship.
|
||||
val welcomeTemplate =
|
||||
WelcomeEvent.build(
|
||||
welcomeBase64 = welcomeBase64,
|
||||
@@ -69,10 +72,14 @@ object WelcomeGiftWrap {
|
||||
nostrGroupId = nostrGroupId,
|
||||
createdAt = createdAt,
|
||||
)
|
||||
val welcomeEvent: WelcomeEvent = signer.sign(welcomeTemplate)
|
||||
val welcomeRumor: WelcomeEvent =
|
||||
RumorAssembler.assembleRumor(
|
||||
pubKey = signer.pubKey,
|
||||
ev = welcomeTemplate,
|
||||
)
|
||||
|
||||
// Step 2: Create a Rumor from the signed event and seal it (kind:13)
|
||||
val rumor = Rumor.create(welcomeEvent)
|
||||
// Step 2: Create a Rumor from the unsigned event and seal it (kind:13)
|
||||
val rumor = Rumor.create(welcomeRumor)
|
||||
val sealedRumor =
|
||||
SealedRumorEvent.create(
|
||||
rumor = rumor,
|
||||
|
||||
+18
-19
@@ -25,30 +25,29 @@ import com.vitorpamplona.quartz.nip44Encryption.crypto.ChaCha20Poly1305
|
||||
import com.vitorpamplona.quartz.utils.RandomInstance
|
||||
import com.vitorpamplona.quartz.utils.Secp256k1Instance
|
||||
import com.vitorpamplona.quartz.utils.mac.MacInstance
|
||||
import com.vitorpamplona.quartz.utils.sha256.sha256
|
||||
import kotlin.io.encoding.Base64
|
||||
import kotlin.io.encoding.ExperimentalEncodingApi
|
||||
|
||||
/**
|
||||
* Handles EncryptedToken creation and decryption for Marmot push notifications (MIP-05).
|
||||
*
|
||||
* EncryptedToken format (280 bytes total):
|
||||
* ephemeral_pubkey(32) || nonce(12) || ciphertext(236 = 220 plaintext + 16 tag)
|
||||
* EncryptedToken format (MUST be exactly 1084 bytes per MIP-05):
|
||||
* ephemeral_pubkey(32) || nonce(12) || ciphertext(1040 = 1024 plaintext + 16 tag)
|
||||
*
|
||||
* Token payload (220 bytes, padded):
|
||||
* platform(1) || token_length(2 BE) || device_token(N) || random_padding(220-3-N)
|
||||
* Token plaintext (MUST be exactly 1024 bytes per MIP-05):
|
||||
* platform(1) || token_length(2 BE) || device_token(N) || random_padding(1024-3-N)
|
||||
*
|
||||
* Key derivation:
|
||||
* 1. ECDH: shared_point = ephemeral_privkey * server_pubkey
|
||||
* 2. shared_x = sha256(shared_point) (x-coordinate as shared secret)
|
||||
* 3. PRK = HKDF-Extract(salt="mip05-v1", IKM=shared_x)
|
||||
* 4. encryption_key = HKDF-Expand(PRK, info="mip05-token-encryption", 32)
|
||||
* 5. Encrypt padded payload with ChaCha20-Poly1305(key, nonce, payload, aad="")
|
||||
* Key derivation (MIP-05 §"Key Derivation"):
|
||||
* 1. ECDH: shared_x = secp256k1_ecdh(ephemeral_privkey, server_pubkey) — raw 32-byte x
|
||||
* 2. PRK = HKDF-Extract(salt="mip05-v1", IKM=shared_x)
|
||||
* 3. encryption_key = HKDF-Expand(PRK, info="mip05-token-encryption", 32)
|
||||
* 4. Encrypt padded plaintext with ChaCha20-Poly1305(key, nonce, plaintext, aad="")
|
||||
*
|
||||
* Platform values: 0x01 = APNs, 0x02 = FCM
|
||||
*/
|
||||
object TokenEncryption {
|
||||
private const val PADDED_PAYLOAD_SIZE = 220
|
||||
/** Token plaintext MUST be exactly 1024 bytes per MIP-05. */
|
||||
private const val PADDED_PAYLOAD_SIZE = 1024
|
||||
private const val NONCE_SIZE = 12
|
||||
private const val PUBKEY_SIZE = 32
|
||||
private const val HEADER_SIZE = 3 // platform(1) + token_length(2)
|
||||
@@ -97,9 +96,9 @@ object TokenEncryption {
|
||||
// Extract the 32-byte x-only public key by dropping the SEC1 prefix byte
|
||||
val ephemeralPubKey = compressedPubKey.copyOfRange(1, 33)
|
||||
|
||||
// ECDH: shared_x = sha256(ephemeral_privkey * server_pubkey)
|
||||
val sharedPoint = Secp256k1Instance.pubKeyTweakMulCompact(serverPubKey, ephemeralPrivKey)
|
||||
val sharedX = sha256(sharedPoint)
|
||||
// ECDH: shared_x = secp256k1_ecdh(ephemeral_privkey, server_pubkey) — raw 32-byte x
|
||||
// per MIP-05. Do NOT hash; HKDF-Extract will mix the salt.
|
||||
val sharedX = Secp256k1Instance.pubKeyTweakMulCompact(serverPubKey, ephemeralPrivKey)
|
||||
|
||||
// HKDF-Extract then Expand to get encryption key
|
||||
val encryptionKey = hkdfDeriveKey(sharedX)
|
||||
@@ -108,7 +107,7 @@ object TokenEncryption {
|
||||
val nonce = RandomInstance.bytes(NONCE_SIZE)
|
||||
val ciphertextWithTag = ChaCha20Poly1305.encrypt(payload, EMPTY_AAD, nonce, encryptionKey)
|
||||
|
||||
// Assemble: ephemeral_pubkey(32) || nonce(12) || ciphertext+tag(236)
|
||||
// Assemble: ephemeral_pubkey(32) || nonce(12) || ciphertext+tag(1040)
|
||||
val result = ByteArray(TokenTag.ENCRYPTED_TOKEN_SIZE)
|
||||
ephemeralPubKey.copyInto(result, 0)
|
||||
nonce.copyInto(result, PUBKEY_SIZE)
|
||||
@@ -140,9 +139,9 @@ object TokenEncryption {
|
||||
val nonce = data.copyOfRange(PUBKEY_SIZE, PUBKEY_SIZE + NONCE_SIZE)
|
||||
val ciphertextWithTag = data.copyOfRange(PUBKEY_SIZE + NONCE_SIZE, data.size)
|
||||
|
||||
// ECDH: shared_x = sha256(server_privkey * ephemeral_pubkey)
|
||||
val sharedPoint = Secp256k1Instance.pubKeyTweakMulCompact(ephemeralPubKey, serverPrivKey)
|
||||
val sharedX = sha256(sharedPoint)
|
||||
// ECDH: shared_x = secp256k1_ecdh(server_privkey, ephemeral_pubkey) — raw 32-byte x
|
||||
// per MIP-05.
|
||||
val sharedX = Secp256k1Instance.pubKeyTweakMulCompact(ephemeralPubKey, serverPrivKey)
|
||||
|
||||
// Derive encryption key
|
||||
val encryptionKey = hkdfDeriveKey(sharedX)
|
||||
|
||||
+5
-9
@@ -23,7 +23,6 @@ package com.vitorpamplona.quartz.marmot.mip05PushNotifications
|
||||
import androidx.compose.runtime.Immutable
|
||||
import com.vitorpamplona.quartz.nip01Core.core.Event
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.TagArrayBuilder
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.eventTemplate
|
||||
import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
|
||||
@@ -33,8 +32,10 @@ import com.vitorpamplona.quartz.utils.TimeUtils
|
||||
* Unsigned application message sent inside a GroupEvent (kind:445) when a device
|
||||
* leaves a group or wants to disable push notifications.
|
||||
*
|
||||
* Has no tags — the MLS leaf index is implicit from the MLS sender identity.
|
||||
* Receiving clients MUST remove the token for the identified leaf.
|
||||
* Per MIP-05 this event MUST have **no tags**. The MLS leaf index is implicit
|
||||
* from the MLS sender identity; receiving clients MUST remove the token for
|
||||
* the identified leaf. Adding extra tags could leak metadata or be rejected
|
||||
* by strict MIP-05 validators (e.g. the MDK reference).
|
||||
*
|
||||
* MUST remain unsigned (no sig field) per MIP-03 security requirements.
|
||||
*/
|
||||
@@ -50,11 +51,6 @@ class TokenRemovalEvent(
|
||||
companion object {
|
||||
const val KIND = 449
|
||||
|
||||
fun build(
|
||||
createdAt: Long = TimeUtils.now(),
|
||||
initializer: TagArrayBuilder<TokenRemovalEvent>.() -> Unit = {},
|
||||
) = eventTemplate(KIND, "", createdAt) {
|
||||
initializer()
|
||||
}
|
||||
fun build(createdAt: Long = TimeUtils.now()) = eventTemplate<TokenRemovalEvent>(KIND, "", createdAt)
|
||||
}
|
||||
}
|
||||
|
||||
+6
-3
@@ -38,7 +38,7 @@ import com.vitorpamplona.quartz.utils.ensure
|
||||
*/
|
||||
@Immutable
|
||||
data class TokenTagData(
|
||||
/** Base64-encoded EncryptedToken (280 bytes when decoded) */
|
||||
/** Base64-encoded EncryptedToken (1084 bytes when decoded per MIP-05) */
|
||||
val encryptedToken: String,
|
||||
/** Hex-encoded notification server public key */
|
||||
val serverPubKey: HexKey,
|
||||
@@ -52,8 +52,11 @@ class TokenTag {
|
||||
companion object {
|
||||
const val TAG_NAME = "token"
|
||||
|
||||
/** Expected decoded size of an EncryptedToken */
|
||||
const val ENCRYPTED_TOKEN_SIZE = 280
|
||||
/**
|
||||
* Expected decoded size of an EncryptedToken per MIP-05:
|
||||
* ephemeral_pubkey(32) || nonce(12) || ciphertext(1024 + 16 tag) = 1084 bytes.
|
||||
*/
|
||||
const val ENCRYPTED_TOKEN_SIZE = 1084
|
||||
|
||||
fun parse(tag: Array<String>): TokenTagData? {
|
||||
ensure(tag.has(3) && tag[0] == TAG_NAME) { return null }
|
||||
|
||||
+37
-4
@@ -23,6 +23,8 @@ package com.vitorpamplona.quartz.marmot.mls.framing
|
||||
import com.vitorpamplona.quartz.marmot.mls.codec.TlsReader
|
||||
import com.vitorpamplona.quartz.marmot.mls.codec.TlsSerializable
|
||||
import com.vitorpamplona.quartz.marmot.mls.codec.TlsWriter
|
||||
import com.vitorpamplona.quartz.marmot.mls.messages.Commit
|
||||
import com.vitorpamplona.quartz.marmot.mls.messages.Proposal
|
||||
|
||||
/**
|
||||
* MLS MLSMessage (RFC 9420 Section 6).
|
||||
@@ -161,7 +163,17 @@ data class PublicMessage(
|
||||
encodeSender(writer, sender)
|
||||
writer.putOpaqueVarInt(authenticatedData)
|
||||
writer.putUint8(contentType.value)
|
||||
writer.putBytes(content)
|
||||
|
||||
// RFC 9420 §6 FramedContent.content is a type-dependent body:
|
||||
// case application: opaque application_data<V>
|
||||
// case proposal: Proposal proposal (struct, no outer length prefix)
|
||||
// case commit: Commit commit (struct, no outer length prefix)
|
||||
// `content` holds the already-serialized body for proposal/commit, and
|
||||
// the raw application bytes for application.
|
||||
when (contentType) {
|
||||
ContentType.APPLICATION -> writer.putOpaqueVarInt(content)
|
||||
ContentType.PROPOSAL, ContentType.COMMIT -> writer.putBytes(content)
|
||||
}
|
||||
|
||||
// FramedContentAuthData
|
||||
writer.putOpaqueVarInt(signature)
|
||||
@@ -191,9 +203,30 @@ data class PublicMessage(
|
||||
val authenticatedData = reader.readOpaqueVarInt()
|
||||
val contentType = ContentType.fromValue(reader.readUint8())
|
||||
|
||||
// Content is variable based on content_type, read remaining content
|
||||
// For now, read as opaque
|
||||
val content = reader.readOpaqueVarInt()
|
||||
// RFC 9420 §6 FramedContent.content body varies by content_type.
|
||||
// For PROPOSAL/COMMIT we decode the inner struct to advance the reader
|
||||
// and then re-serialize back to bytes so the invariant
|
||||
// "content holds the serialized body" holds for all variants.
|
||||
val content: ByteArray =
|
||||
when (contentType) {
|
||||
ContentType.APPLICATION -> {
|
||||
reader.readOpaqueVarInt()
|
||||
}
|
||||
|
||||
ContentType.PROPOSAL -> {
|
||||
val proposal = Proposal.decodeTls(reader)
|
||||
val w = TlsWriter()
|
||||
proposal.encodeTls(w)
|
||||
w.toByteArray()
|
||||
}
|
||||
|
||||
ContentType.COMMIT -> {
|
||||
val commit = Commit.decodeTls(reader)
|
||||
val w = TlsWriter()
|
||||
commit.encodeTls(w)
|
||||
w.toByteArray()
|
||||
}
|
||||
}
|
||||
val signature = reader.readOpaqueVarInt()
|
||||
|
||||
val confirmationTag =
|
||||
|
||||
+9
-4
@@ -79,8 +79,11 @@ import kotlinx.coroutines.sync.withLock
|
||||
* ## Cross-Implementation Notes
|
||||
*
|
||||
* This manager uses ciphersuite 0x0001 (MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519).
|
||||
* The epoch secret retention window is [EPOCH_RETENTION_WINDOW] = 2, meaning secrets
|
||||
* for the current and previous epoch are kept for late-message decryption.
|
||||
* The epoch secret retention window is [EPOCH_RETENTION_WINDOW] = 5, matching the
|
||||
* `DEFAULT_EPOCH_LOOKBACK` used by the MDK reference implementation so that late-
|
||||
* arriving MIP-03 GroupEvents (and MLS application messages) whose outer ChaCha20-
|
||||
* Poly1305 key was derived from a prior epoch's exporter secret can still be
|
||||
* decrypted after a Commit advances the group.
|
||||
*
|
||||
* Thread safety: All suspending mutation methods are guarded by a [Mutex]
|
||||
* to prevent concurrent state corruption. Non-suspending read methods
|
||||
@@ -662,9 +665,11 @@ class MlsGroupManager(
|
||||
|
||||
/**
|
||||
* Number of past epochs to retain for late-arriving message decryption.
|
||||
* MLS forward secrecy guarantees mean we want to limit this window.
|
||||
* Matches MDK's `DEFAULT_EPOCH_LOOKBACK` so a message encrypted under
|
||||
* the prior N epochs' exporter secrets can still be decrypted after a
|
||||
* Commit advances the group. Capped for forward-secrecy reasons.
|
||||
*/
|
||||
const val EPOCH_RETENTION_WINDOW = 2
|
||||
const val EPOCH_RETENTION_WINDOW = 5
|
||||
|
||||
/** Size of reuse_guard in PrivateMessage (RFC 9420 §6.3.1) */
|
||||
private const val REUSE_GUARD_LENGTH = 4
|
||||
|
||||
+26
-14
@@ -37,8 +37,20 @@ class KeyPackageUtilsTest {
|
||||
private val testPubKey = "a".repeat(64)
|
||||
private val testRef = "b".repeat(64)
|
||||
|
||||
/**
|
||||
* Per MIP-00 the `d` tag MUST be 64 lowercase hex characters
|
||||
* (a random 32-byte slot ID). The strict [KeyPackageUtils.isValid] enforces
|
||||
* this on the parse side, so test fixtures need realistic 64-char hex
|
||||
* slot IDs even when we only care about the selection logic.
|
||||
*/
|
||||
private fun slot(label: Int): String = "0".repeat(63) + label.toString(16)
|
||||
|
||||
private val slot0 = slot(0)
|
||||
private val slot1 = slot(1)
|
||||
private val slotLastResort = "f".repeat(64)
|
||||
|
||||
private fun makeKeyPackageEvent(
|
||||
dTag: String = "0",
|
||||
dTag: String = slot0,
|
||||
createdAt: Long = 1000,
|
||||
encoding: String = "base64",
|
||||
ciphersuite: String = "0x0001",
|
||||
@@ -105,8 +117,8 @@ class KeyPackageUtilsTest {
|
||||
|
||||
@Test
|
||||
fun testSelectBest_PrefersNewest() {
|
||||
val old = makeKeyPackageEvent(dTag = "0", createdAt = 1000)
|
||||
val newer = makeKeyPackageEvent(dTag = "1", createdAt = 2000)
|
||||
val old = makeKeyPackageEvent(dTag = slot0, createdAt = 1000)
|
||||
val newer = makeKeyPackageEvent(dTag = slot1, createdAt = 2000)
|
||||
|
||||
val best = KeyPackageUtils.selectBest(listOf(old, newer))
|
||||
assertNotNull(best)
|
||||
@@ -115,33 +127,33 @@ class KeyPackageUtilsTest {
|
||||
|
||||
@Test
|
||||
fun testSelectBest_PrefersNonLastResort() {
|
||||
val lastResort = makeKeyPackageEvent(dTag = "lr", createdAt = 3000)
|
||||
val regular = makeKeyPackageEvent(dTag = "0", createdAt = 1000)
|
||||
val lastResort = makeKeyPackageEvent(dTag = slotLastResort, createdAt = 3000)
|
||||
val regular = makeKeyPackageEvent(dTag = slot0, createdAt = 1000)
|
||||
|
||||
// Even though lastResort is newer, regular is preferred
|
||||
val best = KeyPackageUtils.selectBest(listOf(lastResort, regular), lastResortDTag = "lr")
|
||||
val best = KeyPackageUtils.selectBest(listOf(lastResort, regular), lastResortDTag = slotLastResort)
|
||||
assertNotNull(best)
|
||||
assertEquals("0", best.dTag())
|
||||
assertEquals(slot0, best.dTag())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testSelectBest_FallsBackToLastResort() {
|
||||
val lastResort = makeKeyPackageEvent(dTag = "lr", createdAt = 3000)
|
||||
val lastResort = makeKeyPackageEvent(dTag = slotLastResort, createdAt = 3000)
|
||||
|
||||
// Only last-resort available
|
||||
val best = KeyPackageUtils.selectBest(listOf(lastResort), lastResortDTag = "lr")
|
||||
val best = KeyPackageUtils.selectBest(listOf(lastResort), lastResortDTag = slotLastResort)
|
||||
assertNotNull(best)
|
||||
assertEquals("lr", best.dTag())
|
||||
assertEquals(slotLastResort, best.dTag())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun testSelectBest_FiltersOutInvalid() {
|
||||
val invalid = makeKeyPackageEvent(dTag = "0", encoding = "raw")
|
||||
val valid = makeKeyPackageEvent(dTag = "1", createdAt = 500)
|
||||
val invalid = makeKeyPackageEvent(dTag = slot0, encoding = "raw")
|
||||
val valid = makeKeyPackageEvent(dTag = slot1, createdAt = 500)
|
||||
|
||||
val best = KeyPackageUtils.selectBest(listOf(invalid, valid))
|
||||
assertNotNull(best)
|
||||
assertEquals("1", best.dTag())
|
||||
assertEquals(slot1, best.dTag())
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -159,7 +171,7 @@ class KeyPackageUtilsTest {
|
||||
val template =
|
||||
KeyPackageUtils.buildRotation(
|
||||
newKeyPackageBase64 = "bmV3IGtleXBhY2thZ2U=",
|
||||
dTagSlot = "0",
|
||||
dTagSlot = slot0,
|
||||
newKeyPackageRef = testRef,
|
||||
relays = emptyList(),
|
||||
)
|
||||
|
||||
+121
-13
@@ -115,27 +115,24 @@ class MarmotMipComplianceTest {
|
||||
|
||||
@Test
|
||||
fun marmotGroupData_rejectsZeroDisappearingSecsOnDecode() {
|
||||
// Hand-crafted TLS blob: version=3, group_id=32x0, empty opaque2 for
|
||||
// name/description/admins/relays/images, then disappearing_message_secs
|
||||
// = 8 bytes of zero (invalid).
|
||||
// Hand-crafted TLS blob (MIP-01 QUIC VarInt length prefixes):
|
||||
// uint16 version=3 | opaque group_id[32] | 8x empty VarInt(0) fields
|
||||
// (name..image_upload_key) | disappearing_message_secs = VarInt(8) + 8
|
||||
// zero bytes (invalid per MIP-01).
|
||||
val header =
|
||||
ByteArray(2 + 32) {
|
||||
// version + groupId
|
||||
when (it) {
|
||||
0 -> 0
|
||||
|
||||
1 -> 3
|
||||
|
||||
// version=3
|
||||
else -> 0
|
||||
}
|
||||
}
|
||||
// 8x opaque2 fields of length 0, each encoded as two zero bytes:
|
||||
// 8 empty VarInt-prefixed opaque fields, each a single 0x00 byte:
|
||||
// name, description, admin_pubkeys, relays, image_hash, image_key,
|
||||
// image_nonce, image_upload_key
|
||||
val zeroFields = ByteArray(8 * 2) // all zeros
|
||||
// disappearing_message_secs opaque2 with 8 zero bytes
|
||||
val disappearingField = ByteArray(2 + 8).also { it[1] = 8 }
|
||||
val zeroFields = ByteArray(8) // all 0x00
|
||||
// disappearing_message_secs: VarInt(8) = 0x08, then 8 zero bytes
|
||||
val disappearingField = ByteArray(1 + 8).also { it[0] = 0x08 }
|
||||
val blob = header + zeroFields + disappearingField
|
||||
|
||||
// decodeTls catches any exception and returns null
|
||||
@@ -150,13 +147,124 @@ class MarmotMipComplianceTest {
|
||||
it[0] = 0
|
||||
it[1] = 99
|
||||
}
|
||||
val zeroFields = ByteArray(8 * 2) // name..image_upload_key
|
||||
val disappearingField = ByteArray(2) // zero-length
|
||||
val zeroFields = ByteArray(8) // 8x VarInt(0) for name..image_upload_key
|
||||
val disappearingField = ByteArray(1) // VarInt(0) — zero-length field
|
||||
|
||||
val blob = header + zeroFields + disappearingField
|
||||
|
||||
assertNull(MarmotGroupData.decodeTls(blob))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun marmotGroupData_rejectsDuplicateAdminPubkeys() {
|
||||
// MIP-01: admin_pubkeys MUST NOT contain duplicate keys.
|
||||
assertFailsWith<IllegalArgumentException> {
|
||||
MarmotGroupData(
|
||||
nostrGroupId = groupId32,
|
||||
adminPubkeys = listOf(adminPubkey, adminPubkey),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// --- MIP-01 byte-level interop fixtures (MDK reference) ----------------
|
||||
//
|
||||
// These fixtures were produced by serializing the identical struct via the
|
||||
// Rust `tls_codec` 0.4 crate used by MDK (see commit message for the
|
||||
// generator). They pin Amethyst's v2 encoder output byte-for-byte against
|
||||
// the MDK reference, so any future regression in VarInt framing surfaces
|
||||
// immediately.
|
||||
//
|
||||
// Fixtures are v2 (no `disappearing_message_secs` field) because MDK's
|
||||
// current `CURRENT_VERSION = 2` reference has no v3 support yet.
|
||||
|
||||
private fun mdkFixtureA(): ByteArray =
|
||||
(
|
||||
// version=2 + 32 bytes of group_id (all zero)
|
||||
"0002" +
|
||||
"00".repeat(32) +
|
||||
// name=empty, description=empty (VarInt(0) = single 0x00)
|
||||
"0000" +
|
||||
// admin_pubkeys: VarInt(32) = 0x20, then one 32-byte key of 0xAA
|
||||
"20" + "aa".repeat(32) +
|
||||
// relays: outer VarInt(21) = 0x15; inner VarInt(20) = 0x14 +
|
||||
// "wss://relay.example/" (20 bytes)
|
||||
"15" + "14" + "7773733a2f2f72656c61792e6578616d706c652f" +
|
||||
// image_hash, image_key, image_nonce, image_upload_key — all empty
|
||||
"00000000"
|
||||
).hexToByteArray()
|
||||
|
||||
private fun mdkFixtureB(): ByteArray =
|
||||
(
|
||||
"0002" +
|
||||
"11".repeat(32) +
|
||||
// name: VarInt(8)=0x08 + "Amethyst"
|
||||
"08" + "416d657468797374" +
|
||||
// description: VarInt(10)=0x0a + "Test group"
|
||||
"0a" + "546573742067726f7570" +
|
||||
// admin_pubkeys: outer VarInt(64) — 64 = 0x40, two-byte VarInt
|
||||
// prefix "40 40" (high bits 01, value 0x0040) + 2×32 bytes
|
||||
"4040" + "bb".repeat(32) + "cc".repeat(32) +
|
||||
// relays outer VarInt(44) = 0x2c, then two inner relays each
|
||||
// VarInt(21) + 21-byte URL
|
||||
"2c" +
|
||||
"15" + "7773733a2f2f72656c6179312e6578616d706c652f" +
|
||||
"15" + "7773733a2f2f72656c6179322e6578616d706c652f" +
|
||||
// image_* all empty
|
||||
"00000000"
|
||||
).hexToByteArray()
|
||||
|
||||
@Test
|
||||
fun marmotGroupData_encodesFixtureAByteForByteVsMdk() {
|
||||
// Encode an Amethyst MarmotGroupData with the same inputs and assert the
|
||||
// bytes match MDK's tls_codec 0.4 output exactly.
|
||||
val data =
|
||||
MarmotGroupData(
|
||||
version = 2,
|
||||
nostrGroupId = "00".repeat(32),
|
||||
name = "",
|
||||
description = "",
|
||||
adminPubkeys = listOf("aa".repeat(32)),
|
||||
relays = listOf("wss://relay.example/"),
|
||||
)
|
||||
assertContentEquals(mdkFixtureA(), data.encodeTls())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun marmotGroupData_encodesFixtureBByteForByteVsMdk() {
|
||||
val data =
|
||||
MarmotGroupData(
|
||||
version = 2,
|
||||
nostrGroupId = "11".repeat(32),
|
||||
name = "Amethyst",
|
||||
description = "Test group",
|
||||
adminPubkeys = listOf("bb".repeat(32), "cc".repeat(32)),
|
||||
relays = listOf("wss://relay1.example/", "wss://relay2.example/"),
|
||||
)
|
||||
assertContentEquals(mdkFixtureB(), data.encodeTls())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun marmotGroupData_decodesMdkFixtureA() {
|
||||
val decoded = assertNotNull(MarmotGroupData.decodeTls(mdkFixtureA()))
|
||||
assertEquals(2, decoded.version)
|
||||
assertEquals("00".repeat(32), decoded.nostrGroupId)
|
||||
assertEquals("", decoded.name)
|
||||
assertEquals("", decoded.description)
|
||||
assertEquals(listOf("aa".repeat(32)), decoded.adminPubkeys)
|
||||
assertEquals(listOf("wss://relay.example/"), decoded.relays)
|
||||
assertNull(decoded.disappearingMessageSecs)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun marmotGroupData_decodesMdkFixtureB() {
|
||||
val decoded = assertNotNull(MarmotGroupData.decodeTls(mdkFixtureB()))
|
||||
assertEquals(2, decoded.version)
|
||||
assertEquals("Amethyst", decoded.name)
|
||||
assertEquals("Test group", decoded.description)
|
||||
assertEquals(listOf("bb".repeat(32), "cc".repeat(32)), decoded.adminPubkeys)
|
||||
assertEquals(listOf("wss://relay1.example/", "wss://relay2.example/"), decoded.relays)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun mip01ImageCrypto_deriveImageKeyIs32BytesAndDeterministic() {
|
||||
val seed = "11".repeat(32).hexToByteArray()
|
||||
|
||||
Reference in New Issue
Block a user