Skip to content

feat: validation + NIP-40 expiration + UniFFI surface for disappearing messages (2/3)#306

Merged
dannym-arx merged 3 commits into
masterfrom
disappearing-messages-part-2
May 22, 2026
Merged

feat: validation + NIP-40 expiration + UniFFI surface for disappearing messages (2/3)#306
dannym-arx merged 3 commits into
masterfrom
disappearing-messages-part-2

Conversation

@dannym-arx
Copy link
Copy Markdown
Contributor

@dannym-arx dannym-arx commented May 19, 2026

marmot

Step 2 of 3: splitting #253. Part 1 (#258) landed the v3 wire format and storage column. This PR adds validation, the NIP-40 expiration tag, and the UniFFI surface. Part 3 handles deletion.

What changed

Validation. create_group and update_group_data reject Some(0) with Error::Group. Callers pass None (or Some(None) on the update path) to disable. The extension constructor keeps its silent zero filter as a backstop; surfaced errors live at the public API where callers can act on them.

NIP-40 expiration on outer wrappers. build_message_event builds every outer kind:445 event: messages, proposals, commits. When the group has a disappearing_message_secs setting, the wrapper now carries an expiration tag. If the caller also supplies one (say, an ephemeral location burst), the earlier of the two wins. Group sets a ceiling; caller can ask for shorter but never longer. Both created_at and the expiration math share one Timestamp::now() snapshot so they cannot drift.

UniFFI surface. The Group record now exposes disappearing_message_secs: Option<UInt64>. On GroupDataUpdate, the field is Option<Option<UInt64>> (outer = specified, inner = enabled). For create_group, the binding takes a new disappearing_message_secs parameter. Mobile clients can read and write the duration through the binding now that the field is plumbed on both sides.

What this PR does NOT add

  • Local message deletion methods (Part 3)
  • UniFFI deletion exports (Part 3)
  • PRAGMA secure_delete=ON (Part 3)

Files touched (6)

Area Net lines
mdk-core/src/groups.rs +197 (logic + 4 tests)
mdk-core/src/messages/create.rs +144 (4 tests)
mdk-uniffi/src/lib.rs +32
mdk-core/src/extension/types.rs +5 / -2
Changelogs +6
Total +376 / -8

8 new tests pass. Full workspace suite (458 tests) green. Precommit (fmt, clippy, docs, cargo audit) passes on stable and MSRV.

A second batch of marmots, this one teaching messages how to stop existing on schedule.

This PR (step 2 of 3 for disappearing messages) adds validation for disappearing-message configuration, ensures outer wrapper events receive deterministic NIP-40 expiration tags when a group configures disappearing messages, and exposes the disappearing-message setting through the UniFFI surface so mobile clients can read/write it.

What changed:

  • Validation: mdk-core create_group and update_group_data now reject Some(0) (and Some(Some(0)) on updates); callers must use None (or Some(None) on update) to disable disappearing messages.
  • Expiration tagging: build_message_event now attaches a NIP-40 expiration tag to outer kind:445 wrapper events when the group has disappearing_message_secs, selecting the earlier of a caller-supplied expiration and the group-derived expiration, and omitting the tag when neither applies.
  • Timestamp consistency: created_at and expiration calculations use a single Timestamp::now() snapshot to prevent drift and ensure deterministic expiration math.
  • UniFFI surface: mdk-uniffi Mdk::create_group gains disappearing_message_secs: Option; GroupDataUpdate uses Option<Option> to represent unchanged/disable/set semantics; Group record exposes disappearing_message_secs: Option.
  • Files/crates affected: mdk-core (groups, messages/create, extension/types, changelog) and mdk-uniffi (lib.rs, changelog); storage crates (mdk-sqlite-storage, mdk-memory-storage, mdk-storage-traits) were not modified.

Security impact:
⚠️ Security-sensitive changes

  • Cryptographic operations: build_message_event snapshots now-then derives the MLS exporter secret for event encryption and generates per-event ephemeral signing keys as part of constructing outer wrapper events, so reviewers should audit exporter context strings and key handling.
  • No changes to SQLCipher, file permissions, or deletion/secure-delete behavior in this PR (deletion handled in Part 3).
  • Public API now rejects ambiguous zero encodings to avoid silent misconfiguration that could leak policy expectations.

Protocol changes:

  • Nostr: automatic addition of NIP-40 (expiration) tags to outer wrapper (kind:445) events when a group's disappearing_message_secs is set, following NIP-40 semantics and preferring the earliest expiration between caller and group.
  • MLS: no changes to MLS algorithms or protocol behavior, but code derives the MLS exporter secret during event creation to support encryption of the outer wrapper.

API surface:

  • Breaking/unavoidable API change: UniFFI method signature Mdk::create_group(...) now accepts disappearing_message_secs: Option.
  • New/changed API surface: GroupDataUpdate gains disappearing_message_secs: Option<Option> and Group record exposes disappearing_message_secs: Option.
  • No storage schema changes in this PR; storage deletion and secure-deletion APIs are planned in Part 3.

Testing:

  • Added 8 unit tests covering validation edge cases (rejecting zero), clearing via Some(None), and expiration tag application and precedence for outer wrapper events.
  • Full workspace test suite (458 tests) and precommit checks (fmt, clippy, docs, cargo audit) pass on stable and MSRV.

Review Change Stack

…aring messages (2/3)

Part 2 of splitting #253. Builds on the v3 wire format + storage
groundwork landed in #258.

Adds:
- create_group / update_group_data reject Some(0); callers use None or
  Some(None) to disable.
- build_message_event auto-inserts a NIP-40 expiration tag on every
  outer kind:445 wrapper (messages, proposals, commits) when the group
  has a disappearing_message_secs set. If the caller also supplies an
  expiration, the earlier of (caller, group) wins — the caller can
  request a shorter lifetime but never extend the group's setting.
  The wrapper's created_at and the expiration math share a single
  Timestamp::now() snapshot so they cannot drift.
- UniFFI: disappearing_message_secs on the Group and GroupDataUpdate
  records, new parameter on the create_group binding.

Part 3 will add MessageStorage::delete_message and friends, the
corresponding UniFFI exports, and PRAGMA secure_delete=ON.
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 19, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 8afb5eb8-fc77-4bab-b5ea-85fcf20935ed

📥 Commits

Reviewing files that changed from the base of the PR and between 2263199 and b6342da.

📒 Files selected for processing (1)
  • crates/mdk-uniffi/src/lib.rs
🚧 Files skipped from review as they are similar to previous changes (1)
  • crates/mdk-uniffi/src/lib.rs

📝 Walkthrough

Walkthrough

Adds disappearing-message duration handling: rejects zero-duration encodings on create/update, computes/attaches NIP-40 expiration tags to outer wrapper events using a deterministic snapshot time and the earlier of caller vs group expiration, and exposes the setting through UniFFI bindings.

Changes

Disappearing Messages with NIP-40 Expiration

Layer / File(s) Summary
Validation and docs updates
crates/mdk-core/src/extension/types.rs, crates/mdk-core/CHANGELOG.md, crates/mdk-core/src/groups.rs
Zero-duration disappearing-message settings (Some(0) on creation, Some(Some(0)) on update) are rejected with errors instructing callers to use None/Some(None) to disable; comments/changelog updated to document defense-in-depth semantics.
Expiration tag computation in message building
crates/mdk-core/src/groups.rs
build_message_event snapshots now, sets the event created_at from that snapshot, parses caller expiration tags, computes group-imposed expiration from now + group duration, and applies the earlier of the two (if any) as the NIP-40 expiration tag on kind:445 wrapper events; h and encoding=base64 tags are normalized and non-expiration caller tags preserved.
Message creation expiration tests
crates/mdk-core/src/messages/create.rs
Tests and a helper assert auto-insertion, omission, and precedence rules for outer wrapper Expiration tags when groups have disappearing durations and when callers supply expirations.
Core group tests
crates/mdk-core/src/groups.rs
Adds config_with_disappearing helper and tests asserting create/update reject zero encodings, allow clearing via Some(None), and that commit events include Expiration tags equal to created_at + duration (within bounds).
UniFFI FFI binding for disappearing messages
crates/mdk-uniffi/CHANGELOG.md, crates/mdk-uniffi/src/lib.rs
Mdk::create_group gains disappearing_message_secs: Option<u64> (rejects Some(0) at FFI boundary); GroupDataUpdate gains disappearing_message_secs: Option<Option<u64>>; exported Group exposes disappearing_message_secs: Option<u64>; tests/fixtures updated.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related issues

  • marmot-protocol/marmot-security#103: Implements the NIP-40 expiration tag auto-insertion behavior (snapshotting time, parsing/stripping caller expiration tags, and computing group-imposed expirations) described in the issue.

