Skip to content

MIP-06 multi-device support#251

Open
dannym-arx wants to merge 17 commits into
masterfrom
multi-device
Open

MIP-06 multi-device support#251
dannym-arx wants to merge 17 commits into
masterfrom
multi-device

Conversation

@dannym-arx

@dannym-arx dannym-arx commented Apr 7, 2026

Copy link
Copy Markdown
Contributor

marmot

WIP implementation of MIP-06: multi-device support for Marmot groups.

One marmot, many burrow entrances. This adds the protocol machinery for a single Nostr identity to participate in MLS groups from multiple devices at once.

What's here

  • New mip06 feature flag gating all multi-device code behind x25519-dalek
  • MLS extensions: marmot_multi_device (0xF2F0) for GroupContext and device_name (0xF2EF) for per-leaf metadata
  • X25519 ECDH pairing protocol: ephemeral key exchange between existing and new device, encrypted channel for transferring group state
  • Nostr identity proofs: cryptographic binding between device MLS credentials and the owner's Nostr keypair
  • Group APIs: create_group_with_multi_device, enable_multi_device, add_device_to_groups
  • coalesced_members and own_device_leaves views that group leaf indices by Nostr pubkey
  • Simplified create_message and build_message_event signatures (removed unused EventTag parameter)
  • Removed remove_group from EpochSnapshotManager (dead code)
  • Cleaned up unused storage trait methods and their implementations in memory/sqlite crates

⚠️ Security-sensitive changes

This WIP implements MIP-06 multi-device support so a single Nostr identity can participate in MLS groups from multiple independent devices, adding X25519-based ephemeral pairing, encrypted pairing messages, deterministic join-PSK derivation, and Nostr identity proofs that cryptographically bind device MLS credentials to an owner’s Nostr keypair. It is feature-gated behind a new mip06 Cargo feature and exposes core APIs and UniFFI bindings to create/enable multi-device groups, perform device pairing (add- or External-Commit-based), and manage per-device leaves and PSKs.

What changed:

  • Added workspace optional dependency x25519-dalek v2.0 and a new crate feature mip06, and wired mip06 through mdk-core and mdk-uniffi (crates affected: mdk-core, mdk-uniffi, tests/helpers touched; storage-traits, mdk-memory-storage, and mdk-sqlite-storage had minor cleanup noted).
  • Introduced two MLS extension types: marmot_multi_device (0xF2F0) for GroupContext signaling and device_name (0xF2EF) for encrypted per-leaf device labels using NIP-44.
  • Added a new public mdk-core::mip06 module with submodules device_name, extension, identity_proof, join_psk, pairing, and pairing_crypto implementing TLS-serializable pairing payloads, pairing-wire formats, and pairing crypto helpers.
  • Implemented X25519 ephemeral ECDH pairing with HKDF-SHA256 key derivation, ChaCha20-Poly1305 AEAD encryption using 12-byte nonces, AAD construction tying ephemeral keys, and explicit rejection of all-zero shared secrets.
  • Added deterministic join-PSK derivation and JoinPskId type derived from TLS-serialized GroupContext hashes and integrated exporter usage and External PSK registration.
  • Implemented Nostr identity proofs that compute a SHA-256 challenge, build a canonical Nostr proof event (kind 450) with fixed fields, sign via Schnorr, serialize as authenticated-data, and verify during External Commit processing.
  • Added multi-device group flows and public APIs on MDK: create_group_with_multi_device, enable_multi_device, add_device_to_groups, register_join_psk, build_pairing_payload, join_group_via_external_commit, coalesced_members, own_device_leaves, and the ExternalCommitResult type (all feature-gated).
  • Updated message processing/validation to accept authenticated_data, added validate_external_commit_authorization and validate_external_commit_identity_proof for External Commits, and register join PSKs during commit-merge flows.
  • Exposed UniFFI bindings and types for the new mip06 APIs and added an mdk-uniffi mip06 feature that exports multi-group and per-group outcome types.
  • Minor cleanup: removed dead remove_group from EpochSnapshotManager, trimmed unused storage trait methods/impls, simplified create_message/build_message_event signatures, and adjusted tests to be feature-aware.

Security impact:

  • Cryptography: introduces X25519 ECDH, HKDF-SHA256 with fixed salt/info strings, ChaCha20-Poly1305 AEAD with 12-byte nonces, AAD tying ephemeral keys, and explicit checks against all-zero shared secrets.
  • Key handling: generates per-device X25519 keypairs, moves secret material through pairing flows, and stores/transfers join PSKs as External PSKs in provider storage.
  • Identity binding: External Commit flows include Nostr identity proofs binding joining credentials to an existing Nostr pubkey and require exactly one PreSharedKey proposal in staged commits.
  • Error/leakage: no deliberate SQLCipher or file-permission changes detected; review flagged potential risks around PSK store growth, zeroization across UniFFI/FFI boundaries, and accidental sensitive-data exposure in debug/logs.
  • Review notes: automated review surfaced multiple findings (PSK lifecycle, admin-authorization checks, FFI zeroization, pending-commit race cases); the author addressed or disputed several items but some remediation proposals (PSK cleanup across epochs, explicit admin-authorization confirmation, PSK store pruning) remain for maintainers to decide.

Protocol changes:

  • Implements MIP-06 multi-device flows including versioned pairing payloads (GroupPairingDataV1, PairingPayload), DevicePairingRequest/Response and GroupWelcomeData for add-based flows, and External Commit join using an exporter-derived join-PSK consistent with RFC 9420 semantics.
  • MLS signaling: required_capabilities can advertise marmot_multi_device and device_name, GroupContext extension and capability checks gate multi-device behavior, and group APIs support injecting the multi-device extension at creation or enablement time.
  • Nostr integration: identity proofs use a canonical Nostr event (kind 450) for signatures and NIP-44 for encrypted device labels, and the challenge ties credential_identity, signature_key, and serialized GroupContext.

API surface:

  • Breaking/changed signatures: MDK::process_commit now accepts an additional authenticated_data: &[u8] parameter (consumed conditionally under mip06).
  • New public, feature-gated MDK methods: create_group_with_multi_device, enable_multi_device, add_device_to_groups, register_join_psk, build_pairing_payload, join_group_via_external_commit, coalesced_members, own_device_leaves.
  • New public types: ExternalCommitResult, NostrIdentityProof, JoinPskId, EncryptedDeviceName, PairingMessage and pairing wire types, and UniFFI-exposed ExternalCommitJoinResult, DevicePairingGroupResult, CoalescedMember.
  • UniFFI: mdk-uniffi adds a mip06 feature and exports the multi-device APIs with input validation/conversion at the FFI boundary.
  • Storage/schema: provider storage is used to record External PSKs and join-PSK metadata, creating a potential need for PSK lifecycle management to avoid unbounded growth.

Testing:

  • Many unit and integration tests added within mdk-core::mip06 covering TLS serialization roundtrips, version validation, pairing encrypt/decrypt, join-PSK derivation, identity-proof construct/verify (positive and negative cases), add-based pairing exchange, External Commit join end-to-end, coalesced_members, and own_device_leaves.
  • Existing tests (key-package, mls_extensions validation) were updated to be feature-aware for mip06.

Notable observations:

  • PR is WIP and includes pixel-art; an automated reviewer produced a long line-by-line report and the author replied addressing several points (notably PSK-type and GroupContext offset clarifications), while maintainers should evaluate remaining open items around PSK cleanup, explicit admin-authorization semantics for enable_multi_device, pending-commit lifecycle/race conditions, and secure secret handling across the UniFFI boundary.

@coderabbitai

coderabbitai Bot commented Apr 7, 2026

Copy link
Copy Markdown

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds MIP‑06 multi‑device support behind a new mip06 feature: workspace dependency x25519-dalek, feature wiring in core and uniffi crates, two MLS extension types, new MIP‑06 modules (device name, extension, identity proof, join‑PSK, pairing payloads, pairing crypto), multi‑device group APIs and UniFFI bindings, and external‑commit validation integrated into message processing.

Changes

