feat(users): per-user sealed folders users/<id>/{home,apps,data,permissions}; storage SET_USER/LOCK/PURGE; init sign-in/sign-out loop (purge fresh/removed/guest, seed from factory, session start/stop); shell logout; pm user+system app shelves; terminal SIGNED_OUT; per-program stack (gatekeeper 12p); 128K load scratch

This commit is contained in:
2026-10-28 01:08:50 +01:00
parent 29a25bd8c2
commit 91f7d28834
38 changed files with 1156 additions and 378 deletions

View File

@@ -139,4 +139,14 @@ impl FileSystem for MockFileSystem {
fn rmdir(&mut self, _path: &[u8]) -> bool {
false
}
/// Flat: a tree is the files named under it.
fn purge(&mut self, path: &[u8]) -> bool {
for slot in self.files.iter_mut() {
if slot.as_ref().is_some_and(|f| f.name.as_bytes().starts_with(path)) {
*slot = None;
}
}
true
}
}

View File

@@ -1,9 +1,12 @@
//! 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.
//! The encrypted areas (see the vision's Encryption and Users notes), over
//! any file system. Each user's folder `users/<id>/` holds their areas:
//! `home/`, `permissions/`, and per app `apps/<app>/` and `data/<app>/`,
//! each kept as `filecrypt` files under its own key, derived from that
//! user's master key. Only the signed-in user's areas open; anyone else's
//! are refused (and noise to their key anyway). The rest (programs,
//! `factory/`) is plaintext. Signed out, every area refuses 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};
@@ -22,15 +25,28 @@ enum Area<'a> {
Neither,
}
fn area(path: &[u8]) -> Area<'_> {
/// Where `path` falls, and whose it is: the user folder's name.
fn area(path: &[u8]) -> (Area<'_>, Option<&[u8]>) {
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,
let mut parts = path.splitn(5, |b| *b == b'/');
let (users, user, kind, app, rest) = (parts.next(), parts.next(), parts.next(), parts.next(), parts.next());
if users != Some(b"users") {
return (Area::Plain, None);
}
// `users/` itself holds only the folders, which anyone may list.
let Some(user) = user else { return (Area::Plain, None) };
if user.is_empty() {
return (Area::Neither, None);
}
// `users/<id>/` up to and including `kind`, and the app's folder.
let upto = |n: usize| &path[..n];
let base = 6 + user.len() + 1;
let area = match (kind, app, rest) {
(Some(k @ (b"home" | b"permissions")), _, _) => Area::Sealed(upto(base + k.len())),
(Some(k @ (b"apps" | b"data")), Some(a), Some(r)) if !a.is_empty() && !r.is_empty() => Area::Sealed(upto(base + k.len() + 1 + a.len())),
_ => Area::Neither,
};
(area, Some(user))
}
/// An open encrypted file.
@@ -44,15 +60,20 @@ struct Open {
pub struct Encrypted<F: FileSystem> {
inner: F,
/// The master key and randomness, once the gatekeeper has unlocked.
/// The signed-in user's master key and folder name, and randomness.
master: Option<Key>,
user: [u8; USER_NAME],
user_len: usize,
rng: Option<Drbg>,
open: [Option<Open>; MAX_HANDLES],
}
/// A user folder's name: 16 hex digits, or `guest`.
pub const USER_NAME: usize = 16;
impl<F: FileSystem> Encrypted<F> {
pub fn new(inner: F) -> Self {
Self { inner, master: None, rng: None, open: [const { None }; MAX_HANDLES] }
Self { inner, master: None, user: [0; USER_NAME], user_len: 0, rng: None, open: [const { None }; MAX_HANDLES] }
}
pub fn inner(&self) -> &F {
@@ -68,21 +89,27 @@ impl<F: FileSystem> Encrypted<F> {
self.inner
}
/// The key of `path`'s area, if it is encrypted and the store unlocked:
/// Whether `user`'s folder is the signed-in user's.
fn signed_in(&self, user: Option<&[u8]>) -> bool {
self.master.is_some() && user == Some(&self.user[..self.user_len])
}
/// The key of `path`'s area, if it is encrypted and its user signed in:
/// `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(()),
(Area::Plain, _) => Ok(None),
(Area::Sealed(name), user) if self.signed_in(user) => Ok(self.master.as_ref().map(|m| area_key(m, name))),
_ => Err(()),
}
}
/// Whether `path` may be used as a directory (made, listed, removed).
/// Whether `path` may be used as a directory (made, listed, removed):
/// anywhere plaintext, and in the signed-in user's own folder.
fn dir_allowed(&self, path: &[u8]) -> bool {
match area(path) {
Area::Plain => true,
_ => self.master.is_some(),
(Area::Plain, _) => true,
(_, user) => self.signed_in(user),
}
}
@@ -115,13 +142,33 @@ impl<F: FileSystem> FileSystem for Encrypted<F> {
self.inner.writable()
}
fn unlock(&mut self, master: &[u8; 32], seed: &[u8; 32]) {
if self.master.is_none() {
fn unlock(&mut self, master: &[u8; 32], seed: &[u8; 32], user: &[u8]) {
if self.master.is_none() && !user.is_empty() && user.len() <= USER_NAME {
self.master = Some(Key(*master));
self.rng = Some(Drbg::new(*seed));
self.user[..user.len()].copy_from_slice(user);
self.user_len = user.len();
}
}
fn lock(&mut self) {
for (i, slot) in self.open.iter_mut().enumerate() {
if slot.take().is_some() {
self.inner.close(FileHandle::new(i as u16));
}
}
self.master = None;
self.rng = None;
self.user = [0; USER_NAME];
self.user_len = 0;
}
/// Removes a whole tree without opening anything in it: a removed
/// user's or a guest's files go though no key for them is held.
fn purge(&mut self, path: &[u8]) -> bool {
crate::filesystem::remove_tree(&mut self.inner, path)
}
fn open(&mut self, path: &[u8]) -> Option<FileHandle> {
let key = self.key_for(path).ok()?;
let h = self.inner.open(path)?;
@@ -242,8 +289,8 @@ impl<F: FileSystem> FileSystem for Encrypted<F> {
}
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;
// In a user's folder, a file's size is what it holds.
let sealed = area(path).0 != Area::Plain;
for e in entries[..n].iter_mut().filter(|e| sealed && !e.is_dir) {
e.size = plain_len(e.size).unwrap_or(0);
}
@@ -265,7 +312,7 @@ impl<F: FileSystem> FileSystem for Encrypted<F> {
#[cfg(test)]
pub(crate) fn area_of(path: &[u8]) -> &'static str {
match area(path) {
match area(path).0 {
Area::Plain => "plain",
Area::Sealed(_) => "sealed",
Area::Neither => "neither",

View File

@@ -78,12 +78,55 @@ pub trait FileSystem {
fn seek(&mut self, handle: FileHandle, offset: usize) -> bool;
/// Removes the directory at `path`, which must be empty.
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]) {}
/// The signed-in user's master key, a fresh random seed, and the name
/// of their folder; only an encrypting file system has a use for them.
fn unlock(&mut self, _master: &[u8; 32], _seed: &[u8; 32], _user: &[u8]) {}
/// The user signed out: their key is forgotten.
fn lock(&mut self) {}
/// Removes the tree at `path`, whatever is in it.
fn purge(&mut self, path: &[u8]) -> bool {
remove_tree(self, path)
}
/// Whether anything can be written at all; the server refuses
/// writing requests up front when not.
fn writable(&self) -> bool {
true
}
}
/// Removes everything under `path` and `path` itself, a directory or a
/// file; true if nothing is left there.
pub fn remove_tree<F: FileSystem + ?Sized>(fs: &mut F, path: &[u8]) -> bool {
if fs.delete(path) {
return true;
}
const BATCH: usize = 16;
const LONGEST: usize = 255;
loop {
let mut entries = [DirEntry::empty(); BATCH];
let Some(n) = fs.list_dir(path, &mut entries) else {
// Neither a file nor a directory: nothing there.
return true;
};
let children = entries[..n].iter().filter(|e| e.name.as_bytes() != b"." && e.name.as_bytes() != b"..");
let mut any = false;
for e in children {
any = true;
let mut child = [0u8; LONGEST];
let name = e.name.as_bytes();
let len = path.len() + 1 + name.len();
if len > LONGEST {
return false;
}
child[..path.len()].copy_from_slice(path);
child[path.len()] = b'/';
child[path.len() + 1..len].copy_from_slice(name);
if !remove_tree(fs, &child[..len]) {
return false;
}
}
if !any {
return fs.rmdir(path);
}
}
}

View File

@@ -66,6 +66,16 @@ impl Grants {
Some(cap)
}
/// The grants init made directly, into `out`; how many.
pub fn roots(&self, out: &mut [usize; MAX_GRANTS]) -> usize {
let mut n = 0;
for g in self.grants.iter().flatten().filter(|g| g.parent == 0) {
out[n] = g.badge;
n += 1;
}
n
}
/// Forgets `badge` and every grant narrowed from it, transitively,
/// calling `gone` with each one's badge and capability.
pub fn release(&mut self, badge: usize, mut gone: impl FnMut(usize, CapSlot)) {

View File

@@ -30,6 +30,10 @@ pub enum StorageRequest {
/// Half of the seed for randomness.
SetSeed { half: usize, words: [usize; 2] },
Rmdir { path_len: usize },
/// Whose areas the key will open.
SetUser { id: u64, guest: bool },
Lock,
Purge { path_len: usize },
/// A capability to the path (`path_len` bytes of the buffer), inside
/// the caller's scope.
Grant {
@@ -82,6 +86,9 @@ impl Encode for StorageRequest {
SetKey { half, words } => [op::SET_KEY, half, words[0], words[1]],
SetSeed { half, words } => [op::SET_SEED, half, words[0], words[1]],
Rmdir { path_len } => [op::RMDIR, path_len, 0, 0],
SetUser { id, guest } => [op::SET_USER, id as usize, usize::from(guest), 0],
Lock => [op::LOCK, 0, 0, 0],
Purge { path_len } => [op::PURGE, path_len, 0, 0],
Grant { path_len, write } => [op::GRANT, path_len, usize::from(write), 0],
Release => [op::RELEASE, 0, 0, 0],
}
@@ -106,6 +113,9 @@ impl Decode for StorageRequest {
op::SET_KEY => SetKey { half: w[1], words: [w[2], w[3]] },
op::SET_SEED => SetSeed { half: w[1], words: [w[2], w[3]] },
op::RMDIR => Rmdir { path_len: w[1] },
op::SET_USER => SetUser { id: w[1] as u64, guest: w[2] != 0 },
op::LOCK => Lock,
op::PURGE => Purge { path_len: w[1] },
op::GRANT => Grant {
path_len: w[1],
write: w[2] != 0,

View File

@@ -7,6 +7,8 @@ use crate::grants::Grants;
use crate::protocol::{DIR_ENTRY_SIZE, DIR_NAME_SIZE, MAX_PATH, StorageRequest, StorageResponse};
use crate::scope::Scope;
mod session;
const MAX_CLIENTS: usize = 8;
/// Handles at or past this are never valid (the file systems hand out
/// fewer), so ownership fits a small table.
@@ -54,6 +56,8 @@ pub struct StorageServer<F: FileSystem, H: Host> {
/// init; handed to the file system (and forgotten here) once all in.
key: [Option<[usize; 2]>; 2],
seed: [Option<[usize; 2]>; 2],
/// Whose areas they open, said first.
user: Option<types::user::UserFolder>,
unlocked: bool,
}
@@ -68,6 +72,7 @@ impl<F: FileSystem, H: Host> StorageServer<F, H> {
log: None,
key: [None; 2],
seed: [None; 2],
user: None,
unlocked: false,
}
}
@@ -91,27 +96,6 @@ impl<F: FileSystem, H: Host> StorageServer<F, H> {
}
}
/// Only the unbadged holder (init), each half only once: nobody can
/// replace the key or the seed later.
fn set_half(&mut self, badge: usize, seed: bool, half: usize, words: [usize; 2]) -> StorageResponse {
let halves = if seed { &mut self.seed } else { &mut self.key };
match (badge, self.unlocked, halves.get_mut(half)) {
(0, false, Some(slot @ None)) => *slot = Some(words),
_ => return StorageResponse::Error,
}
if let ([Some(k0), Some(k1)], [Some(s0), Some(s1)]) = (self.key, self.seed) {
let bytes = |w: [usize; 4]| -> [u8; 32] { core::array::from_fn(|i| w[i / 8].to_le_bytes()[i % 8]) };
let (mut master, mut seed) = (bytes([k0[0], k0[1], k1[0], k1[1]]), bytes([s0[0], s0[1], s1[0], s1[1]]));
self.fs.unlock(&master, &seed);
master.fill(0);
seed.fill(0);
self.key = [None; 2];
self.seed = [None; 2];
self.unlocked = true;
}
StorageResponse::Ok
}
/// Whether the encrypted areas are open: the key and seed are in.
pub fn unlocked(&self) -> bool {
self.unlocked
@@ -300,7 +284,7 @@ impl<F: FileSystem, H: Host> StorageServer<F, H> {
None => R::NotFound,
};
let response = match request {
Attach | SetLog | SetKey { .. } | SetSeed { .. } | Release => R::Error,
Attach | SetLog | SetKey { .. } | SetSeed { .. } | SetUser { .. } | Lock | Purge { .. } | Release => R::Error,
Grant { path_len, write } => {
let narrower = scope.narrow(&buf[sent(path_len)], write);
return match narrower.and_then(|s| grants.add(badge, s, |b| host.mint(b))) {
@@ -412,6 +396,9 @@ impl<F: FileSystem, H: Host> Server for StorageServer<F, H> {
Some(StorageRequest::SetLog) => (self.set_log(badge, cap).encode(), None),
Some(StorageRequest::SetKey { half, words }) => (self.set_half(badge, false, half, words).encode(), None),
Some(StorageRequest::SetSeed { half, words }) => (self.set_half(badge, true, half, words).encode(), None),
Some(StorageRequest::SetUser { id, guest }) => (self.set_user(badge, id, guest).encode(), None),
Some(StorageRequest::Lock) => (self.lock(badge).encode(), None),
Some(StorageRequest::Purge { path_len }) => (self.purge(badge, path_len).encode(), None),
// A badge storage never issued, or one it has taken back.
Some(request) => match self.grants.scope(badge) {
None => (StorageResponse::Denied.encode(), None),

View File

@@ -0,0 +1,88 @@
//! Who is signed in, as far as storage cares: init says whose areas open,
//! hands over the key and seed, and at sign-out locks everything again.
//! Every one of these is init's alone (the unbadged holder).
use types::user::UserFolder;
use super::{Host, StorageServer};
use crate::filesystem::{FileHandle, FileSystem};
use crate::grants::MAX_GRANTS;
use crate::protocol::{MAX_PATH, StorageResponse};
impl<F: FileSystem, H: Host> StorageServer<F, H> {
/// Once per sign-in, before the key.
pub(super) fn set_user(&mut self, badge: usize, id: u64, guest: bool) -> StorageResponse {
if badge != 0 || self.unlocked || self.user.is_some() {
return StorageResponse::Error;
}
self.user = Some(if guest { UserFolder::GUEST } else { UserFolder::of(id) });
StorageResponse::Ok
}
/// Each half only once per sign-in: nobody can replace the key or the
/// seed while someone is in. Without a user said first, refused.
pub(super) fn set_half(&mut self, badge: usize, seed: bool, half: usize, words: [usize; 2]) -> StorageResponse {
let halves = if seed { &mut self.seed } else { &mut self.key };
match (badge, self.unlocked, self.user, halves.get_mut(half)) {
(0, false, Some(_), Some(slot @ None)) => *slot = Some(words),
_ => return StorageResponse::Error,
}
if let ([Some(k0), Some(k1)], [Some(s0), Some(s1)], Some(user)) = (self.key, self.seed, self.user) {
let bytes = |w: [usize; 4]| -> [u8; 32] { core::array::from_fn(|i| w[i / 8].to_le_bytes()[i % 8]) };
let (mut master, mut seed) = (bytes([k0[0], k0[1], k1[0], k1[1]]), bytes([s0[0], s0[1], s1[0], s1[1]]));
self.fs.unlock(&master, &seed, user.as_bytes());
master.fill(0);
seed.fill(0);
self.key = [None; 2];
self.seed = [None; 2];
self.unlocked = true;
}
StorageResponse::Ok
}
/// Sign-out: every grant goes (and with them every app's files and
/// buffers), init's own files close, the key is forgotten.
pub(super) fn lock(&mut self, badge: usize) -> StorageResponse {
if badge != 0 {
return StorageResponse::Error;
}
let mut roots = [0usize; MAX_GRANTS];
let n = self.grants.roots(&mut roots);
for &root in &roots[..n] {
self.release(root);
}
for (raw, owner) in self.owners.iter_mut().enumerate() {
if owner.take().is_some() {
self.fs.close(FileHandle::new(raw as u16));
}
}
self.fs.lock();
self.key = [None; 2];
self.seed = [None; 2];
self.user = None;
self.unlocked = false;
StorageResponse::Ok
}
/// While nobody is in, so no one's open file can be pulled away.
pub(super) fn purge(&mut self, badge: usize, path_len: usize) -> StorageResponse {
if badge != 0 || self.unlocked || !self.fs.writable() {
return StorageResponse::Error;
}
let Some(client) = self.clients.iter().flatten().find(|c| c.badge == Some(0)) else {
return StorageResponse::NotAttached;
};
let mut path = [0u8; MAX_PATH];
let len = path_len.min(MAX_PATH).min(client.buf.len());
path[..len].copy_from_slice(&client.buf[..len]);
let path = &path[..len];
let path = path.strip_prefix(b"/").unwrap_or(path);
// `users` (everyone, on a new keystore) or what is in it.
let inside = path == b"users"
|| path.strip_prefix(b"users/").is_some_and(|rest| !rest.split(|b| *b == b'/').any(|p| p.is_empty() || p == b"." || p == b".."));
if !inside {
return StorageResponse::Denied;
}
if self.fs.purge(path) { StorageResponse::Ok } else { StorageResponse::Error }
}
}

View File

@@ -74,11 +74,25 @@ impl FileSystem for Memory {
let (path, _) = self.open.get(&h.raw())?;
Some(self.files[path.as_str()].len())
}
/// The direct children: files, and folders made or implied by a file.
fn list_dir(&mut self, path: &[u8], entries: &mut [DirEntry]) -> Option<usize> {
let prefix = std::format!("{}/", key(path));
let dir = key(path);
let prefix = std::format!("{dir}/");
if !self.dirs.contains(&dir) && !self.files.keys().any(|k| k.starts_with(&prefix)) {
return None;
}
let mut seen: Vec<String> = Vec::new();
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 };
let names = self.files.keys().map(|k| (k, true)).chain(self.dirs.iter().map(|d| (d, false)));
for (name, file) in names.filter(|(k, _)| k.starts_with(&prefix)) {
let rest = &name[prefix.len()..];
let (child, deeper) = rest.split_once('/').map_or((rest, false), |(c, _)| (c, true));
if seen.iter().any(|s| s == child) || n == entries.len() {
continue;
}
seen.push(child.to_string());
let size = if file && !deeper { self.files[name.as_str()].len() } else { 0 };
entries[n] = DirEntry { name: FileName::from_bytes(child.as_bytes()), size, is_dir: deeper || !file };
n += 1;
}
Some(n)
@@ -87,14 +101,22 @@ impl FileSystem for Memory {
self.files.remove(&key(path)).is_some()
}
fn mkdir(&mut self, path: &[u8]) -> bool {
self.dirs.push(key(path));
let k = key(path);
if !self.dirs.contains(&k) {
self.dirs.push(k);
}
true
}
/// Only an empty folder.
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
let prefix = std::format!("{k}/");
let empty = !self.files.keys().chain(self.dirs.iter()).any(|f| f.starts_with(&prefix));
let was = self.dirs.contains(&k) || !empty;
if empty {
self.dirs.retain(|d| *d != k);
}
empty && was
}
}
@@ -102,7 +124,7 @@ const MASTER: [u8; 32] = [9; 32];
fn unlocked() -> Encrypted<Memory> {
let mut fs = Encrypted::new(Memory::default());
fs.unlock(&MASTER, &[3; 32]);
fs.unlock(&MASTER, &[3; 32], b"u1");
fs
}
@@ -129,17 +151,22 @@ fn on_disk(fs: &Encrypted<Memory>, path: &str) -> Vec<u8> {
#[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"users/u1/home/readme.txt"[..], "sealed"),
(b"/users/u1/home", "sealed"),
(b"users/u1/permissions/kos.snake", "sealed"),
(b"users/u1/data/kos.notes/a", "sealed"),
(b"users/u1/apps/kos.notes/notes", "sealed"),
(b"users/u1/data/kos.notes", "neither"),
(b"users/u1/apps", "neither"),
(b"users/u1/loose", "neither"),
(b"users/u1", "neither"),
(b"users//home", "neither"),
(b"users", "plain"),
(b"bin/shell", "plain"),
(b"apps/kos.cat/cat", "plain"),
(b"factory/home/readme.txt", "plain"),
(b"homework/x", "plain"),
(b"home/x", "plain"),
(b"usersx/u1/home/x", "plain"),
(b"selftest.txt", "plain"),
] {
assert_eq!(area_of(p), a, "{}", String::from_utf8_lossy(p));
@@ -150,9 +177,9 @@ fn areas_are_told_by_path() {
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");
put(&mut fs, b"users/u1/home/diary.txt", text);
assert_eq!(get(&mut fs, b"users/u1/home/diary.txt").unwrap(), text);
let disk = on_disk(&fs, "users/u1/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()));
@@ -170,37 +197,37 @@ fn plaintext_areas_stay_plaintext() {
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);
put(&mut fs, b"users/u1/data/kos.notes/big", &big);
assert_eq!(get(&mut fs, b"users/u1/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 h = fs.open(b"users/u1/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);
assert_eq!(get(&mut fs, b"users/u1/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();
put(&mut fs, b"users/u1/home/f", &full);
let h = fs.open(b"users/u1/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);
assert_eq!(get(&mut fs, b"users/u1/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");
put(&mut fs, b"users/u1/data/kos.notes/secret", b"notes only");
let stolen = on_disk(&fs, "users/u1/data/kos.notes/secret");
fs.inner_mut_for_test().files.insert("users/u1/data/kos.chat/secret".to_string(), stolen);
let h = fs.open(b"users/u1/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");
}
@@ -208,68 +235,117 @@ fn one_app_cannot_read_anothers_file_even_moved_into_its_folder() {
#[test]
fn locked_the_areas_refuse_everything() {
let mut fs = unlocked();
put(&mut fs, b"home/x", b"y");
put(&mut fs, b"users/u1/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"users/u1/home/x").is_none());
assert!(locked.create(b"users/u1/home/z").is_none());
assert!(!locked.delete(b"users/u1/home/x"));
assert!(!locked.mkdir(b"users/u1/data/kos.notes"));
assert!(locked.list_dir(b"users/u1/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();
other.unlock(&[1; 32], &[2; 32], b"u1");
let h = other.open(b"users/u1/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());
fs.inner_mut_for_test().files.insert("users/u1/permissions/kos.evil".to_string(), b"[decision]\ngranted = [\"network\"]\n".to_vec());
assert!(fs.open(b"users/u1/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");
put(&mut fs, b"users/u1/home/t", &data);
let good = on_disk(&fs, "users/u1/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);
fs.inner_mut_for_test().files.insert("users/u1/home/t".to_string(), bent);
assert_eq!(get(&mut fs, b"users/u1/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);
fs.inner_mut_for_test().files.insert("users/u1/home/t".to_string(), cut);
assert_eq!(get(&mut fs, b"users/u1/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);
fs.inner_mut_for_test().files.insert("users/u1/home/t".to_string(), swapped);
assert_eq!(get(&mut fs, b"users/u1/home/t"), None);
}
#[test]
fn listings_show_what_files_hold() {
let mut fs = unlocked();
put(&mut fs, b"home/a", b"12345");
put(&mut fs, b"users/u1/home/a", b"12345");
let mut entries = [DirEntry::empty(); 4];
let n = fs.list_dir(b"home", &mut entries).unwrap();
let n = fs.list_dir(b"users/u1/home", &mut entries).unwrap();
assert_eq!((n, entries[0].size), (1, 5));
}
#[test]
fn files_cannot_live_loose_in_data() {
fn files_cannot_live_loose_in_a_users_folder() {
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");
assert!(fs.create(b"users/u1/data/loose").is_none());
assert!(fs.create(b"users/u1/loose").is_none());
assert!(fs.create(b"users/loose").is_none());
assert!(fs.mkdir(b"users/u1/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();
put(&mut fs, b"users/u1/data/kos.notes/a", b"xyz");
let n = fs.list_dir(b"users/u1/data/kos.notes", &mut entries).unwrap();
assert_eq!((n, entries[0].size), (1, 3), "listing the folder shows what its files hold");
}
#[test]
fn only_the_signed_in_users_folder_opens() {
let mut fs = unlocked();
put(&mut fs, b"users/u1/home/mine", b"u1's");
let mut inner = fs.into_inner();
let theirs = inner.files["users/u1/home/mine"].clone();
let mut u2 = Encrypted::new(std::mem::take(&mut inner));
u2.unlock(&MASTER, &[3; 32], b"u2");
assert!(u2.open(b"users/u1/home/mine").is_none(), "not someone else's, even under the same key");
assert!(u2.create(b"users/u1/home/planted").is_none());
assert!(!u2.mkdir(b"users/u1/home/sub"));
assert!(u2.list_dir(b"users/u1/home", &mut [DirEntry::empty(); 4]).is_none());
assert!(!u2.delete(b"users/u1/home/mine"));
// Moved into u2's own home, it is u1's area key that sealed it.
u2.inner_mut_for_test().files.insert("users/u2/home/mine".to_string(), theirs);
let h = u2.open(b"users/u2/home/mine").unwrap();
assert_eq!(u2.read(h, &mut [0u8; 8]), None);
assert!(u2.list_dir(b"users", &mut [DirEntry::empty(); 4]).is_some(), "the folders themselves are no secret");
}
#[test]
fn locking_forgets_the_key_and_closes_sealed_files() {
let mut fs = unlocked();
put(&mut fs, b"users/u1/home/a", b"abc");
let h = fs.open(b"users/u1/home/a").unwrap();
fs.lock();
assert_eq!(fs.read(h, &mut [0u8; 4]), None, "the open file went with the key");
assert!(fs.open(b"users/u1/home/a").is_none());
fs.unlock(&MASTER, &[3; 32], b"u1");
assert_eq!(get(&mut fs, b"users/u1/home/a").unwrap(), b"abc", "and comes back with it");
}
#[test]
fn purging_removes_a_tree_without_its_key() {
let mut fs = unlocked();
put(&mut fs, b"users/u1/home/a", b"1");
put(&mut fs, b"users/u1/data/kos.notes/b", b"2");
assert!(fs.mkdir(b"users/u1/home/empty"));
fs.lock();
fs.inner_mut_for_test().files.insert("users/u2x/home/c".to_string(), b"3".to_vec());
assert!(fs.purge(b"users/u1"));
let left: Vec<&String> = fs.inner().files.keys().collect();
assert_eq!(left, ["users/u2x/home/c"]);
assert!(!fs.inner().dirs.iter().any(|d| d.starts_with("users/u1")), "folders too");
assert!(fs.purge(b"users/nobody"), "nothing there is nothing to remove");
}

View File

@@ -224,17 +224,19 @@ 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"));
fs.unlock(&[4; 32], &[5; 32], b"u1");
for dir in [&b"/users"[..], b"/users/u1", b"/users/u1/home"] {
assert!(fs.mkdir(dir));
}
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");
let h = fs.create(b"/users/u1/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 h = fs.open(b"/users/u1/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);
@@ -244,3 +246,29 @@ fn sealed_files_on_real_ext4_read_back_and_pass_e2fsck() {
let (clean, report) = fsck_clean(&dir, &disk);
assert!(clean, "e2fsck: {report}");
}
#[test]
fn purging_a_users_tree_on_real_ext4_passes_e2fsck() {
use crate::encrypted::Encrypted;
let Some((dir, img)) = setup("purge") else { return };
let mut fs = Encrypted::new(Ext4::mount(FileDisk(img)).expect("mount"));
fs.unlock(&[4; 32], &[5; 32], b"u1");
for d in [&b"/users"[..], b"/users/u1", b"/users/u1/home", b"/users/u1/home/deep", b"/users/u1/data", b"/users/u1/data/kos.notes"] {
assert!(fs.mkdir(d));
}
// More files than one listing batch holds.
for i in 0..20u8 {
let name = std::format!("/users/u1/home/deep/f{i}");
let h = fs.create(name.as_bytes()).expect("create");
assert_eq!(fs.write(h, &[i; 100]), Some(100));
fs.close(h);
}
let h = fs.create(b"/users/u1/data/kos.notes/n").expect("create");
fs.close(h);
assert!(!fs.delete(b"/users/u1/home"), "a folder is not deleted as a file");
fs.lock();
assert!(fs.purge(b"/users/u1"));
assert!(fs.list_dir(b"/users", &mut [crate::filesystem::DirEntry::empty(); 4]).is_some_and(|n| n <= 2), "only . and ..");
let (clean, report) = fsck_clean(&dir, &fs.into_inner().into_device().0);
assert!(clean, "e2fsck: {report}");
}

View File

@@ -479,6 +479,11 @@ fn only_init_unlocks_and_only_once() {
let set = |s: &mut Srv, badge, req| call(s, badge, req).0;
let key = |half| StorageRequest::SetKey { half, words: [1, 2] };
let seed = |half| StorageRequest::SetSeed { half, words: [3, 4] };
let user = StorageRequest::SetUser { id: 7, guest: false };
assert_eq!(set(&mut s, 0, key(0)), StorageResponse::Error, "not before saying whose");
assert_eq!(set(&mut s, 5, user), StorageResponse::Error, "a client cannot say");
assert_eq!(set(&mut s, 0, user), StorageResponse::Ok);
assert_eq!(set(&mut s, 0, user), StorageResponse::Error, "once");
assert_eq!(set(&mut s, 5, key(0)), StorageResponse::Error, "a client cannot");
assert_eq!(set(&mut s, 0, key(0)), StorageResponse::Ok);
assert_eq!(set(&mut s, 0, key(0)), StorageResponse::Error, "nor can a half be replaced");
@@ -490,3 +495,40 @@ fn only_init_unlocks_and_only_once() {
assert!(s.unlocked());
assert_eq!(set(&mut s, 0, key(0)), StorageResponse::Error, "once unlocked, never again");
}
#[test]
fn locking_takes_every_grant_back_and_lets_the_next_user_in() {
let mut s = server();
let set = |s: &mut Srv, badge, req| call(s, badge, req).0;
let init = Client::attach(&mut s, 0);
let shell = init.narrowed(&mut s, b"", true);
let app = shell.narrowed(&mut s, b"", false);
for req in [StorageRequest::SetUser { id: 1, guest: false }, StorageRequest::SetKey { half: 0, words: [1, 2] }, StorageRequest::SetKey { half: 1, words: [1, 2] }, StorageRequest::SetSeed { half: 0, words: [1, 2] }, StorageRequest::SetSeed { half: 1, words: [1, 2] }] {
assert_eq!(set(&mut s, 0, req), StorageResponse::Ok);
}
assert!(s.unlocked());
assert_eq!(set(&mut s, shell.badge, StorageRequest::Lock), StorageResponse::Error, "only init signs out");
assert_eq!(set(&mut s, 0, StorageRequest::Lock), StorageResponse::Ok);
assert!(!s.unlocked());
let revoked: Vec<usize> = s.host().revoked.iter().map(|c| badge_of(*c)).collect();
assert_eq!(revoked, [shell.badge, app.badge]);
assert_eq!(set(&mut s, shell.badge, StorageRequest::FileSize { handle: crate::filesystem::FileHandle::new(0) }), StorageResponse::Denied);
assert_eq!(set(&mut s, 0, StorageRequest::SetUser { id: 2, guest: true }), StorageResponse::Ok, "someone else may sign in");
}
#[test]
fn only_init_purges_only_users_and_only_while_locked() {
let mut s = server();
let init = Client::attach(&mut s, 0);
let shell = init.narrowed(&mut s, b"", true);
let purge = |s: &mut Srv, badge: usize, path: &[u8]| {
init.put(s, path);
call(s, badge, StorageRequest::Purge { path_len: path.len() }).0
};
assert_eq!(purge(&mut s, shell.badge, b"users/x"), StorageResponse::Error);
assert_eq!(purge(&mut s, 0, b"bin"), StorageResponse::Denied);
assert_eq!(purge(&mut s, 0, b"users/../bin"), StorageResponse::Denied);
assert_eq!(purge(&mut s, 0, b"users/"), StorageResponse::Denied);
assert_eq!(purge(&mut s, 0, b"users/x"), StorageResponse::Ok);
assert_eq!(purge(&mut s, 0, b"users"), StorageResponse::Ok);
}