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:
1
Cargo.lock
generated
1
Cargo.lock
generated
@@ -591,6 +591,7 @@ name = "storage-server"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"driver-block-virtio",
|
"driver-block-virtio",
|
||||||
|
"filecrypt",
|
||||||
"libkos",
|
"libkos",
|
||||||
"port-block-device",
|
"port-block-device",
|
||||||
"server-framework",
|
"server-framework",
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ edition = "2024"
|
|||||||
types = { workspace = true }
|
types = { workspace = true }
|
||||||
|
|
||||||
server-framework = { workspace = true }
|
server-framework = { workspace = true }
|
||||||
|
filecrypt = { workspace = true }
|
||||||
port-block-device = { workspace = true }
|
port-block-device = { workspace = true }
|
||||||
driver-block-virtio = { workspace = true }
|
driver-block-virtio = { workspace = true }
|
||||||
libkos = { workspace = true }
|
libkos = { workspace = true }
|
||||||
|
|||||||
273
crates/servers/storage-server/src/encrypted.rs
Normal file
273
crates/servers/storage-server/src/encrypted.rs
Normal file
@@ -0,0 +1,273 @@
|
|||||||
|
//! The encrypted areas (see the vision's Encryption note), over any file
|
||||||
|
//! system: each app's `data/<id>/`, the owner's `home/`, and `system/` are
|
||||||
|
//! kept as `filecrypt` files under their own keys, derived from the
|
||||||
|
//! master key; the rest (programs, `factory/`) is plaintext. Before the key
|
||||||
|
//! arrives the encrypted areas refuse everything, and a plaintext file in
|
||||||
|
//! one is refused too: nothing there is trusted unless it was sealed.
|
||||||
|
|
||||||
|
use filecrypt::{CHUNK, Drbg, HEADER, Header, Key, OVERHEAD, PLAIN_CHUNK, area_key, chunk_offset, chunks, plain_len};
|
||||||
|
|
||||||
|
use crate::filesystem::{DirEntry, FileHandle, FileSystem};
|
||||||
|
|
||||||
|
/// The inner file system's handles this layer can track.
|
||||||
|
const MAX_HANDLES: usize = 16;
|
||||||
|
|
||||||
|
/// Where a path falls.
|
||||||
|
#[derive(Debug, PartialEq, Eq)]
|
||||||
|
enum Area<'a> {
|
||||||
|
Plain,
|
||||||
|
/// Encrypted under the key of the area with this name.
|
||||||
|
Sealed(&'a [u8]),
|
||||||
|
/// `data/` itself, or an app's folder in it: folders, never files.
|
||||||
|
Neither,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn area(path: &[u8]) -> Area<'_> {
|
||||||
|
let path = path.strip_prefix(b"/").unwrap_or(path);
|
||||||
|
let mut parts = path.splitn(3, |b| *b == b'/');
|
||||||
|
match (parts.next(), parts.next(), parts.next()) {
|
||||||
|
(Some(b"home" | b"system"), _, _) => Area::Sealed(&path[..path.iter().position(|b| *b == b'/').unwrap_or(path.len())]),
|
||||||
|
(Some(b"data"), Some(id), Some(rest)) if !id.is_empty() && !rest.is_empty() => Area::Sealed(&path[..5 + id.len()]),
|
||||||
|
(Some(b"data"), _, _) => Area::Neither,
|
||||||
|
_ => Area::Plain,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An open encrypted file.
|
||||||
|
struct Open {
|
||||||
|
key: Key,
|
||||||
|
/// Plaintext bytes in it.
|
||||||
|
size: usize,
|
||||||
|
/// Where the next read or write starts, in plaintext.
|
||||||
|
pos: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Encrypted<F: FileSystem> {
|
||||||
|
inner: F,
|
||||||
|
/// The master key and randomness, once the gatekeeper has unlocked.
|
||||||
|
master: Option<Key>,
|
||||||
|
rng: Option<Drbg>,
|
||||||
|
open: [Option<Open>; MAX_HANDLES],
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<F: FileSystem> Encrypted<F> {
|
||||||
|
pub fn new(inner: F) -> Self {
|
||||||
|
Self { inner, master: None, rng: None, open: [const { None }; MAX_HANDLES] }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn inner(&self) -> &F {
|
||||||
|
&self.inner
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
pub fn inner_mut_for_test(&mut self) -> &mut F {
|
||||||
|
&mut self.inner
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn into_inner(self) -> F {
|
||||||
|
self.inner
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The key of `path`'s area, if it is encrypted and the store unlocked:
|
||||||
|
/// `Ok(None)` for a plaintext path, `Err` for one it must refuse.
|
||||||
|
fn key_for(&self, path: &[u8]) -> Result<Option<Key>, ()> {
|
||||||
|
match area(path) {
|
||||||
|
Area::Plain => Ok(None),
|
||||||
|
Area::Neither => Err(()),
|
||||||
|
Area::Sealed(name) => self.master.as_ref().map(|m| Some(area_key(m, name))).ok_or(()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether `path` may be used as a directory (made, listed, removed).
|
||||||
|
fn dir_allowed(&self, path: &[u8]) -> bool {
|
||||||
|
match area(path) {
|
||||||
|
Area::Plain => true,
|
||||||
|
_ => self.master.is_some(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn slot(&mut self, h: FileHandle) -> Option<&mut Option<Open>> {
|
||||||
|
self.open.get_mut(h.raw() as usize)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Chunk `index` of a file of `size` bytes, opened into `out`; its length.
|
||||||
|
fn read_chunk(inner: &mut F, h: FileHandle, key: &Key, size: usize, index: usize, out: &mut [u8; PLAIN_CHUNK]) -> Option<usize> {
|
||||||
|
let last = index + 1 == chunks(size);
|
||||||
|
let plain = if last { size - index * PLAIN_CHUNK } else { PLAIN_CHUNK };
|
||||||
|
let mut sealed = [0u8; CHUNK];
|
||||||
|
let sealed = &mut sealed[..plain + OVERHEAD];
|
||||||
|
if !inner.seek(h, chunk_offset(index)) || inner.read(h, sealed)? != sealed.len() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
filecrypt::open(key, index as u64, last, sealed, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write_chunk(&mut self, h: FileHandle, key: &Key, index: usize, last: bool, plain: &[u8]) -> bool {
|
||||||
|
let Some(rng) = self.rng.as_mut() else { return false };
|
||||||
|
let mut sealed = [0u8; CHUNK];
|
||||||
|
let n = filecrypt::seal(key, index as u64, last, plain, &mut sealed, rng);
|
||||||
|
self.inner.seek(h, chunk_offset(index)) && self.inner.write(h, &sealed[..n]) == Some(n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<F: FileSystem> FileSystem for Encrypted<F> {
|
||||||
|
fn writable(&self) -> bool {
|
||||||
|
self.inner.writable()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn unlock(&mut self, master: &[u8; 32], seed: &[u8; 32]) {
|
||||||
|
if self.master.is_none() {
|
||||||
|
self.master = Some(Key(*master));
|
||||||
|
self.rng = Some(Drbg::new(*seed));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn open(&mut self, path: &[u8]) -> Option<FileHandle> {
|
||||||
|
let key = self.key_for(path).ok()?;
|
||||||
|
let h = self.inner.open(path)?;
|
||||||
|
let Some(area) = key else { return Some(h) };
|
||||||
|
let mut head = [0u8; HEADER];
|
||||||
|
let header = (self.inner.read(h, &mut head) == Some(HEADER)).then(|| Header::decode(&head)).flatten();
|
||||||
|
let size = self.inner.file_size(h).and_then(plain_len);
|
||||||
|
let (Some(header), Some(size)) = (header, size) else {
|
||||||
|
// Not sealed (planted, or cut short): not one of ours.
|
||||||
|
self.inner.close(h);
|
||||||
|
return None;
|
||||||
|
};
|
||||||
|
let key = header.file_key(&area);
|
||||||
|
*self.slot(h)? = Some(Open { key, size, pos: 0 });
|
||||||
|
Some(h)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn create(&mut self, path: &[u8]) -> Option<FileHandle> {
|
||||||
|
let key = self.key_for(path).ok()?;
|
||||||
|
if key.is_some() && self.rng.is_none() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let h = self.inner.create(path)?;
|
||||||
|
let Some(area) = key else { return Some(h) };
|
||||||
|
let header = Header::new(self.rng.as_mut()?);
|
||||||
|
let file_key = header.file_key(&area);
|
||||||
|
// A header and an empty last chunk: a whole, empty file.
|
||||||
|
let ok = self.inner.write(h, &header.encode()) == Some(HEADER) && self.write_chunk(h, &file_key, 0, true, &[]);
|
||||||
|
if !ok {
|
||||||
|
self.inner.close(h);
|
||||||
|
let _ = self.inner.delete(path);
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
*self.slot(h)? = Some(Open { key: file_key, size: 0, pos: 0 });
|
||||||
|
Some(h)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read(&mut self, h: FileHandle, buf: &mut [u8]) -> Option<usize> {
|
||||||
|
let Some(Some(f)) = self.open.get(h.raw() as usize) else { return self.inner.read(h, buf) };
|
||||||
|
let (key, size, mut pos) = (f.key.clone(), f.size, f.pos);
|
||||||
|
let mut done = 0;
|
||||||
|
let mut chunk = [0u8; PLAIN_CHUNK];
|
||||||
|
while done < buf.len() && pos < size {
|
||||||
|
let index = pos / PLAIN_CHUNK;
|
||||||
|
let n = Self::read_chunk(&mut self.inner, h, &key, size, index, &mut chunk)?;
|
||||||
|
let from = pos % PLAIN_CHUNK;
|
||||||
|
let take = (n - from).min(buf.len() - done);
|
||||||
|
buf[done..done + take].copy_from_slice(&chunk[from..from + take]);
|
||||||
|
done += take;
|
||||||
|
pos += take;
|
||||||
|
}
|
||||||
|
chunk.fill(0);
|
||||||
|
self.slot(h)?.as_mut()?.pos = pos;
|
||||||
|
Some(done)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write(&mut self, h: FileHandle, buf: &[u8]) -> Option<usize> {
|
||||||
|
let Some(Some(f)) = self.open.get(h.raw() as usize) else { return self.inner.write(h, buf) };
|
||||||
|
let (key, old_size, pos) = (f.key.clone(), f.size, f.pos);
|
||||||
|
let new_size = old_size.max(pos + buf.len());
|
||||||
|
let (old_last, new_last) = (chunks(old_size) - 1, chunks(new_size) - 1);
|
||||||
|
let first = pos / PLAIN_CHUNK;
|
||||||
|
// The old last chunk is no longer last: resealed so, even when the
|
||||||
|
// write itself does not touch it.
|
||||||
|
let resealed = (old_last < first && old_last != new_last).then_some(old_last);
|
||||||
|
let mut chunk = [0u8; PLAIN_CHUNK];
|
||||||
|
for index in resealed.into_iter().chain(first..=new_last) {
|
||||||
|
let start = index * PLAIN_CHUNK;
|
||||||
|
let mut len = match index <= old_last {
|
||||||
|
true => Self::read_chunk(&mut self.inner, h, &key, old_size, index, &mut chunk)?,
|
||||||
|
false => 0,
|
||||||
|
};
|
||||||
|
// The part of `buf` that falls in this chunk.
|
||||||
|
let (from, to) = (pos.max(start), (pos + buf.len()).min(start + PLAIN_CHUNK));
|
||||||
|
if from < to {
|
||||||
|
chunk[from - start..to - start].copy_from_slice(&buf[from - pos..to - pos]);
|
||||||
|
len = len.max(to - start);
|
||||||
|
}
|
||||||
|
if !self.write_chunk(h, &key, index, index == new_last, &chunk[..len]) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
chunk.fill(0);
|
||||||
|
let f = self.slot(h)?.as_mut()?;
|
||||||
|
f.size = new_size;
|
||||||
|
f.pos = pos + buf.len();
|
||||||
|
Some(buf.len())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn seek(&mut self, h: FileHandle, offset: usize) -> bool {
|
||||||
|
match self.slot(h) {
|
||||||
|
Some(Some(f)) if offset <= f.size => {
|
||||||
|
f.pos = offset;
|
||||||
|
true
|
||||||
|
}
|
||||||
|
Some(Some(_)) => false,
|
||||||
|
_ => self.inner.seek(h, offset),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn close(&mut self, h: FileHandle) {
|
||||||
|
if let Some(slot) = self.slot(h) {
|
||||||
|
*slot = None;
|
||||||
|
}
|
||||||
|
self.inner.close(h);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn file_size(&mut self, h: FileHandle) -> Option<usize> {
|
||||||
|
match self.open.get(h.raw() as usize) {
|
||||||
|
Some(Some(f)) => Some(f.size),
|
||||||
|
_ => self.inner.file_size(h),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn list_dir(&mut self, path: &[u8], entries: &mut [DirEntry]) -> Option<usize> {
|
||||||
|
if !self.dir_allowed(path) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let n = self.inner.list_dir(path, entries)?;
|
||||||
|
// In an encrypted area a file's size is what it holds, not its disk size.
|
||||||
|
// (Listing an app's own folder, `data/<id>`, shows its sealed files.)
|
||||||
|
let sealed = area(path) != Area::Plain;
|
||||||
|
for e in entries[..n].iter_mut().filter(|e| sealed && !e.is_dir) {
|
||||||
|
e.size = plain_len(e.size).unwrap_or(0);
|
||||||
|
}
|
||||||
|
Some(n)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn delete(&mut self, path: &[u8]) -> bool {
|
||||||
|
self.key_for(path).is_ok() && self.inner.delete(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn mkdir(&mut self, path: &[u8]) -> bool {
|
||||||
|
self.dir_allowed(path) && self.inner.mkdir(path)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rmdir(&mut self, path: &[u8]) -> bool {
|
||||||
|
self.dir_allowed(path) && self.inner.rmdir(path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
pub(crate) fn area_of(path: &[u8]) -> &'static str {
|
||||||
|
match area(path) {
|
||||||
|
Area::Plain => "plain",
|
||||||
|
Area::Sealed(_) => "sealed",
|
||||||
|
Area::Neither => "neither",
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -78,6 +78,9 @@ pub trait FileSystem {
|
|||||||
fn seek(&mut self, handle: FileHandle, offset: usize) -> bool;
|
fn seek(&mut self, handle: FileHandle, offset: usize) -> bool;
|
||||||
/// Removes the directory at `path`, which must be empty.
|
/// Removes the directory at `path`, which must be empty.
|
||||||
fn rmdir(&mut self, path: &[u8]) -> bool;
|
fn rmdir(&mut self, path: &[u8]) -> bool;
|
||||||
|
/// The master key and a fresh random seed, once the owner is in; only
|
||||||
|
/// an encrypting file system has a use for them.
|
||||||
|
fn unlock(&mut self, _master: &[u8; 32], _seed: &[u8; 32]) {}
|
||||||
/// Whether anything can be written at all; the server refuses
|
/// Whether anything can be written at all; the server refuses
|
||||||
/// writing requests up front when not.
|
/// writing requests up front when not.
|
||||||
fn writable(&self) -> bool {
|
fn writable(&self) -> bool {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
extern crate alloc;
|
extern crate alloc;
|
||||||
|
|
||||||
pub mod adapters;
|
pub mod adapters;
|
||||||
|
pub mod encrypted;
|
||||||
pub mod filesystem;
|
pub mod filesystem;
|
||||||
pub mod fs;
|
pub mod fs;
|
||||||
pub mod grants;
|
pub mod grants;
|
||||||
|
|||||||
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");
|
||||||
|
}
|
||||||
@@ -218,3 +218,29 @@ fn writes_land_where_seek_says() {
|
|||||||
let (clean, report) = fsck_clean(&dir, &fs.into_device().0);
|
let (clean, report) = fsck_clean(&dir, &fs.into_device().0);
|
||||||
assert!(clean, "e2fsck: {report}");
|
assert!(clean, "e2fsck: {report}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sealed_files_on_real_ext4_read_back_and_pass_e2fsck() {
|
||||||
|
use crate::encrypted::Encrypted;
|
||||||
|
let Some((dir, img)) = setup("sealed") else { return };
|
||||||
|
let mut fs = Encrypted::new(Ext4::mount(FileDisk(img)).expect("mount"));
|
||||||
|
fs.unlock(&[4; 32], &[5; 32]);
|
||||||
|
assert!(fs.mkdir(b"/home"));
|
||||||
|
let text: Vec<u8> = b"the quick brown fox ".iter().copied().cycle().take(3 * filecrypt::PLAIN_CHUNK + 99).collect();
|
||||||
|
let h = fs.create(b"/home/fox.txt").expect("create");
|
||||||
|
assert_eq!(fs.write(h, &text), Some(text.len()));
|
||||||
|
assert!(fs.seek(h, 5000));
|
||||||
|
assert_eq!(fs.write(h, b"LAZY DOG"), Some(8));
|
||||||
|
fs.close(h);
|
||||||
|
let mut expect = text.clone();
|
||||||
|
expect[5000..5008].copy_from_slice(b"LAZY DOG");
|
||||||
|
let h = fs.open(b"/home/fox.txt").expect("open");
|
||||||
|
let mut back = std::vec![0u8; expect.len()];
|
||||||
|
assert_eq!(fs.read(h, &mut back), Some(expect.len()));
|
||||||
|
fs.close(h);
|
||||||
|
assert_eq!(back, expect);
|
||||||
|
let disk = fs.into_inner().into_device().0;
|
||||||
|
assert!(!disk.windows(9).any(|w| w == b"quick bro"), "no plaintext on the disk");
|
||||||
|
let (clean, report) = fsck_clean(&dir, &disk);
|
||||||
|
assert!(clean, "e2fsck: {report}");
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
mod encrypted;
|
||||||
mod ext4;
|
mod ext4;
|
||||||
mod ext4_real;
|
mod ext4_real;
|
||||||
mod protocol;
|
mod protocol;
|
||||||
|
|||||||
Reference in New Issue
Block a user