Cohort / File(s) Summary
Workspace & Feature Flags
Cargo.toml, crates/mdk-core/Cargo.toml, crates/mdk-uniffi/Cargo.toml
Added workspace dependency x25519-dalek = { version = "2.0", default-features = false, features = ["static_secrets", "zeroize"] }; added mip06 feature and wired optional dependency usage in core and uniffi crates.
Core constants & errors
crates/mdk-core/src/constant.rs, crates/mdk-core/src/error.rs
Introduced MULTI_DEVICE_EXTENSION_TYPE and DEVICE_NAME_EXTENSION_TYPE; made SUPPORTED_EXTENSIONS/TAG_EXTENSIONS feature-dependent; added feature-gated error variants MultiDeviceNotEnabled, PairingError(String), IdentityProofError(String).
MIP‑06 module & lib wiring
crates/mdk-core/src/lib.rs, crates/mdk-core/src/mip06/mod.rs
Exported mip06 module; added required_capabilities_extension_with_multi_device() and top-level mip06 re-exports.
MIP‑06 implementation modules
crates/mdk-core/src/mip06/device_name.rs, .../extension.rs, .../identity_proof.rs, .../join_psk.rs, .../pairing.rs, .../pairing_crypto.rs
New modules implementing encrypted device names (NIP‑44), marmot_multi_device extension parsing/validation, Nostr identity proof construction/verification, join‑PSK derivation/(de)serialization, versioned pairing payload structs, and X25519/HKDF/AEAD pairing message crypto plus helpers/tests.
Group lifecycle & APIs
crates/mdk-core/src/groups.rs
Refactored group creation helpers; added feature-gated multi‑device APIs: create_group_with_multi_device, enable_multi_device, add_device_to_groups, register_join_psk, build_pairing_payload, join_group_via_external_commit, coalesced_members, own_device_leaves; register join‑PSK on merge.
Message processing & validation
crates/mdk-core/src/messages/commit.rs, .../process.rs, .../validation.rs
process_commit gains authenticated_data param; dispatch now captures AAD and passes it; added external‑commit authorization and identity‑proof validation helpers and validation branch for NewMemberCommit (feature-gated).
Key packages & tests
crates/mdk-core/src/key_packages.rs, crates/mdk-core/src/mip06/*
Test helpers updated to be feature-aware for MLS extension tags; added unit and integration tests exercising MIP‑06 flows and modules.
UniFFI bindings & changelogs
crates/mdk-uniffi/src/lib.rs, crates/mdk-uniffi/Cargo.toml, crates/mdk-core/CHANGELOG.md, crates/mdk-uniffi/CHANGELOG.md
Added mip06 feature to UniFFI crate; exported feature-gated UniFFI APIs, result/record types, and updated changelogs.

Sequence Diagram

sequenceDiagram
    participant Device
    participant MDK
    participant MLS
    participant Crypto
    participant Proof

    Device->>MDK: Request PairingPayload (group IDs)
    MDK->>MLS: Export GroupInfo / GroupContext
    MDK->>MDK: Derive join PSK & group_event_key
    MDK-->>Device: Return PairingPayload

    Device->>Crypto: Generate ephemeral X25519 keypair
    Device->>Device: Build External Commit (uses join PSK)
    Device->>Proof: Compute challenge from GroupContext
    Device->>Proof: Sign challenge -> Nostr identity proof
    Device->>Device: Embed proof into authenticated_data

    Device-->>MDK: Send External Commit + authenticated_data
    MDK->>Proof: Parse & verify identity proof from AAD
    MDK->>MLS: Process staged external commit
    MDK->>MDK: Validate joining identity matches existing member
    MDK->>MLS: Merge staged commit
    MDK->>MDK: Register join PSK for new epoch
    MDK-->>Device: Confirm join
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Possibly related PRs

Suggested labels

security, breaking-change, mls-protocol, storage

Suggested reviewers

  • erskingardner
  • jgmontoya
  • mubarakcoded
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The pull request title 'MIP-06 multi-device support' clearly summarizes the main change: implementation of MIP-06 specification for multi-device support in the codebase, enabling a single Nostr identity to participate in MLS groups from multiple devices.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
No Sensitive Identifier Leakage ✅ Passed All custom Debug implementations for sensitive types redact sensitive fields, and no logging or panic calls interpolate GroupId, MLS group IDs, or encryption secrets.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch multi-device

Comment @coderabbitai help to get the list of available commands and usage tips.

@dannym-arx dannym-arx changed the title [Very WIP] MIP06 WIP: MIP-06 multi-device support Apr 7, 2026
@marmot-protocol marmot-protocol deleted a comment from blacksmith-sh Bot Apr 7, 2026
@marmot-protocol marmot-protocol deleted a comment from blacksmith-sh Bot Apr 7, 2026
@marmot-protocol marmot-protocol deleted a comment from blacksmith-sh Bot Apr 7, 2026
@marmot-protocol marmot-protocol deleted a comment from blacksmith-sh Bot Apr 7, 2026
@github-actions

github-actions Bot commented Apr 8, 2026

Copy link
Copy Markdown

UniFFI Binding Coverage

1 public method(s) not bound in UniFFI

Coverage: 50/51 bindable methods (98.0%)

⚠️ Not Bound

Method Source
join_group_via_external_commit crates/mdk-core/src/groups.rs:1725
✅ Bound methods (50)
Method Source
accept_welcome crates/mdk-core/src/welcomes.rs:315
add_device_to_groups crates/mdk-core/src/groups.rs:1584
add_members crates/mdk-core/src/groups.rs:901
build_pairing_payload crates/mdk-core/src/groups.rs:1675
clear_pending_commit crates/mdk-core/src/groups.rs:2323
coalesced_members crates/mdk-core/src/groups.rs:653
create_group crates/mdk-core/src/groups.rs:1346
create_group_with_multi_device crates/mdk-core/src/groups.rs:1372
create_key_package_for_event crates/mdk-core/src/key_packages.rs:88
create_key_package_for_event_with_options crates/mdk-core/src/key_packages.rs:127
create_message crates/mdk-core/src/messages/create.rs:110
decline_welcome crates/mdk-core/src/welcomes.rs:350
decrypt_group_image crates/mdk-core/src/extension/group_image.rs:229
delete_group crates/mdk-core/src/groups.rs:2302
delete_key_package_from_storage crates/mdk-core/src/key_packages.rs:731
delete_key_package_from_storage_by_hash_ref crates/mdk-core/src/key_packages.rs:746
delete_messages_for_group crates/mdk-core/src/groups.rs:2282
derive_upload_keypair crates/mdk-core/src/extension/group_image.rs:334
enable_multi_device crates/mdk-core/src/groups.rs:1525
get_group crates/mdk-core/src/groups.rs:588
get_groups crates/mdk-core/src/groups.rs:600
get_last_message crates/mdk-core/src/messages/mod.rs:289
get_members crates/mdk-core/src/groups.rs:634
get_message crates/mdk-core/src/messages/mod.rs:222
get_messages crates/mdk-core/src/messages/mod.rs:261
get_pending_welcomes crates/mdk-core/src/welcomes.rs:72
get_ratchet_tree_info crates/mdk-core/src/groups.rs:731
get_relays crates/mdk-core/src/groups.rs:1292
get_welcome crates/mdk-core/src/welcomes.rs:40
group_leaf_map crates/mdk-core/src/groups.rs:697
groups_needing_self_update crates/mdk-core/src/groups.rs:618
leave_group crates/mdk-core/src/groups.rs:2234
merge_pending_commit crates/mdk-core/src/groups.rs:2374
own_device_leaves crates/mdk-core/src/groups.rs:667
own_leaf_index crates/mdk-core/src/groups.rs:688
parse_key_package crates/mdk-core/src/key_packages.rs:330
pending_added_members_pubkeys crates/mdk-core/src/groups.rs:787
pending_member_changes crates/mdk-core/src/groups.rs:855
pending_removed_members_pubkeys crates/mdk-core/src/groups.rs:820
prepare_group_image_for_upload crates/mdk-core/src/extension/group_image.rs:416
prepare_group_image_for_upload_with_options crates/mdk-core/src/extension/group_image.rs:463
process_message crates/mdk-core/src/messages/process.rs:340
process_message_with_context crates/mdk-core/src/messages/process.rs:347
process_welcome crates/mdk-core/src/welcomes.rs:178
register_join_psk crates/mdk-core/src/groups.rs:1632
remove_members crates/mdk-core/src/groups.rs:1002
self_demote crates/mdk-core/src/groups.rs:2180
self_update crates/mdk-core/src/groups.rs:2010
sync_group_metadata_from_mls crates/mdk-core/src/groups.rs:2451
update_group_data crates/mdk-core/src/groups.rs:1226
➖ Intentionally excluded (5)
Method Reason
builder Wrapped by new_mdk(), new_mdk_with_key(), new_mdk_unencrypted() free functions
load_mls_group Only available behind debug-examples feature flag, not for production use
media_manager Returns borrowed reference with lifetime — cannot cross FFI boundary
migrate_group_image_v1_to_v2 Migration utility for internal use only, not needed in client bindings
new Wrapped by new_mdk(), new_mdk_with_key(), new_mdk_unencrypted() free functions

@github-actions

github-actions Bot commented Apr 8, 2026

Copy link
Copy Markdown

❌ Coverage: 93.7% → 93.1% (-0.60%)

@github-actions

Copy link
Copy Markdown

❌ Coverage: 93.68% → 93.04% (-0.64%)

@github-actions

Copy link
Copy Markdown

❌ Coverage: 93.68% → 92.91% (-0.77%)

@github-actions

Copy link
Copy Markdown

❌ Coverage: 93.68% → 92.99% (-0.69%)

@github-actions

Copy link
Copy Markdown

❌ Coverage: 93.68% → 93.02% (-0.66%)

@github-actions

Copy link
Copy Markdown

❌ Coverage: 93.68% → 92.99% (-0.69%)

@github-actions

Copy link
Copy Markdown

❌ Coverage: 93.68% → 93% (-0.68%)

@dannym-arx dannym-arx marked this pull request as ready for review April 13, 2026 16:48
@dannym-arx

Copy link
Copy Markdown
Contributor Author

@CodeRabbit We need an extremely deep review here.

make sure the PR is fully following the spec marmot-protocol/marmot#44

analyze every line of code, and if you find enough issues, you'll get a special golden carrot

@dannym-arx dannym-arx changed the title WIP: MIP-06 multi-device support \MIP-06 multi-device support Apr 13, 2026
@dannym-arx dannym-arx changed the title \MIP-06 multi-device support MIP-06 multi-device support Apr 13, 2026
@coderabbitai

coderabbitai Bot commented Apr 13, 2026

Copy link
Copy Markdown

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

The PR description image shows a charming pixel-art marmot sitting at a wooden desk in a cozy underground burrow, surrounded by three devices (phone, desktop computer, and tablet), all showing encrypted chat messages between "Alice" and "Bob." Glowing padlock icons float between the devices, illustrating the multi-device secure messaging concept. The burrow is lit by lanterns with scrolls and a map on the shelves — a fitting visual for MIP-06 multi-device support.

Let me fetch the MIP-06 spec and deeply analyze all the PR code simultaneously.

I need to read the actual spec and all the code. Let me fetch them now.


🏁 Script executed:

#!/bin/bash
# Get the full list of files changed in the PR
gh pr view 251 --repo marmot-protocol/mdk --json files --jq '.files[].path'

Length of output: 718


🏁 Script executed:

#!/bin/bash
# Get the full PR diff
gh pr diff 251 --repo marmot-protocol/mdk

Length of output: 140872


🏁 Script executed:

#!/bin/bash
# Fetch the MIP-06 spec from the marmot-protocol/marmot PR `#44`
gh pr view 44 --repo marmot-protocol/marmot --json title,body | jq -r '.title, .body'

Length of output: 4069


🏁 Script executed:

#!/bin/bash
# Get all files changed in PR `#251`
gh pr view 251 --repo marmot-protocol/mdk --json files --jq '.files[].path'

Length of output: 718


@dannym-arx Here's the deep spec-compliance review. I fetched the MIP-06 spec from marmot-protocol/marmot#44 and compared it line-by-line against every changed file. 🐾🥕


🔴 Critical Issues (Wire-Level Protocol Deviations)

1. Wrong PSK Type: External PSK vs. Resumption PSK

The spec says (marmot-protocol/marmot#44):

exactly one Resumption PSK proposal (usage = application)

The implementation uses External PSK throughout:

crates/mdk-core/src/groups.rsregister_join_psk():

let psk = Psk::External(ExternalPsk::new(join_psk_id_bytes)); // ← WRONG TYPE

crates/mdk-core/src/groups.rsjoin_group_via_external_commit():

let psk = Psk::External(ExternalPsk::new(join_psk_id_bytes)); // ← WRONG TYPE

These are entirely different MLS structures. In RFC 9420, a Resumption PSK (psktype = resumption) encodes (usage, psk_group_id, psk_epoch) and is derived from a previous group epoch's keying material. An External PSK (psktype = external) is an out-of-band key. They have different TLS wire encodings and different semantic guarantees. Using the wrong type means the External Commit will be structurally incompatible with any other spec-compliant MIP-06 implementation.

The spec also calls this value resumption_psk in the pairing payload, but the implementation names it join_psk and derives it as an External PSK. The naming reinforces the divergence.

The implementation even documents the gap itself in validation.rs:

// full PSK ID inspection is blocked by PreSharedKeyProposal::into_psk_id()
// being pub(crate) in OpenMLS.

This means the validator cannot even verify the PSK type of the received proposal — it only checks the count. This leaves condition 5 of the External Commit authorization incompletely validated.


2. Fragile Raw-Byte Offset Hack for GroupContext Extraction

crates/mdk-core/src/groups.rsexport_group_context_bytes():

// MlsMessage header: version (2 bytes) + wire_format (2 bytes) = 4 bytes.
// GroupInfoTBS starts at offset 4, and its first field is GroupContext.
let (group_context, _) =
    GroupContext::tls_deserialize_bytes(&gi_bytes[4..])...

And again in join_group_via_external_commit():

let gc_payload = &gi_bytes[4..];
let (_, remainder) =
    <GroupContext as tls_codec::DeserializeBytes>::tls_deserialize_bytes(gc_payload)...
let gc_bytes = &gc_payload[..gc_payload.len() - remainder.len()];

The spec defines the PSK ID as SHA-256(TLS-serialized GroupContext). The correctness of this entire scheme depends on extracting the exact same bytes the peer would serialize. The [4..] offset is not guaranteed by any MLS spec or OpenMLS API contract — it relies on OpenMLS's internal wire format staying stable. If OpenMLS changes its MlsMessage header layout (e.g., adds a length prefix, or the wire_format discriminant changes size), this will silently produce the wrong bytes, causing PSK ID mismatches and every External Commit to fail in a very hard-to-debug way. This needs a proper OpenMLS API path, not a byte-slicing hack.

Additionally, build_pairing_payload exports GroupInfo with ratchet_tree=true (larger bytes), while export_group_context_bytes exports with false. The offset 4 still points to GroupInfoTBS in both cases, so the extraction is consistent — but this implicit coupling is extremely fragile.


🟠 High Severity Issues

3. enable_multi_device Lacks Admin Authorization Check

crates/mdk-core/src/groups.rsenable_multi_device():

pub fn enable_multi_device(&self, group_id: &GroupId) -> Result<Event, Error> {
    let mut mls_group = self.load_mls_group(group_id)?.ok_or(Error::GroupNotFound)?;
    let signer = self.load_mls_signer(&mls_group)?;

    if crate::mip06::is_multi_device_enabled(&mls_group) {
        return Err(...);
    }
    // ← NO ADMIN CHECK HERE
    let (commit_message, _, _group_info) =
        mls_group.update_group_context_extensions(...)?;

Any group member can call this and generate a GroupContextExtensions commit attempting to add 0xF2F0. Other existing methods like update_group_data call prune_and_validate_admin_update before committing. enable_multi_device skips this entirely. OpenMLS will technically reject the commit if the sender is not an admin per the group's own authorization rules — but only at the point where other members process it. The local member should be rejected at proposal-building time.


4. Mixed-Case Hex Test Now Tests Nothing

crates/mdk-core/src/key_packages.rs, test for mixed-case parsing:

// BEFORE (tests the actual mixed-case behavior):
Tag::custom(TagKind::MlsExtensions, ["0x000a", "0xF2Ee"]), // Mixed case

// AFTER (now all lowercase — test is vacuous):
Tag::custom(TagKind::MlsExtensions, minimal_extensions_tag_values()), // Mixed case

The comment still says "Mixed case" but minimal_extensions_tag_values() returns all-lowercase values. The test no longer actually exercises the mixed-case parsing path it was designed to cover.


5. External Commit Integration Test Bypasses MDK's Validation Pipeline

crates/mdk-core/src/mip06/mod.rstest_external_commit_join_flow():

// The test calls OpenMLS directly, NOT through process_message:
let processed = mls_group
    .process_message(&alice_device1.provider, protocol_msg)
    .expect("Device 1 should accept the External Commit");

// Then manually handles the staged commit:
match processed.into_content() {
    ProcessedMessageContent::StagedCommitMessage(staged_commit) => {
        mls_group.merge_staged_commit(...)...

This bypasses MDK::process_message() entirely. As a result, validate_external_commit_authorization() and validate_external_commit_identity_proof() — the two new MIP-06 validation functions — are never called in any test. The entire identity proof verification path is untested end-to-end.


6. Secret Key Passed as Plaintext String Across FFI Boundary Without Zeroization

crates/mdk-uniffi/src/lib.rsjoin_groups_via_external_commit():

pub fn join_groups_via_external_commit(
    &self,
    pairing_payload_bytes: Vec<u8>,
    nostr_secret_key_hex: String,  // ← raw secret key in heap-allocated String
) -> Result<Vec<ExternalCommitJoinResult>, MdkUniffiError> {
    let secret_key = nostr::SecretKey::parse(&nostr_secret_key_hex)...
    let keys = nostr::Keys::new(secret_key);
    // nostr_secret_key_hex is never zeroized

The Nostr secret key is passed as a heap-allocated String through the FFI layer. It is never zeroized. This leaves the secret key lingering in memory until the allocator happens to reuse the memory. Other UniFFI methods that handle sensitive key material use zeroize — this one doesn't.


7. Old-Epoch PSKs Are Never Cleaned Up

crates/mdk-core/src/groups.rsregister_join_psk():

psk_id
    .store(&self.provider, &join_psk_bytes)
    .map_err(|e| Error::PairingError(...))?;

This is called after every epoch change in every MIP-06-enabled group (in commit.rs, process.rs, and groups.rs). Each call registers a new PSK keyed by SHA-256(current GroupContext). Previous epoch PSKs are never deleted. In a long-lived group with frequent commits, this results in unbounded growth of the PSK store. The spec doesn't define retention policy, but prudent hygiene is to delete the previous epoch's PSK after successfully advancing to the next epoch.


🟡 Medium Severity Issues

8. own_device_leaves Loads MLS Group Twice

crates/mdk-core/src/groups.rs:

pub fn own_device_leaves(&self, group_id: &GroupId) -> Result<Vec<u32>, Error> {
    let group = self.load_mls_group(group_id)?.ok_or(Error::GroupNotFound)?; // load `#1`
    let own_pubkey = self.get_own_pubkey(&group)?;
    let mut coalesced = self.coalesced_members(group_id)?; // calls group_leaf_map → load `#2`
    Ok(coalesced.remove(&own_pubkey).unwrap_or_default())
}

group_leaf_map internally loads the group again. This is a redundant round-trip through the storage layer. own_device_leaves should call group_leaf_map once and extract both the own pubkey and the coalesced map in a single pass.


9. DevicePairingRequest Field Naming Mismatch

crates/mdk-core/src/mip06/pairing.rs:

pub struct DevicePairingRequest {
    version: u16,
    key_package: Vec<u8>,  // ← actually holds Nostr Event JSON bytes
}
// ...
pub fn key_package_bytes(&self) -> &[u8] {
    &self.key_package
}

In the integration test:

let kp_bytes = super::DevicePairingRequest::new(device2_kp_event.as_json().into_bytes())...
let kp_event: nostr::Event = nostr::Event::from_json(
    std::str::from_utf8(request.key_package_bytes()).unwrap()
).unwrap();

The field is named key_package and the getter is key_package_bytes(), strongly implying TLS-encoded MLS KeyPackage bytes. But it actually holds a JSON-serialized Nostr Event. This is the same confusion that has burned reviewers in other parts of MDK. The field should be named key_package_event or key_package_event_json.


10. Module-Level Doc Still Describes Only Add-Based Flow as "The Pairing Flow"

crates/mdk-core/src/mip06/mod.rs:

//! The pairing flow is a two-phase encrypted exchange:
//! 1. New device → Existing device: KeyPackage
//! 2. Existing device → New device: Welcome messages (one per group)

The spec-compliant flow (External Commit) is entirely absent from the module documentation. The described flow is the Add-based workaround. A reader of the module docs would not know there are two flows, which is primary, or that the Add-based flow is a workaround pending OpenMLS limitations.


11. is_multi_device_enabled Condition 3 Is Inferred, Not Verified Per-Leaf

crates/mdk-core/src/mip06/extension.rs:

/// For condition 3: when `required_capabilities` mandates `0xF2F0`, MLS validation
/// ensures all members must advertise it. Conditions 1+2 therefore imply condition 3
/// for groups in a valid state that have at least one member.
pub fn is_multi_device_enabled(group: &MlsGroup) -> bool {
    let ext_present = matches!(MarmotMultiDevice::from_group(group), Ok(Some(_)));
    let required = is_required_in_capabilities(group.extensions());
    let has_members = group.members().next().is_some();
    ext_present && required && has_members
}

The comment's logic is sound for groups in a valid MLS state, but it silently trusts that OpenMLS always enforces required_capabilities at commit time. If this function is called on a group object that has been deserialized from storage after a partial write (e.g., during crash recovery), the inference may not hold. An explicit per-leaf capability check would be more defensive.


12. enable_multi_device Is a Footgun — No Auto-Merge Guard

crates/mdk-core/src/groups.rs:

/// NOTE: Does not merge the pending commit. Clients must call `merge_pending_commit`
/// after publishing the commit to relays.
pub fn enable_multi_device(&self, group_id: &GroupId) -> Result<Event, Error> {

The function generates a pending commit but leaves the group in a "pending" state. If the caller publishes the event to relays and then crashes, or forgets the merge_pending_commit call, the group object is in an inconsistent state for subsequent operations. Unlike create_group which handles commit merging internally, this method leaves state management entirely to the caller with no guard or timeout.


🔵 Low Severity / Nits

13. GroupPairingDataV1 TLS Spec Has Wrong Varint Type for join_psk

crates/mdk-core/src/mip06/pairing.rs:

/// ```tls
/// struct {
///     opaque group_event_key[32];
///     opaque join_psk<1..255>;         // ← 8-bit length prefix
///     opaque group_info<1..2^32-1>;
/// } GroupPairingDataV1;
/// ```
join_psk: Vec<u8>,  // TLS-encodes as <0..2^32-1> (4-byte length prefix)

The doc comment says join_psk<1..255> (1-byte length prefix, max 255 bytes), but Vec<u8> in tls_codec serializes with a 4-byte QUIC-varint/u32 length prefix. The TLS struct documentation and the actual wire encoding are inconsistent. Per the spec, KDF.Nh for ciphersuite 0x0001 (MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519) is 32 bytes — well within 255 — but the wire format is wider than documented.


14. test_full_add_based_pairing_flowmerge_pending_commit Called Out of Order

crates/mdk-core/src/mip06/mod.rs integration test:

alice_device1_mdk
    .merge_pending_commit(&group_id)
    .expect("Should merge pending commit");  // merges the group-creation commit

// ... later ...
alice_device1_mdk
    .merge_pending_commit(&group_id)
    .expect("Should merge add commit");  // merges the add-device commit

create_group_with_multi_device produces a Welcome + initial add commit. The test merges twice. But the first merge happens before the device 2 KP is added, and the second after. This is correct ordering, but the first merge comment says "merge pending commit" with no indication which commit. Given the "Privacy-preserving: don't publish initial commit" comment in create_group_inner, the initial group creation commit is automatically merged inside the function — so the first merge_pending_commit call here may be operating on the welcome-generation add commit, not the initial group creation. The test needs clearer documentation to be reliable as a spec-compliance reference.


15. PairingMessage::MIN_SIZE Off-By-One Comment

crates/mdk-core/src/mip06/pairing_crypto.rs:

/// Minimum message size: 32 (pubkey) + 12 (nonce) + 16 (tag) = 60 bytes.
const MIN_SIZE: usize = 32 + 12 + 16;

// In from_bytes:
assert!(PairingMessage::from_bytes(&[0u8; 59]).is_err());
assert!(PairingMessage::from_bytes(&[0u8; 60]).is_ok());  // 60 bytes, but has zero-length ciphertext

A 60-byte message passes the size check but has 0 bytes of ciphertext (only the 16-byte AEAD tag, which would fail decryption). The minimum for a decryptable message is 60 bytes (tag only, 0 plaintext), but the comment implies 60 bytes means "ciphertext" includes a tag. The boundary test is technically correct (from_bytes only checks structure, not decryptability) but is misleading.


Summary Table

# File Severity Issue
1 groups.rs, join_psk.rs, validation.rs 🔴 Critical PSK type is External PSK; spec mandates Resumption PSK (usage=application)
2 groups.rs 🔴 Critical GroupContext extracted via raw [4..] byte offset — fragile, not spec-mandated
3 groups.rs 🟠 High enable_multi_device missing admin authorization check
4 key_packages.rs 🟠 High Mixed-case hex test silently gutted — now tests lowercase only
5 mip06/mod.rs 🟠 High External Commit integration test bypasses process_message pipeline — validate_external_commit_* never called
6 mdk-uniffi/src/lib.rs 🟠 High Nostr secret key passed as unzeroized String through FFI
7 groups.rs 🟠 High Old-epoch PSKs never cleaned up — unbounded PSK store growth
8 groups.rs 🟡 Medium own_device_leaves loads MLS group twice
9 pairing.rs 🟡 Medium DevicePairingRequest::key_package_bytes() holds JSON Event, not KeyPackage bytes
10 mip06/mod.rs 🟡 Medium Module doc describes only Add-based flow; External Commit flow undocumented
11 extension.rs 🟡 Medium Condition 3 of signaling gate inferred, not verified per-leaf
12 groups.rs 🟡 Medium enable_multi_device leaves group in pending-commit limbo with no guard
13 pairing.rs 🔵 Low join_psk<1..255> doc vs. 4-byte length-prefix wire encoding
14 mip06/mod.rs 🔵 Low Integration test merge ordering comment unclear
15 pairing_crypto.rs 🔵 Low MIN_SIZE boundary test misleading

The most important fix is Issue #1 — the External PSK vs. Resumption PSK type mismatch is a protocol-level incompatibility with the spec that will cause interop failures with any other spec-compliant MIP-06 client. Issue #2 (raw offset GroupContext extraction) is the most likely to cause silent failures in future OpenMLS upgrades. Issue #5 (test not covering the real validation path) means the spec-compliance of the new authorization code is effectively untested.


🧠 Learnings used
Learnt from: CR
Repo: marmot-protocol/mdk PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-02-10T13:56:23.563Z
Learning: Ensure all code follows the Marmot Protocol specification exactly. Ask clarifying questions if any part of the spec is unclear

Learnt from: dannym-arx
Repo: marmot-protocol/mdk PR: 205
File: crates/mdk-cbindings/src/welcomes.rs:64-70
Timestamp: 2026-03-04T20:41:29.623Z
Learning: In marmot-protocol/mdk, the security restriction on `mls_group_id` and `nostr_group_id` (and other sensitive identifiers) applies ONLY to side-channel leakage contexts: log messages (tracing macros), error strings, debug output (Debug trait impls), and panic messages. It does NOT apply to primary FFI/API return values (e.g., the Welcome struct returned via C bindings or UniFFI). These fields are essential application data that callers need to accept/decline welcomes and identify groups. Flagging their presence in API return types is a false positive.

Learnt from: jgmontoya
Repo: marmot-protocol/mdk PR: 223
File: crates/mdk-core/src/groups.rs:1640-1665
Timestamp: 2026-03-09T21:20:16.704Z
Learning: In marmot-protocol/mdk (crates/mdk-core/src/groups.rs), `add_members` and `remove_members` always produce immediate commits (not queued proposals), so the proposal store is effectively empty before any `update_group_data`/`update_group_context_extensions` call made via the public API. The concern that `prune_and_validate_admin_update` validates against stale (pre-proposal) membership is acknowledged as theoretically possible (only via a `leave_group` proposal received through `process_message`), but the MDK maintainers have decided the fix adds disproportionate complexity for a scenario that cannot arise through normal API usage. Do not re-flag this pattern.

Learnt from: jgmontoya
Repo: marmot-protocol/mdk PR: 250
File: crates/mdk-memory-storage/src/lib.rs:1051-1054
Timestamp: 2026-04-05T13:26:09.683Z
Learning: In marmot-protocol/mdk (crates/mdk-memory-storage/src/lib.rs), `delete_group` prunes `group_snapshots` after releasing the `inner` write lock, creating a theoretical race with `create_group_snapshot` and `rollback_group_to_snapshot`. The maintainers intentionally accept this: the race requires simultaneous `delete_group` and `process_message` on the same group, which cannot occur through normal API usage (user must leave the group before deleting it), and the worst-case outcome is a stale snapshot entry, not data corruption. Introducing a shared higher-level lock would require changing create_group_snapshot, rollback_group_to_snapshot, and release_group_snapshot — disproportionate complexity. Do not re-flag this pattern.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 16

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
crates/mdk-core/src/groups.rs (1)

1356-1446: ⚠️ Potential issue | 🟠 Major

Register the initial join PSK during multi-device group creation.

register_join_psk() is only reached from merge_pending_commit(), but this path either merges the initial add-members commit directly with mls_group.merge_pending_commit(&self.provider) or creates a single-member group with no pending commit at all. In both cases, a newly created MIP-06 group is left without the current epoch PSK registered locally, so the creator can't validate the first incoming External Commit for that group.

💡 Proposed fix
         self.storage()
             .replace_group_relays(&group.mls_group_id, config.relays.into_iter().collect())
             .map_err(|e| Error::Group(e.to_string()))?;
 
+        #[cfg(feature = "mip06")]
+        if crate::mip06::is_multi_device_enabled(&mls_group) {
+            self.register_join_psk(&group.mls_group_id)?;
+        }
+
         Ok(GroupResult {
             group,
             welcome_rumors,
         })
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/mdk-core/src/groups.rs` around lines 1356 - 1446, create_group_inner
fails to register the initial join PSK: after you finish applying the initial
add_members commit (i.e. after mls_group.merge_pending_commit(&self.provider)?
when key_packages_vec was non-empty) and also in the single-member creation path
(when no pending commit is created), call the existing register_join_psk(...)
helper with the newly-created mls_group (or its group id/epoch as required by
register_join_psk) before persisting the group and returning GroupResult so the
creator has the current epoch PSK locally for validating the first incoming
External Commit.
🧹 Nitpick comments (9)
crates/mdk-core/src/lib.rs (1)

432-446: Move the inner use to module scope.

MULTI_DEVICE_EXTENSION_TYPE is imported inside the method, which goes against the repo’s Rust style rules. Pull it into the existing top-level use self::constant::{...} block instead.

As per coding guidelines, "All use statements must be placed at the TOP of their containing scope, never inside functions, methods, blocks, or individual test functions."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/mdk-core/src/lib.rs` around lines 432 - 446, The inner use of
MULTI_DEVICE_EXTENSION_TYPE inside the method
required_capabilities_extension_with_multi_device violates the module-scope use
rule; move the import into the module-level use block that already imports from
self::constant (e.g., add MULTI_DEVICE_EXTENSION_TYPE to the existing top-level
use self::constant::{...} list) and remove the in-method use so the method body
only references MULTI_DEVICE_EXTENSION_TYPE directly.
crates/mdk-core/src/mip06/join_psk.rs (1)

23-27: Avoid dual source-of-truth for the join-PSK label.

JOIN_PSK_LABEL and JOIN_PSK_EXPORTER_LABEL encode the same value in different forms; divergence would silently break interop. Add an invariant test to lock them together.

🧪 Minimal safeguard test
 #[cfg(test)]
 mod tests {
     use super::*;
+    #[test]
+    fn test_join_psk_label_constants_match() {
+        assert_eq!(JOIN_PSK_LABEL.as_slice(), JOIN_PSK_EXPORTER_LABEL.as_bytes());
+    }
 
     #[test]
     fn test_join_psk_id_roundtrip() {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/mdk-core/src/mip06/join_psk.rs` around lines 23 - 27, Add a unit test
that enforces the invariant between JOIN_PSK_LABEL and JOIN_PSK_EXPORTER_LABEL
so they cannot drift: write a test (e.g., join_psk_labels_invariant) that
converts JOIN_PSK_EXPORTER_LABEL to bytes (or JOIN_PSK_LABEL to &str) and
asserts equality with JOIN_PSK_LABEL, failing the build if they differ; place
the test alongside other tests in the same module
(crates::mdk_core::mip06::join_psk) so CI will catch any future divergence.
crates/mdk-core/src/mip06/extension.rs (2)

54-56: Move use statement to the top of the file scope.

The use tls_codec::Serialize; inside as_extension() violates coding guidelines. Move it to the imports at the top of the file.

♻️ Suggested refactor

Add to the imports at line 9:

 use tls_codec::{DeserializeBytes, TlsDeserialize, TlsDeserializeBytes, TlsSerialize, TlsSize};
+use tls_codec::Serialize;

Then remove line 55:

     pub fn as_extension(&self) -> Result<Extension, Error> {
-        use tls_codec::Serialize;
         let mut bytes = Vec::new();

Note: You may need to verify Serialize isn't already covered by TlsSerialize for this use case.

As per coding guidelines: "All use statements must be placed at the TOP of their containing scope."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/mdk-core/src/mip06/extension.rs` around lines 54 - 56, Move the inline
use inside as_extension() to the top-level imports: remove the local `use
tls_codec::Serialize;` from the body of the `as_extension` method and add `use
tls_codec::Serialize;` alongside the other imports at the top of the file,
ensuring you don't duplicate an existing import or conflict with `TlsSerialize`;
then run a quick build to verify `Serialize` is the correct trait for the
`as_extension` implementation.

140-151: Move use statement outside the test function.

Per coding guidelines, use statements should be at the top of their containing scope. For tests, this means at the top of the mod tests block, not inside individual test functions.

♻️ Suggested refactor
 #[cfg(test)]
 mod tests {
     use super::*;
+    use tls_codec::Serialize;

     #[test]
     fn test_marmot_multi_device_roundtrip() {
-        use tls_codec::Serialize;
-
         let ext = MarmotMultiDevice::new();
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/mdk-core/src/mip06/extension.rs` around lines 140 - 151, The test
function test_marmot_multi_device_roundtrip contains a local use of
tls_codec::Serialize; move that use statement to the top of the tests module (or
file-level scope that contains the tests) and remove it from inside the function
so the trait import is available for all tests; update any other tests in the
same module to rely on the module-level use and leave
test_marmot_multi_device_roundtrip using MarmotMultiDevice and its
tls_serialize/tls_deserialize_bytes as before.
crates/mdk-core/src/messages/validation.rs (3)

440-440: Move use statement to the top of the file scope.

Per coding guidelines, all use statements must be placed at the TOP of their containing scope, not inside functions. The BasicCredential import should be added to the existing imports at the top of the file.

♻️ Suggested refactor

Add to the imports at the top of the file (around line 7):

-use openmls::prelude::{BasicCredential, LeafNodeIndex, MlsGroup, Proposal, Sender, StagedCommit};
+use openmls::prelude::{BasicCredential, LeafNodeIndex, MlsGroup, Proposal, Sender, StagedCommit};

Note: BasicCredential is already imported at line 7, so simply remove line 440:

     #[cfg(feature = "mip06")]
     fn validate_external_commit_authorization(
         &self,
         mls_group: &MlsGroup,
         staged_commit: &StagedCommit,
     ) -> Result<()> {
-        use openmls::prelude::BasicCredential;
-
         // 1-3. MIP-06 signaling gate: extension present, required, at least one member

As per coding guidelines: "All use statements must be placed at the TOP of their containing scope, never inside functions, methods, blocks, or individual test functions."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/mdk-core/src/messages/validation.rs` at line 440, The file contains a
local `use openmls::prelude::BasicCredential` inside a function; remove that
inner `use` and ensure `BasicCredential` is imported with the other top-level
imports (it’s already present near the top), so simply delete the `use
openmls::prelude::BasicCredential;` line at its current location in
messages/validation.rs and rely on the existing top-scope import to provide
`BasicCredential`.

519-519: Remove redundant use statement inside function.

The BasicCredential import on line 519 is redundant since it's already imported at the top of the file (line 7). This also violates the coding guideline against use statements inside functions.

♻️ Suggested fix
     #[cfg(feature = "mip06")]
     fn extract_joining_pubkey(&self, staged_commit: &StagedCommit) -> Result<nostr::PublicKey> {
-        use openmls::prelude::BasicCredential;
-
         let joining_leaf = staged_commit.update_path_leaf_node().ok_or_else(|| {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/mdk-core/src/messages/validation.rs` at line 519, Remove the redundant
inner import by deleting the `use openmls::prelude::BasicCredential;` statement
found inside the function body and rely on the existing top-level
`BasicCredential` import; ensure no other local `use` of the same symbol remains
and run cargo check/rustfmt to confirm there are no unused-import or scoping
issues.

542-543: Move use statements to the top of the file scope.

The use statements inside validate_external_commit_identity_proof violate coding guidelines. The BasicCredential is already imported at line 7. The mip06 module imports should be added to the top-level imports (potentially with the #[cfg(feature = "mip06")] attribute).

♻️ Suggested refactor

Add at the top of the file with feature gate:

#[cfg(feature = "mip06")]
use crate::mip06::{NostrIdentityProof, verify_identity_proof};

Then remove lines 542-543:

     #[cfg(feature = "mip06")]
     pub(super) fn validate_external_commit_identity_proof(
         &self,
         mls_group: &MlsGroup,
         staged_commit: &StagedCommit,
         authenticated_data: &[u8],
     ) -> Result<()> {
-        use crate::mip06::{NostrIdentityProof, verify_identity_proof};
-        use openmls::prelude::BasicCredential;
-
         let proof = NostrIdentityProof::from_authenticated_data(authenticated_data)?;

As per coding guidelines: "All use statements must be placed at the TOP of their containing scope, never inside functions."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/mdk-core/src/messages/validation.rs` around lines 542 - 543, Move the
function-local imports out to the module top: add a top-level, feature-gated
import `#[cfg(feature = "mip06")] use crate::mip06::{NostrIdentityProof,
verify_identity_proof};` (BasicCredential is already imported) and delete the
`use` lines inside validate_external_commit_identity_proof; ensure the feature
gate matches other mip06 imports in this file so compilation and visibility
remain consistent.
crates/mdk-core/src/mip06/pairing_crypto.rs (2)

207-215: Consider documenting the ephemeral nature more prominently.

The function correctly generates a fresh X25519 keypair. The doc comment mentions the caller must securely delete the private key, which is good. Consider adding #[must_use] to ensure the return value isn't accidentally ignored.

♻️ Suggested enhancement
 /// Generate a fresh X25519 keypair for the new device's pairing session.
 ///
 /// Returns `(private_key, public_key_bytes)`.
 /// The caller MUST securely delete the private key after the pairing session.
+#[must_use]
 pub fn generate_new_device_keypair() -> (StaticSecret, [u8; 32]) {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/mdk-core/src/mip06/pairing_crypto.rs` around lines 207 - 215, The
function generate_new_device_keypair returns an ephemeral private key that must
not be ignored; add the #[must_use] attribute to the generate_new_device_keypair
function declaration so callers get a compiler warning if they discard the
returned (StaticSecret, [u8; 32]) tuple, and keep the existing doc comment about
securely deleting the private key to emphasize its ephemeral nature.

24-35: Consider whether Debug derive on PairingMessage is appropriate.

The PairingMessage derives Debug which will expose existing_ephemeral_pubkey, nonce, and ciphertext in debug output. While these are not secret (ephemeral pubkey is public, nonce is transmitted in cleartext, ciphertext is encrypted), you may want to implement a custom Debug that redacts or truncates these fields to avoid accidentally verbose logging in production.

This is a minor consideration since none of these fields are key material.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/mdk-core/src/mip06/pairing_crypto.rs` around lines 24 - 35,
PairingMessage currently derives Debug which will print full
existing_ephemeral_pubkey, nonce, and ciphertext; replace the derive with a
custom impl fmt::Debug for PairingMessage that redacts or truncates
sensitive/verbose data (e.g., print hex of first N bytes or lengths like
existing_ephemeral_pubkey: "[redacted]" or "len=32", nonce: "len=12",
ciphertext: "len={}", or hex-prefix) to avoid verbose/logging of raw bytes;
remove #[derive(Debug)] and implement std::fmt::Debug for the PairingMessage
struct accordingly so other fields remain readable but raw byte arrays are not
fully exposed.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@Cargo.toml`:
- Line 54: The x25519-dalek dependency was built with default-features = false
which removed the zeroize feature so StaticSecret instances in pairing_crypto.rs
(created around where StaticSecret is instantiated at lines ~125 and ~212) won't
be zeroed on drop; fix this by adding "zeroize" to the features list for
x25519-dalek in Cargo.toml (i.e., change features = ["static_secrets"] to
features = ["static_secrets", "zeroize"]) so StaticSecret will securely zero its
memory on drop.

In `@crates/mdk-core/Cargo.toml`:
- Line 19: Replace the current pattern that immediately converts the X25519
result to bytes and calls reject_zero_shared_secret with a contributory check
using x25519_dalek::SharedSecret::was_contributory() before proceeding to HKDF;
specifically, where you call
existing_secret.diffie_hellman(&new_pubkey).to_bytes() (the two call sites in
pairing_crypto.rs) keep the SharedSecret value, call .was_contributory() and
return an error if it is false, then only call .to_bytes() and feed that into
the HKDF/derive steps (and you can remove or keep reject_zero_shared_secret if
you still want the zero-check as redundant). Ensure both locations (the
diffie_hellman call sites around the current reject_zero_shared_secret usage)
are updated to perform this check.

In `@crates/mdk-core/src/groups.rs`:
- Around line 1506-1530: The loop in add_device_to_groups calls add_members and
abandons earlier successful results on later failure, which leaves pending
commits/rumors stranded; modify add_device_to_groups to either (A) perform a
two-phase approach: first validate/dry-run add_members for all group_ids (or
collect results without finalizing) and only after all succeed produce
GroupWelcomeData and return DevicePairingResponse, or (B) on encountering an
error, iterate over prior successful add_members results and call the
appropriate rollback/abort method (or a new helper like
discard_pending_commit/revert_commit) to clear staged commits before returning
Err; update add_device_to_groups to return partial info only if you also expose
a cleanup API, and ensure you reference add_device_to_groups, add_members,
DevicePairingResponse, GroupWelcomeData and any newly added rollback helper in
your changes.
- Around line 1635-1721: The code in join_group_via_external_commit deserializes
GroupInfo, creates a JoinPskId and stores the join_psk, then proceeds to build
an external commit without enforcing the MIP-06 entry validation; add upfront
validation after extracting vgi and gc_bytes (before creating/storing the PSK
and before calling MlsGroup::external_commit_builder) to reject malformed
pairing payloads: verify marmot_multi_device signaling is present and valid,
ensure required_capabilities includes 0xF2F0, ensure the ratchet_tree and
external_pub are present, ensure every ratchet-tree leaf advertises capability
0xF2F0, and verify join_psk.len() == the ciphersuite KDF output length (Nh) — if
any check fails return Error::PairingError and do not call PreSharedKeyId::new,
psk_id.store, or build_group().
- Around line 1562-1567: The tracing debug call in register_join_psk currently
logs a protected identifier by hex-encoding group_id; remove the group_id field
from the tracing::debug invocation in the register_join_psk code path and log
only non-linkable context (e.g., keep epoch = mls_group.epoch().as_u64() and the
message) or replace group_id with a non-identifying token; ensure the
tracing::debug target "mdk_core::groups::register_join_psk" no longer includes
hex::encode(group_id.as_slice()) to comply with the no-sensitive-identifiers
guideline.
- Around line 56-63: Remove the derived Debug on ExternalCommitResult and wrap
the sensitive group_event_key in the Secret<T> wrapper (e.g., Secret<[u8; 32]>)
or a struct that derives ZeroizeOnDrop so the key is zeroized on drop and not
Copy; then implement Debug manually for ExternalCommitResult to redact both
group_id and group_event_key (showing e.g. "<redacted>") while still exposing
commit_message if desired, and ensure the type does not implement Copy so
secrets cannot be accidentally duplicated; update uses of ExternalCommitResult's
group_event_key to call .expose_secret() only where strictly necessary.
- Around line 1459-1485: The enable_multi_device function is missing an admin
check and allows any member to change GroupContext.extensions and
required_capabilities; before proceeding (after loading mls_group and signer)
call the same admin gate used by update_group_data_extension — verify
is_leaf_node_admin(&mls_group, &signer) or equivalent and return an
Error::Unauthorized (or existing admin error) if false; ensure this check occurs
before constructing extensions or calling
required_capabilities_extension_with_multi_device() and before
update_group_context_extensions(&self.provider, ...).

In `@crates/mdk-core/src/key_packages.rs`:
- Around line 846-848: Update the doc comment above the function that "Returns
the extension tag values that include all required extensions" to stop calling
0x0002 and 0x0003 "GREASE-like" and instead describe them as default/reserved
MLS extension IDs that OpenMLS injects; replace the phrase "GREASE-like values
(0x0002, 0x0003)" with wording such as "default MLS extension IDs injected by
OpenMLS (0x0002, 0x0003)" so the purpose and origin of these IDs are clear for
future maintainers.

In `@crates/mdk-core/src/mip06/device_name.rs`:
- Around line 117-119: The test function test_tls_roundtrip currently has
function-local imports (use tls_codec::{DeserializeBytes, Serialize}); move
these use declarations to the module scope (top of the containing file or test
module) so imports are not inside the function, e.g., add the use
tls_codec::{DeserializeBytes, Serialize} alongside other module-level uses and
remove the local uses from inside test_tls_roundtrip.

In `@crates/mdk-core/src/mip06/identity_proof.rs`:
- Around line 164-173: The verify_identity_proof helper currently only checks
that pubkey signed the challenge but does not ensure credential_identity
actually encodes that same signer; update verify_identity_proof to parse/derive
the expected signer public key from credential_identity (the identity encoding
used for BasicCredential) and assert it equals the pubkey parameter before
computing the challenge and calling verify_canonical_proof_event; if they differ
return an Error (binding NostrIdentityProof/Nostr public key to
credential_identity), keeping compute_challenge and verify_canonical_proof_event
usage unchanged otherwise.
- Around line 74-88: from_authenticated_data currently ignores the remainder
returned by tls_deserialize_bytes, allowing "valid_proof || junk"; change
from_authenticated_data to check that the _remainder is empty and return an
Error::IdentityProofError (e.g., "trailing bytes in authenticated_data" or
similar) if any bytes remain after tls_deserialize_bytes; keep the existing
version check (proof.version != Self::CURRENT_VERSION) and ensure the error type
and message match other IdentityProof errors for consistency.

In `@crates/mdk-core/src/mip06/pairing.rs`:
- Around line 86-105: The constructor new() currently only checks join_psk is
non-empty; enforce the fixed PSK length by validating join_psk.len() == 32
(KDF.Nh for SHA-256) and return Err(Error::PairingError("join_psk must be 32
bytes".to_string())) if not; update the validation inside new() alongside the
existing group_info check so malformed PSKs are rejected early (refer to the
join_psk parameter and the Error::PairingError variant when making the change).
- Around line 146-152: PairingPayload::new currently accepts an empty Vec which
violates the spec (groups must be length >=1); change new to enforce non-empty
groups by validating groups.len() > 0 and failing fast instead of constructing
an invalid payload: update PairingPayload::new to return a Result<Self,
PairingPayloadError> (or another existing error type) and return Err when
groups.is_empty(), or alternatively change the API to accept a non-empty
collection type and document the precondition; reference PairingPayload::new and
GroupPairingDataV1 when implementing the validation and error path.
- Around line 41-50: The from_bytes parser currently ignores the unread suffix
returned by Self::tls_deserialize_bytes, allowing trailing junk; modify the
from_bytes implementation (the call sites using Self::tls_deserialize_bytes
inside the from_bytes generated by the macro) to check the second tuple element
(remainder) and return an Error::PairingError (or appropriate parsing error)
when that remainder is not empty, before calling validate_pairing_version and
returning Ok(val), so that any trailing bytes cause a parse failure.

In `@crates/mdk-uniffi/src/lib.rs`:
- Around line 2205-2207: The deserialization error for PairingPayload is
currently mapped to MdkUniffiError::Mdk, which hides caller-side malformed
input; change the map_err in the PairingPayload::from_bytes call so it returns
MdkUniffiError::InvalidInput with a descriptive message (e.g. "failed to
deserialize pairing payload: {e}") instead of MdkUniffiError::Mdk, so the FFI
boundary classifies malformed payloads as InvalidInput.
- Around line 2120-2143: The binding add_device_to_groups currently returns
opaque TLS-serialized bytes so callers can't access per-group commit events;
change the UniFFI surface to return a structured Vec of entries (e.g., a
UniFFI-compatible struct) that includes the GroupId (or group id string), the
commit_event bytes (from GroupWelcomeData.commit_event) and the welcome/response
bytes for each group instead of a single opaque blob; update the function in
lib.rs (add_device_to_groups) to map mdk.add_device_to_groups() response into
that Vec of structs and return it (and add the new UniFFI struct type
definitions) so non-Rust clients can publish each commit_event and call
merge_pending_commit per group.

---

Outside diff comments:
In `@crates/mdk-core/src/groups.rs`:
- Around line 1356-1446: create_group_inner fails to register the initial join
PSK: after you finish applying the initial add_members commit (i.e. after
mls_group.merge_pending_commit(&self.provider)? when key_packages_vec was
non-empty) and also in the single-member creation path (when no pending commit
is created), call the existing register_join_psk(...) helper with the
newly-created mls_group (or its group id/epoch as required by register_join_psk)
before persisting the group and returning GroupResult so the creator has the
current epoch PSK locally for validating the first incoming External Commit.

---

Nitpick comments:
In `@crates/mdk-core/src/lib.rs`:
- Around line 432-446: The inner use of MULTI_DEVICE_EXTENSION_TYPE inside the
method required_capabilities_extension_with_multi_device violates the
module-scope use rule; move the import into the module-level use block that
already imports from self::constant (e.g., add MULTI_DEVICE_EXTENSION_TYPE to
the existing top-level use self::constant::{...} list) and remove the in-method
use so the method body only references MULTI_DEVICE_EXTENSION_TYPE directly.

In `@crates/mdk-core/src/messages/validation.rs`:
- Line 440: The file contains a local `use openmls::prelude::BasicCredential`
inside a function; remove that inner `use` and ensure `BasicCredential` is
imported with the other top-level imports (it’s already present near the top),
so simply delete the `use openmls::prelude::BasicCredential;` line at its
current location in messages/validation.rs and rely on the existing top-scope
import to provide `BasicCredential`.
- Line 519: Remove the redundant inner import by deleting the `use
openmls::prelude::BasicCredential;` statement found inside the function body and
rely on the existing top-level `BasicCredential` import; ensure no other local
`use` of the same symbol remains and run cargo check/rustfmt to confirm there
are no unused-import or scoping issues.
- Around line 542-543: Move the function-local imports out to the module top:
add a top-level, feature-gated import `#[cfg(feature = "mip06")] use
crate::mip06::{NostrIdentityProof, verify_identity_proof};` (BasicCredential is
already imported) and delete the `use` lines inside
validate_external_commit_identity_proof; ensure the feature gate matches other
mip06 imports in this file so compilation and visibility remain consistent.

In `@crates/mdk-core/src/mip06/extension.rs`:
- Around line 54-56: Move the inline use inside as_extension() to the top-level
imports: remove the local `use tls_codec::Serialize;` from the body of the
`as_extension` method and add `use tls_codec::Serialize;` alongside the other
imports at the top of the file, ensuring you don't duplicate an existing import
or conflict with `TlsSerialize`; then run a quick build to verify `Serialize` is
the correct trait for the `as_extension` implementation.
- Around line 140-151: The test function test_marmot_multi_device_roundtrip
contains a local use of tls_codec::Serialize; move that use statement to the top
of the tests module (or file-level scope that contains the tests) and remove it
from inside the function so the trait import is available for all tests; update
any other tests in the same module to rely on the module-level use and leave
test_marmot_multi_device_roundtrip using MarmotMultiDevice and its
tls_serialize/tls_deserialize_bytes as before.

In `@crates/mdk-core/src/mip06/join_psk.rs`:
- Around line 23-27: Add a unit test that enforces the invariant between
JOIN_PSK_LABEL and JOIN_PSK_EXPORTER_LABEL so they cannot drift: write a test
(e.g., join_psk_labels_invariant) that converts JOIN_PSK_EXPORTER_LABEL to bytes
(or JOIN_PSK_LABEL to &str) and asserts equality with JOIN_PSK_LABEL, failing
the build if they differ; place the test alongside other tests in the same
module (crates::mdk_core::mip06::join_psk) so CI will catch any future
divergence.

In `@crates/mdk-core/src/mip06/pairing_crypto.rs`:
- Around line 207-215: The function generate_new_device_keypair returns an
ephemeral private key that must not be ignored; add the #[must_use] attribute to
the generate_new_device_keypair function declaration so callers get a compiler
warning if they discard the returned (StaticSecret, [u8; 32]) tuple, and keep
the existing doc comment about securely deleting the private key to emphasize
its ephemeral nature.
- Around line 24-35: PairingMessage currently derives Debug which will print
full existing_ephemeral_pubkey, nonce, and ciphertext; replace the derive with a
custom impl fmt::Debug for PairingMessage that redacts or truncates
sensitive/verbose data (e.g., print hex of first N bytes or lengths like
existing_ephemeral_pubkey: "[redacted]" or "len=32", nonce: "len=12",
ciphertext: "len={}", or hex-prefix) to avoid verbose/logging of raw bytes;
remove #[derive(Debug)] and implement std::fmt::Debug for the PairingMessage
struct accordingly so other fields remain readable but raw byte arrays are not
fully exposed.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 1eae65f1-2f36-474c-92ea-7cfe2a45fb32

📥 Commits

Reviewing files that changed from the base of the PR and between 8a8d06c and 03096ed.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (21)
  • Cargo.toml
  • crates/mdk-core/CHANGELOG.md
  • crates/mdk-core/Cargo.toml
  • crates/mdk-core/src/constant.rs
  • crates/mdk-core/src/error.rs
  • crates/mdk-core/src/groups.rs
  • crates/mdk-core/src/key_packages.rs
  • crates/mdk-core/src/lib.rs
  • crates/mdk-core/src/messages/commit.rs
  • crates/mdk-core/src/messages/process.rs
  • crates/mdk-core/src/messages/validation.rs
  • crates/mdk-core/src/mip06/device_name.rs
  • crates/mdk-core/src/mip06/extension.rs
  • crates/mdk-core/src/mip06/identity_proof.rs
  • crates/mdk-core/src/mip06/join_psk.rs
  • crates/mdk-core/src/mip06/mod.rs
  • crates/mdk-core/src/mip06/pairing.rs
  • crates/mdk-core/src/mip06/pairing_crypto.rs
  • crates/mdk-uniffi/CHANGELOG.md
  • crates/mdk-uniffi/Cargo.toml
  • crates/mdk-uniffi/src/lib.rs

Comment thread Cargo.toml Outdated
Comment thread crates/mdk-core/Cargo.toml Outdated
Comment thread crates/mdk-core/src/groups.rs Outdated
Comment thread crates/mdk-core/src/groups.rs
Comment thread crates/mdk-core/src/groups.rs
Comment thread crates/mdk-core/src/mip06/identity_proof.rs
Comment thread crates/mdk-core/src/mip06/pairing.rs Outdated
Comment thread crates/mdk-core/src/mip06/pairing.rs
Comment thread crates/mdk-uniffi/src/lib.rs
Comment thread crates/mdk-uniffi/src/lib.rs
@dannym-arx

dannym-arx commented Apr 13, 2026

Copy link
Copy Markdown
Contributor Author

@coderabbitai

  1. Wrong PSK Type: External PSK vs. Resumption PSK

You have lost your chance at a golden carrot by looking at a wrong commit in marmot#44. This issue WAS raised in that pr and addressed, external PSKs are correct.

  1. Fragile Raw-Byte Offset Hack for GroupContext Extraction

RFC 9420 defines MLSMessage as version(u16) + wire_format(u16) + body; the 4-byte offset is the MLS wire format, not an OpenMLS internal. This will not change unless the MLS spec changes

  1. enable_multi_device Lacks Admin Authorization Check
  1. Mixed-Case Hex Test Now Tests Nothing
  1. External Commit Integration Test Bypasses MDK's Validation Pipeline
  1. own_device_leaves Loads MLS Group Twice
  1. DevicePairingRequest Field Naming Mismatch

yep real issues, here's five carrots 🥕 🥕 🥕 🥕 🥕 for your contribution

  1. Secret Key Passed as Plaintext String Across FFI Boundary Without Zeroization

can't zeroize across FFI boundaries. that's not how memory works

  1. Old-Epoch PSKs Are Never Cleaned Up

not sure what to do about this one, thoughts @erskingardner @jgmontoya @mubarakcoded ?


the rest is all minor stuff

@coderabbitai

coderabbitai Bot commented Apr 13, 2026

Copy link
Copy Markdown

Tip

For best results, initiate chat on the files or code changes.

@dannym-arx — thank you for the detailed corrections! Responses inline:

On #1 (PSK type): Understood — I was looking at an outdated commit in marmot#44. I'll record that External PSKs are correct per the final spec.

On #2 (raw byte offset): Fair point — RFC 9420 §6 defines MLSMessage as version (u16) + wire_format (u16) + body, so the [4..] skip is grounded in the MLS spec itself, not an OpenMLS implementation detail. I'll record this too.

On #6 (FFI zeroization): Agreed — memory across an FFI boundary is managed by the caller's runtime, so zeroizing the Rust-side String wouldn't reach the caller's copy anyway. Not a valid concern here.

On #7 (PSK cleanup): One approach worth considering: store the previous epoch's PSK ID alongside the group state, and delete it atomically when merge_pending_commit successfully completes. That way only one stale entry (from the just-committed epoch) ever exists, and it's cleaned up on the next successful merge. Happy to elaborate further for the team discussion.

🥕🥕🥕🥕🥕 Glad the five confirmed issues were useful — I'll keep digging!


✏️ Learnings added
Learnt from: dannym-arx
URL: https://github.com/marmot-protocol/mdk/pull/251

Timestamp: 2026-04-13T17:12:16.318Z
Learning: In marmot-protocol/mdk (MIP-06, marmot-protocol/marmot#44), the join PSK used in External Commits is correctly typed as an **External PSK** (`Psk::External`), not a Resumption PSK. The MIP-06 spec was updated to clarify this during review of marmot#44. Flagging External PSK usage in join_psk.rs or groups.rs as a spec deviation is a false positive.

Learnt from: dannym-arx
URL: https://github.com/marmot-protocol/mdk/pull/251

Timestamp: 2026-04-13T17:12:16.318Z
Learning: In marmot-protocol/mdk (crates/mdk-core/src/groups.rs), the `[4..]` byte offset used in `export_group_context_bytes` and `join_group_via_external_commit` to skip to the GroupContext within a serialized GroupInfo is grounded in RFC 9420 §6, which defines `MLSMessage` wire format as `version (u16) + wire_format (u16) + body`. This is a stable MLS spec-level layout, not an OpenMLS internal. Do not flag this offset as fragile or implementation-dependent.

Learnt from: dannym-arx
URL: https://github.com/marmot-protocol/mdk/pull/251

Timestamp: 2026-04-13T17:12:16.318Z
Learning: In marmot-protocol/mdk (crates/mdk-uniffi/src/lib.rs), the Nostr secret key is intentionally passed as a `String` across the FFI boundary without zeroization in `join_groups_via_external_commit`. This is by design: memory across an FFI boundary is managed by the caller's runtime, so zeroizing the Rust-side allocation would not reach the caller's copy. Do not flag the absence of `zeroize` on this parameter as a security issue.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.


🧠 Learnings used
Learnt from: dannym-arx
Repo: marmot-protocol/mdk PR: 205
File: crates/mdk-cbindings/src/welcomes.rs:64-70
Timestamp: 2026-03-04T20:41:29.623Z
Learning: In marmot-protocol/mdk, the security restriction on `mls_group_id` and `nostr_group_id` (and other sensitive identifiers) applies ONLY to side-channel leakage contexts: log messages (tracing macros), error strings, debug output (Debug trait impls), and panic messages. It does NOT apply to primary FFI/API return values (e.g., the Welcome struct returned via C bindings or UniFFI). These fields are essential application data that callers need to accept/decline welcomes and identify groups. Flagging their presence in API return types is a false positive.

Learnt from: jgmontoya
Repo: marmot-protocol/mdk PR: 223
File: crates/mdk-core/src/groups.rs:1640-1665
Timestamp: 2026-03-09T21:20:16.704Z
Learning: In marmot-protocol/mdk (crates/mdk-core/src/groups.rs), `add_members` and `remove_members` always produce immediate commits (not queued proposals), so the proposal store is effectively empty before any `update_group_data`/`update_group_context_extensions` call made via the public API. The concern that `prune_and_validate_admin_update` validates against stale (pre-proposal) membership is acknowledged as theoretically possible (only via a `leave_group` proposal received through `process_message`), but the MDK maintainers have decided the fix adds disproportionate complexity for a scenario that cannot arise through normal API usage. Do not re-flag this pattern.

Learnt from: jgmontoya
Repo: marmot-protocol/mdk PR: 250
File: crates/mdk-memory-storage/src/lib.rs:1051-1054
Timestamp: 2026-04-05T13:26:09.683Z
Learning: In marmot-protocol/mdk (crates/mdk-memory-storage/src/lib.rs), `delete_group` prunes `group_snapshots` after releasing the `inner` write lock, creating a theoretical race with `create_group_snapshot` and `rollback_group_to_snapshot`. The maintainers intentionally accept this: the race requires simultaneous `delete_group` and `process_message` on the same group, which cannot occur through normal API usage (user must leave the group before deleting it), and the worst-case outcome is a stale snapshot entry, not data corruption. Introducing a shared higher-level lock would require changing create_group_snapshot, rollback_group_to_snapshot, and release_group_snapshot — disproportionate complexity. Do not re-flag this pattern.

Learnt from: dannym-arx
Repo: marmot-protocol/mdk PR: 253
File: crates/mdk-core/src/messages/create.rs:143-154
Timestamp: 2026-04-10T07:31:46.841Z
Learning: In marmot-protocol/mdk (crates/mdk-core/src/messages/create.rs), per-event expiry and per-group expiry are intentionally orthogonal. When `group.disappearing_message_duration_secs` is `Some(n)`, the group-level timer is authoritative and replaces any caller-supplied `EventTag::expiration`. When it is `None`, caller-supplied per-event expiration tags are passed through unchanged. Do not flag the `None => tags` branch in the `match group.disappearing_message_duration_secs` block as a bug or inconsistency.

Learnt from: CR
Repo: marmot-protocol/mdk PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-02-10T13:56:23.563Z
Learning: For security vulnerabilities, email jipf.dev (do not open public issues). MDK handles cryptographic operations so be careful with key material. All message encryption uses MLS protocol with forward secrecy. Review security implications of any changes to cryptographic code

Learnt from: mubarakcoded
Repo: marmot-protocol/mdk PR: 111
File: crates/mdk-uniffi/CHANGELOG.md:31-31
Timestamp: 2026-01-07T08:18:40.637Z
Learning: In the marmot-protocol/mdk repository, when a feature spans multiple layers (core Rust API and UniFFI bindings), different PRs may be used for each layer. Each crate's CHANGELOG should reference the PR that actually modified that specific crate, not necessarily the PR that introduced the feature in the core API. For example, core API changes reference one PR while UniFFI binding exposure references a separate PR.

Learnt from: CR
Repo: marmot-protocol/mdk PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-02-10T13:56:23.563Z
Learning: Ensure all code follows the Marmot Protocol specification exactly. Ask clarifying questions if any part of the spec is unclear

@github-actions

Copy link
Copy Markdown

❌ Coverage: 93.68% → 93.16% (-0.52%)

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

♻️ Duplicate comments (7)
crates/mdk-core/src/mip06/pairing.rs (3)

41-50: ⚠️ Potential issue | 🟠 Major

Reject trailing bytes in the versioned pairing parsers.

from_bytes() discards the unread suffix from tls_deserialize_bytes(), so valid_payload || junk is still accepted for every generated pairing type. These control messages should fail closed once any trailing bytes remain.

Based on learnings, "Ensure all code follows the Marmot Protocol specification exactly."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/mdk-core/src/mip06/pairing.rs` around lines 41 - 50, from_bytes
currently ignores the remainder from Self::tls_deserialize_bytes allowing
trailing junk; update from_bytes (the function named from_bytes) to reject any
non-empty remainder returned by tls_deserialize_bytes by returning an
Error::PairingError (or another appropriate error) when the second element
(remainder) is non-empty, then proceed to call validate_pairing_version on
val.version and return Ok(val) only when remainder is empty; ensure the error
uses the same formatting style as existing PairingError messages.

86-105: ⚠️ Potential issue | 🟠 Major

Reject malformed join_psk lengths at construction time.

The docs say join_psk is KDF.Nh, but new() only checks non-empty. In this repository the ciphersuite is fixed to SHA-256, so malformed pairing data should be rejected here unless join_psk.len() == 32.

As per coding guidelines, "Ciphersuite: MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/mdk-core/src/mip06/pairing.rs` around lines 86 - 105, The constructor
new() currently only checks that join_psk is non-empty but must reject malformed
lengths since the ciphersuite uses SHA-256 (KDF.Nh == 32); update new() to
enforce join_psk.len() == 32 and return Err(Error::PairingError("join_psk must
be 32 bytes".to_string())) when that check fails (keep existing group_info
non-empty check and the group_event_key fixed-size array), referencing the new()
function and Error::PairingError when adding this explicit length validation for
join_psk according to the MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519
ciphersuite.

146-152: ⚠️ Potential issue | 🟠 Major

Do not serialize empty PairingPayloads.

The TLS shape here is groups<1..2^32-1>, but PairingPayload::new() accepts Vec::new(). export_groups_pairing_payload() in crates/mdk-core/src/groups.rs:1606-1653 returns PairingPayload::new(groups) directly, so an empty input list can still produce a spec-invalid payload.

Based on learnings, "Ensure all code follows the Marmot Protocol specification exactly."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/mdk-core/src/mip06/pairing.rs` around lines 146 - 152,
PairingPayload::new currently allows an empty groups Vec which violates the TLS
shape groups<1..2^32-1>; change the API to validate and reject empty input:
either make PairingPayload::new return a Result<Self, Error> (e.g.,
Result<PairingPayload, PairingPayloadError>) and return an Err when
groups.is_empty(), or keep the signature but add a clear panic/assert (not
preferred) and update callers; specifically update export_groups_pairing_payload
to handle the new error (do not construct PairingPayload on an empty groups
list) so no empty PairingPayload is ever serialized.
crates/mdk-core/src/groups.rs (4)

1594-1599: ⚠️ Potential issue | 🔴 Critical

Do not log group_id in register_join_psk.

This log leaks a protected identifier.

As per coding guidelines "No sensitive identifiers (mls_group_id, nostr_group_id, GroupId, encryption keys, exporter secrets) in tracing macro calls (info!, warn!, error!, debug!, trace!)".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/mdk-core/src/groups.rs` around lines 1594 - 1599, In
register_join_psk, the tracing::debug call is logging the sensitive group_id;
remove the group_id = %hex::encode(...) field (or replace it with a
non-identifying placeholder such as "<redacted>") so the tracing::debug
invocation no longer emits the raw GroupId; keep the epoch logging and message
unchanged to preserve context.

1705-1710: ⚠️ Potential issue | 🟠 Major

Validate join_psk length before storing/using it.

This path accepts pairing_data.join_psk() without enforcing the required KDF output length for the selected ciphersuite. Reject malformed payloads before PSK registration.

✅ Minimal guard
+        let expected_nh = ciphersuite.hash_length();
+        if pairing_data.join_psk().len() != expected_nh {
+            return Err(Error::PairingError(
+                "invalid join_psk length for ciphersuite".to_string(),
+            ));
+        }
+
         let psk = Psk::External(ExternalPsk::new(join_psk_id_bytes));
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/mdk-core/src/groups.rs` around lines 1705 - 1710, Validate the length
of pairing_data.join_psk() against the required KDF output size for the selected
ciphersuite before creating the ExternalPsk or calling PreSharedKeyId::new; if
the length is incorrect, return an appropriate Error::PairingError.
Specifically, check the expected PSK length using the ciphersuite/KDF
parameters, then reject malformed inputs prior to calling
ExternalPsk::new(join_psk_id_bytes) and psk_id.store(&self.provider,
pairing_data.join_psk()) to avoid registering invalid PSKs.

54-64: ⚠️ Potential issue | 🔴 Critical

Redact ExternalCommitResult and protect group_event_key as secret material.

#[derive(Debug)] here can expose group_id and raw key bytes. Also group_event_key should not be stored as plain [u8; 32] without secret handling/zeroization.

🔐 Proposed fix
-#[derive(Debug)]
 pub struct ExternalCommitResult {
@@
-    pub group_id: GroupId,
+    pub group_id: GroupId,
@@
-    pub group_event_key: [u8; 32],
+    pub group_event_key: mdk_storage_traits::Secret<[u8; 32]>,
 }
+
+impl std::fmt::Debug for ExternalCommitResult {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        f.debug_struct("ExternalCommitResult")
+            .field("commit_message", &self.commit_message)
+            .field("group_id", &"[REDACTED]")
+            .field("group_event_key", &"[REDACTED]")
+            .finish()
+    }
+}

As per coding guidelines "Never log, expose, or include in Debug implementations: encryption keys, MLS exporter secrets, mls_group_id, or nostr_group_id in log messages, error messages, debug output, panic messages, or user-facing error descriptions".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/mdk-core/src/groups.rs` around lines 54 - 64, The ExternalCommitResult
struct currently derives Debug and stores group_event_key as a plain [u8; 32],
which can leak secrets and group identifiers; remove #[derive(Debug)] from
ExternalCommitResult, replace the raw group_event_key field with a secret
wrapper type that zeroizes on drop (e.g., a secrecy::Secret or a custom type
implementing zeroize::Zeroize), ensure group_id is not printed in any Debug
implementation, and implement a custom Debug for ExternalCommitResult that
redacts group_event_key and group_id (e.g., show "<redacted>") so no raw key
bytes or sensitive IDs are exposed.

1547-1560: ⚠️ Potential issue | 🟠 Major

Partial failure can strand earlier groups with pending commits.

If one later group fails, earlier successful add_members() calls already staged pending commits but this method returns Err and drops recovery context.

🧯 Suggested rollback pattern
     let mut group_welcomes = Vec::with_capacity(group_ids.len());
+    let mut staged_groups: Vec<GroupId> = Vec::new();

     for group_id in group_ids {
-        let result = self.add_members(group_id, std::slice::from_ref(key_package_event))?;
+        let result = match self.add_members(group_id, std::slice::from_ref(key_package_event)) {
+            Ok(r) => r,
+            Err(e) => {
+                for staged_group_id in staged_groups.iter().rev() {
+                    let _ = self.clear_pending_commit(staged_group_id);
+                }
+                return Err(e);
+            }
+        };
+        staged_groups.push(group_id.clone());
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/mdk-core/src/groups.rs` around lines 1547 - 1560, The loop calls
self.add_members(...) for multiple group_ids and if a later iteration returns
Err it leaves earlier groups with staged pending commits; modify the code to
track successful group_ids/results as you loop and on any error run a
rollback/cleanup for each previously-successful group (e.g., call the
group-level API that cancels or discards the staged commit such as a
revert_pending_commit/cancel_pending_commit method on the group or on self),
ensure rollbacks/errors are best-effort (log/collect but do not mask the
original add_members error) and then return the original Err; reference
self.add_members, group_ids, group_welcomes and GroupWelcomeData when locating
the change.
🧹 Nitpick comments (3)
crates/mdk-core/src/messages/validation.rs (2)

435-543: Move these use statements to module scope.

The local imports here violate the repo’s Rust style rule, and the BasicCredential ones are redundant because it is already imported on Line 7. As per coding guidelines "All use statements must be placed at the TOP of their containing scope, never inside functions, methods, blocks, or individual test functions".

Also applies to: 2315-2315

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/mdk-core/src/messages/validation.rs` around lines 435 - 543, The local
`use` declarations inside validate_external_commit_authorization,
extract_joining_pubkey, and validate_external_commit_identity_proof should be
moved to module scope: remove the in-function `use
openmls::prelude::BasicCredential;` lines (they are redundant since
BasicCredential is already imported at the top) and add the MIP-06 imports `use
crate::mip06::{NostrIdentityProof, verify_identity_proof};` and any remaining
needed `use` items at the top of the file so all `use` statements live at module
scope rather than inside those functions.

2306-2382: Add one true end-to-end assertion for the production path.

This test proves the helper validators accept a legitimate External Commit, but it bypasses the new process_message/process_commit wiring by calling mls_group.process_message(...) and the helpers directly. A companion test that feeds the actual emitted message through the MDK message-processing entrypoint would catch regressions in authenticated_data forwarding and Sender::NewMemberCommit dispatch.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/mdk-core/src/messages/validation.rs` around lines 2306 - 2382, Add a
companion end-to-end test that sends the serialized External Commit through the
MDK message-processing entrypoint instead of calling
mls_group.process_message(...) and helper validators directly: build the pairing
payload and create the External Commit as before, then pass
commit_result.commit_message into the device's normal incoming-message handler
(the MDK entrypoint method on the device struct that receives/dispatches
messages, e.g., the Device::handle_incoming_message / process_incoming_message
equivalent) so the MDK wiring runs (authenticated_data forwarding and
Sender::NewMemberCommit dispatch), and finally assert the production outcome
(e.g., that the device merged the staged commit or that load_mls_group reflects
the new member / merge_pending_commit succeeded) to confirm the full path works.
Ensure the test references alice_device1, alice_device2,
commit_result.commit_message and uses the device entrypoint method to process
the message and then checks merge_staged_commit/merge_pending_commit or group
membership as the final assertion.
crates/mdk-core/src/mip06/pairing_crypto.rs (1)

29-30: Prefer role-neutral naming for the wire field and helper docs.

existing_ephemeral_pubkey and the direction-specific doc comments imply this helper is only “existing device → new device”, but the primary caller in crates/mdk-core/src/mip06/mod.rs:85-127 reuses the same primitive in both pairing phases. Renaming toward sender_ephemeral_pubkey / recipient_pairing_pubkey would make the protocol flow much easier to audit.

Also applies to: 81-82, 109-117, 171-176

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/mdk-core/src/mip06/pairing_crypto.rs` around lines 29 - 30, Rename the
direction-specific field and docs to be role-neutral: change the struct field
existing_ephemeral_pubkey to sender_ephemeral_pubkey (or
recipient_pairing_pubkey if you prefer) and update all occurrences and helper
doc comments in pairing_crypto.rs (including the places referenced around the
other blocks) to describe the value as "the sender's ephemeral X25519 public
key" (or "the pairing peer's ephemeral public key") without implying a fixed
device direction; ensure all uses in functions such as the pairing helper that
read/write existing_ephemeral_pubkey are updated to the new identifier and that
the docstrings explain the field is reused in both pairing phases.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@crates/mdk-core/src/groups.rs`:
- Line 470: There are function-local `use` statements (e.g., `use
tls_codec::{DeserializeBytes as _, Serialize as _};` at line ~470 and similar
occurrences at lines ~1334, 1484, 1543, 1575, 1618-1619, 1672) that violate the
style rule; move each of these `use` imports out of their function/block into
the top of the module scope (the top of crates::mdk-core::groups.rs) and remove
the local `use` lines so the referenced traits/types remain available to the
functions (ensure names such as DeserializeBytes and Serialize remain imported
at module scope).

In `@crates/mdk-core/src/key_packages.rs`:
- Around line 846-856: The test fixture valid_extensions_tag_values() is
incorrectly including GREASE/default extension IDs (e.g., "0x0002","0x0003")
that create_key_package_for_event() and crates/mdk-core/src/constant.rs
explicitly exclude from emitted TAG_EXTENSIONS; update
valid_extensions_tag_values() to remove those GREASE IDs (and any other
default/GREASE IDs referenced in constant.rs) and ensure both the non-mip06 and
mip06 variants only list the actual extension tag hex strings that
create_key_package_for_event() advertises so the fixture matches the producer's
emitted mls_extensions precisely.

In `@crates/mdk-core/src/messages/validation.rs`:
- Line 706: Remove the unused import of tls_codec::Deserialize (the line `use
tls_codec::Deserialize as _`) from the test module in validation.rs to satisfy
Clippy; locate the `use tls_codec::Deserialize as _` statement in the tests
(search for the symbol `Deserialize`) and delete it so the unused-import warning
is cleared and CI can pass.

In `@crates/mdk-core/src/mip06/pairing_crypto.rs`:
- Around line 128-142: Replace OsRng usage with getrandom-based byte fills:
instead of StaticSecret::random_from_rng(OsRng) create a 32-byte array, call
getrandom::getrandom/getrandom::fill to populate it, then construct the X25519
StaticSecret from those bytes (so existing_secret originates from the filled
byte array); similarly, replace OsRng.fill_bytes(&mut nonce_bytes) by
getrandom::fill(&mut nonce_bytes) to generate the 12-byte nonce. Apply the same
pattern in generate_new_device_keypair() where a secret/nonce is generated so
all key and nonce material is produced via getrandom before constructing
StaticSecret/X25519PublicKey or using the nonce_bytes; keep existing calls to
reject_zero_shared_secret and derive_key unchanged.
- Around line 176-180: The decrypt_pairing_message API and key-generation should
use Secret<T> for private keys and derive the public key inside decryption:
change generate_new_device_keypair() to return Secret<StaticSecret> (so the
private key zeroizes on drop), update decrypt_pairing_message to accept a
Secret<StaticSecret> new_ephemeral_privkey (remove the new_ephemeral_pubkey
parameter) and compute the corresponding public key internally (e.g., via
x25519_dalek::PublicKey::from(&priv)). Update all callers to pass the
Secret-wrapped private key and remove manual public-key handling; ensure types
match Secret<T> wrappers used elsewhere in the repo.

In `@crates/mdk-core/src/mip06/pairing.rs`:
- Around line 69-81: GroupPairingDataV1 currently stores sensitive bytes in
plain fields and derives Debug, which can leak secrets; change group_event_key
and join_psk to be wrapped in a Secret<T>-style wrapper (e.g., Secret<[u8;32]>
and Secret<Vec<u8>>), add ZeroizeOnDrop/Zeroize to the struct (or derive
ZeroizeOnDrop) so secrets are zeroed when dropped, remove the derived Debug and
implement a custom Debug that redacts those fields (showing only placeholders),
and update any serializers/deserializers or accessors (e.g., constructors,
getters) for GroupPairingDataV1 to unwrap/wrap the Secret types safely when
needed.

---

Duplicate comments:
In `@crates/mdk-core/src/groups.rs`:
- Around line 1594-1599: In register_join_psk, the tracing::debug call is
logging the sensitive group_id; remove the group_id = %hex::encode(...) field
(or replace it with a non-identifying placeholder such as "<redacted>") so the
tracing::debug invocation no longer emits the raw GroupId; keep the epoch
logging and message unchanged to preserve context.
- Around line 1705-1710: Validate the length of pairing_data.join_psk() against
the required KDF output size for the selected ciphersuite before creating the
ExternalPsk or calling PreSharedKeyId::new; if the length is incorrect, return
an appropriate Error::PairingError. Specifically, check the expected PSK length
using the ciphersuite/KDF parameters, then reject malformed inputs prior to
calling ExternalPsk::new(join_psk_id_bytes) and psk_id.store(&self.provider,
pairing_data.join_psk()) to avoid registering invalid PSKs.
- Around line 54-64: The ExternalCommitResult struct currently derives Debug and
stores group_event_key as a plain [u8; 32], which can leak secrets and group
identifiers; remove #[derive(Debug)] from ExternalCommitResult, replace the raw
group_event_key field with a secret wrapper type that zeroizes on drop (e.g., a
secrecy::Secret or a custom type implementing zeroize::Zeroize), ensure group_id
is not printed in any Debug implementation, and implement a custom Debug for
ExternalCommitResult that redacts group_event_key and group_id (e.g., show
"<redacted>") so no raw key bytes or sensitive IDs are exposed.
- Around line 1547-1560: The loop calls self.add_members(...) for multiple
group_ids and if a later iteration returns Err it leaves earlier groups with
staged pending commits; modify the code to track successful group_ids/results as
you loop and on any error run a rollback/cleanup for each previously-successful
group (e.g., call the group-level API that cancels or discards the staged commit
such as a revert_pending_commit/cancel_pending_commit method on the group or on
self), ensure rollbacks/errors are best-effort (log/collect but do not mask the
original add_members error) and then return the original Err; reference
self.add_members, group_ids, group_welcomes and GroupWelcomeData when locating
the change.

In `@crates/mdk-core/src/mip06/pairing.rs`:
- Around line 41-50: from_bytes currently ignores the remainder from
Self::tls_deserialize_bytes allowing trailing junk; update from_bytes (the
function named from_bytes) to reject any non-empty remainder returned by
tls_deserialize_bytes by returning an Error::PairingError (or another
appropriate error) when the second element (remainder) is non-empty, then
proceed to call validate_pairing_version on val.version and return Ok(val) only
when remainder is empty; ensure the error uses the same formatting style as
existing PairingError messages.
- Around line 86-105: The constructor new() currently only checks that join_psk
is non-empty but must reject malformed lengths since the ciphersuite uses
SHA-256 (KDF.Nh == 32); update new() to enforce join_psk.len() == 32 and return
Err(Error::PairingError("join_psk must be 32 bytes".to_string())) when that
check fails (keep existing group_info non-empty check and the group_event_key
fixed-size array), referencing the new() function and Error::PairingError when
adding this explicit length validation for join_psk according to the
MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519 ciphersuite.
- Around line 146-152: PairingPayload::new currently allows an empty groups Vec
which violates the TLS shape groups<1..2^32-1>; change the API to validate and
reject empty input: either make PairingPayload::new return a Result<Self, Error>
(e.g., Result<PairingPayload, PairingPayloadError>) and return an Err when
groups.is_empty(), or keep the signature but add a clear panic/assert (not
preferred) and update callers; specifically update export_groups_pairing_payload
to handle the new error (do not construct PairingPayload on an empty groups
list) so no empty PairingPayload is ever serialized.

---

Nitpick comments:
In `@crates/mdk-core/src/messages/validation.rs`:
- Around line 435-543: The local `use` declarations inside
validate_external_commit_authorization, extract_joining_pubkey, and
validate_external_commit_identity_proof should be moved to module scope: remove
the in-function `use openmls::prelude::BasicCredential;` lines (they are
redundant since BasicCredential is already imported at the top) and add the
MIP-06 imports `use crate::mip06::{NostrIdentityProof, verify_identity_proof};`
and any remaining needed `use` items at the top of the file so all `use`
statements live at module scope rather than inside those functions.
- Around line 2306-2382: Add a companion end-to-end test that sends the
serialized External Commit through the MDK message-processing entrypoint instead
of calling mls_group.process_message(...) and helper validators directly: build
the pairing payload and create the External Commit as before, then pass
commit_result.commit_message into the device's normal incoming-message handler
(the MDK entrypoint method on the device struct that receives/dispatches
messages, e.g., the Device::handle_incoming_message / process_incoming_message
equivalent) so the MDK wiring runs (authenticated_data forwarding and
Sender::NewMemberCommit dispatch), and finally assert the production outcome
(e.g., that the device merged the staged commit or that load_mls_group reflects
the new member / merge_pending_commit succeeded) to confirm the full path works.
Ensure the test references alice_device1, alice_device2,
commit_result.commit_message and uses the device entrypoint method to process
the message and then checks merge_staged_commit/merge_pending_commit or group
membership as the final assertion.

In `@crates/mdk-core/src/mip06/pairing_crypto.rs`:
- Around line 29-30: Rename the direction-specific field and docs to be
role-neutral: change the struct field existing_ephemeral_pubkey to
sender_ephemeral_pubkey (or recipient_pairing_pubkey if you prefer) and update
all occurrences and helper doc comments in pairing_crypto.rs (including the
places referenced around the other blocks) to describe the value as "the
sender's ephemeral X25519 public key" (or "the pairing peer's ephemeral public
key") without implying a fixed device direction; ensure all uses in functions
such as the pairing helper that read/write existing_ephemeral_pubkey are updated
to the new identifier and that the docstrings explain the field is reused in
both pairing phases.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: c2b87b7b-0cd5-4e7f-8464-7994b1cd6c76

📥 Commits

Reviewing files that changed from the base of the PR and between 03096ed and 084213c.

📒 Files selected for processing (7)
  • crates/mdk-core/src/groups.rs
  • crates/mdk-core/src/key_packages.rs
  • crates/mdk-core/src/messages/validation.rs
  • crates/mdk-core/src/mip06/extension.rs
  • crates/mdk-core/src/mip06/mod.rs
  • crates/mdk-core/src/mip06/pairing.rs
  • crates/mdk-core/src/mip06/pairing_crypto.rs
🚧 Files skipped from review as they are similar to previous changes (1)
  • crates/mdk-core/src/mip06/mod.rs

Comment thread crates/mdk-core/src/groups.rs Outdated
Comment thread crates/mdk-core/src/key_packages.rs Outdated
Comment thread crates/mdk-core/src/messages/validation.rs
Comment thread crates/mdk-core/src/mip06/pairing_crypto.rs Outdated
Comment thread crates/mdk-core/src/mip06/pairing_crypto.rs
Comment thread crates/mdk-core/src/mip06/pairing.rs Outdated
@github-actions

Copy link
Copy Markdown

❌ Coverage: 93.68% → 93.16% (-0.52%)

@coderabbitai

coderabbitai Bot commented Apr 13, 2026

Copy link
Copy Markdown

Tip

For best results, initiate chat on the files or code changes.

The PR description image shows a charming pixel-art marmot in a cozy underground burrow, sitting at a wooden desk and working on three devices — a phone (left), a desktop monitor (center), and a tablet (right). Each device shows a chat interface with messages like "Alice: Hey! Check this key!", "Bob: Secure chat!", and "Alice: Secure chat!". Golden padlock icons are connected by glowing cyan dotted lines between the devices, illustrating the multi-device encrypted key exchange concept. The burrow features warm orange cave walls with glowing veins, a hanging lantern, wooden shelves with scrolls, a world map, and a mug — a fitting thematic backdrop for MIP-06.

@dannym-arx — thank you for the corrections and the 🥕🥕🥕🥕🥕! Happy to elaborate on issue #7 for the team.


Issue #7 — PSK Cleanup Proposal

The core challenge is: after merge_pending_commit advances the epoch, the PSK for the previous epoch is no longer needed, but the current code never deletes it. Here's a concrete low-complexity approach:

  1. At register_join_psk call time, before storing the new PSK, compute the current epoch's JoinPskId (before the epoch transition), save it as a "pending deletion" alongside the group state (e.g., a small field prev_join_psk_id: Option<JoinPskId> in the persisted group record).

  2. At merge_pending_commit / successful epoch advance, read prev_join_psk_id, delete it from the PSK store, then clear the field.

This ensures at most one stale PSK entry ever exists per group (the one from the epoch just committed), and it's cleaned up deterministically on the next successful merge. No timer, no background task, no unbounded growth.

Pseudo-sketch:

// In register_join_psk, before storing new PSK:
if let Some(prev_psk_id) = self.load_prev_join_psk_id(group_id)? {
    prev_psk_id.delete(&self.provider); // best-effort; log on failure
}
// Store new PSK, then save current psk_id as the new "prev" for the next epoch
self.save_prev_join_psk_id(group_id, &psk_id)?;
psk_id.store(&self.provider, &join_psk_bytes)?;

The tradeoff: requires one extra field in the persisted group record (32 + 24 = 56 bytes per group) and a storage migration. If that's too invasive for now, a simpler fallback is to key the PSK store by (group_id, epoch_number) and do a cleanup sweep of old epochs on merge — but that requires iterating the PSK store, which OpenMLS's PSK store API may not support.

Happy to dig into whichever direction the team prefers. 🐾


🧠 Learnings used
Learnt from: dannym-arx
Repo: marmot-protocol/mdk PR: 0
File: :0-0
Timestamp: 2026-04-13T17:12:16.346Z
Learning: In marmot-protocol/mdk (crates/mdk-core/src/groups.rs), the `[4..]` byte offset used in `export_group_context_bytes` and `join_group_via_external_commit` to skip to the GroupContext within a serialized GroupInfo is grounded in RFC 9420 §6, which defines `MLSMessage` wire format as `version (u16) + wire_format (u16) + body`. This is a stable MLS spec-level layout, not an OpenMLS internal. Do not flag this offset as fragile or implementation-dependent.

Learnt from: dannym-arx
Repo: marmot-protocol/mdk PR: 0
File: :0-0
Timestamp: 2026-04-13T17:12:16.346Z
Learning: In marmot-protocol/mdk (MIP-06, marmot-protocol/marmot#44), the join PSK used in External Commits is correctly typed as an **External PSK** (`Psk::External`), not a Resumption PSK. The MIP-06 spec was updated to clarify this during review of marmot#44. Flagging External PSK usage in join_psk.rs or groups.rs as a spec deviation is a false positive.

Learnt from: dannym-arx
Repo: marmot-protocol/mdk PR: 205
File: crates/mdk-cbindings/src/welcomes.rs:64-70
Timestamp: 2026-03-04T20:41:29.623Z
Learning: In marmot-protocol/mdk, the security restriction on `mls_group_id` and `nostr_group_id` (and other sensitive identifiers) applies ONLY to side-channel leakage contexts: log messages (tracing macros), error strings, debug output (Debug trait impls), and panic messages. It does NOT apply to primary FFI/API return values (e.g., the Welcome struct returned via C bindings or UniFFI). These fields are essential application data that callers need to accept/decline welcomes and identify groups. Flagging their presence in API return types is a false positive.

Learnt from: jgmontoya
Repo: marmot-protocol/mdk PR: 223
File: crates/mdk-core/src/groups.rs:1640-1665
Timestamp: 2026-03-09T21:20:16.704Z
Learning: In marmot-protocol/mdk (crates/mdk-core/src/groups.rs), `add_members` and `remove_members` always produce immediate commits (not queued proposals), so the proposal store is effectively empty before any `update_group_data`/`update_group_context_extensions` call made via the public API. The concern that `prune_and_validate_admin_update` validates against stale (pre-proposal) membership is acknowledged as theoretically possible (only via a `leave_group` proposal received through `process_message`), but the MDK maintainers have decided the fix adds disproportionate complexity for a scenario that cannot arise through normal API usage. Do not re-flag this pattern.

Learnt from: dannym-arx
Repo: marmot-protocol/mdk PR: 0
File: :0-0
Timestamp: 2026-04-13T17:12:16.346Z
Learning: In marmot-protocol/mdk (crates/mdk-uniffi/src/lib.rs), the Nostr secret key is intentionally passed as a `String` across the FFI boundary without zeroization in `join_groups_via_external_commit`. This is by design: memory across an FFI boundary is managed by the caller's runtime, so zeroizing the Rust-side allocation would not reach the caller's copy. Do not flag the absence of `zeroize` on this parameter as a security issue.

Learnt from: jgmontoya
Repo: marmot-protocol/mdk PR: 250
File: crates/mdk-memory-storage/src/lib.rs:1051-1054
Timestamp: 2026-04-05T13:26:09.683Z
Learning: In marmot-protocol/mdk (crates/mdk-memory-storage/src/lib.rs), `delete_group` prunes `group_snapshots` after releasing the `inner` write lock, creating a theoretical race with `create_group_snapshot` and `rollback_group_to_snapshot`. The maintainers intentionally accept this: the race requires simultaneous `delete_group` and `process_message` on the same group, which cannot occur through normal API usage (user must leave the group before deleting it), and the worst-case outcome is a stale snapshot entry, not data corruption. Introducing a shared higher-level lock would require changing create_group_snapshot, rollback_group_to_snapshot, and release_group_snapshot — disproportionate complexity. Do not re-flag this pattern.

Learnt from: dannym-arx
Repo: marmot-protocol/mdk PR: 253
File: crates/mdk-core/src/messages/create.rs:143-154
Timestamp: 2026-04-10T07:31:46.841Z
Learning: In marmot-protocol/mdk (crates/mdk-core/src/messages/create.rs), per-event expiry and per-group expiry are intentionally orthogonal. When `group.disappearing_message_duration_secs` is `Some(n)`, the group-level timer is authoritative and replaces any caller-supplied `EventTag::expiration`. When it is `None`, caller-supplied per-event expiration tags are passed through unchanged. Do not flag the `None => tags` branch in the `match group.disappearing_message_duration_secs` block as a bug or inconsistency.

Learnt from: dannym-arx
Repo: marmot-protocol/mdk PR: 239
File: crates/mdk-sqlite-storage/src/groups.rs:185-190
Timestamp: 2026-03-30T13:28:56.932Z
Learning: In marmot-protocol/mdk (crates/mdk-sqlite-storage/src/groups.rs), the call to `validate_group_fields(...)` in `save_group` intentionally passes `usize::MAX` for the `max_admins` parameter. The SQLite backend bounds admin count indirectly via `MAX_ADMIN_PUBKEYS_JSON_SIZE` (50 KB, ~735 pubkeys) through subsequent JSON byte-size validation, rather than a direct count limit. The memory backend uses a count limit. These are two intentionally different but equivalent validation strategies per backend. Do not flag `usize::MAX` here as disabling admin-count validation.

Learnt from: CR
Repo: marmot-protocol/mdk PR: 0
File: coderabbit-custom-pre-merge-checks-unique-id-file-non-traceable-F7F2B60C-1728-4C9A-8889-4F2235E186CA.txt:0-0
Timestamp: 2026-01-13T16:06:00.938Z
Learning: Applies to **/*.rs : No sensitive identifiers (mls_group_id, nostr_group_id, GroupId, encryption keys, exporter secrets) in Debug trait implementations that expose these fields without redaction

