feat(storage): encrypted areas layer (home, system, data/<id>) over any FS; locked until key; plaintext refused; tamper/truncation detected; tests on memory + real ext4
This commit is contained in:
275
crates/servers/storage-server/src/tests/encrypted.rs
Normal file
275
crates/servers/storage-server/src/tests/encrypted.rs
Normal file
@@ -0,0 +1,275 @@
|
||||
extern crate std;
|
||||
|
||||
use crate::encrypted::{Encrypted, area_of};
|
||||
use crate::filesystem::{DirEntry, FileHandle, FileName, FileSystem};
|
||||
use std::collections::BTreeMap;
|
||||
use std::string::{String, ToString};
|
||||
use std::vec;
|
||||
use std::vec::Vec;
|
||||
|
||||
/// Files in memory, with positions: what the layer sits on in tests.
|
||||
#[derive(Default)]
|
||||
struct Memory {
|
||||
files: BTreeMap<String, Vec<u8>>,
|
||||
dirs: Vec<String>,
|
||||
open: BTreeMap<u16, (String, usize)>,
|
||||
next: u16,
|
||||
}
|
||||
|
||||
fn key(path: &[u8]) -> String {
|
||||
String::from_utf8_lossy(path.strip_prefix(b"/").unwrap_or(path)).into_owned()
|
||||
}
|
||||
|
||||
impl Memory {
|
||||
fn handle(&mut self, path: String) -> FileHandle {
|
||||
self.next = (self.next + 1) % 16;
|
||||
self.open.insert(self.next, (path, 0));
|
||||
FileHandle::new(self.next)
|
||||
}
|
||||
}
|
||||
|
||||
impl FileSystem for Memory {
|
||||
fn open(&mut self, path: &[u8]) -> Option<FileHandle> {
|
||||
let k = key(path);
|
||||
self.files.contains_key(&k).then(|| self.handle(k))
|
||||
}
|
||||
fn create(&mut self, path: &[u8]) -> Option<FileHandle> {
|
||||
let k = key(path);
|
||||
if self.files.contains_key(&k) {
|
||||
return None;
|
||||
}
|
||||
self.files.insert(k.clone(), Vec::new());
|
||||
Some(self.handle(k))
|
||||
}
|
||||
fn read(&mut self, h: FileHandle, buf: &mut [u8]) -> Option<usize> {
|
||||
let (path, pos) = self.open.get_mut(&h.raw())?;
|
||||
let data = &self.files[path.as_str()];
|
||||
let n = buf.len().min(data.len().saturating_sub(*pos));
|
||||
buf[..n].copy_from_slice(&data[*pos..*pos + n]);
|
||||
*pos += n;
|
||||
Some(n)
|
||||
}
|
||||
fn write(&mut self, h: FileHandle, buf: &[u8]) -> Option<usize> {
|
||||
let (path, pos) = self.open.get_mut(&h.raw())?;
|
||||
let data = self.files.get_mut(path.as_str())?;
|
||||
if data.len() < *pos + buf.len() {
|
||||
data.resize(*pos + buf.len(), 0);
|
||||
}
|
||||
data[*pos..*pos + buf.len()].copy_from_slice(buf);
|
||||
*pos += buf.len();
|
||||
Some(buf.len())
|
||||
}
|
||||
fn seek(&mut self, h: FileHandle, offset: usize) -> bool {
|
||||
let Some((path, pos)) = self.open.get_mut(&h.raw()) else { return false };
|
||||
if offset > self.files[path.as_str()].len() {
|
||||
return false;
|
||||
}
|
||||
*pos = offset;
|
||||
true
|
||||
}
|
||||
fn close(&mut self, h: FileHandle) {
|
||||
self.open.remove(&h.raw());
|
||||
}
|
||||
fn file_size(&mut self, h: FileHandle) -> Option<usize> {
|
||||
let (path, _) = self.open.get(&h.raw())?;
|
||||
Some(self.files[path.as_str()].len())
|
||||
}
|
||||
fn list_dir(&mut self, path: &[u8], entries: &mut [DirEntry]) -> Option<usize> {
|
||||
let prefix = std::format!("{}/", key(path));
|
||||
let mut n = 0;
|
||||
for (name, data) in self.files.iter().filter(|(k, _)| k.starts_with(&prefix)) {
|
||||
entries[n] = DirEntry { name: FileName::from_bytes(name[prefix.len()..].as_bytes()), size: data.len(), is_dir: false };
|
||||
n += 1;
|
||||
}
|
||||
Some(n)
|
||||
}
|
||||
fn delete(&mut self, path: &[u8]) -> bool {
|
||||
self.files.remove(&key(path)).is_some()
|
||||
}
|
||||
fn mkdir(&mut self, path: &[u8]) -> bool {
|
||||
self.dirs.push(key(path));
|
||||
true
|
||||
}
|
||||
fn rmdir(&mut self, path: &[u8]) -> bool {
|
||||
let k = key(path);
|
||||
let before = self.dirs.len();
|
||||
self.dirs.retain(|d| *d != k);
|
||||
self.dirs.len() < before
|
||||
}
|
||||
}
|
||||
|
||||
const MASTER: [u8; 32] = [9; 32];
|
||||
|
||||
fn unlocked() -> Encrypted<Memory> {
|
||||
let mut fs = Encrypted::new(Memory::default());
|
||||
fs.unlock(&MASTER, &[3; 32]);
|
||||
fs
|
||||
}
|
||||
|
||||
fn put(fs: &mut impl FileSystem, path: &[u8], data: &[u8]) {
|
||||
let h = fs.create(path).expect("create");
|
||||
assert_eq!(fs.write(h, data), Some(data.len()));
|
||||
fs.close(h);
|
||||
}
|
||||
|
||||
fn get(fs: &mut impl FileSystem, path: &[u8]) -> Option<Vec<u8>> {
|
||||
let h = fs.open(path)?;
|
||||
let size = fs.file_size(h)?;
|
||||
let mut out = vec![0u8; size + 10];
|
||||
let n = fs.read(h, &mut out);
|
||||
fs.close(h);
|
||||
out.truncate(n?);
|
||||
Some(out)
|
||||
}
|
||||
|
||||
fn on_disk(fs: &Encrypted<Memory>, path: &str) -> Vec<u8> {
|
||||
fs.inner().files[path].clone()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn areas_are_told_by_path() {
|
||||
for (p, a) in [
|
||||
(&b"home/readme.txt"[..], "sealed"),
|
||||
(b"/home", "sealed"),
|
||||
(b"system/permissions/kos.snake", "sealed"),
|
||||
(b"data/kos.notes/a", "sealed"),
|
||||
(b"data/kos.notes", "neither"),
|
||||
(b"data", "neither"),
|
||||
(b"data/", "neither"),
|
||||
(b"bin/shell", "plain"),
|
||||
(b"apps/kos.cat/cat", "plain"),
|
||||
(b"factory/home/readme.txt", "plain"),
|
||||
(b"homework/x", "plain"),
|
||||
(b"selftest.txt", "plain"),
|
||||
] {
|
||||
assert_eq!(area_of(p), a, "{}", String::from_utf8_lossy(p));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sealed_files_read_back_and_are_noise_on_disk() {
|
||||
let mut fs = unlocked();
|
||||
let text = b"Dear diary, nobody else may read this.";
|
||||
put(&mut fs, b"home/diary.txt", text);
|
||||
assert_eq!(get(&mut fs, b"home/diary.txt").unwrap(), text);
|
||||
let disk = on_disk(&fs, "home/diary.txt");
|
||||
assert!(disk.starts_with(b"KOSFILE1"));
|
||||
assert!(!disk.windows(5).any(|w| w == b"diary"), "the words are not on the disk");
|
||||
assert_eq!(disk.len(), filecrypt::disk_len(text.len()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plaintext_areas_stay_plaintext() {
|
||||
let mut fs = unlocked();
|
||||
put(&mut fs, b"bin/tool", b"\x7fELF");
|
||||
assert_eq!(on_disk(&fs, "bin/tool"), b"\x7fELF");
|
||||
assert_eq!(get(&mut fs, b"bin/tool").unwrap(), b"\x7fELF");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn big_files_and_writes_across_chunks_round_trip() {
|
||||
let mut fs = unlocked();
|
||||
let big: Vec<u8> = (0..3 * filecrypt::PLAIN_CHUNK + 123).map(|i| (i % 251) as u8).collect();
|
||||
put(&mut fs, b"data/kos.notes/big", &big);
|
||||
assert_eq!(get(&mut fs, b"data/kos.notes/big").unwrap(), big);
|
||||
// Overwrite a stretch spanning two chunks in the middle.
|
||||
let h = fs.open(b"data/kos.notes/big").unwrap();
|
||||
let at = filecrypt::PLAIN_CHUNK - 10;
|
||||
assert!(fs.seek(h, at));
|
||||
assert_eq!(fs.write(h, &[0xAA; 30]), Some(30));
|
||||
fs.close(h);
|
||||
let mut expect = big.clone();
|
||||
expect[at..at + 30].fill(0xAA);
|
||||
assert_eq!(get(&mut fs, b"data/kos.notes/big").unwrap(), expect);
|
||||
// Grow exactly from a chunk boundary: the old last chunk stops being last.
|
||||
let mut fs = unlocked();
|
||||
let full = vec![7u8; filecrypt::PLAIN_CHUNK];
|
||||
put(&mut fs, b"home/f", &full);
|
||||
let h = fs.open(b"home/f").unwrap();
|
||||
assert!(fs.seek(h, filecrypt::PLAIN_CHUNK));
|
||||
assert_eq!(fs.write(h, b"more"), Some(4));
|
||||
fs.close(h);
|
||||
let mut expect = full.clone();
|
||||
expect.extend_from_slice(b"more");
|
||||
assert_eq!(get(&mut fs, b"home/f").unwrap(), expect);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn one_app_cannot_read_anothers_file_even_moved_into_its_folder() {
|
||||
let mut fs = unlocked();
|
||||
put(&mut fs, b"data/kos.notes/secret", b"notes only");
|
||||
let stolen = on_disk(&fs, "data/kos.notes/secret");
|
||||
fs.inner_mut_for_test().files.insert("data/kos.chat/secret".to_string(), stolen);
|
||||
let h = fs.open(b"data/kos.chat/secret").expect("it looks like a sealed file");
|
||||
let mut out = [0u8; 64];
|
||||
assert_eq!(fs.read(h, &mut out), None, "but it opens only under kos.notes' key");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn locked_the_areas_refuse_everything() {
|
||||
let mut fs = unlocked();
|
||||
put(&mut fs, b"home/x", b"y");
|
||||
let mut locked = Encrypted::new(fs.into_inner());
|
||||
assert!(locked.open(b"home/x").is_none());
|
||||
assert!(locked.create(b"home/z").is_none());
|
||||
assert!(!locked.delete(b"home/x"));
|
||||
assert!(!locked.mkdir(b"data/kos.notes"));
|
||||
assert!(locked.list_dir(b"home", &mut [DirEntry::empty(); 4]).is_none());
|
||||
assert!(locked.open(b"bin/anything").is_none(), "(absent) but plaintext areas are not refused");
|
||||
// Another master key (a wiped and re-set-up device) reads noise.
|
||||
let mut other = Encrypted::new(locked.into_inner());
|
||||
other.unlock(&[1; 32], &[2; 32]);
|
||||
let h = other.open(b"home/x").unwrap();
|
||||
assert_eq!(other.read(h, &mut [0u8; 8]), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plaintext_planted_in_an_encrypted_area_is_refused() {
|
||||
let mut fs = unlocked();
|
||||
fs.inner_mut_for_test().files.insert("system/permissions/kos.evil".to_string(), b"[decision]\ngranted = [\"network\"]\n".to_vec());
|
||||
assert!(fs.open(b"system/permissions/kos.evil").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tampering_and_truncation_are_caught() {
|
||||
let mut fs = unlocked();
|
||||
let data = vec![5u8; 2 * filecrypt::PLAIN_CHUNK + 50];
|
||||
put(&mut fs, b"home/t", &data);
|
||||
let good = on_disk(&fs, "home/t");
|
||||
// One flipped byte.
|
||||
let mut bent = good.clone();
|
||||
bent[100] ^= 1;
|
||||
fs.inner_mut_for_test().files.insert("home/t".to_string(), bent);
|
||||
assert_eq!(get(&mut fs, b"home/t"), None);
|
||||
// Cut at a chunk boundary: the remaining last chunk was not sealed as last.
|
||||
let cut = good[..filecrypt::chunk_offset(2)].to_vec();
|
||||
fs.inner_mut_for_test().files.insert("home/t".to_string(), cut);
|
||||
assert_eq!(get(&mut fs, b"home/t"), None);
|
||||
// Chunks swapped.
|
||||
let (a, b) = (filecrypt::chunk_offset(0), filecrypt::chunk_offset(1));
|
||||
let mut swapped = good.clone();
|
||||
swapped[a..b].copy_from_slice(&good[b..b + filecrypt::CHUNK]);
|
||||
swapped[b..b + filecrypt::CHUNK].copy_from_slice(&good[a..b]);
|
||||
fs.inner_mut_for_test().files.insert("home/t".to_string(), swapped);
|
||||
assert_eq!(get(&mut fs, b"home/t"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn listings_show_what_files_hold() {
|
||||
let mut fs = unlocked();
|
||||
put(&mut fs, b"home/a", b"12345");
|
||||
let mut entries = [DirEntry::empty(); 4];
|
||||
let n = fs.list_dir(b"home", &mut entries).unwrap();
|
||||
assert_eq!((n, entries[0].size), (1, 5));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn files_cannot_live_loose_in_data() {
|
||||
let mut fs = unlocked();
|
||||
assert!(fs.create(b"data/loose").is_none());
|
||||
assert!(fs.mkdir(b"data/kos.notes"), "an app's folder is fine");
|
||||
let mut entries = [DirEntry::empty(); 2];
|
||||
put(&mut fs, b"data/kos.notes/a", b"xyz");
|
||||
let n = fs.list_dir(b"data/kos.notes", &mut entries).unwrap();
|
||||
assert_eq!((n, entries[0].size), (1, 3), "listing the folder shows what its files hold");
|
||||
}
|
||||
Reference in New Issue
Block a user