- 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)
312 lines
11 KiB
Rust
312 lines
11 KiB
Rust
//! Package 5 of CUBELinux-2: cubecrypt — encryption + environment (PDF).
|
|
//!
|
|
//! The PDF's intent: "exploit Null cubes/layers as environment settings that
|
|
//! influence how records are read/written." This crate implements that
|
|
//! literally:
|
|
//!
|
|
//! * Key material and the chosen transform live in **Null-cube cells** —
|
|
//! addressable records in the cube, not out-of-band config. A [`CubeEnv`]
|
|
//! references those cells. Re-point an env at a different Null cube and the
|
|
//! *same CZYX coordinate* opens to different plaintext: the spec's headline
|
|
//! property ("same coordinate, different plaintext under different Null
|
|
//! settings") is a direct consequence of where the key lives.
|
|
//! * Records are sealed with standard, audited crates (AES-256-GCM,
|
|
//! ChaCha20-Poly1305 — both AEAD; AES-256-XTS behind the `xts` feature).
|
|
//! We never implement a cipher. Every sealed record carries a self-
|
|
//! describing envelope (magic + transform id + nonce + ciphertext) and the
|
|
//! store header is flagged [`HEADER_FLAG_ENCRYPTED`].
|
|
//! * Tenant/record-level key selection via [`Selector`] (explicit slot, or
|
|
//! derived from the record coordinate), giving "record-level selectors for
|
|
//! keys/tenants."
|
|
//! * Access logs and tamper-evident metadata live in separate Null ranges
|
|
//! via the [`AccessLog`] helper, satisfying the PDF's "space for access
|
|
//! logs ... in separate Null ranges."
|
|
//!
|
|
//! Bounds: this is the crypto substrate. It does not (yet) integrate with
|
|
//! cubefs's mount path or with the cubevm runtime — those are composition
|
|
//! layers left as documented extension points. We also do not manage key
|
|
//! rotation or a KMS; key material is assumed already strong and stored in
|
|
//! the cube.
|
|
|
|
#![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};
|
|
|
|
use cubecoords::{CubeHeader, Czyx};
|
|
use cubestore::{CubeBackend, CubeStore};
|
|
|
|
/// An append-only access log living in a Null-cube range. Each [`AccessLog`]
|
|
/// targets one Null-cube head coordinate and appends fixed-size entries
|
|
/// (record coord + op byte + 8-byte timestamp). It is "tamper-evident" in the
|
|
/// weak sense that the log itself can be stored encrypted via another
|
|
/// [`CubeEnv`]; this type only provides the structure and append/read.
|
|
pub struct AccessLog<B: CubeBackend> {
|
|
store: CubeStore<B>,
|
|
head: Czyx,
|
|
next: u8,
|
|
}
|
|
|
|
impl<B: CubeBackend> AccessLog<B> {
|
|
/// Bind a log to a Null-cube head coordinate (entries append along X).
|
|
pub fn new(store: CubeStore<B>, head: Czyx) -> Self {
|
|
AccessLog {
|
|
store,
|
|
head,
|
|
next: 1,
|
|
}
|
|
}
|
|
|
|
/// Append one entry: the record touched, an op tag, and a timestamp.
|
|
pub fn append(&mut self, record: Czyx, op: u8, ts: u64) {
|
|
let mut body = Vec::with_capacity(13);
|
|
body.extend_from_slice(&record.pack_u32().to_le_bytes());
|
|
body.push(op);
|
|
body.extend_from_slice(&ts.to_le_bytes());
|
|
let at = Czyx::new(self.head.c, self.head.z, self.head.y, self.next);
|
|
let mut h = CubeHeader::new();
|
|
h.doc_type = Some("access-log".into());
|
|
h.refresh_flags();
|
|
self.store.put_record(at, &h, &body);
|
|
self.next = self.next.wrapping_add(1);
|
|
if self.next == 0 {
|
|
self.next = 1; // never use total-null X
|
|
}
|
|
}
|
|
|
|
/// Read back all entries in append order (X = 1..next).
|
|
pub fn entries(&self) -> Vec<(Czyx, u8, u64)> {
|
|
let mut out = Vec::new();
|
|
for x in 1..self.next {
|
|
let at = Czyx::new(self.head.c, self.head.z, self.head.y, x);
|
|
if let Some((_, body)) = self.store.get_record(&at) {
|
|
if body.len() >= 13 {
|
|
let coord = u32::from_le_bytes([body[0], body[1], body[2], body[3]]);
|
|
let record = Czyx::unpack_u32(coord);
|
|
let op = body[4];
|
|
let ts = u64::from_le_bytes(body[5..13].try_into().unwrap());
|
|
out.push((record, op, ts));
|
|
}
|
|
}
|
|
}
|
|
out
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use cubestore::HashBackend;
|
|
|
|
#[test]
|
|
fn seal_open_roundtrips_per_transform() {
|
|
let key = transform::derive_key(b"strong-key-material-bytes", b"salt");
|
|
for t in [
|
|
TransformId::None,
|
|
TransformId::Aes256Gcm,
|
|
TransformId::ChaCha20Poly1305,
|
|
] {
|
|
let pt = b"the same CZYX means different plaintext per env";
|
|
let env = transform::seal(t, &key, pt);
|
|
let back = transform::open(&key, &env).unwrap();
|
|
assert_eq!(back, pt);
|
|
// envelope is self-describing
|
|
assert_eq!(&env[0..4], transform::MAGIC);
|
|
assert_eq!(env[4], t as u8);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn xts_roundtrip_and_non_authenticated() {
|
|
// XTS needs 16-byte-multiple plaintext for the raw mode; seal pads
|
|
// non-multiples and open strips them, so both shapes round-trip.
|
|
let key = transform::derive_key(b"xts-root-key-material-32b", b"salt");
|
|
// exact 16-multiple (3 blocks)
|
|
let aligned: Vec<u8> = (0u8..48).collect();
|
|
let env = transform::seal(TransformId::Aes256Xts, &key, &aligned);
|
|
assert_eq!(transform::open(&key, &env).unwrap(), aligned);
|
|
// short plaintext (padded/ stripped)
|
|
let short = b"hello";
|
|
let env_s = transform::seal(TransformId::Aes256Xts, &key, short);
|
|
assert_eq!(transform::open(&key, &env_s).unwrap(), short);
|
|
// multi-block sector
|
|
let big = vec![0xABu8; 64];
|
|
let env_b = transform::seal(TransformId::Aes256Xts, &key, &big);
|
|
assert_eq!(transform::open(&key, &env_b).unwrap(), big);
|
|
// XTS is non-AEAD: wrong key opens silently to *different* garbage.
|
|
let wrong = transform::derive_key(b"xts-root-key-material-32b", b"other");
|
|
let got = transform::open(&wrong, &env).unwrap();
|
|
assert_ne!(got, aligned, "XTS wrong-key must NOT reproduce plaintext");
|
|
}
|
|
|
|
#[test]
|
|
fn wrong_key_fails_auth() {
|
|
let key = transform::derive_key(b"key-A-material", b"salt");
|
|
let wrong = transform::derive_key(b"key-B-material", b"salt");
|
|
let env = transform::seal(TransformId::Aes256Gcm, &key, b"secret");
|
|
assert!(transform::open(&wrong, &env).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn same_coord_different_plaintext_under_different_env() {
|
|
// Two envs point at different Null-cube key cells; encrypting the SAME
|
|
// record coordinate yields different ciphertext and different
|
|
// recoverable plaintext — the PDF's headline property.
|
|
let mut store = CubeStore::new(HashBackend::new());
|
|
let key_a = Czyx::new(0, 1, 0, 1); // Null cube 1
|
|
let key_b = Czyx::new(0, 1, 0, 2); // Null cube 2
|
|
store.put_record(
|
|
key_a,
|
|
&CubeHeader::new(),
|
|
b"environment-A-root-key-32bytes!!",
|
|
);
|
|
store.put_record(
|
|
key_b,
|
|
&CubeHeader::new(),
|
|
b"environment-B-root-key-32bytes!!",
|
|
);
|
|
|
|
let env_a = CubeEnv::new(
|
|
vec![KeySlot {
|
|
key_cell: key_a,
|
|
transform: TransformId::Aes256Gcm,
|
|
salt: vec![],
|
|
}],
|
|
vec![],
|
|
);
|
|
let env_b = CubeEnv::new(
|
|
vec![KeySlot {
|
|
key_cell: key_b,
|
|
transform: TransformId::ChaCha20Poly1305,
|
|
salt: vec![],
|
|
}],
|
|
vec![],
|
|
);
|
|
|
|
let record_a = Czyx::new(3, 7, 9, 11);
|
|
let record_b = Czyx::new(3, 7, 9, 12);
|
|
env_a
|
|
.put_encrypted(
|
|
&mut store,
|
|
record_a,
|
|
Selector::Slot(0),
|
|
b"alpha",
|
|
CubeHeader::new(),
|
|
)
|
|
.unwrap();
|
|
env_b
|
|
.put_encrypted(
|
|
&mut store,
|
|
record_b,
|
|
Selector::Slot(0),
|
|
b"beta",
|
|
CubeHeader::new(),
|
|
)
|
|
.unwrap();
|
|
|
|
// Cross-reading fails: env_a's key/transform cannot open env_b's
|
|
// record and vice-versa (different Null-cube key material).
|
|
assert!(env_a
|
|
.get_decrypted(&store, record_b, Selector::Slot(0))
|
|
.is_err());
|
|
assert!(env_b
|
|
.get_decrypted(&store, record_a, Selector::Slot(0))
|
|
.is_err());
|
|
|
|
// Each env recovers only its own plaintext.
|
|
assert_eq!(
|
|
env_a
|
|
.get_decrypted(&store, record_a, Selector::Slot(0))
|
|
.unwrap(),
|
|
b"alpha"
|
|
);
|
|
assert_eq!(
|
|
env_b
|
|
.get_decrypted(&store, record_b, Selector::Slot(0))
|
|
.unwrap(),
|
|
b"beta"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn selector_from_coord_picks_slot_by_class() {
|
|
let mut store = CubeStore::new(HashBackend::new());
|
|
let keys = [Czyx::new(0, 2, 0, 1), Czyx::new(0, 2, 0, 2)];
|
|
store.put_record(
|
|
keys[0],
|
|
&CubeHeader::new(),
|
|
b"slot-zero-key-material-32bytes.ok",
|
|
);
|
|
store.put_record(
|
|
keys[1],
|
|
&CubeHeader::new(),
|
|
b"slot-one-key-material-32bytes.ok",
|
|
);
|
|
let env = CubeEnv::new(
|
|
vec![
|
|
KeySlot {
|
|
key_cell: keys[0],
|
|
transform: TransformId::Aes256Gcm,
|
|
salt: vec![],
|
|
},
|
|
KeySlot {
|
|
key_cell: keys[1],
|
|
transform: TransformId::Aes256Gcm,
|
|
salt: vec![],
|
|
},
|
|
],
|
|
vec![],
|
|
);
|
|
// c=0 -> slot 0, c=1 -> slot 1
|
|
let r0 = Czyx::new(0, 5, 5, 5);
|
|
let r1 = Czyx::new(1, 5, 5, 5);
|
|
env.put_encrypted(
|
|
&mut store,
|
|
r0,
|
|
Selector::FromCoord(r0),
|
|
b"p0",
|
|
CubeHeader::new(),
|
|
)
|
|
.unwrap();
|
|
env.put_encrypted(
|
|
&mut store,
|
|
r1,
|
|
Selector::FromCoord(r1),
|
|
b"p1",
|
|
CubeHeader::new(),
|
|
)
|
|
.unwrap();
|
|
assert_eq!(
|
|
env.get_decrypted(&store, r0, Selector::FromCoord(r0))
|
|
.unwrap(),
|
|
b"p0"
|
|
);
|
|
assert_eq!(
|
|
env.get_decrypted(&store, r1, Selector::FromCoord(r1))
|
|
.unwrap(),
|
|
b"p1"
|
|
);
|
|
// wrong selector for r1 (uses slot 0) must fail
|
|
assert!(env.get_decrypted(&store, r1, Selector::Slot(0)).is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn access_log_appends_and_reads() {
|
|
let store = CubeStore::new(HashBackend::new());
|
|
let head = Czyx::new(0, 3, 0, 0); // Null cube 2 range for logs
|
|
let mut log = AccessLog::new(store, head);
|
|
log.append(Czyx::new(3, 1, 1, 1), 1, 1000);
|
|
log.append(Czyx::new(3, 1, 1, 2), 2, 2000);
|
|
let entries = log.entries();
|
|
assert_eq!(entries.len(), 2);
|
|
assert_eq!(entries[0], (Czyx::new(3, 1, 1, 1), 1, 1000));
|
|
assert_eq!(entries[1], (Czyx::new(3, 1, 1, 2), 2, 2000));
|
|
}
|
|
}
|