Learnt from: mubarakcoded
Repo: marmot-protocol/mdk PR: 111
File: crates/mdk-uniffi/CHANGELOG.md:31-31
Timestamp: 2026-01-07T08:18:40.637Z
Learning: In the marmot-protocol/mdk repository, when a feature spans multiple layers (core Rust API and UniFFI bindings), different PRs may be used for each layer. Each crate's CHANGELOG should reference the PR that actually modified that specific crate, not necessarily the PR that introduced the feature in the core API. For example, core API changes reference one PR while UniFFI binding exposure references a separate PR.

Learnt from: CR
Repo: marmot-protocol/mdk PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-02-10T13:56:23.563Z
Learning: For security vulnerabilities, email jipf.dev (do not open public issues). MDK handles cryptographic operations so be careful with key material. All message encryption uses MLS protocol with forward secrecy. Review security implications of any changes to cryptographic code

Learnt from: CR
Repo: marmot-protocol/mdk PR: 0
File: coderabbit-custom-pre-merge-checks-unique-id-file-non-traceable-F7F2B60C-1728-4C9A-8889-4F2235E186CA.txt:0-0
Timestamp: 2026-01-13T16:06:00.938Z
Learning: Applies to **/*.rs : No sensitive identifiers (mls_group_id, nostr_group_id, GroupId, encryption keys, exporter secrets) in panic!() or unreachable!() messages

@github-actions

Copy link
Copy Markdown

❌ Coverage: 93.68% → 93.11% (-0.57%)

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5

♻️ Duplicate comments (2)
crates/mdk-core/src/mip06/pairing.rs (1)

72-84: ⚠️ Potential issue | 🟠 Major

Redact pairing secrets instead of deriving Debug.

GroupPairingDataV1 stores group_event_key and join_psk as plain bytes and derives Debug; PairingPayload then inherits that leak surface through its own derived Debug. Any accidental debug print of either type will expose live pairing secrets. These fields should follow the repo’s Secret<T> + redacted Debug pattern.

As per coding guidelines, "Use Secret<T> wrapper for sensitive values, derive ZeroizeOnDrop for types containing key material" and "Never log, expose, or include in Debug implementations: encryption keys, MLS exporter secrets, mls_group_id, or nostr_group_id in log messages, error messages, debug output, panic messages, or user-facing error descriptions."

Also applies to: 133-140

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/mdk-core/src/mip06/pairing.rs` around lines 72 - 84, Replace plaintext
secret fields in GroupPairingDataV1 with the repo's Secret<T> wrapper and add
zeroization: change group_event_key: [u8; 32] to group_event_key: Secret<[u8;
32]> and join_psk: Vec<u8> to join_psk: Secret<Vec<u8>>; add/derive
ZeroizeOnDrop (and keep TlsSerialize/TlsDeserialize as needed) so key material
is zeroed on drop, and rely on Secret<T>'s redacted Debug so derived Debug on
GroupPairingDataV1 and any container types like PairingPayload no longer leak
secrets; apply the same conversion to the other pairing struct referenced in the
comment (the block around lines 133-140) to ensure all pairing secrets use
Secret<T> + ZeroizeOnDrop and are redacted from Debug output.
crates/mdk-core/src/mip06/pairing_crypto.rs (1)

8-11: ⚠️ Potential issue | 🟠 Major

Use getrandom for the X25519 secret and nonce paths.

This file still generates the X25519 secret and AEAD nonces with OsRng at Lines 128, 148, and 225. The repo’s crypto rules require getrandom for key generation/nonces, so MIP-06 is still off-pattern in its most security-sensitive path.

Run this to verify no OsRng-based generation remains after the fix:

#!/bin/bash
rg -n 'OsRng|random_from_rng|fill_bytes' crates/mdk-core/src/mip06/pairing_crypto.rs

As per coding guidelines, "For key generation, MUST use getrandom for all key generation (CSPRNG), never use weak RNGs."

Also applies to: 128-148, 224-227

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/mdk-core/src/mip06/pairing_crypto.rs` around lines 8 - 11, Replace all
uses of OsRng-based generation for X25519 secrets and AEAD nonces with
getrandom-based filling: where StaticSecret is currently constructed from OsRng
(X25519 secret creation) and where nonce bytes are produced with OsRng (AEAD
nonce generation), allocate the appropriate fixed-size byte arrays, call
getrandom::getrandom(...) to fill them, then create the X25519 StaticSecret from
the secret byte array (and the AEAD Nonce from the nonce byte array). Ensure you
zeroize the secret byte buffer after constructing the StaticSecret and remove
any remaining references to OsRng, random_from_rng, or fill_bytes in
pairing_crypto.rs so key/nonce generation uses getrandom only.
🧹 Nitpick comments (1)
crates/mdk-core/src/mip06/mod.rs (1)

90-123: This integration test doesn’t exercise the public MIP-06 path end-to-end.

Phase 1/2 pairing messages are round-tripped locally with the same ephemeral keypair, and Lines 490-496 feed the External Commit straight into OpenMLS instead of MDK::process_message(). That leaves both the actual device-to-device transport contract and the public validation path outside the “full flow” scenario this test name suggests. Either drive the exchange through the public MDK API or split this into narrower tests with more precise names.

As per coding guidelines, "For this PR’s cryptographic code changes, ensure any new code paths have unit/integration tests for both success and error cases (including feature-gated mip06 paths)."

Also applies to: 438-543

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/mdk-core/src/mip06/mod.rs` around lines 90 - 123, The test is
bypassing the public MDK API and using local round-trips
(encrypt_pairing_message / decrypt_pairing_message and directly feeding an
OpenMLS External Commit) so it does not exercise the public MIP-06 path; update
the test to drive the pairing exchange through the MDK public methods (e.g.,
create DevicePairingRequest via DevicePairingRequest::new, send it into
MDK::process_message on the recipient side instead of calling
decrypt_pairing_message directly, and likewise send DevicePairingResponse back
through MDK::process_message) or split the current test into two focused tests
(one unit test for encrypt_pairing_message/decrypt_pairing_message and one
integration test that exercises add_device_to_groups and MDK::process_message
end-to-end), ensuring both success and error cases for the mip06 feature are
covered.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@crates/mdk-core/src/groups.rs`:
- Around line 1901-1926: The finalized MLS group is never persisted into MDK
storage, causing later sync_group_metadata_from_mls()->get_group(...) to fail
with GroupNotFound; after finalize(...) and before returning
ExternalCommitResult in the external-commit path (the block that creates
mls_group, commit_bundle, commit_msg and group_id), construct and persist the
corresponding group_types::Group row and any required relay metadata using the
provider.storage() (use the same mdk_storage_traits::GroupId derived from
mls_group.group_id() and the group_event_key from pairing_data), and ensure the
stored entry includes the necessary fields so
merge_pending_commit()/sync_group_metadata_from_mls() can find the group.
- Around line 1629-1647: register_join_psk stores a new persistent join PSK
every epoch without deleting older or failed-attempt PSKs, causing leaks; update
register_join_psk (and the similar code paths that call Psk::External /
PreSharedKeyId::new and .store, e.g., join_group_via_external_commit and the
other noted blocks) to: (1) before storing a new PSK, attempt to find and delete
any existing/superseded PSK records for this group/id (use the same
PreSharedKeyId/identifier mechanism used to store them), and (2) if storing
succeeds but subsequent operations fail (or if you store preemptively in
join_group_via_external_commit), ensure you delete the newly stored PSK in the
error path/rollback so failed pairing attempts don’t leave records behind; use
the same provider-backed delete/remove API that complements
PreSharedKeyId::store to perform these deletions and propagate sensible
PairingError messages on failures.
- Around line 1557-1563: enable_multi_device() currently builds a commit_event
but never records it, so call the same tracking used elsewhere before returning:
after creating serialized_commit/commit_event, invoke
self.track_processed_message(...) to record the enablement commit (use the same
group identifier and the commit message/serialized bytes as other
commit-producing paths) so the local client treats its own relay echo as
already-processed, then return Ok(commit_event).

In `@crates/mdk-core/src/mip06/pairing.rs`:
- Around line 41-53: from_bytes currently only checks TLS deserialization,
version, and trailing bytes but skips semantic validation; after deserializing
(the result bound to val) call its validate() method (e.g.,
PairingPayload::validate or GroupPairingDataV1::validate) and propagate
validation errors as Error::PairingError to ensure empty groups, malformed
join_psk lengths, or empty group_info are rejected; update the from_bytes
implementations (the functions named from_bytes around the shown diff and the
similar ones at the other affected blocks) to invoke validate() after
validate_pairing_version(val.version)? and before Ok(val), mapping any
validation failure into the same PairingError path.

In `@crates/mdk-uniffi/src/lib.rs`:
- Around line 2227-2237: The loop currently calls
mdk.join_group_via_external_commit for each group and returns early on the first
error, discarding prior successes; change the function to emit per-group
outcomes instead of failing fast: iterate payload.groups(), call
mdk.join_group_via_external_commit for each, and push either an
ExternalCommitJoinResult on success or the corresponding error into a
Vec<Result<ExternalCommitJoinResult, /* appropriate error type, e.g. MdkError
*/>> (reference mdk.join_group_via_external_commit, ExternalCommitJoinResult,
payload.groups(), results); update the function’s return type on the FFI
boundary to return this per-group Result vector (or alternatively change the API
to accept a single-group payload) so callers can see which groups succeeded and
which failed without losing prior successes.

