mirror of
https://github.com/vitorpamplona/amethyst.git
synced 2026-08-09 16:14:40 +00:00
fix(marmot): match mdk/whitenoise MIP-01 v2 group-image scheme for interop
Verified against the mdk-core revision whitenoise-rs pins (marmot-protocol/mdk @e8cd584): its NostrGroupDataExtension parser consumes name/description/admins/ relays/image_hash/image_key/image_nonce/image_upload_key and rejects ANY trailing bytes at a known version, and its extension/group_image.rs fully implements avatar encryption. The previous "canonical raw-key + media_type" approach both (a) added a trailing media_type field that mdk rejects — breaking the whole group for whitenoise members — and (b) used a key scheme mdk can't decrypt. Re-implement to mdk's exact MIP-01 v2 scheme so avatars interoperate byte-for-byte: - image_key / image_upload_key are HKDF seeds (reusing Mip01ImageCrypto's mip01-image-encryption-v2 / mip01-blossom-upload-v2 labels; HKDF-SHA256 with empty salt == mdk's Hkdf::new(None, seed)). AEAD key derived from the seed. - ChaCha20-Poly1305, 12-byte nonce, EMPTY AAD, image_hash = SHA-256(ciphertext). - Decrypt tries v2 (HKDF) then falls back to v1 (raw key), exactly like mdk. - Remove media_type from the wire entirely (and from the model/cipher/uploader), so a v2 image extension ends at image_upload_key with zero trailing bytes. The plaintext MIME isn't stored; the display path lets Coil sniff the format. - Derive the Blossom upload keypair from image_upload_key instead of storing a raw key. Adds a regression test that reproduces mdk's v1/v2 field consumption and asserts a v2 image extension has no trailing bytes, plus a test pinning the HKDF-seed + empty-AAD scheme so future drift from mdk is caught. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01JL3GXW1fmHa3xWfQjLLqfp
This commit is contained in:
-1
@@ -2183,7 +2183,6 @@ class AccountViewModel(
|
||||
imageKey = icon.upload.imageKey,
|
||||
imageNonce = icon.upload.imageNonce,
|
||||
imageUploadKey = icon.upload.imageUploadKey,
|
||||
imageMediaType = icon.upload.mediaType,
|
||||
)
|
||||
}
|
||||
val relays = account.marmotGroupRelays(nostrGroupId)
|
||||
|
||||
+3
-2
@@ -80,11 +80,12 @@ fun rememberMarmotGroupIconUrl(
|
||||
value = resolver.findServers(blossomUri)?.serverUrl ?: fallbackUrl
|
||||
}
|
||||
|
||||
val cipher = remember(image) { MarmotGroupImageCipher(image.key, image.nonce, image.mediaType) }
|
||||
val cipher = remember(image) { MarmotGroupImageCipher(image.key, image.nonce) }
|
||||
// Register the cipher only when the URL or cipher changes (not on every recomposition),
|
||||
// and synchronously during composition so the interceptor can decrypt before Coil fetches.
|
||||
// The plaintext MIME isn't stored (MIP-01 v2), so Coil sniffs the format from the bytes.
|
||||
remember(url, cipher) {
|
||||
Amethyst.instance.keyCache.add(url, cipher, image.mediaType)
|
||||
Amethyst.instance.keyCache.add(url, cipher, null)
|
||||
url
|
||||
}
|
||||
|
||||
|
||||
+16
-19
@@ -31,7 +31,6 @@ import com.vitorpamplona.amethyst.ui.actions.mediaServers.ServerName
|
||||
import com.vitorpamplona.amethyst.ui.stringRes
|
||||
import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupImageCipher
|
||||
import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupImageEncryption
|
||||
import com.vitorpamplona.quartz.marmot.mip04EncryptedMedia.Mip04MediaEncryption
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
|
||||
import com.vitorpamplona.quartz.nip01Core.signers.NostrSignerInternal
|
||||
@@ -46,14 +45,12 @@ import java.io.File
|
||||
class MarmotGroupIconUpload(
|
||||
/** SHA-256 (hex) of the encrypted blob = its Blossom content hash. */
|
||||
val imageHash: HexKey,
|
||||
/** Raw 32-byte ChaCha20-Poly1305 key. */
|
||||
/** 32-byte HKDF seed for the image AEAD key (MIP-01 v2). */
|
||||
val imageKey: ByteArray,
|
||||
/** 12-byte nonce. */
|
||||
val imageNonce: ByteArray,
|
||||
/** Raw 32-byte Blossom-auth secret key. */
|
||||
/** 32-byte HKDF seed for the Blossom-auth keypair (MIP-01 v2). */
|
||||
val imageUploadKey: ByteArray,
|
||||
/** Canonical MIME type of the plaintext image. */
|
||||
val mediaType: String,
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -73,9 +70,10 @@ sealed class MarmotGroupIconChange {
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypts a picked image with the canonical `marmot-group-image-v1` scheme and
|
||||
* uploads the ciphertext to Blossom, signing the upload authorization with a fresh
|
||||
* keypair (so any admin holding `image_upload_key` can later replace/delete it).
|
||||
* Encrypts a picked image with the MIP-01 v2 scheme (see [MarmotGroupImageEncryption])
|
||||
* and uploads the ciphertext to Blossom, signing the upload authorization with the
|
||||
* keypair derived from `image_upload_key` (so any admin holding that seed can later
|
||||
* replace/delete the blob).
|
||||
*
|
||||
* Reuses [UploadOrchestrator.uploadEncrypted] for compression, metadata stripping,
|
||||
* upload, and re-download verification — the same pipeline as MIP-04 message media.
|
||||
@@ -89,21 +87,21 @@ class MarmotGroupIconUploader(
|
||||
server: ServerName,
|
||||
context: Context,
|
||||
): MarmotGroupIconUpload {
|
||||
// Compress/downscale up front (avatars don't need full resolution) so the stored
|
||||
// media_type reflects the ACTUAL post-compression bytes. The AEAD binds media_type,
|
||||
// and MediaCompressor transcodes images to JPEG — so we must know the final type
|
||||
// before building the cipher, then upload the already-compressed bytes uncompressed.
|
||||
// Compress/downscale up front — avatars don't need full resolution, and a smaller
|
||||
// blob is cheaper for every member to fetch. The MIP-01 crypto uses no AAD and does
|
||||
// not bind the MIME type, so the (possibly transcoded) output type is irrelevant to
|
||||
// decryption; we just hand the compressed bytes to the encrypting uploader.
|
||||
val compressed = MediaCompressor().compress(uri, mimeType, CompressorQuality.MEDIUM, context.applicationContext)
|
||||
val mediaType = Mip04MediaEncryption.canonicalizeMimeType(compressed.contentType ?: mimeType ?: DEFAULT_MIME)
|
||||
val cipher = MarmotGroupImageCipher.forNewImage(mediaType)
|
||||
val uploadKey = MarmotGroupImageEncryption.generateUploadKey()
|
||||
val uploadSigner = NostrSignerInternal(KeyPair(privKey = uploadKey))
|
||||
val uploadMime = compressed.contentType ?: mimeType ?: DEFAULT_MIME
|
||||
val cipher = MarmotGroupImageCipher.forNewImage()
|
||||
val uploadKeySeed = MarmotGroupImageEncryption.generateUploadKey()
|
||||
val uploadSigner = NostrSignerInternal(KeyPair(privKey = MarmotGroupImageEncryption.deriveUploadKeypairSecret(uploadKeySeed)))
|
||||
|
||||
try {
|
||||
val state =
|
||||
UploadOrchestrator().uploadEncrypted(
|
||||
uri = compressed.uri,
|
||||
mimeType = mediaType,
|
||||
mimeType = uploadMime,
|
||||
alt = null,
|
||||
contentWarningReason = null,
|
||||
compressionQuality = CompressorQuality.UNCOMPRESSED,
|
||||
@@ -124,8 +122,7 @@ class MarmotGroupIconUploader(
|
||||
imageHash = hash,
|
||||
imageKey = cipher.imageKey,
|
||||
imageNonce = cipher.imageNonce,
|
||||
imageUploadKey = uploadKey,
|
||||
mediaType = mediaType,
|
||||
imageUploadKey = uploadKeySeed,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
-1
@@ -749,7 +749,6 @@ class MarmotManager(
|
||||
hash = metadata.imageHash!!,
|
||||
key = metadata.imageKey!!,
|
||||
nonce = metadata.imageNonce!!,
|
||||
mediaType = metadata.imageMediaType,
|
||||
)
|
||||
} else {
|
||||
null
|
||||
|
||||
+5
-8
@@ -28,34 +28,31 @@ import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
*
|
||||
* Extracted from the group's [com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData]
|
||||
* so front ends can render the icon: the encrypted blob is content-addressed on Blossom by
|
||||
* [hash], and decrypted with [key]/[nonce] (and [mediaType] for the AEAD associated data)
|
||||
* via [com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupImageEncryption].
|
||||
* [hash], and decrypted with [key]/[nonce] via
|
||||
* [com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupImageEncryption] (MIP-01 v2:
|
||||
* [key] is an HKDF seed).
|
||||
*/
|
||||
@Immutable
|
||||
class MarmotGroupImage(
|
||||
/** SHA-256 (hex) of the encrypted blob — the Blossom content hash. */
|
||||
val hash: HexKey,
|
||||
/** Raw ChaCha20-Poly1305 key (canonical scheme) or HKDF seed (legacy). */
|
||||
/** 32-byte HKDF seed for the image AEAD key (MIP-01 v2). */
|
||||
val key: ByteArray,
|
||||
/** 12-byte ChaCha20-Poly1305 nonce. */
|
||||
val nonce: ByteArray,
|
||||
/** Canonical plaintext MIME type; null for legacy groups predating the field. */
|
||||
val mediaType: String?,
|
||||
) {
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (other !is MarmotGroupImage) return false
|
||||
return hash == other.hash &&
|
||||
key.contentEquals(other.key) &&
|
||||
nonce.contentEquals(other.nonce) &&
|
||||
mediaType == other.mediaType
|
||||
nonce.contentEquals(other.nonce)
|
||||
}
|
||||
|
||||
override fun hashCode(): Int {
|
||||
var result = hash.hashCode()
|
||||
result = 31 * result + key.contentHashCode()
|
||||
result = 31 * result + nonce.contentHashCode()
|
||||
result = 31 * result + (mediaType?.hashCode() ?: 0)
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
+16
-43
@@ -49,21 +49,22 @@ import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||
* opaque admin_pubkeys<0..2^16-1>; // Concatenated raw 32-byte x-only pubkeys
|
||||
* RelayUrl relays<0..2^16-1>;
|
||||
* opaque image_hash<0..32>;
|
||||
* opaque image_key<0..32>; // canonical: raw ChaCha20-Poly1305 key
|
||||
* opaque image_key<0..32>; // MIP-01 v2: HKDF seed for the AEAD key
|
||||
* opaque image_nonce<0..12>;
|
||||
* opaque image_upload_key<0..32>; // canonical: raw Blossom-auth secret key
|
||||
* opaque image_upload_key<0..32>; // MIP-01 v2: HKDF seed for the Blossom-auth key
|
||||
* opaque disappearing_message_secs<0..8>; // v3+: 0 bytes = persist forever,
|
||||
* // 8 bytes big-endian uint64 = expiration secs
|
||||
* // (value 0 is rejected)
|
||||
* opaque image_media_type<0..128>; // trailing: canonical MIME of the plaintext image
|
||||
* } NostrGroupData;
|
||||
* ```
|
||||
*
|
||||
* The image fields carry the canonical `marmot.group.blossom.image.v1` app-component
|
||||
* data (see [MarmotGroupImageEncryption]). `image_key`/`image_upload_key` are RAW keys,
|
||||
* not HKDF seeds; `image_hash` is the SHA-256 of the encrypted blob. `image_media_type`
|
||||
* is appended after `disappearing_message_secs` so older readers ignore it (forward
|
||||
* compatibility); it feeds the AEAD associated data on decrypt.
|
||||
* The image fields carry the group avatar under the MIP-01 v2 scheme (see
|
||||
* [MarmotGroupImageEncryption]). `image_key`/`image_upload_key` are HKDF **seeds**;
|
||||
* `image_hash` is the SHA-256 of the encrypted blob. This is the exact field layout
|
||||
* mdk-core's v1/v2 `NostrGroupDataExtension` parser expects — the image fields are the
|
||||
* last ones it reads — so populating an avatar stays byte-compatible with
|
||||
* whitenoise/mdk (no trailing bytes). The plaintext MIME type is intentionally NOT
|
||||
* stored here: mdk rejects any trailing bytes at a known version and has no such field.
|
||||
*/
|
||||
@Immutable
|
||||
data class MarmotGroupData(
|
||||
@@ -89,11 +90,11 @@ data class MarmotGroupData(
|
||||
val relays: List<String> = emptyList(),
|
||||
/** SHA-256 hash (hex) of the ENCRYPTED group image blob (= its Blossom hash). Null if no image. */
|
||||
val imageHash: HexKey? = null,
|
||||
/** Raw 32-byte ChaCha20-Poly1305 key for the image blob (canonical scheme). Null if no image. */
|
||||
/** 32-byte HKDF seed for the image AEAD key (MIP-01 v2). Null if no image. */
|
||||
val imageKey: ByteArray? = null,
|
||||
/** 12-byte ChaCha20-Poly1305 nonce for image encryption. Null if no image. */
|
||||
val imageNonce: ByteArray? = null,
|
||||
/** Raw 32-byte secret key of the fresh Nostr keypair that authorizes Blossom writes. Null if no image. */
|
||||
/** 32-byte HKDF seed for the Blossom-auth keypair (MIP-01 v2). Null if no image. */
|
||||
val imageUploadKey: ByteArray? = null,
|
||||
/**
|
||||
* Disappearing-message duration in seconds (v3+).
|
||||
@@ -102,12 +103,6 @@ data class MarmotGroupData(
|
||||
* Per MIP-01, a value of `0` MUST be rejected.
|
||||
*/
|
||||
val disappearingMessageSecs: ULong? = null,
|
||||
/**
|
||||
* Canonical MIME type of the plaintext image (e.g. `image/jpeg`), fed into the
|
||||
* `marmot-group-image-v1` AEAD associated data. `null` for legacy groups that
|
||||
* predate the field; the decryptor then falls back to the deprecated scheme.
|
||||
*/
|
||||
val imageMediaType: String? = null,
|
||||
) {
|
||||
init {
|
||||
require(version > 0) { "MarmotGroupData version 0 is reserved/invalid" }
|
||||
@@ -134,14 +129,12 @@ data class MarmotGroupData(
|
||||
imageKey: ByteArray,
|
||||
imageNonce: ByteArray,
|
||||
imageUploadKey: ByteArray,
|
||||
imageMediaType: String,
|
||||
): MarmotGroupData =
|
||||
copy(
|
||||
imageHash = imageHash,
|
||||
imageKey = imageKey,
|
||||
imageNonce = imageNonce,
|
||||
imageUploadKey = imageUploadKey,
|
||||
imageMediaType = imageMediaType,
|
||||
)
|
||||
|
||||
/** Return a copy with the group image cleared. */
|
||||
@@ -151,7 +144,6 @@ data class MarmotGroupData(
|
||||
imageKey = null,
|
||||
imageNonce = null,
|
||||
imageUploadKey = null,
|
||||
imageMediaType = null,
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -208,13 +200,11 @@ data class MarmotGroupData(
|
||||
writer.putOpaqueVarInt(imageNonce ?: ByteArray(0))
|
||||
writer.putOpaqueVarInt(imageUploadKey ?: ByteArray(0))
|
||||
|
||||
// disappearing_message_secs (0 bytes = none, 8 bytes big-endian uint64 = secs).
|
||||
// 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). It is ALSO
|
||||
// emitted (as an empty 0-byte field) whenever image_media_type follows, so the
|
||||
// trailing field stays positionally unambiguous on decode.
|
||||
val emitDisappearing = version >= 3 || imageMediaType != null
|
||||
if (emitDisappearing) {
|
||||
// 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 mdk's v1/v2 NostrGroupDataExtension
|
||||
// parser (which ends at image_upload_key and rejects any trailing bytes).
|
||||
if (version >= 3) {
|
||||
val disappearingBytes =
|
||||
disappearingMessageSecs?.let { secs ->
|
||||
val out = ByteArray(8)
|
||||
@@ -228,12 +218,6 @@ data class MarmotGroupData(
|
||||
writer.putOpaqueVarInt(disappearingBytes)
|
||||
}
|
||||
|
||||
// Trailing image_media_type (canonical marmot-group-image-v1). Only emitted when
|
||||
// set; older readers ignore trailing bytes (MIP-01 forward compatibility).
|
||||
if (imageMediaType != null) {
|
||||
writer.putOpaqueVarInt(imageMediaType.encodeToByteArray())
|
||||
}
|
||||
|
||||
return writer.toByteArray()
|
||||
}
|
||||
|
||||
@@ -319,7 +303,6 @@ data class MarmotGroupData(
|
||||
* opaque image_nonce<V>
|
||||
* opaque image_upload_key<V>
|
||||
* opaque disappearing_message_secs<V> // v3+: 0 bytes or 8-byte uint64 (reject 0)
|
||||
* opaque image_media_type<V> // trailing: canonical image MIME (empty = none)
|
||||
* ```
|
||||
*
|
||||
* Unknown trailing bytes from future versions are silently ignored for
|
||||
@@ -388,15 +371,6 @@ data class MarmotGroupData(
|
||||
}
|
||||
}
|
||||
|
||||
// Trailing image_media_type (canonical marmot-group-image-v1). Absent for
|
||||
// legacy groups; an empty value decodes to null.
|
||||
val imageMediaType =
|
||||
if (reader.hasRemaining) {
|
||||
reader.readOpaqueVarInt().takeIf { it.isNotEmpty() }?.decodeToString()
|
||||
} else {
|
||||
null
|
||||
}
|
||||
|
||||
MarmotGroupData(
|
||||
version = version,
|
||||
nostrGroupId = nostrGroupId,
|
||||
@@ -409,7 +383,6 @@ data class MarmotGroupData(
|
||||
imageNonce = imageNonce,
|
||||
imageUploadKey = imageUploadKey,
|
||||
disappearingMessageSecs = disappearingMessageSecs,
|
||||
imageMediaType = imageMediaType,
|
||||
)
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
|
||||
+14
-15
@@ -25,48 +25,47 @@ import com.vitorpamplona.quartz.utils.RandomInstance
|
||||
import com.vitorpamplona.quartz.utils.ciphers.NostrCipher
|
||||
|
||||
/**
|
||||
* [NostrCipher] for a Marmot group avatar, implementing the canonical
|
||||
* `marmot-group-image-v1` scheme (see [MarmotGroupImageEncryption]).
|
||||
* [NostrCipher] for a Marmot group avatar, implementing the MIP-01 v2 scheme (see
|
||||
* [MarmotGroupImageEncryption]) — byte-for-byte interoperable with mdk/whitenoise.
|
||||
*
|
||||
* The same instance serves two paths:
|
||||
* - **Upload** — the file-upload pipeline calls [encrypt] over the (compressed)
|
||||
* image bytes; the resulting blob is stored on Blossom and addressed by
|
||||
* `SHA-256(ciphertext)`. The [imageKey]/[imageNonce] are generated up front so
|
||||
* the caller can persist them into the group's [MarmotGroupData].
|
||||
* `SHA-256(ciphertext)`. The [imageKey] seed / [imageNonce] are generated up front
|
||||
* so the caller can persist them into the group's [MarmotGroupData].
|
||||
* - **Display** — registered in the encrypted-blob HTTP cache keyed by the blob
|
||||
* URL, so a fetched avatar is transparently decrypted via [decryptOrNull]
|
||||
* (which also opens blobs from the deprecated MIP-01 scheme).
|
||||
* (v2 first, then the v1 raw-key fallback).
|
||||
*/
|
||||
class MarmotGroupImageCipher(
|
||||
/** Raw 32-byte ChaCha20-Poly1305 key (canonical) or HKDF seed (legacy fallback). */
|
||||
/** 32-byte HKDF seed stored as `image_key` (the AEAD key is derived from it). */
|
||||
val imageKey: ByteArray,
|
||||
/** 12-byte nonce. */
|
||||
val imageNonce: ByteArray,
|
||||
/** Canonical MIME type of the plaintext image; null only for legacy blobs on decrypt. */
|
||||
val mediaType: String?,
|
||||
) : NostrCipher {
|
||||
override fun name(): String = MarmotGroupImageEncryption.AAD_LABEL
|
||||
override fun name(): String = "mip01-image-encryption-v2"
|
||||
|
||||
override fun encrypt(bytesToEncrypt: ByteArray): ByteArray {
|
||||
val type = requireNotNull(mediaType) { "media type is required to encrypt a group image" }
|
||||
return ChaCha20Poly1305.encrypt(bytesToEncrypt, MarmotGroupImageEncryption.buildAad(type), imageNonce, imageKey)
|
||||
val aeadKey = Mip01ImageCrypto.deriveImageEncryptionKey(imageKey)
|
||||
return ChaCha20Poly1305.encrypt(bytesToEncrypt, EMPTY_AAD, imageNonce, aeadKey)
|
||||
}
|
||||
|
||||
override fun decrypt(bytesToDecrypt: ByteArray): ByteArray = decryptOrNull(bytesToDecrypt) ?: throw IllegalStateException("Failed to decrypt Marmot group image")
|
||||
|
||||
override fun decryptOrNull(bytesToDecrypt: ByteArray): ByteArray? = MarmotGroupImageEncryption.decryptAny(bytesToDecrypt, imageKey, imageNonce, mediaType)
|
||||
override fun decryptOrNull(bytesToDecrypt: ByteArray): ByteArray? = MarmotGroupImageEncryption.decryptAny(bytesToDecrypt, imageKey, imageNonce)
|
||||
|
||||
companion object {
|
||||
private val EMPTY_AAD = ByteArray(0)
|
||||
|
||||
/**
|
||||
* Build a cipher with a freshly-generated key + nonce, ready to encrypt a new
|
||||
* Build a cipher with a freshly-generated seed + nonce, ready to encrypt a new
|
||||
* avatar. The generated [imageKey]/[imageNonce] are exposed on the returned
|
||||
* instance so the caller can persist them into [MarmotGroupData].
|
||||
*/
|
||||
fun forNewImage(mediaType: String): MarmotGroupImageCipher =
|
||||
fun forNewImage(): MarmotGroupImageCipher =
|
||||
MarmotGroupImageCipher(
|
||||
imageKey = RandomInstance.bytes(MarmotGroupImageEncryption.KEY_LENGTH),
|
||||
imageNonce = RandomInstance.bytes(MarmotGroupImageEncryption.NONCE_LENGTH),
|
||||
mediaType = mediaType,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+61
-97
@@ -20,7 +20,6 @@
|
||||
*/
|
||||
package com.vitorpamplona.quartz.marmot.mip01Groups
|
||||
|
||||
import com.vitorpamplona.quartz.marmot.mip04EncryptedMedia.Mip04MediaEncryption
|
||||
import com.vitorpamplona.quartz.nip01Core.core.HexKey
|
||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||
import com.vitorpamplona.quartz.nip44Encryption.crypto.ChaCha20Poly1305
|
||||
@@ -28,62 +27,42 @@ import com.vitorpamplona.quartz.utils.RandomInstance
|
||||
import com.vitorpamplona.quartz.utils.sha256.sha256
|
||||
|
||||
/**
|
||||
* Marmot group image (avatar) encryption.
|
||||
* Marmot group image (avatar) encryption — MIP-01 v2.
|
||||
*
|
||||
* Implements the canonical `marmot.group.blossom.image.v1` app component scheme
|
||||
* (the successor to the deprecated MIP-01 image scheme). The plaintext avatar is
|
||||
* encrypted with ChaCha20-Poly1305 and the ciphertext is uploaded as an opaque
|
||||
* blob to a Blossom server, addressed by the SHA-256 of the *ciphertext*.
|
||||
* This is byte-for-byte interoperable with the reference implementation
|
||||
* (`mdk-core`'s `extension/group_image.rs`, used by whitenoise): a group avatar
|
||||
* is encrypted with ChaCha20-Poly1305 and the ciphertext is uploaded to Blossom,
|
||||
* addressed by the SHA-256 of the *ciphertext*.
|
||||
*
|
||||
* The encryption parameters live inside the group's [MarmotGroupData] extension:
|
||||
* - `image_key` — the raw 32-byte ChaCha20-Poly1305 key (NOT an HKDF seed).
|
||||
* - `image_nonce` — the 12-byte nonce.
|
||||
* - `image_hash` — SHA-256 of the encrypted blob (= the Blossom hash).
|
||||
* - `image_upload_key` — the raw 32-byte secret key of a fresh Nostr keypair used
|
||||
* to authorize Blossom writes (see [MarmotGroupData.imageUploadKey]).
|
||||
* - `media_type` — the canonical MIME type of the plaintext image.
|
||||
* The parameters live inside the group's [MarmotGroupData] extension:
|
||||
* - `image_key` — a 32-byte HKDF **seed**. The AEAD key is
|
||||
* `HKDF-SHA256(salt=∅, ikm=image_key, info="mip01-image-encryption-v2", 32)`
|
||||
* (see [Mip01ImageCrypto.deriveImageEncryptionKey]).
|
||||
* - `image_nonce` — the 12-byte ChaCha20-Poly1305 nonce (used verbatim).
|
||||
* - `image_hash` — SHA-256 of the encrypted blob (= the Blossom hash).
|
||||
* - `image_upload_key` — a 32-byte HKDF **seed**. The Blossom-auth secp256k1
|
||||
* secret is `HKDF-SHA256(salt=∅, ikm=image_upload_key, info="mip01-blossom-upload-v2", 32)`
|
||||
* (see [Mip01ImageCrypto.deriveBlossomUploadSeed]).
|
||||
*
|
||||
* ```
|
||||
* aad = "marmot-group-image-v1" || 0x00 || media_type
|
||||
* encrypted_blob = ChaCha20-Poly1305.encrypt(image_key, image_nonce, plaintext, aad)
|
||||
* image_hash = SHA-256(encrypted_blob)
|
||||
* ```
|
||||
* The AEAD uses **no associated data** (empty AAD), matching MIP-01. The plaintext
|
||||
* MIME type is descriptive metadata only — it is deliberately NOT bound into the
|
||||
* crypto and NOT stored on the wire, because mdk's `NostrGroupDataExtension` parser
|
||||
* rejects any trailing bytes at a known version and has no media_type field, so
|
||||
* storing it would break group parsing for whitenoise/mdk members.
|
||||
*
|
||||
* A fetching client MUST verify that the fetched bytes hash to `image_hash`
|
||||
* before decrypting.
|
||||
* A fetching client MUST verify that the fetched bytes hash to `image_hash` before
|
||||
* decrypting.
|
||||
*
|
||||
* ### Backward compatibility (parse-both)
|
||||
* Amethyst never shipped the deprecated MIP-01 image scheme (no client code ever
|
||||
* populated the image fields), but other clients might have. For robustness,
|
||||
* [decryptAny] first tries the canonical raw-key scheme and, on authentication
|
||||
* failure, falls back to the deprecated scheme where `image_key` is an HKDF seed
|
||||
* ([Mip01ImageCrypto.deriveImageEncryptionKey]) and the AEAD carries no AAD.
|
||||
* ### Version fallback (parse both), mirroring mdk
|
||||
* [decryptAny] first tries v2 (HKDF-derived key); on failure it falls back to v1,
|
||||
* where `image_key` is used directly as the AEAD key. New images are always v2.
|
||||
*/
|
||||
object MarmotGroupImageEncryption {
|
||||
/** ASCII label mixed into the AEAD associated data. */
|
||||
const val AAD_LABEL = "marmot-group-image-v1"
|
||||
|
||||
const val KEY_LENGTH = 32
|
||||
const val NONCE_LENGTH = 12
|
||||
|
||||
private val NULL_SEPARATOR = byteArrayOf(0x00)
|
||||
private val EMPTY_AAD = ByteArray(0)
|
||||
|
||||
/**
|
||||
* Build the AEAD associated data: `"marmot-group-image-v1" || 0x00 || media_type`.
|
||||
* The media type is canonicalized the same way MIP-04 canonicalizes it
|
||||
* (lowercased, trimmed, parameters stripped) so both peers derive identical bytes.
|
||||
*/
|
||||
fun buildAad(mediaType: String): ByteArray {
|
||||
val label = AAD_LABEL.encodeToByteArray()
|
||||
val mime = Mip04MediaEncryption.canonicalizeMimeType(mediaType).encodeToByteArray()
|
||||
val out = ByteArray(label.size + 1 + mime.size)
|
||||
label.copyInto(out, 0)
|
||||
NULL_SEPARATOR.copyInto(out, label.size)
|
||||
mime.copyInto(out, label.size + 1)
|
||||
return out
|
||||
}
|
||||
|
||||
/**
|
||||
* Result of encrypting a group image, ready to be uploaded to Blossom and
|
||||
* folded into a [MarmotGroupData].
|
||||
@@ -91,7 +70,7 @@ object MarmotGroupImageEncryption {
|
||||
class Encrypted(
|
||||
/** The encrypted blob to upload to Blossom (ciphertext || 16-byte tag). */
|
||||
val ciphertext: ByteArray,
|
||||
/** Random 32-byte ChaCha20-Poly1305 key — store as `image_key`. */
|
||||
/** Random 32-byte HKDF seed — store as `image_key`. */
|
||||
val imageKey: ByteArray,
|
||||
/** Random 12-byte nonce — store as `image_nonce`. */
|
||||
val imageNonce: ByteArray,
|
||||
@@ -100,27 +79,25 @@ object MarmotGroupImageEncryption {
|
||||
)
|
||||
|
||||
/**
|
||||
* Encrypt a plaintext image with a freshly-generated key + nonce, per the
|
||||
* canonical scheme. Returns the ciphertext to upload plus the parameters to
|
||||
* persist in [MarmotGroupData].
|
||||
* Encrypt a plaintext image with a freshly-generated seed + nonce (MIP-01 v2).
|
||||
* Returns the ciphertext to upload plus the parameters to persist in
|
||||
* [MarmotGroupData].
|
||||
*/
|
||||
fun encrypt(
|
||||
plaintext: ByteArray,
|
||||
mediaType: String,
|
||||
): Encrypted {
|
||||
val imageKey = RandomInstance.bytes(KEY_LENGTH)
|
||||
fun encrypt(plaintext: ByteArray): Encrypted {
|
||||
val imageKeySeed = RandomInstance.bytes(KEY_LENGTH)
|
||||
val imageNonce = RandomInstance.bytes(NONCE_LENGTH)
|
||||
val ciphertext = ChaCha20Poly1305.encrypt(plaintext, buildAad(mediaType), imageNonce, imageKey)
|
||||
val aeadKey = Mip01ImageCrypto.deriveImageEncryptionKey(imageKeySeed)
|
||||
val ciphertext = ChaCha20Poly1305.encrypt(plaintext, EMPTY_AAD, imageNonce, aeadKey)
|
||||
return Encrypted(
|
||||
ciphertext = ciphertext,
|
||||
imageKey = imageKey,
|
||||
imageKey = imageKeySeed,
|
||||
imageNonce = imageNonce,
|
||||
imageHash = sha256(ciphertext).toHexKey(),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt a group image blob using the canonical raw-key scheme.
|
||||
* Decrypt a group image blob (MIP-01 v2): `image_key` is an HKDF seed.
|
||||
*
|
||||
* @throws IllegalStateException on authentication failure.
|
||||
*/
|
||||
@@ -128,61 +105,48 @@ object MarmotGroupImageEncryption {
|
||||
ciphertext: ByteArray,
|
||||
imageKey: ByteArray,
|
||||
imageNonce: ByteArray,
|
||||
mediaType: String,
|
||||
): ByteArray = ChaCha20Poly1305.decrypt(ciphertext, buildAad(mediaType), imageNonce, imageKey)
|
||||
): ByteArray {
|
||||
val aeadKey = Mip01ImageCrypto.deriveImageEncryptionKey(imageKey)
|
||||
return ChaCha20Poly1305.decrypt(ciphertext, EMPTY_AAD, imageNonce, aeadKey)
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt a group image blob, trying the canonical raw-key scheme first and
|
||||
* falling back to the deprecated MIP-01 HKDF-seed scheme.
|
||||
*
|
||||
* Returns null if neither scheme authenticates (wrong key, corrupt blob, or an
|
||||
* unknown future scheme).
|
||||
*
|
||||
* @param mediaType canonical MIME type from [MarmotGroupData.imageMediaType];
|
||||
* may be null for legacy groups that predate the `media_type` field, in which
|
||||
* case only the legacy fallback is attempted.
|
||||
* Decrypt a group image blob, trying v2 (HKDF-derived key) first and falling
|
||||
* back to v1 (raw `image_key`), exactly like mdk. Returns null if neither
|
||||
* authenticates.
|
||||
*/
|
||||
fun decryptAny(
|
||||
ciphertext: ByteArray,
|
||||
imageKey: ByteArray,
|
||||
imageNonce: ByteArray,
|
||||
mediaType: String?,
|
||||
): ByteArray? {
|
||||
if (imageKey.size == KEY_LENGTH && imageNonce.size == NONCE_LENGTH && mediaType != null) {
|
||||
try {
|
||||
return decrypt(ciphertext, imageKey, imageNonce, mediaType)
|
||||
} catch (_: Exception) {
|
||||
// fall through to the deprecated scheme
|
||||
}
|
||||
}
|
||||
return decryptLegacyOrNull(ciphertext, imageKey, imageNonce)
|
||||
}
|
||||
if (imageKey.size != KEY_LENGTH || imageNonce.size != NONCE_LENGTH) return null
|
||||
|
||||
/**
|
||||
* Deprecated MIP-01 image scheme: `image_key` is an HKDF seed rather than the
|
||||
* raw AEAD key, and the AEAD carries no associated data. Kept only so we can
|
||||
* still open avatars produced by pre-canonical clients.
|
||||
*/
|
||||
private fun decryptLegacyOrNull(
|
||||
ciphertext: ByteArray,
|
||||
imageKeySeed: ByteArray,
|
||||
imageNonce: ByteArray,
|
||||
): ByteArray? =
|
||||
// v2: image_key is an HKDF seed.
|
||||
try {
|
||||
if (imageKeySeed.size != Mip01ImageCrypto.OUTPUT_LENGTH || imageNonce.size != NONCE_LENGTH) {
|
||||
null
|
||||
} else {
|
||||
val key = Mip01ImageCrypto.deriveImageEncryptionKey(imageKeySeed)
|
||||
ChaCha20Poly1305.decrypt(ciphertext, EMPTY_AAD, imageNonce, key)
|
||||
}
|
||||
return decrypt(ciphertext, imageKey, imageNonce)
|
||||
} catch (_: Exception) {
|
||||
// fall through to v1
|
||||
}
|
||||
|
||||
// v1: image_key is the AEAD key directly.
|
||||
return try {
|
||||
ChaCha20Poly1305.decrypt(ciphertext, EMPTY_AAD, imageNonce, imageKey)
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate the raw 32-byte secret key of a fresh Nostr keypair to authorize
|
||||
* Blossom writes — store as [MarmotGroupData.imageUploadKey]. Any admin that
|
||||
* later holds this value can re-sign uploads/deletions for the blob.
|
||||
* Generate the 32-byte HKDF **seed** stored as [MarmotGroupData.imageUploadKey].
|
||||
* The actual Blossom-auth secp256k1 secret is derived from it via
|
||||
* [deriveUploadKeypairSecret].
|
||||
*/
|
||||
fun generateUploadKey(): ByteArray = RandomInstance.bytes(KEY_LENGTH)
|
||||
|
||||
/**
|
||||
* Derive the 32-byte secp256k1 secret used to authorize Blossom writes for the
|
||||
* avatar from the stored `image_upload_key` seed (MIP-01 v2).
|
||||
*/
|
||||
fun deriveUploadKeypairSecret(imageUploadKey: ByteArray): ByteArray = Mip01ImageCrypto.deriveBlossomUploadSeed(imageUploadKey)
|
||||
}
|
||||
|
||||
+87
-68
@@ -24,6 +24,7 @@ import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupData
|
||||
import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupImageCipher
|
||||
import com.vitorpamplona.quartz.marmot.mip01Groups.MarmotGroupImageEncryption
|
||||
import com.vitorpamplona.quartz.marmot.mip01Groups.Mip01ImageCrypto
|
||||
import com.vitorpamplona.quartz.marmot.mls.codec.TlsReader
|
||||
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
|
||||
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
|
||||
import com.vitorpamplona.quartz.nip44Encryption.crypto.ChaCha20Poly1305
|
||||
@@ -41,62 +42,64 @@ class MarmotGroupImageTest {
|
||||
private val nostrGroupId = "aa".repeat(32)
|
||||
private val plaintext = "PNGDATA-a-fake-avatar-image-payload".encodeToByteArray()
|
||||
|
||||
// ------------------------------------------------------------ encryption
|
||||
private val emptyAad = ByteArray(0)
|
||||
|
||||
// ------------------------------------------------------------ encryption (MIP-01 v2)
|
||||
|
||||
@Test
|
||||
fun encrypt_thenDecrypt_roundTrips() {
|
||||
val enc = MarmotGroupImageEncryption.encrypt(plaintext, "image/png")
|
||||
val enc = MarmotGroupImageEncryption.encrypt(plaintext)
|
||||
|
||||
assertEquals(MarmotGroupImageEncryption.KEY_LENGTH, enc.imageKey.size)
|
||||
assertEquals(MarmotGroupImageEncryption.NONCE_LENGTH, enc.imageNonce.size)
|
||||
|
||||
val decrypted =
|
||||
MarmotGroupImageEncryption.decrypt(enc.ciphertext, enc.imageKey, enc.imageNonce, "image/png")
|
||||
val decrypted = MarmotGroupImageEncryption.decrypt(enc.ciphertext, enc.imageKey, enc.imageNonce)
|
||||
assertContentEquals(plaintext, decrypted)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun imageHash_isSha256OfCiphertext() {
|
||||
val enc = MarmotGroupImageEncryption.encrypt(plaintext, "image/jpeg")
|
||||
val enc = MarmotGroupImageEncryption.encrypt(plaintext)
|
||||
assertEquals(sha256(enc.ciphertext).toHexKey(), enc.imageHash)
|
||||
}
|
||||
|
||||
/**
|
||||
* Byte-for-byte interop guard: the AEAD key MUST be
|
||||
* HKDF(image_key, "mip01-image-encryption-v2") with empty AAD — exactly what mdk's
|
||||
* group_image.rs does. If this drifts, Amethyst and whitenoise stop interoperating.
|
||||
*/
|
||||
@Test
|
||||
fun mediaType_isCanonicalizedInAad() {
|
||||
// "IMAGE/PNG; charset=binary" canonicalizes to "image/png" — must still decrypt with "image/png".
|
||||
val enc = MarmotGroupImageEncryption.encrypt(plaintext, "IMAGE/PNG; charset=binary")
|
||||
val decrypted =
|
||||
MarmotGroupImageEncryption.decrypt(enc.ciphertext, enc.imageKey, enc.imageNonce, "image/png")
|
||||
assertContentEquals(plaintext, decrypted)
|
||||
fun scheme_matchesMdk_hkdfSeedAndEmptyAad() {
|
||||
val enc = MarmotGroupImageEncryption.encrypt(plaintext)
|
||||
val derivedKey = Mip01ImageCrypto.deriveImageEncryptionKey(enc.imageKey)
|
||||
val manual = ChaCha20Poly1305.decrypt(enc.ciphertext, emptyAad, enc.imageNonce, derivedKey)
|
||||
assertContentEquals(plaintext, manual)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun decrypt_wrongMediaType_fails() {
|
||||
val enc = MarmotGroupImageEncryption.encrypt(plaintext, "image/png")
|
||||
fun decrypt_wrongSeed_fails() {
|
||||
val enc = MarmotGroupImageEncryption.encrypt(plaintext)
|
||||
assertFailsWith<IllegalStateException> {
|
||||
MarmotGroupImageEncryption.decrypt(enc.ciphertext, enc.imageKey, enc.imageNonce, "image/jpeg")
|
||||
MarmotGroupImageEncryption.decrypt(enc.ciphertext, RandomInstance.bytes(32), enc.imageNonce)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun decryptAny_canonical_succeeds() {
|
||||
val enc = MarmotGroupImageEncryption.encrypt(plaintext, "image/webp")
|
||||
val out =
|
||||
MarmotGroupImageEncryption.decryptAny(enc.ciphertext, enc.imageKey, enc.imageNonce, "image/webp")
|
||||
fun decryptAny_v2_succeeds() {
|
||||
val enc = MarmotGroupImageEncryption.encrypt(plaintext)
|
||||
val out = MarmotGroupImageEncryption.decryptAny(enc.ciphertext, enc.imageKey, enc.imageNonce)
|
||||
assertNotNull(out)
|
||||
assertContentEquals(plaintext, out)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun decryptAny_fallsBackToDeprecatedHkdfScheme() {
|
||||
// Produce a blob with the DEPRECATED scheme: image_key is an HKDF seed, no AAD.
|
||||
val seed = RandomInstance.bytes(32)
|
||||
fun decryptAny_fallsBackToV1RawKey() {
|
||||
// v1: image_key is used directly as the AEAD key (no HKDF), empty AAD — mdk's fallback.
|
||||
val rawKey = RandomInstance.bytes(32)
|
||||
val nonce = RandomInstance.bytes(12)
|
||||
val legacyKey = Mip01ImageCrypto.deriveImageEncryptionKey(seed)
|
||||
val legacyBlob = ChaCha20Poly1305.encrypt(plaintext, ByteArray(0), nonce, legacyKey)
|
||||
val v1Blob = ChaCha20Poly1305.encrypt(plaintext, emptyAad, nonce, rawKey)
|
||||
|
||||
// decryptAny tries canonical first (seed-as-raw-key + media AAD → fails), then legacy.
|
||||
val out = MarmotGroupImageEncryption.decryptAny(legacyBlob, seed, nonce, "image/png")
|
||||
val out = MarmotGroupImageEncryption.decryptAny(v1Blob, rawKey, nonce)
|
||||
assertNotNull(out)
|
||||
assertContentEquals(plaintext, out)
|
||||
}
|
||||
@@ -108,35 +111,42 @@ class MarmotGroupImageTest {
|
||||
RandomInstance.bytes(64),
|
||||
RandomInstance.bytes(32),
|
||||
RandomInstance.bytes(12),
|
||||
"image/png",
|
||||
)
|
||||
assertNull(out)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun uploadKeypairSecret_isDeterministicAndDistinctFromSeed() {
|
||||
val seed = RandomInstance.bytes(32)
|
||||
val s1 = MarmotGroupImageEncryption.deriveUploadKeypairSecret(seed)
|
||||
val s2 = MarmotGroupImageEncryption.deriveUploadKeypairSecret(seed)
|
||||
assertEquals(32, s1.size)
|
||||
assertContentEquals(s1, s2)
|
||||
assertTrue(!s1.contentEquals(seed), "upload secret must be derived, not the raw seed")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun cipher_encryptDecrypt_roundTrips_asUsedByUploadAndDisplay() {
|
||||
// The upload path builds a fresh cipher, encrypts, and stores its key/nonce;
|
||||
// the display path rebuilds the same cipher from those fields and decrypts.
|
||||
val uploadCipher = MarmotGroupImageCipher.forNewImage("image/png")
|
||||
val uploadCipher = MarmotGroupImageCipher.forNewImage()
|
||||
val blob = uploadCipher.encrypt(plaintext)
|
||||
|
||||
val displayCipher = MarmotGroupImageCipher(uploadCipher.imageKey, uploadCipher.imageNonce, "image/png")
|
||||
val displayCipher = MarmotGroupImageCipher(uploadCipher.imageKey, uploadCipher.imageNonce)
|
||||
assertContentEquals(plaintext, displayCipher.decrypt(blob))
|
||||
assertContentEquals(plaintext, displayCipher.decryptOrNull(blob))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun cipher_decryptOrNull_wrongKey_returnsNull() {
|
||||
val uploadCipher = MarmotGroupImageCipher.forNewImage("image/png")
|
||||
fun cipher_decryptOrNull_wrongSeed_returnsNull() {
|
||||
val uploadCipher = MarmotGroupImageCipher.forNewImage()
|
||||
val blob = uploadCipher.encrypt(plaintext)
|
||||
val wrong = MarmotGroupImageCipher(RandomInstance.bytes(32), uploadCipher.imageNonce, "image/png")
|
||||
val wrong = MarmotGroupImageCipher(RandomInstance.bytes(32), uploadCipher.imageNonce)
|
||||
assertNull(wrong.decryptOrNull(blob))
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------ wire format
|
||||
|
||||
@Test
|
||||
fun wire_roundTrips_withImageAndMediaType() {
|
||||
fun wire_roundTrips_withImage() {
|
||||
val original =
|
||||
MarmotGroupData(
|
||||
version = 2,
|
||||
@@ -149,17 +159,14 @@ class MarmotGroupImageTest {
|
||||
imageKey = "dd".repeat(32).hexToByteArray(),
|
||||
imageNonce = "ee".repeat(12).hexToByteArray(),
|
||||
imageUploadKey = "ff".repeat(32).hexToByteArray(),
|
||||
imageMediaType = "image/png",
|
||||
)
|
||||
|
||||
val decoded = assertNotNull(MarmotGroupData.decodeTls(original.encodeTls()))
|
||||
assertEquals("Otters", decoded.name)
|
||||
assertEquals("river friends", decoded.description)
|
||||
assertEquals("cc".repeat(32), decoded.imageHash)
|
||||
assertContentEquals("dd".repeat(32).hexToByteArray(), decoded.imageKey)
|
||||
assertContentEquals("ee".repeat(12).hexToByteArray(), decoded.imageNonce)
|
||||
assertContentEquals("ff".repeat(32).hexToByteArray(), decoded.imageUploadKey)
|
||||
assertEquals("image/png", decoded.imageMediaType)
|
||||
assertNull(decoded.disappearingMessageSecs)
|
||||
assertTrue(decoded.hasImage())
|
||||
}
|
||||
@@ -176,11 +183,51 @@ class MarmotGroupImageTest {
|
||||
)
|
||||
val decoded = assertNotNull(MarmotGroupData.decodeTls(original.encodeTls()))
|
||||
assertEquals("Plain", decoded.name)
|
||||
assertNull(decoded.imageMediaType)
|
||||
assertNull(decoded.imageHash)
|
||||
assertTrue(!decoded.hasImage())
|
||||
}
|
||||
|
||||
/**
|
||||
* INTEROP GUARD: at version 2, a group WITH an image must serialize with the image
|
||||
* fields as the LAST fields and ZERO trailing bytes — exactly what mdk-core's
|
||||
* `TlsNostrGroupDataExtensionV1V2` parser consumes. mdk rejects any trailing bytes at a
|
||||
* known version, so a stray byte here silently breaks the group for whitenoise/mdk
|
||||
* members. This test reproduces mdk's v1/v2 field consumption and asserts nothing is
|
||||
* left over.
|
||||
*/
|
||||
@Test
|
||||
fun wire_v2WithImage_hasNoTrailingBytesForMdk() {
|
||||
val withImage =
|
||||
MarmotGroupData(
|
||||
version = 2,
|
||||
nostrGroupId = nostrGroupId,
|
||||
name = "Compat",
|
||||
description = "d",
|
||||
adminPubkeys = listOf("bb".repeat(32)),
|
||||
relays = listOf("wss://relay.example/"),
|
||||
imageHash = "cc".repeat(32),
|
||||
imageKey = "dd".repeat(32).hexToByteArray(),
|
||||
imageNonce = "ee".repeat(12).hexToByteArray(),
|
||||
imageUploadKey = "ff".repeat(32).hexToByteArray(),
|
||||
)
|
||||
|
||||
val reader = TlsReader(withImage.encodeTls())
|
||||
reader.readUint16() // version
|
||||
reader.readBytes(32) // nostr_group_id
|
||||
reader.readOpaqueVarInt() // name
|
||||
reader.readOpaqueVarInt() // description
|
||||
reader.readOpaqueVarInt() // admin_pubkeys
|
||||
reader.readOpaqueVarInt() // relays
|
||||
reader.readOpaqueVarInt() // image_hash
|
||||
reader.readOpaqueVarInt() // image_key
|
||||
reader.readOpaqueVarInt() // image_nonce
|
||||
reader.readOpaqueVarInt() // image_upload_key
|
||||
assertTrue(
|
||||
!reader.hasRemaining,
|
||||
"v2 image extension has trailing bytes past image_upload_key → mdk would reject it",
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun wire_roundTrips_v3Disappearing_withImage() {
|
||||
val original =
|
||||
@@ -194,38 +241,13 @@ class MarmotGroupImageTest {
|
||||
imageKey = "dd".repeat(32).hexToByteArray(),
|
||||
imageNonce = "ee".repeat(12).hexToByteArray(),
|
||||
imageUploadKey = "ff".repeat(32).hexToByteArray(),
|
||||
imageMediaType = "image/jpeg",
|
||||
disappearingMessageSecs = 3600UL,
|
||||
)
|
||||
val decoded = assertNotNull(MarmotGroupData.decodeTls(original.encodeTls()))
|
||||
assertEquals(3600UL, decoded.disappearingMessageSecs)
|
||||
assertEquals("image/jpeg", decoded.imageMediaType)
|
||||
assertTrue(decoded.hasImage())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun wire_v2WithMediaType_readByLegacyDecoderIgnoresTrailing() {
|
||||
// Emitting media_type at v2 forces an empty disappearing field to keep alignment.
|
||||
// A reader that stops at disappearing (older logic) must still parse cleanly and
|
||||
// see no disappearing timer.
|
||||
val withImage =
|
||||
MarmotGroupData(
|
||||
version = 2,
|
||||
nostrGroupId = nostrGroupId,
|
||||
name = "Compat",
|
||||
adminPubkeys = listOf("bb".repeat(32)),
|
||||
relays = emptyList(),
|
||||
imageHash = "cc".repeat(32),
|
||||
imageKey = "dd".repeat(32).hexToByteArray(),
|
||||
imageNonce = "ee".repeat(12).hexToByteArray(),
|
||||
imageUploadKey = "ff".repeat(32).hexToByteArray(),
|
||||
imageMediaType = "image/png",
|
||||
)
|
||||
val decoded = assertNotNull(MarmotGroupData.decodeTls(withImage.encodeTls()))
|
||||
assertNull(decoded.disappearingMessageSecs)
|
||||
assertEquals("image/png", decoded.imageMediaType)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun withImage_andWithoutImage_helpers() {
|
||||
val base =
|
||||
@@ -234,15 +256,12 @@ class MarmotGroupImageTest {
|
||||
name = "Base",
|
||||
adminPubkeys = listOf("bb".repeat(32)),
|
||||
)
|
||||
val enc = MarmotGroupImageEncryption.encrypt(plaintext, "image/png")
|
||||
val withImg =
|
||||
base.withImage(enc.imageHash, enc.imageKey, enc.imageNonce, RandomInstance.bytes(32), "image/png")
|
||||
val enc = MarmotGroupImageEncryption.encrypt(plaintext)
|
||||
val withImg = base.withImage(enc.imageHash, enc.imageKey, enc.imageNonce, RandomInstance.bytes(32))
|
||||
assertTrue(withImg.hasImage())
|
||||
assertEquals("image/png", withImg.imageMediaType)
|
||||
|
||||
val cleared = withImg.withoutImage()
|
||||
assertTrue(!cleared.hasImage())
|
||||
assertNull(cleared.imageMediaType)
|
||||
assertNull(cleared.imageUploadKey)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user