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
125
Cargo.lock
generated
125
Cargo.lock
generated
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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)]
|
||||||
|
|
|
||||||
|
|
@ -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
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]
|
#[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() {
|
||||||
|
|
|
||||||
21
src/main.rs
21
src/main.rs
|
|
@ -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);
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue