Add optional per-origin Ed25519 signing for gossip (Tier C federation)

This commit is contained in:
Jean Chevronnet 2026-07-19 21:44:51 +00:00
parent 01da702704
commit e3c7f373d3
No known key found for this signature in database
7 changed files with 353 additions and 8 deletions

View file

@ -31,6 +31,7 @@ mod account;
mod channel;
mod event;
mod network;
pub mod sign;
mod store;
pub use event::Event;
@ -747,6 +748,12 @@ pub struct LogEntry {
seq: u64,
#[serde(default)]
lamport: u64,
// Optional Ed25519 signature (base64) over the entry, for Tier C federation. When
// signing is off it's None and `skip_serializing_if` omits it entirely, so the log
// format is byte-identical to an unsigned deployment. Must precede the flattened
// event so serde extracts it as a named field before the event captures the rest.
#[serde(default, skip_serializing_if = "Option::is_none")]
sig: Option<String>,
#[serde(flatten)]
event: Event,
}
@ -754,7 +761,7 @@ pub struct LogEntry {
#[cfg(test)]
impl LogEntry {
pub(crate) fn for_test(origin: &str, seq: u64, lamport: u64, event: Event) -> Self {
LogEntry { origin: origin.to_string(), seq, lamport, event }
LogEntry { origin: origin.to_string(), seq, lamport, sig: None, event }
}
}
@ -796,11 +803,14 @@ pub struct EventLog {
// event (~20% of the append cost on real disk). Reset to None after compact()
// replaces the file, since the old handle would point at a discarded inode.
file: Option<std::fs::File>,
// Tier C federation: when set, Global entries this node authors are signed and
// ingested Global entries must carry a signature from a trusted origin key.
signing: Option<sign::Signing>,
}
impl EventLog {
fn open(path: PathBuf, origin: String) -> (Self, Vec<Event>) {
let mut log = Self { path, origin, lamport: 0, versions: HashMap::new(), entries: Vec::new(), outbound: None, readonly: false, file: None };
let mut log = Self { path, origin, lamport: 0, versions: HashMap::new(), entries: Vec::new(), outbound: None, readonly: false, file: None, signing: None };
if let Ok(data) = std::fs::read_to_string(&log.path) {
for line in data.lines().filter(|l| !l.trim().is_empty()) {
match serde_json::from_str::<LogEntry>(line) {
@ -842,9 +852,11 @@ impl EventLog {
let global = event.scope() == Scope::Global;
let entry = if global {
self.lamport = self.lamport.saturating_add(1);
LogEntry { origin: self.origin.clone(), seq: self.next_seq(), lamport: self.lamport, event }
let seq = self.next_seq();
let sig = self.signing.as_ref().map(|s| s.sign(&self.origin, seq, self.lamport, &event));
LogEntry { origin: self.origin.clone(), seq, lamport: self.lamport, sig, event }
} else {
LogEntry { origin: self.origin.clone(), seq: 0, lamport: 0, event }
LogEntry { origin: self.origin.clone(), seq: 0, lamport: 0, sig: None, event }
};
if let Err(e) = self.persist(&entry) {
// Surface the real cause (disk full, permissions, ...) here at the
@ -874,6 +886,14 @@ impl EventLog {
if entry.event.scope() != Scope::Global {
return Ok(None);
}
// Tier C: when signing is configured, a Global entry must carry a valid
// signature from a trusted origin key, or it's rejected before it can touch
// the version vector. Inert when signing is off (flat-trust / Tier B).
if let Some(s) = &self.signing {
if !s.verify(&entry.origin, entry.seq, entry.lamport, &entry.event, entry.sig.as_deref()) {
return Ok(None);
}
}
// Apply strictly in per-origin sequence. The FIRST entry ever seen from an
// origin sets the baseline — a peer syncing from a COMPACTED node starts
// mid-stream (its seqs restart at next_seq(), not 0), so we can't demand 0.
@ -910,6 +930,10 @@ impl EventLog {
self.outbound = Some(tx);
}
fn set_signing(&mut self, signing: sign::Signing) {
self.signing = Some(signing);
}
// The events appended at or after `mark`, cloned. Used by the audit feed to
// see exactly what a just-run command changed (the log only ever grows during
// a command, so `[mark..]` is that command's footprint).
@ -973,12 +997,14 @@ impl EventLog {
for event in events {
let entry = if event.scope() == Scope::Global {
self.lamport = self.lamport.saturating_add(1);
let e = LogEntry { origin: self.origin.clone(), seq, lamport: self.lamport, event };
// Compaction re-seqs entries, so any signature must be recomputed.
let sig = self.signing.as_ref().map(|s| s.sign(&self.origin, seq, self.lamport, &event));
let e = LogEntry { origin: self.origin.clone(), seq, lamport: self.lamport, sig, event };
last_global = Some(seq);
seq += 1;
e
} else {
LogEntry { origin: self.origin.clone(), seq: 0, lamport: 0, event }
LogEntry { origin: self.origin.clone(), seq: 0, lamport: 0, sig: None, event }
};
snapshot.push(entry);
}
@ -1409,6 +1435,12 @@ impl Db {
self.log.set_outbound(tx);
}
/// Enable Tier C gossip signing: sign Global entries we author, and verify
/// ingested Global entries against the trusted per-origin keys.
pub fn set_gossip_signing(&mut self, signing: sign::Signing) {
self.log.set_signing(signing);
}
/// The number of entries in the log, a mark to later diff against.
pub fn log_len(&self) -> usize {
self.log.len()

110
src/engine/db/sign.rs Normal file
View file

@ -0,0 +1,110 @@
// Per-origin Ed25519 signatures for gossiped (Global) log entries — the Tier C
// federation trust model (see docs/federation.md). Each node signs the entries it
// authors; peers verify against the author origin's public key, so a peer can only
// assert records for an origin whose private key it holds. Entirely optional: with
// no `[gossip.signing]` configured, entries are unsigned and this is inert.
use std::collections::HashMap;
use base64::engine::general_purpose::STANDARD as B64;
use base64::Engine as _;
use ed25519_dalek::{Signature, Signer, SigningKey, VerifyingKey};
use super::Event;
// The bytes a signature covers: everything that fixes the entry's identity and
// content. `serde_json` is deterministic for the Global event set (all `Vec`/scalar
// fields — no unordered maps), so sender and receiver derive the same bytes.
fn payload(origin: &str, seq: u64, lamport: u64, event: &Event) -> Vec<u8> {
let mut v = Vec::new();
v.extend_from_slice(origin.as_bytes());
v.push(0);
v.extend_from_slice(&seq.to_le_bytes());
v.extend_from_slice(&lamport.to_le_bytes());
v.push(0);
v.extend_from_slice(serde_json::to_string(event).unwrap_or_default().as_bytes());
v
}
// A node's signing configuration: its own key (to sign what it authors) plus the
// public keys it trusts, keyed by origin SID (to verify what it ingests).
pub struct Signing {
signer: SigningKey,
trust: HashMap<String, VerifyingKey>,
}
impl Signing {
// Build from base64 config: this node's 32-byte secret key, and a map of
// origin -> its 32-byte public key. Errors describe the offending field.
pub fn new(secret_b64: &str, trust_b64: &HashMap<String, String>) -> Result<Self, String> {
let secret = B64.decode(secret_b64.trim()).map_err(|_| "gossip.signing.key is not valid base64".to_string())?;
let bytes: [u8; 32] = secret.as_slice().try_into().map_err(|_| "gossip.signing.key must be 32 bytes".to_string())?;
let signer = SigningKey::from_bytes(&bytes);
let mut trust = HashMap::new();
for (origin, pk_b64) in trust_b64 {
let raw = B64.decode(pk_b64.trim()).map_err(|_| format!("gossip.signing.trust key for {origin} is not valid base64"))?;
let arr: [u8; 32] = raw.as_slice().try_into().map_err(|_| format!("gossip.signing.trust key for {origin} must be 32 bytes"))?;
let vk = VerifyingKey::from_bytes(&arr).map_err(|_| format!("gossip.signing.trust key for {origin} is not a valid ed25519 public key"))?;
trust.insert(origin.clone(), vk);
}
Ok(Signing { signer, trust })
}
// Sign an entry we're authoring; base64 of the 64-byte signature.
pub fn sign(&self, origin: &str, seq: u64, lamport: u64, event: &Event) -> String {
B64.encode(self.signer.sign(&payload(origin, seq, lamport, event)).to_bytes())
}
// True if `sig` is a valid signature over the entry by the trusted key for its
// origin. False if the origin isn't trusted, the signature is missing, or it
// doesn't verify (`verify_strict` also rejects malleable/degenerate signatures).
pub fn verify(&self, origin: &str, seq: u64, lamport: u64, event: &Event, sig: Option<&str>) -> bool {
let Some(vk) = self.trust.get(origin) else { return false };
let Some(sig) = sig else { return false };
let Ok(raw) = B64.decode(sig.trim()) else { return false };
let Ok(bytes) = <[u8; 64]>::try_from(raw.as_slice()) else { return false };
vk.verify_strict(&payload(origin, seq, lamport, event), &Signature::from_bytes(&bytes)).is_ok()
}
}
// A fresh keypair as (secret_b64, public_b64), for the `--gen-gossip-key` CLI.
pub fn generate() -> (String, String) {
let signer = SigningKey::generate(&mut rand_core::OsRng);
(B64.encode(signer.to_bytes()), B64.encode(signer.verifying_key().to_bytes()))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::engine::db::Event;
fn ev() -> Event {
Event::AccountDropped { account: "alice".into() }
}
#[test]
fn sign_then_verify_roundtrips_and_rejects_tampering() {
let (sec, pubk) = generate();
let trust = HashMap::from([("A".to_string(), pubk)]);
let s = Signing::new(&sec, &trust).unwrap();
let sig = s.sign("A", 3, 4, &ev());
assert!(s.verify("A", 3, 4, &ev(), Some(&sig)), "a genuine signature verifies");
// Any change to the covered fields invalidates it.
assert!(!s.verify("A", 4, 4, &ev(), Some(&sig)), "a different seq is rejected");
assert!(!s.verify("A", 3, 4, &Event::AccountDropped { account: "bob".into() }, Some(&sig)), "a different event is rejected");
assert!(!s.verify("A", 3, 4, &ev(), None), "a missing signature is rejected");
assert!(!s.verify("B", 3, 4, &ev(), Some(&sig)), "an untrusted origin is rejected");
}
#[test]
fn a_forged_key_does_not_verify() {
let (sec_a, pub_a) = generate();
let (sec_b, _pub_b) = generate();
// We trust A's key; B tries to forge an entry claiming origin A.
let s = Signing::new(&sec_a, &HashMap::from([("A".to_string(), pub_a)])).unwrap();
let forger = Signing::new(&sec_b, &HashMap::new()).unwrap();
let forged = forger.sign("A", 1, 1, &ev());
assert!(!s.verify("A", 1, 1, &ev(), Some(&forged)), "a signature from the wrong key is rejected");
}
}

View file

@ -424,7 +424,7 @@
#[test]
fn ingest_is_idempotent() {
let (mut log, _) = EventLog::open(tmp("ingest"), "local".into());
let entry = LogEntry { origin: "peer".into(), seq: 0, lamport: 5, event: cert("x", "f") };
let entry = LogEntry { origin: "peer".into(), seq: 0, lamport: 5, sig: None, event: cert("x", "f") };
assert!(log.ingest(entry.clone()).unwrap().is_some(), "first ingest applies");
assert_eq!(log.versions.get("peer"), Some(&0));
assert_eq!(log.lamport, 6, "receive rule: max(local, remote) + 1");
@ -441,7 +441,7 @@
name: "bob".into(), email: None,
ts: 0, home: "peer".into(), scram256: None, scram512: None, certfps: vec![], verified: true, ajoin: vec![], suspension: None, memos: vec![], memo_ignore: vec![], memo_notify: true, memo_limit: None, greet: String::new(), no_autoop: false, no_protect: false, hide_status: false, snotice: false, vhost: None, vhost_request: None, last_seen: 0, noexpire: false, expiry_warned: false, oper_note: None,
};
let entry = LogEntry { origin: "peer".into(), seq: 0, lamport: 1, event: Event::AccountRegistered(Box::new(bob)) };
let entry = LogEntry { origin: "peer".into(), seq: 0, lamport: 1, sig: None, event: Event::AccountRegistered(Box::new(bob)) };
db.ingest(entry).unwrap();
assert!(db.exists("bob"), "peer's account is present locally");
assert!(db.exists("alice"), "local account still there");
@ -582,6 +582,50 @@
assert_eq!(b.certfps("alice"), &[fp][..], "certs arrive folded into the snapshot");
}
// Tier C: a node accepts a signed entry from a trusted origin, and rejects one
// from an origin it doesn't trust — even if that origin signed it validly.
#[test]
fn gossip_signing_accepts_trusted_and_rejects_untrusted() {
use super::sign::{self, Signing};
use std::collections::HashMap;
let (a_sec, a_pub) = sign::generate();
let (b_sec, _) = sign::generate();
let (c_sec, _) = sign::generate();
let mut a = Db::open(tmp("signA"), "AAA");
a.scram_iterations = 4096;
a.set_gossip_signing(Signing::new(&a_sec, &HashMap::new()).unwrap());
a.register("alice", "pw", None).unwrap();
// B trusts A's public key (and its own, though unused here).
let mut b = Db::open(tmp("signB"), "BBB");
b.scram_iterations = 4096;
b.set_gossip_signing(Signing::new(&b_sec, &HashMap::from([("AAA".to_string(), a_pub)])).unwrap());
for entry in a.missing_for(&b.version_vector()) {
b.ingest(entry).unwrap();
}
assert!(b.exists("alice"), "a signed entry from a trusted origin is accepted");
// C signs validly with its own key, but B doesn't trust origin CCC.
let mut c = Db::open(tmp("signC"), "CCC");
c.scram_iterations = 4096;
c.set_gossip_signing(Signing::new(&c_sec, &HashMap::new()).unwrap());
c.register("mallory", "pw", None).unwrap();
for entry in c.missing_for(&b.version_vector()) {
b.ingest(entry).unwrap();
}
assert!(!b.exists("mallory"), "an entry from an untrusted origin is rejected");
}
// With signing off, the serialized log carries no `sig` field — byte-identical
// to a pre-signing deployment (the compatibility guarantee).
#[test]
fn unsigned_entries_omit_the_sig_field() {
let entry = LogEntry::for_test("A", 0, 1, Event::AccountDropped { account: "x".into() });
let json = serde_json::to_string(&entry).unwrap();
assert!(!json.contains("\"sig\""), "no sig field when unsigned: {json}");
}
// Channels register (case-insensitively unique), persist, and drop.
#[test]
fn channels_register_drop_and_persist() {