diff --git a/crates/apps/shell/src/main.rs b/crates/apps/shell/src/main.rs index da26fce..a9bf8ce 100644 --- a/crates/apps/shell/src/main.rs +++ b/crates/apps/shell/src/main.rs @@ -19,11 +19,12 @@ use types::address::VirtualAddress; use types::apps::reply; use types::bounded::CapSlot; -/// Where init's manifest put them: `uses`, then storage. +/// Where init's manifest put them: `uses`, then storage, then signing out. const TERMINAL: CapSlot = CapSlot::const_new(0); const PERMISSION: CapSlot = CapSlot::const_new(1); -/// The owner's files, `/home`: the shell's `/`. +/// The user's files, their `home`: the shell's `/`. const HOME: CapSlot = CapSlot::const_new(2); +const SESSION: CapSlot = CapSlot::const_new(3); /// Bits on the shell's notification: the program died (its death watch is /// unbadged), or Ctrl+C was typed (the terminal's copy). @@ -227,7 +228,7 @@ fn run(sh: &mut Shell, line: &str) { match args.first().copied() { None => {} Some("help") => { - println!("built-ins: help, echo , clear, cd , pwd"); + println!("built-ins: help, echo , clear, cd , pwd, logout"); println!("apps: apps, install , allow|revoke "); println!("anything else runs an app: try ls, cat readme.txt, snake"); println!("apps see the current directory, read-only, and only what you allowed"); @@ -244,6 +245,19 @@ fn run(sh: &mut Shell, line: &str) { Some("pwd") => println!("{}", sh.cwd), Some("echo") => println!("{}", Joined(&args[1..])), Some("clear") => libkos::terminal::clear(TERMINAL), + // Init stops this shell and everything it ran, and the lock + // screen comes back. + Some("logout") => { + libkos::terminal::clear(TERMINAL); + if syscall::send_msg(SESSION, [types::spawn::SIGN_OUT, 0, 0, 0], None).is_err() { + println!("logout: cannot sign out"); + return; + } + // No prompt over the lock screen: wait to be stopped. + loop { + let _ = syscall::wait(sh.done); + } + } Some(_) => run_program(sh, args), } } diff --git a/crates/apps/spawntest/src/main.rs b/crates/apps/spawntest/src/main.rs index 63b634b..9b74459 100644 --- a/crates/apps/spawntest/src/main.rs +++ b/crates/apps/spawntest/src/main.rs @@ -14,16 +14,18 @@ use libkos::syscall; use types::bounded::CapSlot; use types::budget::Limits; -/// Where init put them (see init's manifest: `uses`, the spawn endpoint, -/// then control). +/// Where init put them (see init's manifest: `uses`, storage (the disk, +/// the user's home), the spawn endpoint, then control). const PERMISSION: CapSlot = CapSlot::const_new(0); const STORAGE: CapSlot = CapSlot::const_new(1); -const INIT: CapSlot = CapSlot::const_new(2); -const CONTROL: CapSlot = CapSlot::const_new(3); +const HOME: CapSlot = CapSlot::const_new(2); +const INIT: CapSlot = CapSlot::const_new(3); +const CONTROL: CapSlot = CapSlot::const_new(4); /// Where storage's buffers are mapped: the whole disk's, a narrowed one's. const STORAGE_BUF_VA: types::address::VirtualAddress = types::address::VirtualAddress::from_raw(0x0180_0000); const NARROW_BUF_VA: types::address::VirtualAddress = types::address::VirtualAddress::from_raw(0x0190_0000); const REQUEST_PAGE_VA: types::address::VirtualAddress = types::address::VirtualAddress::from_raw(0x01C0_0000); +const HOME_BUF_VA: types::address::VirtualAddress = types::address::VirtualAddress::from_raw(0x01B0_0000); /// Read back from the disk image by scripts/test-qemu.sh. const NOTE: &[u8] = b"written by k-os on ext4\n"; @@ -125,9 +127,9 @@ fn revoke_stops_a_running_app(died: CapSlot) -> Result<(), &'static str> { Ok(()) } -/// The owner's area is sealed: seeded from the factory, and what is -/// written there one boot reads back the next. -fn encrypted_home(fs: &mut Storage) -> Result<(), &'static str> { +/// The user's home is sealed: seeded from the factory, and what is written +/// there one boot reads back the next. Theirs is the only folder that opens. +fn encrypted_home(fs: &mut Storage, home_fs: &mut Storage) -> Result<(), &'static str> { let mut home = [0u8; 512]; let mut factory = [0u8; 512]; let read = |fs: &mut Storage, path: &[u8], out: &mut [u8]| -> Result { @@ -136,7 +138,7 @@ fn encrypted_home(fs: &mut Storage) -> Result<(), &'static str> { let _ = fs.close(h); Ok(n) }; - let n = read(fs, b"home/readme.txt", &mut home)?; + let n = read(home_fs, b"readme.txt", &mut home)?; let m = read(fs, b"factory/home/readme.txt", &mut factory)?; if home[..n] != factory[..m] { return Err("the encrypted home does not hold the factory's readme"); @@ -144,19 +146,29 @@ fn encrypted_home(fs: &mut Storage) -> Result<(), &'static str> { libkos::println!("spawntest: home seeded from the factory, reads back through its seal: ok"); const NOTE: &[u8] = b"sealed across a reboot"; let mut back = [0u8; 64]; - match read(fs, b"home/persist.txt", &mut back) { + match read(home_fs, b"persist.txt", &mut back) { Ok(n) if &back[..n] == NOTE => libkos::println!("spawntest: an encrypted file from the last boot reads back: ok"), Ok(_) => return Err("an encrypted file from the last boot reads back wrong"), Err(_) => { - let h = fs.create(b"home/persist.txt").map_err(|_| "cannot create in home")?; - let wrote = fs.write(h, NOTE) == Ok(NOTE.len()); - let _ = fs.close(h); + let h = home_fs.create(b"persist.txt").map_err(|_| "cannot create in home")?; + let wrote = home_fs.write(h, NOTE) == Ok(NOTE.len()); + let _ = home_fs.close(h); if !wrote { return Err("cannot write in home"); } libkos::println!("spawntest: wrote an encrypted file for the next boot"); } } + // A made-up user's: nobody may reach it, the whole disk's holder neither. + if fs.create(b"users/0123456789abcdef/home/planted").is_ok() || fs.mkdir(b"users/0123456789abcdef").is_ok() { + return Err("someone else's folder could be written"); + } + let mut folders = 0; + fs.list_dir(b"users", |e| folders += usize::from(e.name() != b"." && e.name() != b"..")).map_err(|_| "cannot list the users")?; + if folders != 1 { + return Err("not exactly one user's folder"); + } + libkos::println!("spawntest: only the signed-in user's folder opens: ok"); Ok(()) } @@ -164,7 +176,8 @@ fn check() -> Result<(), &'static str> { let mut fs = Storage::attach(STORAGE, STORAGE_BUF_VA).map_err(|_| "cannot attach to storage")?; write_a_file(&mut fs)?; libkos::println!("spawntest: a file written to ext4 reads back: ok"); - encrypted_home(&mut fs)?; + let mut home = Storage::attach(HOME, HOME_BUF_VA).map_err(|_| "cannot attach to home")?; + encrypted_home(&mut fs, &mut home)?; let died = syscall::create_notification().map_err(|_| "no notification")?; narrow_storage(&mut fs, died)?; diff --git a/crates/init/src/factory.rs b/crates/init/src/factory.rs index 33d0d3d..1285ee9 100644 --- a/crates/init/src/factory.rs +++ b/crates/init/src/factory.rs @@ -1,15 +1,14 @@ -//! The encrypted areas' starting point. The image ships starter content -//! (the owner's first files, the pre-approved decisions) in plaintext under -//! `factory/`; with a new master key (a fresh device, or one just wiped) -//! init empties the encrypted areas and seeds them from it, storage -//! sealing what is written. A device just wiped so looks factory-fresh, -//! not full of files no key opens. An area simply missing (a disk image -//! rebuilt) is seeded too. +//! A user's folder's starting point. The image ships starter content (a +//! user's first files, the pre-approved decisions) in plaintext under +//! `factory/`; each area of the signed-in user's folder that is missing (a +//! new user, a guest, a disk image rebuilt) is made and seeded from it, +//! storage sealing what is written. use libkos::fs::Storage; use types::storage::MAX_PATH; -const AREAS: [&str; 3] = ["home", "system", "data"]; +/// Each area of a user's folder, and what it starts with. +const AREAS: [(&str, &str); 4] = [("home", "factory/home"), ("permissions", "factory/permissions"), ("data", "factory/data"), ("apps", "factory/apps")]; /// Entries handled per listing; a folder with more is listed again. const BATCH: usize = 32; const NAME: usize = types::storage::DIR_NAME_SIZE; @@ -61,25 +60,6 @@ fn entries(fs: &mut Storage, dir: &Path) -> Option<([([u8; NAME], usize, bool); Some((out, n)) } -/// Removes everything inside `dir`, keeping `dir`. -fn empty(fs: &mut Storage, dir: &Path) -> Result<(), &'static str> { - loop { - let (list, n) = entries(fs, dir).ok_or("cannot list an area to empty it")?; - if n == 0 { - return Ok(()); - } - for (name, len, is_dir) in &list[..n] { - let child = dir.join(&name[..*len]).ok_or("path too long")?; - if *is_dir { - empty(fs, &child)?; - fs.rmdir(child.as_bytes()).map_err(|_| "cannot remove a folder")?; - } else { - fs.delete(child.as_bytes()).map_err(|_| "cannot remove a file")?; - } - } - } -} - /// Copies the file `from` to a new file `to`. fn copy_file(fs: &mut Storage, from: &Path, to: &Path, buf: &mut [u8]) -> Result<(), &'static str> { let src = fs.open(from.as_bytes()).map_err(|_| "cannot open a factory file")?; @@ -115,23 +95,23 @@ fn copy_tree(fs: &mut Storage, from: &Path, to: &Path, buf: &mut [u8]) -> Result Ok(()) } -/// Readies the encrypted areas once storage has the key: `fresh` when the -/// master key is new. -pub(crate) fn prepare(fs: &mut Storage, fresh: bool, buf: &mut [u8]) -> Result<(), &'static str> { - for area in AREAS { - let path = Path::new(area); - let present = fs.list_dir(path.as_bytes(), |_| {}).is_ok(); - if present && !fresh { +/// Readies `folder`'s areas once storage has its key. +pub(crate) fn prepare(fs: &mut Storage, folder: types::user::UserFolder, buf: &mut [u8]) -> Result<(), &'static str> { + let name = core::str::from_utf8(folder.as_bytes()).map_err(|_| "a user folder named oddly")?; + let base = Path::new("users").join(name.as_bytes()).ok_or("path too long")?; + for dir in [Path::new("users"), base] { + if fs.list_dir(dir.as_bytes(), |_| {}).is_err() { + fs.mkdir(dir.as_bytes()).map_err(|_| "cannot make a user's folder")?; + } + } + for (area, factory) in AREAS { + let path = base.join(area.as_bytes()).ok_or("path too long")?; + if fs.list_dir(path.as_bytes(), |_| {}).is_ok() { continue; } - if present { - empty(fs, &path)?; - } else { - fs.mkdir(path.as_bytes()).map_err(|_| "cannot make an area")?; - } - let factory = Path::new("factory").join(area.as_bytes()).ok_or("path too long")?; - copy_tree(fs, &factory, &path, buf)?; - libkos::println!("init: {} {}", if fresh { "set up" } else { "seeded" }, area); + fs.mkdir(path.as_bytes()).map_err(|_| "cannot make an area")?; + copy_tree(fs, &Path::new(factory), &path, buf)?; + libkos::println!("init: set up {}", area); } Ok(()) } diff --git a/crates/init/src/launch.rs b/crates/init/src/launch.rs index 02ddc4a..0677a8d 100644 --- a/crates/init/src/launch.rs +++ b/crates/init/src/launch.rs @@ -9,9 +9,11 @@ use types::badge::Badge; use types::bounded::CapSlot; use types::permission::{Permission, PermissionSet}; +use types::user::UserFolder; + use crate::devices::Devices; use crate::load::{self, LoadedProcess}; -use crate::manifest::{Device, Program}; +use crate::manifest::{Device, Program, Scope}; use crate::spawn::Spawner; const USER_STACK_TOP: StackPointer = StackPointer::from_raw(0x10000); @@ -34,6 +36,22 @@ pub(crate) struct Launcher<'a> { /// Bound to init's thread: spawned children's deaths, a bit each. pub(crate) deaths: CapSlot, pub(crate) spawner: Spawner, + /// Who is signed in, and what runs for them (stopped at sign-out). + pub(crate) user: Option, + pub(crate) running: [Option; MAX_SESSION], + /// What a session runs after `SESSION`: the desktop, or the tests. + pub(crate) desktop: &'static [Program], +} + +/// Programs one session runs at once, at most. +pub(crate) const MAX_SESSION: usize = 4; + +/// What became of starting a program. +pub(crate) struct Launched { + pub(crate) note: &'static str, + pub(crate) process: CapSlot, + /// Its endpoint, if it serves one. + pub(crate) ep: Option, } impl<'a> Launcher<'a> { @@ -47,25 +65,46 @@ impl<'a> Launcher<'a> { // Storage, started by boot, is not among them: it is reached // through grants (a program's `storage` scope). let served = [None; MAX_SERVED]; - Ok(Self { storage, scratch, devices, served, next_badge: 1, spawn_ep, deaths, spawner: Spawner::new() }) + Ok(Self { + storage, + scratch, + devices, + served, + next_badge: 1, + spawn_ep, + deaths, + spawner: Spawner::new(), + user: None, + running: [None; MAX_SESSION], + desktop: &[], + }) } pub(crate) fn storage(&mut self) -> (&mut Storage, &mut [u8]) { (self.storage, self.scratch) } + /// The endpoint of the running server `name`. + pub(crate) fn served(&self, name: &str) -> Option { + self.endpoint(name) + } + fn endpoint(&self, name: &str) -> Option { self.served.iter().flatten().find(|(n, _)| *n == name).map(|(_, ep)| *ep) } - /// What became of the program's device, if it has one. - pub(crate) fn launch(&mut self, p: &Program) -> Result<&'static str, &'static str> { + pub(crate) fn launch(&mut self, p: &Program) -> Result { let loaded = - load::load_binary(self.storage, self.scratch, p.file, p.limits).ok_or("cannot load")?; + load::load_binary(self.storage, self.scratch, p.file, p.limits, p.stack_pages).ok_or("cannot load")?; let child = loaded.process_cap; let ep = match p.serves { true => { - let ep = syscall::create_endpoint().ok_or("no endpoint")?; + // Started again (a new sign-in): its clients went with the + // last one, so the endpoint is reused. + let ep = match self.endpoint(p.name) { + Some(ep) => ep, + None => syscall::create_endpoint().ok_or("no endpoint")?, + }; // Read to receive on it; init keeps the original. let server = perms(&[Permission::Read, Permission::Execute]); syscall::delegate(ep, child, server).ok_or("cannot give its endpoint")?; @@ -87,8 +126,10 @@ impl<'a> Launcher<'a> { given.ok_or("cannot give an endpoint")?; } // Storage issues its own badges, each naming a scope. - if let Some(scope) = p.storage { - let grant = self.storage.grant(scope.path, scope.write).map_err(|_| "storage refused a grant")?; + for scope in p.storage { + let mut path = [0u8; types::storage::MAX_PATH]; + let path = self.scope_path(scope, &mut path).ok_or("a storage scope for nobody")?; + let grant = self.storage.grant(path, scope.write).map_err(|_| "storage refused a grant")?; let given = syscall::delegate(grant, child, perms(&[Permission::Execute, Permission::Delegate])); syscall::drop_cap(grant); given.ok_or("cannot give storage")?; @@ -100,6 +141,14 @@ impl<'a> Launcher<'a> { let control = crate::devices::system_control().ok_or("no SystemControl to give")?; syscall::delegate(control, child, perms(&[Permission::Execute])).ok_or("cannot give SystemControl")?; } + if p.signs_out { + let badge = Badge::new(types::spawn::SESSION_BADGE); + // Delegate only to hand it over: the shell's copy cannot pass it on. + let copy = syscall::mint(self.spawn_ep, badge, perms(&[Permission::Execute, Permission::Delegate])).map_err(|_| "cannot mint")?; + let given = syscall::delegate(copy, child, perms(&[Permission::Execute])); + syscall::drop_cap(copy); + given.ok_or("cannot give signing out")?; + } let console = libkos::start::console() .filter(|_| p.console) .and_then(|c| syscall::delegate(c, child, perms(&[Permission::Write]))); @@ -107,23 +156,34 @@ impl<'a> Launcher<'a> { let mut note = ""; if let Some(ep) = ep { - if let Some(free) = self.served.iter_mut().find(|s| s.is_none()) { + let known = self.served.iter().flatten().any(|(n, _)| *n == p.name); + if let Some(free) = self.served.iter_mut().find(|s| s.is_none()).filter(|_| !known) { *free = Some((p.name, ep)); } for device in p.devices { note = self.give(*device, ep); } - if p.unlocks { - let fresh = unlock(ep)?; - crate::factory::prepare(self.storage, fresh, self.scratch)?; - note = if fresh { ", the owner is in (a new key)" } else { ", the owner is in" }; - } if console.is_some() { hand_over_console(ep); note = ", console handed over"; } } - Ok(note) + Ok(Launched { note, process: child, ep }) + } + + /// The path on disk `scope` names: inside the user's folder if it is a + /// user's scope. + fn scope_path<'p>(&self, scope: &Scope, buf: &'p mut [u8; types::storage::MAX_PATH]) -> Option<&'p [u8]> { + if !scope.user { + return Some(scope.path); + } + let folder: UserFolder = self.user?.folder; + let mut len = 0; + for part in [&b"users/"[..], folder.as_bytes(), if scope.path.is_empty() { b"" } else { b"/" }, scope.path] { + buf.get_mut(len..len + part.len())?.copy_from_slice(part); + len += part.len(); + } + Some(&buf[..len]) } /// A badged copy of init's endpoint: the badge names the client and so @@ -154,29 +214,6 @@ impl<'a> Launcher<'a> { } } -/// Waits for the gatekeeper behind `ep` to authenticate the owner, then -/// passes the master key and a fresh seed on to storage, which alone keeps -/// them. Whether the key is new. -fn unlock(ep: CapSlot) -> Result { - use types::gatekeeper::{KEY, SEED, UNLOCK, reply}; - let fresh = match syscall::request(ep, [UNLOCK, 0, 0, 0]) { - Ok([reply::OK, fresh, ..]) => fresh != 0, - _ => return Err("the gatekeeper could not unlock"), - }; - for (ask, give) in [(KEY, types::storage::op::SET_KEY), (SEED, types::storage::op::SET_SEED)] { - let mut secret = syscall::request(ep, [ask, 0, 0, 0]).map_err(|_| "the gatekeeper gave no key")?; - let given = [0, 1].iter().all(|&half| { - let words = [give, half, secret[half * 2], secret[half * 2 + 1]]; - syscall::request(crate::STORAGE_EP, words).is_ok_and(|r| r[0] == types::storage::reply::OK) - }); - secret.fill(0); - if !given { - return Err("storage would not take the key"); - } - } - Ok(fresh) -} - /// From here on init prints through the log server, like everyone else, /// and holds no console of its own. fn hand_over_console(log: CapSlot) { diff --git a/crates/init/src/lib.rs b/crates/init/src/lib.rs index b8d72b3..06daef2 100644 --- a/crates/init/src/lib.rs +++ b/crates/init/src/lib.rs @@ -9,6 +9,7 @@ mod launch; mod load; mod manifest; mod selftest; +mod session; mod spawn; use libkos::fs::Storage; @@ -59,15 +60,20 @@ pub fn run() -> ! { // The test image carries the SMP self-test; it and the tests run // instead of the desktop, and the last one halts the machine. let (storage, scratch) = launcher.storage(); - if storage + launcher.desktop = if storage .open(b"bin/smptest") .is_ok_and(|h| storage.close(h).is_ok()) { libkos::println!("init: running the SMP self-test"); selftest::run(storage, scratch); - launch_all(&mut launcher, manifest::TESTS); + manifest::TESTS } else { - launch_all(&mut launcher, manifest::DESKTOP); + manifest::DESKTOP + }; + // Nothing that touches a user's files starts before someone is in. + if let Err(why) = session::sign_in(&mut launcher) { + libkos::println!("init: STOP ({})", why); + idle(); } libkos::println!("init: all loaded, serving spawns"); @@ -80,12 +86,7 @@ fn launch_all(launcher: &mut launch::Launcher, programs: &[manifest::Program]) { for p in programs { libkos::print!("init: loading {}... ", p.name); match launcher.launch(p) { - Ok(note) => libkos::println!("ok{}", note), - // Without the owner, nothing that touches user data may start. - Err(why) if p.unlocks => { - libkos::println!("STOP ({})", why); - idle(); - } + Ok(started) => libkos::println!("ok{}", started.note), Err(why) => libkos::println!("SKIP ({})", why), } } diff --git a/crates/init/src/load.rs b/crates/init/src/load.rs index e6315d5..0479753 100644 --- a/crates/init/src/load.rs +++ b/crates/init/src/load.rs @@ -8,12 +8,15 @@ use types::budget::Limits; const PAGE_SIZE: usize = 4096; const USER_CODE_BASE: usize = 0x10000; -const USER_STACK_PAGES: usize = 4; +/// A program's stack, below its image, unless its manifest says more. +pub(crate) const STACK_PAGES: usize = 4; +/// The most there is room for under the image (page 0 stays unmapped). +const MAX_STACK_PAGES: usize = USER_CODE_BASE / PAGE_SIZE - 1; const CREATE_CAP: CapSlot = CapSlot::const_new(0); -/// Where a program file is read to before it is loaded (16 pages: the -/// largest program init will load). -const SCRATCH_VA: VirtualAddress = VirtualAddress::from_raw(0x30000); -const SCRATCH_PAGES: u16 = 16; +/// Where a program file is read to before it is loaded (32 pages: the +/// largest program init will load; the gatekeeper is past 16). +const SCRATCH_VA: VirtualAddress = VirtualAddress::from_raw(0x0120_0000); +const SCRATCH_PAGES: u16 = 32; pub(crate) struct LoadedProcess { pub(crate) process_cap: CapSlot, @@ -35,7 +38,9 @@ pub(crate) fn load_binary( scratch: &mut [u8], filename: &[u8], limits: Limits, + stack_pages: usize, ) -> Option { + let stack_pages = stack_pages.min(MAX_STACK_PAGES); let file_size = read_file(storage, scratch, filename)?; let elf_data = &scratch[..file_size]; let elf_info = match elf::parse(elf_data) { @@ -71,10 +76,10 @@ pub(crate) fn load_binary( } } - for i in 0..USER_STACK_PAGES { + for i in 0..stack_pages { let page_count = PageCount::new(1)?; let stack_va = syscall::request_pages(page_count)?; - let dst = VirtualAddress::from_raw(USER_CODE_BASE - (USER_STACK_PAGES - i) * PAGE_SIZE); + let dst = VirtualAddress::from_raw(USER_CODE_BASE - (stack_pages - i) * PAGE_SIZE); syscall::map_page(process_cap, stack_va, dst, false); } diff --git a/crates/init/src/manifest.rs b/crates/init/src/manifest.rs index 32751b9..745c728 100644 --- a/crates/init/src/manifest.rs +++ b/crates/init/src/manifest.rs @@ -12,35 +12,45 @@ pub enum Device { } /// A storage grant: a folder (or the whole disk, `b""`) and whether it may -/// be changed. +/// be changed. With `user`, the path is inside the signed-in user's folder. #[derive(Clone, Copy)] pub struct Scope { pub path: &'static [u8], pub write: bool, + pub user: bool, +} + +const fn disk(path: &'static [u8], write: bool) -> Scope { + Scope { path, write, user: false } +} + +const fn users(path: &'static [u8], write: bool) -> Scope { + Scope { path, write, user: true } } pub struct Program { pub name: &'static str, pub file: &'static [u8], pub limits: Limits, + /// Its stack, in pages. + pub stack_pages: usize, /// Serves an endpoint, which it finds in slot 0. pub serves: bool, /// Handed over in this order, after it starts. pub devices: &'static [Device], - /// Authenticates the owner: init waits for the master key before - /// starting anything that touches user data. - pub unlocks: bool, /// Earlier programs' endpoints it may call, in its next slots; each /// program's copy carries its own badge, so a server can tell them apart. pub uses: &'static [&'static str], - /// Storage it may reach, in the slot after `uses`. - pub storage: Option, + /// Storage it may reach, in the slots after `uses`. + pub storage: &'static [Scope], /// Takes the console over from init; everyone else prints through it. pub console: bool, /// May have init spawn programs, whose limits together stay within this. pub spawns: Option, /// May halt the machine. pub system_control: bool, + /// May sign the user out (`types::spawn::SIGN_OUT`), in the last slot. + pub signs_out: bool, } /// Enough for a small program: its image, stack and a little heap. @@ -56,14 +66,15 @@ const fn server(name: &'static str, file: &'static [u8], limits: Limits) -> Prog name, file, limits, + stack_pages: crate::load::STACK_PAGES, serves: true, devices: &[], - unlocks: false, uses: &[], - storage: None, + storage: &[], console: false, spawns: None, system_control: false, + signs_out: false, } } @@ -80,46 +91,52 @@ pub const SERVERS: &[Program] = &[ }, // Text on the display, lines from the keyboard (it takes input focus). Program { uses: &["display"], ..server("terminal", b"bin/terminal", SMALL) }, - // Nothing that touches user data starts before the owner is in. It - // holds the screen (through the terminal), its keystore and randomness, - // and exits once it has handed over the key. - Program { - uses: &["terminal"], - devices: &[Device::Keystore, Device::Random], - unlocks: true, - // argon2's 16 MiB, its image and a little more. - ..server("gatekeeper", b"bin/gatekeeper", Limits { pages: 4096 + 160, regions: 6, ..SMALL }) - }, - // The only one that equips apps (docs/apps.md): it reaches every app's - // files and the owner's decisions, and runs apps through init. - Program { - uses: &["terminal", "display"], - storage: Some(Scope { path: b"", write: true }), - // Room for a few apps at a time. - spawns: Some(Limits { pages: 512, endpoints: 8, notifications: 8, regions: 8 }), - ..server("permission", b"bin/permission", Limits { pages: 96, ..SMALL }) - }, server("session", b"bin/session", SMALL), ]; +/// Started for each sign-in, before anything that touches a user's files. +/// It holds the screen (through the terminal), its keystore and randomness, +/// and exits once it has handed over who is in and their key. +pub const GATEKEEPER: Program = Program { + uses: &["terminal"], + devices: &[Device::Keystore, Device::Random], + // A user's record is KiBs, and it handles a few at once. + stack_pages: 12, + // argon2's 16 MiB, its image and a little more. + ..server("gatekeeper", b"bin/gatekeeper", Limits { pages: 4096 + 160, regions: 6, ..SMALL }) +}; + +/// Started for each signed-in user, and stopped when they sign out. The +/// permission manager is the only one that equips apps (docs/apps.md): it +/// reaches the user's apps, their data and decisions, and the system's apps +/// (read-only), and runs apps through init. +pub const SESSION: &[Program] = &[Program { + uses: &["terminal", "display"], + storage: &[users(b"", true), disk(b"apps", false)], + // Room for a few apps at a time. + spawns: Some(Limits { pages: 512, endpoints: 8, notifications: 8, regions: 8 }), + ..server("permission", b"bin/permission", Limits { pages: 96, ..SMALL }) +}]; + /// Run on the test image after the SMP self-test, instead of the desktop: /// spawns through init, then reports and halts. pub const TESTS: &[Program] = &[Program { serves: false, // The permission manager, to run an app the way the shell does. uses: &["permission"], - storage: Some(Scope { path: b"", write: true }), + storage: &[disk(b"", true), users(b"home", true)], spawns: Some(Limits { pages: 128, endpoints: 2, notifications: 2, regions: 2 }), system_control: true, ..server("spawntest", b"bin/spawntest", SMALL) }]; /// The desktop; the test image runs the SMP self-test instead. The shell -/// holds the owner's files and asks the permission manager to run apps; -/// it holds nothing to give them beyond that. +/// holds the user's files and asks the permission manager to run apps; +/// it holds nothing to give them beyond that, and may sign the user out. pub const DESKTOP: &[Program] = &[Program { serves: false, uses: &["terminal", "permission"], - storage: Some(Scope { path: b"home", write: true }), + storage: &[users(b"home", true)], + signs_out: true, ..server("shell", b"bin/shell", SMALL) }]; diff --git a/crates/init/src/selftest.rs b/crates/init/src/selftest.rs index e2be7ca..a2958cc 100644 --- a/crates/init/src/selftest.rs +++ b/crates/init/src/selftest.rs @@ -39,7 +39,7 @@ fn start_spinner(storage: &mut Storage, scratch: &mut [u8], page: CapSlot, words for w in words { w.store(0, Ordering::SeqCst); } - let spinner = crate::load::load_binary(storage, scratch, b"bin/smptest", crate::manifest::SMALL).ok_or("cannot load bin/smptest")?; + let spinner = crate::load::load_binary(storage, scratch, b"bin/smptest", crate::manifest::SMALL, crate::load::STACK_PAGES).ok_or("cannot load bin/smptest")?; let rw = PermissionSet::empty() .with(Permission::Read) .with(Permission::Write) diff --git a/crates/init/src/session.rs b/crates/init/src/session.rs new file mode 100644 index 0000000..66817a1 --- /dev/null +++ b/crates/init/src/session.rs @@ -0,0 +1,163 @@ +//! Signing in and out (see the vision's Users note). The gatekeeper says +//! who is in; init clears away what must go (everyone's files on a new +//! keystore, a removed user's, the last guest's), hands storage the user +//! and their key, readies their folder and starts their session. Signing +//! out stops everything the session ran, locks storage, and the gatekeeper +//! runs again. + +use libkos::fs::Storage; +use libkos::syscall; +use types::bounded::CapSlot; +use types::gatekeeper::{KEY, NAME, REMOVED, SEED, UNLOCK, reply, unlocked}; +use types::user::UserFolder; + +use crate::launch::Launcher; +use crate::manifest::{GATEKEEPER, SESSION}; + +/// Users a keystore holds, so removed at one sign-in at most. +const MAX_REMOVED: usize = 8; +const LONGEST_NAME: usize = 16; + +#[derive(Clone, Copy)] +pub(crate) struct User { + pub(crate) folder: UserFolder, + pub(crate) guest: bool, +} + +/// All the gatekeeper hands over. +struct Handover { + flags: usize, + id: u64, + key: [usize; 4], + seed: [usize; 4], + name: [u8; LONGEST_NAME], + name_len: usize, + removed: [Option; MAX_REMOVED], +} + +impl Drop for Handover { + fn drop(&mut self) { + self.key.fill(0); + self.seed.fill(0); + } +} + +/// Waits for the gatekeeper behind `ep` to let someone in. +fn ask(ep: CapSlot) -> Result { + let [reply::OK, flags, id, _role] = syscall::request(ep, [UNLOCK, 0, 0, 0]).map_err(|_| "the gatekeeper is gone")? else { + return Err("the gatekeeper could not unlock"); + }; + let mut h = Handover { flags, id: id as u64, key: [0; 4], seed: [0; 4], name: [0; LONGEST_NAME], name_len: 0, removed: [None; MAX_REMOVED] }; + h.key = syscall::request(ep, [KEY, 0, 0, 0]).map_err(|_| "the gatekeeper gave no key")?; + h.seed = syscall::request(ep, [SEED, 0, 0, 0]).map_err(|_| "the gatekeeper gave no seed")?; + let name = syscall::request(ep, [NAME, 0, 0, 0]).map_err(|_| "the gatekeeper gave no name")?; + if let Some((text, len)) = types::log::text_of(name) { + h.name_len = len.min(LONGEST_NAME); + h.name[..h.name_len].copy_from_slice(&text[..h.name_len]); + } + for slot in h.removed.iter_mut() { + match syscall::request(ep, [REMOVED, 0, 0, 0]) { + Ok([reply::OK, id, ..]) => *slot = Some(id as u64), + _ => break, + } + } + Ok(h) +} + +fn user_path(folder: UserFolder, buf: &mut [u8; 32]) -> &[u8] { + buf[..6].copy_from_slice(b"users/"); + let f = folder.as_bytes(); + buf[6..6 + f.len()].copy_from_slice(f); + &buf[..6 + f.len()] +} + +/// Removes `folder`'s files; none there is fine. +fn purge(fs: &mut Storage, folder: UserFolder) -> Result<(), &'static str> { + fs.purge(user_path(folder, &mut [0; 32])).map_err(|_| "cannot remove a user's files") +} + +/// Runs the gatekeeper until someone is in and starts their session. +pub(crate) fn sign_in(l: &mut Launcher) -> Result<(), &'static str> { + libkos::print!("init: loading gatekeeper... "); + let gatekeeper = l.launch(&GATEKEEPER)?; + libkos::println!("ok{}", gatekeeper.note); + let handover = gatekeeper.ep.ok_or("the gatekeeper serves nothing").and_then(ask); + // It exits on its own once it has handed over. + syscall::drop_cap(gatekeeper.process); + let h = handover?; + let guest = h.flags & unlocked::GUEST != 0; + let folder = if guest { UserFolder::GUEST } else { UserFolder::of(h.id) }; + let (fs, scratch) = l.storage(); + // While storage is locked: it removes trees only then. + if h.flags & unlocked::FRESH != 0 { + fs.purge(b"users").map_err(|_| "cannot clear the users of an old keystore")?; + libkos::println!("init: a new keystore, every earlier user's files removed"); + } + for id in h.removed.iter().flatten() { + purge(fs, UserFolder::of(*id))?; + libkos::println!("init: a removed user's files removed"); + } + if guest { + purge(fs, UserFolder::GUEST)?; + } + let handed = fs.set_user(h.id, guest).is_ok() + && [(false, h.key), (true, h.seed)].iter().all(|(seed, w)| (0..2).all(|half| fs.set_half(*seed, half, [w[half * 2], w[half * 2 + 1]]).is_ok())); + if !handed { + return Err("storage would not take the key"); + } + crate::factory::prepare(fs, folder, scratch)?; + let name = core::str::from_utf8(&h.name[..h.name_len]).unwrap_or("someone"); + libkos::println!("init: {} is in", name); + l.user = Some(User { folder, guest }); + start_session(l); + Ok(()) +} + +/// Starts what runs for a signed-in user, keeping each process to stop +/// at sign-out. +fn start_session(l: &mut Launcher) { + let desktop = l.desktop; + for p in SESSION.iter().chain(desktop) { + libkos::print!("init: loading {}... ", p.name); + match l.launch(p) { + Ok(started) => { + libkos::println!("ok{}", started.note); + match l.running.iter_mut().find(|r| r.is_none()) { + Some(free) => *free = Some(started.process), + None => { + // Untracked, it could not be stopped at sign-out. + let _ = syscall::kill(started.process); + syscall::drop_cap(started.process); + } + } + } + Err(why) => libkos::println!("SKIP ({})", why), + } + } +} + +/// Stops the session, locks storage, and lets the next one in. +pub(crate) fn sign_out(l: &mut Launcher) { + l.spawner.clear(); + for process in l.running.iter_mut().filter_map(Option::take) { + let _ = syscall::kill(process); + syscall::drop_cap(process); + } + if let Some(terminal) = l.served("terminal") { + let _ = syscall::request(terminal, [types::terminal::SIGNED_OUT, 0, 0, 0]); + } + let user = l.user.take(); + let (fs, _) = l.storage(); + let locked = fs.lock().is_ok(); + // A guest leaves nothing behind. + let cleared = !user.is_some_and(|u| u.guest) || purge(fs, UserFolder::GUEST).is_ok(); + libkos::println!("init: signed out"); + let next = match locked && cleared { + true => sign_in(l), + false => Err("storage would not lock"), + }; + if let Err(why) = next { + libkos::println!("init: STOP ({})", why); + crate::idle(); + } +} diff --git a/crates/init/src/spawn.rs b/crates/init/src/spawn.rs index 371c7b7..b667ce7 100644 --- a/crates/init/src/spawn.rs +++ b/crates/init/src/spawn.rs @@ -68,6 +68,17 @@ impl Spawner { } } +impl Spawner { + /// Sign-out: every client goes, and every child is stopped. + pub(crate) fn clear(&mut self) { + for child in self.children.iter_mut().filter_map(Option::take) { + let _ = syscall::kill(child.process); + syscall::drop_cap(child.process); + } + self.allowances = [None; MAX_CLIENTS]; + } +} + /// Where init maps a spawn request's file grant while it reads it. const PROGRAM_BUF_VA: VirtualAddress = VirtualAddress::from_raw(0x01A0_0000); @@ -115,7 +126,7 @@ impl Launcher<'_> { // The grant is the file: its path inside it is empty. let found = file.open(b"").and_then(|h| file.close(h)).is_ok(); let (_, scratch) = self.storage(); - let loaded = found.then(|| load::load_binary(&mut file, scratch, b"", r.limits)).flatten(); + let loaded = found.then(|| load::load_binary(&mut file, scratch, b"", r.limits, load::STACK_PAGES)).flatten(); file.detach(); let Some(loaded) = loaded else { return refused(if found { reply::ERROR } else { reply::NOT_FOUND }); @@ -170,6 +181,12 @@ impl Server for Launcher<'_> { } fn handle_call(&mut self, badge: usize, words: [usize; 4], cap: Option) -> ([usize; 4], Option) { + if badge == types::spawn::SESSION_BADGE { + if words[0] == types::spawn::SIGN_OUT { + crate::session::sign_out(self); + } + return ([MALFORMED, 0, 0, 0], None); + } match (Request::decode(words), cap) { (Some(r), Some(binary)) => { let (reply, process) = self.spawn(badge, &r, binary); diff --git a/crates/libkos/src/fs.rs b/crates/libkos/src/fs.rs index 23518d5..e4334dc 100644 --- a/crates/libkos/src/fs.rs +++ b/crates/libkos/src/fs.rs @@ -141,6 +141,30 @@ impl Storage { self.with_path(op::RMDIR, path, reply::OK).map(|_| ()) } + /// Init only, while nobody is signed in: removes the tree at `path` + /// (in `users/`) without its key. + pub fn purge(&mut self, path: &[u8]) -> Result<()> { + self.with_path(op::PURGE, path, reply::OK).map(|_| ()) + } + + /// Init only: whose areas the key about to be sent opens (see + /// `types::user`). + pub fn set_user(&mut self, id: u64, guest: bool) -> Result<()> { + self.expect([op::SET_USER, id as usize, usize::from(guest), 0], reply::OK).map(|_| ()) + } + + /// Init only: half (`half` 0 or 1) of the master key, or with `seed` of + /// the seed for storage's randomness. + pub fn set_half(&mut self, seed: bool, half: usize, words: [usize; 2]) -> Result<()> { + let opcode = if seed { op::SET_SEED } else { op::SET_KEY }; + self.expect([opcode, half, words[0], words[1]], reply::OK).map(|_| ()) + } + + /// Init only: the user signed out; every grant and file is gone. + pub fn lock(&mut self) -> Result<()> { + self.expect([op::LOCK, 0, 0, 0], reply::OK).map(|_| ()) + } + pub fn close(&mut self, handle: FileHandle) -> Result<()> { self.expect([op::CLOSE, handle.0 as usize, 0, 0], reply::OK).map(|_| ()) } diff --git a/crates/servers/gatekeeper/src/flow.rs b/crates/servers/gatekeeper/src/flow.rs index 3b61844..78af43a 100644 --- a/crates/servers/gatekeeper/src/flow.rs +++ b/crates/servers/gatekeeper/src/flow.rs @@ -94,16 +94,21 @@ pub struct Unlocked { pub removed: [Option; SLOTS], } +// Each step keeps its records and buffers (KiBs) to itself: inlined into +// `run` together they would overflow the 16 KiB stack init gives a program. + /// The keystore's newest record, and where the next write goes. +#[inline(never)] fn load(ks: &mut impl Keystore) -> Result<(Option, usize), Failure> { - let (mut a, mut b) = ([0u8; RECORD_SIZE], [0u8; RECORD_SIZE]); + let (mut a, mut b) = (alloc::boxed::Box::new([0u8; RECORD_SIZE]), alloc::boxed::Box::new([0u8; RECORD_SIZE])); if !ks.read(0, &mut a) || !ks.read(1, &mut b) { return Err(Failure::Device); } - let copies = Copies { copies: [&a, &b] }; + let copies = Copies { copies: [&a[..], &b[..]] }; Ok((copies.newest().map(|(r, _)| r), copies.next_copy())) } +#[inline(never)] fn store(ks: &mut impl Keystore, copy: usize, r: &Record) -> Result<(), Failure> { if ks.write(copy, &r.encode()) { Ok(()) } else { Err(Failure::Device) } } @@ -117,6 +122,7 @@ fn save(ks: &mut impl Keystore, r: &Record) -> Result<(), Failure> { /// Destroys every slot and the directory: noise over both copies, twice, /// then blank (the device then looks never set up). Marked as wiping /// first, so a power cut part way is finished on the next boot. +#[inline(never)] fn wipe(ks: &mut impl Keystore, record: &Record, next: usize, rng: &mut impl Random) -> Result<(), Failure> { let first = record.wiping(rng).ok_or(Failure::Random)?; let second = first.wiping(rng).ok_or(Failure::Random)?; @@ -175,6 +181,7 @@ fn ask_passcodes(prompt: &mut impl Prompt, real: &mut Secret, duress: &mut Secre } /// A device never set up (or wiped): its first user, an owner. +#[inline(never)] fn setup(ks: &mut impl Keystore, rng: &mut impl Random, prompt: &mut impl Prompt, params: KdfParams, memory: &mut [Block]) -> Result { // After a wipe nothing earlier may show: it must look like a new device. prompt.clear(); @@ -190,6 +197,7 @@ fn setup(ks: &mut impl Keystore, rng: &mut impl Random, prompt: &mut impl Prompt } /// What the owner may change at the lock screen, after signing in. +#[inline(never)] fn manage(ks: &mut impl Keystore, rng: &mut impl Random, prompt: &mut impl Prompt, memory: &mut [Block], session: &Session, removed: &mut [Option; SLOTS]) -> Result<(), Failure> { loop { let (Some(record), _) = load(ks)? else { return Err(Failure::Device) }; diff --git a/crates/servers/permission-manager/src/lib.rs b/crates/servers/permission-manager/src/lib.rs index 1e5c641..d1a0fc0 100644 --- a/crates/servers/permission-manager/src/lib.rs +++ b/crates/servers/permission-manager/src/lib.rs @@ -22,18 +22,20 @@ use types::address::VirtualAddress; use types::bounded::CapSlot; /// Where init's manifest put them: its own endpoint, then `uses`, then -/// storage, then the spawn endpoint. +/// storage (the user's folder, the system's apps), then the spawn endpoint. const EP: CapSlot = CapSlot::const_new(0); const TERMINAL: CapSlot = CapSlot::const_new(1); const DISPLAY: CapSlot = CapSlot::const_new(2); -const STORAGE: CapSlot = CapSlot::const_new(3); -const INIT: CapSlot = CapSlot::const_new(4); +const USER: CapSlot = CapSlot::const_new(3); +const SYSTEM: CapSlot = CapSlot::const_new(4); +const INIT: CapSlot = CapSlot::const_new(5); -const STORAGE_BUF_VA: VirtualAddress = VirtualAddress::from_raw(0x0180_0000); +const USER_BUF_VA: VirtualAddress = VirtualAddress::from_raw(0x0180_0000); const SOURCE_BUF_VA: VirtualAddress = VirtualAddress::from_raw(0x0190_0000); +const SYSTEM_BUF_VA: VirtualAddress = VirtualAddress::from_raw(0x01B0_0000); pub fn run() -> ! { - let Ok(fs) = Storage::attach(STORAGE, STORAGE_BUF_VA) else { + let (Ok(user), Ok(system)) = (Storage::attach(USER, USER_BUF_VA), Storage::attach(SYSTEM, SYSTEM_BUF_VA)) else { libkos::println!("permission: no storage, exiting"); syscall::exit(); }; @@ -41,7 +43,7 @@ pub fn run() -> ! { libkos::println!("permission: no notification, exiting"); syscall::exit(); }; - let store = store::DiskStore::new(fs, SOURCE_BUF_VA); + let store = store::DiskStore::new(user, system, SOURCE_BUF_VA); let consent = consent::TerminalConsent { terminal: TERMINAL }; let mut srv = server::PermissionServer::new(store, consent, INIT, Some(DISPLAY), deaths); libkos::println!("permission: serving"); diff --git a/crates/servers/permission-manager/src/server.rs b/crates/servers/permission-manager/src/server.rs index 4a89bab..47dec0b 100644 --- a/crates/servers/permission-manager/src/server.rs +++ b/crates/servers/permission-manager/src/server.rs @@ -19,7 +19,7 @@ use types::start_arg::StartArg; use crate::consent::{CapabilityNames, TerminalConsent, version}; use crate::policy::{self, Refusal, Store}; -use crate::store::{self, DiskStore, PATH_BUF}; +use crate::store::DiskStore; /// What each app gets to run in, out of the permission manager's allowance. const PROGRAM: Limits = Limits { pages: 128, endpoints: 1, notifications: 1, regions: 1 }; @@ -164,9 +164,7 @@ impl PermissionServer { let plan = policy::plan_launch(&mut self.store, command, available).map_err(refused)?; let index = self.running.iter().position(Option::is_none).ok_or(code(reply::BUSY))?; - let mut p = [0u8; PATH_BUF]; - let binary = store::binary_path(&mut p, &plan.app).ok_or(code(reply::FAILED))?; - let file = self.store.storage().grant(binary, false).map_err(|_| code(reply::FAILED))?; + let file = self.store.binary_grant(&plan.app).ok_or(code(reply::FAILED))?; let spawned = libkos::spawn::spawn(self.init, file, PROGRAM); let _ = libkos::fs::release(file); let child = spawned.map_err(|e| match e { @@ -176,7 +174,7 @@ impl PermissionServer { let call = PermissionSet::empty().with(Permission::Execute); let data = match plan.granted.contains(Capability::Storage) { - true => store::data_path(&mut p, &plan.app.id).and_then(|d| self.store.storage().grant(d, true).ok()), + true => self.store.data_grant(&plan.app.id), false => None, }; let staged = (badge == self.staged.badge).then_some(&self.staged); diff --git a/crates/servers/permission-manager/src/store.rs b/crates/servers/permission-manager/src/store.rs index b5e6b46..57dfb61 100644 --- a/crates/servers/permission-manager/src/store.rs +++ b/crates/servers/permission-manager/src/store.rs @@ -1,5 +1,7 @@ -//! The `Store` port on the storage server: apps in `/apps//`, their -//! data in `/data//`, the owner's decisions in `/system/permissions/`. +//! The `Store` port on the storage server, through two grants: the +//! signed-in user's folder (their apps in `apps//`, each app's data in +//! `data//`, their decisions in `permissions/`) and, read-only, the +//! system image's apps (`/apps//`), which every user shares. use libkos::fs::Storage; use manifest::{AppId, Decision, MAX_DECISION_TEXT, Manifest}; @@ -10,18 +12,18 @@ use crate::policy::{MAX_MANIFEST, Store}; const APPS: &str = "apps"; const DATA: &str = "data"; -const DECISIONS: &str = "system/permissions"; +const DECISIONS: &str = "permissions"; const MANIFEST: &str = "manifest.toml"; /// Apps `find` looks through. pub const MAX_APPS: usize = 32; const LONGEST_PATH: usize = 160; const COPY_CHUNK: usize = 4096; -/// `parts` joined with `/`, in `buf`. +/// `parts` (the empty ones skipped) joined with `/`, in `buf`. fn path<'a>(buf: &'a mut [u8; LONGEST_PATH], parts: &[&str]) -> Option<&'a [u8]> { let mut len = 0; - for (i, p) in parts.iter().enumerate() { - let sep = usize::from(i > 0); + for p in parts.iter().filter(|p| !p.is_empty()) { + let sep = usize::from(len > 0); let end = len + sep + p.len(); let room = buf.get_mut(len..end)?; room[..sep].copy_from_slice(&b"/"[..sep]); @@ -63,29 +65,69 @@ fn copy(src: &mut Storage, from: &[u8], dst: &mut Storage, to: &[u8]) -> bool { ok } +/// Where an app is installed: on the system image, or by the user. +#[derive(Clone, Copy, PartialEq, Eq)] +enum Shelf { + System, + User, +} + pub struct DiskStore { - fs: Storage, + /// The signed-in user's folder. + user: Storage, + /// The system image's apps, read-only. + system: Storage, /// Where an offered app folder's grant is mapped while it is read. source_va: VirtualAddress, } impl DiskStore { - pub fn new(fs: Storage, source_va: VirtualAddress) -> Self { - Self { fs, source_va } + pub fn new(user: Storage, system: Storage, source_va: VirtualAddress) -> Self { + Self { user, system, source_va } } - /// Storage itself, for grants on the app's behalf. - pub fn storage(&mut self) -> &mut Storage { - &mut self.fs + /// The storage holding `shelf`, and its apps' folder in it. + fn on(&mut self, shelf: Shelf) -> (&mut Storage, &'static str) { + match shelf { + Shelf::System => (&mut self.system, ""), + Shelf::User => (&mut self.user, APPS), + } } - /// The ids of the installed apps (folder names in `/apps`). + /// The ids of the installed apps (folder names), the system's first. pub fn ids(&mut self, mut each: impl FnMut(AppId)) { - let _ = self.fs.list_dir(APPS.as_bytes(), |e| { - if let Some(id) = core::str::from_utf8(e.name()).ok().and_then(AppId::new) { - each(id); - } - }); + for shelf in [Shelf::System, Shelf::User] { + let (fs, apps) = self.on(shelf); + let _ = fs.list_dir(apps.as_bytes(), |e| { + if let Some(id) = core::str::from_utf8(e.name()).ok().and_then(AppId::new) { + each(id); + } + }); + } + } + + /// Which shelf holds `id`'s manifest; the system's wins, so a user + /// cannot stand in for a system app. + fn shelf(&mut self, id: &AppId) -> Option { + [Shelf::System, Shelf::User].into_iter().find(|shelf| { + let (fs, apps) = self.on(*shelf); + let mut p = [0u8; LONGEST_PATH]; + path(&mut p, &[apps, id.as_str(), MANIFEST]).is_some_and(|p| fs.open(p).and_then(|h| fs.close(h)).is_ok()) + }) + } + + /// A read-only grant to `app`'s binary, for init to load it from. + pub fn binary_grant(&mut self, app: &Manifest) -> Option { + let shelf = self.shelf(&app.id)?; + let (fs, apps) = self.on(shelf); + let mut p = [0u8; LONGEST_PATH]; + fs.grant(path(&mut p, &[apps, app.id.as_str(), app.binary.as_str()])?, false).ok() + } + + /// A read-write grant to `id`'s own data folder, for the app. + pub fn data_grant(&mut self, id: &AppId) -> Option { + let mut p = [0u8; LONGEST_PATH]; + self.user.grant(path(&mut p, &[DATA, id.as_str()])?, true).ok() } fn with_source(&self, source: CapSlot, f: impl FnOnce(&mut Storage) -> T) -> Option { @@ -108,9 +150,7 @@ impl Store for DiskStore { } fn is_installed(&mut self, id: &AppId) -> bool { - let mut p = [0u8; LONGEST_PATH]; - let Some(p) = path(&mut p, &[APPS, id.as_str(), MANIFEST]) else { return false }; - self.fs.open(p).and_then(|h| self.fs.close(h)).is_ok() + self.shelf(id).is_some() } fn install(&mut self, source: &CapSlot, app: &Manifest, data_folder: bool) -> bool { @@ -125,8 +165,8 @@ impl Store for DiskStore { }; // There already if an earlier attempt failed part way; an app // counts as installed only once its manifest is in. - let _ = self.fs.mkdir(folder); - let fs = &mut self.fs; + let _ = self.user.mkdir(folder); + let fs = &mut self.user; let copied = Storage::attach(*source, self.source_va).ok().map(|mut src| { // The binary first: an app folder with a manifest is installed. let ok = copy(&mut src, app.binary.as_str().as_bytes(), fs, to_binary) @@ -139,14 +179,16 @@ impl Store for DiskStore { } let mut data = [0u8; LONGEST_PATH]; // Kept from an earlier install if there is one: the app's data. - !data_folder || path(&mut data, &[DATA, id]).is_some_and(|d| self.fs.mkdir(d).is_ok() || self.fs.list_dir(d, |_| {}).is_ok()) + !data_folder || path(&mut data, &[DATA, id]).is_some_and(|d| self.user.mkdir(d).is_ok() || self.user.list_dir(d, |_| {}).is_ok()) } fn manifest(&mut self, id: &AppId) -> Option { + let shelf = self.shelf(id)?; + let (fs, apps) = self.on(shelf); let mut p = [0u8; LONGEST_PATH]; - let p = path(&mut p, &[APPS, id.as_str(), MANIFEST])?; + let p = path(&mut p, &[apps, id.as_str(), MANIFEST])?; let mut text = [0u8; MAX_MANIFEST]; - let m = Manifest::parse(read_text(&mut self.fs, p, &mut text)?).ok()?; + let m = Manifest::parse(read_text(fs, p, &mut text)?).ok()?; // A manifest in the wrong folder is no app. (m.id == *id).then_some(m) } @@ -167,7 +209,7 @@ impl Store for DiskStore { let mut p = [0u8; LONGEST_PATH]; let p = path(&mut p, &[DECISIONS, id.as_str()])?; let mut text = [0u8; MAX_DECISION_TEXT]; - Decision::parse(read_text(&mut self.fs, p, &mut text)?).ok() + Decision::parse(read_text(&mut self.user, p, &mut text)?).ok() } fn record(&mut self, id: &AppId, decision: &Decision) -> bool { @@ -176,21 +218,10 @@ impl Store for DiskStore { let mut text = [0u8; MAX_DECISION_TEXT]; let n = decision.write(&mut text); // Replaced whole: there is no truncate. - let _ = self.fs.delete(p); - let Ok(h) = self.fs.create(p) else { return false }; - let written = self.fs.write(h, &text[..n]) == Ok(n); - let _ = self.fs.close(h); + let _ = self.user.delete(p); + let Ok(h) = self.user.create(p) else { return false }; + let written = self.user.write(h, &text[..n]) == Ok(n); + let _ = self.user.close(h); written } } - -/// The path of app `id`'s binary and its data folder, for grants. -pub fn binary_path<'a>(buf: &'a mut [u8; LONGEST_PATH], app: &Manifest) -> Option<&'a [u8]> { - path(buf, &[APPS, app.id.as_str(), app.binary.as_str()]) -} - -pub fn data_path<'a>(buf: &'a mut [u8; LONGEST_PATH], id: &AppId) -> Option<&'a [u8]> { - path(buf, &[DATA, id.as_str()]) -} - -pub const PATH_BUF: usize = LONGEST_PATH; diff --git a/crates/servers/storage-server/src/adapters/mock_fs.rs b/crates/servers/storage-server/src/adapters/mock_fs.rs index a8f0f41..5343efc 100644 --- a/crates/servers/storage-server/src/adapters/mock_fs.rs +++ b/crates/servers/storage-server/src/adapters/mock_fs.rs @@ -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 + } } diff --git a/crates/servers/storage-server/src/encrypted.rs b/crates/servers/storage-server/src/encrypted.rs index baa0ebc..44b56c1 100644 --- a/crates/servers/storage-server/src/encrypted.rs +++ b/crates/servers/storage-server/src/encrypted.rs @@ -1,9 +1,12 @@ -//! The encrypted areas (see the vision's Encryption note), over any file -//! system: each app's `data//`, 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//` holds their areas: +//! `home/`, `permissions/`, and per app `apps//` and `data//`, +//! 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//` 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 { 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, + user: [u8; USER_NAME], + user_len: usize, rng: Option, open: [Option; MAX_HANDLES], } +/// A user folder's name: 16 hex digits, or `guest`. +pub const USER_NAME: usize = 16; + impl Encrypted { 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 Encrypted { 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, ()> { 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 FileSystem for Encrypted { 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 { let key = self.key_for(path).ok()?; let h = self.inner.open(path)?; @@ -242,8 +289,8 @@ impl FileSystem for Encrypted { } 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/`, 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 FileSystem for Encrypted { #[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", diff --git a/crates/servers/storage-server/src/filesystem.rs b/crates/servers/storage-server/src/filesystem.rs index aa171c1..57ef72a 100644 --- a/crates/servers/storage-server/src/filesystem.rs +++ b/crates/servers/storage-server/src/filesystem.rs @@ -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(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); + } + } +} diff --git a/crates/servers/storage-server/src/grants.rs b/crates/servers/storage-server/src/grants.rs index 73aceb8..d71b540 100644 --- a/crates/servers/storage-server/src/grants.rs +++ b/crates/servers/storage-server/src/grants.rs @@ -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)) { diff --git a/crates/servers/storage-server/src/protocol.rs b/crates/servers/storage-server/src/protocol.rs index d2affc4..2e74761 100644 --- a/crates/servers/storage-server/src/protocol.rs +++ b/crates/servers/storage-server/src/protocol.rs @@ -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, diff --git a/crates/servers/storage-server/src/server.rs b/crates/servers/storage-server/src/server.rs index d549e75..9e7d7c4 100644 --- a/crates/servers/storage-server/src/server.rs +++ b/crates/servers/storage-server/src/server.rs @@ -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 { /// 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, unlocked: bool, } @@ -68,6 +72,7 @@ impl StorageServer { log: None, key: [None; 2], seed: [None; 2], + user: None, unlocked: false, } } @@ -91,27 +96,6 @@ impl StorageServer { } } - /// 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 StorageServer { 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 Server for StorageServer { 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), diff --git a/crates/servers/storage-server/src/server/session.rs b/crates/servers/storage-server/src/server/session.rs new file mode 100644 index 0000000..da8549a --- /dev/null +++ b/crates/servers/storage-server/src/server/session.rs @@ -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 StorageServer { + /// 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 } + } +} diff --git a/crates/servers/storage-server/src/tests/encrypted.rs b/crates/servers/storage-server/src/tests/encrypted.rs index 5e82875..ad09a20 100644 --- a/crates/servers/storage-server/src/tests/encrypted.rs +++ b/crates/servers/storage-server/src/tests/encrypted.rs @@ -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 { - 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 = 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 { 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, path: &str) -> Vec { #[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 = (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"); +} diff --git a/crates/servers/storage-server/src/tests/ext4_real.rs b/crates/servers/storage-server/src/tests/ext4_real.rs index 06d9732..b3deade 100644 --- a/crates/servers/storage-server/src/tests/ext4_real.rs +++ b/crates/servers/storage-server/src/tests/ext4_real.rs @@ -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 = 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}"); +} diff --git a/crates/servers/storage-server/src/tests/server.rs b/crates/servers/storage-server/src/tests/server.rs index ef227b6..4cf74d6 100644 --- a/crates/servers/storage-server/src/tests/server.rs +++ b/crates/servers/storage-server/src/tests/server.rs @@ -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 = 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); +} diff --git a/crates/servers/terminal-server/src/lib.rs b/crates/servers/terminal-server/src/lib.rs index 481b6c4..dcf7eb3 100644 --- a/crates/servers/terminal-server/src/lib.rs +++ b/crates/servers/terminal-server/src/lib.rs @@ -103,6 +103,10 @@ impl server::Console for Screen { fn interrupt(&mut self, notification: CapSlot) { let _ = syscall::signal(notification); } + + fn forget(&mut self, notification: CapSlot) { + syscall::drop_cap(notification); + } } pub fn run() -> ! { diff --git a/crates/servers/terminal-server/src/server.rs b/crates/servers/terminal-server/src/server.rs index cbca61b..fe1f1fa 100644 --- a/crates/servers/terminal-server/src/server.rs +++ b/crates/servers/terminal-server/src/server.rs @@ -24,6 +24,8 @@ pub trait Console { /// Takes input focus back from whoever took it, and clears the screen. fn take_focus(&mut self); fn interrupt(&mut self, notification: CapSlot); + /// Lets go of a notification it was given. + fn forget(&mut self, notification: CapSlot); } pub const BACKSPACE: u8 = 8; @@ -232,7 +234,7 @@ impl Server for TerminalServer { Done } - fn handle_call(&mut self, _badge: usize, words: [usize; 4], cap: Option) -> ([usize; 4], Option) { + fn handle_call(&mut self, badge: usize, words: [usize; 4], cap: Option) -> ([usize; 4], Option) { let reply = match words[0] { types::terminal::READ => self.read(false), types::terminal::READ_SECRET => self.read(true), @@ -252,6 +254,13 @@ impl Server for TerminalServer { } _ => [MALFORMED, 0, 0, 0], }, + types::terminal::SIGNED_OUT if badge == 0 => { + if let Some(n) = self.on_interrupt.take() { + self.console.forget(n); + } + [0; 4] + } + types::terminal::SIGNED_OUT => [MALFORMED, 0, 0, 0], types::terminal::CLEAR => { self.handle(Request::Clear); [0; 4] diff --git a/crates/servers/terminal-server/src/tests/server.rs b/crates/servers/terminal-server/src/tests/server.rs index 78c3456..6f4274b 100644 --- a/crates/servers/terminal-server/src/tests/server.rs +++ b/crates/servers/terminal-server/src/tests/server.rs @@ -20,6 +20,7 @@ struct Fake { /// Every row drawn, with how many cells. rows_drawn: Vec<(usize, usize)>, interrupted: Vec, + forgotten: Vec, } impl Console for Fake { @@ -48,6 +49,9 @@ impl Console for Fake { fn interrupt(&mut self, notification: CapSlot) { self.interrupted.push(notification); } + fn forget(&mut self, notification: CapSlot) { + self.forgotten.push(notification); + } } fn term(keys: &[u8]) -> TerminalServer { @@ -186,3 +190,17 @@ fn a_secret_line_shows_stars_and_is_forgotten() { assert_eq!(&t.grid().row(0)[..8], b"*******\x20"); assert!(!t.grid().row(0).windows(7).any(|w| w == b"hunter2")); } + +#[test] +fn only_init_lets_the_next_session_register_for_ctrl_c() { + use server_framework::MALFORMED; + let mut t = term(&[]); + assert_eq!(on_interrupt(&mut t, 7), 0); + assert_eq!(on_interrupt(&mut t, 8), MALFORMED, "an app cannot take Ctrl+C over"); + let signed_out = |t: &mut TerminalServer, badge| t.handle_call(badge, [types::terminal::SIGNED_OUT, 0, 0, 0], None).0[0]; + assert_eq!(signed_out(&mut t, 1), MALFORMED, "nor say the user signed out"); + assert_eq!(on_interrupt(&mut t, 8), MALFORMED); + assert_eq!(signed_out(&mut t, 0), 0); + assert_eq!(t.console().forgotten, [CapSlot::new(7).unwrap()]); + assert_eq!(on_interrupt(&mut t, 9), 0, "the next shell may"); +} diff --git a/crates/tools/app-image/src/main.rs b/crates/tools/app-image/src/main.rs index ea4ecd9..2da0a96 100644 --- a/crates/tools/app-image/src/main.rs +++ b/crates/tools/app-image/src/main.rs @@ -3,7 +3,7 @@ //! //! app-image install //! an installed, pre-approved app: /apps//, and (under factory/, -//! which init seeds the encrypted areas from) its decision granting +//! which init seeds each user's folder from) its decision granting //! everything it asks for, and data// if that includes storage //! app-image stage //! an app folder the owner can install later (`install `) @@ -37,7 +37,7 @@ fn install(root: &Path, manifest: &Path, binary: &Path) -> Result<(), String> { }; let mut out = [0u8; MAX_DECISION_TEXT]; let n = decision.write(&mut out); - let decisions = root.join("factory/system/permissions"); + let decisions = root.join("factory/permissions"); fs::create_dir_all(&decisions).map_err(|e| e.to_string())?; fs::write(decisions.join(m.id.as_str()), &out[..n]).map_err(|e| e.to_string())?; if decision.granted.contains(Capability::Storage) { diff --git a/crates/types/src/lib.rs b/crates/types/src/lib.rs index 6859c39..f174dfd 100644 --- a/crates/types/src/lib.rs +++ b/crates/types/src/lib.rs @@ -24,3 +24,4 @@ pub mod storage; pub mod syscall_abi; pub mod terminal; pub mod time; +pub mod user; diff --git a/crates/types/src/spawn.rs b/crates/types/src/spawn.rs index 49fd4d8..67780f7 100644 --- a/crates/types/src/spawn.rs +++ b/crates/types/src/spawn.rs @@ -17,6 +17,12 @@ pub const STACK_TOP: usize = 0x10000; /// A slot word meaning "none". pub const NONE: usize = usize::MAX; +/// Sent one way (no reply: the sender is about to be stopped) through the +/// endpoint copy init gives a session's shell: the user signs out. +pub const SIGN_OUT: usize = 1; +/// The badge on that copy; spawn clients' badges are small indices. +pub const SESSION_BADGE: usize = 0x5E55; + pub mod reply { pub const OK: usize = 0; /// The grant names no file init can read. diff --git a/crates/types/src/storage.rs b/crates/types/src/storage.rs index 7f661fe..e5ba1b1 100644 --- a/crates/types/src/storage.rs +++ b/crates/types/src/storage.rs @@ -41,6 +41,17 @@ pub mod op { pub const SET_SEED: usize = 14; /// Removes the (empty) directory at the path in the buffer. pub const RMDIR: usize = 15; + /// Whose areas the key opens (init only, before the key): word 1 the + /// user id as the gatekeeper sent it, word 2 non-zero for a guest. See + /// `types::user`. + pub const SET_USER: usize = 16; + /// The user signed out (init only): every grant is taken back, every + /// file closed, the key forgotten; a new user, key and seed may follow. + pub const LOCK: usize = 17; + /// Removes the tree at the path in the buffer, which must be in + /// `users/`, without its key (init only, while locked): a removed + /// user's or a guest's files. + pub const PURGE: usize = 18; } /// Reply tags (word 0). diff --git a/crates/types/src/terminal.rs b/crates/types/src/terminal.rs index aa10ad7..5578136 100644 --- a/crates/types/src/terminal.rs +++ b/crates/types/src/terminal.rs @@ -12,8 +12,11 @@ pub const READ_SECRET: usize = 14; /// that drew on the screen itself. pub const REDRAW: usize = 12; /// Carries a notification the terminal signals on Ctrl+C while no line is -/// being read. Accepted once. +/// being read. Accepted once a session (see `SIGNED_OUT`). pub const ON_INTERRUPT: usize = 13; +/// From the unbadged holder (init) only, at sign-out: forgets who +/// `ON_INTERRUPT` signals, so the next session's shell may register. +pub const SIGNED_OUT: usize = 15; /// What Ctrl+C types. pub const CTRL_C: u8 = 3; diff --git a/crates/types/src/user.rs b/crates/types/src/user.rs new file mode 100644 index 0000000..1b6e4a2 --- /dev/null +++ b/crates/types/src/user.rs @@ -0,0 +1,45 @@ +//! Where a user's files live: `users//`, the folder named by the +//! user's id in hex (as the gatekeeper hands it over), or `guest`. Shared by +//! init and storage so the two name the same place. + +/// A user folder's name. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub struct UserFolder { + bytes: [u8; 16], + len: usize, +} + +impl UserFolder { + pub const GUEST: Self = Self { + bytes: *b"guest\0\0\0\0\0\0\0\0\0\0\0", + len: 5, + }; + + /// The folder of the user whose id the gatekeeper sent as `id`. + pub fn of(id: u64) -> Self { + let raw = id.to_le_bytes(); + let digits = b"0123456789abcdef"; + let bytes = + core::array::from_fn(|i| digits[(raw[i / 2] >> (4 * (1 - i % 2)) & 0xF) as usize]); + Self { bytes, len: 16 } + } + + pub fn as_bytes(&self) -> &[u8] { + &self.bytes[..self.len] + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn folders_are_hex_ids_or_guest() { + assert_eq!( + UserFolder::of(0x0123_4567_89ab_cdef).as_bytes(), + b"efcdab8967452301" + ); + assert_eq!(UserFolder::GUEST.as_bytes(), b"guest"); + assert_ne!(UserFolder::of(1), UserFolder::of(256)); + } +} diff --git a/docs/apps.md b/docs/apps.md index 6dce91e..8ec6beb 100644 --- a/docs/apps.md +++ b/docs/apps.md @@ -1,12 +1,12 @@ # Apps, manifests and the permission manager How a program gets its authority. The kernel only knows capabilities; this -is the userspace layer that decides who holds which, on the owner's behalf +is the userspace layer that decides who holds which, on the user's behalf (see the vision's Capability Management and App Manifest notes). ## Apps -Every program the owner runs is an **app**: a manifest plus a binary. +Every program a user runs is an **app**: a manifest plus a binary. An app is identified by its **id** (`kos.snake`), never by a file name or a process id. The id is what decisions, the app's data folder and (later) its encryption keys hang off. @@ -18,12 +18,12 @@ starts them from its own table, with exactly the capabilities listed there. An app's capabilities have exactly two sources: -1. **Its manifest and the owner's consent.** Services the app declared +1. **Its manifest and the user's consent.** Services the app declared (`display`, `storage`, …), granted only by the permission manager, only - as far as the owner approved. + as far as the user approved. 2. **Its launcher.** Whoever starts an app may pass along what it already holds: the terminal as its output, a read-only grant to the current - folder, a file the owner picked. A launcher can only give what it has. + folder, a file the user picked. A launcher can only give what it has. `cat` declares nothing and gets only what the shell passes it. Snake declares `display`. Nothing gets anything "just in case". @@ -31,22 +31,27 @@ declares `display`. Nothing gets anything "just in case". ## Disk layout ``` -/home/ the owner's files; the shell's `/` (encrypted) -/apps// an installed app: manifest.toml and its binary -/data// the app's private folder (its `storage`) (encrypted, its own key) -/system/permissions/ the owner's decisions, one file per app id (encrypted) -/bin/ system servers and the shell (init starts these) -/factory/ plaintext starter content the encrypted areas are - seeded from with a new master key +/apps// a system app: manifest.toml and its binary, shared by every user +/users// one user's folder (encrypted, under their own key) + home/ their files; the shell's `/` + apps// an app they installed + data// an app's private folder (its `storage`, its own key) + permissions/ their decisions, one file per app id +/bin/ system servers and the shell (init starts these) +/factory/ plaintext starter content a user's folder is + seeded from (home/, permissions/, data/) ``` -Only the permission manager reaches `/apps`, `/data` and `/system`. The -shell's storage grant is `/home`, so neither it nor anything it runs can -read another app's data or rewrite a decision. +Users each decide for themselves, even about the system's apps, and what +one installs the others do not see. An app id a system app has cannot be +installed. Only the signed-in user's folder opens. Only the permission +manager reaches their `apps/`, `data/` and `permissions/` (and `/apps`, +read-only). The shell's storage grant is their `home/`, so neither it nor +anything it runs can read an app's data or rewrite a decision. ## Manifest -`/apps//manifest.toml`, a strict subset of TOML: +`apps//manifest.toml`, a strict subset of TOML: ```toml [app] @@ -60,7 +65,7 @@ icon = "icon.png" # optional: a file in the app's folder [capabilities] required = ["display"] # cannot work without; denied = not installed -optional = [] # works without; the owner decides each +optional = [] # works without; the user decides each [appearance] accent = "#e91e63" # optional: checked for format here, contrast by K-UI @@ -79,29 +84,29 @@ optional one is simply not granted. | Capability | What the app gets | |---|---| | `display` | the display server: the screen, and the keyboard while in focus | -| `storage` | `/data//`, read-write (its own folder only) | +| `storage` | the user's `data//`, read-write (its own folder only) | ## Decisions -`/system/permissions/`, the same TOML subset: +The user's `permissions/`, the same TOML subset: ```toml [decision] granted = ["display"] ``` -Written by the permission manager when the owner decides; apps on the +Written by the permission manager when the user decides; apps on the system image ship with their decisions already recorded under -`factory/system/` (pre-approved by whoever built the image), and init -seeds them into the encrypted `system/` on a fresh setup. Changing one later is the owner's call, through -the permission manager. +`factory/permissions/` (pre-approved by whoever built the image), and init +seeds them into each new user's `permissions/`. Changing one later is that +user's call, through the permission manager. ## Permission manager A system server with no hardware. Its logic (checking manifests, deciding what an app gets, keeping decisions) is pure and generic over two ports: -- `Consent`: asks the owner. Today: a yes/no prompt on the terminal. +- `Consent`: asks the user. Today: a yes/no prompt on the terminal. - `Store`: reads manifests and decisions, writes decisions, installs files. Today: the storage server. @@ -113,9 +118,9 @@ From a folder the launcher passes as a read-only grant: 1. Read and check its `manifest.toml`; refuse if the id is installed. 2. Required capabilities this device lacks: refuse. -3. Ask the owner about the required ones together (no = not installed), +3. Ask the user about the required ones together (no = not installed), then each optional one. -4. Copy the binary and manifest to `/apps//`, create `/data//` if +4. Copy the binary and manifest to the user's `apps//`, create `data//` if `storage` was granted, record the decision. ### Launch @@ -136,7 +141,7 @@ permission manager: ### Revoke -The owner withdraws a capability: the decision changes and the next launch +The user withdraws a capability: the decision changes and the next launch gets less. A running app that held it is **stopped**: all its access dies at once. (Endpoint capabilities like `display` are copies the permission manager cannot take back from one app alone yet; once servers issue diff --git a/docs/ipc.md b/docs/ipc.md index 02f9ae5..f5bf114 100644 --- a/docs/ipc.md +++ b/docs/ipc.md @@ -91,22 +91,37 @@ and budget listed there. Storage, started before the log server, receives its log endpoint from init later (`SET_LOG`, accepted only from the unbadged holder of its endpoint). -Nothing that touches user data starts before the owner is authenticated. +Nothing that touches a user's files starts before someone has signed in. Init starts the log, input, display and terminal servers, then the gatekeeper (`types::gatekeeper`), handing it only the terminal, the keystore (a small disk of its own, told from the data disk by its size) and the -virtio-rng device. Init calls `UNLOCK`, which returns once the owner is in, -then `KEY`, whose reply is the master key; init passes it to storage -(`SET_KEY`, from the unbadged holder only, each half once), and a fresh -32-byte seed (`SEED` → `SET_SEED`) for storage's randomness this boot. -Storage's encrypted areas (`home/`, `system/`, each app's `data//`; -crate `filecrypt`, layer `storage_server::encrypted`) open once both are -in. With a new key (a fresh device, or one just wiped: `UNLOCK`'s word 1) -init empties those areas and seeds them from the plaintext `factory/`; -an area that is missing is seeded too. Only then does init start the -permission manager and the desktop. If the gatekeeper cannot -unlock, init stops there. The gatekeeper exits, and its capabilities go -with it. +virtio-rng device. Init calls `UNLOCK`, which returns once someone is in: +`[OK, flags, user id, role]`, a guest (`GUEST`) or a new keystore +(`FRESH`) among the flags. Then `KEY` (their master key), `SEED` (32 fresh +random bytes for storage this sign-in), `NAME`, and `REMOVED` until `DONE` +(users the owner removed just now). The gatekeeper exits. + +Each user's files are in `users//` (`types::user`: the id in hex, or +`guest`): `home/`, `permissions/`, and per app `apps//` and +`data//`, each sealed under its own key derived from that user's +master key (crate `filecrypt`, layer `storage_server::encrypted`); the rest +of the disk is plaintext. While storage is still locked, init removes what +must go (`PURGE`, init only, only in `users/`, only while locked): every +user's folder on a new keystore, a removed user's, the last guest's. Then +it tells storage who is in (`SET_USER`) and hands over the key and seed +(`SET_KEY`, `SET_SEED`, a half per call, each once a sign-in; init only). +Only the signed-in user's folder opens; anyone else's is refused. Init +seeds whatever area of theirs is missing from the plaintext `factory/`, +then starts the session: the permission manager (the user's folder, and +`apps/` read-only) and the desktop. If the gatekeeper cannot unlock, init +stops there. + +Signing out (`types::spawn::SIGN_OUT`, sent one way through the copy of +init's endpoint the shell holds) stops everything the session ran (the +shell, the permission manager, every app), tells the terminal +(`SIGNED_OUT`, init only), locks storage (`LOCK`, init only: every grant +taken back, every file closed, the key forgotten), removes a guest's +files, and runs the gatekeeper again. A program's `uses` endpoints arrive as badged copies with `Execute` and `Delegate`: the badge (unique per copy) tells the server which client is @@ -135,12 +150,12 @@ log endpoint is the terminal prints there with `println!`: standard output is whatever text endpoint a program was given. `READ` (`types::terminal`) blocks until a line is typed and returns it in chunks, the last ending in `\n`. Ctrl+C there drops the line. Keys typed while no line is read are -kept for the next one, except Ctrl+C, which signals the notification its -first `ON_INTERRUPT` caller gave. `REDRAW` repaints the screen and takes +kept for the next one, except Ctrl+C, which signals the notification the +session's first `ON_INTERRUPT` caller (its shell) gave. `REDRAW` repaints the screen and takes input focus back. -The desktop is the shell, given the terminal, the permission manager and -`/home` (its `/`). Everything the owner runs is an app (docs/apps.md): a +The desktop is the shell, given the terminal, the permission manager, +the user's `home/` (its `/`) and signing out. Everything the owner runs is an app (docs/apps.md): a command that is not a built-in asks the permission manager (`types::apps`, client `libkos::apps`) to run it, passing the terminal as its output and a read-only grant to the current folder (`cd`, `pwd`). The permission manager diff --git a/scripts/make-disk-image.sh b/scripts/make-disk-image.sh index d6aba99..ddb8e9a 100755 --- a/scripts/make-disk-image.sh +++ b/scripts/make-disk-image.sh @@ -37,9 +37,9 @@ staged() { fi } -# home/, system/ and data/ are encrypted, so not made here: init seeds them -# from factory/ (plaintext) once the gatekeeper has the key. -mkdir -p "${ROOT}/bin" "${ROOT}/apps" "${ROOT}/factory/home" "${ROOT}/factory/system/permissions" "${ROOT}/factory/data" +# Each user's folder in users/ is encrypted, so not filled here: init seeds +# a new user's areas from factory/ (plaintext) once they are signed in. +mkdir -p "${ROOT}/bin" "${ROOT}/apps" "${ROOT}/users" "${ROOT}/factory/home" "${ROOT}/factory/permissions" "${ROOT}/factory/data" "${ROOT}/factory/apps" # System servers and the shell: init starts these. copy display-qemu display copy input-qemu input @@ -55,7 +55,7 @@ app ls app sleep app snake staged counter inbox/counter -# The owner's first files. +# A user's first files. cp -r disk/home/. "${ROOT}/factory/home/" # Only the test image: its presence makes init run the self-test. if [ -n "${SMPTEST:-}" ]; then diff --git a/scripts/test-qemu.sh b/scripts/test-qemu.sh index de7dafb..a13b979 100755 --- a/scripts/test-qemu.sh +++ b/scripts/test-qemu.sh @@ -1,9 +1,9 @@ #!/bin/bash # Boots IMAGE headless and waits for the self-test (init's SMP checks, then # spawntest) to report; the machine halts itself when done. Twice, on one -# fresh keystore: the first boot sets a passcode up at the gatekeeper, the -# second gets it wrong once and then right. Exit 0 only if both unlock and -# both say "SMPTEST PASS". +# fresh keystore: the first boot sets up its first user at the gatekeeper, +# the second gets the passcode wrong once and then right. Exit 0 only if +# both sign in and both say "SMPTEST PASS". set -uo pipefail IMAGE="${1:?usage: test-qemu.sh IMAGE}" @@ -40,18 +40,20 @@ boot() { return $s } -# First boot: a new passcode (twice), no duress passcode (twice empty). -boot 1234 1234 "" "" +# First boot: the first user's name, a passcode (twice), no duress +# passcode (twice empty). +boot tester 1234 1234 "" "" status=$? first_ok=0 -grep -aq "the owner is in" "${LOG}" && grep -aq "SMPTEST PASS" "${LOG}" && first_ok=1 -echo "test-qemu: first boot: passcode set up: $([ ${first_ok} -eq 1 ] && echo ok || echo FAIL)" -# Second boot: the keystore remembers; a wrong guess, then the passcode. -boot 9999 1234 +grep -aq "init: tester is in" "${LOG}" && grep -aq "SMPTEST PASS" "${LOG}" && first_ok=1 +echo "test-qemu: first boot: first user set up: $([ ${first_ok} -eq 1 ] && echo ok || echo FAIL)" +# Second boot: the keystore remembers; a wrong guess, then the passcode, +# then on past the menu. +boot tester 9999 tester 1234 "" [ ${status} -eq 0 ] && status=$? second_ok=0 -grep -aq "the owner is in" "${LOG}" && grep -aq "from the last boot reads back: ok" "${LOG}" && second_ok=1 -echo "test-qemu: second boot: wrong passcode refused, right one unlocks, sealed file kept: $([ ${second_ok} -eq 1 ] && echo ok || echo FAIL)" +grep -aq "init: tester is in" "${LOG}" && grep -aq "from the last boot reads back: ok" "${LOG}" && second_ok=1 +echo "test-qemu: second boot: wrong passcode refused, right one signs in, sealed file kept: $([ ${second_ok} -eq 1 ] && echo ok || echo FAIL)" rm -f "${KEYS}" "${MON}" grep -a "smptest:\|spawntest:\|Hello\|SMPTEST" "${LOG}" @@ -62,10 +64,18 @@ if command -v e2fsck >/dev/null && command -v debugfs >/dev/null; then echo "test-qemu: e2fsck rejects the disk:"; cat "${LOG}.fsck"; disk_ok=0 elif [ "$(debugfs -R 'cat /selftest.txt' "${IMAGE}" 2>/dev/null)" != "written by k-os on ext4" ]; then echo "test-qemu: /selftest.txt is not on the disk"; disk_ok=0 - elif debugfs -R 'cat /home/readme.txt' "${IMAGE}" 2>/dev/null | grep -aq "Welcome"; then - echo "test-qemu: /home/readme.txt is plaintext on the disk"; disk_ok=0 - elif ! debugfs -R 'cat /home/readme.txt' "${IMAGE}" 2>/dev/null | head -c 8 | grep -aq "KOSFILE1"; then - echo "test-qemu: /home/readme.txt is not a sealed file"; disk_ok=0 + fi + # The one user's folder: users/<16 hex digits>. + user="$(debugfs -R 'ls /users' "${IMAGE}" 2>/dev/null | grep -aoE '[0-9a-f]{16}' | head -1)" + readme="/users/${user}/home/readme.txt" + if [ "${disk_ok}" -eq 0 ]; then + : + elif [ -z "${user}" ]; then + echo "test-qemu: no user's folder on the disk"; disk_ok=0 + elif debugfs -R "cat ${readme}" "${IMAGE}" 2>/dev/null | grep -aq "Welcome"; then + echo "test-qemu: ${readme} is plaintext on the disk"; disk_ok=0 + elif ! debugfs -R "cat ${readme}" "${IMAGE}" 2>/dev/null | head -c 8 | grep -aq "KOSFILE1"; then + echo "test-qemu: ${readme} is not a sealed file"; disk_ok=0 else echo "test-qemu: e2fsck clean, file on disk, home sealed" fi