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

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