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

125
Cargo.lock generated
View file

@ -177,6 +177,12 @@ version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "base64ct"
version = "1.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
[[package]] [[package]]
name = "bitflags" name = "bitflags"
version = "2.13.0" version = "2.13.0"
@ -225,6 +231,12 @@ dependencies = [
"cc", "cc",
] ]
[[package]]
name = "const-oid"
version = "0.9.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
[[package]] [[package]]
name = "cpufeatures" name = "cpufeatures"
version = "0.2.17" version = "0.2.17"
@ -244,6 +256,43 @@ dependencies = [
"typenum", "typenum",
] ]
[[package]]
name = "curve25519-dalek"
version = "4.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be"
dependencies = [
"cfg-if",
"cpufeatures",
"curve25519-dalek-derive",
"digest",
"fiat-crypto",
"rustc_version",
"subtle",
"zeroize",
]
[[package]]
name = "curve25519-dalek-derive"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "der"
version = "0.7.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
dependencies = [
"const-oid",
"zeroize",
]
[[package]] [[package]]
name = "digest" name = "digest"
version = "0.10.7" version = "0.10.7"
@ -299,6 +348,7 @@ dependencies = [
"echo-operserv", "echo-operserv",
"echo-reportserv", "echo-reportserv",
"echo-statserv", "echo-statserv",
"ed25519-dalek",
"hmac", "hmac",
"pbkdf2", "pbkdf2",
"prost", "prost",
@ -452,6 +502,31 @@ dependencies = [
"echo-api", "echo-api",
] ]
[[package]]
name = "ed25519"
version = "2.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53"
dependencies = [
"pkcs8",
"signature",
]
[[package]]
name = "ed25519-dalek"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9"
dependencies = [
"curve25519-dalek",
"ed25519",
"rand_core",
"serde",
"sha2",
"subtle",
"zeroize",
]
[[package]] [[package]]
name = "either" name = "either"
version = "1.16.0" version = "1.16.0"
@ -480,6 +555,12 @@ version = "2.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
[[package]]
name = "fiat-crypto"
version = "0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d"
[[package]] [[package]]
name = "find-msvc-tools" name = "find-msvc-tools"
version = "0.1.9" version = "0.1.9"
@ -1029,6 +1110,16 @@ version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
name = "pkcs8"
version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7"
dependencies = [
"der",
"spki",
]
[[package]] [[package]]
name = "pkg-config" name = "pkg-config"
version = "0.3.33" version = "0.3.33"
@ -1276,6 +1367,15 @@ dependencies = [
"windows-sys 0.52.0", "windows-sys 0.52.0",
] ]
[[package]]
name = "rustc_version"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92"
dependencies = [
"semver",
]
[[package]] [[package]]
name = "rustix" name = "rustix"
version = "1.1.4" version = "1.1.4"
@ -1347,6 +1447,12 @@ version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
[[package]]
name = "semver"
version = "1.0.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
[[package]] [[package]]
name = "serde" name = "serde"
version = "1.0.228" version = "1.0.228"
@ -1458,6 +1564,15 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "signature"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
dependencies = [
"rand_core",
]
[[package]] [[package]]
name = "slab" name = "slab"
version = "0.4.12" version = "0.4.12"
@ -1490,6 +1605,16 @@ dependencies = [
"windows-sys 0.61.2", "windows-sys 0.61.2",
] ]
[[package]]
name = "spki"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d"
dependencies = [
"base64ct",
"der",
]
[[package]] [[package]]
name = "stable_deref_trait" name = "stable_deref_trait"
version = "1.2.1" version = "1.2.1"

View file

@ -59,6 +59,7 @@ base64 = "0.22"
sha2 = "0.10" sha2 = "0.10"
hmac = "0.12" hmac = "0.12"
pbkdf2 = { version = "0.12", default-features = false, features = ["hmac"] } pbkdf2 = { version = "0.12", default-features = false, features = ["hmac"] }
ed25519-dalek = { version = "2", features = ["rand_core"] }
subtle = "2" subtle = "2"
regex = "1" regex = "1"
anyhow = "1" anyhow = "1"

View file

@ -325,6 +325,18 @@ pub struct Gossip {
// Mutual-TLS for the peer link. Absent = plaintext. // Mutual-TLS for the peer link. Absent = plaintext.
#[serde(default)] #[serde(default)]
pub tls: Option<Tls>, pub tls: Option<Tls>,
// Tier C per-origin signing (see docs/federation.md). Absent = flat trust.
#[serde(default)]
pub signing: Option<Signing>,
}
#[derive(Debug, Deserialize, Clone)]
pub struct Signing {
// This node's base64 Ed25519 secret key (from `echo --gen-gossip-key`).
pub key: String,
// origin SID -> its base64 Ed25519 public key. Include your own SID plus each peer's.
#[serde(default)]
pub trust: std::collections::HashMap<String, String>,
} }
#[derive(Debug, Deserialize, Clone)] #[derive(Debug, Deserialize, Clone)]

View file

