diff --git a/Cargo.lock b/Cargo.lock index b39c030..364bd40 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -287,6 +287,16 @@ dependencies = [ name = "elf" version = "0.1.0" +[[package]] +name = "filecrypt" +version = "0.1.0" +dependencies = [ + "blake2", + "chacha20", + "chacha20poly1305", + "zeroize", +] + [[package]] name = "gatekeeper" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index e29e40c..3d979dc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -15,6 +15,7 @@ members = [ "crates/elf", "crates/manifest", "crates/keystore", + "crates/filecrypt", "crates/ports/entropy", "crates/drivers/rng/virtio", "crates/tools/app-image", @@ -76,11 +77,13 @@ domain = { path = "crates/domain" } elf = { path = "crates/elf" } manifest = { path = "crates/manifest" } keystore = { path = "crates/keystore" } +filecrypt = { path = "crates/filecrypt" } # Audited RustCrypto implementations; never our own. argon2 = { version = "0.5", default-features = false } chacha20poly1305 = { version = "0.10", default-features = false } blake2 = { version = "0.10", default-features = false } zeroize = { version = "1", default-features = false } +chacha20 = { version = "0.9", default-features = false } port-entropy = { path = "crates/ports/entropy" } driver-rng-virtio = { path = "crates/drivers/rng/virtio" } platform = { path = "crates/platform" } diff --git a/crates/filecrypt/Cargo.toml b/crates/filecrypt/Cargo.toml new file mode 100644 index 0000000..4dbc97e --- /dev/null +++ b/crates/filecrypt/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "filecrypt" +version = "0.1.0" +edition = "2024" + +[dependencies] +chacha20poly1305 = { workspace = true } +chacha20 = { workspace = true } +blake2 = { workspace = true } +zeroize = { workspace = true } diff --git a/crates/filecrypt/src/drbg.rs b/crates/filecrypt/src/drbg.rs new file mode 100644 index 0000000..4e0d6af --- /dev/null +++ b/crates/filecrypt/src/drbg.rs @@ -0,0 +1,39 @@ +//! Randomness from a seed: ChaCha20's keystream, re-keyed after every +//! request (fast key erasure), so what came out before cannot be worked +//! back from the state. The seed must be fresh each boot (the gatekeeper +//! draws it from the hardware), or nonces would repeat. + +use chacha20::ChaCha20; +use chacha20::cipher::{KeyIvInit, StreamCipher}; +use zeroize::Zeroize; + +use crate::{KEY_SIZE, Random}; + +pub struct Drbg { + key: [u8; KEY_SIZE], +} + +impl Drbg { + pub fn new(seed: [u8; KEY_SIZE]) -> Self { + Self { key: seed } + } +} + +impl Random for Drbg { + fn fill(&mut self, buf: &mut [u8]) { + let mut stream = ChaCha20::new((&self.key).into(), &[0u8; 12].into()); + // The next key first, then the output. + let mut next = [0u8; KEY_SIZE]; + stream.apply_keystream(&mut next); + buf.fill(0); + stream.apply_keystream(buf); + self.key = next; + next.zeroize(); + } +} + +impl Drop for Drbg { + fn drop(&mut self) { + self.key.zeroize(); + } +} diff --git a/crates/filecrypt/src/lib.rs b/crates/filecrypt/src/lib.rs new file mode 100644 index 0000000..112ae4e --- /dev/null +++ b/crates/filecrypt/src/lib.rs @@ -0,0 +1,186 @@ +//! How a file in an encrypted area looks on disk (see the vision's +//! Encryption note): a header with the file's salt, then its contents in +//! chunks, each sealed with XChaCha20-Poly1305 under a fresh random nonce, +//! bound to its place in the file and to whether it is the last one. +//! Changing, swapping or cutting chunks off is detected; restoring an old +//! copy of a whole file is not. Pure: no I/O. +//! +//! Keys: the master key → an area's key (`area_key`, keyed BLAKE2s over +//! the area's name) → each file's key (over its salt). + +#![no_std] +#![forbid(unsafe_code)] + +mod drbg; + +#[cfg(test)] +mod tests; + +pub use drbg::Drbg; + +use blake2::Blake2sMac256; +use blake2::digest::Mac; +use chacha20poly1305::aead::{AeadInPlace, KeyInit}; +use chacha20poly1305::{Tag, XChaCha20Poly1305, XNonce}; +use zeroize::Zeroize; + +pub const KEY_SIZE: usize = 32; +const MAGIC: &[u8; 8] = b"KOSFILE1"; +const SALT_SIZE: usize = 24; +/// The header: magic and salt. +pub const HEADER: usize = 8 + SALT_SIZE; +const NONCE: usize = 24; +const TAG: usize = 16; +/// What a chunk adds to what it holds. +pub const OVERHEAD: usize = NONCE + TAG; +/// A sealed chunk on disk, the last one possibly shorter. +pub const CHUNK: usize = 4096; +/// What a full chunk holds. +pub const PLAIN_CHUNK: usize = CHUNK - OVERHEAD; + +/// A key, wiped from memory when dropped. +#[derive(Clone)] +pub struct Key(pub [u8; KEY_SIZE]); + +impl Drop for Key { + fn drop(&mut self) { + self.0.zeroize(); + } +} + +fn mac(key: &[u8; KEY_SIZE], parts: &[&[u8]]) -> Key { + let mut m = + ::new_from_slice(key).expect("BLAKE2s takes a 32-byte key"); + for p in parts { + m.update(p); + } + Key(m.finalize().into_bytes().into()) +} + +/// The key of the area named `area` (`home`, `data/kos.notes`, ...): +/// each app's data is noise under another app's key. +pub fn area_key(master: &Key, area: &[u8]) -> Key { + mac(&master.0, &[b"kos/storage/v1/area/", area]) +} + +/// Randomness for salts and nonces. +pub trait Random { + fn fill(&mut self, buf: &mut [u8]); +} + +/// A file's header: its salt, which with its area's key makes its key. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub struct Header { + salt: [u8; SALT_SIZE], +} + +impl Header { + pub fn new(rng: &mut impl Random) -> Self { + let mut salt = [0u8; SALT_SIZE]; + rng.fill(&mut salt); + Self { salt } + } + + pub fn encode(&self) -> [u8; HEADER] { + let mut out = [0u8; HEADER]; + out[..8].copy_from_slice(MAGIC); + out[8..].copy_from_slice(&self.salt); + out + } + + /// `None` unless it begins an encrypted file (a plaintext file, say). + pub fn decode(bytes: &[u8]) -> Option { + let bytes = bytes.get(..HEADER)?; + if &bytes[..8] != MAGIC { + return None; + } + let mut salt = [0u8; SALT_SIZE]; + salt.copy_from_slice(&bytes[8..]); + Some(Self { salt }) + } + + pub fn file_key(&self, area: &Key) -> Key { + mac(&area.0, &[b"kos/storage/v1/file/", &self.salt]) + } +} + +/// How many plaintext bytes a file holding `disk` bytes has; `None` if no +/// encrypted file is that long (a chunk cut short). +pub fn plain_len(disk: usize) -> Option { + let body = disk.checked_sub(HEADER)?; + let (full, rest) = (body / CHUNK, body % CHUNK); + match rest { + // The last chunk is full. + 0 if full > 0 => Some(full * PLAIN_CHUNK), + r if r >= OVERHEAD => Some(full * PLAIN_CHUNK + r - OVERHEAD), + _ => None, + } +} + +/// How many chunks hold `plain` bytes: always at least one, so even an +/// empty file has a last chunk to say it is complete. +pub fn chunks(plain: usize) -> usize { + plain.div_ceil(PLAIN_CHUNK).max(1) +} + +/// What a file holding `plain` bytes takes on disk. +pub fn disk_len(plain: usize) -> usize { + HEADER + plain + chunks(plain) * OVERHEAD +} + +/// Where chunk `index` starts on disk. +pub fn chunk_offset(index: usize) -> usize { + HEADER + index * CHUNK +} + +/// Binds a chunk to its place, and to being the last or not. +fn associated(index: u64, last: bool) -> [u8; 9] { + let mut ad = [0u8; 9]; + ad[..8].copy_from_slice(&index.to_le_bytes()); + ad[8] = u8::from(last); + ad +} + +/// Seals `plain` (at most `PLAIN_CHUNK` bytes) as chunk `index` into `out` +/// (room for `plain.len() + OVERHEAD`); its length. +pub fn seal( + key: &Key, + index: u64, + last: bool, + plain: &[u8], + out: &mut [u8], + rng: &mut impl Random, +) -> usize { + let n = plain.len().min(PLAIN_CHUNK); + let (nonce, rest) = out.split_at_mut(NONCE); + rng.fill(nonce); + let (body, tag) = rest.split_at_mut(n); + body.copy_from_slice(&plain[..n]); + let t = XChaCha20Poly1305::new((&key.0).into()) + .encrypt_in_place_detached(XNonce::from_slice(nonce), &associated(index, last), body) + .expect("a chunk is far below XChaCha20-Poly1305's limits"); + tag[..TAG].copy_from_slice(&t); + n + OVERHEAD +} + +/// Opens chunk `index` (`sealed` as read from disk) into `out`; its length, +/// or `None` if it is not that chunk of that file, unaltered. +pub fn open(key: &Key, index: u64, last: bool, sealed: &[u8], out: &mut [u8]) -> Option { + let n = sealed.len().checked_sub(OVERHEAD)?; + let (nonce, rest) = sealed.split_at(NONCE); + let (body, tag) = rest.split_at(n); + let out = out.get_mut(..n)?; + out.copy_from_slice(body); + let ok = XChaCha20Poly1305::new((&key.0).into()) + .decrypt_in_place_detached( + XNonce::from_slice(nonce), + &associated(index, last), + out, + Tag::from_slice(tag), + ) + .is_ok(); + if !ok { + out.zeroize(); + } + ok.then_some(n) +} diff --git a/crates/filecrypt/src/tests.rs b/crates/filecrypt/src/tests.rs new file mode 100644 index 0000000..59281ea --- /dev/null +++ b/crates/filecrypt/src/tests.rs @@ -0,0 +1,130 @@ +extern crate std; + +use crate::*; +use std::vec; + +fn master() -> Key { + Key([7; KEY_SIZE]) +} + +fn rng() -> Drbg { + Drbg::new([1; KEY_SIZE]) +} + +fn sealed(key: &Key, index: u64, last: bool, plain: &[u8]) -> std::vec::Vec { + let mut out = vec![0u8; plain.len() + OVERHEAD]; + let n = seal(key, index, last, plain, &mut out, &mut rng()); + out.truncate(n); + out +} + +#[test] +fn a_chunk_opens_only_as_itself() { + let key = Header::new(&mut rng()).file_key(&area_key(&master(), b"home")); + let chunk = sealed(&key, 3, true, b"hello, k-os"); + let mut out = [0u8; PLAIN_CHUNK]; + assert_eq!(open(&key, 3, true, &chunk, &mut out), Some(11)); + assert_eq!(&out[..11], b"hello, k-os"); + assert_eq!( + open(&key, 4, true, &chunk, &mut out), + None, + "moved elsewhere in the file" + ); + assert_eq!( + open(&key, 3, false, &chunk, &mut out), + None, + "no longer the last: something was cut off or added" + ); + let other = Header::new(&mut Drbg::new([2; KEY_SIZE])).file_key(&area_key(&master(), b"home")); + assert_eq!( + open(&other, 3, true, &chunk, &mut out), + None, + "another file's key" + ); + for at in [0, 23, 24, 30, chunk.len() - 1] { + let mut bent = chunk.clone(); + bent[at] ^= 1; + assert_eq!( + open(&key, 3, true, &bent, &mut out), + None, + "byte {at} changed" + ); + } +} + +#[test] +fn one_apps_area_key_opens_nothing_of_anothers() { + let header = Header::new(&mut rng()); + let notes = header.file_key(&area_key(&master(), b"data/kos.notes")); + let chat = header.file_key(&area_key(&master(), b"data/kos.chat")); + let chunk = sealed(¬es, 0, true, b"private"); + let mut out = [0u8; 16]; + assert_eq!(open(&chat, 0, true, &chunk, &mut out), None); + let other_master = header.file_key(&area_key(&Key([8; KEY_SIZE]), b"data/kos.notes")); + assert_eq!( + open(&other_master, 0, true, &chunk, &mut out), + None, + "another master key (a wiped device)" + ); +} + +#[test] +fn every_seal_takes_a_fresh_nonce() { + let key = Key([3; KEY_SIZE]); + let mut r = rng(); + let (mut a, mut b) = ([0u8; 64], [0u8; 64]); + seal(&key, 0, true, b"same", &mut a, &mut r); + seal(&key, 0, true, b"same", &mut b, &mut r); + assert_ne!(a, b); +} + +#[test] +fn headers_mark_encrypted_files() { + let h = Header::new(&mut rng()); + assert_eq!(Header::decode(&h.encode()), Some(h)); + assert_eq!( + Header::decode(b"Welcome to k-os. This is a plaintext file."), + None + ); + assert_eq!(Header::decode(b"KOSFILE"), None, "too short"); +} + +#[test] +fn sizes_round_trip_across_chunk_boundaries() { + for plain in [ + 0, + 1, + 100, + PLAIN_CHUNK - 1, + PLAIN_CHUNK, + PLAIN_CHUNK + 1, + 3 * PLAIN_CHUNK, + 3 * PLAIN_CHUNK + 7, + ] { + let disk = disk_len(plain); + assert_eq!(plain_len(disk), Some(plain), "{plain}"); + } + assert_eq!(chunks(0), 1, "an empty file still has its last chunk"); + for cut in [0, HEADER, HEADER + OVERHEAD - 1, HEADER + CHUNK + 5] { + assert_eq!(plain_len(cut), None, "{cut} bytes is no encrypted file"); + } +} + +#[test] +fn the_drbg_never_repeats_and_depends_on_its_seed() { + let mut a = Drbg::new([1; KEY_SIZE]); + let (mut x, mut y) = ([0u8; 32], [0u8; 32]); + a.fill(&mut x); + a.fill(&mut y); + assert_ne!(x, y); + let mut b = Drbg::new([1; KEY_SIZE]); + let mut z = [0u8; 32]; + b.fill(&mut z); + assert_eq!( + x, z, + "the same seed, the same stream (so the seed must be fresh)" + ); + let mut c = Drbg::new([2; KEY_SIZE]); + c.fill(&mut z); + assert_ne!(x, z); +}