Add optional per-origin Ed25519 signing for gossip (Tier C federation)
This commit is contained in:
parent
01da702704
commit
e3c7f373d3
7 changed files with 353 additions and 8 deletions
|
|
@ -325,6 +325,18 @@ pub struct Gossip {
|
|||
// Mutual-TLS for the peer link. Absent = plaintext.
|
||||
#[serde(default)]
|
||||
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)]
|
||||
|
|
|
|||
|
|
@ -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
110
src/engine/db/sign.rs
Normal 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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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() {
|
||||
|
|
|
|||
21
src/main.rs
21
src/main.rs
|
|
@ -75,6 +75,15 @@ async fn main() -> Result<()> {
|
|||
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 cfg = config::Config::load(&path)?;
|
||||
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);
|
||||
db.scram_iterations = cfg.server.scram_iterations;
|
||||
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_external_accounts(cfg.auth.as_ref().is_some_and(|a| a.external));
|
||||
db.set_confusable_check(cfg.register.confusable_check);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue