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

@@ -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",