crates/installer/nordisk-pairing/src/fed_at_rest.rs
//! **The installer's feed, at rest** (0.5.17, A7 row 21; Gunnar 0a038157 `fed_at_rest`).
//!
//! `<data>/pairing/joined.toml` is the whole [`crate::join::JoinReply`] a box or a front was
//! fed: on a front the shop's client key, the console token, every `tls.*.key` and each box's
//! `wg_psk.<box>`; on main and twin the console token and the link's `wg_psk`. It was written
//! plain, 0600 ([`crate::boxside::write_atomic`]).
//!
//! Now it is written SEALED and 0600, the way Gunnar does it: AES-256-GCM under the pairing
//! KEK, the file's relative path bound as the AEAD's associated data (a sealed file moved
//! under another name does not open). The KEK is `NORDISK_SECRET_KEK` (or Gunnar's
//! `GUNNAR_SECRET_KEK`, 64 hex characters) when the medium carries one, else
//! `<data>/pairing/secrets.kek`, 32 bytes, 0600, minted on the first WRITE. No TPM exists on
//! the boxes, so a key the box reads unattended at boot is a key on the box: what this buys
//! is that the feed's secrets are no longer one `cat`, one support tarball or one copied
//! file away, and that a copy of the file alone reads as noise.
//!
//! The sealed bytes are Gunnar's `gsk1` format (`gunnar_core::seal`): magic `gsk1`, a fresh
//! 12-byte nonce, ciphertext and tag. A PLAINTEXT file (a box fed before 0.5.17, a test
//! fixture) is still read, and is sealed the next time the feed is written. A sealed file
//! never makes the box mint a key to read it.
use std::path::Path;
use ring::aead::{Aad, LessSafeKey, Nonce, UnboundKey, AES_256_GCM, NONCE_LEN};
use ring::rand::{SecureRandom as _, SystemRandom};
use zeroize::Zeroize as _;
/// The pairing directory the KEK lives in, under the data root.
pub const PAIRING_DIR_REL: &str = "pairing";
/// The file the on-disk KEK is kept in, inside [`PAIRING_DIR_REL`].
pub const KEK_FILE: &str = "secrets.kek";
/// Magic and version prefix on every sealed value (byte-compatible with Gunnar's `gsk1`).
pub const SEAL_MAGIC: &[u8; 4] = b"gsk1";
/// The environment variables a KEK handed in from outside the machine is read from, in
/// order, when the product named none ([`name_kek_env`]): this crate's, then Gunnar's.
pub const KEK_ENVS: [&str; 2] = ["NORDISK_SECRET_KEK", "GUNNAR_SECRET_KEK"];
const KEK_LEN: usize = 32;
const TAG_LEN: usize = 16;
/// A key-encrypting key; never printed, wiped on drop.
struct Kek([u8; KEK_LEN]);
impl Drop for Kek {
fn drop(&mut self) {
self.0.zeroize();
}
}
impl Kek {
fn aead(&self) -> Result<LessSafeKey, String> {
UnboundKey::new(&AES_256_GCM, &self.0).map(LessSafeKey::new).map_err(|_| "the KEK is not an AES-256 key".to_string())
}
}
/// The KEK handed in from outside the machine, if any. Set and unusable is an error, never a
/// silent fall back to a key on the disk.
fn kek_from_env() -> Result<Option<Kek>, String> {
kek_from_lookup(&kek_envs(), &|n| std::env::var(n).ok())
}
/// The product's own KEK variable (lane D14), named once at boot by [`name_kek_env`].
static PRODUCT_KEK_ENV: std::sync::OnceLock<&'static str> = std::sync::OnceLock::new();
/// **The product names its KEK variable** (lane D14): a Gunnar box `GUNNAR_SECRET_KEK`, so its
/// files open with Gunnar's key and no other, whatever else the environment holds. Named once,
/// before the first read or write; `false` when another name was given first. Unnamed, the
/// variables are [`KEK_ENVS`], in that order.
pub fn name_kek_env(name: &'static str) -> bool {
PRODUCT_KEK_ENV.get_or_init(|| name) == &name
}
/// The variables the KEK is read from: the product's own when it named one, else [`KEK_ENVS`].
fn kek_envs() -> Vec<&'static str> {
PRODUCT_KEK_ENV.get().map(|n| vec![*n]).unwrap_or_else(|| KEK_ENVS.to_vec())
}
/// [`kek_from_env`] over the variables to read and the lookup, in order.
fn kek_from_lookup(names: &[&str], get: &dyn Fn(&str) -> Option<String>) -> Result<Option<Kek>, String> {
for &name in names {
let Some(text) = get(name) else { continue };
let text = text.trim();
if text.len() != KEK_LEN * 2 || !text.bytes().all(|b| b.is_ascii_hexdigit()) {
return Err(format!("{name} is not {} hexadecimal characters", KEK_LEN * 2));
}
let mut k = [0u8; KEK_LEN];
for (i, b) in k.iter_mut().enumerate() {
*b = u8::from_str_radix(&text[2 * i..2 * i + 2], 16).map_err(|_| format!("{name} is not hexadecimal"))?;
}
return Ok(Some(Kek(k)));
}
Ok(None)
}
fn kek_from_file(path: &Path) -> Result<Kek, String> {
let mut raw = std::fs::read(path).map_err(|e| format!("{}: {e}", path.display()))?;
let k: Result<[u8; KEK_LEN], _> = raw.as_slice().try_into();
let n = raw.len();
raw.zeroize();
k.map(Kek).map_err(|_| format!("the KEK at {} is {n} bytes; a KEK is {KEK_LEN} (refusing to guess)", path.display()))
}
/// The KEK to WRITE with: the environment's, else the file's, minted (0600, durably) on first use.
fn kek_for_write(root: &Path) -> Result<Kek, String> {
if let Some(k) = kek_from_env()? {
return Ok(k);
}
let path = root.join(PAIRING_DIR_REL).join(KEK_FILE);
if path.exists() {
return kek_from_file(&path);
}
let mut k = [0u8; KEK_LEN];
SystemRandom::new().fill(&mut k).map_err(|_| "the system CSPRNG refused to produce a KEK".to_string())?;
let kek = Kek(k);
k.zeroize();
match appliance_pid1::durable::write_new(&path, &kek.0, Some(0o600)) {
Ok(()) => Ok(kek),
// Another writer minted it first: that one is the box's.
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => kek_from_file(&path),
Err(e) => Err(format!("{}: {e}", path.display())),
}
}
/// Whether `raw` is a sealed value.
pub fn is_sealed(raw: &[u8]) -> bool {
raw.len() >= SEAL_MAGIC.len() + NONCE_LEN + TAG_LEN && raw.starts_with(SEAL_MAGIC)
}
fn seal(kek: &Kek, rel: &str, plain: &[u8]) -> Result<Vec<u8>, String> {
let mut nonce = [0u8; NONCE_LEN];
SystemRandom::new().fill(&mut nonce).map_err(|_| "the system CSPRNG refused to produce a nonce".to_string())?;
let mut body = plain.to_vec();
kek.aead()?.seal_in_place_append_tag(Nonce::assume_unique_for_key(nonce), Aad::from(rel.as_bytes()), &mut body).map_err(|_| format!("could not seal {rel}"))?;
let mut out = Vec::with_capacity(SEAL_MAGIC.len() + NONCE_LEN + body.len());
out.extend_from_slice(SEAL_MAGIC);
out.extend_from_slice(&nonce);
out.extend_from_slice(&body);
Ok(out)
}
fn unseal(kek: &Kek, rel: &str, sealed: &[u8]) -> Result<Vec<u8>, String> {
let body_at = SEAL_MAGIC.len() + NONCE_LEN;
let mut nonce = [0u8; NONCE_LEN];
nonce.copy_from_slice(&sealed[SEAL_MAGIC.len()..body_at]);
let mut body = sealed[body_at..].to_vec();
let plain = kek.aead()?.open_in_place(Nonce::assume_unique_for_key(nonce), Aad::from(rel.as_bytes()), &mut body).map_err(|_| "wrong key, wrong file name, or modified bytes".to_string())?.to_vec();
body.zeroize();
Ok(plain)
}
/// **Write the feed**: sealed, 0600, durably (staged, fsync, rename, fsync of the directory).
pub fn write(root: &Path, rel: &str, text: &str) -> Result<(), String> {
let kek = kek_for_write(root)?;
let sealed = seal(&kek, rel, text.as_bytes())?;
appliance_pid1::durable::write_atomic(&root.join(rel), &sealed, Some(0o600)).map_err(|e| format!("{}: {e}", root.join(rel).display()))
}
/// **Read the feed**: unsealed, or a plaintext file from before as it is. `None` when there
/// is no file, or it cannot be opened (the caller that must tell the two apart asks
/// [`read_checked`]).
pub fn read(root: &Path, rel: &str) -> Option<String> {
match read_checked(root, rel) {
Ok(t) => t,
Err(e) => {
// Gunnar's line (7e33c0f7 `fed_at_rest::read`), word for word.
tracing::error!(file = rel, error = %e, "the fed file cannot be read — taken as absent");
None
}
}
}
/// [`read`], telling ABSENT (`Ok(None)`) from PRESENT AND UNREADABLE (`Err`): a kept answer
/// that cannot be opened must not read as "nobody fed this box".
pub fn read_checked(root: &Path, rel: &str) -> Result<Option<String>, String> {
let path = root.join(rel);
let raw = match std::fs::read(&path) {
Ok(r) => r,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(e) => return Err(format!("{}: {e}", path.display())),
};
if !is_sealed(&raw) {
// A feed from before 0.5.17: plaintext, sealed on its next write.
return String::from_utf8(raw).map(Some).map_err(|e| format!("{}: {e}", path.display()));
}
// Never mint a key to read with: a sealed file with no key beside it is unreadable.
let kek_file = root.join(PAIRING_DIR_REL).join(KEK_FILE);
let kek = match kek_from_env()? {
Some(k) => k,
None if kek_file.exists() => kek_from_file(&kek_file)?,
None => return Err(format!("{} is sealed and this box holds no key for it ({} is absent)", path.display(), kek_file.display())),
};
let plain = unseal(&kek, rel, &raw).map_err(|e| format!("{} does not open under this box's key: {e}", path.display()))?;
String::from_utf8(plain).map(Some).map_err(|e| format!("{}: {e}", path.display()))
}
#[cfg(test)]
mod tests {
use super::*;
use std::os::unix::fs::PermissionsExt as _;
const REL: &str = crate::boxside::JOINED_REL;
const SECRET: &str = "sk_test_feedAtRestNEVERlive0123456789";
fn text() -> String {
format!("estate = \"e\"\nrole = \"front\"\npeers = []\n\n[me]\nname = \"vm1\"\noverlay = \"10.44.0.1\"\n\n[feed]\n\"secret.shop_client_key\" = \"{SECRET}\"\nconsole_admin_token = \"{}\"\n", "a".repeat(64))
}
fn dir(tag: &str) -> std::path::PathBuf {
let d = std::env::temp_dir().join(format!("fed-at-rest-{tag}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(d.join(PAIRING_DIR_REL)).unwrap();
d
}
/// ★ Lane D14 (rickard-81): the product names its KEK variable and only that one is read:
/// a Gunnar box with both variables set opens with Gunnar's; unnamed, the old order.
#[test]
fn the_kek_is_read_from_the_variable_the_product_names() {
let env = |n: &str| match n {
"NORDISK_SECRET_KEK" => Some("11".repeat(32)),
"GUNNAR_SECRET_KEK" => Some("22".repeat(32)),
_ => None,
};
assert_eq!(kek_from_lookup(&["GUNNAR_SECRET_KEK"], &env).unwrap().map(|k| k.0), Some([0x22u8; 32]), "the product's own");
assert_eq!(kek_from_lookup(&KEK_ENVS, &env).unwrap().map(|k| k.0), Some([0x11u8; 32]), "unnamed: the old order");
assert!(kek_from_lookup(&["HOLGER_SECRET_KEK"], &env).unwrap().is_none(), "a named variable unset: none from the others");
}
/// Gunnar's `the_feed_is_sealed_and_owner_only_on_disk_and_reads_back_whole`.
#[test]
fn the_feed_is_sealed_and_owner_only_on_disk_and_reads_back_whole() {
let d = dir("sealed");
write(&d, REL, &text()).unwrap();
let raw = std::fs::read(d.join(REL)).unwrap();
assert!(!raw.windows(SECRET.len()).any(|w| w == SECRET.as_bytes()), "the shop key is readable in the file on disk");
assert!(!String::from_utf8_lossy(&raw).contains("console_admin_token"), "the feed's keys are readable on disk");
assert!(is_sealed(&raw), "the file is a sealed value");
let mode = std::fs::metadata(d.join(REL)).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o600, "the feed is the owner's alone");
let kek = std::fs::metadata(d.join(PAIRING_DIR_REL).join(KEK_FILE)).unwrap().permissions().mode() & 0o777;
assert_eq!(kek, 0o600, "the key beside it too");
assert_eq!(read(&d, REL).as_deref(), Some(text().as_str()), "and it reads back byte for byte");
let _ = std::fs::remove_dir_all(&d);
}
/// Gunnar's `a_plaintext_feed_from_before_is_still_read_and_sealed_on_the_next_write`.
#[test]
fn a_plaintext_feed_from_before_is_still_read_and_sealed_on_the_next_write() {
let d = dir("legacy");
std::fs::write(d.join(REL), text()).unwrap();
assert_eq!(read(&d, REL).as_deref(), Some(text().as_str()));
write(&d, REL, &text()).unwrap();
assert!(is_sealed(&std::fs::read(d.join(REL)).unwrap()));
let _ = std::fs::remove_dir_all(&d);
}
/// Gunnar's `a_sealed_feed_is_bound_to_its_file_and_its_key`, and a sealed file never
/// makes the box mint a key to read it.
#[test]
fn a_sealed_feed_is_bound_to_its_file_and_its_key() {
let d = dir("bound");
write(&d, REL, &text()).unwrap();
// Moved under another name: the associated data no longer matches.
std::fs::copy(d.join(REL), d.join("pairing/other.toml")).unwrap();
assert_eq!(read(&d, "pairing/other.toml"), None);
assert!(read_checked(&d, "pairing/other.toml").is_err(), "present and unreadable is an error, never absent");
// Another box's KEK, or none: nothing, and no key is minted to read with.
let e = dir("bound-other");
std::fs::copy(d.join(REL), e.join(REL)).unwrap();
assert_eq!(read(&e, REL), None, "a copy of the file without its box's key reads as nothing");
assert!(!e.join(PAIRING_DIR_REL).join(KEK_FILE).exists(), "reading never mints a key");
write(&e, "pairing/x.toml", "x").unwrap();
std::fs::copy(d.join(REL), e.join(REL)).unwrap();
assert!(read_checked(&e, REL).unwrap_err().contains("does not open"), "another box's key does not open it");
assert_eq!(read_checked(&d, "pairing/absent.toml"), Ok(None), "absent is absent");
let _ = (std::fs::remove_dir_all(&d), std::fs::remove_dir_all(&e));
}
/// ★ 0.5.18 (lane G7, Gunnar 7e33c0f7 `fed_at_rest::read`): a feed that is THERE and cannot
/// be read is still taken as absent, and SAID at ERROR with the file's name; an absent one
/// is said nothing.
#[test]
fn a_feed_that_cannot_be_read_is_said_and_taken_as_absent() {
let root = std::env::temp_dir().join(format!("fed-unreadable-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
let (none, said) = crate::heard(|| read(&root, "pairing/joined.toml"));
assert_eq!(none, None);
assert!(said.is_empty(), "an absent feed is no news: {said}");
let mut junk = SEAL_MAGIC.to_vec();
junk.extend_from_slice(&[7u8; 64]);
appliance_pid1::durable::write_atomic(&root.join("pairing/joined.toml"), &junk, Some(0o600)).unwrap();
let (none, said) = crate::heard(|| read(&root, "pairing/joined.toml"));
assert_eq!(none, None, "taken as absent");
assert!(said.contains("ERROR") && said.contains("the fed file cannot be read — taken as absent") && said.contains("pairing/joined.toml"), "{said}");
let _ = std::fs::remove_dir_all(&root);
}
}