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
2 changes: 2 additions & 0 deletions crates/chatd/src/adapters/tiny.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1222,6 +1222,8 @@ pub(crate) fn start_with(ctx: Context, session: &str, inbox: Receiver<Command>,
// made, registered, key packages up. Never stops the account.
e2ee::begin(&saved, ctx.commands.clone());
messages::refresh(&ctx, &saved);
// Catch missed membership hints even when this server has no live socket.
e2ee::resume(&saved);
serve_inbox(&ctx, &saved, &live, inbox);
// The socket thread sleeps on the socket; this is how it hears the adapter stopped.
live.wake();
Expand Down
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
66 changes: 66 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,67 @@ 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 resuming_after_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());
struct Registered(Vec<(String, String)>);
impl Drop for Registered {
fn drop(&mut self) {
let mut registry = lock(devices());
for key in &self.0 {
registry.remove(key);
}
}
}
let _registered = Registered(vec![registry_key(&creator_saved), registry_key(&recipient_saved)]);
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);
// Navigation refreshes the list too, but must not start membership workers.
super::super::messages::publish(&creator_ctx, &creator_saved, &HashMap::new());
assert!(!lock(&creator).membership_nudge);
assert_eq!(lock(&fake.server).epoch, Some(0));
// Startup and socket ready/resume explicitly nudge after list publication.
resume(&creator_saved);
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());
resume(&recipient_saved);
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());
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
21 changes: 19 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,10 @@ 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])),
// Cached groups can belong to rooms we have left or that were deleted.
Err(why) => tracing::debug!("chatd: Tiny e2ee: {conversation}: {why}"),
}
}
}
Expand Down
3 changes: 3 additions & 0 deletions crates/chatd/src/adapters/tiny/live.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1810,6 +1810,9 @@ fn ready_heard(ctx: &Context, saved: &Saved, live: &Live, seat: &mut Seat, heard
if fresh {
super::messages::publish(ctx, saved, &seat.names);
}
// Both fresh ready and resume can follow a missed membership hint. UI list
// refreshes also call publish, so catch-up belongs here rather than there.
super::e2ee::resume(saved);
}

/// Who is in one room's call, with their names, as a voice channel's occupants. A call chatd
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
51 changes: 51 additions & 0 deletions e2e/scenarios/encrypted-membership-reconnect/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# 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.
All platform drivers and recordings are explicitly deferred:

| Platform | Driver | Recording |
| --- | --- | --- |
| Linux | Deferred: no `drive.py` or `drive.linux.sh` | Not run |
| Windows | Deferred: no `drive.py` or `drive.windows.ps1` | Not run |
| Android | Deferred: no `drive.py` or `drive.android.sh` | Not run |
| macOS | Deferred: no `drive.py` or `drive.macos.sh`; lab permissions/host blocked | Not run |
| iOS | Deferred: no `drive.py` or `drive.ios.sh`; lab has no passing run | Not run |

Linux, Windows and Android can record today, but this scenario still needs real-app
UI automation for two isolated accounts and both cold-start and network-resume
cases. The existing daemon proof drivers do not implement that interaction.
`scenario.json` lists required coverage, not implemented support. There is nothing
for the lab to execute for this scenario yet. Keep the PR draft; it cannot claim
`e2e-proof` or satisfy the quality gate until those drivers and base/branch
recordings exist. This scenario is not in the release suite.

The focused source regression uses real MLS devices and conversation
list publication followed by the explicit reconnect nudge 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 resuming_after_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