feat(filecrypt): per-area keys (BLAKE2s), per-file salt, XChaCha20-Poly1305 chunks bound to index+last, seed DRBG w/ fast key erasure
This commit is contained in:
10
Cargo.lock
generated
10
Cargo.lock
generated
@@ -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"
|
||||
|
||||
@@ -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" }
|
||||
|
||||
10
crates/filecrypt/Cargo.toml
Normal file
10
crates/filecrypt/Cargo.toml
Normal file
@@ -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 }
|
||||
39
crates/filecrypt/src/drbg.rs
Normal file
39
crates/filecrypt/src/drbg.rs
Normal file
@@ -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();
|
||||
}
|
||||
}
|
||||
186
crates/filecrypt/src/lib.rs
Normal file
186
crates/filecrypt/src/lib.rs
Normal file
@@ -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 =
|
||||
<Blake2sMac256 as KeyInit>::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<Self> {
|
||||
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<usize> {
|
||||
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<usize> {
|
||||
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)
|
||||
}
|
||||
130
crates/filecrypt/src/tests.rs
Normal file
130
crates/filecrypt/src/tests.rs
Normal file
@@ -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<u8> {
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user