diff --git a/Cargo.lock b/Cargo.lock index 62fdf02..b19cef5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,6 +17,16 @@ dependencies = [ "types", ] +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + [[package]] name = "alarm-server" version = "0.1.0" @@ -28,6 +38,17 @@ dependencies = [ "manifest", ] +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures", +] + [[package]] name = "audio-capture-server" version = "0.1.0" @@ -36,6 +57,30 @@ version = "0.1.0" name = "audio-playback-server" version = "0.1.0" +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + [[package]] name = "bluetooth-server" version = "0.1.0" @@ -56,6 +101,47 @@ dependencies = [ "types", ] +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "chacha20" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "chacha20poly1305" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" +dependencies = [ + "aead", + "chacha20", + "cipher", + "poly1305", + "zeroize", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", + "zeroize", +] + [[package]] name = "clipboard-server" version = "0.1.0" @@ -72,6 +158,36 @@ dependencies = [ "types", ] +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", + "subtle", +] + [[package]] name = "display-qemu" version = "0.1.0" @@ -149,6 +265,16 @@ dependencies = [ "types", ] +[[package]] +name = "driver-rng-virtio" +version = "0.1.0" +dependencies = [ + "hw-virtio", + "libkos", + "port-entropy", + "types", +] + [[package]] name = "echo" version = "0.1.0" @@ -165,6 +291,16 @@ version = "0.1.0" name = "gatekeeper" version = "0.1.0" +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + [[package]] name = "gps-server" version = "0.1.0" @@ -195,6 +331,15 @@ dependencies = [ "types", ] +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + [[package]] name = "input-qemu" version = "0.1.0" @@ -231,6 +376,22 @@ dependencies = [ name = "keychain-server" version = "0.1.0" +[[package]] +name = "keystore" +version = "0.1.0" +dependencies = [ + "argon2", + "blake2", + "chacha20poly1305", + "zeroize", +] + +[[package]] +name = "libc" +version = "0.2.191" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" + [[package]] name = "libkos" version = "0.1.0" @@ -271,6 +432,12 @@ version = "0.1.0" name = "notification-server" version = "0.1.0" +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + [[package]] name = "package-manager" version = "0.1.0" @@ -292,10 +459,25 @@ dependencies = [ "types", ] +[[package]] +name = "poly1305" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" +dependencies = [ + "cpufeatures", + "opaque-debug", + "universal-hash", +] + [[package]] name = "port-block-device" version = "0.1.0" +[[package]] +name = "port-entropy" +version = "0.1.0" + [[package]] name = "port-framebuffer" version = "0.1.0" @@ -395,6 +577,12 @@ dependencies = [ "types", ] +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "sync-server" version = "0.1.0" @@ -412,10 +600,26 @@ dependencies = [ "types", ] +[[package]] +name = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + [[package]] name = "types" version = "0.1.0" +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + [[package]] name = "usb-server" version = "0.1.0" @@ -423,3 +627,15 @@ version = "0.1.0" [[package]] name = "user-manager" version = "0.1.0" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "zeroize" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879" diff --git a/Cargo.toml b/Cargo.toml index fe4b98c..e29e40c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,6 +14,9 @@ members = [ "crates/domain", "crates/elf", "crates/manifest", + "crates/keystore", + "crates/ports/entropy", + "crates/drivers/rng/virtio", "crates/tools/app-image", "crates/init", "crates/kernel", @@ -72,6 +75,14 @@ types = { path = "crates/types" } domain = { path = "crates/domain" } elf = { path = "crates/elf" } manifest = { path = "crates/manifest" } +keystore = { path = "crates/keystore" } +# Audited RustCrypto implementations; never our own. +argon2 = { version = "0.5", default-features = false } +chacha20poly1305 = { version = "0.10", default-features = false } +blake2 = { version = "0.10", default-features = false } +zeroize = { version = "1", default-features = false } +port-entropy = { path = "crates/ports/entropy" } +driver-rng-virtio = { path = "crates/drivers/rng/virtio" } platform = { path = "crates/platform" } kernel = { path = "crates/kernel" } server-framework = { path = "crates/server-framework" } diff --git a/crates/drivers/rng/virtio/Cargo.toml b/crates/drivers/rng/virtio/Cargo.toml new file mode 100644 index 0000000..636e438 --- /dev/null +++ b/crates/drivers/rng/virtio/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "driver-rng-virtio" +version = "0.1.0" +edition = "2024" + +[dependencies] +types = { workspace = true } +libkos = { workspace = true } +hw-virtio = { workspace = true } +port-entropy = { workspace = true } diff --git a/crates/drivers/rng/virtio/src/lib.rs b/crates/drivers/rng/virtio/src/lib.rs new file mode 100644 index 0000000..791da2f --- /dev/null +++ b/crates/drivers/rng/virtio/src/lib.rs @@ -0,0 +1,74 @@ +//! virtio-rng (virtio spec 5.4): the device fills a buffer with entropy. + +#![no_std] +#![forbid(unsafe_code)] + +use hw_virtio::mmio::{MmioAccess, Registers}; +use hw_virtio::queue::{Descriptor, VirtioQueue}; +use libkos::device::Dma; +use port_entropy::Entropy; +use types::address::{ByteOffset, VirtualAddress}; +use types::bounded::PageCount; + +const MMIO_VA: VirtualAddress = VirtualAddress::from_raw(0x2A000); +const VQUEUE_VA: VirtualAddress = VirtualAddress::from_raw(0x2B000); +const BUFFER_VA: VirtualAddress = VirtualAddress::from_raw(0x2D000); +/// Asked for at most this much at a time. +const CHUNK: usize = 256; + +pub struct VirtioRng { + regs: Registers, + queue: VirtioQueue, + buffer: Dma, +} + +impl VirtioRng { + /// Takes over the virtio-rng device behind `mmio`; `None` if it is not + /// one or any step fails. + pub fn new(mmio: types::bounded::CapSlot) -> Option { + let regs = Registers::new(libkos::device::map_mmio(mmio, MMIO_VA).ok()?); + if regs.read(hw_virtio::mmio::REG_MAGIC) != hw_virtio::mmio::MAGIC + || regs.read(hw_virtio::mmio::REG_DEVICE_ID) != hw_virtio::mmio::DEVICE_ID_RNG + { + return None; + } + let rings = libkos::device::dma(PageCount::new(2)?, VQUEUE_VA).ok()?; + let buffer = libkos::device::dma(PageCount::new(1)?, BUFFER_VA).ok()?; + hw_virtio::mmio::init_legacy_device( + ®s, + rings.physical, + hw_virtio::queue::QUEUE_SIZE as u32, + ); + Some(Self { + regs, + queue: VirtioQueue::new(rings.window), + buffer, + }) + } +} + +impl Entropy for VirtioRng { + fn fill(&mut self, buf: &mut [u8]) -> bool { + let mut done = 0; + while done < buf.len() { + let want = (buf.len() - done).min(CHUNK); + let desc = [Descriptor { + addr: self.buffer.physical.raw() as u64, + len: want as u32, + writable: true, + }]; + let got = match self.queue.submit_written(&self.regs, &desc) { + Some(n) if n > 0 => (n as usize).min(want), + // The device gave nothing (or never answered): no randomness. + _ => return false, + }; + self.buffer + .window + .copy_to(ByteOffset::ZERO, &mut buf[done..done + got]); + done += got; + } + // What was handed out is not left lying in the buffer. + self.buffer.window.fill_u32(ByteOffset::ZERO, CHUNK / 4, 0); + true + } +} diff --git a/crates/hw-virtio/src/mmio.rs b/crates/hw-virtio/src/mmio.rs index b2036e2..2c4e055 100644 --- a/crates/hw-virtio/src/mmio.rs +++ b/crates/hw-virtio/src/mmio.rs @@ -29,6 +29,7 @@ pub const STATUS_DRIVER: u32 = 2; pub const STATUS_DRIVER_OK: u32 = 4; pub const DEVICE_ID_BLOCK: u32 = 2; +pub const DEVICE_ID_RNG: u32 = 4; pub const DEVICE_ID_GPU: u32 = 16; pub const DEVICE_ID_INPUT: u32 = 18; diff --git a/crates/hw-virtio/src/queue.rs b/crates/hw-virtio/src/queue.rs index 09fd22f..3748076 100644 --- a/crates/hw-virtio/src/queue.rs +++ b/crates/hw-virtio/src/queue.rs @@ -28,8 +28,14 @@ impl VirtioQueue { /// Hands the device a chain of `descs` and waits until it is used. pub fn submit(&self, mmio: &impl MmioAccess, descs: &[Descriptor]) -> bool { + self.submit_written(mmio, descs).is_some() + } + + /// Like `submit`; how many bytes the device wrote into the chain's + /// writable buffers (it may write fewer than they hold). + pub fn submit_written(&self, mmio: &impl MmioAccess, descs: &[Descriptor]) -> Option { if descs.is_empty() || descs.len() > QUEUE_SIZE { - return false; + return None; } let r = &self.rings; for (i, d) in descs.iter().enumerate() { @@ -56,9 +62,11 @@ impl VirtioQueue { core::hint::spin_loop(); timeout -= 1; if timeout == 0 { - return false; + return None; } } - true + // The used element just added: the chain's head id, then its length. + let element = USED + 4 + (avail_idx as usize % QUEUE_SIZE) * 8; + Some(r.read::(element + 4)) } } diff --git a/crates/keystore/Cargo.toml b/crates/keystore/Cargo.toml new file mode 100644 index 0000000..1283851 --- /dev/null +++ b/crates/keystore/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "keystore" +version = "0.1.0" +edition = "2024" + +[dependencies] +argon2 = { workspace = true } +chacha20poly1305 = { workspace = true } +blake2 = { workspace = true } +zeroize = { workspace = true } diff --git a/crates/keystore/src/lib.rs b/crates/keystore/src/lib.rs new file mode 100644 index 0000000..12c1047 --- /dev/null +++ b/crates/keystore/src/lib.rs @@ -0,0 +1,224 @@ +//! 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). +//! +//! 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. + +#![no_std] +#![forbid(unsafe_code)] + +mod record; + +#[cfg(test)] +mod tests; + +pub use record::{Copies, KdfParams, RECORD_SIZE, Record, State}; + +use argon2::{Algorithm, Argon2, Params, Version}; +use chacha20poly1305::aead::{AeadInPlace, KeyInit}; +use chacha20poly1305::{ChaCha20Poly1305, Nonce, Tag}; +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; + +/// The master 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]); + +impl Drop for Key { + fn drop(&mut self) { + self.0.zeroize(); + } +} + +impl core::fmt::Debug for Key { + fn fmt(&self, f: &mut core::fmt::Formatter) -> core::fmt::Result { + f.write_str("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 { + 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]); + Argon2::new(Algorithm::Argon2id, Version::V0x13, p) + .hash_password_into_with_memory(passcode, salt, &mut out.0, memory) + .ok()?; + 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 { + let mut s = [0u8; SLOT_SIZE]; + rng.fill(&mut s).then_some(Self(s)) + } + + /// `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 { + 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; + } + 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)) + } + + /// 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]; + 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 { + 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 + } +} diff --git a/crates/keystore/src/record.rs b/crates/keystore/src/record.rs new file mode 100644 index 0000000..d51533c --- /dev/null +++ b/crates/keystore/src/record.rs @@ -0,0 +1,137 @@ +//! The record's bytes, and the two copies it is kept in. + +use blake2::{Blake2s256, Digest}; + +use crate::{SLOT_SIZE, Slot}; + +/// One record fills one sector. +pub const RECORD_SIZE: usize = 512; +const MAGIC: &[u8; 8] = b"KOSKEYS1"; +const CHECKSUM: usize = 32; +const BODY: usize = 8 + 8 + 4 + 8 + 2 * SLOT_SIZE; + +/// How slow a passcode guess is: argon2id's memory (KiB) and passes. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct KdfParams { + pub memory_kib: u32, + pub passes: u32, +} + +impl KdfParams { + /// argon2id with 16 MiB and 3 passes: above OWASP's minimum (12 MiB, 3). + pub const DEFAULT: Self = Self { + memory_kib: 16 * 1024, + passes: 3, + }; +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum State { + Active, + /// A wipe started and may not have finished. + Wiping, +} + +#[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. + 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, + pub params: KdfParams, + pub slots: [Slot; 2], +} + +impl core::fmt::Debug for Record { + fn fmt(&self, f: &mut core::fmt::Formatter) -> core::fmt::Result { + f.debug_struct("Record") + .field("generation", &self.generation) + .field("state", &self.state) + .field("failures", &self.failures) + .field("threshold", &self.threshold) + .finish_non_exhaustive() + } +} + +fn checksum(body: &[u8]) -> [u8; CHECKSUM] { + Blake2s256::digest(body).into() +} + +impl Record { + pub fn encode(&self) -> [u8; RECORD_SIZE] { + let mut out = [0u8; RECORD_SIZE]; + out[0..8].copy_from_slice(MAGIC); + out[8..16].copy_from_slice(&self.generation.to_le_bytes()); + out[16] = match self.state { + State::Active => 1, + State::Wiping => 2, + }; + out[17] = self.threshold; + out[18] = self.failures; + 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[BODY..BODY + CHECKSUM].copy_from_slice(&sum); + out + } + + /// `None` unless it is a whole, unaltered record (magic and checksum). + pub fn decode(bytes: &[u8]) -> Option { + let bytes = bytes.get(..RECORD_SIZE)?; + if &bytes[0..8] != MAGIC || bytes[BODY..BODY + CHECKSUM] != checksum(&bytes[..BODY]) { + return None; + } + let word = |at: usize| u32::from_le_bytes(bytes[at..at + 4].try_into().unwrap_or([0; 4])); + let state = match bytes[16] { + 1 => State::Active, + 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]); + Some(Self { + generation: u64::from_le_bytes(bytes[8..16].try_into().ok()?), + state, + threshold: bytes[17], + failures: bytes[18], + params: crate::KdfParams { + memory_kib: word(20), + passes: word(24), + }, + slots: [Slot(a), Slot(b)], + }) + } +} + +/// The record's two copies, as read from the device. +pub struct Copies<'a> { + pub copies: [&'a [u8]; 2], +} + +impl Copies<'_> { + /// The newest whole record and the copy it is in; `None` for a device + /// never set up (or wiped). + pub fn newest(&self) -> Option<(Record, usize)> { + let [a, b] = self.copies.map(Record::decode); + match (a, b) { + (Some(a), Some(b)) if b.generation > a.generation => Some((b, 1)), + (Some(a), _) => Some((a, 0)), + (None, Some(b)) => Some((b, 1)), + (None, None) => None, + } + } + + /// Where the next write goes: over the copy that is not the newest, so + /// a torn write leaves the newest whole. + pub fn next_copy(&self) -> usize { + self.newest().map_or(0, |(_, copy)| 1 - copy) + } +} diff --git a/crates/keystore/src/tests.rs b/crates/keystore/src/tests.rs new file mode 100644 index 0000000..00d7480 --- /dev/null +++ b/crates/keystore/src/tests.rs @@ -0,0 +1,172 @@ +extern crate std; + +use crate::*; +use std::vec; +use std::vec::Vec; + +/// Fast enough for tests; the real ones are `KdfParams::DEFAULT`. +const FAST: KdfParams = KdfParams { + memory_kib: 8, + passes: 1, +}; + +/// A deterministic stand-in for the hardware's randomness. +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(31).wrapping_add(7); + *b = self.0; + } + true + } +} + +struct Broken; + +impl Random for Broken { + fn fill(&mut self, _buf: &mut [u8]) -> bool { + false + } +} + +fn memory() -> Vec { + 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() +} + +#[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:?}"); + } +} + +#[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); + assert_eq!( + record.unlock(b"", mem), + Outcome::Wrong, + "not even an empty one" + ); +} + +#[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; + } + assert_eq!(firsts, [true, true]); +} + +#[test] +fn records_round_trip_and_damage_is_detected() { + let (record, _) = setup(Some(b"0000"), 3); + let bytes = record.encode(); + assert_eq!(Record::decode(&bytes), Some(record)); + for at in [0, 9, 18, 40, 200] { + 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" + ); +} + +#[test] +fn the_newest_whole_copy_wins_and_writes_go_to_the_other() { + let (old, _) = setup(None, 4); + 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. + 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] + } + .newest(), + None + ); +} + +#[test] +fn wiping_leaves_nothing_any_passcode_opens() { + let (record, _) = setup(Some(b"0000"), 5); + 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(); + for p in [&b"1234"[..], b"0000"] { + assert_eq!(wiped.unlock(p, mem), Outcome::Wrong); + } +} + +#[test] +fn too_many_failures_mean_a_wipe() { + let (record, _) = setup(None, 6); + assert!(!record.must_wipe()); + let worn = record.next(|r| r.failures = r.threshold); + assert!(worn.must_wipe()); +} + +#[test] +fn no_randomness_means_no_keystore() { + assert!(Record::setup(b"1234", None, 5, FAST, &mut memory(), &mut Broken).is_none()); +} + +#[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() + ); +} diff --git a/crates/ports/entropy/Cargo.toml b/crates/ports/entropy/Cargo.toml new file mode 100644 index 0000000..4a43e6d --- /dev/null +++ b/crates/ports/entropy/Cargo.toml @@ -0,0 +1,6 @@ +[package] +name = "port-entropy" +version = "0.1.0" +edition = "2024" + +[dependencies] diff --git a/crates/ports/entropy/src/lib.rs b/crates/ports/entropy/src/lib.rs new file mode 100644 index 0000000..97f4fdd --- /dev/null +++ b/crates/ports/entropy/src/lib.rs @@ -0,0 +1,10 @@ +//! Where randomness comes from: keys, salts, nonces. + +#![no_std] +#![forbid(unsafe_code)] + +pub trait Entropy { + /// Fills `buf` with random bytes from the hardware; false if it could + /// not (then nothing in `buf` may be used). + fn fill(&mut self, buf: &mut [u8]) -> bool; +}