---

Duplicate comments:
In `@crates/mdk-core/src/mip06/pairing_crypto.rs`:
- Around line 8-11: Replace all uses of OsRng-based generation for X25519
secrets and AEAD nonces with getrandom-based filling: where StaticSecret is
currently constructed from OsRng (X25519 secret creation) and where nonce bytes
are produced with OsRng (AEAD nonce generation), allocate the appropriate
fixed-size byte arrays, call getrandom::getrandom(...) to fill them, then create
the X25519 StaticSecret from the secret byte array (and the AEAD Nonce from the
nonce byte array). Ensure you zeroize the secret byte buffer after constructing
the StaticSecret and remove any remaining references to OsRng, random_from_rng,
or fill_bytes in pairing_crypto.rs so key/nonce generation uses getrandom only.

In `@crates/mdk-core/src/mip06/pairing.rs`:
- Around line 72-84: Replace plaintext secret fields in GroupPairingDataV1 with
the repo's Secret<T> wrapper and add zeroization: change group_event_key: [u8;
32] to group_event_key: Secret<[u8; 32]> and join_psk: Vec<u8> to join_psk:
Secret<Vec<u8>>; add/derive ZeroizeOnDrop (and keep TlsSerialize/TlsDeserialize
as needed) so key material is zeroed on drop, and rely on Secret<T>'s redacted
Debug so derived Debug on GroupPairingDataV1 and any container types like
PairingPayload no longer leak secrets; apply the same conversion to the other
pairing struct referenced in the comment (the block around lines 133-140) to
ensure all pairing secrets use Secret<T> + ZeroizeOnDrop and are redacted from
Debug output.

