feat(gatekeeper): auth flow (setup, count-before-check, threshold/duress wipe, torn-write-safe resume) over Keystore/Random/Prompt ports; host tests
This commit is contained in:
189
crates/servers/gatekeeper/src/flow.rs
Normal file
189
crates/servers/gatekeeper/src/flow.rs
Normal file
@@ -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<Record>, 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<Key, Failure> {
|
||||
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<Key, Failure> {
|
||||
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" });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user