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 _;
pub const PAIRING_DIR_REL: &str = "pairing";
pub const KEK_FILE: &str = "secrets.kek";
pub const SEAL_MAGIC: &[u8; 4] = b"gsk1";
pub const KEK_ENVS: [&str; 2] = ["NORDISK_SECRET_KEK", "GUNNAR_SECRET_KEK"];
const KEK_LEN: usize = 32;
const TAG_LEN: usize = 16;
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())
}
}
fn kek_from_env() -> Result<Option<Kek>, String> {
kek_from_lookup(&kek_envs(), &|n| std::env::var(n).ok())
}
static PRODUCT_KEK_ENV: std::sync::OnceLock<&'static str> = std::sync::OnceLock::new();
pub fn name_kek_env(name: &'static str) -> bool {
PRODUCT_KEK_ENV.get_or_init(|| name) == &name
}
fn kek_envs() -> Vec<&'static str> {
PRODUCT_KEK_ENV.get().map(|n| vec![*n]).unwrap_or_else(|| KEK_ENVS.to_vec())
}
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()))
}
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),
Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => kek_from_file(&path),
Err(e) => Err(format!("{}: {e}", path.display())),
}
}
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)
}
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()))
}
pub fn read(root: &Path, rel: &str) -> Option<String> {
match read_checked(root, rel) {
Ok(t) => t,
Err(e) => {
tracing::error!(file = rel, error = %e, "the fed file cannot be read — taken as absent");
None
}
}
}
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) {
return String::from_utf8(raw).map(Some).map_err(|e| format!("{}: {e}", path.display()));
}
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
}
#[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");
}
#[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);
}
#[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);
}
#[test]
fn a_sealed_feed_is_bound_to_its_file_and_its_key() {
let d = dir("bound");
write(&d, REL, &text()).unwrap();
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");
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));
}
#[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);
}
}