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
5 changes: 5 additions & 0 deletions crates/chatd/src/adapters/tiny.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
3 changes: 2 additions & 1 deletion crates/chatd/src/adapters/tiny/e2ee.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
1 change: 1 addition & 0 deletions crates/chatd/src/adapters/tiny/e2ee/cache_auth.rs
Original file line number Diff line number Diff line change
@@ -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::*;

Expand Down
96 changes: 85 additions & 11 deletions crates/chatd/src/adapters/tiny/messages.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1798,19 +1798,21 @@ fn map_sealed(saved: &Saved, names: &HashMap<String, String>, 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();
message.sender.name = attribution.name.clone();
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 {
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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]
Expand Down
9 changes: 9 additions & 0 deletions crates/chatd/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
5 changes: 3 additions & 2 deletions crates/protocol/src/message.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
12 changes: 12 additions & 0 deletions crates/tinyclient/src/gui/conversation/rows.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand Down
5 changes: 5 additions & 0 deletions e2e/scenarios/legacy-saved-speakers/drive.linux.sh
Original file line number Diff line number Diff line change
@@ -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
5 changes: 5 additions & 0 deletions e2e/scenarios/legacy-saved-speakers/drive.macos.sh
Original file line number Diff line number Diff line change
@@ -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
87 changes: 87 additions & 0 deletions e2e/scenarios/legacy-saved-speakers/drive.py
Original file line number Diff line number Diff line change
@@ -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())
17 changes: 17 additions & 0 deletions e2e/scenarios/legacy-saved-speakers/drive.windows.ps1
Original file line number Diff line number Diff line change
@@ -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
12 changes: 12 additions & 0 deletions e2e/scenarios/legacy-saved-speakers/scenario.json
Original file line number Diff line number Diff line change
@@ -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
}
Loading