Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions crates/marmot-app/src/client/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1120,7 +1120,7 @@ impl AppClient {
let should_project_locally = !notifications::is_push_gossip_kind(event.kind);
if should_project_locally {
let update = self.record_local_app_event_projection(
&group_id_hex,
group_id,
&sender,
&event,
None,
Expand Down Expand Up @@ -1187,7 +1187,7 @@ impl AppClient {
.map(|report| hex::encode(report.message_id.as_slice()));
if should_project_locally {
let update = self.record_local_app_event_projection(
&group_id_hex,
group_id,
&sender,
&event,
source_message_id_hex,
Expand Down
57 changes: 50 additions & 7 deletions crates/marmot-app/src/client/projection.rs
Comment thread
jgmontoya marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@ use cgka_traits::app_components::{
GROUP_BLOSSOM_IMAGE_COMPONENT_ID, GROUP_ENCRYPTED_MEDIA_COMPONENT_ID,
GROUP_MESSAGE_RETENTION_COMPONENT_ID, NOSTR_ROUTING_COMPONENT_ID,
};
use cgka_traits::app_event::{MARMOT_APP_EVENT_KIND_CHAT, MarmotAppEvent as MarmotInnerEvent};
use cgka_traits::app_event::{
MARMOT_APP_EVENT_KIND_CHAT, MARMOT_APP_EVENT_KIND_DELETE, MarmotAppEvent as MarmotInnerEvent,
};

use crate::groups::{EventGroupProjection, GroupConfirmationProjection, add_group};
use crate::{
Expand All @@ -23,18 +25,24 @@ impl AppClient {
/// post-publish success projection advances it.
pub(crate) fn record_local_app_event_projection(
&self,
group_id_hex: &str,
group_id: &GroupId,
sender: &str,
event: &MarmotInnerEvent,
source_message_id_hex: Option<String>,
source_epoch: Option<u64>,
advance_read_marker: bool,
) -> Result<crate::AppProjectionUpdate, AppError> {
let group_id_hex = hex::encode(group_id.as_slice());
// Stamped on the sender's own store too, so a moderation delete of
// another member's message survives local reprojection instead of
// resurrecting the target.
let moderation_grant = event.kind == MARMOT_APP_EVENT_KIND_DELETE
&& self.delete_moderation_grant(group_id, sender);
let message_projection = AppMessageProjection {
message_id_hex: event.id.clone(),
source_message_id_hex,
direction: "sent".to_owned(),
group_id_hex: group_id_hex.to_owned(),
group_id_hex: group_id_hex.clone(),
sender: sender.to_owned(),
plaintext: event.content.clone(),
kind: event.kind,
Expand All @@ -44,14 +52,26 @@ impl AppClient {
// Only synthesized kind-1210 system rows carry an origin commit;
// ordinary sent app events do not.
origin_commit_id: None,
moderation_grant,
};
// The reconciling post-publish projection (advance_read_marker) runs
// after group sync, so its recomputed moderation grant supersedes the
// optimistic pre-send one; the pre-send projection keeps the default
// freeze so a later no-op re-record can't downgrade it.
let update = if advance_read_marker {
self.app
.record_account_app_event_refreshing_moderation_grant(
&self.state.label,
&message_projection,
)?
} else {
self.app
.record_account_app_event(&self.state.label, &message_projection)?
};
let update = self
.app
.record_account_app_event(&self.state.label, &message_projection)?;
if advance_read_marker && event.kind == MARMOT_APP_EVENT_KIND_CHAT {
let read_marker =
self.app
.mark_timeline_message_read(&self.state.label, group_id_hex, &event.id);
.mark_timeline_message_read(&self.state.label, &group_id_hex, &event.id);
if let Err(err) = read_marker {
let error_code = read_marker_error_code(&err);
tracing::warn!(
Expand All @@ -65,6 +85,28 @@ impl AppClient {
Ok(update)
}

/// Fail-closed: a group or admin-policy lookup failure yields no grant, so
/// the delete degrades to self-retraction semantics.
///
/// Accepted trade-off: the grant is evaluated against the admin set this
/// device sees now (current signed group state), not the admin set as of
/// the delete's epoch, and it is then frozen at first record. Two devices
/// that first observe the same delete at different points in their own sync
/// — one already past an admin-adding commit, the other not, or one while
/// the group is quarantined — can therefore disagree permanently on whether
/// it is honored, a milder echo of the cross-device divergence #873
/// addresses. This is the deliberate fail-closed / frozen posture; an
/// epoch-anchored admin evaluation is future work.
pub(crate) fn delete_moderation_grant(&self, group_id: &GroupId, sender_hex: &str) -> bool {
let Ok(group) = self.runtime.group_record(group_id) else {
return false;
};
let Ok(admins) = self.runtime.admin_pubkeys(group_id) else {
return false;
};
crate::groups::delete_moderation_grant(&group, &admins, sender_hex)
}

pub(crate) fn state_group_record(&self, group_id: &GroupId) -> Option<AppGroupRecord> {
let group_id_hex = hex::encode(group_id.as_slice());
self.state
Expand Down Expand Up @@ -342,6 +384,7 @@ fn build_group_system_projection(
tags: material.tags,
source_epoch: Some(epoch),
recorded_at: Some(recorded_at),
moderation_grant: false,
// Non-unique link to the origin commit so a losing-branch rollback can
// invalidate every row this commit synthesized (1:N).
origin_commit_id,
Expand Down
8 changes: 7 additions & 1 deletion crates/marmot-app/src/client/sync.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
use std::collections::{HashMap, HashSet};

use cgka_traits::TransportAdapter;
use cgka_traits::app_event::MARMOT_APP_EVENT_KIND_CHAT;
use cgka_traits::app_event::{MARMOT_APP_EVENT_KIND_CHAT, MARMOT_APP_EVENT_KIND_DELETE};
use cgka_traits::ingest::{IngestOutcome, StaleReason};
use storage_sqlite::clamp_to_max_future_skew;
use tokio::time::timeout;
Expand Down Expand Up @@ -493,6 +493,11 @@ impl AppClient {
);
}
self.app.remember_directory_message_sender(&message)?;
// Evaluated against the signed MLS group state while the
// delete's epoch context is live, then persisted with the
// event so later admin-set changes cannot flip the verdict.
let moderation_grant = message.kind == MARMOT_APP_EVENT_KIND_DELETE
&& self.delete_moderation_grant(&message.group_id, &message.sender);
let message_projection = AppMessageProjection {
message_id_hex: message.message_id_hex.clone(),
source_message_id_hex: Some(message.source_message_id_hex.clone()),
Expand All @@ -506,6 +511,7 @@ impl AppClient {
recorded_at: Some(source_recorded_at),
// Received app messages are not synthesized system rows.
origin_commit_id: None,
moderation_grant,
};
let projection_update = self
.app
Expand Down
4 changes: 4 additions & 0 deletions crates/marmot-app/src/conversions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,7 @@ pub(crate) fn stored_app_event_from_projection(
recorded_at: message.recorded_at.unwrap_or(received_at),
received_at,
origin_commit_id: message.origin_commit_id.clone(),
moderation_grant: message.moderation_grant,
}
}

Expand All @@ -257,6 +258,9 @@ pub(crate) fn stored_app_event_from_message_record(record: &AppMessageRecord) ->
recorded_at: record.recorded_at,
received_at: record.received_at,
origin_commit_id: None,
// Legacy-import records predate moderation deletes, so none carries a
// grant.
moderation_grant: false,
}
}

Expand Down
113 changes: 113 additions & 0 deletions crates/marmot-app/src/groups.rs
Comment thread
jgmontoya marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -836,6 +836,119 @@ pub(crate) enum GroupConfirmationProjection {
},
}

/// Whether a delete may tombstone other members' messages: its authenticated
/// sender must be in the group's current admin set, and the group must not
/// look like a direct (two-member, unnamed) conversation.
///
/// Known limitation: "direct" is a heuristic over mutable state
/// (`members.len() == 2 && name.is_empty()`), not an immutable conversation
/// kind. The creator is always an implicit admin, and the only
/// create/rename API takes a free-form name for any member count, so an admin
/// can name — or later rename — a two-member conversation to escape this gate
/// and gain moderation over the peer's messages, with no signal to the peer.
/// Closing that fully needs a conversation-kind fixed at creation time
/// (protocol/engine plus client work); until then this is a deliberate,
/// documented limitation rather than a guarantee that 1:1 chats can never be
/// moderated.
pub(crate) fn delete_moderation_grant(
group: &Group,
admins: &[[u8; 32]],
sender_hex: &str,
) -> bool {
let direct = group.members.len() == 2 && group.name.trim().is_empty();
!direct && admins.iter().any(|admin| hex::encode(admin) == sender_hex)
}

#[cfg(test)]
mod delete_moderation_grant_tests {
use super::*;
use cgka_traits::group::Member;
use cgka_traits::types::{EpochId, MemberId};

fn group_with(name: &str, member_count: usize) -> Group {
Group {
id: GroupId::new(vec![1u8; 16]),
name: name.to_owned(),
description: String::new(),
epoch: EpochId(3),
members: (0..member_count)
.map(|index| Member {
id: MemberId::new(vec![index as u8; 32]),
credential: Vec::new(),
})
.collect(),
required_capabilities: Default::default(),
removed: false,
join_epoch: EpochId(0),
}
}

const ADMIN: [u8; 32] = [7u8; 32];

#[test]
fn admin_in_named_group_gets_grant() {
let group = group_with("ops", 3);
assert!(delete_moderation_grant(
&group,
&[ADMIN],
&hex::encode(ADMIN)
));
}

#[test]
fn non_admin_sender_gets_no_grant() {
let group = group_with("ops", 3);
assert!(!delete_moderation_grant(
&group,
&[ADMIN],
&hex::encode([9u8; 32])
));
}

#[test]
fn direct_conversation_never_grants_even_to_admin() {
let group = group_with("", 2);
assert!(!delete_moderation_grant(
&group,
&[ADMIN],
&hex::encode(ADMIN)
));
// A whitespace-only name is still an unnamed direct conversation.
let group = group_with(" ", 2);
assert!(!delete_moderation_grant(
&group,
&[ADMIN],
&hex::encode(ADMIN)
));
}

#[test]
fn two_member_named_group_is_not_direct() {
let group = group_with("pair", 2);
assert!(delete_moderation_grant(
&group,
&[ADMIN],
&hex::encode(ADMIN)
));
}

#[test]
fn unnamed_larger_group_is_not_direct() {
let group = group_with("", 3);
assert!(delete_moderation_grant(
&group,
&[ADMIN],
&hex::encode(ADMIN)
));
}

#[test]
fn empty_admin_set_grants_nothing() {
let group = group_with("ops", 3);
assert!(!delete_moderation_grant(&group, &[], &hex::encode(ADMIN)));
}
}

/// Strictly decode the inner Marmot app event from MLS plaintext and bind it to
/// the MLS-authenticated sender. Returns `None` (rejecting the message) when the
/// canonical id does not match or the inner `pubkey` is not the authenticated
Expand Down
23 changes: 23 additions & 0 deletions crates/marmot-app/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -779,6 +779,11 @@ pub(crate) struct AppMessageProjection {
/// system row, so the row can be invalidated by origin commit if that commit
/// loses a fork. `None` for all other projections.
pub(crate) origin_commit_id: Option<String>,
/// True only for a delete whose authenticated sender may moderate other
/// members' messages (group admin, non-direct group), evaluated against
/// the signed MLS group state when the delete is recorded and persisted
/// with the event. `false` for every other projection.
pub(crate) moderation_grant: bool,
}

fn generate_telemetry_install_id() -> String {
Expand Down Expand Up @@ -2742,6 +2747,24 @@ impl MarmotApp {
self.app_projection_update(label, storage_update)
}

/// As [`Self::record_account_app_event`], but a conflicting row's
/// `moderation_grant` is replaced rather than frozen. Used by the local
/// sender's post-publish reconciling projection so a moderation grant
/// recomputed after group sync supersedes the optimistic pre-send value.
pub(crate) fn record_account_app_event_refreshing_moderation_grant(
&self,
label: &str,
message: &AppMessageProjection,
) -> Result<AppProjectionUpdate, AppError> {
let now = unix_now_seconds();
let storage_update = self
.account_storage(label)?
.record_app_event_refreshing_moderation_grant(&stored_app_event_from_projection(
message, now,
))?;
self.app_projection_update(label, storage_update)
}

pub(crate) fn invalidate_timeline_source_message(
&self,
label: &str,
Expand Down
1 change: 1 addition & 0 deletions crates/marmot-app/src/projection/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -276,6 +276,7 @@ fn prune_group_messages_before_removes_only_expired_group_rows() {
source_epoch: None,
recorded_at: Some(recorded_at),
origin_commit_id: None,
moderation_grant: false,
})
.unwrap();
}
Expand Down
4 changes: 4 additions & 0 deletions crates/marmot-app/src/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1215,6 +1215,7 @@ fn legacy_account_projection_imports_once_into_account_storage() {
source_epoch: None,
recorded_at: Some(1_700_000_101),
origin_commit_id: None,
moderation_grant: false,
})
.unwrap();
legacy
Expand Down Expand Up @@ -1280,6 +1281,7 @@ fn legacy_account_projection_imports_once_into_account_storage() {
source_epoch: None,
recorded_at: Some(1_700_000_102),
origin_commit_id: None,
moderation_grant: false,
})
.unwrap();
assert_eq!(app.messages("alice").unwrap().len(), 1);
Expand Down Expand Up @@ -2286,6 +2288,7 @@ fn secure_prune_account_app_events_before_returns_media_hashes_above_storage_lay
source_epoch: None,
recorded_at: Some(10),
origin_commit_id: None,
moderation_grant: false,
},
)
.unwrap();
Expand Down Expand Up @@ -2358,6 +2361,7 @@ fn group_state_invalidated_event_tombstones_origin_commit_system_rows() {
source_epoch: Some(2),
recorded_at: Some(10),
origin_commit_id,
moderation_grant: false,
};
// The losing commit synthesized this row (the "B renamed the group" lie).
app.record_account_app_event(
Expand Down
Loading