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
|
std::thread::sleep(Duration::from_secs(2)); // let B receive + ack, A record
|
||||||
|
|
||||||
let r = a.receipts();
|
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);
|
println!("✅ A sees its clipboard was delivered → seen by {:?} [{}]", rec.name, rec.source);
|
||||||
} else {
|
} else {
|
||||||
println!("❌ no receipt recorded ({} total)", r.len());
|
println!("❌ no receipt recorded ({} total)", r.len());
|
||||||
|
|||||||
+27
-6
@@ -97,6 +97,25 @@ type DirectSet = Arc<Mutex<HashSet<String>>>;
|
|||||||
/// present peer is directly reachable.
|
/// present peer is directly reachable.
|
||||||
type PresentSet = Arc<Mutex<HashMap<String, Instant>>>;
|
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.
|
/// 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
|
/// 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
|
/// 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 cfg = self.cfg.clone();
|
||||||
let transports = self.transports.clone();
|
let transports = self.transports.clone();
|
||||||
let rt = self.rt.clone();
|
let rt = self.rt.clone();
|
||||||
|
let direct = self.direct.clone();
|
||||||
|
let present = self.present.clone();
|
||||||
let sink: Sink = Arc::new(move |m: Message| {
|
let sink: Sink = Arc::new(move |m: Message| {
|
||||||
// Delivery acks addressed to us → record "seen by <name>".
|
// Delivery acks addressed to us → record "seen by <name>".
|
||||||
if m.kind == "receipt" {
|
if m.kind == "receipt" {
|
||||||
@@ -257,7 +278,7 @@ impl Engine {
|
|||||||
if !m.from.is_empty() && m.from != cfg.from {
|
if !m.from.is_empty() && m.from != cfg.from {
|
||||||
let ack = Message {
|
let ack = Message {
|
||||||
kind: "receipt".into(),
|
kind: "receipt".into(),
|
||||||
text: m.text.clone(),
|
text: fingerprint(&m.text), // hash, not the clipboard content
|
||||||
from: cfg.from.clone(),
|
from: cfg.from.clone(),
|
||||||
to: m.from.clone(),
|
to: m.from.clone(),
|
||||||
role: cfg.name.clone(), // our friendly name for the receipt
|
role: cfg.name.clone(), // our friendly name for the receipt
|
||||||
@@ -265,8 +286,12 @@ impl Engine {
|
|||||||
room: cfg.room.clone(),
|
room: cfg.room.clone(),
|
||||||
ts: 0,
|
ts: 0,
|
||||||
};
|
};
|
||||||
|
let covered = directly_covered(&direct, &present);
|
||||||
let h = rt.handle();
|
let h = rt.handle();
|
||||||
for t in &transports {
|
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());
|
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
|
/// direct channel — i.e. the relay isn't needed for delivery. False if we
|
||||||
/// don't yet know who's present (be safe, relay).
|
/// don't yet know who's present (be safe, relay).
|
||||||
fn directly_covered(&self) -> bool {
|
fn directly_covered(&self) -> bool {
|
||||||
let direct = self.direct.lock().unwrap();
|
directly_covered(&self.direct, &self.present)
|
||||||
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))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Stop all transports at their next checkpoint.
|
/// Stop all transports at their next checkpoint.
|
||||||
|
|||||||
Reference in New Issue
Block a user