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
+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
}