diff --git a/Cargo.lock b/Cargo.lock index 633c372..35865ad 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9094,6 +9094,7 @@ dependencies = [ "image", "keyring", "keyring-core", + "libc", "matrix-sdk", "notify", "num-bigint", diff --git a/Cargo.toml b/Cargo.toml index 48ec847..0fccb94 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -102,6 +102,8 @@ keyring-core = "1" ed25519-dalek = { version = "2", features = ["rand_core", "pem"] } sha2 = "0.10" rand = "0.8" +# SO_PEERCRED on the machined socket — the daemon logs who asked for what +libc = "0.2" # Cron expression parsing (schedule system) cron = "0.13" @@ -179,6 +181,12 @@ name = "souveraine-secrets" path = "src/bin/souveraine-secrets.rs" required-features = ["secrets"] +# System-tier machine identity daemon. No feature gate: zero new heavy deps, +# and the phone package should always ship it. +[[bin]] +name = "souveraine-machined" +path = "src/bin/souveraine-machined.rs" + [profile.release] opt-level = 3 lto = true diff --git a/packaging/arch/PKGBUILD b/packaging/arch/PKGBUILD index 2bdd69a..06dbe34 100644 --- a/packaging/arch/PKGBUILD +++ b/packaging/arch/PKGBUILD @@ -34,5 +34,15 @@ package() { # runs the pacman-owned binary sed 's|%h/.local/bin/souveraine|/usr/bin/souveraine|' packaging/souveraine.service \ | install -Dm644 /dev/stdin "$pkgdir/usr/lib/systemd/user/souveraine.service" + + # System tier: machine identity daemon + its service user. pacman runs + # systemd-sysusers on install, so the `souveraine` user exists before + # anyone runs `souveraine machine init`. + install -Dm755 target/release/souveraine-machined "$pkgdir/usr/bin/souveraine-machined" + install -Dm644 packaging/souveraine-machined.service \ + "$pkgdir/usr/lib/systemd/system/souveraine-machined.service" + install -Dm644 packaging/arch/souveraine.sysusers \ + "$pkgdir/usr/lib/sysusers.d/souveraine.conf" + install -Dm644 LICENSE "$pkgdir/usr/share/licenses/$pkgname/LICENSE" } diff --git a/packaging/arch/souveraine.sysusers b/packaging/arch/souveraine.sysusers new file mode 100644 index 0000000..8a7e582 --- /dev/null +++ b/packaging/arch/souveraine.sysusers @@ -0,0 +1,5 @@ +# The `souveraine` system user owns the machine's system tier: the machine +# seed (/var/lib/souveraine) and the machined socket (/run/souveraine). +# Session users join the group to talk to the daemon: +# usermod -aG souveraine +u souveraine - "SouveraineOS system tier" /var/lib/souveraine - diff --git a/packaging/souveraine-machined.service b/packaging/souveraine-machined.service new file mode 100644 index 0000000..f2a4df8 --- /dev/null +++ b/packaging/souveraine-machined.service @@ -0,0 +1,54 @@ +# Souveraine machine identity — SYSTEM unit (contrast souveraine.service, +# which is a user unit). Runs from boot, before any human authenticates; +# owns the machine seed and serves signatures over a Unix socket. +# +# Access control is the socket: /run/souveraine (0750 souveraine:souveraine) +# + machined.sock (0660). Add session users to the `souveraine` group: +# usermod -aG souveraine +# +# Provision the seed before first start: +# sudo souveraine machine init --fresh +# sudo souveraine machine init --migrate-from /home//.souveraine/seed-id +[Unit] +Description=Souveraine machine identity (system tier) + +[Service] +User=souveraine +Group=souveraine +ExecStart=/usr/bin/souveraine-machined +Restart=on-failure +RestartSec=5 + +# /var/lib/souveraine — the seed. /run/souveraine — the socket. +StateDirectory=souveraine +StateDirectoryMode=0700 +RuntimeDirectory=souveraine +RuntimeDirectoryMode=0750 +UMask=0007 + +# Network-less signer, RedFlag-executor posture: nothing to reach, nothing +# reachable. AF_UNIX only, no privileges, no writable system. +NoNewPrivileges=yes +CapabilityBoundingSet= +RestrictAddressFamilies=AF_UNIX +IPAddressDeny=any +ProtectSystem=strict +ProtectHome=yes +PrivateTmp=yes +PrivateDevices=yes +ProtectKernelTunables=yes +ProtectKernelModules=yes +ProtectControlGroups=yes +ProtectClock=yes +ProtectHostname=yes +LockPersonality=yes +MemoryDenyWriteExecute=yes +RestrictRealtime=yes +RestrictNamespaces=yes +RestrictSUIDSGID=yes +SystemCallArchitectures=native +SystemCallFilter=@system-service +SystemCallErrorNumber=EPERM + +[Install] +WantedBy=multi-user.target diff --git a/src/bin/souveraine-machined.rs b/src/bin/souveraine-machined.rs new file mode 100644 index 0000000..183185e --- /dev/null +++ b/src/bin/souveraine-machined.rs @@ -0,0 +1,69 @@ +//! souveraine-machined — system-tier machine identity daemon. +//! +//! Owns the machine's Ed25519 seed at `/var/lib/souveraine/seed-id` and +//! serves pubkey/sign requests over `/run/souveraine/machined.sock`. The +//! private key never crosses the socket; user-tier processes (agents, the +//! federation bridge, the shell) get signatures, not key material. +//! +//! The seed is a precondition, never something this daemon creates. If no +//! seed exists, startup fails loudly — identity creation is a deliberate, +//! guarded action that lives in `souveraine machine init`, not something a +//! background service does on your behalf. Same doctrine as +//! `souveraine-secrets`; see `SeedId::load` in `core/identity/seed.rs`. + +#[path = "../core/identity/seed.rs"] +mod identity; +#[path = "../machined/protocol.rs"] +mod protocol; +#[path = "../machined/server.rs"] +mod server; + +use std::path::PathBuf; + +fn main() -> anyhow::Result<()> { + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::try_from_default_env() + .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")), + ) + .init(); + + let mut seed_dir = PathBuf::from(protocol::DEFAULT_SEED_DIR); + let mut socket = PathBuf::from(protocol::DEFAULT_SOCKET_PATH); + + let mut args = std::env::args().skip(1); + while let Some(arg) = args.next() { + match arg.as_str() { + "--seed-dir" => { + seed_dir = PathBuf::from(args.next().ok_or_else(|| { + anyhow::anyhow!("--seed-dir requires a path") + })?); + } + "--socket" => { + socket = PathBuf::from(args.next().ok_or_else(|| { + anyhow::anyhow!("--socket requires a path") + })?); + } + "--help" | "-h" => { + println!( + "souveraine-machined [--seed-dir {}] [--socket {}]", + protocol::DEFAULT_SEED_DIR, + protocol::DEFAULT_SOCKET_PATH + ); + return Ok(()); + } + other => anyhow::bail!("unknown argument: {other}"), + } + } + + let seed = identity::SeedId::load(&seed_dir).map_err(|e| { + anyhow::anyhow!( + "{e:#}\n\nsouveraine-machined refuses to start without a machine identity.\n\ + Provision one: sudo souveraine machine init --fresh\n\ + Or carry an existing identity over: sudo souveraine machine init \ + --migrate-from ~/.souveraine/seed-id" + ) + })?; + + server::run(seed, &socket) +} diff --git a/src/core/tools/agent.rs b/src/core/tools/agent.rs index 6a312fd..31819f2 100644 --- a/src/core/tools/agent.rs +++ b/src/core/tools/agent.rs @@ -133,11 +133,13 @@ fn dispatch_summon( })?; // The *machine* seed addresses this box — it is the reply route, and the - // bridge signs the transport envelope with it. - let machine_seed_id = SeedId::load_or_generate(&SeedId::default_dir(&base)) - .map(|s| s.public_key_hex()) + // bridge signs the transport envelope with it. System tier (machined) + // answers first; the legacy user-tier seed is a loud transitional + // fallback that never generates. + let machine_seed_id = crate::machined::client::machine_pubkey_with_fallback(&base) + .map(|(pk, _source)| pk) .map_err(|e| ToolError::invalid_input(&format!( - "I couldn't load my machine seed identity: {e}" + "I couldn't establish my machine identity: {e:#}" )))?; // The *agent* seed proves who is reaching. It lives beside the memfs diff --git a/src/machined/client.rs b/src/machined/client.rs new file mode 100644 index 0000000..654f269 --- /dev/null +++ b/src/machined/client.rs @@ -0,0 +1,133 @@ +//! Client for souveraine-machined — how user-tier processes reach the +//! system-tier machine identity. +//! +//! Synchronous by design: one tiny local round-trip, callable from both sync +//! tool code and async server code without ceremony. +//! +//! The legacy fallback exists because deployed machines still carry their +//! machine seed at `~/.souveraine/seed-id` from before the system tier +//! existed. It is transitional, loud, and strict — it will `load` an +//! existing legacy seed but never generate one. New identity comes only +//! from `souveraine machine init`. + +use std::io::{BufRead, BufReader, Write}; +use std::os::unix::net::UnixStream; +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use anyhow::{Context, Result}; +use tracing::warn; + +use super::protocol::{Request, DEFAULT_SOCKET_PATH}; +use crate::core::identity::SeedId; + +/// Where the machine seed identity came from — callers that report or audit +/// should say which tier answered. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MachineIdentitySource { + /// The system-tier daemon answered on its socket. + Machined, + /// Transitional: read from the legacy user-tier seed directory. + LegacyUserSeed, +} + +/// Test hook only — points the client at a scratch socket. Not a deployment +/// knob; production callers always use the default path. +pub fn socket_path() -> PathBuf { + std::env::var_os("SOUVERAINE_MACHINED_SOCKET") + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from(DEFAULT_SOCKET_PATH)) +} + +fn request(req: &Request) -> Result { + let path = socket_path(); + let stream = UnixStream::connect(&path) + .with_context(|| format!("connecting to souveraine-machined at {}", path.display()))?; + stream.set_read_timeout(Some(Duration::from_secs(5)))?; + stream.set_write_timeout(Some(Duration::from_secs(5)))?; + + let mut writer = stream.try_clone()?; + writer.write_all(serde_json::to_string(req)?.as_bytes())?; + writer.write_all(b"\n")?; + writer.flush()?; + + let mut line = String::new(); + BufReader::new(stream) + .read_line(&mut line) + .context("reading souveraine-machined response")?; + let value: serde_json::Value = + serde_json::from_str(line.trim()).context("parsing souveraine-machined response")?; + + if value.get("ok").and_then(|v| v.as_bool()) == Some(true) { + Ok(value) + } else { + let reason = value + .get("reason") + .and_then(|v| v.as_str()) + .unwrap_or("unspecified refusal"); + anyhow::bail!("souveraine-machined refused: {reason}") + } +} + +/// Daemon status as reported by the daemon itself. +pub fn status() -> Result { + request(&Request::Status) +} + +/// Machine public key + glyph from the daemon. +pub fn pubkey() -> Result<(String, String)> { + let value = request(&Request::Pubkey)?; + let pk = value + .get("public_key") + .and_then(|v| v.as_str()) + .context("response missing public_key")? + .to_string(); + let glyph = value + .get("glyph") + .and_then(|v| v.as_str()) + .unwrap_or_default() + .to_string(); + Ok((pk, glyph)) +} + +/// Domain-separated signature over `payload`. Returns the signature hex; +/// the signed bytes are `protocol::signing_bytes(domain, payload)`. +#[allow(dead_code)] // phase 2: federation envelope signing routes through machined +pub fn sign(domain: &str, payload: &[u8]) -> Result { + let value = request(&Request::Sign { + domain: domain.to_string(), + payload_hex: hex::encode(payload), + })?; + value + .get("signature") + .and_then(|v| v.as_str()) + .map(str::to_string) + .context("response missing signature") +} + +/// Resolve this box's machine public key: system tier first, legacy user +/// seed as a loud transitional fallback. Never generates — a machine with +/// no identity is an error with provisioning instructions, not a fresh key. +pub fn machine_pubkey_with_fallback(base: &Path) -> Result<(String, MachineIdentitySource)> { + match pubkey() { + Ok((pk, _glyph)) => return Ok((pk, MachineIdentitySource::Machined)), + Err(e) => { + warn!( + "souveraine-machined unavailable ({e:#}); falling back to legacy \ + user-tier machine seed — provision the system tier with \ + `sudo souveraine machine init`" + ); + } + } + + let legacy_dir = SeedId::default_dir(base); + let seed = SeedId::load(&legacy_dir).with_context(|| { + format!( + "no machine identity: souveraine-machined is not running and no legacy \ + seed exists at {} — provision one with `sudo souveraine machine init --fresh` \ + (or `--migrate-from ` to carry an existing identity over)", + legacy_dir.display() + ) + })?; + Ok((seed.public_key_hex(), MachineIdentitySource::LegacyUserSeed)) +} diff --git a/src/machined/mod.rs b/src/machined/mod.rs new file mode 100644 index 0000000..956b485 --- /dev/null +++ b/src/machined/mod.rs @@ -0,0 +1,16 @@ +//! souveraine-machined — the system tier's machine identity. +//! +//! SouveraineOS splits identity into two tiers. The **machine** (seed at +//! `/var/lib/souveraine/seed-id`, owned by the `souveraine` system user, +//! served by the `souveraine-machined` daemon) exists from boot, before any +//! human authenticates — it is what enrolls with central authority, signs +//! federation transport, and anchors node commissions. **Agents** (per-agent +//! seeds beside their memfs) belong to the user tier and exist only inside +//! an authenticated session. +//! +//! This module carries the wire protocol and the user-tier client. The serve +//! loop (`server.rs`) is compiled only into the `souveraine-machined` bin, +//! the same `#[path]` pattern as `souveraine-secrets`. + +pub mod client; +pub mod protocol; diff --git a/src/machined/protocol.rs b/src/machined/protocol.rs new file mode 100644 index 0000000..99c0cbc --- /dev/null +++ b/src/machined/protocol.rs @@ -0,0 +1,103 @@ +//! Wire protocol for souveraine-machined — the system-tier machine identity +//! daemon. +//! +//! One JSON object per line over a Unix socket. Responses follow the guarded +//! `ok`/`reason` pattern used by the session IPC surface: refusals always +//! carry a reason, never a silent failure. +//! +//! Signatures are domain-separated: the daemon never signs caller-supplied +//! bytes raw. A machined signature can therefore never be confused with (or +//! replayed as) a federation envelope, a node commission, or any other +//! payload signed by the same key outside this protocol. + +// Shared with the souveraine-machined bin via #[path]; the main crate uses +// only a subset of these items. +#![allow(dead_code)] + +use serde::{Deserialize, Serialize}; + +/// Where the daemon listens. `RuntimeDirectory=souveraine` in the unit owns +/// the parent; the socket itself is group-rw so members of the `souveraine` +/// group may connect. +pub const DEFAULT_SOCKET_PATH: &str = "/run/souveraine/machined.sock"; + +/// Where the machine seed lives on the system tier. Root-of-trust for this +/// box; provisioned deliberately via `souveraine machine init`, never by the +/// daemon itself. +pub const DEFAULT_SEED_DIR: &str = "/var/lib/souveraine/seed-id"; + +/// Upper bound on one request line. Anything longer is refused, not read. +pub const MAX_REQUEST_BYTES: u64 = 64 * 1024; + +/// Version tag baked into every signature's domain separation. +pub const SIGNING_CONTEXT: &str = "souveraine-machined:v1"; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "op", rename_all = "snake_case")] +pub enum Request { + /// Daemon liveness + identity summary. + Status, + /// Public key + glyph only. + Pubkey, + /// Sign `payload_hex` under `domain`. The signed bytes are + /// `{SIGNING_CONTEXT}:{domain}:{payload}` — see [`signing_bytes`]. + Sign { domain: String, payload_hex: String }, +} + +/// The exact bytes a machined signature covers. +pub fn signing_bytes(domain: &str, payload: &[u8]) -> Vec { + let mut bytes = Vec::with_capacity(SIGNING_CONTEXT.len() + domain.len() + payload.len() + 2); + bytes.extend_from_slice(SIGNING_CONTEXT.as_bytes()); + bytes.push(b':'); + bytes.extend_from_slice(domain.as_bytes()); + bytes.push(b':'); + bytes.extend_from_slice(payload); + bytes +} + +/// Domains are short ASCII labels. Excluding `:` keeps [`signing_bytes`] +/// framing unambiguous. +pub fn valid_domain(domain: &str) -> bool { + !domain.is_empty() + && domain.len() <= 64 + && domain + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_' || b == b'.') +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn signing_bytes_are_framed() { + let bytes = signing_bytes("federation-envelope", b"payload"); + assert_eq!( + bytes, + b"souveraine-machined:v1:federation-envelope:payload".to_vec() + ); + } + + #[test] + fn domain_charset_is_enforced() { + assert!(valid_domain("federation-envelope")); + assert!(valid_domain("node.commission_v1")); + assert!(!valid_domain("")); + assert!(!valid_domain("has:colon")); + assert!(!valid_domain("has space")); + assert!(!valid_domain(&"x".repeat(65))); + } + + #[test] + fn request_round_trips() { + let req: Request = + serde_json::from_str(r#"{"op":"sign","domain":"d","payload_hex":"00ff"}"#).unwrap(); + match req { + Request::Sign { domain, payload_hex } => { + assert_eq!(domain, "d"); + assert_eq!(payload_hex, "00ff"); + } + _ => panic!("wrong variant"), + } + } +} diff --git a/src/machined/server.rs b/src/machined/server.rs new file mode 100644 index 0000000..ae4c32b --- /dev/null +++ b/src/machined/server.rs @@ -0,0 +1,213 @@ +//! Serve loop for souveraine-machined. +//! +//! Deliberately synchronous, std-only: one small thread per connection, a +//! few requests per client, everything auditable in one sitting. The daemon +//! is the only process that reads the machine private key; callers get +//! signatures and the public key over the socket, never the key itself. +//! +//! Compiled into the `souveraine-machined` bin via `#[path]` includes, the +//! same pattern as `souveraine-secrets` — `crate::identity` and +//! `crate::protocol` below resolve to the bin's module tree. + +use std::io::{BufRead, BufReader, Read, Write}; +use std::os::fd::AsRawFd; +use std::os::unix::fs::PermissionsExt; +use std::os::unix::net::{UnixListener, UnixStream}; +use std::path::Path; +use std::sync::Arc; + +use anyhow::{Context, Result}; +use tracing::{info, warn}; + +use crate::identity::SeedId; +use crate::protocol::{self, Request, MAX_REQUEST_BYTES}; + +/// SO_PEERCRED identity of the connecting process. Logged on every request +/// so the audit trail names the caller, not just the call. +#[derive(Debug, Clone, Copy)] +struct PeerCred { + pid: i32, + uid: u32, + gid: u32, +} + +fn peer_cred(stream: &UnixStream) -> Option { + let mut cred = libc::ucred { pid: 0, uid: 0, gid: 0 }; + let mut len = std::mem::size_of::() as libc::socklen_t; + // SAFETY: SO_PEERCRED fills a ucred struct of the size we pass; the fd + // is live for the duration of the call because we hold &UnixStream. + let rc = unsafe { + libc::getsockopt( + stream.as_raw_fd(), + libc::SOL_SOCKET, + libc::SO_PEERCRED, + &mut cred as *mut libc::ucred as *mut libc::c_void, + &mut len, + ) + }; + if rc == 0 { + Some(PeerCred { pid: cred.pid, uid: cred.uid, gid: cred.gid }) + } else { + None + } +} + +pub fn run(seed: SeedId, socket_path: &Path) -> Result<()> { + // A live daemon answers on the socket; a stale file from a crash does + // not. Refuse to double-bind, clean up only what is genuinely dead. + if socket_path.exists() { + if UnixStream::connect(socket_path).is_ok() { + anyhow::bail!( + "another souveraine-machined is already serving {}", + socket_path.display() + ); + } + warn!("removing stale socket at {}", socket_path.display()); + std::fs::remove_file(socket_path) + .with_context(|| format!("removing stale socket {}", socket_path.display()))?; + } + + let listener = UnixListener::bind(socket_path) + .with_context(|| format!("binding {}", socket_path.display()))?; + // Group-rw: the RuntimeDirectory's 0750 + this 0660 make membership in + // the `souveraine` group the access control. No auth theater on top — + // the socket permission IS the policy, and every request is logged. + std::fs::set_permissions(socket_path, std::fs::Permissions::from_mode(0o660)) + .with_context(|| format!("setting socket permissions on {}", socket_path.display()))?; + + info!( + "machine identity {} ({}) serving on {}", + seed.glyph(), + seed.public_key_hex(), + socket_path.display() + ); + + // Signing is &self; the key never needs to be copied, only shared. + let seed = Arc::new(seed); + for stream in listener.incoming() { + let stream = match stream { + Ok(s) => s, + Err(e) => { + warn!("accept failed: {e}"); + continue; + } + }; + let seed = Arc::clone(&seed); + std::thread::spawn(move || handle_connection(stream, seed)); + } + Ok(()) +} + +fn handle_connection(stream: UnixStream, seed: Arc) { + let cred = peer_cred(&stream); + let (uid, gid, pid) = match cred { + Some(c) => (c.uid, c.gid, c.pid), + None => { + warn!("connection without readable peer credentials — refusing"); + return; + } + }; + + let _ = stream.set_read_timeout(Some(std::time::Duration::from_secs(30))); + let _ = stream.set_write_timeout(Some(std::time::Duration::from_secs(10))); + + let mut writer = match stream.try_clone() { + Ok(w) => w, + Err(e) => { + warn!("could not clone stream for uid={uid} pid={pid}: {e}"); + return; + } + }; + let mut reader = BufReader::new(stream); + + loop { + let mut line = String::new(); + // take() caps a single request; a client shoveling an oversized line + // gets a refusal and the connection dropped, not an OOM. + match (&mut reader).take(MAX_REQUEST_BYTES).read_line(&mut line) { + Ok(0) => return, // clean EOF + Ok(_) => {} + Err(e) => { + warn!("read error from uid={uid} pid={pid}: {e}"); + return; + } + } + if line.len() as u64 >= MAX_REQUEST_BYTES { + let _ = respond(&mut writer, refusal("request exceeds size limit")); + warn!("oversized request from uid={uid} pid={pid} — connection dropped"); + return; + } + let line = line.trim(); + if line.is_empty() { + continue; + } + + let response = match serde_json::from_str::(line) { + Ok(req) => handle_request(&seed, req, uid, gid, pid), + Err(e) => { + warn!("malformed request from uid={uid} pid={pid}: {e}"); + refusal(&format!("malformed request: {e}")) + } + }; + if respond(&mut writer, response).is_err() { + return; + } + } +} + +fn handle_request( + seed: &SeedId, + req: Request, + uid: u32, + gid: u32, + pid: i32, +) -> serde_json::Value { + match req { + Request::Status => serde_json::json!({ + "ok": true, + "public_key": seed.public_key_hex(), + "glyph": seed.glyph(), + "signing_context": protocol::SIGNING_CONTEXT, + }), + Request::Pubkey => serde_json::json!({ + "ok": true, + "public_key": seed.public_key_hex(), + "glyph": seed.glyph(), + }), + Request::Sign { domain, payload_hex } => { + if !protocol::valid_domain(&domain) { + warn!("sign refused for uid={uid} pid={pid}: invalid domain {domain:?}"); + return refusal("invalid domain: ascii alphanumeric/-/_/., max 64 chars"); + } + let payload = match hex::decode(&payload_hex) { + Ok(p) => p, + Err(e) => { + warn!("sign refused for uid={uid} pid={pid} domain={domain}: bad payload hex: {e}"); + return refusal("payload_hex is not valid hex"); + } + }; + let bytes = protocol::signing_bytes(&domain, &payload); + let signature = hex::encode(seed.sign(&bytes).to_bytes()); + info!( + "signed domain={domain} payload_len={} for uid={uid} gid={gid} pid={pid}", + payload.len() + ); + serde_json::json!({ + "ok": true, + "signature": signature, + "public_key": seed.public_key_hex(), + "domain": domain, + }) + } + } +} + +fn refusal(reason: &str) -> serde_json::Value { + serde_json::json!({ "ok": false, "reason": reason }) +} + +fn respond(writer: &mut UnixStream, value: serde_json::Value) -> std::io::Result<()> { + writer.write_all(value.to_string().as_bytes())?; + writer.write_all(b"\n")?; + writer.flush() +} diff --git a/src/main.rs b/src/main.rs index 97fc4d9..02fd9ec 100644 --- a/src/main.rs +++ b/src/main.rs @@ -10,6 +10,7 @@ mod cli; mod core; mod harness; mod interface; +mod machined; mod server; mod ui; @@ -224,6 +225,16 @@ and — with [federation].auto_wake — spawns the full server to answer them.") host: bool, }, + /// System-tier machine identity (souveraine-machined) + #[command(long_about = "Provision and inspect the machine identity served by \ + souveraine-machined. The machine seed lives at /var/lib/souveraine/seed-id, \ + owned by the `souveraine` system user — it exists from boot, before any \ + human authenticates, and is distinct from per-agent seeds.")] + Machine { + #[command(subcommand)] + action: MachineAction, + }, + /// Show known federated peers #[command(long_about = "Show all peers tracked by the device registry, from federation announcements.")] Peers { @@ -299,6 +310,22 @@ enum IdentityAction { }, } +#[derive(Subcommand)] +enum MachineAction { + /// Provision the machine seed (run with sudo; requires --fresh or --migrate-from) + Init { + /// Generate a brand-new machine identity + #[arg(long)] + fresh: bool, + /// Copy an existing seed directory (e.g. ~/.souveraine/seed-id) so + /// federation trust in this machine survives the tier move + #[arg(long, value_name = "DIR", conflicts_with = "fresh")] + migrate_from: Option, + }, + /// Show daemon + seed state + Status, +} + #[derive(Subcommand)] enum EventsAction { /// Show recent events from the firehose @@ -382,6 +409,11 @@ async fn main() -> anyhow::Result<()> { return run_events(action, cli.json).await; } + // Handle machine early — system-tier identity, no backend needed + if let Some(Commands::Machine { action }) = &cli.command { + return run_machine(action, cli.json); + } + // Handle peers early — reads known_peers.json, no backend needed if let Some(Commands::Peers { json }) = &cli.command { return run_peers(*json).await; @@ -403,7 +435,7 @@ async fn main() -> anyhow::Result<()> { Commands::Reflect { conversation } => { run_reflect(config, cli.agent.clone(), conversation.clone(), cli.json).await? } - Commands::Init | Commands::Completions { .. } | Commands::Auth { .. } | Commands::Schedule { .. } | Commands::Identity { .. } | Commands::Events { .. } | Commands::Peers { .. } => unreachable!(), + Commands::Init | Commands::Completions { .. } | Commands::Auth { .. } | Commands::Schedule { .. } | Commands::Identity { .. } | Commands::Machine { .. } | Commands::Events { .. } | Commands::Peers { .. } => unreachable!(), } Ok(()) @@ -665,6 +697,151 @@ async fn run_identity( Ok(()) } +fn run_machine(action: &MachineAction, json: bool) -> anyhow::Result<()> { + use crate::core::identity::SeedId; + use crate::machined::protocol::DEFAULT_SEED_DIR; + + let seed_dir = PathBuf::from(DEFAULT_SEED_DIR); + + match action { + MachineAction::Init { fresh, migrate_from } => { + if seed_dir.join("private.key").exists() { + anyhow::bail!( + "A machine identity already exists at {}. Refusing to overwrite — \ + replacing it breaks federation trust and node commissions. \ + Remove it manually first if you truly mean to.", + seed_dir.display() + ); + } + + // Identity creation is deliberate: the operator states whether + // this machine is new or carries an existing identity forward. + let seed = match (fresh, migrate_from) { + (true, None) => { + std::fs::create_dir_all(&seed_dir)?; + SeedId::load_or_generate(&seed_dir)? + } + (false, Some(source)) => { + let source_key = source.join("private.key"); + if !source_key.exists() { + anyhow::bail!( + "no seed at {} — nothing to migrate", + source_key.display() + ); + } + std::fs::create_dir_all(&seed_dir)?; + std::fs::copy(&source_key, seed_dir.join("private.key"))?; + let source_pub = source.join("public.key"); + if source_pub.exists() { + std::fs::copy(&source_pub, seed_dir.join("public.key"))?; + } + // load (not load_or_generate): a botched copy must fail + // loudly here, not silently mint a different identity. + SeedId::load(&seed_dir)? + } + _ => anyhow::bail!( + "state your intent: --fresh (new identity) or --migrate-from \ + (carry an existing one over)" + ), + }; + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions( + seed_dir.join("private.key"), + std::fs::Permissions::from_mode(0o600), + )?; + std::fs::set_permissions(&seed_dir, std::fs::Permissions::from_mode(0o700))?; + } + + // The daemon runs as the `souveraine` system user; hand the seed + // over if we can (init is normally run via sudo). A missing user + // means the sysusers entry isn't installed yet — say so, don't + // half-succeed silently. + let chown = std::process::Command::new("chown") + .arg("-R") + .arg("souveraine:souveraine") + .arg(&seed_dir) + .status(); + let owned = matches!(chown, Ok(s) if s.success()); + if !owned { + eprintln!( + "WARNING: could not chown {} to souveraine:souveraine — is the \ + `souveraine` system user installed (sysusers.d) and are you root? \ + souveraine-machined will refuse to read the seed until ownership \ + is fixed.", + seed_dir.display() + ); + } + + if json { + println!("{}", serde_json::json!({ + "public_key": seed.public_key_hex(), + "glyph": seed.glyph(), + "seed_dir": seed_dir.display().to_string(), + "owned_by_service_user": owned, + })); + } else { + println!("Machine identity provisioned."); + println!(" Glyph: {}", seed.glyph()); + println!(" Public key: {}", seed.public_key_hex()); + println!(" Location: {}", seed_dir.display()); + println!("Next: systemctl enable --now souveraine-machined"); + } + } + MachineAction::Status => { + match crate::machined::client::status() { + Ok(state) => { + if json { + println!("{}", state); + } else { + println!("souveraine-machined: running"); + if let Some(glyph) = state.get("glyph").and_then(|v| v.as_str()) { + println!(" Glyph: {glyph}"); + } + if let Some(pk) = state.get("public_key").and_then(|v| v.as_str()) { + println!(" Public key: {pk}"); + } + } + } + Err(e) => { + // Daemon down. Report what can be seen from here: a + // permission error on the key is the healthy shape (the + // seed exists and only the service user reads it). + let key = seed_dir.join("private.key"); + let seed_state = match std::fs::metadata(&key) { + Ok(_) => "present", + Err(err) if err.kind() == std::io::ErrorKind::PermissionDenied => { + "present (unreadable from this user — expected)" + } + Err(_) => "absent", + }; + if json { + println!("{}", serde_json::json!({ + "ok": false, + "daemon": "unreachable", + "reason": format!("{e:#}"), + "seed": seed_state, + "seed_dir": seed_dir.display().to_string(), + })); + } else { + println!("souveraine-machined: not reachable ({e:#})"); + println!(" Seed at {}: {seed_state}", seed_dir.display()); + if seed_state == "absent" { + println!(" Provision: sudo souveraine machine init --fresh"); + println!(" or: sudo souveraine machine init --migrate-from ~/.souveraine/seed-id"); + } else { + println!(" Start: systemctl enable --now souveraine-machined"); + } + } + } + } + } + } + Ok(()) +} + async fn run_peers(json: bool) -> anyhow::Result<()> { let base = dirs::home_dir() .unwrap_or_default()