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:
2026-06-21 03:25:38 -05:00
parent 7ccb32bb64
commit ee938600c3
2 changed files with 28 additions and 7 deletions

View File

@@ -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());

View File

@@ -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.