feat(keystore): sealed real/duress slots (argon2id 16MiB/3, ChaCha20-Poly1305), torn-write-safe 2-copy record, wipe state; virtio-rng driver + Entropy port; RustCrypto deps

This commit is contained in:
2026-10-27 23:06:25 +01:00
parent 6b13bf8bbf
commit 600140d518
12 changed files with 882 additions and 3 deletions

216
Cargo.lock generated
View File

@@ -17,6 +17,16 @@ dependencies = [
"types", "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]] [[package]]
name = "alarm-server" name = "alarm-server"
version = "0.1.0" version = "0.1.0"
@@ -28,6 +38,17 @@ dependencies = [
"manifest", "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]] [[package]]
name = "audio-capture-server" name = "audio-capture-server"
version = "0.1.0" version = "0.1.0"
@@ -36,6 +57,30 @@ version = "0.1.0"
name = "audio-playback-server" name = "audio-playback-server"
version = "0.1.0" 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]] [[package]]
name = "bluetooth-server" name = "bluetooth-server"
version = "0.1.0" version = "0.1.0"
@@ -56,6 +101,47 @@ dependencies = [
"types", "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]] [[package]]
name = "clipboard-server" name = "clipboard-server"
version = "0.1.0" version = "0.1.0"
@@ -72,6 +158,36 @@ dependencies = [
"types", "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]] [[package]]
name = "display-qemu" name = "display-qemu"
version = "0.1.0" version = "0.1.0"
@@ -149,6 +265,16 @@ dependencies = [
"types", "types",
] ]
[[package]]
name = "driver-rng-virtio"
version = "0.1.0"
dependencies = [
"hw-virtio",
"libkos",
"port-entropy",
"types",
]
[[package]] [[package]]
name = "echo" name = "echo"
version = "0.1.0" version = "0.1.0"
@@ -165,6 +291,16 @@ version = "0.1.0"
name = "gatekeeper" name = "gatekeeper"
version = "0.1.0" 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]] [[package]]
name = "gps-server" name = "gps-server"
version = "0.1.0" version = "0.1.0"
@@ -195,6 +331,15 @@ dependencies = [
"types", "types",
] ]
[[package]]
name = "inout"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
dependencies = [
"generic-array",
]
[[package]] [[package]]
name = "input-qemu" name = "input-qemu"
version = "0.1.0" version = "0.1.0"
@@ -231,6 +376,22 @@ dependencies = [
name = "keychain-server" name = "keychain-server"
version = "0.1.0" 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]] [[package]]
name = "libkos" name = "libkos"
version = "0.1.0" version = "0.1.0"
@@ -271,6 +432,12 @@ version = "0.1.0"
name = "notification-server" name = "notification-server"
version = "0.1.0" 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]] [[package]]
name = "package-manager" name = "package-manager"
version = "0.1.0" version = "0.1.0"
@@ -292,10 +459,25 @@ dependencies = [
"types", "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]] [[package]]
name = "port-block-device" name = "port-block-device"
version = "0.1.0" version = "0.1.0"
[[package]]
name = "port-entropy"
version = "0.1.0"
[[package]] [[package]]
name = "port-framebuffer" name = "port-framebuffer"
version = "0.1.0" version = "0.1.0"
@@ -395,6 +577,12 @@ dependencies = [
"types", "types",
] ]
[[package]]
name = "subtle"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]] [[package]]
name = "sync-server" name = "sync-server"
version = "0.1.0" version = "0.1.0"
@@ -412,10 +600,26 @@ dependencies = [
"types", "types",
] ]
[[package]]
name = "typenum"
version = "1.20.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
[[package]] [[package]]
name = "types" name = "types"
version = "0.1.0" 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]] [[package]]
name = "usb-server" name = "usb-server"
version = "0.1.0" version = "0.1.0"
@@ -423,3 +627,15 @@ version = "0.1.0"
[[package]] [[package]]
name = "user-manager" name = "user-manager"
version = "0.1.0" 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"

View File

@@ -14,6 +14,9 @@ members = [
"crates/domain", "crates/domain",
"crates/elf", "crates/elf",
"crates/manifest", "crates/manifest",
"crates/keystore",
"crates/ports/entropy",
"crates/drivers/rng/virtio",
"crates/tools/app-image", "crates/tools/app-image",
"crates/init", "crates/init",
"crates/kernel", "crates/kernel",
@@ -72,6 +75,14 @@ types = { path = "crates/types" }
domain = { path = "crates/domain" } domain = { path = "crates/domain" }
elf = { path = "crates/elf" } elf = { path = "crates/elf" }
manifest = { path = "crates/manifest" } 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" } platform = { path = "crates/platform" }
kernel = { path = "crates/kernel" } kernel = { path = "crates/kernel" }
server-framework = { path = "crates/server-framework" } server-framework = { path = "crates/server-framework" }

View File

@@ -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 }

View File

@@ -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<Self> {
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(
&regs,
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
}
}

View File

@@ -29,6 +29,7 @@ pub const STATUS_DRIVER: u32 = 2;
pub const STATUS_DRIVER_OK: u32 = 4; pub const STATUS_DRIVER_OK: u32 = 4;
pub const DEVICE_ID_BLOCK: u32 = 2; 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_GPU: u32 = 16;
pub const DEVICE_ID_INPUT: u32 = 18; pub const DEVICE_ID_INPUT: u32 = 18;

View File

@@ -28,8 +28,14 @@ impl VirtioQueue {
/// Hands the device a chain of `descs` and waits until it is used. /// Hands the device a chain of `descs` and waits until it is used.
pub fn submit(&self, mmio: &impl MmioAccess, descs: &[Descriptor]) -> bool { 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<u32> {
if descs.is_empty() || descs.len() > QUEUE_SIZE { if descs.is_empty() || descs.len() > QUEUE_SIZE {
return false; return None;
} }
let r = &self.rings; let r = &self.rings;
for (i, d) in descs.iter().enumerate() { for (i, d) in descs.iter().enumerate() {
@@ -56,9 +62,11 @@ impl VirtioQueue {
core::hint::spin_loop(); core::hint::spin_loop();
timeout -= 1; timeout -= 1;
if timeout == 0 { 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::<u32>(element + 4))
} }
} }

View File

@@ -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 }

224
crates/keystore/src/lib.rs Normal file
View File

@@ -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<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]);
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<Self> {
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<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;
}
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<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
}
}

View File

@@ -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<Self> {
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)
}
}

View File

@@ -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<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()
}
#[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()
);
}

View File

@@ -0,0 +1,6 @@
[package]
name = "port-entropy"
version = "0.1.0"
edition = "2024"
[dependencies]

View File

@@ -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;
}