diff --git a/.config/tracey/config.styx b/.config/tracey/config.styx index 45c2f46b..e5d1b9f4 100644 --- a/.config/tracey/config.styx +++ b/.config/tracey/config.styx @@ -43,6 +43,34 @@ specs ( } ) } + { + name transport + source_url https://github.com/beyondessential/seedling + include (docs/spec/transport.md) + impls ( + { + name main + include ( + crates/*/src/*.rs + crates/*/src/**/*.rs + ) + } + ) + } + { + name grove + source_url https://github.com/beyondessential/seedling + include (docs/spec/grove.md) + impls ( + { + name main + include ( + crates/*/src/*.rs + crates/*/src/**/*.rs + ) + } + ) + } { name web source_url https://github.com/beyondessential/seedling diff --git a/Cargo.lock b/Cargo.lock index bc48c0d1..68d82327 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2229,6 +2229,9 @@ name = "hex" version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +dependencies = [ + "serde", +] [[package]] name = "hex-literal" @@ -4426,6 +4429,12 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "ryu-js" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6518fc26bced4d53678a22d6e423e9d8716377def84545fe328236e3af070e7f" + [[package]] name = "same-file" version = "1.0.6" @@ -4631,6 +4640,7 @@ dependencies = [ "dirs 6.0.0", "ed25519-dalek", "futures-util", + "hex", "jiff", "quinn", "rand_core 0.6.4", @@ -4638,6 +4648,7 @@ dependencies = [ "rustls 0.23.40", "rustls-pki-types", "serde", + "serde_jcs", "serde_json", "sha2 0.11.0", "subtle", @@ -4723,6 +4734,17 @@ dependencies = [ "syn", ] +[[package]] +name = "serde_jcs" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3a60f3fda61525e439ef6d67422118f11e986566997d9021c56867ad814a0aa" +dependencies = [ + "ryu-js", + "serde", + "serde_json", +] + [[package]] name = "serde_json" version = "1.0.150" diff --git a/crates/core/src/grove.rs b/crates/core/src/grove.rs new file mode 100644 index 00000000..ebe79718 --- /dev/null +++ b/crates/core/src/grove.rs @@ -0,0 +1,5 @@ +pub mod db; +pub mod publish; +pub mod state; + +pub use state::GroveState; diff --git a/crates/core/src/grove/db.rs b/crates/core/src/grove/db.rs new file mode 100644 index 00000000..163c8a70 --- /dev/null +++ b/crates/core/src/grove/db.rs @@ -0,0 +1,338 @@ +//! Read/write helpers over the v53 grove tables. +//! +//! All functions take a [`crate::runtime::db::Db`] reference and run inline +//! on the DB thread, mirroring the pattern in `oi/auth.rs`. Callers wrap +//! them in `state.db.call(...)` to dispatch onto the DB actor. + +use jiff::Timestamp; +use rusqlite::{OptionalExtension, params}; +use uuid::Uuid; + +use seedling_protocol::grove::{Param, Payload, Role, SignedPayload}; + +use crate::runtime::db::Db; + +/// One row of `grove_membership` (id is implicitly 1) decoded into a +/// typed [`SignedPayload`] plus the metadata captured when this node +/// joined the grove. +#[derive(Debug, Clone)] +pub struct Membership { + pub grove_id: Uuid, + pub role: Role, + pub leader_fingerprint: String, + pub joined_at: Timestamp, + pub current: SignedPayload, +} + +#[derive(Debug)] +pub enum LoadError { + Sql(rusqlite::Error), + UuidLength(usize), + UnknownRole(String), + PayloadDecode(serde_json::Error), + TimestampDecode(jiff::Error), +} + +impl std::fmt::Display for LoadError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Sql(e) => write!(f, "sql: {e}"), + Self::UuidLength(n) => write!(f, "grove_id is {n} bytes, expected 16"), + Self::UnknownRole(s) => write!(f, "unknown role: {s:?}"), + Self::PayloadDecode(e) => write!(f, "payload decode: {e}"), + Self::TimestampDecode(e) => write!(f, "timestamp decode: {e}"), + } + } +} + +impl std::error::Error for LoadError {} + +impl From for LoadError { + fn from(e: rusqlite::Error) -> Self { + Self::Sql(e) + } +} + +/// Load this node's grove membership, or `None` if it is not in any grove. +// g[impl identity] +pub fn load_membership(db: &Db) -> Result, LoadError> { + let row = db + .conn + .query_row( + "SELECT grove_id, role, leader_fingerprint, current_payload, current_signature, joined_at \ + FROM grove_membership WHERE id = 1", + [], + |r| { + Ok(( + r.get::<_, Vec>(0)?, + r.get::<_, String>(1)?, + r.get::<_, String>(2)?, + r.get::<_, Vec>(3)?, + r.get::<_, Vec>(4)?, + r.get::<_, String>(5)?, + )) + }, + ) + .optional()?; + + let Some((grove_id_bytes, role_s, leader_fp, payload_bytes, sig_bytes, joined_at_s)) = row + else { + return Ok(None); + }; + + let grove_id = match grove_id_bytes.len() { + 16 => Uuid::from_bytes(grove_id_bytes.as_slice().try_into().unwrap()), + n => return Err(LoadError::UuidLength(n)), + }; + let role = match role_s.as_str() { + "leader" => Role::Leader, + "follower" => Role::Follower, + _ => return Err(LoadError::UnknownRole(role_s)), + }; + let payload: Payload = + serde_json::from_slice(&payload_bytes).map_err(LoadError::PayloadDecode)?; + let joined_at = joined_at_s + .parse::() + .map_err(LoadError::TimestampDecode)?; + + Ok(Some(Membership { + grove_id, + role, + leader_fingerprint: leader_fp, + joined_at, + current: SignedPayload { + payload, + signature: sig_bytes, + }, + })) +} + +/// Persist this node's grove membership and the currently-applied signed +/// payload. Used by both `grove init` (initial membership row) and +/// payload-apply (subsequent updates to the same row). Always runs on +/// `id = 1`; the table's CHECK constraint enforces the single-row invariant. +// g[impl identity] +// g[impl membership.canonical] +pub fn write_membership(db: &Db, m: &Membership) -> Result<(), LoadError> { + let payload_bytes = serde_json::to_vec(&m.current.payload).map_err(LoadError::PayloadDecode)?; + db.conn.execute( + "INSERT INTO grove_membership \ + (id, grove_id, role, leader_fingerprint, current_seq, current_payload, current_signature, joined_at) \ + VALUES (1, ?1, ?2, ?3, ?4, ?5, ?6, ?7) \ + ON CONFLICT(id) DO UPDATE SET \ + grove_id = excluded.grove_id, \ + role = excluded.role, \ + leader_fingerprint = excluded.leader_fingerprint, \ + current_seq = excluded.current_seq, \ + current_payload = excluded.current_payload, \ + current_signature = excluded.current_signature", + params![ + m.grove_id.as_bytes().to_vec(), + match m.role { + Role::Leader => "leader", + Role::Follower => "follower", + }, + m.leader_fingerprint, + m.current.payload.seq as i64, + payload_bytes, + m.current.signature, + m.joined_at.to_string(), + ], + )?; + Ok(()) +} + +/// Insert a payload into the audit history, idempotent on `seq`. Returns +/// `true` if a row was inserted, `false` if the seq was already present. +pub fn insert_version( + db: &Db, + signed: &SignedPayload, + received_at: Timestamp, +) -> Result { + let payload_bytes = serde_json::to_vec(&signed.payload).map_err(LoadError::PayloadDecode)?; + let rows = db.conn.execute( + "INSERT OR IGNORE INTO grove_versions (seq, payload, signature, received_at) \ + VALUES (?1, ?2, ?3, ?4)", + params![ + signed.payload.seq as i64, + payload_bytes, + signed.signature, + received_at.to_string(), + ], + )?; + Ok(rows == 1) +} + +/// Replace the denormalised `grove_params` projection to match the given +/// payload's parameter list. Atomic with respect to other DB writes by +/// virtue of the DB actor's serialisation; callers wanting transactional +/// atomicity with a `write_membership` in the same closure must wrap both +/// in a `Db::conn::unchecked_transaction`. +// g[impl params.set] +pub fn replace_params(db: &Db, version_seq: u64, params_list: &[Param]) -> Result<(), LoadError> { + db.conn.execute("DELETE FROM grove_params", [])?; + let mut stmt = db.conn.prepare( + "INSERT INTO grove_params (name, kind, value, version_seq) VALUES (?1, ?2, ?3, ?4)", + )?; + for p in params_list { + stmt.execute(params![p.name, p.kind, p.value, version_seq as i64])?; + } + Ok(()) +} + +/// Read back the denormalised grove parameters in name order. +pub fn list_params(db: &Db) -> Result, LoadError> { + let mut stmt = db + .conn + .prepare("SELECT name, kind, value, version_seq FROM grove_params ORDER BY name ASC")?; + let rows = stmt.query_map([], |r| { + Ok(( + Param { + name: r.get(0)?, + kind: r.get(1)?, + value: r.get(2)?, + }, + r.get::<_, i64>(3)? as u64, + )) + })?; + let mut out = Vec::new(); + for row in rows { + out.push(row?); + } + Ok(out) +} + +#[cfg(test)] +mod tests { + use ed25519_dalek::SigningKey; + use jiff::Timestamp; + use rand_core::OsRng; + use seedling_protocol::grove::{Member, Param, Payload}; + use uuid::Uuid; + + use super::*; + + fn fresh_db() -> Db { + Db::open_in_memory().expect("in-memory db") + } + + fn sample_signed(seq: u64) -> SignedPayload { + let key = SigningKey::generate(&mut OsRng); + Payload { + grove_id: Uuid::from_u128(0xfeed_face_cafe_babe_dead_beef_0000_0001), + seq, + created_at: Timestamp::from_second(1_700_000_000).unwrap(), + leader_fp: "fp-leader".into(), + members: vec![Member { + fp: "fp-leader".into(), + label: "leader".into(), + }], + params: vec![Param { + name: "greeting".into(), + kind: "text".into(), + value: "hello".into(), + }], + secrets: vec![], + } + .sign(&key) + .expect("sign") + } + + fn sample_membership(seq: u64) -> Membership { + let signed = sample_signed(seq); + Membership { + grove_id: signed.payload.grove_id, + role: Role::Leader, + leader_fingerprint: signed.payload.leader_fp.clone(), + joined_at: Timestamp::from_second(1_700_000_000).unwrap(), + current: signed, + } + } + + // g[verify identity] + #[test] + fn load_membership_returns_none_on_fresh_db() { + let db = fresh_db(); + assert!(load_membership(&db).unwrap().is_none()); + } + + // g[verify identity] + // g[verify membership.canonical] + #[test] + fn write_then_load_membership_round_trips() { + let db = fresh_db(); + let m = sample_membership(1); + write_membership(&db, &m).expect("write"); + let loaded = load_membership(&db).expect("load").expect("present"); + assert_eq!(loaded.grove_id, m.grove_id); + assert_eq!(loaded.role, m.role); + assert_eq!(loaded.leader_fingerprint, m.leader_fingerprint); + assert_eq!(loaded.joined_at, m.joined_at); + assert_eq!(loaded.current.payload.seq, m.current.payload.seq); + assert_eq!(loaded.current.signature, m.current.signature); + } + + #[test] + fn write_membership_replaces_existing_row() { + let db = fresh_db(); + write_membership(&db, &sample_membership(1)).unwrap(); + write_membership(&db, &sample_membership(2)).unwrap(); + let loaded = load_membership(&db).unwrap().unwrap(); + assert_eq!(loaded.current.payload.seq, 2); + } + + #[test] + fn insert_version_is_idempotent_on_seq() { + let db = fresh_db(); + let s1 = sample_signed(1); + let inserted_first = insert_version(&db, &s1, Timestamp::now()).unwrap(); + let inserted_second = insert_version(&db, &s1, Timestamp::now()).unwrap(); + assert!(inserted_first); + assert!(!inserted_second); + let count: i64 = db + .conn + .query_row("SELECT COUNT(*) FROM grove_versions", [], |r| r.get(0)) + .unwrap(); + assert_eq!(count, 1); + } + + // g[verify params.set] + #[test] + fn replace_params_overwrites_previous_set() { + let db = fresh_db(); + replace_params( + &db, + 1, + &[Param { + name: "a".into(), + kind: "text".into(), + value: "first".into(), + }], + ) + .unwrap(); + replace_params( + &db, + 2, + &[ + Param { + name: "b".into(), + kind: "text".into(), + value: "second".into(), + }, + Param { + name: "a".into(), + kind: "text".into(), + value: "first-updated".into(), + }, + ], + ) + .unwrap(); + let loaded = list_params(&db).unwrap(); + assert_eq!(loaded.len(), 2); + assert_eq!(loaded[0].0.name, "a"); + assert_eq!(loaded[0].0.value, "first-updated"); + assert_eq!(loaded[0].1, 2); + assert_eq!(loaded[1].0.name, "b"); + assert_eq!(loaded[1].1, 2); + } +} diff --git a/crates/core/src/grove/publish.rs b/crates/core/src/grove/publish.rs new file mode 100644 index 00000000..a0295668 --- /dev/null +++ b/crates/core/src/grove/publish.rs @@ -0,0 +1,419 @@ +//! Leader-side publish operations. +//! +//! All mutating leader actions (`grove init`, `grove invite`, `grove +//! revoke`, `grove param set`/`unset`) flow through one of two entry +//! points: [`GroveState::init`] (no current membership) or +//! [`GroveState::publish`] (current membership exists). Both serialise on +//! [`GroveState::publish_mutex`] so the seq is monotonic and the on-disk +//! membership row is always consistent with the latest signed payload. + +use ed25519_dalek::SigningKey; +use jiff::Timestamp; +use uuid::Uuid; + +use seedling_protocol::grove::{ + GroveError as ProtoError, Member, Param, Payload, Role, SignedPayload, +}; +use seedling_protocol::keys; + +use crate::grove::db::{self, Membership}; +use crate::grove::state::GroveState; + +/// Hard maximum on the canonical-JSON byte length of a published payload. +/// Receivers reject anything larger; the leader's pre-publish check +/// reserves headroom below this. +// g[impl versioning.size-cap] +pub const PAYLOAD_SIZE_CAP_BYTES: usize = 256 * 1024; + +/// Reservation kept below [`PAYLOAD_SIZE_CAP_BYTES`]. Leader mutations +/// that would push the next payload past `cap - headroom` are rejected +/// at the point of mutation, before seq is bumped or the signature is +/// computed. +pub const PAYLOAD_SIZE_HEADROOM_BYTES: usize = 16 * 1024; + +/// Effective leader cap after subtracting the headroom reservation. +pub const PAYLOAD_PUBLISH_CAP_BYTES: usize = PAYLOAD_SIZE_CAP_BYTES - PAYLOAD_SIZE_HEADROOM_BYTES; + +#[derive(Debug)] +pub enum PublishError { + /// This node is not a member of any grove. Use [`GroveState::init`] first. + NotMember, + /// This node is a member but is a follower; only the leader may publish. + NotLeader, + /// `init` was called on a node that is already a member. + AlreadyMember, + /// The supplied signing key's SPKI fingerprint does not match the grove's + /// pinned `leader_fingerprint`. Almost always a configuration error. + LeaderKeyMismatch { + expected: String, + actual: String, + }, + /// The candidate payload's canonical-JSON encoding exceeds the leader + /// publish cap. Operators must shrink the change set before retrying. + // g[impl versioning.size-cap] + PayloadTooLarge { + current_bytes: usize, + cap_bytes: usize, + }, + /// In-version v0, secret grove parameters are rejected at the publish + /// boundary. The signed-payload `secrets` field is reserved but + /// always-empty in this version. + // g[impl params.no-secrets] + SecretsNotSupported, + Sign(ProtoError), + Db(db::LoadError), +} + +impl std::fmt::Display for PublishError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::NotMember => write!(f, "not a member of any grove"), + Self::NotLeader => write!(f, "publish requires the leader role"), + Self::AlreadyMember => write!(f, "node is already a member of a grove"), + Self::LeaderKeyMismatch { expected, actual } => write!( + f, + "leader key fingerprint {actual} does not match pinned leader {expected}" + ), + Self::PayloadTooLarge { + current_bytes, + cap_bytes, + } => write!( + f, + "next payload would be {current_bytes} bytes, exceeding the {cap_bytes}-byte publish cap" + ), + Self::SecretsNotSupported => write!( + f, + "secret grove params are not supported in this protocol version" + ), + Self::Sign(e) => write!(f, "sign: {e}"), + Self::Db(e) => write!(f, "db: {e}"), + } + } +} + +impl std::error::Error for PublishError {} + +/// View of the payload fields a leader operation may mutate. Other +/// fields (`grove_id`, `seq`, `created_at`, `leader_fp`, `secrets`) are +/// managed by [`GroveState::publish`] itself. +pub struct PendingPayload { + pub members: Vec, + pub params: Vec, +} + +impl GroveState { + /// Initialise a new grove with this node as leader. Generates a fresh + /// `grove_id`, creates a seq=1 self-only signed payload, persists, + /// and returns the signed payload. + /// + /// Fails with [`PublishError::AlreadyMember`] if this node is already + /// in a grove. + // g[impl bootstrap.init] + // g[impl membership.bootstrap] + pub fn init( + &self, + leader_key: &SigningKey, + leader_label: String, + ) -> Result { + let _guard = self.publish_mutex.lock(); + let existing: Option = self + .db + .call(db::load_membership) + .map_err(PublishError::Db)?; + if existing.is_some() { + return Err(PublishError::AlreadyMember); + } + + let leader_fp = keys::fingerprint(&keys::spki_der(leader_key)); + let now = Timestamp::now(); + + let payload = Payload { + grove_id: Uuid::new_v4(), + seq: 1, + created_at: now, + leader_fp: leader_fp.clone(), + members: vec![Member { + fp: leader_fp.clone(), + label: leader_label, + }], + params: Vec::new(), + secrets: Vec::new(), + }; + + self.finalise_publish(payload, leader_key, Role::Leader, leader_fp, now) + } + + /// Run a leader-side publish operation. Acquires the publish mutex, + /// loads the current membership from the DB inside the lock, applies + /// `mutate` to a clone of the current members + params, bumps seq, + /// canonicalises, size-checks, signs, and persists atomically. + // g[impl versioning.seq] + // g[impl membership.invite] + // g[impl membership.revoke] + // g[impl params.set] + // g[impl surface.role-gate] + pub fn publish( + &self, + leader_key: &SigningKey, + mutate: F, + ) -> Result + where + F: FnOnce(&mut PendingPayload), + { + let _guard = self.publish_mutex.lock(); + let m: Membership = self + .db + .call(db::load_membership) + .map_err(PublishError::Db)? + .ok_or(PublishError::NotMember)?; + if m.role != Role::Leader { + return Err(PublishError::NotLeader); + } + + let leader_fp = keys::fingerprint(&keys::spki_der(leader_key)); + if leader_fp != m.leader_fingerprint { + return Err(PublishError::LeaderKeyMismatch { + expected: m.leader_fingerprint.clone(), + actual: leader_fp, + }); + } + + let mut pending = PendingPayload { + members: m.current.payload.members.clone(), + params: m.current.payload.params.clone(), + }; + mutate(&mut pending); + + let now = Timestamp::now(); + let payload = Payload { + grove_id: m.current.payload.grove_id, + seq: m.current.payload.seq + 1, + created_at: now, + leader_fp: leader_fp.clone(), + members: pending.members, + params: pending.params, + secrets: Vec::new(), + }; + + self.finalise_publish(payload, leader_key, Role::Leader, leader_fp, m.joined_at) + } + + fn finalise_publish( + &self, + mut payload: Payload, + leader_key: &SigningKey, + role: Role, + leader_fp: String, + joined_at: Timestamp, + ) -> Result { + // g[impl params.no-secrets] + if !payload.secrets.is_empty() { + return Err(PublishError::SecretsNotSupported); + } + payload.canonicalise(); + let size = payload.canonical_size().map_err(PublishError::Sign)?; + if size > PAYLOAD_PUBLISH_CAP_BYTES { + return Err(PublishError::PayloadTooLarge { + current_bytes: size, + cap_bytes: PAYLOAD_PUBLISH_CAP_BYTES, + }); + } + let signed = payload.sign(leader_key).map_err(PublishError::Sign)?; + + let m = Membership { + grove_id: signed.payload.grove_id, + role, + leader_fingerprint: leader_fp, + joined_at, + current: signed.clone(), + }; + let signed_for_history = signed.clone(); + let received_at = Timestamp::now(); + let params_list = signed.payload.params.clone(); + let new_seq = signed.payload.seq; + + self.db + .call(move |db| -> Result<(), db::LoadError> { + let tx = db.conn.unchecked_transaction()?; + db::write_membership(db, &m)?; + db::insert_version(db, &signed_for_history, received_at)?; + db::replace_params(db, new_seq, ¶ms_list)?; + tx.commit()?; + Ok(()) + }) + .map_err(PublishError::Db)?; + + // g[impl trust] + *self.current.write() = Some(signed.clone()); + let mut trust = self.trust.write(); + trust.clear(); + for member in &signed.payload.members { + trust.insert(member.fp.clone()); + } + + Ok(signed) + } +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + use std::sync::Arc; + + use ed25519_dalek::SigningKey; + use rand_core::OsRng; + + use seedling_protocol::keys; + + use super::*; + use crate::runtime::db::DbHandle; + use crate::transport::TransportState; + + fn fresh_state() -> (Arc, SigningKey) { + let db = DbHandle::open_in_memory().expect("db"); + let transport = TransportState::new(PathBuf::from("/tmp/grove-publish-test-key")); + let state = GroveState::load(transport, db).expect("state"); + (state, SigningKey::generate(&mut OsRng)) + } + + // g[verify bootstrap.init] + // g[verify membership.bootstrap] + #[test] + fn init_creates_seq_one_self_only_payload() { + let (state, key) = fresh_state(); + let signed = state.init(&key, "leader".into()).expect("init"); + assert_eq!(signed.payload.seq, 1); + assert_eq!(signed.payload.members.len(), 1); + let leader_fp = keys::fingerprint(&keys::spki_der(&key)); + assert_eq!(signed.payload.members[0].fp, leader_fp); + assert_eq!(signed.payload.leader_fp, leader_fp); + assert!(state.is_member()); + assert!(state.trust.read().contains(&leader_fp)); + } + + #[test] + fn init_fails_if_already_a_member() { + let (state, key) = fresh_state(); + state.init(&key, "leader".into()).unwrap(); + let err = state + .init(&key, "leader".into()) + .expect_err("second init must fail"); + assert!(matches!(err, PublishError::AlreadyMember)); + } + + // g[verify versioning.seq] + // g[verify membership.invite] + #[test] + fn publish_after_init_invite_bumps_seq_and_extends_membership() { + let (state, key) = fresh_state(); + state.init(&key, "leader".into()).unwrap(); + let signed = state + .publish(&key, |p| { + p.members.push(Member { + fp: "fp-new".into(), + label: "n".into(), + }); + }) + .expect("invite"); + assert_eq!(signed.payload.seq, 2); + assert_eq!(signed.payload.members.len(), 2); + // canonicalise sorts alphabetically by fp; "fp-new" precedes the + // leader fingerprint only if it sorts that way, so just check both + // are present. + let fps: Vec<&str> = signed + .payload + .members + .iter() + .map(|m| m.fp.as_str()) + .collect(); + assert!(fps.contains(&"fp-new")); + let trust = state.trust.read(); + assert_eq!(trust.len(), 2); + assert!(trust.contains("fp-new")); + } + + // g[verify params.set] + #[test] + fn publish_param_set_appears_in_grove_params_table() { + let (state, key) = fresh_state(); + state.init(&key, "leader".into()).unwrap(); + state + .publish(&key, |p| { + p.params.push(Param { + name: "greeting".into(), + kind: "text".into(), + value: "hello".into(), + }); + }) + .unwrap(); + let listed = state.db.call(db::list_params).expect("list"); + assert_eq!(listed.len(), 1); + assert_eq!(listed[0].0.name, "greeting"); + assert_eq!(listed[0].0.value, "hello"); + assert_eq!(listed[0].1, 2); + } + + // g[verify surface.role-gate] + #[test] + fn publish_fails_on_non_member() { + let (state, key) = fresh_state(); + let err = state.publish(&key, |_| {}).expect_err("must fail"); + assert!(matches!(err, PublishError::NotMember)); + } + + // g[verify versioning.size-cap] + #[test] + fn publish_rejects_oversize_payload() { + let (state, key) = fresh_state(); + state.init(&key, "leader".into()).unwrap(); + let err = state + .publish(&key, |p| { + // Push enough members to blow past the publish cap. + let big_label = "x".repeat(1024); + for i in 0..1024 { + p.members.push(Member { + fp: format!("fp-padding-{i:08}"), + label: big_label.clone(), + }); + } + }) + .expect_err("must reject"); + match err { + PublishError::PayloadTooLarge { + current_bytes, + cap_bytes, + } => { + assert!( + current_bytes > cap_bytes, + "got {current_bytes} <= cap {cap_bytes}" + ); + } + other => panic!("expected PayloadTooLarge, got {other:?}"), + } + // Seq did not advance. + let m = state + .db + .call(db::load_membership) + .unwrap() + .expect("present"); + assert_eq!(m.current.payload.seq, 1); + } + + #[test] + fn publish_rejects_wrong_leader_key() { + let (state, leader_key) = fresh_state(); + state.init(&leader_key, "leader".into()).unwrap(); + let other_key = SigningKey::generate(&mut OsRng); + let err = state.publish(&other_key, |_| {}).expect_err("must reject"); + assert!(matches!(err, PublishError::LeaderKeyMismatch { .. })); + } + + #[test] + fn publish_signature_is_verifiable_against_leader_key() { + let (state, key) = fresh_state(); + let signed = state.init(&key, "leader".into()).unwrap(); + signed + .verify(&key.verifying_key()) + .expect("signature must verify against leader key"); + } +} diff --git a/crates/core/src/grove/state.rs b/crates/core/src/grove/state.rs new file mode 100644 index 00000000..25538199 --- /dev/null +++ b/crates/core/src/grove/state.rs @@ -0,0 +1,162 @@ +//! Per-node grove state. +//! +//! Built on top of [`crate::transport::TransportState`]. One [`GroveState`] +//! per node; the publish mutex serialises leader-side mutation, and the +//! cached signed payload provides the in-memory view of "current grove" +//! that handlers and the dial loop read. + +use std::sync::Arc; + +use parking_lot::{Mutex, RwLock}; + +use seedling_protocol::grove::SignedPayload; + +use crate::grove::db::{self, Membership}; +use crate::runtime::db::DbHandle; +use crate::transport::TransportState; +use crate::transport::auth::{TrustedKeys, new_trusted_keys}; + +pub struct GroveState { + pub transport: Arc, + pub db: DbHandle, + /// Grove trust set: SPKI fingerprints of the current grove's members. + /// Reconciled by the apply pipeline (lands in commit 5) on every + /// payload-applied event. Registered against the shared trust registry + /// in [`Self::register`]. + pub trust: TrustedKeys, + /// Single-writer lock for the leader publish pathway. Serialises + /// (load current → mutate → bump seq → sign → persist) so the + /// monotonic-seq invariant holds under concurrent operator-driven + /// mutations. + // g[impl versioning.seq] + pub publish_mutex: Mutex<()>, + /// In-memory cache of the latest applied signed payload, or `None` if + /// this node is not yet in any grove. Refreshed on construction and + /// on every payload-applied. + pub current: RwLock>, +} + +#[derive(Debug)] +pub enum LoadError { + Db(db::LoadError), +} + +impl std::fmt::Display for LoadError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Db(e) => write!(f, "{e}"), + } + } +} + +impl std::error::Error for LoadError {} + +impl From for LoadError { + fn from(e: db::LoadError) -> Self { + Self::Db(e) + } +} + +impl GroveState { + /// Construct a [`GroveState`] and populate the in-memory cache from the + /// DB. The grove trust set is also seeded from the latest signed + /// payload's membership list, so a daemon restart preserves grove + /// authorisation without waiting for a peer connection. + pub fn load(transport: Arc, db: DbHandle) -> Result, LoadError> { + let trust = new_trusted_keys(); + let membership: Option = db.call(db::load_membership)?; + + if let Some(m) = &membership { + let mut t = trust.write(); + for member in &m.current.payload.members { + t.insert(member.fp.clone()); + } + } + + Ok(Arc::new(Self { + transport, + db, + trust, + publish_mutex: Mutex::new(()), + current: RwLock::new(membership.map(|m| m.current)), + })) + } + + /// Whether this node currently belongs to a grove. + pub fn is_member(&self) -> bool { + self.current.read().is_some() + } +} + +#[cfg(test)] +mod tests { + use std::path::PathBuf; + + use ed25519_dalek::SigningKey; + use jiff::Timestamp; + use rand_core::OsRng; + use uuid::Uuid; + + use seedling_protocol::grove::{Member, Param, Payload, Role, SignedPayload}; + + use super::*; + + fn fresh_transport() -> Arc { + TransportState::new(PathBuf::from("/tmp/seedling-grove-test-key")) + } + + fn signed_payload_with_members(members: Vec<&str>) -> SignedPayload { + let key = SigningKey::generate(&mut OsRng); + Payload { + grove_id: Uuid::from_u128(1), + seq: 1, + created_at: Timestamp::from_second(1_700_000_000).unwrap(), + leader_fp: members.first().copied().unwrap_or("fp-leader").into(), + members: members + .into_iter() + .map(|fp| Member { + fp: fp.into(), + label: fp.into(), + }) + .collect(), + params: vec![Param { + name: "p".into(), + kind: "text".into(), + value: "v".into(), + }], + secrets: vec![], + } + .sign(&key) + .expect("sign") + } + + #[test] + fn load_returns_state_without_membership_on_fresh_db() { + let db = DbHandle::open_in_memory().expect("db"); + let state = GroveState::load(fresh_transport(), db).expect("load"); + assert!(!state.is_member()); + assert!(state.trust.read().is_empty()); + } + + // g[verify trust] + #[test] + fn load_seeds_trust_set_from_persisted_membership() { + let db = DbHandle::open_in_memory().expect("db"); + let signed = signed_payload_with_members(vec!["fp-a", "fp-b"]); + let m = Membership { + grove_id: signed.payload.grove_id, + role: Role::Leader, + leader_fingerprint: signed.payload.leader_fp.clone(), + joined_at: Timestamp::from_second(1_700_000_000).unwrap(), + current: signed, + }; + db.call(move |db| db::write_membership(db, &m).expect("write")); + + let state = GroveState::load(fresh_transport(), db).expect("load"); + assert!(state.is_member()); + let trusted = state.trust.read(); + assert!(trusted.contains("fp-a")); + assert!(trusted.contains("fp-b")); + assert_eq!(trusted.len(), 2); + } +} diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index cccc8c85..a9ef1d98 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -1,10 +1,12 @@ use rhai::{Engine, Scope}; pub mod defs; +pub mod grove; pub mod oi; pub mod runtime; pub mod sysconst; pub mod system; +pub mod transport; #[cfg(test)] mod tests; diff --git a/crates/core/src/oi.rs b/crates/core/src/oi.rs index 02468ab8..d1923654 100644 --- a/crates/core/src/oi.rs +++ b/crates/core/src/oi.rs @@ -6,4 +6,4 @@ pub mod server; pub mod shells; pub mod state; -pub use server::{DEFAULT_PORT, run}; +pub use server::register; diff --git a/crates/core/src/oi/auth.rs b/crates/core/src/oi/auth.rs index 986ada89..f220d34c 100644 --- a/crates/core/src/oi/auth.rs +++ b/crates/core/src/oi/auth.rs @@ -1,34 +1,11 @@ use std::{ - collections::HashSet, io, path::Path, - sync::Arc, time::{SystemTime, UNIX_EPOCH}, }; -use parking_lot::RwLock; -use rustls::{ - DigitallySignedStruct, DistinguishedName, SignatureScheme, - client::danger::HandshakeSignatureValid, - server::danger::{ClientCertVerified, ClientCertVerifier}, -}; -use rustls_pki_types::{CertificateDer, SubjectPublicKeyInfoDer, UnixTime}; -use subtle::ConstantTimeEq; - use crate::runtime::db::Db; - -use seedling_protocol::keys; - -// --------------------------------------------------------------------------- -// In-memory trusted key set -// --------------------------------------------------------------------------- - -/// Thread-safe in-memory set of trusted client SPKI fingerprints. -pub type TrustedKeys = Arc>>; - -pub fn new_trusted_keys() -> TrustedKeys { - Arc::new(RwLock::new(HashSet::new())) -} +use crate::transport::auth::TrustedKeys; // --------------------------------------------------------------------------- // DB helpers @@ -50,6 +27,7 @@ pub fn load_from_db(db: &Db, trusted: &TrustedKeys) -> rusqlite::Result<()> { /// Read `$data_dir/authorized_keys` and import any entries not already in /// the DB. Lines have the form `