feat(storage): per-app/owner/system encryption live: gatekeeper hands seed + fresh flag, storage unlocks layer, init seeds areas from factory/ (empties on new key); rmdir op; test-qemu checks sealed home + reboot persistence

This commit is contained in:
2026-10-28 00:07:41 +01:00
parent e8191be160
commit 9dc751c466
20 changed files with 344 additions and 72 deletions

View File

@@ -125,10 +125,46 @@ fn revoke_stops_a_running_app(died: CapSlot) -> Result<(), &'static str> {
Ok(()) 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> {
let mut home = [0u8; 512];
let mut factory = [0u8; 512];
let read = |fs: &mut Storage, path: &[u8], out: &mut [u8]| -> Result<usize, &'static str> {
let h = fs.open(path).map_err(|_| "cannot open")?;
let n = fs.read(h, out).map_err(|_| "cannot read (a seal broken?)")?;
let _ = fs.close(h);
Ok(n)
};
let n = read(fs, b"home/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");
}
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) {
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);
if !wrote {
return Err("cannot write in home");
}
libkos::println!("spawntest: wrote an encrypted file for the next boot");
}
}
Ok(())
}
fn check() -> Result<(), &'static str> { fn check() -> Result<(), &'static str> {
let mut fs = Storage::attach(STORAGE, STORAGE_BUF_VA).map_err(|_| "cannot attach to storage")?; let mut fs = Storage::attach(STORAGE, STORAGE_BUF_VA).map_err(|_| "cannot attach to storage")?;
write_a_file(&mut fs)?; write_a_file(&mut fs)?;
libkos::println!("spawntest: a file written to ext4 reads back: ok"); libkos::println!("spawntest: a file written to ext4 reads back: ok");
encrypted_home(&mut fs)?;
let died = syscall::create_notification().map_err(|_| "no notification")?; let died = syscall::create_notification().map_err(|_| "no notification")?;
narrow_storage(&mut fs, died)?; narrow_storage(&mut fs, died)?;

137
crates/init/src/factory.rs Normal file
View File

@@ -0,0 +1,137 @@
//! 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.
use libkos::fs::Storage;
use types::storage::MAX_PATH;
const AREAS: [&str; 3] = ["home", "system", "data"];
/// Entries handled per listing; a folder with more is listed again.
const BATCH: usize = 32;
const NAME: usize = types::storage::DIR_NAME_SIZE;
/// A path being built, `a/b/c`.
#[derive(Clone, Copy)]
struct Path {
bytes: [u8; MAX_PATH],
len: usize,
}
impl Path {
fn new(s: &str) -> Self {
let mut p = Self { bytes: [0; MAX_PATH], len: 0 };
p.bytes[..s.len()].copy_from_slice(s.as_bytes());
p.len = s.len();
p
}
fn join(&self, name: &[u8]) -> Option<Self> {
let mut p = *self;
let end = p.len + 1 + name.len();
let room = p.bytes.get_mut(p.len..end)?;
room[0] = b'/';
room[1..].copy_from_slice(name);
p.len = end;
Some(p)
}
fn as_bytes(&self) -> &[u8] {
&self.bytes[..self.len]
}
}
/// Up to `BATCH` entries of `dir`: names and whether each is a folder.
fn entries(fs: &mut Storage, dir: &Path) -> Option<([([u8; NAME], usize, bool); BATCH], usize)> {
let mut out = [([0u8; NAME], 0, false); BATCH];
let mut n = 0;
fs.list_dir(dir.as_bytes(), |e| {
let name = e.name();
if name != b"." && name != b".." && n < BATCH {
out[n].0[..name.len()].copy_from_slice(name);
out[n].1 = name.len();
out[n].2 = e.is_dir;
n += 1;
}
})
.ok()?;
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")?;
let dst = fs.create(to.as_bytes()).map_err(|_| "cannot create a file")?;
let done = loop {
match fs.read(src, buf) {
Ok(0) => break Ok(()),
Ok(n) if fs.write(dst, &buf[..n]) == Ok(n) => {}
_ => break Err("cannot copy a factory file"),
}
};
let _ = fs.close(src);
let _ = fs.close(dst);
done
}
/// Copies everything in `from` into `to` (which exists).
fn copy_tree(fs: &mut Storage, from: &Path, to: &Path, buf: &mut [u8]) -> Result<(), &'static str> {
let (list, n) = match entries(fs, from) {
Some(l) => l,
// Nothing shipped for this area.
None => return Ok(()),
};
for (name, len, is_dir) in &list[..n] {
let (src, dst) = (from.join(&name[..*len]).ok_or("path too long")?, to.join(&name[..*len]).ok_or("path too long")?);
if *is_dir {
fs.mkdir(dst.as_bytes()).map_err(|_| "cannot make a folder")?;
copy_tree(fs, &src, &dst, buf)?;
} else {
copy_file(fs, &src, &dst, buf)?;
}
}
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 {
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);
}
Ok(())
}