@ -31,6 +31,7 @@ mod account;
mod channel; mod channel;
mod event; mod event;
mod network; mod network;
pub mod sign;
mod store; mod store;
pub use event::Event; pub use event::Event;
@ -747,6 +748,12 @@ pub struct LogEntry {
seq: u64, seq: u64,
#[serde(default)] #[serde(default)]
lamport: u64, 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)] #[serde(flatten)]
event: Event, event: Event,
} }
@ -754,7 +761,7 @@ pub struct LogEntry {
#[cfg(test)] #[cfg(test)]
impl LogEntry { impl LogEntry {
pub(crate) fn for_test(origin: &str, seq: u64, lamport: u64, event: Event) -> Self { 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() // 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. // replaces the file, since the old handle would point at a discarded inode.
file: Option<std::fs::File>, 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 { impl EventLog {
fn open(path: PathBuf, origin: String) -> (Self, Vec<Event>) { 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) { if let Ok(data) = std::fs::read_to_string(&log.path) {
for line in data.lines().filter(|l| !l.trim().is_empty()) { for line in data.lines().filter(|l| !l.trim().is_empty()) {
match serde_json::from_str::<LogEntry>(line) { match serde_json::from_str::<LogEntry>(line) {
@ -842,9 +852,11 @@ impl EventLog {
let global = event.scope() == Scope::Global; let global = event.scope() == Scope::Global;
let entry = if global { let entry = if global {
self.lamport = self.lamport.saturating_add(1); 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 { } 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) { if let Err(e) = self.persist(&entry) {
// Surface the real cause (disk full, permissions, ...) here at the // Surface the real cause (disk full, permissions, ...) here at the
@ -874,6 +886,14 @@ impl EventLog {
if entry.event.scope() != Scope::Global { if entry.event.scope() != Scope::Global {
return Ok(None); 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 // 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 // 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. // 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); 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 // 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 // see exactly what a just-run command changed (the log only ever grows during
// a command, so `[mark..]` is that command's footprint). // a command, so `[mark..]` is that command's footprint).
@ -973,12 +997,14 @@ impl EventLog {
for event in events { for event in events {
let entry = if event.scope() == Scope::Global { let entry = if event.scope() == Scope::Global {
self.lamport = self.lamport.saturating_add(1); 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); last_global = Some(seq);
seq += 1; seq += 1;
e e
} else { } 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); snapshot.push(entry);
} }
@ -1409,6 +1435,12 @@ impl Db {
self.log.set_outbound(tx); 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. /// The number of entries in the log, a mark to later diff against.
pub fn log_len(&self) -> usize { pub fn log_len(&self) -> usize {
self.log.len() 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] #[test]
fn ingest_is_idempotent() { fn ingest_is_idempotent() {
let (mut log, _) = EventLog::open(tmp("ingest"), "local".into()); 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!(log.ingest(entry.clone()).unwrap().is_some(), "first ingest applies");
assert_eq!(log.versions.get("peer"), Some(&0)); assert_eq!(log.versions.get("peer"), Some(&0));
assert_eq!(log.lamport, 6, "receive rule: max(local, remote) + 1"); assert_eq!(log.lamport, 6, "receive rule: max(local, remote) + 1");
@ -441,7 +441,7 @@
name: "bob".into(), email: None, 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, 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(); db.ingest(entry).unwrap();
assert!(db.exists("bob"), "peer's account is present locally"); assert!(db.exists("bob"), "peer's account is present locally");
assert!(db.exists("alice"), "local account still there"); assert!(db.exists("alice"), "local account still there");
@ -582,6 +582,50 @@
assert_eq!(b.certfps("alice"), &[fp][..], "certs arrive folded into the snapshot"); 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. // Channels register (case-insensitively unique), persist, and drop.
#[test] #[test]
fn channels_register_drop_and_persist() { fn channels_register_drop_and_persist() {

View file

@ -75,6 +75,15 @@ async fn main() -> Result<()> {
return Ok(()); return Ok(());
} }
// `echo --gen-gossip-key` prints a fresh Ed25519 keypair for Tier C federation.
if matches!(argv.get(1).map(String::as_str), Some("--gen-gossip-key") | Some("gen-gossip-key")) {
let (secret, public) = engine::db::sign::generate();
println!("# gossip signing keypair — keep `key` secret; publish `public` to your peers");
println!("key = \"{secret}\"");
println!("public = \"{public}\"");
return Ok(());
}
let path = std::env::args().nth(1).unwrap_or_else(|| "config.toml".to_string()); let path = std::env::args().nth(1).unwrap_or_else(|| "config.toml".to_string());
let cfg = config::Config::load(&path)?; let cfg = config::Config::load(&path)?;
let ts = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs(); let ts = SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs();
@ -185,6 +194,18 @@ async fn main() -> Result<()> {
let mut db = engine::db::Db::open("echo.db.jsonl", &cfg.server.sid); let mut db = engine::db::Db::open("echo.db.jsonl", &cfg.server.sid);
db.scram_iterations = cfg.server.scram_iterations; db.scram_iterations = cfg.server.scram_iterations;
db.set_outbound(gossip_tx.clone()); db.set_outbound(gossip_tx.clone());
if let Some(sig) = cfg.gossip.as_ref().and_then(|g| g.signing.as_ref()) {
match engine::db::sign::Signing::new(&sig.key, &sig.trust) {
Ok(s) => {
db.set_gossip_signing(s);
tracing::info!(trusted = sig.trust.len(), "gossip signing enabled (Tier C per-origin signatures)");
}
Err(e) => {
tracing::error!(%e, "invalid [gossip.signing] config");
std::process::exit(1);
}
}
}
db.set_email_enabled(cfg.email.is_some()); db.set_email_enabled(cfg.email.is_some());
db.set_external_accounts(cfg.auth.as_ref().is_some_and(|a| a.external)); db.set_external_accounts(cfg.auth.as_ref().is_some_and(|a| a.external));
db.set_confusable_check(cfg.register.confusable_check); db.set_confusable_check(cfg.register.confusable_check);