core: harden receipts — hash not text, and gate the ack on direct-coverage
Receipts carried the clipboard text and weren't coverage-gated, so an ack could echo content over the relay even when the clipboard stayed P2P. Now the receipt carries fingerprint(text) (sha256[..8]) instead of the text, and the auto-ack skips the SSE relay when a direct path covers the room — same rule as send(). Closes the last way content could reach the server once P2P is up. Verified: prefer_direct still keeps the payload off the bus (only presence relayed, no receipts), and receipt_demo still confirms delivery (matched by device name now that the text is hashed). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -28,7 +28,7 @@ fn main() {
|
||||
std::thread::sleep(Duration::from_secs(2)); // let B receive + ack, A record
|
||||
|
||||
let r = a.receipts();
|
||||
if let Some(rec) = r.iter().find(|r| r.text == "important-link") {
|
||||
if let Some(rec) = r.iter().find(|r| r.name == "Patrick's iPhone") {
|
||||
println!("✅ A sees its clipboard was delivered → seen by {:?} [{}]", rec.name, rec.source);
|
||||
} else {
|
||||
println!("❌ no receipt recorded ({} total)", r.len());
|
||||
|
||||
@@ -97,6 +97,25 @@ type DirectSet = Arc<Mutex<HashSet<String>>>;
|
||||
/// present peer is directly reachable.
|
||||
type PresentSet = Arc<Mutex<HashMap<String, Instant>>>;
|
||||
|
||||
/// Short content fingerprint — a receipt carries this, never the clipboard text,
|
||||
/// so acks can't leak content (sender matches by computing the same fingerprint).
|
||||
fn fingerprint(text: &str) -> String {
|
||||
use sha2::{Digest, Sha256};
|
||||
Sha256::digest(text.as_bytes())[..8]
|
||||
.iter()
|
||||
.map(|b| format!("{b:02x}"))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// True when every present peer is reachable over a direct channel.
|
||||
fn directly_covered(direct: &DirectSet, present: &PresentSet) -> bool {
|
||||
let d = direct.lock().unwrap();
|
||||
let now = Instant::now();
|
||||
let mut p = present.lock().unwrap();
|
||||
p.retain(|_, t| now.duration_since(*t) < Duration::from_secs(20));
|
||||
!p.is_empty() && p.keys().all(|x| d.contains(x))
|
||||
}
|
||||
|
||||
/// Canonical room id for a shared identity: sha256(account)[..4] as 8 hex chars.
|
||||
/// Defined once here and exported so every client (Swift/Kotlin/agent) derives
|
||||
/// the SAME room — that's what puts all of one account's devices together across
|
||||
@@ -227,6 +246,8 @@ impl Engine {
|
||||
let cfg = self.cfg.clone();
|
||||
let transports = self.transports.clone();
|
||||
let rt = self.rt.clone();
|
||||
let direct = self.direct.clone();
|
||||
let present = self.present.clone();
|
||||
let sink: Sink = Arc::new(move |m: Message| {
|
||||
// Delivery acks addressed to us → record "seen by <name>".
|
||||
if m.kind == "receipt" {
|
||||
@@ -257,7 +278,7 @@ impl Engine {
|
||||
if !m.from.is_empty() && m.from != cfg.from {
|
||||
let ack = Message {
|
||||
kind: "receipt".into(),
|
||||
text: m.text.clone(),
|
||||
text: fingerprint(&m.text), // hash, not the clipboard content
|
||||
from: cfg.from.clone(),
|
||||
to: m.from.clone(),
|
||||
role: cfg.name.clone(), // our friendly name for the receipt
|
||||
@@ -265,8 +286,12 @@ impl Engine {
|
||||
room: cfg.room.clone(),
|
||||
ts: 0,
|
||||
};
|
||||
let covered = directly_covered(&direct, &present);
|
||||
let h = rt.handle();
|
||||
for t in &transports {
|
||||
if covered && t.name() == "sse" {
|
||||
continue; // keep the ack off the relay too when P2P covers
|
||||
}
|
||||
t.publish(h, ack.clone());
|
||||
}
|
||||
}
|
||||
@@ -314,11 +339,7 @@ impl Engine {
|
||||
/// direct channel — i.e. the relay isn't needed for delivery. False if we
|
||||
/// don't yet know who's present (be safe, relay).
|
||||
fn directly_covered(&self) -> bool {
|
||||
let direct = self.direct.lock().unwrap();
|
||||
let now = Instant::now();
|
||||
let mut present = self.present.lock().unwrap();
|
||||
present.retain(|_, t| now.duration_since(*t) < Duration::from_secs(20));
|
||||
!present.is_empty() && present.keys().all(|p| direct.contains(p))
|
||||
directly_covered(&self.direct, &self.present)
|
||||
}
|
||||
|
||||
/// Stop all transports at their next checkpoint.
|
||||
|
||||
Reference in New Issue
Block a user