---

Nitpick comments:
In `@crates/mdk-core/src/mip06/mod.rs`:
- Around line 90-123: The test is bypassing the public MDK API and using local
round-trips (encrypt_pairing_message / decrypt_pairing_message and directly
feeding an OpenMLS External Commit) so it does not exercise the public MIP-06
path; update the test to drive the pairing exchange through the MDK public
methods (e.g., create DevicePairingRequest via DevicePairingRequest::new, send
it into MDK::process_message on the recipient side instead of calling
decrypt_pairing_message directly, and likewise send DevicePairingResponse back
through MDK::process_message) or split the current test into two focused tests
(one unit test for encrypt_pairing_message/decrypt_pairing_message and one
integration test that exercises add_device_to_groups and MDK::process_message
end-to-end), ensuring both success and error cases for the mip06 feature are
covered.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: acbe4a2d-c57f-4f1b-b6dc-af70284c4da8

📥 Commits

Reviewing files that changed from the base of the PR and between d0f15a5 and 72dc81c.

📒 Files selected for processing (9)
  • Cargo.toml
  • crates/mdk-core/src/groups.rs
  • crates/mdk-core/src/key_packages.rs
  • crates/mdk-core/src/mip06/device_name.rs
  • crates/mdk-core/src/mip06/identity_proof.rs
  • crates/mdk-core/src/mip06/mod.rs
  • crates/mdk-core/src/mip06/pairing.rs
  • crates/mdk-core/src/mip06/pairing_crypto.rs
  • crates/mdk-uniffi/src/lib.rs
