diff --git a/crates/chatd/src/adapters/tiny.rs b/crates/chatd/src/adapters/tiny.rs index d6c20a38..4d180a3f 100644 --- a/crates/chatd/src/adapters/tiny.rs +++ b/crates/chatd/src/adapters/tiny.rs @@ -43,6 +43,11 @@ pub fn prove_sealed_preview() -> Result<(), String> { messages::prove_sealed_preview() } +/// The lab's legacy-saved-speakers proof. See `messages::prove_legacy_saved_speakers`. +pub fn prove_legacy_saved_speakers() -> Result<(), String> { + messages::prove_legacy_saved_speakers() +} + /// The lab's cache-open-without-device proof. See `messages::prove_cache_open_without_device`. pub fn prove_cache_open_without_device() -> Result<(), String> { messages::prove_cache_open_without_device() diff --git a/crates/chatd/src/adapters/tiny/e2ee.rs b/crates/chatd/src/adapters/tiny/e2ee.rs index 719b3728..39e13e6b 100644 --- a/crates/chatd/src/adapters/tiny/e2ee.rs +++ b/crates/chatd/src/adapters/tiny/e2ee.rs @@ -554,7 +554,8 @@ impl OpenedFile { #[derive(Clone)] pub(crate) enum Read { Opened(Opened), - /// Saved plaintext from before sender authentication was retained. Never a wire identity. + /// Saved plaintext from before sender authentication was retained. Its speaker is the + /// wire author, unverified and for display only; `saved_history` keeps it read-only. Legacy(Opened), /// An edit, folded into its target: not a row of its own. Edit, diff --git a/crates/chatd/src/adapters/tiny/e2ee/cache_auth.rs b/crates/chatd/src/adapters/tiny/e2ee/cache_auth.rs index bebf5b20..9da56760 100644 --- a/crates/chatd/src/adapters/tiny/e2ee/cache_auth.rs +++ b/crates/chatd/src/adapters/tiny/e2ee/cache_auth.rs @@ -1,4 +1,5 @@ //! Authentication retained with sealed plaintext, independently of later server row claims. +//! A row without it shows the wire author, unverified, and stays `saved_history`. use super::*; diff --git a/crates/chatd/src/adapters/tiny/messages.rs b/crates/chatd/src/adapters/tiny/messages.rs index 2b2e557b..83ae665f 100644 --- a/crates/chatd/src/adapters/tiny/messages.rs +++ b/crates/chatd/src/adapters/tiny/messages.rs @@ -1798,12 +1798,11 @@ fn map_sealed(saved: &Saved, names: &HashMap, wire: &WireMessage message.sender.name = name_of(names, saved, &author); message.outgoing = author == saved.user_id; } else { + // Unauthenticated cache hits (device-link archive, pre-auth caches, decrypt + // fallback) keep the wire author for display only. Clearing id made every + // speaker share one empty id, so continues() collapsed a back-and-forth into + // one monologue. saved_history still blocks edits; MLS auth still wins above. message.saved_history = true; - message.sender.id.clear(); - message.sender.name = "Saved message".to_owned(); - message.outgoing = false; - message.sent = if opened.at != 0 { opened.at } else { sent }; - message.sent_subsec_millis = 0; message.client_id = None; if let Some(attribution) = &opened.saved_attribution { message.sender.id = attribution.id.clone(); @@ -1811,6 +1810,9 @@ fn map_sealed(saved: &Saved, names: &HashMap, wire: &WireMessage message.outgoing = attribution.outgoing; message.sent = attribution.sent; message.sent_subsec_millis = attribution.sent_subsec_millis; + } else if message.sent == 0 && opened.at != 0 { + message.sent = opened.at; + message.sent_subsec_millis = 0; } } if let Some(millis) = opened.sent_at { @@ -2429,6 +2431,57 @@ struct WireReaction { me: bool, } +/// Proves unauthenticated sealed cache rows keep distinct wire speakers instead of collapsing to +/// one empty-id "Saved message". The lab sets `TINYCLIENT_PROVE=legacy-saved-speakers`. +pub(super) fn prove_legacy_saved_speakers() -> Result<(), String> { + const ME: &str = "11111111-1111-1111-1111-111111111111"; + const PEER: &str = "22222222-2222-2222-2222-222222222222"; + let saved = Saved { + base_url: "http://127.0.0.1:9".to_owned(), + token: "t".to_owned(), + user_id: ME.to_owned(), + email: None, + display_name: "Justin".to_owned(), + role: "member".to_owned(), + plain_http_ok: false, + e2ee: None, + }; + let names = HashMap::from([(PEER.to_owned(), "Larsen".to_owned())]); + let mine: WireMessage = serde_json::from_value(serde_json::json!({ + "id": "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", "author_id": ME, "e2ee": true, + "created_at": "2024-01-02T03:04:05Z" + })) + .map_err(|err| err.to_string())?; + let theirs: WireMessage = serde_json::from_value(serde_json::json!({ + "id": "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", "author_id": PEER, "e2ee": true, + "created_at": "2024-01-02T03:04:06Z" + })) + .map_err(|err| err.to_string())?; + let opened = e2ee::Opened { + text: "hi".into(), + ..e2ee::Opened::default() + }; + let reads = HashMap::from([ + (mine.id.clone(), Read::Legacy(opened.clone())), + (theirs.id.clone(), Read::Legacy(opened)), + ]); + let mine = map_message(&saved, &names, &mine, &reads, &mut Vec::new()).ok_or("mine missing")?; + let theirs = map_message(&saved, &names, &theirs, &reads, &mut Vec::new()).ok_or("theirs missing")?; + if !mine.saved_history || !theirs.saved_history { + return Err("legacy rows must stay marked as saved history".into()); + } + if mine.sender.id != ME || !mine.outgoing || mine.sender.name != "Justin" { + return Err(format!("mine mapped as {} / {} outgoing={}", mine.sender.id, mine.sender.name, mine.outgoing)); + } + if theirs.sender.id != PEER || theirs.outgoing || theirs.sender.name != "Larsen" { + return Err(format!("theirs mapped as {} / {} outgoing={}", theirs.sender.id, theirs.sender.name, theirs.outgoing)); + } + if mine.sender.id == theirs.sender.id { + return Err("both speakers collapsed to the same id".into()); + } + Ok(()) +} + /// Proves a ciphertext row with no `e2ee` flag is stored as undecryptable, not as blank plaintext. /// The lab sets `TINYCLIENT_PROVE=sealed-row`. A build from before the fix ignores that and keeps /// serving, so the same driver fails there. @@ -2915,19 +2968,40 @@ mod tests { } #[test] - fn legacy_saved_text_has_neutral_attribution_even_when_wire_claims_it_is_mine() { - let saved = saved("http://unused"); + fn legacy_saved_text_keeps_wire_display_attribution_and_marks_saved_history() { + let mut saved = saved("http://unused"); + saved.display_name = "Justin".into(); let wire: WireMessage = serde_json::from_value(json!({"id":"legacy", "author_id":ME, "e2ee":true, "created_at":WHEN, "client_id":"forged-own"})).unwrap(); let opened = e2ee::Opened { text:"saved words".into(), at:123, ..e2ee::Opened::default() }; let reads = HashMap::from([("legacy".to_owned(), Read::Legacy(opened))]); let mapped = map_message(&saved, &HashMap::new(), &wire, &reads, &mut Vec::new()).unwrap(); assert_eq!(mapped.text, "saved words"); - assert_eq!(mapped.sender.name, "Saved message"); - assert!(mapped.sender.id.is_empty()); - assert!(!mapped.outgoing); + assert_eq!(mapped.sender.name, "Justin"); + assert_eq!(mapped.sender.id, ME); + assert!(mapped.outgoing); assert!(mapped.client_id.is_none()); assert!(mapped.saved_history); - assert_eq!(mapped.sent, 123); + // Wire created_at wins over coarse opened.at so speakers keep distinct timestamps. + assert_eq!(mapped.sent, rfc3339(WHEN).unwrap()); + } + + #[test] + fn legacy_saved_text_from_two_wire_authors_keeps_both_speakers() { + let saved = saved("http://unused"); + let names = HashMap::from([(PEER.to_owned(), "Larsen".to_owned())]); + let mine: WireMessage = serde_json::from_value(json!({"id":"mine", "author_id":ME, "e2ee":true, "created_at":WHEN})).unwrap(); + let theirs: WireMessage = serde_json::from_value(json!({"id":"theirs", "author_id":PEER, "e2ee":true, "created_at":WHEN})).unwrap(); + let opened = e2ee::Opened { text:"hi".into(), ..e2ee::Opened::default() }; + let reads = HashMap::from([ + ("mine".to_owned(), Read::Legacy(opened.clone())), + ("theirs".to_owned(), Read::Legacy(opened)), + ]); + let mine = map_message(&saved, &names, &mine, &reads, &mut Vec::new()).unwrap(); + let theirs = map_message(&saved, &names, &theirs, &reads, &mut Vec::new()).unwrap(); + assert_eq!((mine.sender.id.as_str(), mine.outgoing, mine.saved_history), (ME, true, true)); + assert_eq!((theirs.sender.id.as_str(), theirs.sender.name.as_str(), theirs.outgoing, theirs.saved_history), + (PEER, "Larsen", false, true)); + assert_ne!(mine.sender.id, theirs.sender.id); } #[test] diff --git a/crates/chatd/src/main.rs b/crates/chatd/src/main.rs index 42d4393e..882d2833 100644 --- a/crates/chatd/src/main.rs +++ b/crates/chatd/src/main.rs @@ -39,6 +39,15 @@ fn main() -> io::Result<()> { } return Ok(()); } + // The lab's legacy-saved-speakers driver. A build without this returns to the server below and stays up. + if std::env::var("TINYCLIENT_PROVE").ok().as_deref() == Some("legacy-saved-speakers") { + if let Err(err) = chatd::adapters::tiny::prove_legacy_saved_speakers() { + eprintln!("legacy-saved-speakers: {err}"); + std::process::exit(1); + } + eprintln!("prove-legacy-saved-speakers:0"); + return Ok(()); + } // The lab's sealed-row driver. A build without this returns to the server below and stays up. if std::env::var("TINYCLIENT_PROVE").ok().as_deref() == Some("sealed-row") { if let Err(err) = chatd::adapters::tiny::prove_missing_seal() { diff --git a/crates/protocol/src/message.rs b/crates/protocol/src/message.rs index 42d2dbcb..0c2c3fc0 100644 --- a/crates/protocol/src/message.rs +++ b/crates/protocol/src/message.rs @@ -325,8 +325,9 @@ pub struct Sender { pub struct Message { pub id: String, pub sender: Sender, - /// Saved legacy plaintext whose sender authentication was not retained. Any old local - /// attribution is historical display metadata, never cryptographic authorization. + /// Saved legacy plaintext whose sender authentication was not retained. The sender shown + /// is the wire author or old local attribution: display metadata, never cryptographic + /// authorization, so the row cannot be edited. #[serde(default, skip_serializing_if = "is_false")] pub saved_history: bool, /// Unix seconds. diff --git a/crates/tinyclient/src/gui/conversation/rows.rs b/crates/tinyclient/src/gui/conversation/rows.rs index 956be7d9..c8a15cc6 100644 --- a/crates/tinyclient/src/gui/conversation/rows.rs +++ b/crates/tinyclient/src/gui/conversation/rows.rs @@ -75,6 +75,10 @@ fn continues(before: &Message, message: &Message, same_day: fn(i64, i64) -> bool before.kind == MessageKind::Normal && message.kind == MessageKind::Normal && message.reply_to.is_none() + // Empty ids used to mean "Saved message" for every unauthenticated sealed row; treating + // them as equal collapsed a whole back-and-forth under one header. + && !before.sender.id.is_empty() + && !message.sender.id.is_empty() // The same bot user posting as a different app is a different author. && before.sender.id == message.sender.id && before.sender.name == message.sender.name @@ -230,6 +234,14 @@ mod tests { assert_eq!(shape(&lay(&[ci, deploys], None)), "SDHH"); } + #[test] + fn empty_sender_ids_never_group_even_with_the_same_display_name() { + let t = 10 * DAY; + let first = with(said("1", "Saved message", t), |m| m.sender.id.clear()); + let second = with(said("2", "Saved message", t + 1), |m| m.sender.id.clear()); + assert_eq!(shape(&lay(&[first, second], None)), "SDHH"); + } + #[test] fn the_new_divider_sits_above_the_first_unread_and_above_a_day_divider() { let t = 10 * DAY; diff --git a/e2e/scenarios/legacy-saved-speakers/drive.linux.sh b/e2e/scenarios/legacy-saved-speakers/drive.linux.sh new file mode 100644 index 00000000..cc1b589e --- /dev/null +++ b/e2e/scenarios/legacy-saved-speakers/drive.linux.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +# legacy-saved-speakers on Linux. The lab runs this in the console session it is already filming. +set -euo pipefail +cd "$(dirname "$0")" +exec python3 ./drive.py diff --git a/e2e/scenarios/legacy-saved-speakers/drive.macos.sh b/e2e/scenarios/legacy-saved-speakers/drive.macos.sh new file mode 100644 index 00000000..6d5ac7b6 --- /dev/null +++ b/e2e/scenarios/legacy-saved-speakers/drive.macos.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +# legacy-saved-speakers on macOS. The lab runs this in the console session it is already filming. +set -euo pipefail +cd "$(dirname "$0")" +exec python3 ./drive.py diff --git a/e2e/scenarios/legacy-saved-speakers/drive.py b/e2e/scenarios/legacy-saved-speakers/drive.py new file mode 100644 index 00000000..6e485c61 --- /dev/null +++ b/e2e/scenarios/legacy-saved-speakers/drive.py @@ -0,0 +1,87 @@ +#!/usr/bin/env python3 +"""Unauthenticated sealed rows keep distinct speakers. + +Starts this build's chatd with TINYCLIENT_PROVE=legacy-saved-speakers. That build maps two +Legacy sealed rows from different wire authors and exits 0 only when both keep their speaker +ids (and the prove marker is printed). A build from before the fix ignores the variable and +keeps serving, so this driver fails there. + +Env from the lab: E2E_OUT, E2E_T0, E2E_APP_DIR. +""" + +import os +import subprocess +import sys +import time + +OUT = os.environ.get("E2E_OUT", "") +T0 = float(os.environ.get("E2E_T0") or time.time()) +APP = os.environ.get("E2E_APP_DIR", "") + +FAILED = False +STEPS = "" + + +def now(): + return time.time() - T0 + + +def step(words, ok): + global FAILED + if not ok: + FAILED = True + line = '{"t": %s, "step": "%s", "ok": %s}' % (round(now(), 1), words.replace('"', "'"), "true" if ok else "false") + print(("[ok] " if ok else "[FAILED] ") + words, flush=True) + if STEPS: + with open(STEPS, "a", encoding="utf-8") as handle: + handle.write(line + "\n") + + +def chatd_bin(app_dir): + if sys.platform == "win32": + return os.path.join(app_dir, "chatd.exe") + if sys.platform == "darwin" and app_dir.endswith(".app"): + return os.path.join(app_dir, "Contents", "MacOS", "chatd") + return os.path.join(app_dir, "chatd") + + +def main(): + global STEPS + if not OUT or not APP: + print("E2E_OUT and E2E_APP_DIR are required", file=sys.stderr) + return 1 + os.makedirs(OUT, exist_ok=True) + STEPS = os.path.join(OUT, "steps.jsonl") + open(STEPS, "w", encoding="utf-8").close() + binary = chatd_bin(APP) + if not os.path.isfile(binary): + step("chatd is in E2E_APP_DIR", False) + return 1 + + data = os.path.join(OUT, "data") + os.makedirs(data, exist_ok=True) + env = os.environ.copy() + env["TINYCLIENT_DIR"] = data + env["TINYCLIENT_PROVE"] = "legacy-saved-speakers" + env["TINYCLIENT_LOG_LEVEL"] = "info" + kwargs = {"cwd": data, "env": env, "stdin": subprocess.DEVNULL} + if sys.platform == "win32": + kwargs["creationflags"] = 0x08000000 + with open(os.path.join(OUT, "chatd.out.log"), "w", encoding="utf-8") as out, open(os.path.join(OUT, "chatd.err.log"), "w", encoding="utf-8") as err: + kwargs["stdout"] = out + kwargs["stderr"] = err + proc = subprocess.Popen([binary], **kwargs) + try: + code = proc.wait(timeout=45) + except subprocess.TimeoutExpired: + proc.kill() + proc.wait(timeout=5) + code = None + with open(os.path.join(OUT, "chatd.err.log"), encoding="utf-8") as handle: + checked = "prove-legacy-saved-speakers:0" in handle.read() + step("legacy sealed history keeps both wire speakers", code == 0 and checked) + return 1 if FAILED else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/e2e/scenarios/legacy-saved-speakers/drive.windows.ps1 b/e2e/scenarios/legacy-saved-speakers/drive.windows.ps1 new file mode 100644 index 00000000..e803900a --- /dev/null +++ b/e2e/scenarios/legacy-saved-speakers/drive.windows.ps1 @@ -0,0 +1,17 @@ +# legacy-saved-speakers on Windows. The lab runs this in the console session it is already filming. +$ErrorActionPreference = 'Stop' +$drive = Join-Path $PSScriptRoot 'drive.py' +if (Get-Command py -ErrorAction SilentlyContinue) { + & py -3 $drive + exit $LASTEXITCODE +} +if (Get-Command python -ErrorAction SilentlyContinue) { + & python $drive + exit $LASTEXITCODE +} +if (Get-Command python3 -ErrorAction SilentlyContinue) { + & python3 $drive + exit $LASTEXITCODE +} +Write-Host 'python is not on PATH (tried py -3, python, python3)' +exit 1 diff --git a/e2e/scenarios/legacy-saved-speakers/scenario.json b/e2e/scenarios/legacy-saved-speakers/scenario.json new file mode 100644 index 00000000..3620fa8c --- /dev/null +++ b/e2e/scenarios/legacy-saved-speakers/scenario.json @@ -0,0 +1,12 @@ +{ + "id": "legacy-saved-speakers", + "title": "Unauthenticated sealed history keeps distinct wire speakers instead of one Saved message wall", + "platforms": ["windows", "linux", "macos"], + "not_applicable": { + "android": "The phone proof build does not run desktop chatd against a localhost sealed map. The same map_sealed path is what this desktop run checks.", + "ios": "The phone proof build does not run desktop chatd against a localhost sealed map. The same map_sealed path is what this desktop run checks." + }, + "needs": [], + "release": false, + "max_still_seconds": 60 +}