feat(users): keystore v2 (8 slots, per-user salt+name tag, owner admin key opens sealed user dir, per-user duress); gatekeeper login by name+passcode, owner menu (add/remove/guests), passcode change, guest sessions

This commit is contained in:
2026-10-28 00:29:07 +01:00
parent 9dc751c466
commit 29a25bd8c2
9 changed files with 1232 additions and 417 deletions

View File

@@ -1,23 +1,29 @@
//! The keystore's contents (see the vision's Gatekeeper note): two sealed
//! key slots, the failed-attempt counter, and the wipe state, in a record
//! kept twice so a torn write never loses both. Pure: the gatekeeper does
//! the I/O; the crypto is RustCrypto's (argon2id, ChaCha20-Poly1305,
//! BLAKE2s).
//! The keystore's contents (see the vision's Gatekeeper and Users notes):
//! eight sealed slots, the user directory, the failed-attempt counter and
//! the wipe state, in a record kept twice so a torn write never loses
//! both. Pure: the gatekeeper does the I/O; the crypto is RustCrypto's
//! (argon2id, ChaCha20-Poly1305, BLAKE2s).
//!
//! One slot seals the master key under the real passcode, the other a
//! "wipe" marker under the duress passcode (or is plain random bytes when
//! there is none). Which is which is random, and both look like noise:
//! nothing on the device tells them apart, and every unlock tries both.
//! A passcode is stretched once (argon2id, with the device's salt) and the
//! result tried on every slot. A user's slot seals their master key, role,
//! name and id (and, for an owner, the admin key that opens the
//! directory); the duress slot seals a wipe marker; the rest are noise.
//! Nothing on the device tells the slots apart, or how many users there
//! are: the directory listing them opens only to an owner.
#![no_std]
#![forbid(unsafe_code)]
mod record;
mod user;
#[cfg(test)]
mod tests;
pub use record::{Copies, KdfParams, RECORD_SIZE, Record, State};
pub use record::{
Copies, Entry, KdfParams, Outcome, RECORD_SIZE, Record, Refused, SLOTS, Session, State,
};
pub use user::{Name, Role, User, UserId};
use argon2::{Algorithm, Argon2, Params, Version};
use chacha20poly1305::aead::{AeadInPlace, KeyInit};
@@ -27,17 +33,12 @@ use zeroize::Zeroize;
pub use argon2::Block;
pub const KEY_SIZE: usize = 32;
const SALT_SIZE: usize = 16;
const NONCE_SIZE: usize = 12;
const TAG_SIZE: usize = 16;
/// What a slot seals: its kind, then a key.
const SEALED_SIZE: usize = 1 + KEY_SIZE;
const SLOT_SIZE: usize = SALT_SIZE + NONCE_SIZE + SEALED_SIZE + TAG_SIZE;
const REAL: u8 = 1;
const DURESS: u8 = 2;
pub(crate) const SALT_SIZE: usize = 16;
pub(crate) const NONCE_SIZE: usize = 12;
pub(crate) const TAG_SIZE: usize = 16;
/// The master key, wiped from memory when dropped. Compared only in tests:
/// a plain `==` on secrets would not be constant-time.
/// A key, wiped from memory when dropped. Compared only in tests: a plain
/// `==` on secrets would not be constant-time.
#[derive(Clone)]
#[cfg_attr(test, derive(PartialEq, Eq))]
pub struct Key(pub [u8; KEY_SIZE]);
@@ -54,22 +55,18 @@ impl core::fmt::Debug for Key {
}
}
/// What a passcode turned out to be.
#[derive(Debug)]
#[cfg_attr(test, derive(PartialEq, Eq))]
pub enum Outcome {
Real(Key),
Duress,
Wrong,
}
/// Fills a buffer with randomness; false if it could not.
pub trait Random {
fn fill(&mut self, buf: &mut [u8]) -> bool;
}
/// argon2id over the caller's memory (`params.memory_kib` blocks): no heap.
fn derive(passcode: &[u8], salt: &[u8], params: KdfParams, memory: &mut [Block]) -> Option<Key> {
pub(crate) fn stretch(
passcode: &[u8],
salt: &[u8],
params: KdfParams,
memory: &mut [Block],
) -> Option<Key> {
let p = Params::new(params.memory_kib, params.passes, 1, Some(KEY_SIZE)).ok()?;
let memory = memory.get_mut(..params.memory_kib as usize)?;
let mut out = Key([0; KEY_SIZE]);
@@ -79,146 +76,48 @@ fn derive(passcode: &[u8], salt: &[u8], params: KdfParams, memory: &mut [Block])
Some(out)
}
/// A slot: salt, nonce, the sealed kind and key, the tag.
#[derive(Clone, Copy, PartialEq, Eq)]
pub struct Slot(pub [u8; SLOT_SIZE]);
impl Slot {
fn random(rng: &mut impl Random) -> Option<Self> {
let mut s = [0u8; SLOT_SIZE];
rng.fill(&mut s).then_some(Self(s))
/// Seals `data` (in place) under `key` into `nonce | data | tag`.
pub(crate) fn seal(key: &Key, out: &mut [u8], rng: &mut impl Random) -> bool {
let (nonce, rest) = out.split_at_mut(NONCE_SIZE);
let Some(split) = rest.len().checked_sub(TAG_SIZE) else {
return false;
};
let (data, tag) = rest.split_at_mut(split);
if !rng.fill(nonce) {
return false;
}
/// `kind` and `key`, sealed under a key derived from `passcode`.
fn seal(
kind: u8,
key: &Key,
passcode: &[u8],
params: KdfParams,
memory: &mut [Block],
rng: &mut impl Random,
) -> Option<Self> {
let mut s = [0u8; SLOT_SIZE];
let (salt, rest) = s.split_at_mut(SALT_SIZE);
let (nonce, rest) = rest.split_at_mut(NONCE_SIZE);
let (sealed, tag) = rest.split_at_mut(SEALED_SIZE);
if !rng.fill(salt) || !rng.fill(nonce) {
return None;
match ChaCha20Poly1305::new((&key.0).into()).encrypt_in_place_detached(
Nonce::from_slice(nonce),
b"",
data,
) {
Ok(t) => {
tag.copy_from_slice(&t);
true
}
let kek = derive(passcode, salt, params, memory)?;
sealed[0] = kind;
sealed[1..].copy_from_slice(&key.0);
let t = ChaCha20Poly1305::new((&kek.0).into())
.encrypt_in_place_detached(Nonce::from_slice(nonce), b"", sealed)
.ok()?;
tag.copy_from_slice(&t);
Some(Self(s))
Err(_) => false,
}
}
/// What the slot seals, if `passcode` opens it.
fn open(&self, passcode: &[u8], params: KdfParams, memory: &mut [Block]) -> Option<(u8, Key)> {
let (salt, rest) = self.0.split_at(SALT_SIZE);
let (nonce, rest) = rest.split_at(NONCE_SIZE);
let (sealed, tag) = rest.split_at(SEALED_SIZE);
let kek = derive(passcode, salt, params, memory)?;
let mut plain = [0u8; SEALED_SIZE];
plain.copy_from_slice(sealed);
let opened = ChaCha20Poly1305::new((&kek.0).into())
.decrypt_in_place_detached(
Nonce::from_slice(nonce),
b"",
&mut plain,
Tag::from_slice(tag),
)
.is_ok();
let mut key = Key([0; KEY_SIZE]);
key.0.copy_from_slice(&plain[1..]);
let kind = plain[0];
/// Opens what `seal` made into `plain` (its data's length); false (and
/// `plain` wiped) unless `key` sealed exactly this.
pub(crate) fn open(key: &Key, sealed: &[u8], plain: &mut [u8]) -> bool {
let Some((nonce, rest)) = sealed.split_at_checked(NONCE_SIZE) else {
return false;
};
let Some(split) = rest.len().checked_sub(TAG_SIZE) else {
return false;
};
let (data, tag) = rest.split_at(split);
let Some(plain) = plain.get_mut(..data.len()) else {
return false;
};
plain.copy_from_slice(data);
let ok = ChaCha20Poly1305::new((&key.0).into())
.decrypt_in_place_detached(Nonce::from_slice(nonce), b"", plain, Tag::from_slice(tag))
.is_ok();
if !ok {
plain.zeroize();
opened.then_some((kind, key))
}
}
impl Record {
/// A fresh keystore: a new random master key, sealed under `real`; a
/// duress slot under `duress`, or noise. Returns it and the master key.
pub fn setup(
real: &[u8],
duress: Option<&[u8]>,
threshold: u8,
params: KdfParams,
memory: &mut [Block],
rng: &mut impl Random,
) -> Option<(Self, Key)> {
let mut master = Key([0; KEY_SIZE]);
if !rng.fill(&mut master.0) {
return None;
}
let real_slot = Slot::seal(REAL, &master, real, params, memory, rng)?;
let other = match duress {
Some(d) => {
// Its key is never used: random, so the slot looks like the other.
let mut filler = Key([0; KEY_SIZE]);
if !rng.fill(&mut filler.0) {
return None;
}
Slot::seal(DURESS, &filler, d, params, memory, rng)?
}
None => Slot::random(rng)?,
};
let mut coin = [0u8; 1];
if !rng.fill(&mut coin) {
return None;
}
let slots = if coin[0] & 1 == 0 {
[real_slot, other]
} else {
[other, real_slot]
};
let record = Self {
generation: 1,
state: State::Active,
threshold: threshold.max(1),
failures: 0,
params,
slots,
};
Some((record, master))
}
/// Tries `passcode` on both slots, always both, so the time taken does
/// not tell which (if any) it opened.
pub fn unlock(&self, passcode: &[u8], memory: &mut [Block]) -> Outcome {
let a = self.slots[0].open(passcode, self.params, memory);
let b = self.slots[1].open(passcode, self.params, memory);
match a.or(b) {
Some((REAL, key)) => Outcome::Real(key),
Some((DURESS, _)) => Outcome::Duress,
_ => Outcome::Wrong,
}
}
/// The same record, one later, with `changes` made.
pub fn next(&self, changes: impl FnOnce(&mut Self)) -> Self {
let mut r = *self;
r.generation = self.generation.wrapping_add(1);
changes(&mut r);
r
}
/// This record with both slots overwritten by noise and marked as
/// being wiped, so an interrupted wipe is finished on the next boot.
pub fn wiping(&self, rng: &mut impl Random) -> Option<Self> {
let slots = [Slot::random(rng)?, Slot::random(rng)?];
Some(self.next(|r| {
r.state = State::Wiping;
r.slots = slots;
}))
}
/// Whether this record must be wiped before anything else: a wipe was
/// interrupted, or too many wrong passcodes.
pub fn must_wipe(&self) -> bool {
self.state == State::Wiping || self.failures >= self.threshold
}
ok
}

View File

@@ -1,14 +1,31 @@
//! The record's bytes, and the two copies it is kept in.
//! The record: header, the sealed user directory, the slots, a checksum;
//! its bytes, and the two copies it is kept in.
use blake2::{Blake2s256, Digest};
use blake2::{Blake2s256, Blake2sMac256, Digest, digest::Mac};
use chacha20poly1305::aead::KeyInit;
use zeroize::Zeroize;
use crate::{SLOT_SIZE, Slot};
use crate::user::{Name, Role, User, UserId};
use crate::{Block, KEY_SIZE, Key, NONCE_SIZE, Random, SALT_SIZE, TAG_SIZE, open, seal, stretch};
/// One record fills one sector.
pub const RECORD_SIZE: usize = 512;
const MAGIC: &[u8; 8] = b"KOSKEYS1";
/// One record fills four sectors.
pub const RECORD_SIZE: usize = 2048;
pub const SLOTS: usize = 8;
const MAGIC: &[u8; 8] = b"KOSKEYS2";
const NAME_TAG: usize = 16;
/// What a slot seals: kind, role, user id, name, master key, admin key
/// (an owner's; noise for anyone else).
const PLAIN: usize = 1 + 1 + 8 + Name::MAX + KEY_SIZE + KEY_SIZE;
const SLOT: usize = NAME_TAG + SALT_SIZE + NONCE_SIZE + PLAIN + TAG_SIZE;
/// One directory entry: kind (0 free), role, user id, name.
const ENTRY: usize = 1 + 1 + 8 + Name::MAX;
const DIRECTORY: usize = NONCE_SIZE + SLOTS * ENTRY + TAG_SIZE;
const HEADER: usize = 8 + 8 + 4 + 8 + SALT_SIZE;
const BODY: usize = HEADER + DIRECTORY + SLOTS * SLOT;
const CHECKSUM: usize = 32;
const BODY: usize = 8 + 8 + 4 + 8 + 2 * SLOT_SIZE;
const FREE: u8 = 0;
const USER: u8 = 1;
const DURESS: u8 = 2;
/// How slow a passcode guess is: argon2id's memory (KiB) and passes.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -32,18 +49,69 @@ pub enum State {
Wiping,
}
/// A signed-in user: who, their master key, which slot holds them, and,
/// for an owner, the admin key that opens the directory.
pub struct Session {
pub user: User,
pub key: Key,
pub slot: usize,
admin: Option<Key>,
}
impl Session {
pub fn is_owner(&self) -> bool {
self.admin.is_some()
}
}
/// What a name and passcode turned out to be.
pub enum Outcome {
User(Session),
Duress,
Wrong,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Refused {
NameTaken,
/// No free slots (a user takes one, two with a duress passcode).
Full,
NoSuchUser,
/// Only an owner manages users.
NotOwner,
/// It would leave the device without an owner.
LastOwner,
/// No randomness, the KDF failed, or the record does not open.
Failed,
}
#[derive(Clone, Copy, PartialEq, Eq)]
struct Slot([u8; SLOT]);
/// A directory entry, as an owner reads it.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub struct Entry {
duress: bool,
pub user: User,
}
#[derive(Clone, Copy, PartialEq, Eq)]
pub struct Record {
/// Which copy is newer: the higher one.
pub generation: u64,
pub state: State,
/// Wrong passcodes in a row that wipe the keys.
/// Wrong passcodes in a row (for any name) that wipe every user.
pub threshold: u8,
/// Wrong passcodes in a row so far; counted before a passcode is
/// checked, so pulling the power cannot save a guess.
pub failures: u8,
/// A guest may sign in (with an empty name).
pub guests: bool,
pub params: KdfParams,
pub slots: [Slot; 2],
/// Keys the name tags, so names are not written out.
pepper: [u8; SALT_SIZE],
directory: [u8; DIRECTORY],
slots: [Slot; SLOTS],
}
impl core::fmt::Debug for Record {
@@ -52,16 +120,412 @@ impl core::fmt::Debug for Record {
.field("generation", &self.generation)
.field("state", &self.state)
.field("failures", &self.failures)
.field("threshold", &self.threshold)
.field("guests", &self.guests)
.finish_non_exhaustive()
}
}
fn checksum(body: &[u8]) -> [u8; CHECKSUM] {
Blake2s256::digest(body).into()
fn noise<const N: usize>(rng: &mut impl Random) -> Option<[u8; N]> {
let mut b = [0u8; N];
rng.fill(&mut b).then_some(b)
}
/// The plaintext a slot seals.
fn slot_plain(kind: u8, user: &User, key: &Key, admin: &Key) -> [u8; PLAIN] {
let mut p = [0u8; PLAIN];
p[0] = kind;
p[1] = user.role.byte();
p[2..10].copy_from_slice(&user.id.0);
p[10..10 + Name::MAX].copy_from_slice(&user.name.raw());
p[10 + Name::MAX..10 + Name::MAX + KEY_SIZE].copy_from_slice(&key.0);
p[10 + Name::MAX + KEY_SIZE..].copy_from_slice(&admin.0);
p
}
fn parse_user(role: u8, id: &[u8], name: &[u8]) -> Option<User> {
let mut i = [0u8; 8];
i.copy_from_slice(id);
let mut n = [0u8; Name::MAX];
n.copy_from_slice(name);
Some(User {
id: UserId(i),
role: Role::from_byte(role)?,
name: Name::from_raw(n)?,
})
}
impl Record {
fn name_tag(&self, name: &Name) -> [u8; NAME_TAG] {
let mut m = <Blake2sMac256 as KeyInit>::new_from_slice(&self.pepper)
.expect("BLAKE2s takes a 16-byte key");
m.update(b"kos/keystore/v2/name/");
m.update(name.as_bytes());
let mut tag = [0u8; NAME_TAG];
tag.copy_from_slice(&m.finalize().into_bytes()[..NAME_TAG]);
tag
}
/// `plain` sealed into slot `i` for the name `tag`, under `passcode`.
fn seal_slot(
&mut self,
i: usize,
tag: &[u8; NAME_TAG],
plain: &[u8; PLAIN],
passcode: &[u8],
memory: &mut [Block],
rng: &mut impl Random,
) -> bool {
let mut s = [0u8; SLOT];
s[..NAME_TAG].copy_from_slice(tag);
let (salt, body) = s[NAME_TAG..].split_at_mut(SALT_SIZE);
if !rng.fill(salt) {
return false;
}
let Some(kek) = stretch(passcode, salt, self.params, memory) else {
return false;
};
body[NONCE_SIZE..NONCE_SIZE + PLAIN].copy_from_slice(plain);
let ok = seal(&kek, body, rng);
self.slots[i] = Slot(s);
ok
}
/// The directory, opened with an owner's admin key.
fn entries(&self, admin: &Key) -> Option<[Option<Entry>; SLOTS]> {
let mut plain = [0u8; SLOTS * ENTRY];
if !open(admin, &self.directory, &mut plain) {
return None;
}
let out = core::array::from_fn(|i| {
let e = &plain[i * ENTRY..(i + 1) * ENTRY];
match e[0] {
FREE => None,
kind => parse_user(e[1], &e[2..10], &e[10..]).map(|user| Entry {
duress: kind == DURESS,
user,
}),
}
});
plain.zeroize();
Some(out)
}
fn seal_directory(
&mut self,
admin: &Key,
entries: &[Option<Entry>; SLOTS],
rng: &mut impl Random,
) -> bool {
let mut d = [0u8; DIRECTORY];
for (i, e) in entries.iter().enumerate() {
let at = NONCE_SIZE + i * ENTRY;
if let Some(e) = e {
d[at] = if e.duress { DURESS } else { USER };
d[at + 1] = e.user.role.byte();
d[at + 2..at + 10].copy_from_slice(&e.user.id.0);
d[at + 10..at + ENTRY].copy_from_slice(&e.user.name.raw());
}
}
let ok = seal(admin, &mut d, rng);
self.directory = d;
ok
}
/// A device with one owner, `name`, and (if given) their duress
/// passcode. Returns the record and the owner's session.
pub fn setup(
name: Name,
passcode: &[u8],
duress: Option<&[u8]>,
threshold: u8,
params: KdfParams,
memory: &mut [Block],
rng: &mut impl Random,
) -> Option<(Self, Session)> {
let mut slots = [Slot([0; SLOT]); SLOTS];
for s in slots.iter_mut() {
*s = Slot(noise(rng)?);
}
let empty = Self {
generation: 1,
state: State::Active,
threshold: threshold.max(1),
failures: 0,
guests: false,
params,
pepper: noise(rng)?,
directory: [0; DIRECTORY],
slots,
};
let admin = Key(noise(rng)?);
let mut first = empty;
if !first.seal_directory(&admin, &[None; SLOTS], rng) {
return None;
}
let owner = Session {
user: User {
id: UserId([0; 8]),
role: Role::Owner,
name,
},
key: Key([0; KEY_SIZE]),
slot: 0,
admin: Some(admin),
};
let (record, user) = first
.add_user(&owner, name, Role::Owner, passcode, duress, memory, rng)
.ok()?;
let session = record.unlock(name.as_bytes(), passcode, memory);
let _ = user;
match session {
Outcome::User(s) => Some((record, s)),
_ => None,
}
}
/// Tries `name` and `passcode`. Always does the same work, two KDF
/// runs, whether the name exists or not, so the time taken tells
/// nothing.
pub fn unlock(&self, name: &[u8], passcode: &[u8], memory: &mut [Block]) -> Outcome {
let tag = Name::new(name).map(|n| self.name_tag(&n));
let mut candidates = [None; 2];
let mut n = 0;
for i in 0..SLOTS {
if tag.is_some_and(|t| self.slots[i].0[..NAME_TAG] == t) && n < 2 {
candidates[n] = Some(i);
n += 1;
}
}
let mut found = None;
for c in candidates {
// A missing candidate still costs a KDF run, over a fake salt.
let slot = c.map_or([0u8; SLOT], |i| self.slots[i].0);
let (salt, body) = slot[NAME_TAG..].split_at(SALT_SIZE);
let Some(kek) = stretch(passcode, salt, self.params, memory) else {
continue;
};
let mut plain = [0u8; PLAIN];
if c.is_some() && open(&kek, body, &mut plain) && found.is_none() {
found = c.map(|i| (i, plain));
}
plain.zeroize();
}
let Some((slot, mut plain)) = found else {
return Outcome::Wrong;
};
let outcome = match (
plain[0],
parse_user(plain[1], &plain[2..10], &plain[10..10 + Name::MAX]),
) {
(DURESS, _) => Outcome::Duress,
(USER, Some(user)) => {
let mut key = Key([0; KEY_SIZE]);
key.0
.copy_from_slice(&plain[10 + Name::MAX..10 + Name::MAX + KEY_SIZE]);
let mut admin = Key([0; KEY_SIZE]);
admin.0.copy_from_slice(&plain[10 + Name::MAX + KEY_SIZE..]);
let admin = (user.role == Role::Owner).then_some(admin);
Outcome::User(Session {
user,
key,
slot,
admin,
})
}
_ => Outcome::Wrong,
};
plain.zeroize();
outcome
}
/// What `name`'s slot carries as the admin key, opened with `passcode`.
#[cfg(test)]
pub(crate) fn carried_admin(
&self,
name: &[u8],
passcode: &[u8],
memory: &mut [Block],
) -> Option<Key> {
let tag = self.name_tag(&Name::new(name)?);
let slot = self.slots.iter().find(|s| s.0[..NAME_TAG] == tag)?;
let (salt, body) = slot.0[NAME_TAG..].split_at(SALT_SIZE);
let kek = stretch(passcode, salt, self.params, memory)?;
let mut plain = [0u8; PLAIN];
open(&kek, body, &mut plain).then(|| {
let mut k = Key([0; KEY_SIZE]);
k.0.copy_from_slice(&plain[10 + Name::MAX + KEY_SIZE..]);
k
})
}
#[cfg(test)]
pub(crate) fn admin_of(s: &Session) -> Option<Key> {
s.admin.clone()
}
/// Who has an account (their duress slots left out); only for an owner.
pub fn users(&self, by: &Session) -> Result<[Option<User>; SLOTS], Refused> {
let admin = by.admin.as_ref().ok_or(Refused::NotOwner)?;
let entries = self.entries(admin).ok_or(Refused::Failed)?;
Ok(entries.map(|e| e.filter(|e| !e.duress).map(|e| e.user)))
}
/// An owner adds a user; the new record and the user.
pub fn add_user(
&self,
by: &Session,
name: Name,
role: Role,
passcode: &[u8],
duress: Option<&[u8]>,
memory: &mut [Block],
rng: &mut impl Random,
) -> Result<(Self, User), Refused> {
let admin = by.admin.clone().ok_or(Refused::NotOwner)?;
let mut entries = self.entries(&admin).ok_or(Refused::Failed)?;
if entries.iter().flatten().any(|e| e.user.name == name) {
return Err(Refused::NameTaken);
}
let need = 1 + usize::from(duress.is_some());
let free: [usize; SLOTS] = core::array::from_fn(|i| i);
let mut free = free.into_iter().filter(|&i| entries[i].is_none());
let (Some(real), spare) = (free.next(), free.next()) else {
return Err(Refused::Full);
};
if need == 2 && spare.is_none() {
return Err(Refused::Full);
}
let user = User {
id: UserId(noise(rng).ok_or(Refused::Failed)?),
role,
name,
};
let key = Key(noise(rng).ok_or(Refused::Failed)?);
// Only an owner's slot carries the admin key; anyone else's, noise.
let carried = match role {
Role::Owner => admin.clone(),
Role::Standard => Key(noise(rng).ok_or(Refused::Failed)?),
};
let mut r = self.next(|_| {});
let tag = r.name_tag(&name);
if !r.seal_slot(
real,
&tag,
&slot_plain(USER, &user, &key, &carried),
passcode,
memory,
rng,
) {
return Err(Refused::Failed);
}
entries[real] = Some(Entry {
duress: false,
user,
});
if let (Some(d), Some(i)) = (duress, spare) {
let filler = Key(noise(rng).ok_or(Refused::Failed)?);
if !r.seal_slot(
i,
&tag,
&slot_plain(DURESS, &user, &filler, &filler),
d,
memory,
rng,
) {
return Err(Refused::Failed);
}
entries[i] = Some(Entry { duress: true, user });
}
if !r.seal_directory(&admin, &entries, rng) {
return Err(Refused::Failed);
}
Ok((r, user))
}
/// An owner removes the user `name` (their slots become noise); the
/// new record and the removed user's id, whose files are to go too.
pub fn remove_user(
&self,
by: &Session,
name: &Name,
rng: &mut impl Random,
) -> Result<(Self, UserId), Refused> {
let admin = by.admin.clone().ok_or(Refused::NotOwner)?;
let mut entries = self.entries(&admin).ok_or(Refused::Failed)?;
let gone = entries
.iter()
.flatten()
.find(|e| e.user.name == *name)
.map(|e| e.user)
.ok_or(Refused::NoSuchUser)?;
let owners_left = entries
.iter()
.flatten()
.filter(|e| !e.duress && e.user.role == Role::Owner && e.user.id != gone.id)
.count();
if gone.role == Role::Owner && owners_left == 0 {
return Err(Refused::LastOwner);
}
let mut r = self.next(|_| {});
for i in 0..SLOTS {
if entries[i].is_some_and(|e| e.user.id == gone.id) {
r.slots[i] = Slot(noise(rng).ok_or(Refused::Failed)?);
entries[i] = None;
}
}
if !r.seal_directory(&admin, &entries, rng) {
return Err(Refused::Failed);
}
Ok((r, gone.id))
}
/// The signed-in user picks a new passcode: their slot is resealed.
pub fn change_passcode(
&self,
by: &Session,
passcode: &[u8],
memory: &mut [Block],
rng: &mut impl Random,
) -> Result<Self, Refused> {
let carried = match &by.admin {
Some(a) => a.clone(),
None => Key(noise(rng).ok_or(Refused::Failed)?),
};
let mut r = self.next(|_| {});
let tag = r.name_tag(&by.user.name);
let plain = slot_plain(USER, &by.user, &by.key, &carried);
if !r.seal_slot(by.slot, &tag, &plain, passcode, memory, rng) {
return Err(Refused::Failed);
}
Ok(r)
}
/// The same record, one later, with `changes` made.
pub fn next(&self, changes: impl FnOnce(&mut Self)) -> Self {
let mut r = *self;
r.generation = self.generation.wrapping_add(1);
changes(&mut r);
r
}
/// Everything sealed overwritten by noise, marked as being wiped, so an
/// interrupted wipe is finished on the next boot.
pub fn wiping(&self, rng: &mut impl Random) -> Option<Self> {
let mut r = self.next(|r| r.state = State::Wiping);
r.directory = noise(rng)?;
r.pepper = noise(rng)?;
for s in r.slots.iter_mut() {
*s = Slot(noise(rng)?);
}
Some(r)
}
/// Whether this record must be wiped before anything else: a wipe was
/// interrupted, or too many wrong passcodes.
pub fn must_wipe(&self) -> bool {
self.state == State::Wiping || self.failures >= self.threshold
}
pub fn encode(&self) -> [u8; RECORD_SIZE] {
let mut out = [0u8; RECORD_SIZE];
out[0..8].copy_from_slice(MAGIC);
@@ -72,11 +536,16 @@ impl Record {
};
out[17] = self.threshold;
out[18] = self.failures;
out[19] = u8::from(self.guests);
out[20..24].copy_from_slice(&self.params.memory_kib.to_le_bytes());
out[24..28].copy_from_slice(&self.params.passes.to_le_bytes());
out[28..28 + SLOT_SIZE].copy_from_slice(&self.slots[0].0);
out[28 + SLOT_SIZE..BODY].copy_from_slice(&self.slots[1].0);
let sum = checksum(&out[..BODY]);
out[28..HEADER].copy_from_slice(&self.pepper);
out[HEADER..HEADER + DIRECTORY].copy_from_slice(&self.directory);
for (i, s) in self.slots.iter().enumerate() {
let at = HEADER + DIRECTORY + i * SLOT;
out[at..at + SLOT].copy_from_slice(&s.0);
}
let sum: [u8; CHECKSUM] = Blake2s256::digest(&out[..BODY]).into();
out[BODY..BODY + CHECKSUM].copy_from_slice(&sum);
out
}
@@ -84,7 +553,8 @@ impl Record {
/// `None` unless it is a whole, unaltered record (magic and checksum).
pub fn decode(bytes: &[u8]) -> Option<Self> {
let bytes = bytes.get(..RECORD_SIZE)?;
if &bytes[0..8] != MAGIC || bytes[BODY..BODY + CHECKSUM] != checksum(&bytes[..BODY]) {
let sum: [u8; CHECKSUM] = Blake2s256::digest(&bytes[..BODY]).into();
if &bytes[0..8] != MAGIC || bytes[BODY..BODY + CHECKSUM] != sum {
return None;
}
let word = |at: usize| u32::from_le_bytes(bytes[at..at + 4].try_into().unwrap_or([0; 4]));
@@ -93,20 +563,24 @@ impl Record {
2 => State::Wiping,
_ => return None,
};
let mut a = [0u8; SLOT_SIZE];
let mut b = [0u8; SLOT_SIZE];
a.copy_from_slice(&bytes[28..28 + SLOT_SIZE]);
b.copy_from_slice(&bytes[28 + SLOT_SIZE..BODY]);
let mut slots = [Slot([0; SLOT]); SLOTS];
for (i, s) in slots.iter_mut().enumerate() {
let at = HEADER + DIRECTORY + i * SLOT;
s.0.copy_from_slice(&bytes[at..at + SLOT]);
}
Some(Self {
generation: u64::from_le_bytes(bytes[8..16].try_into().ok()?),
state,
threshold: bytes[17],
failures: bytes[18],
params: crate::KdfParams {
guests: bytes[19] != 0,
params: KdfParams {
memory_kib: word(20),
passes: word(24),
},
slots: [Slot(a), Slot(b)],
pepper: bytes[28..HEADER].try_into().ok()?,
directory: bytes[HEADER..HEADER + DIRECTORY].try_into().ok()?,
slots,
})
}
}
@@ -135,3 +609,5 @@ impl Copies<'_> {
self.newest().map_or(0, |(_, copy)| 1 - copy)
}
}
const _: () = assert!(BODY + CHECKSUM <= RECORD_SIZE);

View File

@@ -23,150 +23,294 @@ impl Random for Counter {
}
}
struct Broken;
impl Random for Broken {
fn fill(&mut self, _buf: &mut [u8]) -> bool {
false
}
}
fn memory() -> Vec<Block> {
vec![Block::default(); FAST.memory_kib as usize]
}
fn setup(duress: Option<&[u8]>, seed: u8) -> (Record, Key) {
Record::setup(b"1234", duress, 5, FAST, &mut memory(), &mut Counter(seed)).unwrap()
fn name(s: &str) -> Name {
Name::new(s.as_bytes()).unwrap()
}
#[test]
fn the_real_passcode_opens_the_master_key_and_nothing_else_does() {
let (record, master) = setup(Some(b"0000"), 1);
let mem = &mut memory();
assert_eq!(record.unlock(b"1234", mem), Outcome::Real(master));
assert_eq!(record.unlock(b"0000", mem), Outcome::Duress);
for wrong in [&b"1235"[..], b"", b"12345", b"123"] {
assert_eq!(record.unlock(wrong, mem), Outcome::Wrong, "{wrong:?}");
fn setup(duress: Option<&[u8]>, seed: u8) -> (Record, Session) {
Record::setup(
name("gabriel"),
b"1234",
duress,
5,
FAST,
&mut memory(),
&mut Counter(seed),
)
.unwrap()
}
fn signed_in(r: &Record, who: &str, passcode: &[u8]) -> Option<Session> {
match r.unlock(who.as_bytes(), passcode, &mut memory()) {
Outcome::User(s) => Some(s),
_ => None,
}
}
fn is_wrong(o: Outcome) -> bool {
matches!(o, Outcome::Wrong)
}
#[test]
fn without_a_duress_passcode_its_slot_opens_for_nothing() {
let (record, master) = setup(None, 2);
let mem = &mut memory();
assert_eq!(record.unlock(b"1234", mem), Outcome::Real(master));
assert_eq!(record.unlock(b"0000", mem), Outcome::Wrong);
fn the_owner_signs_in_with_name_and_passcode() {
let (r, owner) = setup(Some(b"0000"), 1);
assert!(owner.is_owner());
let again = signed_in(&r, "gabriel", b"1234").unwrap();
assert_eq!(again.key, owner.key, "the same master key every time");
assert_eq!(again.user, owner.user);
assert!(matches!(
r.unlock(b"gabriel", b"0000", &mut memory()),
Outcome::Duress
));
assert!(is_wrong(r.unlock(b"gabriel", b"1235", &mut memory())));
assert!(
is_wrong(r.unlock(b"nobody", b"1234", &mut memory())),
"an unknown name is just wrong"
);
assert!(is_wrong(r.unlock(b"", b"1234", &mut memory())));
}
#[test]
fn users_have_their_own_keys_and_may_even_share_a_passcode() {
let (r, owner) = setup(None, 2);
let mut rng = Counter(40);
let (r, work) = r
.add_user(
&owner,
name("work"),
Role::Standard,
b"1234",
None,
&mut memory(),
&mut rng,
)
.unwrap();
let w = signed_in(&r, "work", b"1234").unwrap();
let g = signed_in(&r, "gabriel", b"1234").unwrap();
assert_eq!(w.user, work);
assert_ne!(
w.key, g.key,
"same passcode, different users, different keys"
);
assert_ne!(w.user.id, g.user.id);
assert!(!w.is_owner(), "a standard user holds no admin key");
let admin = Record::admin_of(&owner).unwrap();
assert_ne!(
r.carried_admin(b"work", b"1234", &mut memory()),
Some(admin),
"not even hidden in their slot"
);
assert_eq!(
record.unlock(b"", mem),
Outcome::Wrong,
"not even an empty one"
r.add_user(
&w,
name("x"),
Role::Standard,
b"9",
None,
&mut memory(),
&mut rng
)
.err(),
Some(Refused::NotOwner)
);
assert_eq!(r.users(&w).err(), Some(Refused::NotOwner));
}
#[test]
fn owners_see_and_manage_the_users() {
let (r, owner) = setup(Some(b"0000"), 3);
let mut rng = Counter(50);
let (r, _) = r
.add_user(
&owner,
name("work"),
Role::Standard,
b"5678",
Some(b"8765"),
&mut memory(),
&mut rng,
)
.unwrap();
let (r, second) = r
.add_user(
&owner,
name("anna"),
Role::Owner,
b"4321",
None,
&mut memory(),
&mut rng,
)
.unwrap();
let names: Vec<_> = r
.users(&owner)
.unwrap()
.iter()
.flatten()
.map(|u| std::string::String::from_utf8_lossy(u.name.as_bytes()).into_owned())
.collect();
assert_eq!(names.len(), 3, "duress slots are not listed: {names:?}");
assert_eq!(
r.add_user(
&owner,
name("work"),
Role::Standard,
b"x",
None,
&mut memory(),
&mut rng
)
.err(),
Some(Refused::NameTaken)
);
// A second owner manages users too: the admin key reached them.
let anna = signed_in(&r, "anna", b"4321").unwrap();
assert_eq!(anna.user, second);
assert!(r.users(&anna).is_ok());
// Work's duress passcode wipes; it is work's, nobody else's.
assert!(matches!(
r.unlock(b"work", b"8765", &mut memory()),
Outcome::Duress
));
assert!(is_wrong(r.unlock(b"gabriel", b"8765", &mut memory())));
// Removing work removes both of work's slots.
let (r, gone) = r.remove_user(&owner, &name("work"), &mut rng).unwrap();
assert!(signed_in(&r, "work", b"5678").is_none());
assert!(is_wrong(r.unlock(b"work", b"8765", &mut memory())));
assert_ne!(gone, owner.user.id);
assert_eq!(
r.remove_user(&owner, &name("work"), &mut rng).err(),
Some(Refused::NoSuchUser)
);
}
#[test]
fn which_slot_is_real_is_not_fixed() {
// Different randomness puts the real slot first in some setups, second
// in others: its position gives nothing away.
let mem = &mut memory();
let mut firsts = [false; 2];
for seed in 0..16 {
let (record, _) = setup(Some(b"0000"), seed);
let only_first = Record {
slots: [record.slots[0], Slot(record.slots[1].0.map(|b| !b))],
..record
};
firsts[matches!(only_first.unlock(b"1234", mem), Outcome::Real(_)) as usize] = true;
fn the_last_owner_stays() {
let (r, owner) = setup(None, 4);
assert_eq!(
r.remove_user(&owner, &name("gabriel"), &mut Counter(1))
.err(),
Some(Refused::LastOwner)
);
}
#[test]
fn slots_run_out() {
let (mut r, owner) = setup(Some(b"0000"), 5);
let mut rng = Counter(60);
let mut added = 0;
for i in 0..SLOTS {
let n = std::format!("u{i}");
match r.add_user(
&owner,
name(&n),
Role::Standard,
b"pass",
None,
&mut memory(),
&mut rng,
) {
Ok((next, _)) => {
r = next;
added += 1;
}
Err(e) => {
assert_eq!(e, Refused::Full);
break;
}
}
}
assert_eq!(firsts, [true, true]);
assert_eq!(
added,
SLOTS - 2,
"the owner and the owner's duress slot take two"
);
}
#[test]
fn a_new_passcode_shuts_out_the_old_one_and_keeps_the_key() {
let (r, owner) = setup(None, 6);
let r = r
.change_passcode(&owner, b"9999", &mut memory(), &mut Counter(70))
.unwrap();
assert!(signed_in(&r, "gabriel", b"1234").is_none());
let s = signed_in(&r, "gabriel", b"9999").unwrap();
assert_eq!(s.key, owner.key, "the files stay readable");
assert!(s.is_owner(), "and an owner stays an owner");
assert!(r.users(&s).is_ok(), "who can still manage users");
}
#[test]
fn records_round_trip_and_damage_is_detected() {
let (record, _) = setup(Some(b"0000"), 3);
let (record, _) = setup(Some(b"0000"), 7);
let bytes = record.encode();
assert_eq!(Record::decode(&bytes), Some(record));
for at in [0, 9, 18, 40, 200] {
for at in [0, 9, 18, 40, 300, 1200] {
let mut torn = bytes;
torn[at] ^= 1;
assert_eq!(Record::decode(&torn), None, "byte {at}");
}
assert_eq!(
Record::decode(&[0; RECORD_SIZE]),
None,
"a blank device holds no record"
);
assert_eq!(Record::decode(&[0; RECORD_SIZE]), None);
}
#[test]
fn the_newest_whole_copy_wins_and_writes_go_to_the_other() {
let (old, _) = setup(None, 4);
let (old, _) = setup(None, 8);
let new = old.next(|r| r.failures = 1);
let (a, b) = (old.encode(), new.encode());
let copies = Copies { copies: [&a, &b] };
assert_eq!(copies.newest(), Some((new, 1)));
assert_eq!(copies.next_copy(), 0, "the older copy is overwritten");
// A torn write of the newer copy leaves the older one.
assert_eq!(Copies { copies: [&a, &b] }.newest(), Some((new, 1)));
assert_eq!(Copies { copies: [&b, &a] }.newest(), Some((new, 0)));
assert_eq!(Copies { copies: [&a, &b] }.next_copy(), 0);
let mut torn = b;
torn[100] ^= 0xFF;
let copies = Copies {
copies: [&a, &torn],
};
assert_eq!(copies.newest(), Some((old, 0)));
assert_eq!(copies.next_copy(), 1);
// Newest wins wherever it is.
let copies = Copies { copies: [&b, &a] };
assert_eq!(copies.newest(), Some((new, 0)));
let blank = [0u8; RECORD_SIZE];
assert_eq!(
Copies {
copies: [&blank, &blank]
copies: [&a, &torn]
}
.newest(),
None
Some((old, 0))
);
}
#[test]
fn wiping_leaves_nothing_any_passcode_opens() {
let (record, _) = setup(Some(b"0000"), 5);
fn wiping_leaves_nothing_any_name_opens() {
let (record, owner) = setup(Some(b"0000"), 9);
let wiped = record.wiping(&mut Counter(99)).unwrap();
assert_eq!(wiped.state, State::Wiping);
assert!(
wiped.must_wipe(),
"an interrupted wipe is finished next boot"
);
assert!(
wiped.generation > record.generation,
"it is the newest copy"
);
let mem = &mut memory();
assert!(wiped.must_wipe());
assert!(wiped.generation > record.generation);
for p in [&b"1234"[..], b"0000"] {
assert_eq!(wiped.unlock(p, mem), Outcome::Wrong);
assert!(is_wrong(wiped.unlock(b"gabriel", p, &mut memory())));
}
assert_eq!(
wiped.users(&owner).err(),
Some(Refused::Failed),
"nor does the directory open"
);
}
#[test]
fn too_many_failures_mean_a_wipe() {
let (record, _) = setup(None, 6);
let (record, _) = setup(None, 10);
assert!(!record.must_wipe());
let worn = record.next(|r| r.failures = r.threshold);
assert!(worn.must_wipe());
assert!(record.next(|r| r.failures = r.threshold).must_wipe());
}
#[test]
fn no_randomness_means_no_keystore() {
assert!(Record::setup(b"1234", None, 5, FAST, &mut memory(), &mut Broken).is_none());
fn names_are_printable_and_short() {
assert!(Name::new(b"").is_none());
assert!(Name::new(b"has space").is_none());
assert!(Name::new(&[b'a'; 17]).is_none());
assert!(Name::new(b"Gabriel_K-1").is_some());
}
#[test]
fn the_default_parameters_are_accepted_by_argon2() {
assert!(
argon2::Params::new(
KdfParams::DEFAULT.memory_kib,
KdfParams::DEFAULT.passes,
1,
Some(KEY_SIZE)
)
.is_ok()
fn user_ids_name_folders_in_hex() {
assert_eq!(
&UserId([0xde, 0xad, 0xbe, 0xef, 0, 1, 2, 0xff]).hex(),
b"deadbeef000102ff"
);
}

View File

@@ -0,0 +1,91 @@
//! Who a slot belongs to.
/// Up to 16 bytes of printable ASCII.
#[derive(Clone, Copy, PartialEq, Eq)]
pub struct Name {
bytes: [u8; Name::MAX],
len: u8,
}
impl Name {
pub const MAX: usize = 16;
pub fn new(s: &[u8]) -> Option<Self> {
let ok =
!s.is_empty() && s.len() <= Self::MAX && s.iter().all(|b| (b'!'..=b'~').contains(b));
ok.then(|| {
let mut bytes = [0; Self::MAX];
bytes[..s.len()].copy_from_slice(s);
Self {
bytes,
len: s.len() as u8,
}
})
}
pub fn as_bytes(&self) -> &[u8] {
&self.bytes[..self.len as usize]
}
pub(crate) fn raw(&self) -> [u8; Self::MAX] {
self.bytes
}
pub(crate) fn from_raw(raw: [u8; Self::MAX]) -> Option<Self> {
let len = raw.iter().position(|b| *b == 0).unwrap_or(Self::MAX);
Self::new(&raw[..len])
}
}
impl core::fmt::Debug for Name {
fn fmt(&self, f: &mut core::fmt::Formatter) -> core::fmt::Result {
write!(
f,
"{:?}",
core::str::from_utf8(self.as_bytes()).unwrap_or("?")
)
}
}
/// A user's stable id: random, so their folders give nothing away.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub struct UserId(pub [u8; 8]);
impl UserId {
/// As a folder name: 16 lowercase hex digits.
pub fn hex(&self) -> [u8; 16] {
let digits = b"0123456789abcdef";
core::array::from_fn(|i| digits[(self.0[i / 2] >> (4 * (1 - i % 2)) & 0xF) as usize])
}
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum Role {
/// Manages users at the lock screen.
Owner,
Standard,
}
impl Role {
pub(crate) fn byte(self) -> u8 {
match self {
Role::Owner => 1,
Role::Standard => 2,
}
}
pub(crate) fn from_byte(b: u8) -> Option<Self> {
match b {
1 => Some(Role::Owner),
2 => Some(Role::Standard),
_ => None,
}
}
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub struct User {
pub id: UserId,
pub role: Role,
pub name: Name,
}