View File

@@ -114,8 +114,9 @@ impl<'a> Launcher<'a> {
note = self.give(*device, ep); note = self.give(*device, ep);
} }
if p.unlocks { if p.unlocks {
unlock(ep)?; let fresh = unlock(ep)?;
note = ", the owner is in"; 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() { if console.is_some() {
hand_over_console(ep); hand_over_console(ep);
@@ -154,20 +155,26 @@ impl<'a> Launcher<'a> {
} }
/// Waits for the gatekeeper behind `ep` to authenticate the owner, then /// Waits for the gatekeeper behind `ep` to authenticate the owner, then
/// passes the master key on to storage, which alone keeps it. /// passes the master key and a fresh seed on to storage, which alone keeps
fn unlock(ep: CapSlot) -> Result<(), &'static str> { /// them. Whether the key is new.
use types::gatekeeper::{KEY, UNLOCK, reply}; fn unlock(ep: CapSlot) -> Result<bool, &'static str> {
match syscall::request(ep, [UNLOCK, 0, 0, 0]) { use types::gatekeeper::{KEY, SEED, UNLOCK, reply};
Ok([reply::OK, ..]) => {} let fresh = match syscall::request(ep, [UNLOCK, 0, 0, 0]) {
Ok([reply::OK, fresh, ..]) => fresh != 0,
_ => return Err("the gatekeeper could not unlock"), _ => return Err("the gatekeeper could not unlock"),
} };
let mut key = syscall::request(ep, [KEY, 0, 0, 0]).map_err(|_| "the gatekeeper gave no key")?; 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 given = [0, 1].iter().all(|&half| {
let words = [types::storage::op::SET_KEY, half, key[half * 2], key[half * 2 + 1]]; 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) syscall::request(crate::STORAGE_EP, words).is_ok_and(|r| r[0] == types::storage::reply::OK)
}); });
key.fill(0); secret.fill(0);
if given { Ok(()) } else { Err("storage would not take the key") } if !given {
return Err("storage would not take the key");
}
}
Ok(fresh)
} }
/// From here on init prints through the log server, like everyone else, /// From here on init prints through the log server, like everyone else,

View File

@@ -4,6 +4,7 @@
extern crate alloc; extern crate alloc;
mod devices; mod devices;
mod factory;
mod launch; mod launch;
mod load; mod load;
mod manifest; mod manifest;

View File

@@ -136,6 +136,11 @@ impl Storage {
self.with_path(op::MKDIR, path, reply::OK).map(|_| ()) self.with_path(op::MKDIR, path, reply::OK).map(|_| ())
} }
/// Removes the empty directory at `path`.
pub fn rmdir(&mut self, path: &[u8]) -> Result<()> {
self.with_path(op::RMDIR, path, reply::OK).map(|_| ())
}
pub fn close(&mut self, handle: FileHandle) -> Result<()> { pub fn close(&mut self, handle: FileHandle) -> Result<()> {
self.expect([op::CLOSE, handle.0 as usize, 0, 0], reply::OK).map(|_| ()) self.expect([op::CLOSE, handle.0 as usize, 0, 0], reply::OK).map(|_| ())
} }

View File