✅ Files skipped from review due to trivial changes (2)
  • Cargo.toml
  • crates/mdk-core/src/key_packages.rs
🚧 Files skipped from review as they are similar to previous changes (1)
  • crates/mdk-core/src/mip06/identity_proof.rs

Comment thread crates/mdk-core/src/groups.rs
Comment thread crates/mdk-core/src/groups.rs
Comment thread crates/mdk-core/src/groups.rs Outdated
Comment thread crates/mdk-core/src/mip06/pairing.rs Outdated
Comment thread crates/mdk-uniffi/src/lib.rs
@github-actions

Copy link
Copy Markdown

❌ Coverage: 93.68% → 93.07% (-0.61%)

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

♻️ Duplicate comments (1)
crates/mdk-core/src/groups.rs (1)

1639-1654: ⚠️ Potential issue | 🟠 Major

Join-PSK entries can still accumulate across epochs.

Current cleanup deletes only the current deterministic ID before re-store. Because the ID changes with GroupContext, older epoch PSKs remain unless separately tracked/deleted, which can lead to unbounded PSK-store growth in long-lived groups.

Also applies to: 1867-1880

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/mdk-core/src/groups.rs` around lines 1639 - 1654, The code deletes
only the freshly-derived join PSK ID (join_psk_id_bytes) so PSKs from earlier
epochs still accumulate; modify the flow so you persist and rotate the group's
previous join-PSK IDs and delete them before storing the new one: update
derive_join_psk_with_id/where you create Psk::External and call
PreSharedKeyId::new to read any stored prior PSK id(s) for this group (e.g., a
last_join_psk_id entry keyed by the group identity), call
provider.storage().delete_psk(...) for each prior id, then overwrite the stored
last_join_psk_id with the new join_psk_id_bytes and proceed to call
psk_id.store(...); apply the same change to the other occurrence at the
referenced block (lines ~1867-1880) so old epoch PSKs are removed on
re-registration.
🧹 Nitpick comments (5)
crates/mdk-core/src/mip06/mod.rs (5)

97-123: Add a wrong-recipient / tamper case for the pairing channel.

This only proves the happy path. It would not catch a regression where encrypt_pairing_message / decrypt_pairing_message stops binding ciphertext to the intended device or stops authenticating it. A different private key and a modified ciphertext should both fail here.

As per coding guidelines, "Use only authenticated encryption modes: ChaCha20-Poly1305 or AES-GCM. Never use unauthenticated encryption (AES-CBC alone, etc.)."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/mdk-core/src/mip06/mod.rs` around lines 97 - 123, Add negative tests
to ensure pairing messages are authenticated and bound to the intended
recipient: after generating (new_priv, new_pub) via generate_new_device_keypair
and producing encrypted ciphertext with encrypt_pairing_message, create a
different keypair and assert that decrypt_pairing_message with the wrong private
key returns an error (do not unwrap), and separately mutate a byte in the
encrypted payload and assert decrypt_pairing_message also errors; do the same
for both DevicePairingRequest and DevicePairingResponse paths to catch both
wrong-recipient and tampering regressions.

