diff --git a/Cargo.lock b/Cargo.lock index b19cef5..9ca30b3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -290,6 +290,10 @@ version = "0.1.0" [[package]] name = "gatekeeper" version = "0.1.0" +dependencies = [ + "keystore", + "zeroize", +] [[package]] name = "generic-array" diff --git a/crates/servers/gatekeeper/Cargo.toml b/crates/servers/gatekeeper/Cargo.toml index 5db048a..96e5c09 100644 --- a/crates/servers/gatekeeper/Cargo.toml +++ b/crates/servers/gatekeeper/Cargo.toml @@ -4,3 +4,5 @@ version = "0.1.0" edition = "2024" [dependencies] +keystore = { workspace = true } +zeroize = { workspace = true } diff --git a/crates/servers/gatekeeper/src/flow.rs b/crates/servers/gatekeeper/src/flow.rs new file mode 100644 index 0000000..9a64454 --- /dev/null +++ b/crates/servers/gatekeeper/src/flow.rs @@ -0,0 +1,189 @@ +//! What the gatekeeper does, over its three ports: the keystore device, +//! randomness, and the owner (`Prompt`). No kernel, no hardware: tested on +//! the host. + +use keystore::{Block, Copies, KdfParams, Key, Outcome, RECORD_SIZE, Random, Record}; +use zeroize::Zeroize; + +/// The keystore device: the record's two copies. +pub trait Keystore { + fn read(&mut self, copy: usize, out: &mut [u8; RECORD_SIZE]) -> bool; + fn write(&mut self, copy: usize, data: &[u8; RECORD_SIZE]) -> bool; +} + +/// A passcode as typed, wiped from memory when dropped. +pub struct Secret { + bytes: [u8; MAX_SECRET], + len: usize, +} + +pub const MAX_SECRET: usize = 64; + +impl Secret { + pub const fn new() -> Self { + Self { bytes: [0; MAX_SECRET], len: 0 } + } + + pub fn set(&mut self, typed: &[u8]) { + self.bytes.zeroize(); + let n = typed.len().min(MAX_SECRET); + self.bytes[..n].copy_from_slice(&typed[..n]); + self.len = n; + } + + pub fn as_bytes(&self) -> &[u8] { + &self.bytes[..self.len] + } + + /// The same bytes, compared without stopping at the first difference. + fn same(&self, other: &Secret) -> bool { + let diff = self.bytes.iter().zip(&other.bytes).fold(0u8, |d, (a, b)| d | (a ^ b)); + (diff | (self.len ^ other.len) as u8) == 0 && self.len == other.len + } +} + +impl Default for Secret { + fn default() -> Self { + Self::new() + } +} + +impl Drop for Secret { + fn drop(&mut self) { + self.bytes.zeroize(); + } +} + +/// The owner, on the screen and keyboard. +pub trait Prompt { + fn say(&mut self, text: &str); + /// Shows `question` and reads a passcode without showing it; false if + /// nothing could be read. + fn secret(&mut self, question: &str, out: &mut Secret) -> bool; +} + +/// Why the gatekeeper could not hand over a key. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Failure { + /// The keystore could not be read or written. + Device, + /// No randomness for keys and salts. + Random, + /// The owner could not be asked. + Prompt, +} + +/// Wrong passcodes in a row that wipe the keys. +pub const THRESHOLD: u8 = 10; +/// Shortest passcode accepted at setup. +pub const MIN_PASSCODE: usize = 4; + +/// The keystore's newest record, and where the next write goes. +fn load(ks: &mut impl Keystore) -> Result<(Option, usize), Failure> { + let (mut a, mut b) = ([0u8; RECORD_SIZE], [0u8; RECORD_SIZE]); + if !ks.read(0, &mut a) || !ks.read(1, &mut b) { + return Err(Failure::Device); + } + let copies = Copies { copies: [&a, &b] }; + Ok((copies.newest().map(|(r, _)| r), copies.next_copy())) +} + +fn store(ks: &mut impl Keystore, copy: usize, r: &Record) -> Result<(), Failure> { + if ks.write(copy, &r.encode()) { Ok(()) } else { Err(Failure::Device) } +} + +/// Destroys the key slots: 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. +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)?; + for r in [&first, &second] { + store(ks, next, r)?; + store(ks, 1 - next, r)?; + } + let blank = [0u8; RECORD_SIZE]; + if ks.write(next, &blank) && ks.write(1 - next, &blank) { Ok(()) } else { Err(Failure::Device) } +} + +/// Asks for a new passcode twice until both match; `optional` allows none. +fn choose(prompt: &mut impl Prompt, question: &str, optional: bool, out: &mut Secret) -> Result<(), Failure> { + loop { + let mut again = Secret::new(); + if !prompt.secret(question, out) || !prompt.secret("Again: ", &mut again) { + return Err(Failure::Prompt); + } + if !out.same(&again) { + prompt.say("They differ; once more.\n"); + } else if out.len == 0 && optional { + return Ok(()); + } else if out.len < MIN_PASSCODE { + prompt.say("At least 4 characters.\n"); + } else { + return Ok(()); + } + } +} + +/// A device never set up (or wiped): the owner chooses a passcode, and +/// may choose a duress one. Returns the new master key. +fn setup(ks: &mut impl Keystore, rng: &mut impl Random, prompt: &mut impl Prompt, params: KdfParams, memory: &mut [Block]) -> Result { + prompt.say("This device has no passcode yet. Choose one; it protects everything on it.\n"); + let mut real = Secret::new(); + choose(prompt, "New passcode: ", false, &mut real)?; + prompt.say("A duress passcode wipes the device when it is entered instead. Leave it empty for none.\n"); + let mut duress = Secret::new(); + loop { + choose(prompt, "Duress passcode: ", true, &mut duress)?; + if duress.len == 0 || !duress.same(&real) { + break; + } + prompt.say("It must differ from the passcode.\n"); + } + let duress = (duress.len > 0).then_some(duress.as_bytes()); + let (record, key) = Record::setup(real.as_bytes(), duress, THRESHOLD, params, memory, rng).ok_or(Failure::Random)?; + store(ks, 0, &record)?; + store(ks, 1, &record)?; + Ok(key) +} + +/// 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 +/// 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 { + loop { + let (record, next) = load(ks)?; + let Some(record) = record else { + return setup(ks, rng, prompt, params, memory); + }; + if record.must_wipe() { + wipe(ks, &record, next, rng)?; + continue; + } + let mut typed = Secret::new(); + if !prompt.secret("Passcode: ", &mut typed) { + return Err(Failure::Prompt); + } + // Counted before it is checked: pulling the power cannot save a guess. + let counted = record.next(|r| r.failures = r.failures.saturating_add(1)); + store(ks, next, &counted)?; + match record.unlock(typed.as_bytes(), memory) { + Outcome::Real(key) => { + store(ks, 1 - next, &counted.next(|r| r.failures = 0))?; + return Ok(key); + } + Outcome::Duress => { + // Recorded as worn out first: a wipe cut short resumes. + store(ks, 1 - next, &counted.next(|r| r.failures = r.threshold))?; + let (worn, next) = load(ks)?; + wipe(ks, &worn.ok_or(Failure::Device)?, next, rng)?; + } + Outcome::Wrong => { + let left = counted.threshold.saturating_sub(counted.failures); + if left > 0 { + prompt.say(if left == 1 { "Wrong passcode. One try left before the device is wiped.\n" } else { "Wrong passcode.\n" }); + } + } + } + } +} diff --git a/crates/servers/gatekeeper/src/lib.rs b/crates/servers/gatekeeper/src/lib.rs index 4d4946f..c3074a5 100644 --- a/crates/servers/gatekeeper/src/lib.rs +++ b/crates/servers/gatekeeper/src/lib.rs @@ -1,16 +1,14 @@ +//! The gatekeeper (see the vision's Gatekeeper note): authenticates the +//! owner before anything that touches user data starts, and hands the +//! master key over. `flow` is the logic; `run` wires the devices. + +#![no_std] #![forbid(unsafe_code)] -pub fn add(left: u64, right: u64) -> u64 { - left + right -} +extern crate alloc; + +pub mod flow; #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn it_works() { - let result = add(2, 2); - assert_eq!(result, 4); - } -} +#[path = "tests/mod.rs"] +mod tests; diff --git a/crates/servers/gatekeeper/src/tests/mod.rs b/crates/servers/gatekeeper/src/tests/mod.rs new file mode 100644 index 0000000..91579aa --- /dev/null +++ b/crates/servers/gatekeeper/src/tests/mod.rs @@ -0,0 +1,212 @@ +extern crate std; + +use crate::flow::*; +use keystore::{Block, KdfParams, RECORD_SIZE, Random, Record}; +use std::collections::VecDeque; +use std::string::String; +use std::vec; +use std::vec::Vec; + +const FAST: KdfParams = KdfParams { memory_kib: 8, passes: 1 }; + +/// The keystore in memory; `cut_after` writes simulate the power going. +#[derive(Default, Clone)] +struct Device { + copies: [Vec; 2], + writes: usize, + cut_after: Option, + /// The power goes in the middle of the first write that starts a + /// wipe: only its start lands. + tear_wipe: bool, +} + +impl Device { + fn new() -> Self { + Self { copies: [vec![0; RECORD_SIZE], vec![0; RECORD_SIZE]], ..Self::default() } + } + + fn record(&self) -> Option { + keystore::Copies { copies: [&self.copies[0], &self.copies[1]] }.newest().map(|(r, _)| r) + } +} + +impl Keystore for Device { + fn read(&mut self, copy: usize, out: &mut [u8; RECORD_SIZE]) -> bool { + out.copy_from_slice(&self.copies[copy]); + true + } + fn write(&mut self, copy: usize, data: &[u8; RECORD_SIZE]) -> bool { + if self.cut_after.is_some_and(|n| self.writes >= n) { + return false; + } + let starts_wipe = Record::decode(data).is_some_and(|r| r.state == keystore::State::Wiping); + if self.tear_wipe && starts_wipe { + // Into the slots: the record (and its checksum) is not whole. + self.copies[copy][..100].copy_from_slice(&data[..100]); + self.cut_after = Some(self.writes); + return false; + } + self.writes += 1; + self.copies[copy] = data.to_vec(); + true + } +} + +struct Counter(u8); + +impl Random for Counter { + fn fill(&mut self, buf: &mut [u8]) -> bool { + for b in buf { + self.0 = self.0.wrapping_mul(37).wrapping_add(11); + *b = self.0; + } + true + } +} + +/// An owner typing from a script; what was shown is kept. +struct Owner { + typing: VecDeque<&'static str>, + shown: String, +} + +impl Owner { + fn types(lines: &[&'static str]) -> Self { + Self { typing: lines.iter().copied().collect(), shown: String::new() } + } +} + +impl Prompt for Owner { + fn say(&mut self, text: &str) { + self.shown.push_str(text); + } + fn secret(&mut self, question: &str, out: &mut Secret) -> bool { + self.shown.push_str(question); + let Some(line) = self.typing.pop_front() else { return false }; + out.set(line.as_bytes()); + true + } +} + +fn memory() -> Vec { + vec![Block::default(); FAST.memory_kib as usize] +} + +fn run_with(dev: &mut Device, owner: &mut Owner) -> Result { + run(dev, &mut Counter(5), owner, FAST, &mut memory()) +} + +/// A device set up with passcode 1234 and duress 0000; its master key. +fn set_up() -> (Device, [u8; 32]) { + let mut dev = Device::new(); + let key = run_with(&mut dev, &mut Owner::types(&["1234", "1234", "0000", "0000"])).unwrap(); + (dev, key.0) +} + +#[test] +fn a_fresh_device_is_set_up_and_then_unlocks_with_its_passcode() { + let (mut dev, key) = set_up(); + let mut owner = Owner::types(&["1234"]); + assert_eq!(run_with(&mut dev, &mut owner).unwrap().0, key, "the same master key every boot"); + assert_eq!(dev.record().unwrap().failures, 0); +} + +#[test] +fn setup_insists_on_a_confirmed_passcode_of_four_or_more() { + let mut dev = Device::new(); + let mut owner = Owner::types(&["1234", "1243", "12", "12", "123456", "123456", "", ""]); + run_with(&mut dev, &mut owner).unwrap(); + assert!(owner.shown.contains("They differ")); + assert!(owner.shown.contains("At least 4")); + let mut unlock = Owner::types(&["123456"]); + assert!(run_with(&mut dev, &mut unlock).is_ok(), "no duress chosen: the passcode is 123456"); +} + +#[test] +fn the_duress_passcode_must_differ() { + let mut dev = Device::new(); + let mut owner = Owner::types(&["1234", "1234", "1234", "1234", "", ""]); + run_with(&mut dev, &mut owner).unwrap(); + assert!(owner.shown.contains("must differ")); +} + +#[test] +fn every_guess_is_counted_before_it_is_checked() { + let (mut dev, _) = set_up(); + // The power goes after the guess was counted, before the check could + // clear it: even the right passcode stays counted. + dev.cut_after = Some(dev.writes + 1); + let mut owner = Owner::types(&["1234"]); + assert_eq!(run_with(&mut dev, &mut owner).err(), Some(Failure::Device)); + assert_eq!(dev.record().unwrap().failures, 1, "the guess stays counted"); +} + +#[test] +fn wrong_passcodes_are_counted_and_a_right_one_resets_the_count() { + let (mut dev, key) = set_up(); + let mut owner = Owner::types(&["1111", "2222", "1234"]); + assert_eq!(run_with(&mut dev, &mut owner).unwrap().0, key); + assert_eq!(owner.shown.matches("Wrong passcode").count(), 2); + assert_eq!(dev.record().unwrap().failures, 0); +} + +#[test] +fn too_many_wrong_passcodes_wipe_the_keys_and_the_device_starts_fresh() { + let (mut dev, key) = set_up(); + let mut guesses = vec!["9999"; THRESHOLD as usize]; + // Then it looks fresh: the owner sets it up again. + guesses.extend(["5678", "5678", "", ""]); + let mut owner = Owner::types(&guesses); + let new = run_with(&mut dev, &mut owner).unwrap(); + assert!(owner.shown.contains("One try left")); + assert!(owner.shown.ends_with("This device has no passcode yet. Choose one; it protects everything on it.\nNew passcode: Again: A duress passcode wipes the device when it is entered instead. Leave it empty for none.\nDuress passcode: Again: ")); + assert_ne!(new.0, key, "the old master key is gone"); + let mut old = Owner::types(&["1234"]); + let mut dev2 = dev.clone(); + dev2.cut_after = None; + assert!(run_with(&mut dev2, &mut old).is_err(), "the old passcode opens nothing"); +} + +#[test] +fn the_duress_passcode_wipes_silently_and_the_device_looks_fresh() { + let (mut dev, key) = set_up(); + let mut owner = Owner::types(&["0000", "4321", "4321", "", ""]); + let new = run_with(&mut dev, &mut owner).unwrap(); + assert!(!owner.shown.contains("Wrong"), "nothing tells it was a duress passcode"); + assert!(owner.shown.contains("no passcode yet")); + assert_ne!(new.0, key); +} + +#[test] +fn a_wipe_cut_short_is_finished_on_the_next_boot() { + let (mut dev, key) = set_up(); + // Duress, and the power goes during the wipe (after its first write). + let marked = dev.writes + 2; + dev.cut_after = Some(marked + 1); + assert_eq!(run_with(&mut dev, &mut Owner::types(&["0000"])).err(), Some(Failure::Device)); + dev.cut_after = None; + // Next boot: the wipe completes before anything is asked; then setup. + let mut owner = Owner::types(&["4321", "4321", "", ""]); + let new = run_with(&mut dev, &mut owner).unwrap(); + assert!(owner.shown.starts_with("This device has no passcode yet"), "no passcode is asked of a half-wiped device"); + assert_ne!(new.0, key); +} + +#[test] +fn a_duress_cut_short_before_the_wipe_still_wipes_next_boot() { + let (mut dev, _) = set_up(); + // The power goes right after the duress was recorded (before any wipe write). + dev.cut_after = Some(dev.writes + 2); + assert_eq!(run_with(&mut dev, &mut Owner::types(&["0000"])).err(), Some(Failure::Device)); + assert!(dev.record().unwrap().must_wipe()); +} + +#[test] +fn a_duress_whose_wipe_is_torn_still_wipes_next_boot() { + let (mut dev, _) = set_up(); + // Counted, recorded, then the wipe's first write is torn. + dev.tear_wipe = true; + assert_eq!(run_with(&mut dev, &mut Owner::types(&["0000"])).err(), Some(Failure::Device)); + dev.cut_after = None; + assert!(dev.record().unwrap().must_wipe(), "the duress was recorded before the wipe began"); +}