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