diff --git a/cubecrypt/src/auth.rs b/cubecrypt/src/auth.rs new file mode 100644 index 0000000..f6f2bf5 --- /dev/null +++ b/cubecrypt/src/auth.rs @@ -0,0 +1,194 @@ +//! Challenge-response authentication for the daemon HELLO frame (plan R4). +//! +//! The daemon's `HELLO` identity is, by itself, self-asserted — any client can +//! claim to be `alice` on `tenant-x`. On a single-owner box behind a private +//! LAN that is acceptable, but the moment the daemon is reachable by anything +//! other than a trusted local process, an impersonator can forge an identity. +//! +//! R4 closes that with a pre-shared-key (PSK) mutual-auth handshake: +//! +//! 1. On connect, if the server has a PSK configured (`--auth-key`), it sends +//! `CHALLENGE ` (32 random bytes). +//! 2. The client proves possession of the PSK by replying +//! `HELLO [] ` where +//! `hexsig = HMAC-SHA256(psk, nonce || "|" || tenant || "|" || +//! owner_local || "|" || owner_remote)`. +//! 3. The server recomputes the HMAC and rejects the connection on mismatch. +//! +//! The nonce makes each signature single-use, so a sniffed handshake cannot be +//! replayed. The HMAC key is the PSK (never sent over the wire). We implement +//! HMAC-SHA256 with the `sha2` crate already in this crate's dependency tree — +//! no new dependency, and we never roll a cipher. +//! +//! Without a configured PSK the server skips the challenge entirely and +//! behaves exactly as before (legacy `cubec`/`stress.sh` keep working). + +use sha2::{Digest, Sha256}; + +/// Block size of SHA-256, used by the HMAC inner/outer padding construction. +const SHA256_BLOCK: usize = 64; + +/// HMAC-SHA256 over `msg` with key `key`. Implemented directly from RFC 2104 +/// using two SHA-256 passes — no external `hmac` crate needed. +pub fn hmac_sha256(key: &[u8], msg: &[u8]) -> [u8; 32] { + // Keys longer than the block are hashed first (RFC 2104 step 1). + let mut k = [0u8; SHA256_BLOCK]; + if key.len() > SHA256_BLOCK { + let h = Sha256::digest(key); + k[..32].copy_from_slice(&h); + } else { + k[..key.len()].copy_from_slice(key); + } + + let ipad: [u8; SHA256_BLOCK] = core::array::from_fn(|i| k[i] ^ 0x36); + let opad: [u8; SHA256_BLOCK] = core::array::from_fn(|i| k[i] ^ 0x5c); + + let mut inner = Sha256::new(); + inner.update(ipad); + inner.update(msg); + let inner_h = inner.finalize(); + + let mut outer = Sha256::new(); + outer.update(opad); + outer.update(inner_h); + let outer_h = outer.finalize(); + + let mut out = [0u8; 32]; + out.copy_from_slice(&outer_h); + out +} + +/// The exact message string signed for a HELLO frame, given the server's nonce +/// and the asserted identity fields. `owner_remote` is `""` when absent so the +/// canonical string is unambiguous (no optional-trailing-separator ambiguity). +fn hello_msg(nonce_hex: &str, tenant: &str, owner_local: &str, owner_remote: &str) -> String { + format!("{}|{}|{}|{}", nonce_hex, tenant, owner_local, owner_remote) +} + +/// Produce the hex signature a client should send after receiving a challenge. +pub fn sign_hello( + psk: &[u8], + nonce_hex: &str, + tenant: &str, + owner_local: &str, + owner_remote: Option<&str>, +) -> String { + let remote = owner_remote.unwrap_or(""); + let msg = hello_msg(nonce_hex, tenant, owner_local, remote); + let sig = hmac_sha256(psk, msg.as_bytes()); + hex_encode(&sig) +} + +/// Verify a client-sent signature against the expected PSK + nonce + identity. +/// Constant-ish compare (not a timing-safe compare — the threat here is +/// impersonation, not a remote timing oracle over a private LAN; for a +/// stronger guarantee swap in a `subtle::ConstantTimeEq`). Returns true on +/// match. +pub fn verify_hello( + psk: &[u8], + nonce_hex: &str, + tenant: &str, + owner_local: &str, + owner_remote: Option<&str>, + sig_hex: &str, +) -> bool { + let expected = sign_hello(psk, nonce_hex, tenant, owner_local, owner_remote); + expected.eq_ignore_ascii_case(sig_hex) +} + +/// Lowercase hex encode (no allocation beyond the result). +pub fn hex_encode(bytes: &[u8]) -> String { + const HEX: &[u8; 16] = b"0123456789abcdef"; + let mut s = String::with_capacity(bytes.len() * 2); + for &b in bytes { + s.push(HEX[(b >> 4) as usize] as char); + s.push(HEX[(b & 0x0f) as usize] as char); + } + s +} + +/// Generate a random nonce as a hex string, reading `n` bytes from the OS +/// CSPRNG (`/dev/urandom` on Linux/Unix). Dependency-free; used by the daemon +/// to mint per-connection `CHALLENGE` nonces for the HELLO handshake. Returns +/// an error only if the entropy source cannot be opened/read. +pub fn random_nonce_hex(n: usize) -> std::io::Result { + use std::io::Read; + let mut buf = vec![0u8; n]; + let mut f = std::fs::File::open("/dev/urandom")?; + f.read_exact(&mut buf)?; + Ok(hex_encode(&buf)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn hmac_sha256_known_vector() { + // RFC 4231 Test Case 1: key="Jefe", data="what do ya want for nothing?". + let key = b"Jefe"; + let data = b"what do ya want for nothing?"; + let out = hmac_sha256(key, data); + // Expected (RFC 4231 Test Case 1, HMAC-SHA256): + // 5bdcc146bf60754e6a042426089575c75a003f089d2739839dec58b964ec3843 + let want = "5bdcc146bf60754e6a042426089575c75a003f089d2739839dec58b964ec3843"; + assert_eq!(hex_encode(&out), want, "HMAC-SHA256 RFC4231 TC1"); + } + + #[test] + fn hmac_sha256_long_key_is_hashed_first() { + // Key longer than the 64-byte block must be pre-hashed (RFC 2104). + let key = vec![0xaa; 80]; + let data = b"test message"; + let out = hmac_sha256(&key, data); + // Same input via a 32-byte pre-hash path should be stable. + let key32 = Sha256::digest(&key); + let out2 = hmac_sha256(&key32, data); + assert_eq!(out, out2, "long key pre-hash path matches"); + } + + #[test] + fn hello_sign_verify_roundtrips() { + let psk = b"shared-secret-for-lan-daemon"; + let nonce = "deadbeefcafe1234"; + let sig = sign_hello(psk, nonce, "agent-a", "alice", None); + assert!(verify_hello(psk, nonce, "agent-a", "alice", None, &sig)); + // Wrong owner must fail. + assert!(!verify_hello(psk, nonce, "agent-a", "mallory", None, &sig)); + // Wrong nonce must fail. + assert!(!verify_hello( + psk, + "differentnonce", + "agent-a", + "alice", + None, + &sig + )); + // With remote present. + let sig_r = sign_hello(psk, nonce, "agent-a", "alice", Some("remote-x")); + assert!(verify_hello( + psk, + nonce, + "agent-a", + "alice", + Some("remote-x"), + &sig_r + )); + assert!(!verify_hello( + psk, + nonce, + "agent-a", + "alice", + Some("other-remote"), + &sig_r + )); + } + + #[test] + fn signature_is_nonce_dependent() { + let psk = b"k"; + let a = sign_hello(psk, "nonce1", "t", "o", None); + let b = sign_hello(psk, "nonce2", "t", "o", None); + assert_ne!(a, b, "different nonces must yield different signatures"); + } +} diff --git a/cubecrypt/src/lib.rs b/cubecrypt/src/lib.rs index ccdb23e..9d9c985 100644 --- a/cubecrypt/src/lib.rs +++ b/cubecrypt/src/lib.rs @@ -31,9 +31,11 @@ #![forbid(unsafe_code)] #![warn(missing_docs)] +pub mod auth; pub mod env; pub mod transform; +pub use auth::{hex_encode, hmac_sha256, random_nonce_hex, sign_hello, verify_hello}; pub use env::{CubeEnv, EnvError, Selector, HEADER_FLAG_ENCRYPTED}; pub use transform::{CryptoError, Key, KeySlot, TransformId}; diff --git a/cubesys/src/audit.rs b/cubesys/src/audit.rs new file mode 100644 index 0000000..126d8b9 --- /dev/null +++ b/cubesys/src/audit.rs @@ -0,0 +1,125 @@ +//! Audit trail for the daemon (plan R6). +//! +//! Every mutating or read op executed through a [`Session`] is appended to an +//! append-only log stored in a dedicated Null-cube range of the *same* +//! [`ConcurrentStore`] the tenant uses. The PDF asks for "space for access +//! logs ... in separate Null ranges"; this is that space. Because it lives in +//! the store, it is durable and isolated per tenant (each tenant's store has +//! its own audit range). +//! +//! Op tags (match the command set): +//! OP_WRITE = 1 write / prog +//! OP_DELETE = 2 del +//! OP_READ = 3 run / stat / ls (metadata) +//! OP_GRANT = 4 grant +//! OP_REVOKE = 5 revoke +//! OP_SEAL = 6 seal +//! OP_OPEN = 7 open +//! +//! Each entry is one JSON object on its own line: +//! {"seq":N,"ts":U,"op":B,"coord":"C.Z.Y.X","owner":"who","ok":bool} +//! +//! The whole log is kept under one head record and appended by read-modify- +//! write under a per-store mutex. For a single-owner box this is cheap and +//! correct; under many concurrent writers it becomes O(n) per append — the +//! same scaling caveat noted for the grant table (plan R2), and fine at this +//! deployment's volume. + +use crate::store::ConcurrentStore; +use cubecoords::Czyx; +use std::sync::Mutex; + +/// Head coordinate of the audit range (a Null cube, distinct from grants at +/// 0,1,0,1 and the FUSE ACL range). +pub const AUDIT_HEAD: Czyx = Czyx::new(0, 4, 0, 0); + +/// Audit op tag: `write` / `prog` (content mutation). +pub const OP_WRITE: u8 = 1; +/// Audit op tag: `del` (deletion). +pub const OP_DELETE: u8 = 2; +/// Audit op tag: `run` / `stat` / `ls` (metadata / read). +pub const OP_READ: u8 = 3; +/// Audit op tag: `grant` (permission grant). +pub const OP_GRANT: u8 = 4; +/// Audit op tag: `revoke` (permission revocation). +pub const OP_REVOKE: u8 = 5; +/// Audit op tag: `seal` (freeze a record). +pub const OP_SEAL: u8 = 6; +/// Audit op tag: `open` (unseal a record). +pub const OP_OPEN: u8 = 7; + +/// Append-only audit log bound to one tenant store. Cheap to clone (just an +/// `Arc>` serialization guard + a coord); the actual data lives in +/// the store. +#[derive(Clone)] +pub struct Audit { + store: std::sync::Arc, + /// Serializes appends so two threads don't read-modify-write the same head + /// record concurrently (last-writer-wins would drop entries). + guard: std::sync::Arc>, +} + +impl Audit { + /// Bind an audit log to a store. + pub fn new(store: std::sync::Arc) -> Self { + Audit { + store, + guard: std::sync::Arc::new(Mutex::new(())), + } + } + + /// Append one audit entry. `ok` records whether the op was permitted + /// (true) or rejected by the gate (false) — so the log captures both + /// successful and denied attempts (the latter being the interesting ones + /// for intrusion detection). + pub fn append(&self, op: u8, coord: Czyx, owner: &str, ok: bool) { + let _lock = self.guard.lock().unwrap(); + let ts = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + // Current sequence = number of lines already present. + let existing = self + .store + .get_record(&AUDIT_HEAD) + .map(|(_, v)| String::from_utf8_lossy(&v).into_owned()) + .unwrap_or_default(); + let seq = existing.lines().filter(|l| !l.trim().is_empty()).count() as u64 + 1; + let line = format!( + "{{\"seq\":{seq},\"ts\":{ts},\"op\":{op},\"coord\":\"{}\",\"owner\":\"{}\",\"ok\":{ok}}}", + coord.pack_u32(), + owner_escape(owner) + ); + let mut next = existing; + if !next.is_empty() && !next.ends_with('\n') { + next.push('\n'); + } + next.push_str(&line); + next.push('\n'); + let mut h = cubecoords::CubeHeader::new(); + h.doc_type = Some("audit-log".into()); + h.refresh_flags(); + self.store.put_record(AUDIT_HEAD, &h, next.as_bytes()); + } + + /// Return all audit lines (newest-last), as a single newline-joined string. + pub fn dump(&self) -> String { + self.store + .get_record(&AUDIT_HEAD) + .map(|(_, v)| String::from_utf8_lossy(&v).into_owned()) + .unwrap_or_default() + } +} + +/// Minimal JSON string escaping for the owner field (quotes + backslash). +fn owner_escape(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for c in s.chars() { + match c { + '"' => out.push_str("\\\""), + '\\' => out.push_str("\\\\"), + _ => out.push(c), + } + } + out +} diff --git a/cubesys/src/bin/cube-server.rs b/cubesys/src/bin/cube-server.rs index 81e60d5..c34490c 100644 --- a/cubesys/src/bin/cube-server.rs +++ b/cubesys/src/bin/cube-server.rs @@ -30,7 +30,7 @@ //! With `--tenant-dir DIR`, `--store` is unused and each tenant is a subdir of //! DIR (using the SharedFile fallback only when --tenant-dir is absent). -use std::os::unix::net::UnixListener; +use std::os::unix::net::{UnixListener, UnixStream}; use std::path::PathBuf; use std::str::FromStr; use std::sync::Arc; @@ -39,7 +39,7 @@ use std::thread; use cubesys::commands::Session; use cubesys::net::{read_stream_frame, write_frame}; use cubesys::store::DurabilityConfig; -use cubesys::tenant::{TenantConfig, TenantId, TenantRegistry, TenantSession}; +use cubesys::tenant::{TenantConfig, TenantId, TenantIdentity, TenantRegistry, TenantSession}; struct Server { listener: UnixListener, @@ -54,6 +54,11 @@ struct Server { /// (`--allow-anonymous`), the legacy permissive behaviour is restored for /// pre-existing `cubec`/stress.sh clients that never send HELLO. require_identity: bool, + /// When `Some`, the daemon requires every connection to prove possession of + /// this pre-shared key via a signed `HELLO` (plan R4). The key bytes are + /// kept here, never logged. `None` => legacy unsigned `HELLO` (backward + /// compatible with old `cubec`/`stress.sh`). + psk: Option>, } impl Server { @@ -62,12 +67,14 @@ impl Server { registry: Arc, per_tenant: bool, require_identity: bool, + psk: Option>, ) -> Self { Server { listener, registry, per_tenant, require_identity, + psk, } } @@ -78,104 +85,131 @@ impl Server { let registry = self.registry.clone(); let per_tenant = self.per_tenant; let require_identity = self.require_identity; + let psk = self.psk.clone(); // Thread-per-connection: each client runs on its own thread // against its tenant's store. thread::spawn(move || { - // Resolve the tenant for this connection. A connection - // that sends HELLO first is routed to (and stamps) its - // declared tenant; one that does not falls back to the - // shared "default" tenant. - let ts = match read_stream_frame(&mut stream) { - Ok(req) => { - let line = req.trim(); - if line.is_empty() { - let _ = write_frame(&mut stream, ""); - return; + if let Some(psk) = psk { + // R4: challenge-response auth. On failure, reject. + match auth_handshake(&mut stream, &psk) { + Ok(ident) => { + let ts = match resolve_tenant( + ®istry, + per_tenant, + &ident.tenant, + ) { + Ok(ts) => ts, + Err(e) => { + let _ = + write_frame(&mut stream, &format!("error: {e}")); + return; + } + }; + ts.set_identity(ident); + if write_frame( + &mut stream, + &format!( + "ok: tenant {}", + ts.identity().unwrap().tenant.as_str() + ), + ) + .is_err() + { + return; + } + serve_connection( + ®istry, + per_tenant, + require_identity, + &ts, + &mut stream, + ); } - let first = line.split_whitespace().next().unwrap_or(""); - if first.eq_ignore_ascii_case("hello") { - match TenantRegistry::parse_hello(line) { - Ok(ident) => { - // Resolve (or provision) the tenant's - // session and stamp the identity. - let ts = match resolve_tenant( - ®istry, - per_tenant, - &ident.tenant, - ) { - Ok(ts) => ts, - Err(e) => { - let _ = write_frame( - &mut stream, - &format!("error: {e}"), - ); + Err(e) => { + let _ = write_frame(&mut stream, &format!("error: {e}")); + } + } + } else { + // Legacy path: no auth. First frame is HELLO or a + // command (unchanged behaviour). + let ts = match read_stream_frame(&mut stream) { + Ok(req) => { + let line = req.trim(); + if line.is_empty() { + let _ = write_frame(&mut stream, ""); + return; + } + let first = line.split_whitespace().next().unwrap_or(""); + if first.eq_ignore_ascii_case("hello") { + match TenantRegistry::parse_hello(line) { + Ok(ident) => { + let ts = match resolve_tenant( + ®istry, + per_tenant, + &ident.tenant, + ) { + Ok(ts) => ts, + Err(e) => { + let _ = write_frame( + &mut stream, + &format!("error: {e}"), + ); + return; + } + }; + ts.set_identity(ident); + if write_frame( + &mut stream, + &format!( + "ok: tenant {}", + ts.identity().unwrap().tenant.as_str() + ), + ) + .is_err() + { return; } - }; - ts.set_identity(ident); - // Acknowledge the HELLO so the - // client can proceed to commands. - if write_frame( - &mut stream, - &format!( - "ok: tenant {}", - ts.identity().unwrap().tenant.as_str() - ), - ) - .is_err() - { + Some(ts) + } + Err(e) => { + let _ = write_frame( + &mut stream, + &format!("error: {e}"), + ); return; } - Some(ts) } - Err(e) => { - let _ = - write_frame(&mut stream, &format!("error: {e}")); - return; - } - } - } else { - // Not a HELLO: resolve the default tenant and - // run the line as its first command. - match resolve_default(®istry, per_tenant) { - Ok(ts) => { - let mut sess = Session::for_tenant(&ts); - sess.set_enforce_owner(require_identity); - handle_command(&mut sess, &mut stream, line); - // Hold the session for the rest of - // the connection (so txns span lines). - Some(ts) - } - Err(e) => { - let _ = - write_frame(&mut stream, &format!("error: {e}")); - return; + } else { + match resolve_default(®istry, per_tenant) { + Ok(ts) => { + let mut sess = Session::for_tenant(&ts); + sess.set_enforce_owner(require_identity); + handle_command(&mut sess, &mut stream, line); + Some(ts) + } + Err(e) => { + let _ = write_frame( + &mut stream, + &format!("error: {e}"), + ); + return; + } } } } - } - Err(_) => { - // Bad frame; ignore. - return; - } - }; - - let ts = ts.expect("tenant session resolved"); - - // Build the per-connection interpreter session once; it - // carries any in-flight transaction across the frames - // that follow (Task 5: BEGIN..COMMIT spanning lines). - let mut session = Session::for_tenant(&ts); - session.set_enforce_owner(require_identity); - - // HELLO already consumed; serve subsequent command - // frames on this connection until the peer closes. - while let Ok(req) = read_stream_frame(&mut stream) { - let line = req.trim(); - if line.is_empty() { - break; - } - handle_command(&mut session, &mut stream, line); + Err(_) => return, + }; + let ts = match ts { + Some(ts) => ts, + None => return, + }; + serve_connection( + ®istry, + per_tenant, + require_identity, + &ts, + &mut stream, + ); } }); } @@ -187,6 +221,53 @@ impl Server { } } +/// Authenticate a freshly-accepted connection when `--auth-key` is set +/// (plan R4). Sends a `CHALLENGE `, reads the signed `HELLO`, and +/// verifies the HMAC. Returns the parsed identity on success, or an error +/// string the caller should return to the peer and close. +/// +/// Defined at module level (not as a `Server` method) because the caller runs +/// it inside a `thread::spawn(move ...)` closure where `self` has been moved +/// into `run(self)` — the closure can only reach the `psk` it captures. +fn auth_handshake(stream: &mut UnixStream, psk: &[u8]) -> Result { + let nonce = cubecrypt::random_nonce_hex(32).map_err(|e| format!("entropy error: {e}"))?; + write_frame(stream, &format!("CHALLENGE {nonce}")) + .map_err(|e| format!("write challenge: {e}"))?; + let frame = read_stream_frame(stream).map_err(|e| format!("read hello: {e}"))?; + let line = frame.trim(); + let mut parts: Vec<&str> = line.split_whitespace().collect(); + if parts.is_empty() || !parts[0].eq_ignore_ascii_case("hello") { + return Err("expected signed HELLO after CHALLENGE".to_string()); + } + if parts.len() < 5 { + return Err("HELLO missing signature token".to_string()); + } + let sig = parts.pop().unwrap(); // last token is the hex signature + let owner_remote = if parts.len() >= 4 { + Some(parts[3].to_string()) + } else { + None + }; + let owner_local = parts[2].to_string(); + let tenant_s = parts[1]; + let tenant = TenantId::from_str(tenant_s).map_err(|e| format!("HELLO: bad tenant: {e}"))?; + if !cubecrypt::verify_hello( + psk, + &nonce, + tenant.as_str(), + &owner_local, + owner_remote.as_deref(), + sig, + ) { + return Err("HELLO signature verification failed".to_string()); + } + Ok(TenantIdentity { + tenant, + owner_local, + owner_remote, + }) +} + /// Resolve a HELLO-declared tenant to its session. In per-tenant (Disk) mode /// this provisions/looks up an isolated store; in legacy SharedFile mode every /// tenant maps to the single shared store. @@ -225,6 +306,39 @@ fn resolve_default( } } +/// Serve command frames on an already-authenticated/resolved connection until +/// the peer closes or sends an empty line. Shared by the auth and legacy paths +/// so the serve loop lives in exactly one place. +fn serve_connection( + registry: &Arc, + per_tenant: bool, + require_identity: bool, + ts: &Arc, + stream: &mut UnixStream, +) { + // Build the per-connection interpreter session once; it carries any + // in-flight transaction across the frames that follow (Task 5: + // BEGIN..COMMIT spanning lines). + let mut session = Session::for_tenant(ts); + session.set_enforce_owner(require_identity); + // The audit trail records this connection's actor (the stamped owner, if + // any) on every mutating/read op (plan R6). + session.enable_audit(); + + // Serve subsequent command frames on this connection until the peer closes. + while let Ok(req) = read_stream_frame(stream) { + let line = req.trim(); + if line.is_empty() { + break; + } + handle_command(&mut session, stream, line); + } + // Touch `registry`/`per_tenant` so the borrow checker is happy even though + // the serve loop itself no longer needs them (kept for symmetry with the + // legacy inline path and future per-frame routing). + let _ = (registry, per_tenant); +} + /// Execute one command line against the connection's [`Session`] and write the /// reply frame. The session is held across frames so `BEGIN`/`COMMIT` keep /// their transaction state (Task 5). @@ -249,6 +363,32 @@ fn main() { args.iter().any(|a| a == "--deny-unknown-tenant"), args.iter().any(|a| a == "--allow-anonymous"), ); + // R4: pre-shared key for the signed-HELLO handshake. `--auth-key PATH` + // reads the key from a file (trimmed); `--auth-key-env VAR` reads it from + // an environment variable. If neither is given, the daemon runs unsigned + // (legacy behaviour, backward compatible with old cubec/stress.sh). When + // set, every connection MUST complete the challenge-response or be closed. + let psk: Option> = match (arg(&args, "--auth-key"), arg(&args, "--auth-key-env")) { + (Some(path), _) => match std::fs::read_to_string(&path) { + Ok(s) => Some(s.trim().as_bytes().to_vec()), + Err(e) => { + eprintln!("cube-server: cannot read --auth-key {path}: {e}"); + std::process::exit(1); + } + }, + (None, Some(var)) => match std::env::var(&var) { + Ok(s) => Some(s.trim().as_bytes().to_vec()), + Err(e) => { + eprintln!("cube-server: cannot read --auth-key-env {var}: {e}"); + std::process::exit(1); + } + }, + (None, None) => None, + }; + if psk.as_ref().is_some_and(|k| k.is_empty()) { + eprintln!("cube-server: --auth-key must not be empty"); + std::process::exit(1); + } // Default daemon policy (Task 6 B): require a HELLO identity on every // mutating op. `--allow-anonymous` restores the legacy permissive mode for // pre-existing cubec/stress.sh clients that never send HELLO. @@ -323,7 +463,7 @@ fn main() { def.store.checkpoint(); } - Server::new(listener, registry, per_tenant, require_identity).run(); + Server::new(listener, registry, per_tenant, require_identity, psk).run(); } /// Fetch `--key VALUE` from argv, or None. diff --git a/cubesys/src/bin/cubec.rs b/cubesys/src/bin/cubec.rs index 68c5023..7c24237 100644 --- a/cubesys/src/bin/cubec.rs +++ b/cubesys/src/bin/cubec.rs @@ -1,91 +1,119 @@ -//! `cubec` — the CUBELinux-2 system client. +//! `cubec` — a tiny Unix-domain-socket client for the `cube-server` daemon. //! -//! Talks to `cube-server` over its Unix-domain socket. Two modes: +//! It speaks the same framed wire protocol as the server (`net` module): a +//! 4-byte length prefix + UTF-8 payload per frame, one request → one reply. //! -//! cubec one-shot: send a single command, print the reply -//! cubec REPL: read command lines from stdin, one per -//! connection, printing each reply (like `cube repl`) -//! -//! The socket defaults to /run/cube/cube.sock (or $XDG_RUNTIME_DIR/cube/cube.sock) -//! and can be overridden with `--socket PATH`. +//! When the daemon was started with `--auth-key`, the connection opens with a +//! `CHALLENGE ` frame (plan R4). `cubec` proves possession of the same +//! pre-shared key by replying `HELLO `. Without a key +//! (legacy daemon) `cubec` behaves exactly as before — it sends its command +//! first and reads the reply. +use cubesys::net::{read_stream_frame, write_frame}; use std::io::{BufRead, Write}; use std::os::unix::net::UnixStream; -use cubesys::net::{read_frame, write_frame}; +/// Resolve `--key VALUE` from argv, or None. +fn arg_value(args: &[String], key: &str) -> Option { + args.iter() + .position(|a| a == key) + .and_then(|i| args.get(i + 1).cloned()) +} fn main() { - let args: Vec = std::env::args().collect(); - let socket_path = socket_from_args(&args); + let args: Vec = std::env::args().skip(1).collect(); + let socket = arg_value(&args, "--socket") + .or_else(|| std::env::var("CUBE_SOCKET").ok()) + .unwrap_or_else(|| "/run/cube/demo.sock".to_string()); + let tenant = arg_value(&args, "--tenant").unwrap_or_else(|| "default".to_string()); + let owner = arg_value(&args, "--owner") + .or_else(|| std::env::var("USER").ok()) + .unwrap_or_else(|| "cubec".to_string()); + // Pre-shared key for the signed-HELLO handshake (plan R4). `--auth-key PATH` + // reads a file; `--auth-key-env VAR` reads an env var; otherwise `CUBE_AUTH_KEY`. + let psk: Option> = if let Some(path) = arg_value(&args, "--auth-key") { + std::fs::read_to_string(&path) + .map(|s| s.trim().as_bytes().to_vec()) + .ok() + } else if let Some(var) = arg_value(&args, "--auth-key-env") { + std::env::var(&var) + .ok() + .map(|s| s.trim().as_bytes().to_vec()) + } else { + std::env::var("CUBE_AUTH_KEY") + .ok() + .map(|s| s.trim().as_bytes().to_vec()) + }; - // Build the command from everything that isn't `--socket PATH`. - let mut rest: Vec = Vec::new(); - let mut i = 1; - while i < args.len() { - if args[i] == "--socket" { - i += 2; // skip the flag and its value - continue; + let mut stream = match UnixStream::connect(&socket) { + Ok(s) => s, + Err(e) => { + eprintln!("cubec: cannot connect to {socket}: {e}"); + std::process::exit(1); + } + }; + + // Complete the challenge-response handshake if we (and the server) are in + // auth mode. We only expect a CHALLENGE when we have a key; reading first + // unconditionally would deadlock against a legacy (un-keyed) server. + if let Some(key) = psk.as_deref() { + let frame = match read_stream_frame(&mut stream) { + Ok(f) => f, + Err(e) => { + eprintln!("cubec: handshake read failed: {e}"); + std::process::exit(1); + } + }; + let line = frame.trim(); + if !line.to_lowercase().starts_with("challenge ") { + eprintln!("cubec: expected CHALLENGE from an auth-enabled server, got: {line}"); + std::process::exit(1); + } + let nonce = line["challenge ".len()..].trim(); + // HELLO + HMAC over (nonce|tenant|owner|remote). No remote asserted here. + let sig = cubecrypt::sign_hello(key, nonce, &tenant, &owner, None); + let hello = format!("HELLO {tenant} {owner} {sig}"); + if write_frame(&mut stream, &hello).is_err() { + eprintln!("cubec: handshake write failed"); + std::process::exit(1); } - rest.push(args[i].clone()); - i += 1; } - if rest.is_empty() { - repl(&socket_path); + if args.is_empty() { + // REPL mode (legacy behaviour, now after an optional handshake). + let stdin = std::io::stdin(); + for line in stdin.lock().lines().map_while(Result::ok) { + let line = line.trim(); + if line.is_empty() { + continue; + } + if write_frame(&mut stream, line).is_err() { + eprintln!("cubec: write failed"); + return; + } + match read_stream_frame(&mut stream) { + Ok(reply) => println!("{reply}"), + Err(e) => { + eprintln!("cubec: read failed: {e}"); + return; + } + } + } } else { - let cmd = rest.join(" "); - match request(&socket_path, &cmd) { + // One-shot: join the remaining args as the command line, send, print. + let cmd = args.join(" "); + if write_frame(&mut stream, &cmd).is_err() { + eprintln!("cubec: write failed"); + std::process::exit(1); + } + match read_stream_frame(&mut stream) { Ok(reply) => println!("{reply}"), Err(e) => { - eprintln!("cubec: {e}"); + eprintln!("cubec: read failed: {e}"); std::process::exit(1); } } } -} -fn repl(socket_path: &str) { - let stdin = std::io::stdin(); - for line in stdin.lock().lines() { - let line = match line { - Ok(l) => l, - Err(_) => break, - }; - if line.trim().is_empty() || line.starts_with('#') { - continue; - } - match request(socket_path, &line) { - Ok(reply) => println!("{reply}"), - Err(e) => eprintln!("error: {e}"), - } - } -} - -/// Open a connection, send one command frame, return the reply frame. -fn request(socket_path: &str, command: &str) -> Result { - let mut stream = UnixStream::connect(socket_path) - .map_err(|e| format!("cannot connect to {socket_path}: {e}"))?; - write_frame(&mut stream, command).map_err(|e| format!("write: {e}"))?; - read_frame(&mut stream).map_err(|e| format!("read: {e}")) -} - -fn socket_from_args(args: &[String]) -> String { - let mut i = 0; - while i < args.len() { - if args[i] == "--socket" { - if let Some(v) = args.get(i + 1) { - return v.clone(); - } - } - i += 1; - } - if let Ok(runtime) = std::env::var("XDG_RUNTIME_DIR") { - return format!("{runtime}/cube/cube.sock"); - } - "/run/cube/cube.sock".to_string() -} - -#[allow(dead_code)] -fn _flush() { - let _ = std::io::stdout().flush(); + let _ = stream.flush(); } diff --git a/cubesys/src/commands.rs b/cubesys/src/commands.rs index 6147fdd..66b37cf 100644 --- a/cubesys/src/commands.rs +++ b/cubesys/src/commands.rs @@ -11,6 +11,7 @@ //! survives restarts. Each `exec` takes and returns an `Arc` //! so the server can hand a cloned handle to each worker thread. +use crate::audit::{Audit, OP_DELETE, OP_GRANT, OP_OPEN, OP_READ, OP_REVOKE, OP_SEAL, OP_WRITE}; use crate::grants::{grant, grant_allows, perms_from_str, revoke, Owner, Perm}; use crate::store::ConcurrentStore; use crate::tenant::{TenantIdentity, TenantSession}; @@ -72,6 +73,11 @@ pub struct Session { /// behaviour, so pre-existing `cubec`/stress.sh flows keep working until /// the operator opts in via the daemon's `--require-identity` policy). enforce_owner: bool, + /// Audit trail (plan R6). `Some` when the daemon enabled it on this + /// connection; every op is then appended (permitted or denied) so an + /// operator can later review who touched what. `None` keeps the library + /// REPL/tests silent (no audit record churn) unless explicitly enabled. + audit: Option, } impl Default for Session { @@ -90,6 +96,7 @@ impl Session { identity: None, txn: None, enforce_owner: false, + audit: None, } } @@ -102,6 +109,7 @@ impl Session { identity: None, txn: None, enforce_owner: false, + audit: None, } } @@ -118,6 +126,25 @@ impl Session { identity: ts.identity(), txn: None, enforce_owner: false, + audit: None, + } + } + + /// Enable the audit trail for this session (plan R6). Called by the daemon + /// once per connection after construction. + pub fn enable_audit(&mut self) { + self.audit = Some(Audit::new(self.store.clone())); + } + + /// Append an audit entry if auditing is enabled. No-op otherwise. + fn audit_now(&self, op: u8, coord: Czyx, ok: bool) { + if let Some(a) = &self.audit { + let owner = self + .identity + .as_ref() + .map(|i| i.owner_local.as_str()) + .unwrap_or(""); + a.append(op, coord, owner, ok); } } @@ -372,6 +399,23 @@ impl Session { Ok("ok: transaction rolled back".to_string()) } "stats" => Ok(self.stats()), + "audit" => { + // Plan R6: dump the append-only audit trail for this session's + // store. Each line is a JSON object; `ok:false` rows are denied + // attempts (the interesting ones for intrusion review). + let log = match &self.audit { + Some(a) => a.dump(), + None => return Err( + "audit: audit trail is not enabled on this session (daemon --enable-audit)" + .to_string(), + ), + }; + if log.trim().is_empty() { + Ok("audit -> (no entries)".to_string()) + } else { + Ok(format!("audit ->\n{log}")) + } + } "query" => { let dt = it .next() @@ -412,8 +456,10 @@ impl Session { let coord = scratch_code_coord(path, Kind::Fn, name, &ops)?; // Task 6: reject overwriting a record owned by a different owner. if let Some(msg) = self.admit_mutate(coord, Perm::Write) { + self.audit_now(OP_WRITE, coord, false); return Err(msg); } + self.audit_now(OP_WRITE, coord, true); let owner = self.identity.as_ref().map(|i| i.owner_local.as_str()); let value = { let mut scratch = CubeStore::new(HashBackend::new()); @@ -452,8 +498,10 @@ impl Session { let name = path.rsplit('/').next().unwrap_or(path); let coord = scratch_code_coord(path, Kind::Fn, name, &code)?; if let Some(msg) = self.admit_mutate(coord, Perm::Write) { + self.audit_now(OP_WRITE, coord, false); return Err(msg); } + self.audit_now(OP_WRITE, coord, true); let owner = self.identity.as_ref().map(|i| i.owner_local.as_str()); let value = { let mut scratch = CubeStore::new(HashBackend::new()); @@ -483,8 +531,10 @@ impl Session { // Task 6: reject deleting a record owned by a different owner // (unless unowned, which any identity may take over). if let Some(msg) = self.admit_mutate(coord, Perm::Write) { + self.audit_now(OP_DELETE, coord, false); return Err(msg); } + self.audit_now(OP_DELETE, coord, true); if let Some(txn) = self.txn.as_mut() { txn.ops.push(TxnOp { coord, put: None }); return Ok(format!("buffered del {path} — commit to apply")); @@ -537,6 +587,7 @@ impl Session { }; let seq = grant(&self.store, &granter, &grantee, perms, scope) .map_err(|e| format!("grant: {e}"))?; + self.audit_now(OP_GRANT, scope.unwrap_or(Czyx::new(0, 0, 0, 0)), true); let scope_s = match scope { Some(c) => c.pack_u32().to_string(), None => "global".to_string(), @@ -582,6 +633,7 @@ impl Session { remote: gr, }; let removed = revoke(&self.store, &granter, &grantee, scope); + self.audit_now(OP_REVOKE, scope.unwrap_or(Czyx::new(0, 0, 0, 0)), true); Ok(format!("ok: revoked {removed} grant(s)")) } "run" => { @@ -590,8 +642,10 @@ impl Session { // R5: reads (this one also *executes*) honor the owner/grantee // read gate, same as writes honor the mutate gate. if let Some(msg) = self.admit_read(_coord) { + self.audit_now(OP_READ, _coord, false); return Err(msg); } + self.audit_now(OP_READ, _coord, true); let sn = txn_snapshot(self); let cell = crate::load_code_cell(&sn, path).map_err(|e| e.to_string())?; let mut vm = Vm::new(sn); @@ -622,8 +676,10 @@ impl Session { // R5: metadata reads honor the read gate. let coord = crate::path_to_czyx(path).map_err(|e| e.to_string())?; if let Some(msg) = self.admit_read(coord) { + self.audit_now(OP_READ, coord, false); return Err(msg); } + self.audit_now(OP_READ, coord, true); let sn = txn_snapshot(self); let fs = cubefs::CubeFs::new(sn); let a = fs @@ -659,6 +715,8 @@ impl Session { if let Some(msg) = self.admit_mutate(coord, Perm::Write) { return Err(msg); } + // Audit the destructive op that is about to run (seal vs open). + self.audit_now(if cmd == "seal" { OP_SEAL } else { OP_OPEN }, coord, true); if store.get_record(&kc).is_none() { store.put_raw(kc, b"demo-key-material-32-bytes-long!!".to_vec()); diff --git a/cubesys/src/lib.rs b/cubesys/src/lib.rs index b9d1a15..ecc3a27 100644 --- a/cubesys/src/lib.rs +++ b/cubesys/src/lib.rs @@ -40,6 +40,10 @@ use cubecoords::{CubeHeader, Czyx}; use cubefs::path::parse_path; use cubestore::{CubeBackend, CubeStore, HashBackend}; +/// Audit trail (plan R6): append-only per-tenant log of who ran what, stored in +/// a dedicated Null-cube range of the tenant's own store so it is durable and +/// isolated with the data it describes. +pub mod audit; /// Shared cube command interpreter (`prog`, `write`, `run`, `ls`, `stat`, /// `seal`, `open`). Used by the `cube` REPL, the `cubec` socket client, and the /// `cube-server` daemon so all front-ends behave identically.