Possibly related PRs

Suggested labels

mls-protocol, breaking-change

Suggested reviewers

  • mubarakcoded
  • jgmontoya
  • erskingardner
🚥 Pre-merge checks | ✅ 6
✅ Passed checks (6 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and specifically summarizes the main changes: validation logic, NIP-40 expiration tag implementation, and UniFFI surface additions for disappearing messages—reflecting the core work in this PR.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
No Sensitive Identifier Leakage ✅ Passed No sensitive identifiers (mls_group_id, nostr_group_id, GroupId, secrets, keys) appear in tracing macros, error messages, panic messages, or Debug implementations.

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

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch disappearing-messages-part-2

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

@github-actions
Copy link
Copy Markdown

github-actions Bot commented May 19, 2026

✅ Coverage: 94.51% → 94.52% (+0.01%)

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 4

Caution

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

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

1079-1102: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Validate Some(0) at the UniFFI boundary to preserve the declared FFI contract.

disappearing_message_secs is forwarded directly in both create/update flows. With current From<MdkError> mapping, downstream validation won’t reliably surface as MdkUniffiError::InvalidInput at the boundary.

🛠️ Suggested fix
 pub fn create_group(
     &self,
@@
     admins: Vec<String>,
     disappearing_message_secs: Option<u64>,
 ) -> Result<CreateGroupResult, MdkUniffiError> {
+    if disappearing_message_secs == Some(0) {
+        return Err(MdkUniffiError::InvalidInput(
+            "disappearing_message_secs must be > 0 or None".to_string(),
+        ));
+    }
+
     let creator_pubkey = parse_public_key(&creator_public_key)?;
@@
 pub fn update_group_data(
@@
 ) -> Result<UpdateGroupResult, MdkUniffiError> {
+    if matches!(update.disappearing_message_secs, Some(Some(0))) {
+        return Err(MdkUniffiError::InvalidInput(
+            "disappearing_message_secs must be > 0 when provided".to_string(),
+        ));
+    }
+
     let group_id = parse_group_id(&mls_group_id)?;

Also applies to: 1323-1325

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/mdk-uniffi/src/lib.rs` around lines 1079 - 1102, The UniFFI boundary
must explicitly validate disappearing_message_secs so that Some(0) is rejected
as InvalidInput before constructing NostrGroupConfigData; update the create and
update entry points (the function building NostrGroupConfigData in lib.rs, and
the analogous update function around lines ~1323-1325) to check
disappearing_message_secs: if Some(0) return
Err(MdkUniffiError::InvalidInput(...)) rather than forwarding it into
NostrGroupConfigData::new; ensure the error uses the MdkUniffiError variant
expected at the FFI boundary so callers receive InvalidInput consistently.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/mdk-core/CHANGELOG.md`:
- Around line 38-39: Update the two changelog bullets that end with "Part 2 of
`#253`" to append the proper PR reference for this change: replace the trailing
"Part 2 of `#253`" text with "Part 2 of `#253`.
([`#306`](https://github.com/marmot-protocol/mdk/pull/306))" so each entry ends
with the required markdown PR link; locate the lines in CHANGELOG.md containing
the phrases "create_group and update_group_data now reject" and "Outer kind:445
wrappers built by build_message_event" and update their endings accordingly.

In `@crates/mdk-core/src/groups.rs`:
- Line 8732: The test-local `use nostr::{TagKind, TagStandard, Timestamp};` must
be moved out of the test function and placed in the module-level `use` block;
locate the test containing that inline import in groups.rs and remove the
in-function `use` and add the same `use nostr::{TagKind, TagStandard,
Timestamp};` to the top of the containing module's `use` section (alongside
other `use nostr::...` entries) so all imports live at the module scope per the
repo guideline.

In `@crates/mdk-core/src/messages/create.rs`:
- Around line 966-967: Move the repeated function-local import `use
nostr::TagStandard;` out of the test functions and add a single `use
nostr::TagStandard;` at the top of the module scope so it is available to all
tests; remove the inline `use nostr::TagStandard;` statements inside the test
bodies (the tests that currently contain that import) and rely on the
module-level import instead. Ensure there are no other function/block-local `use
nostr::TagStandard;` occurrences left in the file.

In `@crates/mdk-uniffi/CHANGELOG.md`:
- Around line 33-34: The changelog entries for Mdk::create_group and
GroupDataUpdate currently end with an issue reference (`#253`); update both
entries in CHANGELOG.md to append the mandatory PR link suffix (e.g., "See PR
#<number>" or the repository's standard "[#<PR>](...)" format) referencing the
correct PR number(s) that implemented these changes so they no longer point to
issue `#253` and comply with the **/CHANGELOG.md** guideline; ensure both the
Mdk::create_group line and the GroupDataUpdate line include the PR link format
used elsewhere in the file.

---

Outside diff comments:
In `@crates/mdk-uniffi/src/lib.rs`:
- Around line 1079-1102: The UniFFI boundary must explicitly validate
disappearing_message_secs so that Some(0) is rejected as InvalidInput before
constructing NostrGroupConfigData; update the create and update entry points
(the function building NostrGroupConfigData in lib.rs, and the analogous update
function around lines ~1323-1325) to check disappearing_message_secs: if Some(0)
return Err(MdkUniffiError::InvalidInput(...)) rather than forwarding it into
NostrGroupConfigData::new; ensure the error uses the MdkUniffiError variant
expected at the FFI boundary so callers receive InvalidInput consistently.
🪄 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: 82577860-c8bc-40f6-b293-050b2d687811

📥 Commits

Reviewing files that changed from the base of the PR and between 592a582 and 94cf931.

📒 Files selected for processing (6)
  • crates/mdk-core/CHANGELOG.md
  • crates/mdk-core/src/extension/types.rs
  • crates/mdk-core/src/groups.rs
  • crates/mdk-core/src/messages/create.rs
  • crates/mdk-uniffi/CHANGELOG.md
  • crates/mdk-uniffi/src/lib.rs

Comment thread crates/mdk-core/CHANGELOG.md Outdated
Comment thread crates/mdk-core/src/groups.rs Outdated
Comment thread crates/mdk-core/src/messages/create.rs Outdated
Comment thread crates/mdk-uniffi/CHANGELOG.md Outdated
- Append PR #306 link to Part-2 changelog entries (mdk-core + mdk-uniffi)
- Hoist in-function nostr imports (TagKind, TagStandard, Timestamp) to
  module-level use blocks in groups.rs tests and messages/create.rs tests
coderabbitai[bot]
coderabbitai Bot previously approved these changes May 19, 2026
Comment thread crates/mdk-uniffi/CHANGELOG.md
Comment thread crates/mdk-uniffi/src/lib.rs
…nput

The CHANGELOG for #306 claims that `create_group` and `update_group_data`
surface `Some(0)` (and `Some(Some(0))` on updates) as
`MdkUniffiError::InvalidInput`, but the rejection actually happens in
`mdk_core`, which returns `Error::Group`. The `impl From<MdkError> for
MdkUniffiError` then maps every `MdkError` to `MdkUniffiError::Mdk(...)`,
so mobile clients pattern-matching on the error variant get the
catch-all `Mdk` instead of the documented `InvalidInput`.

Add an explicit pre-check at the FFI boundary, matching how other
parameter-format errors are reported in the uniffi crate (e.g. invalid
relay URLs, invalid admin keys). The CHANGELOG contract now holds.

Adds binding-level tests asserting the `InvalidInput` variant for both
paths, following the existing `test_create_group_invalid_*` /
`test_update_group_data_invalid_*` style.
@dannym-arx dannym-arx merged commit 25005ea into master May 22, 2026
21 checks passed
@dannym-arx dannym-arx deleted the disappearing-messages-part-2 branch May 22, 2026 11:24
dannym-arx added a commit that referenced this pull request May 22, 2026
…PR refs

The cherry-pick from the original combined PR #253 placed the deletion-API
and secure_delete entries in the [0.8.0] section because that PR predated
the 0.8.0 release cut. Move them to Unreleased and append the #315 PR
link, matching the convention used for Part 1 (#258) and Part 2 (#306).

Also adds the missing mdk-uniffi changelog entry for the new deletion
bindings.
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.

2 participants