From b83bd5bdd9548b0e7ca7b1c9068dd47cdb5246d9 Mon Sep 17 00:00:00 2001 From: tv Date: Mon, 9 Nov 2026 17:32:08 +0000 Subject: signatures: a transfer carries one signature per writer in it Under a policy requiring signatures, a node takes a change only once a signature vouches for it: one of the change itself, or of a later change of the same writer. A writer is a node's run, each start, rejoin or drop making a fresh actor, so an epoch gathers writers for as long as it lasts, and a transfer of its whole history (a joiner pulling the document, a delta) must carry a signature for each of them, or the joiner waits for the rest for good. A message's signatures are now chosen by what it carries: * changes, up to 256 signed among them: each one's signature; * more (a whole document pulled): each writer's latest signed change among them, found by the document's change metadata (`Signatures::for_changes`), vouching for the rest; * a delta: each writer with changes in it (`get_changes_meta(from)`), its latest verified signature (`for_delta`). A message carries up to 65536 (6 MiB), and as many wait at the receiver for their changes (a delta's come after it). So a transfer leaves writers unvouched only in an epoch with more than 65536 runs writing since it began; a remake starts the count again. Wire version 48. --- docs/api.md | 15 +++-- src/document/signatures.rs | 137 ++++++++++++++++++++++++++++++++------------- src/swarm/sync.rs | 12 ++-- src/transport/mod.rs | 5 +- src/transport/wire.rs | 2 +- 5 files changed, 118 insertions(+), 53 deletions(-) diff --git a/docs/api.md b/docs/api.md index 3fb99be..a4b76b0 100644 --- a/docs/api.md +++ b/docs/api.md @@ -291,15 +291,18 @@ peer was sent before, so changes sent again (a message lost on the way) arrive vouched for as the first time; and a node checks one it receives once it holds the change (a signature costs about 30 µs to check, and one vouches for its node's changes before it). A message carrying no changes carries no signatures; one carrying more -than 4096 signed changes (a whole document pulled), and a delta, carry each node run's -latest instead. A node signs the latest change of each of its runs that nothing vouches -for as it starts, before anything leaves, and whenever its document is swapped (a drop, -a switch, a rejoin): changes it kept unsigned, never sent. +than 256 signed changes (a whole document pulled) carries each writer's latest among +them instead, and a delta each writer's latest with changes in it. A writer is a node's +run: each start, rejoin or drop makes a fresh one. A node signs the latest change of +each of its runs that nothing vouches for as it starts, before anything leaves, and +whenever its document is swapped (a drop, a switch, a rejoin): changes it kept +unsigned, never sent. Two cases still arrive unvouched, and wait under a policy requiring signatures: a change vouched for only by a later change of its node that the rules refused (nodes take it, -then can't pass the proof on, [`future-work.md`](future-work.md)); and, in one transfer, -the changes of an epoch with more than 4096 node runs writing. +then can't pass the proof on); and, in one transfer, the changes of runs past the +65536 a message carries signatures of at most, in an epoch with more runs writing than +that since it began. A remake starts the count again. The signatures a node has are kept in its document file (`--document-file`) with the document. diff --git a/src/document/signatures.rs b/src/document/signatures.rs index e5c9e69..b62e545 100644 --- a/src/document/signatures.rs +++ b/src/document/signatures.rs @@ -21,7 +21,7 @@ //! more of its actor's changes than any before; one of a change not there yet waits //! (`pending`, a bounded number: the oldest go). -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use super::{ChangeHash, Document}; @@ -148,11 +148,17 @@ impl<'de> serde::Deserialize<'de> for SignEvery { } } -/// Signatures a sync message carries at most (`for_changes`, `latest_each`), as many -/// as wait for their changes (`MAX_PENDING`): 384 KiB of them. A whole document pulled, -/// or a delta, carries each actor's latest, and an actor is a node's run: an epoch with -/// more runs writing than this leaves some unvouched for in such a transfer. -pub(crate) const MAX_PER_MESSAGE: usize = MAX_PENDING; +/// Signatures a sync message carries at most, and as many wait for their changes +/// (`MAX_PENDING`): 6 MiB of them. A message carries one per writer it holds changes of +/// at most (`for_changes`, `for_delta`), and a writer is a node's run (each start, rejoin +/// or drop makes a fresh actor) since the epoch began: a transfer of an epoch with more +/// runs writing than this, a whole document pulled or a delta, leaves the rest unvouched +/// for, and a node taking it under a policy requiring signatures waits for them. A remake +/// starts the count again. +pub(crate) const MAX_PER_MESSAGE: usize = 1 << 16; +/// Past this many signed changes among those a message carries, it carries each +/// writer's latest among them instead (`for_changes`): they vouch for the rest. +const EACH_WRITER_PAST: usize = 256; /// A signature as messages and the document file carry it (`pack`): the change's /// hash (32 bytes), then the signature (64). pub(crate) const PACKED: usize = 32 + 64; @@ -186,7 +192,8 @@ pub(crate) fn unpack(bytes: &[u8]) -> Option> { ) } /// Received signatures of changes not there yet, kept at most (`offer`): the oldest go. -const MAX_PENDING: usize = 4096; +/// As many as a message carries, so a delta's (whose changes come after it) all wait. +const MAX_PENDING: usize = MAX_PER_MESSAGE; /// Received signatures of changes the document doesn't hold yet, `MAX_PENDING` at most: /// the oldest go, so junk a peer sent once (signatures of changes that never come) @@ -237,8 +244,6 @@ pub(crate) struct Signatures { unsigned: Option, /// By actor: its latest change a signature verified, so its changes up to it. latest: HashMap, - /// Counts each change of `latest`: which became so last (`latest_each`). - stamp: u64, /// Received signatures of changes the document doesn't hold yet. pending: Pending, } @@ -248,8 +253,6 @@ pub(crate) struct Signatures { struct Latest { seq: u64, hash: ChangeHash, - /// `Signatures::stamp` when it became the latest. - stamp: u64, } impl Signatures { @@ -325,15 +328,7 @@ impl Signatures { { return false; } - self.stamp += 1; - self.latest.insert( - actor, - Latest { - seq, - hash, - stamp: self.stamp, - }, - ); + self.latest.insert(actor, Latest { seq, hash }); true } @@ -368,24 +363,40 @@ impl Signatures { kept } - /// Each actor's latest verified signature, those that became so last first, - /// `MAX_PER_MESSAGE` at most: for changes of any actor, as a delta carries. - pub(crate) fn latest_each(&self) -> Vec<(ChangeHash, [u8; 64])> { - let mut latest: Vec<&Latest> = self.latest.values().collect(); - latest.sort_unstable_by_key(|latest| std::cmp::Reverse(latest.stamp)); - latest.truncate(MAX_PER_MESSAGE); - latest + /// The signatures to send with a delta from `from` (`SyncBody::Delta`): of each + /// writer with changes in it, its latest verified, vouching for its changes before + /// it; `MAX_PER_MESSAGE` at most. The delta's changes come after it, so these wait + /// for them at the peer (`offer`). + pub(crate) fn for_delta( + &self, + doc: &Document, + from: &[ChangeHash], + ) -> Vec<(ChangeHash, [u8; 64])> { + let writers: HashSet = doc + .get_changes_meta(from) .into_iter() + .map(|meta| meta.actor.into_owned()) + .collect(); + writers + .iter() + .filter_map(|writer| self.latest.get(writer)) .map(|latest| (latest.hash, self.by_change[&latest.hash])) + .take(MAX_PER_MESSAGE) .collect() } /// The signatures to send with `changes`, those a message carries: of each of them - /// signed, each vouching for its actor's changes before it among them. Whatever the + /// signed, each vouching for its writer's changes before it among them. Whatever the /// peer was sent before, so changes sent again (a message lost on the way, then - /// repaired) arrive vouched for as the first time. Past `MAX_PER_MESSAGE` (a message - /// carrying the batches of many writers), each actor's latest instead. - pub(crate) fn for_changes(&self, changes: &[ChangeHash]) -> Vec<(ChangeHash, [u8; 64])> { + /// repaired) arrive vouched for as the first time. Past `EACH_WRITER_PAST` (a whole + /// document pulled, a message carrying the batches of many writers), each writer's + /// latest among them, which vouches for the rest: one per writer, + /// `MAX_PER_MESSAGE` at most. + pub(crate) fn for_changes( + &self, + doc: &Document, + changes: &[ChangeHash], + ) -> Vec<(ChangeHash, [u8; 64])> { let signed: Vec<(ChangeHash, [u8; 64])> = changes .iter() .filter_map(|hash| { @@ -394,10 +405,28 @@ impl Signatures { .map(|signature| (*hash, *signature)) }) .collect(); - if signed.len() > MAX_PER_MESSAGE { - return self.latest_each(); + if signed.len() <= EACH_WRITER_PAST { + return signed; } - signed + let signed: HashMap = signed.into_iter().collect(); + // Each signed one's writer and place among its changes: the metadata of the + // document's changes, read once (a message this large carries most of them). + let mut latest: HashMap = HashMap::new(); + for meta in doc.get_changes_meta(&[]) { + if signed.contains_key(&meta.hash) { + let at = latest + .entry(meta.actor.into_owned()) + .or_insert((meta.seq, meta.hash)); + if meta.seq > at.0 { + *at = (meta.seq, meta.hash); + } + } + } + latest + .into_values() + .map(|(_, hash)| (hash, signed[&hash])) + .take(MAX_PER_MESSAGE) + .collect() } /// Received signatures waiting for their changes (`offer`) checked against @@ -424,13 +453,11 @@ impl Signatures { .get(actor) .is_none_or(|latest| latest.seq < change.seq()) { - self.stamp += 1; self.latest.insert( actor.clone(), Latest { seq: change.seq(), hash, - stamp: self.stamp, }, ); } @@ -588,6 +615,35 @@ mod tests { .unwrap(); } + /// Past `EACH_WRITER_PAST` signed changes, a message carries each writer's latest + /// among them; a delta, each writer's with changes in it. + #[test] + fn each_writers_latest_past_many() { + let key = NodeKey::from_seed([1; 32]); + let mut doc = Document::new(); + let mut signatures = Signatures::default(); + let mut hashes = Vec::new(); + for i in 0..=EACH_WRITER_PAST { + write(&mut doc, &key.id(), &["n"], json!(i)); + let hash = doc.get_heads()[0]; + signatures.sign(&doc, hash, &key); + hashes.push(hash); + } + let last = *hashes.last().unwrap(); + assert_eq!(signatures.for_changes(&doc, &hashes).len(), 1); + assert_eq!(signatures.for_changes(&doc, &hashes)[0].0, last); + // Fewer than that: each signed one. + assert_eq!(signatures.for_changes(&doc, &hashes[..10]).len(), 10); + // Another writer's changes alone in a delta: only its latest. + let mut theirs = doc.fork().with_actor(ActorId::random()); + write(&mut theirs, &key.id(), &["m"], json!(1)); + let other = theirs.get_heads()[0]; + signatures.sign(&theirs, other, &key); + let delta = signatures.for_delta(&theirs, &[last]); + assert_eq!(delta.len(), 1); + assert_eq!(delta[0].0, other); + } + /// A listed signed path is a pattern: `*` takes any one key, within and above. #[test] fn listed_paths_take_a_star() { @@ -718,13 +774,14 @@ mod tests { signatures.offer(&ours, vec![(second, other.sign(&message(&second)))]), 0 ); - assert_eq!(signatures.latest_each(), vec![signed(second)]); + assert_eq!(signatures.for_delta(&ours, &[]), vec![signed(second)]); + assert!(signatures.for_delta(&ours, &[second]).is_empty()); // Sent with the changes they are of: the signed one's alone. assert_eq!( - signatures.for_changes(&[first, second]), + signatures.for_changes(&ours, &[first, second]), vec![signed(second)] ); - assert!(signatures.for_changes(&[first]).is_empty()); + assert!(signatures.for_changes(&ours, &[first]).is_empty()); // A change not there yet: its signature waits for it. write(&mut theirs, &key.id(), &["c"], json!(3)); @@ -735,7 +792,7 @@ mod tests { .unwrap(); assert_eq!(signatures.offer(&ours, Vec::new()), 1); assert!(signatures.verified(&actor, 3)); - assert_eq!(signatures.latest_each(), vec![signed(third)]); + assert_eq!(signatures.for_delta(&ours, &[]), vec![signed(third)]); // Kept in a file and read again: verified as before. let again = Signatures::from_kept(&ours, signatures.since(0)); diff --git a/src/swarm/sync.rs b/src/swarm/sync.rs index e3f6055..3a3d92c 100644 --- a/src/swarm/sync.rs +++ b/src/swarm/sync.rs @@ -300,11 +300,15 @@ async fn deliver_sync_to_peer(state: &AppState, peer: &str) -> Result<()> { // With changes, the signatures of those it carries: they go where // changes go, along the trees, so a peer that got these changes // elsewhere got the signatures with them; and changes sent again go - // vouched for as the first time. A delta, of any writer's changes, - // each writer's latest. A message carrying no changes takes none. + // vouched for as the first time. A delta, of each writer with changes + // in it, its latest. A message carrying no changes takes none. let signatures = match &body { - Some(transport::SyncBody::Delta(_)) => replica.signatures.latest_each(), - Some(_) => replica.signatures.for_changes(peer_state.sync.carried()), + Some(transport::SyncBody::Delta(spec)) => { + replica.signatures.for_delta(&replica.doc, &spec.from) + } + Some(_) => replica + .signatures + .for_changes(&replica.doc, peer_state.sync.carried()), None => Vec::new(), }; let signatures = crate::document::signatures::pack(&signatures); diff --git a/src/transport/mod.rs b/src/transport/mod.rs index cbb6734..fcdf56e 100644 --- a/src/transport/mod.rs +++ b/src/transport/mod.rs @@ -734,8 +734,9 @@ pub(crate) struct SyncEnvelope { /// to come (`swarm::hop`). pub(crate) sent: crate::clock::UnixMillis, /// Signatures of changes (`document::signatures`): of the changes the message - /// carries, or each actor's latest for a delta or past `signatures::MAX_PER_MESSAGE` - /// (`Signatures::for_changes`, `latest_each`), as `signatures::pack` packs them. + /// carries, each writer's latest among them past a few hundred; of a delta, each + /// writer's latest with changes in it (`Signatures::for_changes`, `for_delta`), + /// `signatures::MAX_PER_MESSAGE` at most, as `signatures::pack` packs them. pub(crate) signatures: serde_bytes::ByteBuf, } diff --git a/src/transport/wire.rs b/src/transport/wire.rs index 6784f8e..7fe8983 100644 --- a/src/transport/wire.rs +++ b/src/transport/wire.rs @@ -14,7 +14,7 @@ use super::{EncodedMessage, MessageKind, ParsedMessage}; /// The wire format's version, the first byte of every payload. Bump it with every change /// to a message type or a codec (`golden_bytes` fails then). -pub(crate) const VERSION: u8 = 47; +pub(crate) const VERSION: u8 = 48; /// What the second byte of a payload says. fn tag(kind: MessageKind) -> u8 { -- cgit v1.3.1