Skip to content
Draft
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
11 changes: 11 additions & 0 deletions crates/chatd/src/adapters/tiny/e2ee.rs
Original file line number Diff line number Diff line change
Expand Up @@ -989,6 +989,8 @@ pub(crate) struct Device {
/// Conversations where someone the server is adding has no device in the group yet, as the
/// last sync saw them.
joining: BTreeSet<String>,
/// Coalesce reconnect and device-link membership catch-up workers.
membership_nudge: bool,
/// Conversations with a row that did not open because this device was not in the group, or
/// not at its epoch, yet. Read again when the group's handshake next comes.
unopened: BTreeSet<String>,
Expand Down Expand Up @@ -1141,6 +1143,14 @@ pub(crate) fn begin(saved: &Saved, commands: std::sync::mpsc::Sender<crate::adap
device.enrol();
}

/// Live hints can be missed while offline (or before enrollment finished). Once the
/// conversation list is known, revisit its encrypted groups without waiting for a send.
pub(crate) fn resume(saved: &Saved) {
if let Some(shared) = started(saved) {
linking::nudge(shared);
}
}

/// Signing out: the device is revoked on the server and its state forgotten here. What it opened
/// stays, sealed under the cache key [`keep_history`] keeps in the keychain, unless the person
/// chose to erase it ([`erase_history`]); a later sign-in here is a new device that reads it.
Expand Down Expand Up @@ -1417,6 +1427,7 @@ impl Device {
files: HashMap::new(),
checked: BTreeSet::new(),
joining: BTreeSet::new(),
membership_nudge: false,
unopened: BTreeSet::new(),
linking: false,
commands: None,
Expand Down
54 changes: 54 additions & 0 deletions crates/chatd/src/adapters/tiny/e2ee/fake.rs
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,11 @@ impl Server {
let body: Value = serde_json::from_str(body).unwrap_or(Value::Null);
let conversation = format!("/v1/e2ee/conversations/{CONVERSATION}");
match method {
"GET" if matches!(path, "/v1/channels" | "/v1/rooms") => (200, json!([])),
"GET" if path == "/v1/directs" => (200, json!([{
"id": CONVERSATION, "e2ee": true,
"peer": {"user_id": if caller == ADA { BEA } else { ADA }, "display_name": "Peer"}
}])),
"GET" if path == "/v1/capabilities" => (
200,
json!({ "features": ["e2ee_text"], "e2ee_text": "optional" }),
Expand Down Expand Up @@ -422,6 +427,55 @@ fn the_first_message_of_a_new_conversation_opens_for_the_other_person() {
prove_first_encrypted_message().expect("first message opens for the other person");
}

#[test]
fn publishing_conversations_settles_an_add_missed_while_offline() {
let fake = Fake::start(0);
let mut creator = device(&fake, ADA, "offline-creator");
creator.sync(CONVERSATION).unwrap();
assert_eq!(lock(&fake.server).epoch, Some(0));
// The creator is now offline. The new recipient can trigger a signed Add,
// but cannot commit it or join until the creator processes it.
let mut recipient = device(&fake, BEA, "waiting-recipient");
recipient.sync(CONVERSATION).unwrap();
assert!(!recipient.engine().unwrap().has_group(ConversationId::parse(CONVERSATION).unwrap()));
assert!(lock(&fake.server).pending.iter().any(|add| add.issued));

let creator_saved = creator.saved.clone();
let recipient_saved = recipient.saved.clone();
let creator = Arc::new(Mutex::new(creator));
let recipient = Arc::new(Mutex::new(recipient));
let registry_key = |saved: &Saved| (saved.base_url.clone(), saved.user_id.clone());
lock(devices()).insert(registry_key(&creator_saved), creator.clone());
lock(devices()).insert(registry_key(&recipient_saved), recipient.clone());
let context = |saved: &Saved| {
let hub = Arc::new(crate::hub::Hub::new(crate::store::Store::open(Path::new(":memory:")).unwrap()));
crate::adapters::local(protocol::AccountKey { network: protocol::Network::Tiny, account: saved.user_id.clone() }, hub)
};
let (_adapter, creator_ctx, _inbox) = context(&creator_saved);
// The normal list refresh after connection must drive membership even with
// no live handshake event and no messages sent or opened by the creator.
super::super::messages::publish(&creator_ctx, &creator_saved, &HashMap::new());
let deadline = Instant::now() + Duration::from_secs(5);
while lock(&fake.server).epoch == Some(0) && Instant::now() < deadline {
std::thread::sleep(Duration::from_millis(10));
}
let committed = lock(&fake.server).epoch == Some(1);
if committed {
let (_adapter, ctx, _inbox) = context(&recipient_saved);
super::super::messages::publish(&ctx, &recipient_saved, &HashMap::new());
while !lock(&recipient).engine().unwrap().has_group(ConversationId::parse(CONVERSATION).unwrap()) && Instant::now() < deadline {
std::thread::sleep(Duration::from_millis(10));
}
}
let joined = lock(&recipient).engine().unwrap().has_group(ConversationId::parse(CONVERSATION).unwrap());
lock(devices()).remove(&registry_key(&creator_saved));
lock(devices()).remove(&registry_key(&recipient_saved));
assert!(committed, "returning creator must commit the missed Add without opening or sending a message");
assert!(joined, "returning recipient must take its Welcome without a live hint");
let answer = lock(&recipient).post(CONVERSATION, "after-resume", Kind::Message, None, &Envelope::message("joined after reconnect"), &[]).unwrap();
assert_eq!(text(&fake.read(&mut lock(&creator)), &id_of(&answer)), "joined after reconnect");
}

/// A row that did not open because this device was not in the group yet (here, the sync of the
/// read failed) is read again when the group arrives, not only when another message does.
#[test]
Expand Down
20 changes: 18 additions & 2 deletions crates/chatd/src/adapters/tiny/e2ee/linking.rs
Original file line number Diff line number Diff line change
Expand Up @@ -286,9 +286,24 @@ const NUDGES: [Duration; 3] = [
/// Settle a device addition or removal immediately, so a linked device gets its Welcome before
/// its first send. Older servers batch membership changes, so retain the later retries too.
pub(super) fn nudge(device: Shared) {
{
let mut state = lock(&device);
if !state.ready() || state.membership_nudge {
return;
}
state.membership_nudge = true;
}
struct Running(Shared);
impl Drop for Running {
fn drop(&mut self) {
lock(&self.0).membership_nudge = false;
}
}
let running = Running(device.clone());
let _ = std::thread::Builder::new()
.name("tiny-e2ee-nudge".into())
.spawn(move || {
let _running = running;
for wait in NUDGES {
std::thread::sleep(wait);
let conversations = lock(&device).known_encrypted();
Expand All @@ -298,8 +313,9 @@ pub(super) fn nudge(device: Shared) {
if !device.ready() {
return;
}
if let Err(why) = device.sync(&conversation) {
tracing::debug!("chatd: Tiny e2ee: {conversation}: {why}");
match device.sync(&conversation) {
Ok(()) => device.reload(&BTreeSet::from([conversation])),
Err(why) => tracing::warn!("chatd: Tiny e2ee: {conversation}: {why}"),
}
}
}
Expand Down
2 changes: 2 additions & 0 deletions crates/chatd/src/adapters/tiny/live.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1809,6 +1809,8 @@ fn ready_heard(ctx: &Context, saved: &Saved, live: &Live, seat: &mut Seat, heard
// without another members read: a resume is not fresh and reads nothing here.
if fresh {
super::messages::publish(ctx, saved, &seat.names);
} else {
super::e2ee::resume(saved);
}
}

Expand Down
3 changes: 3 additions & 0 deletions crates/chatd/src/adapters/tiny/messages.rs
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,9 @@ pub(crate) fn publish(ctx: &Context, saved: &Saved, names: &HashMap<String, Stri
record_call(ctx, saved, names, &direct.id, call);
}
}
// Includes groups with no messages yet: opening only cached history does not
// drive pending Adds, and their live hint may have arrived while we were offline.
e2ee::resume(saved);
}

/// The server lists direct calls. A server not yet checked is asked; a 404 is an older one.
Expand Down
7 changes: 6 additions & 1 deletion docs/e2ee-text-client.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,12 @@ that account here (a new device) reads them. "Erase" deletes the directory and t
starts the group if nobody has (`POST .../group`), applies the handshake feed in order (an Add
for a device not yet verified makes it fetch that person's list and try once more), and commits
what is pending, with the Welcome for every device it adds. It runs on `e2ee.handshake`, before
every send, and when a message is from an epoch ahead (once per read).
every send, and when a message is from an epoch ahead (once per read). After publishing the
conversation list at startup, and after socket resume, a background worker also revisits known
encrypted groups. This catches pending additions whose live hint was missed while offline,
including conversations with no messages. It retries after 35 and then 40 seconds for older
servers that batch membership changes, and requests a message backfill after successful sync.
Concurrent catch-up requests share one worker per device.
- **Sending.** Never plaintext to an encrypted conversation: if this device is not ready, the send
is an error. While the server lists an Add for someone with no device in the group yet, the send
waits, syncing every 2 s, up to 45 s: the server signs Adds only after its commit batch
Expand Down
36 changes: 36 additions & 0 deletions e2e/scenarios/encrypted-membership-reconnect/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# Encrypted membership after reconnect

Required recorded real-app scenario, using isolated profiles and invented accounts:

1. Sign in Ada and create an encrypted direct conversation with Bea before Bea
has registered an encryption device. Ada's device is the sole group member.
2. Close Ada's app. Sign in Bea and open the direct conversation. Verify that
Bea cannot send yet. The server should have a signed, uncommitted Add for Bea.
3. Start Ada's release-profile app again. Leave it at the conversation list;
do not open the conversation, send a message, or press Update encryption.
4. Restart Bea's app, then send a unique message from Bea. It must send and
Ada must be able to read it. Reply from Ada and verify Bea reads it too.
5. Repeat with Ada's process left running but its network disconnected while
Bea's Add is created. Restore the network and repeat step 4. This checks
socket resume as well as cold startup. Allow 90 seconds for bounded retries
on older servers that batch membership changes.

Run the same scenario on the base and branch on every platform. The base must
fail at step 4 because connection/list refresh does not process the pending Add.
The lab must record and publish these runs; no full GUI proof is claimed here.
Platform UI automation and lab recordings are still pending, so this scenario
does not currently certify the change and is not in the release suite.

The focused source regression uses real MLS devices and the actual conversation
list publication path with an in-process server. It checks that the creator
commits the missed Add, that the recipient takes its Welcome without a live
hint, and that the recipient's first message opens on the creator:

```sh
cargo test --locked -p chatd --lib publishing_conversations_settles_an_add_missed_while_offline -- --nocapture
```

For a behavioral base comparison, transplant the test and its fake server list
routes, leaving production code unchanged. A missing test is not a base failure.
This source regression supplements the required real-app scenario; it is not
`e2e-proof` or a substitute for recordings.
7 changes: 7 additions & 0 deletions e2e/scenarios/encrypted-membership-reconnect/scenario.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"id": "encrypted-membership-reconnect",
"title": "Returning clients finish a pending encrypted-chat admission after missing its live event",
"platforms": ["windows", "macos", "linux", "android", "ios"],
"needs": ["server", "two-people"],
"release": false
}
Loading