machined: system-tier machine identity daemon
The machine seed moves out of the user session: souveraine-machined owns /var/lib/souveraine/seed-id as the souveraine system user and serves pubkey/sign over /run/souveraine/machined.sock (SO_PEERCRED logged, domain-separated signatures, group-gated socket). Seed is a precondition - provision via 'souveraine machine init --fresh|--migrate-from', the daemon never generates. reach/consult now resolves the machine key through the daemon with a loud legacy fallback. Hardened system unit + sysusers shipped in the Arch package.
This commit is contained in:
parent
04a8390e8d
commit
5039163a52
12 changed files with 796 additions and 5 deletions
1
Cargo.lock
generated
1
Cargo.lock
generated
|
|
@ -9094,6 +9094,7 @@ dependencies = [
|
|||
"image",
|
||||
"keyring",
|
||||
"keyring-core",
|
||||
"libc",
|
||||
"matrix-sdk",
|
||||
"notify",
|
||||
"num-bigint",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
5
packaging/arch/souveraine.sysusers
Normal file
5
packaging/arch/souveraine.sysusers
Normal file
|
|
@ -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 <user>
|
||||
u souveraine - "SouveraineOS system tier" /var/lib/souveraine -
|
||||
54
packaging/souveraine-machined.service
Normal file
54
packaging/souveraine-machined.service
Normal file
|
|
@ -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 <user>
|
||||
#
|
||||
# Provision the seed before first start:
|
||||
# sudo souveraine machine init --fresh
|
||||
# sudo souveraine machine init --migrate-from /home/<user>/.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
|
||||
69
src/bin/souveraine-machined.rs
Normal file
69
src/bin/souveraine-machined.rs
Normal file
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
133
src/machined/client.rs
Normal file
133
src/machined/client.rs
Normal file
|
|
@ -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<serde_json::Value> {
|
||||
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<serde_json::Value> {
|
||||
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<String> {
|
||||
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 <dir>` to carry an existing identity over)",
|
||||
legacy_dir.display()
|
||||
)
|
||||
})?;
|
||||
Ok((seed.public_key_hex(), MachineIdentitySource::LegacyUserSeed))
|
||||
}
|
||||
16
src/machined/mod.rs
Normal file
16
src/machined/mod.rs
Normal file
|
|
@ -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;
|
||||
103
src/machined/protocol.rs
Normal file
103
src/machined/protocol.rs
Normal file
|
|
@ -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<u8> {
|
||||
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"),
|
||||
}
|
||||
}
|
||||
}
|
||||
213
src/machined/server.rs
Normal file
213
src/machined/server.rs
Normal file
|
|
@ -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<PeerCred> {
|
||||
let mut cred = libc::ucred { pid: 0, uid: 0, gid: 0 };
|
||||
let mut len = std::mem::size_of::<libc::ucred>() 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<SeedId>) {
|
||||
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::<Request>(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()
|
||||
}
|
||||
179
src/main.rs
179
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<PathBuf>,
|
||||
},
|
||||
/// 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 <dir> \
|
||||
(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()
|
||||
|
|
|
|||
Loading…
Reference in a new issue