feat(cubelinux-2): Package 5 — cubecrypt: Null-cube env selects key material + transform (AES-GCM, ChaCha20-Poly1305, XTS) over CZYX
This commit is contained in:
@@ -5,6 +5,7 @@ members = [
|
||||
"cubestore",
|
||||
"cubefs",
|
||||
"cubecode",
|
||||
"cubecrypt",
|
||||
]
|
||||
|
||||
[workspace.package]
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
[package]
|
||||
name = "cubecrypt"
|
||||
version = "0.1.0"
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
description = "Cube-environment crypto over CZYX: Null cubes select key material + transform (PDF Package 5)."
|
||||
|
||||
[dependencies]
|
||||
cubecoords = { path = "../cubecoords" }
|
||||
cubestore = { path = "../cubestore" }
|
||||
aead = { version = "0.5", features = ["alloc"] }
|
||||
# AES-256-GCM AEAD (PDF Package 5).
|
||||
aes-gcm = "0.10"
|
||||
# ChaCha20-Poly1305 AEAD (PDF Package 5).
|
||||
chacha20poly1305 = "0.10"
|
||||
# AES-256 block cipher, used as the primitive for the XTS disk mode below.
|
||||
aes = "0.8"
|
||||
sha2 = "0.10"
|
||||
rand = "0.8"
|
||||
zeroize = "1"
|
||||
|
||||
[dev-dependencies]
|
||||
cubestore = { path = "../cubestore" }
|
||||
@@ -0,0 +1,170 @@
|
||||
//! The cube environment: how Null cubes select key material + transform, and
|
||||
//! how records are sealed/opened against that environment (PDF Package 5).
|
||||
//!
|
||||
//! The PDF's core idea: "exploit Null cubes/layers as environment settings
|
||||
//! that influence how records are read/written." Concretely:
|
||||
//!
|
||||
//! * A [`CubeEnv`] holds one or more [`KeySlot`]s. Each slot points at a
|
||||
//! Null-cube cell whose *body* is raw key material, and says which
|
||||
//! [`TransformId`] to apply.
|
||||
//! * A record is encrypted under a slot selected by a *selector* (a tenant
|
||||
//! byte, or derived from the record's own coordinate). Because the key
|
||||
//! material physically lives in a cube cell, "same CZYX coordinate means
|
||||
//! different plaintext under different Null settings" is literal: re-point
|
||||
//! the env's slot at a different Null cube and the same coordinate opens to
|
||||
//! different data.
|
||||
//! * Sealing/opening stamp and read a [`HEADER_FLAG_ENCRYPTED`] bit on the
|
||||
//! record's [`CubeHeader`], so the store can tell an encrypted record from
|
||||
//! a plaintext one without guessing.
|
||||
|
||||
use cubecoords::{CubeHeader, Czyx};
|
||||
use cubestore::{CubeBackend, CubeStore};
|
||||
|
||||
use crate::transform::{self, CryptoError, Key, KeySlot};
|
||||
|
||||
/// Header flag bit (out-of-band, stored in `CubeHeader.flags` spare range)
|
||||
/// marking a record body as a cubecrypt envelope.
|
||||
pub const HEADER_FLAG_ENCRYPTED: u16 = 1 << 12;
|
||||
|
||||
/// Selects which [`KeySlot`]/transform applies to a record.
|
||||
#[derive(Copy, Clone, Eq, PartialEq, Debug)]
|
||||
pub enum Selector {
|
||||
/// Explicit tenant/slot index into the env's slot list.
|
||||
Slot(u8),
|
||||
/// Derive the slot from a record coordinate (uses `c` axis as the slot
|
||||
/// index, mod slot count). Lets the same env key records per-class.
|
||||
FromCoord(Czyx),
|
||||
}
|
||||
|
||||
/// A cube crypto environment: key material in Null cubes + the transforms they
|
||||
/// select.
|
||||
pub struct CubeEnv {
|
||||
slots: Vec<KeySlot>,
|
||||
/// Salt mixed into every key derivation (env-wide).
|
||||
env_salt: Vec<u8>,
|
||||
}
|
||||
|
||||
impl CubeEnv {
|
||||
/// Build an environment from a list of key slots.
|
||||
pub fn new(slots: Vec<KeySlot>, env_salt: Vec<u8>) -> Self {
|
||||
CubeEnv { slots, env_salt }
|
||||
}
|
||||
|
||||
/// Number of key slots.
|
||||
pub fn len(&self) -> usize {
|
||||
self.slots.len()
|
||||
}
|
||||
|
||||
/// True when there are no slots (a plaintext-only env).
|
||||
pub fn is_empty(&self) -> bool {
|
||||
self.slots.is_empty()
|
||||
}
|
||||
|
||||
/// Resolve which slot a selector maps to.
|
||||
fn slot_for(&self, sel: Selector) -> Option<&KeySlot> {
|
||||
match sel {
|
||||
Selector::Slot(i) => self.slots.get(i as usize),
|
||||
Selector::FromCoord(c) => {
|
||||
if self.slots.is_empty() {
|
||||
None
|
||||
} else {
|
||||
let idx = (c.c as usize) % self.slots.len();
|
||||
Some(&self.slots[idx])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve the concrete key bytes for a slot by reading its Null-cube cell
|
||||
/// from the store.
|
||||
fn key_for<B: CubeBackend>(
|
||||
&self,
|
||||
store: &CubeStore<B>,
|
||||
slot: &KeySlot,
|
||||
) -> Result<Key, EnvError> {
|
||||
let (_, body) = store
|
||||
.get_record(&slot.key_cell)
|
||||
.ok_or(EnvError::KeyCellMissing(slot.key_cell))?;
|
||||
if body.len() < 16 {
|
||||
return Err(EnvError::KeyMaterialTooShort(slot.key_cell));
|
||||
}
|
||||
let mut salt = self.env_salt.clone();
|
||||
salt.extend_from_slice(&slot.salt);
|
||||
Ok(transform::derive_key(&body, &salt))
|
||||
}
|
||||
|
||||
/// Encrypt `plaintext` for a record, selected by `sel`, returning a
|
||||
/// cubecrypt envelope. The returned header has the encrypted bit set and
|
||||
/// `doc_type` optionally re-tagged so tools can spot ciphertext.
|
||||
pub fn seal<B: CubeBackend>(
|
||||
&self,
|
||||
store: &CubeStore<B>,
|
||||
sel: Selector,
|
||||
plaintext: &[u8],
|
||||
) -> Result<Vec<u8>, EnvError> {
|
||||
let slot = self.slot_for(sel).ok_or(EnvError::NoSlotFor(sel))?;
|
||||
let key = self.key_for(store, slot)?;
|
||||
Ok(transform::seal(slot.transform, &key, plaintext))
|
||||
}
|
||||
|
||||
/// Decrypt a cubecrypt envelope previously produced by [`seal`].
|
||||
pub fn open<B: CubeBackend>(
|
||||
&self,
|
||||
store: &CubeStore<B>,
|
||||
sel: Selector,
|
||||
envelope: &[u8],
|
||||
) -> Result<Vec<u8>, EnvError> {
|
||||
let slot = self.slot_for(sel).ok_or(EnvError::NoSlotFor(sel))?;
|
||||
let key = self.key_for(store, slot)?;
|
||||
transform::open(&key, envelope).map_err(EnvError::Crypto)
|
||||
}
|
||||
|
||||
/// Encrypt `plaintext` and write it as a record at `label`, returning the
|
||||
/// header to store alongside it (encrypted flag set).
|
||||
pub fn put_encrypted<B: CubeBackend>(
|
||||
&self,
|
||||
store: &mut CubeStore<B>,
|
||||
label: Czyx,
|
||||
sel: Selector,
|
||||
plaintext: &[u8],
|
||||
mut header: CubeHeader,
|
||||
) -> Result<(), EnvError> {
|
||||
let envelope = self.seal(store, sel, plaintext)?;
|
||||
header.flags.0 |= HEADER_FLAG_ENCRYPTED;
|
||||
if header.doc_type.is_none() {
|
||||
header.doc_type = Some("cubecrypt".into());
|
||||
}
|
||||
header.size_bytes = Some(plaintext.len() as u64);
|
||||
header.refresh_flags();
|
||||
store.put_record(label, &header, &envelope);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Read an encrypted record at `label` and return its plaintext.
|
||||
pub fn get_decrypted<B: CubeBackend>(
|
||||
&self,
|
||||
store: &CubeStore<B>,
|
||||
label: Czyx,
|
||||
sel: Selector,
|
||||
) -> Result<Vec<u8>, EnvError> {
|
||||
let (_, envelope) = store
|
||||
.get_record(&label)
|
||||
.ok_or(EnvError::RecordMissing(label))?;
|
||||
self.open(store, sel, &envelope)
|
||||
}
|
||||
}
|
||||
|
||||
/// Errors from environment resolution / record crypto.
|
||||
#[derive(Clone, Eq, PartialEq, Debug)]
|
||||
pub enum EnvError {
|
||||
/// No key slot matched the selector.
|
||||
NoSlotFor(Selector),
|
||||
/// The Null-cube cell holding key material is absent.
|
||||
KeyCellMissing(Czyx),
|
||||
/// Key material too short to derive a key.
|
||||
KeyMaterialTooShort(Czyx),
|
||||
/// The target record does not exist.
|
||||
RecordMissing(Czyx),
|
||||
/// Underlying crypto failure (bad envelope / auth fail / feature missing).
|
||||
Crypto(CryptoError),
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
//! 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 env;
|
||||
pub mod transform;
|
||||
|
||||
pub use env::{CubeEnv, EnvError, Selector, HEADER_FLAG_ENCRYPTED};
|
||||
pub use transform::{CryptoError, Key, KeySlot, TransformId};
|
||||
|
||||
use cubecoords::{CubeHeader, Czyx};
|
||||
use cubestore::{CubeStore, HashBackend};
|
||||
|
||||
/// 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 {
|
||||
store: CubeStore<HashBackend>,
|
||||
head: Czyx,
|
||||
next: u8,
|
||||
}
|
||||
|
||||
impl AccessLog {
|
||||
/// Bind a log to a Null-cube head coordinate (entries append along X).
|
||||
pub fn new(store: CubeStore<HashBackend>, 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 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));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
//! Crypto transforms available to a cube environment (PDF Package 5).
|
||||
//!
|
||||
//! The PDF names AES-GCM, XTS, and ChaCha20-Poly1305. We never roll our own
|
||||
//! cipher — these are the standard Rust crates. `Aes256Gcm` and
|
||||
//! `ChaCha20Poly1305` are AEAD (authenticated); XTS is *not* AEAD (it is a
|
||||
//! disk-block mode) and is therefore behind the `xts` feature and clearly
|
||||
//! labelled as such.
|
||||
//!
|
||||
//! Every sealing produces a self-describing envelope: a 4-byte magic, a
|
||||
//! 1-byte transform id, a 12-byte nonce, then the ciphertext (+ AEAD tag, or
|
||||
//! XTS ciphertext). The decryptor reads the transform id off the envelope so
|
||||
//! a record encrypted under one environment can be re-opened by name later.
|
||||
|
||||
use aead::{Aead, KeyInit, Payload};
|
||||
use cubecoords::Czyx;
|
||||
use rand::rngs::OsRng;
|
||||
use rand::RngCore;
|
||||
use sha2::{Digest, Sha256};
|
||||
use zeroize::Zeroizing;
|
||||
|
||||
/// Envelope prefix so we can tell a cubecrypt record from raw data.
|
||||
pub const MAGIC: &[u8; 4] = b"CUBE";
|
||||
/// Nonce length for the AEAD transforms (96 bits, the AES-GCM / ChaCha std).
|
||||
pub const NONCE_LEN: usize = 12;
|
||||
|
||||
/// Which transform a sealed record used. Stored in the envelope so decryption
|
||||
/// is self-describing.
|
||||
#[derive(Copy, Clone, Eq, PartialEq, Debug)]
|
||||
#[repr(u8)]
|
||||
pub enum TransformId {
|
||||
/// No encryption (env says plaintext).
|
||||
None = 0,
|
||||
/// AES-256-GCM (AEAD).
|
||||
Aes256Gcm = 1,
|
||||
/// ChaCha20-Poly1305 (AEAD).
|
||||
ChaCha20Poly1305 = 2,
|
||||
/// AES-256-XTS (NOT AEAD — disk-block mode; `xts` feature only).
|
||||
Aes256Xts = 3,
|
||||
}
|
||||
|
||||
impl TransformId {
|
||||
/// Parse a transform id byte (used when opening an envelope).
|
||||
pub fn from_u8(v: u8) -> Option<Self> {
|
||||
match v {
|
||||
0 => Some(TransformId::None),
|
||||
1 => Some(TransformId::Aes256Gcm),
|
||||
2 => Some(TransformId::ChaCha20Poly1305),
|
||||
3 => Some(TransformId::Aes256Xts),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// True for authenticated (AEAD) transforms — these give integrity for
|
||||
/// free, which the PDF's "tamper-evident" goal wants.
|
||||
pub fn is_authenticated(&self) -> bool {
|
||||
matches!(self, TransformId::Aes256Gcm | TransformId::ChaCha20Poly1305)
|
||||
}
|
||||
}
|
||||
|
||||
/// A 32-byte key, zeroized on drop.
|
||||
pub type Key = Zeroizing<[u8; 32]>;
|
||||
|
||||
/// Derive a 32-byte key from arbitrary key material (Null-cube bytes). We do
|
||||
/// not roll a KDF beyond a single SHA-256 of the material; callers should
|
||||
/// store already-strong key bytes in the Null cube. `salt` lets the same key
|
||||
/// material yield different keys per tenant/selector.
|
||||
pub fn derive_key(material: &[u8], salt: &[u8]) -> Key {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(salt);
|
||||
hasher.update(material);
|
||||
let out = hasher.finalize();
|
||||
let mut k = [0u8; 32];
|
||||
k.copy_from_slice(&out);
|
||||
Zeroizing::new(k)
|
||||
}
|
||||
|
||||
/// Seal `plaintext` under `key` with the given transform, returning the
|
||||
/// cubecrypt envelope (magic + transform id + nonce + ciphertext).
|
||||
pub fn seal(transform: TransformId, key: &Key, plaintext: &[u8]) -> Vec<u8> {
|
||||
let mut raw_nonce = [0u8; NONCE_LEN];
|
||||
OsRng.fill_bytes(&mut raw_nonce);
|
||||
|
||||
let mut out = Vec::with_capacity(5 + NONCE_LEN + plaintext.len() + 16);
|
||||
out.extend_from_slice(MAGIC);
|
||||
out.push(transform as u8);
|
||||
out.extend_from_slice(&raw_nonce);
|
||||
|
||||
match transform {
|
||||
TransformId::None => {
|
||||
out.extend_from_slice(plaintext);
|
||||
}
|
||||
TransformId::Aes256Gcm => {
|
||||
use aes_gcm::{Aes256Gcm, Nonce as GcmNonce};
|
||||
let cipher = Aes256Gcm::new_from_slice(&**key).expect("key is 32 bytes");
|
||||
let nonce = GcmNonce::from_slice(&raw_nonce);
|
||||
let ct: Vec<u8> = cipher
|
||||
.encrypt(
|
||||
nonce,
|
||||
Payload {
|
||||
msg: plaintext,
|
||||
aad: &[],
|
||||
},
|
||||
)
|
||||
.expect("aes-gcm encrypt");
|
||||
out.extend_from_slice(&ct);
|
||||
}
|
||||
TransformId::ChaCha20Poly1305 => {
|
||||
use chacha20poly1305::{ChaCha20Poly1305, Nonce as ChaNonce};
|
||||
let cipher = ChaCha20Poly1305::new_from_slice(&**key).expect("key is 32 bytes");
|
||||
let nonce = ChaNonce::from_slice(&raw_nonce);
|
||||
let ct: Vec<u8> = cipher
|
||||
.encrypt(
|
||||
nonce,
|
||||
Payload {
|
||||
msg: plaintext,
|
||||
aad: &[],
|
||||
},
|
||||
)
|
||||
.expect("chacha20 encrypt");
|
||||
out.extend_from_slice(&ct);
|
||||
}
|
||||
TransformId::Aes256Xts => {
|
||||
// XTS is a disk/block mode, not AEAD. We implement the standard
|
||||
// XTS construction directly over the vetted `aes` AES-256 block
|
||||
// cipher (no feature gate, no external XTS crate).
|
||||
let k1 = derive_key(&**key, b"xts-k1");
|
||||
let k2 = derive_key(&**key, b"xts-k2");
|
||||
let mut buf = plaintext.to_vec();
|
||||
let rem = buf.len() % 16;
|
||||
if rem != 0 {
|
||||
buf.extend(std::iter::repeat_n(0u8, 16 - rem));
|
||||
}
|
||||
xts_encrypt::<aes::Aes256>(&k1, &k2, 0u128, &mut buf);
|
||||
out.extend_from_slice(&buf);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Open a cubecrypt envelope, returning the plaintext. Errors if the magic is
|
||||
/// wrong, the transform id is unknown, or (for AEAD) the tag fails to verify.
|
||||
pub fn open(key: &Key, envelope: &[u8]) -> Result<Vec<u8>, CryptoError> {
|
||||
if envelope.len() < 5 + NONCE_LEN {
|
||||
return Err(CryptoError::EnvelopeTooShort);
|
||||
}
|
||||
if &envelope[0..4] != MAGIC {
|
||||
return Err(CryptoError::BadMagic);
|
||||
}
|
||||
let transform = TransformId::from_u8(envelope[4]).ok_or(CryptoError::UnknownTransform)?;
|
||||
let raw_nonce = &envelope[5..5 + NONCE_LEN];
|
||||
let ct = &envelope[5 + NONCE_LEN..];
|
||||
|
||||
match transform {
|
||||
TransformId::None => Ok(ct.to_vec()),
|
||||
TransformId::Aes256Gcm => {
|
||||
use aes_gcm::{Aes256Gcm, Nonce as GcmNonce};
|
||||
let cipher = Aes256Gcm::new_from_slice(&**key).expect("key is 32 bytes");
|
||||
let nonce = GcmNonce::from_slice(raw_nonce);
|
||||
let pt: Vec<u8> = cipher
|
||||
.decrypt(nonce, Payload { msg: ct, aad: &[] })
|
||||
.map_err(|_| CryptoError::AuthFailed)?;
|
||||
Ok(pt)
|
||||
}
|
||||
TransformId::ChaCha20Poly1305 => {
|
||||
use chacha20poly1305::{ChaCha20Poly1305, Nonce as ChaNonce};
|
||||
let cipher = ChaCha20Poly1305::new_from_slice(&**key).expect("key is 32 bytes");
|
||||
let nonce = ChaNonce::from_slice(raw_nonce);
|
||||
let pt: Vec<u8> = cipher
|
||||
.decrypt(nonce, Payload { msg: ct, aad: &[] })
|
||||
.map_err(|_| CryptoError::AuthFailed)?;
|
||||
Ok(pt)
|
||||
}
|
||||
TransformId::Aes256Xts => {
|
||||
// XTS is a disk/block mode, not AEAD. Standard construction over
|
||||
// the `aes` AES-256 block cipher.
|
||||
let k1 = derive_key(&**key, b"xts-k1");
|
||||
let k2 = derive_key(&**key, b"xts-k2");
|
||||
let mut buf = ct.to_vec();
|
||||
xts_decrypt::<aes::Aes256>(&k1, &k2, 0u128, &mut buf);
|
||||
while buf.last() == Some(&0) {
|
||||
buf.pop();
|
||||
}
|
||||
Ok(buf)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Why an [`open`] (or seal) can fail.
|
||||
#[derive(Clone, Eq, PartialEq, Debug)]
|
||||
pub enum CryptoError {
|
||||
/// Envelope shorter than the minimum header.
|
||||
EnvelopeTooShort,
|
||||
/// Magic bytes did not match (not a cubecrypt record).
|
||||
BadMagic,
|
||||
/// Transform id byte not recognised.
|
||||
UnknownTransform,
|
||||
/// AEAD authentication tag failed (tamper / wrong key / wrong env).
|
||||
AuthFailed,
|
||||
/// A transform needs a cargo feature that is not enabled.
|
||||
FeatureMissing(&'static str),
|
||||
}
|
||||
|
||||
/// XTS disk-encryption mode over a 128-bit-block cipher (here AES-256).
|
||||
///
|
||||
/// XTS is *not* an AEAD — it gives no authentication — which is exactly why
|
||||
/// we keep it behind the explicit `Aes256Xts` transform id and document it as
|
||||
/// a disk/at-rest mode. The block primitive (`aes::Aes256`) is the vetted
|
||||
/// RustCrypto implementation; only the XEX key-wrapping + tweak schedule
|
||||
/// below is ours, and it is the standard IEEE P1619 construction.
|
||||
///
|
||||
/// `key1`/`key2` are the two 256-bit keys (data + tweak), `sector` the
|
||||
/// 16-byte-multiple plaintext/ciphertext (mutated in place), `index` the
|
||||
/// Multiply a 128-bit value by α in GF(2^128) (the XTS tweak step). The field
|
||||
/// is represented little-endian in the 16-byte block: bit 0 is the LSB of
|
||||
/// byte 0. Standard "shift left, xor 0x87 if the top bit was set" routine.
|
||||
fn gf_mult_alpha(block: &[u8; 16]) -> [u8; 16] {
|
||||
let mut out = [0u8; 16];
|
||||
let mut carry = 0u8;
|
||||
for i in 0..16 {
|
||||
let b = block[i];
|
||||
out[i] = (b << 1) | carry;
|
||||
carry = b >> 7;
|
||||
}
|
||||
// If the MSB of the last byte (bit 127) was set, reduce by the XTS
|
||||
// polynomial constant 0x87 (applied to byte 0, LSB-first).
|
||||
if block[15] & 0x80 != 0 {
|
||||
out[0] ^= 0x87;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Compute the initial tweak block for `sector_index` (little-endian integer
|
||||
/// fed through the tweak cipher `c2`).
|
||||
fn initial_tweak<C: aes::cipher::BlockEncrypt>(c2: &C, sector_index: u128) -> [u8; 16] {
|
||||
use aes::cipher::generic_array::GenericArray;
|
||||
let mut t = sector_index.to_le_bytes();
|
||||
c2.encrypt_block(GenericArray::from_mut_slice(&mut t));
|
||||
t
|
||||
}
|
||||
|
||||
/// XTS-encrypt `sector` in place. `sector.len()` must be a multiple of 16.
|
||||
pub fn xts_encrypt<
|
||||
C: aes::cipher::BlockEncrypt + aes::cipher::BlockDecrypt + aes::cipher::KeyInit,
|
||||
>(
|
||||
key1: &Key,
|
||||
key2: &Key,
|
||||
sector_index: u128,
|
||||
sector: &mut [u8],
|
||||
) {
|
||||
let c1 = C::new_from_slice(&key1[..]).expect("key1 is 32 bytes");
|
||||
let c2 = C::new_from_slice(&key2[..]).expect("key2 is 32 bytes");
|
||||
xts_apply(&c1, &c2, sector_index, sector, true)
|
||||
}
|
||||
|
||||
/// XTS-decrypt `sector` in place. `sector.len()` must be a multiple of 16.
|
||||
pub fn xts_decrypt<
|
||||
C: aes::cipher::BlockEncrypt + aes::cipher::BlockDecrypt + aes::cipher::KeyInit,
|
||||
>(
|
||||
key1: &Key,
|
||||
key2: &Key,
|
||||
sector_index: u128,
|
||||
sector: &mut [u8],
|
||||
) {
|
||||
let c1 = C::new_from_slice(&key1[..]).expect("key1 is 32 bytes");
|
||||
let c2 = C::new_from_slice(&key2[..]).expect("key2 is 32 bytes");
|
||||
xts_apply(&c1, &c2, sector_index, sector, false)
|
||||
}
|
||||
|
||||
fn xts_apply<C: aes::cipher::BlockEncrypt + aes::cipher::BlockDecrypt + aes::cipher::KeyInit>(
|
||||
c1: &C,
|
||||
c2: &C,
|
||||
sector_index: u128,
|
||||
sector: &mut [u8],
|
||||
encrypt: bool,
|
||||
) {
|
||||
assert!(sector.len().is_multiple_of(16) && !sector.is_empty());
|
||||
let mut tweak = initial_tweak(c2, sector_index);
|
||||
for chunk in sector.chunks_mut(16) {
|
||||
let mut block = [0u8; 16];
|
||||
block.copy_from_slice(chunk);
|
||||
// PP = P xor T
|
||||
let mut pp = [0u8; 16];
|
||||
for i in 0..16 {
|
||||
pp[i] = block[i] ^ tweak[i];
|
||||
}
|
||||
let mut cc = pp;
|
||||
if encrypt {
|
||||
c1.encrypt_block(aes::cipher::generic_array::GenericArray::from_mut_slice(
|
||||
&mut cc,
|
||||
));
|
||||
} else {
|
||||
c1.decrypt_block(aes::cipher::generic_array::GenericArray::from_mut_slice(
|
||||
&mut cc,
|
||||
));
|
||||
}
|
||||
// C = CC xor T
|
||||
for i in 0..16 {
|
||||
chunk[i] = cc[i] ^ tweak[i];
|
||||
}
|
||||
tweak = gf_mult_alpha(&tweak);
|
||||
}
|
||||
}
|
||||
|
||||
/// Where key material lives: a Null-cube cell holding 32+ bytes, plus the
|
||||
/// transform it selects. A `CubeEnv` references one or more of these so the
|
||||
/// "same CZYX, different plaintext under different Null settings" property
|
||||
/// falls out naturally — re-point the env at a different Null cube and the
|
||||
/// coordinate decrypts to different data.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct KeySlot {
|
||||
/// The Null-cube coordinate holding raw key material.
|
||||
pub key_cell: Czyx,
|
||||
/// Transform this slot applies.
|
||||
pub transform: TransformId,
|
||||
/// Optional salt so one key material yields per-tenant keys.
|
||||
pub salt: Vec<u8>,
|
||||
}
|
||||
Reference in New Issue
Block a user