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:
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
|
||||
|
||||
@@ -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
@@ -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(
|
||||
®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 <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
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user