205-243: Add a mismatched credential_identity failure case.

This test shows the proof breaks with the wrong signer key, but it would still pass if verify_identity_proof stopped binding the proof to BasicCredential.identity, which is the impersonation boundary here.

Test sketch
     // Wrong key fails
     let other = Keys::generate();
     assert!(
         super::identity_proof::verify_identity_proof(
             &decoded,
             &other.public_key(),
             &credential_identity,
             b"signature-key",
             b"group-context",
         )
         .is_err()
     );
+
+    // Wrong credential identity also fails
+    let wrong_identity = Keys::generate().public_key().to_bytes();
+    assert!(
+        super::identity_proof::verify_identity_proof(
+            &decoded,
+            &keys.public_key(),
+            &wrong_identity,
+            b"signature-key",
+            b"group-context",
+        )
+        .is_err()
+    );

As per coding guidelines, "Credential identity MUST match event signer (prevents impersonation)".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/mdk-core/src/mip06/mod.rs` around lines 205 - 243, The test currently
checks that a proof fails with the wrong signer key but doesn't assert the proof
is bound to BasicCredential.identity; update test_identity_proof_standalone to
also generate a different credential_identity (e.g., from
other.public_key().to_bytes()), then call
super::identity_proof::verify_identity_proof(&decoded, &keys.public_key(),
&other_credential_identity, b"signature-key", b"group-context") and assert it
returns Err; this ensures construct_identity_proof/verify_identity_proof bind
the proof to the provided credential_identity (use existing variables
Keys::generate(), credential_identity, other, decoded, and the same
signature/group context symbols).

295-320: Please cover the unauthorized path for enable_multi_device.

This is the only regression coverage here for a security-sensitive state transition, and it exercises only the admin happy path. Please add a non-admin case that asserts both rejection and that no pending commit is left behind after the failure.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/mdk-core/src/mip06/mod.rs` around lines 295 - 320, Add a new test that
covers the unauthorized path for enable_multi_device: create a group with Alice
as admin, then attempt to call enable_multi_device using a non-admin identity
(e.g., Bob's Keys/MDK instance or a second user), assert that
enable_multi_device returns an Err indicating lack of authorization, assert that
merge_pending_commit either is not created or returns an appropriate error (or
calling merge_pending_commit after the failed enable returns Ok but does not
change state), and finally reload the MLS group via load_mls_group and assert
super::is_multi_device_enabled(&mls_group) is still false and that no pending
commit remains; reference the existing test flow in
test_enable_multi_device_on_existing_group and reuse functions create_group,
enable_multi_device, merge_pending_commit, load_mls_group, and
is_multi_device_enabled to implement the checks.

463-473: Add a tampered join-PSK rejection case.

The happy-path External Commit test proves a valid payload works, but it will not catch a regression where join_group_via_external_commit ignores or mis-binds group_data.join_psk(). Flipping one byte in the PSK should make the join fail before merge.

Test sketch
         let group_data = &payload.groups()[0];
 
+        let mut bad_join_psk = group_data.join_psk().to_vec();
+        bad_join_psk[0] ^= 0x01;
+        let tampered = super::GroupPairingDataV1::new(
+            *group_data.group_event_key(),
+            bad_join_psk,
+            group_data.group_info().to_vec(),
+        )
+        .unwrap();
+        assert!(alice_device2
+            .join_group_via_external_commit(&tampered, &alice_keys)
+            .is_err());
+
         let commit_result = alice_device2
             .join_group_via_external_commit(group_data, &alice_keys)
             .expect("External Commit should succeed");
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/mdk-core/src/mip06/mod.rs` around lines 463 - 473, Add a negative test
that flips a byte in the PSK from the pairing payload and asserts
join_group_via_external_commit fails instead of succeeding; locate the existing
happy-path sequence using build_pairing_payload, payload.groups()[0], and
join_group_via_external_commit, clone group_data, mutate group_data.join_psk()
(e.g., to_vec and flip one byte) so the PSK is tampered, then call
alice_device2.join_group_via_external_commit with the tampered group data and
assert it returns an Err (or fails before performing merge) rather than
producing a commit_result with the original group_id.

281-284: Prefer structured assertions in these rejection tests.

Line 377 only checks is_err(), and Lines 283 and 392 pin the tests to Display text. Matching the concrete crate::Error variant/cause will make these failures much harder to satisfy for the wrong reason.

Also applies to: 377-378, 390-394

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@crates/mdk-core/src/mip06/mod.rs` around lines 281 - 284, Replace fragile
string-based and is_err checks in the tests with structured pattern matching
against the concrete crate::Error variants: change assertions that test is_err()
to assert!(matches!(result, Err(crate::Error::SomeVariant(_)))) and replace
assertions that inspect Display text (like matching
msg.contains("ratchet_tree")) with a match on Err(crate::Error::PairingError(_))
or the exact variant/inner type expected; if you need to inspect fields, match
the inner value (e.g., Err(crate::Error::PairingError(ref detail))) and assert
on those fields explicitly rather than on Display output. This applies to the
current assertions that use is_err() and the ones referencing PairingError
message text—use assert!(matches!(...)) or if let
Err(crate::Error::PairingError(detail)) = result { assert_eq!(detail.some_field,
expected) } to make the checks structural and robust.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@crates/mdk-core/src/groups.rs`:
- Line 1922: Run rustfmt (cargo fmt --all) to fix formatting around the call
that currently ends with "&signer,"; the CI error indicates formatting mismatch
in the invocation containing the &signer argument, so reformat the file (e.g.,
adjust trailing commas/spacing in the function call where &signer is passed) and
commit the resulting changes so cargo fmt --all -- --check passes.

---

Duplicate comments:
In `@crates/mdk-core/src/groups.rs`:
- Around line 1639-1654: The code deletes only the freshly-derived join PSK ID
(join_psk_id_bytes) so PSKs from earlier epochs still accumulate; modify the
flow so you persist and rotate the group's previous join-PSK IDs and delete them
before storing the new one: update derive_join_psk_with_id/where you create
Psk::External and call PreSharedKeyId::new to read any stored prior PSK id(s)
for this group (e.g., a last_join_psk_id entry keyed by the group identity),
call provider.storage().delete_psk(...) for each prior id, then overwrite the
stored last_join_psk_id with the new join_psk_id_bytes and proceed to call
psk_id.store(...); apply the same change to the other occurrence at the
referenced block (lines ~1867-1880) so old epoch PSKs are removed on
re-registration.

---

Nitpick comments:
In `@crates/mdk-core/src/mip06/mod.rs`:
- Around line 97-123: Add negative tests to ensure pairing messages are
authenticated and bound to the intended recipient: after generating (new_priv,
new_pub) via generate_new_device_keypair and producing encrypted ciphertext with
encrypt_pairing_message, create a different keypair and assert that
decrypt_pairing_message with the wrong private key returns an error (do not
unwrap), and separately mutate a byte in the encrypted payload and assert
decrypt_pairing_message also errors; do the same for both DevicePairingRequest
and DevicePairingResponse paths to catch both wrong-recipient and tampering
regressions.
- Around line 205-243: The test currently checks that a proof fails with the
wrong signer key but doesn't assert the proof is bound to
BasicCredential.identity; update test_identity_proof_standalone to also generate
a different credential_identity (e.g., from other.public_key().to_bytes()), then
call super::identity_proof::verify_identity_proof(&decoded, &keys.public_key(),
&other_credential_identity, b"signature-key", b"group-context") and assert it
returns Err; this ensures construct_identity_proof/verify_identity_proof bind
the proof to the provided credential_identity (use existing variables
Keys::generate(), credential_identity, other, decoded, and the same
signature/group context symbols).
- Around line 295-320: Add a new test that covers the unauthorized path for
enable_multi_device: create a group with Alice as admin, then attempt to call
enable_multi_device using a non-admin identity (e.g., Bob's Keys/MDK instance or
a second user), assert that enable_multi_device returns an Err indicating lack
of authorization, assert that merge_pending_commit either is not created or
returns an appropriate error (or calling merge_pending_commit after the failed
enable returns Ok but does not change state), and finally reload the MLS group
via load_mls_group and assert super::is_multi_device_enabled(&mls_group) is
still false and that no pending commit remains; reference the existing test flow
in test_enable_multi_device_on_existing_group and reuse functions create_group,
enable_multi_device, merge_pending_commit, load_mls_group, and
is_multi_device_enabled to implement the checks.
- Around line 463-473: Add a negative test that flips a byte in the PSK from the
pairing payload and asserts join_group_via_external_commit fails instead of
succeeding; locate the existing happy-path sequence using build_pairing_payload,
payload.groups()[0], and join_group_via_external_commit, clone group_data,
mutate group_data.join_psk() (e.g., to_vec and flip one byte) so the PSK is
tampered, then call alice_device2.join_group_via_external_commit with the
tampered group data and assert it returns an Err (or fails before performing
merge) rather than producing a commit_result with the original group_id.
- Around line 281-284: Replace fragile string-based and is_err checks in the
tests with structured pattern matching against the concrete crate::Error
variants: change assertions that test is_err() to assert!(matches!(result,
Err(crate::Error::SomeVariant(_)))) and replace assertions that inspect Display
text (like matching msg.contains("ratchet_tree")) with a match on
Err(crate::Error::PairingError(_)) or the exact variant/inner type expected; if
you need to inspect fields, match the inner value (e.g.,
Err(crate::Error::PairingError(ref detail))) and assert on those fields
explicitly rather than on Display output. This applies to the current assertions
that use is_err() and the ones referencing PairingError message text—use
assert!(matches!(...)) or if let Err(crate::Error::PairingError(detail)) =
result { assert_eq!(detail.some_field, expected) } to make the checks structural
and robust.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 5756d6e0-2a36-46c5-8c81-dfa6658bca75

📥 Commits

Reviewing files that changed from the base of the PR and between 72dc81c and a5ad1bc.

⛔ Files ignored due to path filters (1)
  • Cargo.lock is excluded by !**/*.lock
📒 Files selected for processing (7)
  • crates/mdk-core/Cargo.toml
  • crates/mdk-core/src/groups.rs
  • crates/mdk-core/src/key_packages.rs
  • crates/mdk-core/src/mip06/mod.rs
  • crates/mdk-core/src/mip06/pairing.rs
  • crates/mdk-core/src/mip06/pairing_crypto.rs
  • crates/mdk-uniffi/src/lib.rs
✅ Files skipped from review due to trivial changes (2)
  • crates/mdk-core/src/key_packages.rs
  • crates/mdk-core/src/mip06/pairing.rs
🚧 Files skipped from review as they are similar to previous changes (3)
  • crates/mdk-core/Cargo.toml
  • crates/mdk-core/src/mip06/pairing_crypto.rs
  • crates/mdk-uniffi/src/lib.rs

Comment thread crates/mdk-core/src/groups.rs
@github-actions

Copy link
Copy Markdown

❌ Coverage: 93.68% → 93.06% (-0.62%)

@github-actions

Copy link
Copy Markdown

❌ Coverage: 93.68% → 93.05% (-0.63%)

@github-actions

Copy link
Copy Markdown

❌ Coverage: 93.68% → 93.28% (-0.40%)

@github-actions

Copy link
Copy Markdown

❌ Coverage: 93.68% → 93.22% (-0.46%)

@github-actions

Copy link
Copy Markdown

✅ Coverage: 93.68% → 93.79% (+0.11%)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant