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
+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();
}