@@ -151,14 +151,21 @@ fn setup(ks: &mut impl Keystore, rng: &mut impl Random, prompt: &mut impl Prompt
Ok(key) Ok(key)
} }
/// The owner is in: the master key, and whether it is new (a fresh
/// device, or one just wiped), so nothing sealed under an old one exists.
pub struct Unlocked {
pub key: Key,
pub fresh: bool,
}
/// Authenticates the owner and returns the master key: setting one up on /// Authenticates the owner and returns the master key: setting one up on
/// a fresh device, wiping on duress or too many failures (the device then /// a fresh device, wiping on duress or too many failures (the device then
/// looks fresh, and is set up anew). /// looks fresh, and is set up anew).
pub fn run(ks: &mut impl Keystore, rng: &mut impl Random, prompt: &mut impl Prompt, params: KdfParams, memory: &mut [Block]) -> Result<Key, Failure> { pub fn run(ks: &mut impl Keystore, rng: &mut impl Random, prompt: &mut impl Prompt, params: KdfParams, memory: &mut [Block]) -> Result<Unlocked, Failure> {
loop { loop {
let (record, next) = load(ks)?; let (record, next) = load(ks)?;
let Some(record) = record else { let Some(record) = record else {
return setup(ks, rng, prompt, params, memory); return setup(ks, rng, prompt, params, memory).map(|key| Unlocked { key, fresh: true });
}; };
if record.must_wipe() { if record.must_wipe() {
wipe(ks, &record, next, rng)?; wipe(ks, &record, next, rng)?;
@@ -174,7 +181,7 @@ pub fn run(ks: &mut impl Keystore, rng: &mut impl Random, prompt: &mut impl Prom
match record.unlock(typed.as_bytes(), memory) { match record.unlock(typed.as_bytes(), memory) {
Outcome::Real(key) => { Outcome::Real(key) => {
store(ks, 1 - next, &counted.next(|r| r.failures = 0))?; store(ks, 1 - next, &counted.next(|r| r.failures = 0))?;
return Ok(key); return Ok(Unlocked { key, fresh: false });
} }
Outcome::Duress => { Outcome::Duress => {
// Recorded as worn out first: a wipe cut short resumes. // Recorded as worn out first: a wipe cut short resumes.

View File

@@ -18,7 +18,7 @@ use alloc::vec;
use keystore::{Block, KdfParams}; use keystore::{Block, KdfParams};
use libkos::syscall::{self, Received}; use libkos::syscall::{self, Received};
use types::bounded::CapSlot; use types::bounded::CapSlot;
use types::gatekeeper::{KEY, UNLOCK, reply}; use types::gatekeeper::{KEY, SEED, UNLOCK, reply};
/// Where init's manifest put them: its own endpoint (devices arrive on /// Where init's manifest put them: its own endpoint (devices arrive on
/// it, then init's calls), then the terminal. /// it, then init's calls), then the terminal.
@@ -53,9 +53,10 @@ pub fn run() -> ! {
}; };
let mut memory = vec![Block::default(); KdfParams::DEFAULT.memory_kib as usize]; let mut memory = vec![Block::default(); KdfParams::DEFAULT.memory_kib as usize];
libkos::println!("gatekeeper: waiting for the owner"); libkos::println!("gatekeeper: waiting for the owner");
let key = flow::run( let mut random = devices::Hardware(random);
let unlocked = flow::run(
&mut devices::Disk(keystore), &mut devices::Disk(keystore),
&mut devices::Hardware(random), &mut random,
&mut devices::Terminal(TERMINAL), &mut devices::Terminal(TERMINAL),
KdfParams::DEFAULT, KdfParams::DEFAULT,
&mut memory, &mut memory,
@@ -65,14 +66,19 @@ pub fn run() -> ! {
*b = Block::default(); *b = Block::default();
} }
drop(memory); drop(memory);
match key { match unlocked {
Ok(key) => { Ok(flow::Unlocked { key, fresh }) => {
libkos::println!("gatekeeper: unlocked"); libkos::println!("gatekeeper: unlocked");
libkos::terminal::clear(TERMINAL); libkos::terminal::clear(TERMINAL);
let word = |i: usize| usize::from_le_bytes(key.0[i * 8..i * 8 + 8].try_into().unwrap_or([0; 8])); let words = |b: &[u8; 32]| core::array::from_fn::<usize, 4, _>(|i| usize::from_le_bytes(b[i * 8..i * 8 + 8].try_into().unwrap_or([0; 8])));
if answer(UNLOCK, [reply::OK, 0, 0, 0]) { let mut seed = [0u8; 32];
let _ = answer(KEY, [word(0), word(1), word(2), word(3)]); let seeded = keystore::Random::fill(&mut random, &mut seed);
if seeded && answer(UNLOCK, [reply::OK, usize::from(fresh), 0, 0]) && answer(KEY, words(&key.0)) {
let _ = answer(SEED, words(&seed));
} else if !seeded {
let _ = answer(UNLOCK, [reply::FAILED, 0, 0, 0]);
} }
seed.fill(0);
} }
Err(why) => { Err(why) => {
libkos::println!("gatekeeper: cannot go on: {:?}", why); libkos::println!("gatekeeper: cannot go on: {:?}", why);

View File

@@ -101,7 +101,11 @@ fn memory() -> Vec<Block> {
} }
fn run_with(dev: &mut Device, owner: &mut Owner) -> Result<keystore::Key, Failure> { fn run_with(dev: &mut Device, owner: &mut Owner) -> Result<keystore::Key, Failure> {
run(dev, &mut Counter(5), owner, FAST, &mut memory()) run(dev, &mut Counter(5), owner, FAST, &mut memory()).map(|u| u.key)
}
fn fresh(dev: &mut Device, owner: &mut Owner) -> bool {
run(dev, &mut Counter(5), owner, FAST, &mut memory()).unwrap().fresh
} }
/// A device set up with passcode 1234 and duress 0000; its master key. /// A device set up with passcode 1234 and duress 0000; its master key.
@@ -219,3 +223,11 @@ fn a_duress_whose_wipe_is_torn_still_wipes_next_boot() {
dev.cut_after = None; dev.cut_after = None;
assert!(dev.record().unwrap().must_wipe(), "the duress was recorded before the wipe began"); assert!(dev.record().unwrap().must_wipe(), "the duress was recorded before the wipe began");
} }
#[test]
fn only_a_new_master_key_counts_as_fresh() {
let mut dev = Device::new();
assert!(fresh(&mut dev, &mut Owner::types(&["1234", "1234", "0000", "0000"])), "first boot");
assert!(!fresh(&mut dev, &mut Owner::types(&["1234"])), "an unlock");
assert!(fresh(&mut dev, &mut Owner::types(&["0000", "5678", "5678", "", ""])), "after a duress wipe");
}

View File

@@ -76,6 +76,6 @@ pub fn run() -> ! {
libkos::println!("storage: no ext4 filesystem it can read, exiting"); libkos::println!("storage: no ext4 filesystem it can read, exiting");
syscall::exit(); syscall::exit();
}; };
let mut srv = server::StorageServer::new(ext4, Kernel); let mut srv = server::StorageServer::new(encrypted::Encrypted::new(ext4), Kernel);
server_framework::serve(&mut srv, ep) server_framework::serve(&mut srv, ep)
} }

View File

@@ -27,6 +27,9 @@ pub enum StorageRequest {
SetLog, SetLog,
/// Half (`half`: 0 or 1) of the master key. /// Half (`half`: 0 or 1) of the master key.
SetKey { half: usize, words: [usize; 2] }, SetKey { half: usize, words: [usize; 2] },
/// Half of the seed for randomness.
SetSeed { half: usize, words: [usize; 2] },
Rmdir { path_len: usize },
/// A capability to the path (`path_len` bytes of the buffer), inside /// A capability to the path (`path_len` bytes of the buffer), inside
/// the caller's scope. /// the caller's scope.
Grant { Grant {
@@ -77,6 +80,8 @@ impl Encode for StorageRequest {
Mkdir { path_len } => [op::MKDIR, path_len, 0, 0], Mkdir { path_len } => [op::MKDIR, path_len, 0, 0],
SetLog => [op::SET_LOG, 0, 0, 0], SetLog => [op::SET_LOG, 0, 0, 0],
SetKey { half, words } => [op::SET_KEY, half, words[0], words[1]], 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],
Grant { path_len, write } => [op::GRANT, path_len, usize::from(write), 0], Grant { path_len, write } => [op::GRANT, path_len, usize::from(write), 0],
Release => [op::RELEASE, 0, 0, 0], Release => [op::RELEASE, 0, 0, 0],
} }
@@ -99,6 +104,8 @@ impl Decode for StorageRequest {
op::MKDIR => Mkdir { path_len: w[1] }, op::MKDIR => Mkdir { path_len: w[1] },
op::SET_LOG => SetLog, op::SET_LOG => SetLog,
op::SET_KEY => SetKey { half: w[1], words: [w[2], w[3]] }, 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::GRANT => Grant { op::GRANT => Grant {
path_len: w[1], path_len: w[1],
write: w[2] != 0, write: w[2] != 0,

View File

@@ -50,9 +50,11 @@ pub struct StorageServer<F: FileSystem, H: Host> {
clients: [Option<Client>; MAX_CLIENTS], clients: [Option<Client>; MAX_CLIENTS],
owners: [Option<Owner>; MAX_HANDLES], owners: [Option<Owner>; MAX_HANDLES],
log: Option<CapSlot>, log: Option<CapSlot>,
/// The master key's halves, from the gatekeeper through init: what the /// The master key's and the seed's halves, from the gatekeeper through
/// disk's encryption will be keyed with. /// init; handed to the file system (and forgotten here) once all in.
key: [Option<[usize; 2]>; 2], key: [Option<[usize; 2]>; 2],
seed: [Option<[usize; 2]>; 2],
unlocked: bool,
} }
impl<F: FileSystem, H: Host> StorageServer<F, H> { impl<F: FileSystem, H: Host> StorageServer<F, H> {
@@ -65,6 +67,8 @@ impl<F: FileSystem, H: Host> StorageServer<F, H> {
owners: [None; MAX_HANDLES], owners: [None; MAX_HANDLES],
log: None, log: None,
key: [None; 2], key: [None; 2],
seed: [None; 2],
unlocked: false,
} }
} }
@@ -88,20 +92,29 @@ impl<F: FileSystem, H: Host> StorageServer<F, H> {
} }
/// Only the unbadged holder (init), each half only once: nobody can /// Only the unbadged holder (init), each half only once: nobody can
/// replace the key later. /// replace the key or the seed later.
fn set_key(&mut self, badge: usize, half: usize, words: [usize; 2]) -> StorageResponse { fn set_half(&mut self, badge: usize, seed: bool, half: usize, words: [usize; 2]) -> StorageResponse {
match (badge, self.key.get_mut(half)) { let halves = if seed { &mut self.seed } else { &mut self.key };
(0, Some(slot @ None)) => { match (badge, self.unlocked, halves.get_mut(half)) {
*slot = Some(words); (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 StorageResponse::Ok
} }
_ => StorageResponse::Error,
}
}
/// Whether the whole master key has arrived. /// Whether the encrypted areas are open: the key and seed are in.
pub fn unlocked(&self) -> bool { pub fn unlocked(&self) -> bool {
self.key.iter().all(Option::is_some) self.unlocked
} }
pub fn filesystem(&self) -> &F { pub fn filesystem(&self) -> &F {
@@ -225,7 +238,7 @@ impl<F: FileSystem, H: Host> StorageServer<F, H> {
Write { handle, .. } => self Write { handle, .. } => self
.owner(handle) .owner(handle)
.is_some_and(|o| o.badge == badge && o.write), .is_some_and(|o| o.badge == badge && o.write),
Create { .. } | Delete { .. } | Mkdir { .. } => scope.write, Create { .. } | Delete { .. } | Mkdir { .. } | Rmdir { .. } => scope.write,
_ => true, _ => true,
}; };
if !allowed { if !allowed {
@@ -233,7 +246,7 @@ impl<F: FileSystem, H: Host> StorageServer<F, H> {
} }
let mutates = matches!( let mutates = matches!(
request, request,
Write { .. } | Create { .. } | Delete { .. } | Mkdir { .. } Write { .. } | Create { .. } | Delete { .. } | Mkdir { .. } | Rmdir { .. }
); );
if mutates && !self.fs.writable() { if mutates && !self.fs.writable() {
return (R::ReadOnly, None); return (R::ReadOnly, None);
@@ -263,6 +276,7 @@ impl<F: FileSystem, H: Host> StorageServer<F, H> {
| Create { path_len } | Create { path_len }
| Delete { path_len } | Delete { path_len }
| Mkdir { path_len } | Mkdir { path_len }
| Rmdir { path_len }
| ListDir { path_len } => Some(path_len), | ListDir { path_len } => Some(path_len),
_ => None, _ => None,
}; };
@@ -286,7 +300,7 @@ impl<F: FileSystem, H: Host> StorageServer<F, H> {
None => R::NotFound, None => R::NotFound,
}; };
let response = match request { let response = match request {
Attach | SetLog | SetKey { .. } | Release => R::Error, Attach | SetLog | SetKey { .. } | SetSeed { .. } | Release => R::Error,
Grant { path_len, write } => { Grant { path_len, write } => {
let narrower = scope.narrow(&buf[sent(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))) { return match narrower.and_then(|s| grants.add(badge, s, |b| host.mint(b))) {
@@ -353,6 +367,13 @@ impl<F: FileSystem, H: Host> StorageServer<F, H> {
R::Error R::Error
} }
} }
Rmdir { .. } => {
if fs.rmdir(path) {
R::Ok
} else {
R::Error
}
}
}; };
(response, None) (response, None)
} }
@@ -389,7 +410,8 @@ impl<F: FileSystem, H: Host> Server for StorageServer<F, H> {
) -> ([usize; 4], Option<Give>) { ) -> ([usize; 4], Option<Give>) {
match StorageRequest::decode(words) { match StorageRequest::decode(words) {
Some(StorageRequest::SetLog) => (self.set_log(badge, cap).encode(), None), Some(StorageRequest::SetLog) => (self.set_log(badge, cap).encode(), None),
Some(StorageRequest::SetKey { half, words }) => (self.set_key(badge, half, words).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),
// A badge storage never issued, or one it has taken back. // A badge storage never issued, or one it has taken back.
Some(request) => match self.grants.scope(badge) { Some(request) => match self.grants.scope(badge) {
None => (StorageResponse::Denied.encode(), None), None => (StorageResponse::Denied.encode(), None),

View File

@@ -26,6 +26,8 @@ fn every_request_roundtrips() {
}, },
StorageRequest::Release, StorageRequest::Release,
StorageRequest::SetKey { half: 1, words: [7, 9] }, StorageRequest::SetKey { half: 1, words: [7, 9] },
StorageRequest::SetSeed { half: 0, words: [1, 2] },
StorageRequest::Rmdir { path_len: 3 },
] { ] {
assert_eq!(StorageRequest::decode(req.encode()), Some(req)); assert_eq!(StorageRequest::decode(req.encode()), Some(req));
} }

View File

@@ -474,14 +474,19 @@ fn the_unbadged_holder_cannot_be_released() {
} }
#[test] #[test]
fn only_init_sets_the_key_and_only_once() { fn only_init_unlocks_and_only_once() {
let mut s = server(); let mut s = server();
let set = |s: &mut Srv, badge, half| call(s, badge, StorageRequest::SetKey { half, words: [1, 2] }).0; let set = |s: &mut Srv, badge, req| call(s, badge, req).0;
assert_eq!(set(&mut s, 5, 0), StorageResponse::Error, "a client cannot"); let key = |half| StorageRequest::SetKey { half, words: [1, 2] };
assert_eq!(set(&mut s, 0, 0), StorageResponse::Ok); let seed = |half| StorageRequest::SetSeed { half, words: [3, 4] };
assert!(!s.unlocked()); assert_eq!(set(&mut s, 5, key(0)), StorageResponse::Error, "a client cannot");
assert_eq!(set(&mut s, 0, 0), StorageResponse::Error, "nor can it be replaced"); assert_eq!(set(&mut s, 0, key(0)), StorageResponse::Ok);
assert_eq!(set(&mut s, 0, 2), StorageResponse::Error, "two halves only"); assert_eq!(set(&mut s, 0, key(0)), StorageResponse::Error, "nor can a half be replaced");
assert_eq!(set(&mut s, 0, 1), StorageResponse::Ok); assert_eq!(set(&mut s, 0, key(2)), StorageResponse::Error, "two halves only");
assert_eq!(set(&mut s, 0, key(1)), StorageResponse::Ok);
assert_eq!(set(&mut s, 0, seed(0)), StorageResponse::Ok);
assert!(!s.unlocked(), "not without the whole seed");
assert_eq!(set(&mut s, 0, seed(1)), StorageResponse::Ok);
assert!(s.unlocked()); assert!(s.unlocked());
assert_eq!(set(&mut s, 0, key(0)), StorageResponse::Error, "once unlocked, never again");
} }

View File

@@ -2,8 +2,9 @@
//! the same strict parser the permission manager uses: //! the same strict parser the permission manager uses:
//! //!
//! app-image install <root> <manifest.toml> <binary> //! app-image install <root> <manifest.toml> <binary>
//! an installed, pre-approved app: /apps/<id>/, its decision granting //! an installed, pre-approved app: /apps/<id>/, and (under factory/,
//! everything it asks for, and /data/<id>/ if that includes storage //! which init seeds the encrypted areas from) its decision granting
//! everything it asks for, and data/<id>/ if that includes storage
//! app-image stage <folder> <manifest.toml> <binary> //! app-image stage <folder> <manifest.toml> <binary>
//! an app folder the owner can install later (`install <folder>`) //! an app folder the owner can install later (`install <folder>`)
@@ -36,11 +37,11 @@ fn install(root: &Path, manifest: &Path, binary: &Path) -> Result<(), String> {
}; };
let mut out = [0u8; MAX_DECISION_TEXT]; let mut out = [0u8; MAX_DECISION_TEXT];
let n = decision.write(&mut out); let n = decision.write(&mut out);
let decisions = root.join("system/permissions"); let decisions = root.join("factory/system/permissions");
fs::create_dir_all(&decisions).map_err(|e| e.to_string())?; 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())?; fs::write(decisions.join(m.id.as_str()), &out[..n]).map_err(|e| e.to_string())?;
if decision.granted.contains(Capability::Storage) { if decision.granted.contains(Capability::Storage) {
fs::create_dir_all(root.join("data").join(m.id.as_str())).map_err(|e| e.to_string())?; fs::create_dir_all(root.join("factory/data").join(m.id.as_str())).map_err(|e| e.to_string())?;
} }
Ok(()) Ok(())
} }

View File

@@ -1,11 +1,15 @@
//! Init and the gatekeeper: after handing it the keystore and randomness //! Init and the gatekeeper: after handing it the keystore and randomness
//! (device setup calls), init calls `UNLOCK`, which returns once the owner //! (device setup calls), init calls `UNLOCK`, which returns once the owner
//! is authenticated (`OK`) or the gatekeeper cannot go on (`FAILED`); then //! is authenticated (`OK`, word 1 non-zero if the master key is new: a
//! `KEY`, whose reply is the 32-byte master key in its four words. The //! fresh device or one just wiped) or the gatekeeper cannot go on
//! gatekeeper then exits. Only the unbadged holder (init) is answered. //! (`FAILED`); then `KEY`, whose reply is the 32-byte master key in its four
//! words, and `SEED`, 32 fresh random bytes from the hardware for storage's
//! randomness this boot. The gatekeeper then exits. Only the unbadged
//! holder (init) is answered.
pub const UNLOCK: usize = 1; pub const UNLOCK: usize = 1;
pub const KEY: usize = 2; pub const KEY: usize = 2;
pub const SEED: usize = 3;
pub mod reply { pub mod reply {
pub const OK: usize = 0; pub const OK: usize = 0;

View File

@@ -36,6 +36,11 @@ pub mod op {
/// Half the master key (word 1: which half, words 2..4: it), from the /// Half the master key (word 1: which half, words 2..4: it), from the
/// unbadged holder (init) only, each half once. /// unbadged holder (init) only, each half once.
pub const SET_KEY: usize = 13; pub const SET_KEY: usize = 13;
/// Half the seed for storage's randomness this boot, as `SET_KEY`. The
/// encrypted areas open once the whole key and seed are in.
pub const SET_SEED: usize = 14;
/// Removes the (empty) directory at the path in the buffer.
pub const RMDIR: usize = 15;
} }
/// Reply tags (word 0). /// Reply tags (word 0).

View File

@@ -31,11 +31,13 @@ declares `display`. Nothing gets anything "just in case".
## Disk layout ## Disk layout
``` ```
/home/ the owner's files; the shell's `/` /home/ the owner's files; the shell's `/` (encrypted)
/apps/<id>/ an installed app: manifest.toml and its binary /apps/<id>/ an installed app: manifest.toml and its binary
/data/<id>/ the app's private folder (its `storage` capability) /data/<id>/ the app's private folder (its `storage`) (encrypted, its own key)
/system/permissions/ the owner's decisions, one file per app id /system/permissions/ the owner's decisions, one file per app id (encrypted)
/bin/ system servers and the shell (init starts these) /bin/ system servers and the shell (init starts these)
/factory/ plaintext starter content the encrypted areas are
seeded from with a new master key
``` ```
Only the permission manager reaches `/apps`, `/data` and `/system`. The Only the permission manager reaches `/apps`, `/data` and `/system`. The
@@ -89,8 +91,9 @@ granted = ["display"]
``` ```
Written by the permission manager when the owner decides; apps on the Written by the permission manager when the owner decides; apps on the
system image ship with their decisions already recorded (pre-approved by system image ship with their decisions already recorded under
whoever built the image). Changing one later is the owner's call, through `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. the permission manager.
## Permission manager ## Permission manager

View File

@@ -97,8 +97,14 @@ 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 (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, 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 then `KEY`, whose reply is the master key; init passes it to storage
(`SET_KEY`, from the unbadged holder only, each half once) and only then (`SET_KEY`, from the unbadged holder only, each half once), and a fresh
starts the permission manager and the desktop. If the gatekeeper cannot 32-byte seed (`SEED` → `SET_SEED`) for storage's randomness this boot.
Storage's encrypted areas (`home/`, `system/`, each app's `data/<id>/`;
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 unlock, init stops there. The gatekeeper exits, and its capabilities go
with it. with it.

View File

@@ -33,11 +33,13 @@ app() {
staged() { staged() {
local crate="$1" folder="$2" local crate="$1" folder="$2"
if [ -f "${BUILD_DIR}/${crate}" ]; then if [ -f "${BUILD_DIR}/${crate}" ]; then
"${APP_IMAGE[@]}" stage "${ROOT}/home/${folder}" "crates/apps/${crate}/manifest.toml" "${BUILD_DIR}/${crate}" "${APP_IMAGE[@]}" stage "${ROOT}/factory/home/${folder}" "crates/apps/${crate}/manifest.toml" "${BUILD_DIR}/${crate}"
fi fi
} }
mkdir -p "${ROOT}/bin" "${ROOT}/apps" "${ROOT}/data" "${ROOT}/system/permissions" "${ROOT}/home" # 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"
# System servers and the shell: init starts these. # System servers and the shell: init starts these.
copy display-qemu display copy display-qemu display
copy input-qemu input copy input-qemu input
@@ -53,8 +55,8 @@ app ls
app sleep app sleep
app snake app snake
staged counter inbox/counter staged counter inbox/counter
# The owner's files. # The owner's first files.
cp -r disk/home/. "${ROOT}/home/" cp -r disk/home/. "${ROOT}/factory/home/"
# Only the test image: its presence makes init run the self-test. # Only the test image: its presence makes init run the self-test.
if [ -n "${SMPTEST:-}" ]; then if [ -n "${SMPTEST:-}" ]; then
copy smptest smptest copy smptest smptest

View File

@@ -50,8 +50,8 @@ echo "test-qemu: first boot: passcode set up: $([ ${first_ok} -eq 1 ] && echo ok
boot 9999 1234 boot 9999 1234
[ ${status} -eq 0 ] && status=$? [ ${status} -eq 0 ] && status=$?
second_ok=0 second_ok=0
grep -aq "the owner is in" "${LOG}" && second_ok=1 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: $([ ${second_ok} -eq 1 ] && echo ok || echo FAIL)" echo "test-qemu: second boot: wrong passcode refused, right one unlocks, sealed file kept: $([ ${second_ok} -eq 1 ] && echo ok || echo FAIL)"
rm -f "${KEYS}" "${MON}" rm -f "${KEYS}" "${MON}"
grep -a "smptest:\|spawntest:\|Hello\|SMPTEST" "${LOG}" grep -a "smptest:\|spawntest:\|Hello\|SMPTEST" "${LOG}"
@@ -62,8 +62,12 @@ 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 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 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 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
else else
echo "test-qemu: e2fsck clean, file on disk" echo "test-qemu: e2fsck clean, file on disk, home sealed"
fi fi
rm -f "${LOG}.fsck" rm -f "${LOG}.fsck"
fi fi