feat(cubesys/cubecrypt): R4 challenge-response auth + R5/R6 wiring

- cubecrypt/src/auth.rs: HMAC-SHA256 signed HELLO (sign_hello/verify_hello),
  random_nonce_hex entropy source (plan R4)
- cube-server: --auth-key enables CHALLENGE/HELLO handshake; auth_handshake is
  a module-level free fn (run(self) consumes self, so the thread closure can
  only reach the captured psk). Resolves the earlier E0425 compile failure
- cubec.rs client: --auth-key builds a signed HELLO frame
- commands.rs: audit op constants + read-gating hooks wired into admit_read
- audit.rs: per-tenant append-only audit log in a Null-cube range (plan R6)
- clippy -D warnings clean; full ./check gate ALL CHECKS PASSED (61 tests)
This commit is contained in:
CUBELinux-2
2026-08-11 15:59:10 -04:00
parent 78cd5c0046
commit 94bddda3dd
7 changed files with 710 additions and 159 deletions
+194
View File
@@ -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 <hexnonce>` (32 random bytes).
//! 2. The client proves possession of the PSK by replying
//! `HELLO <tenant> <owner_local> [<owner_remote>] <hexsig>` 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<String> {
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");
}
}
+2
View File
@@ -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};
+125
View File
@@ -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<Mutex<()>>` serialization guard + a coord); the actual data lives in
/// the store.
#[derive(Clone)]
pub struct Audit {
store: std::sync::Arc<ConcurrentStore>,
/// 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<Mutex<()>>,
}
impl Audit {
/// Bind an audit log to a store.
pub fn new(store: std::sync::Arc<ConcurrentStore>) -> 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
}
+229 -89
View File
@@ -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<Vec<u8>>,
}
impl Server {
@@ -62,12 +67,14 @@ impl Server {
registry: Arc<TenantRegistry>,
per_tenant: bool,
require_identity: bool,
psk: Option<Vec<u8>>,
) -> 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(
&registry,
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(
&registry,
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(
&registry,
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(
&registry,
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(&registry, 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(&registry, 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(
&registry,
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 <nonce>`, 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<TenantIdentity, String> {
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<TenantRegistry>,
per_tenant: bool,
require_identity: bool,
ts: &Arc<TenantSession>,
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<Vec<u8>> = 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.
+98 -70
View File
@@ -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 <command...> 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 <nonce>` frame (plan R4). `cubec` proves possession of the same
//! pre-shared key by replying `HELLO <tenant> <owner> <sig>`. 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<String> {
args.iter()
.position(|a| a == key)
.and_then(|i| args.get(i + 1).cloned())
}
fn main() {
let args: Vec<String> = std::env::args().collect();
let socket_path = socket_from_args(&args);
let args: Vec<String> = 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<Vec<u8>> = 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<String> = 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<String, String> {
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();
}
+58
View File
@@ -11,6 +11,7 @@
//! survives restarts. Each `exec` takes and returns an `Arc<ConcurrentStore>`
//! 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<Audit>,
}
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("<anonymous>");
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());
+4
View File
@@ -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.