use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
pub const DEFAULT_PATH: &str = "/boot/korp/layout.conf";
pub const CONFIG_ENV: &str = "GUNNAR_LAYOUT_CONFIG";
pub const ROOT_KEY_ENV: &str = "GUNNAR_LAYOUT_ROOT_KEY";
pub const HEADER: &str = "korp-layout-config";
pub const VERSION: u32 = 1;
pub const DOMAIN: &[u8] = b"korp.layout-config.v1\0";
const AUTH_MARKER: &str = "auth.domain=";
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Authenticity {
Verified,
IntegrityOnly,
}
impl Authenticity {
pub fn word(self) -> &'static str {
match self {
Authenticity::Verified => "verified",
Authenticity::IntegrityOnly => "INTEGRITY ONLY",
}
}
pub fn is_verified(self) -> bool {
matches!(self, Authenticity::Verified)
}
}
#[derive(Clone, Debug)]
pub struct LayoutConfig {
path: PathBuf,
version: u32,
authenticity: Authenticity,
note: String,
fields: BTreeMap<String, String>,
}
impl LayoutConfig {
pub fn path(&self) -> &Path {
&self.path
}
pub fn version(&self) -> u32 {
self.version
}
pub fn authenticity(&self) -> Authenticity {
self.authenticity
}
pub fn note(&self) -> &str {
&self.note
}
pub fn get(&self, key: &str) -> Option<&str> {
self.fields
.get(key)
.map(String::as_str)
.filter(|v| !v.is_empty())
}
pub fn u64(&self, key: &str) -> Option<u64> {
self.get(key).and_then(|v| v.parse().ok())
}
pub fn yes_no(&self, key: &str) -> Option<bool> {
match self.get(key)? {
"yes" => Some(true),
"no" => Some(false),
_ => None,
}
}
pub fn count(&self, prefix: &str) -> usize {
self.u64(&format!("{prefix}.count")).unwrap_or(0) as usize
}
pub fn fields(&self) -> impl Iterator<Item = (&str, &str)> {
self.fields.iter().map(|(k, v)| (k.as_str(), v.as_str()))
}
pub fn dir_path(&self, name: &str) -> Option<PathBuf> {
self.get(&format!("dir.{name}.path")).map(PathBuf::from)
}
pub fn dir_requires_off_stripe(&self, name: &str) -> Option<bool> {
self.yes_no(&format!("dir.{name}.off_stripe_required"))
}
pub fn dir_off_stripe_satisfied(&self, name: &str) -> Option<bool> {
self.yes_no(&format!("dir.{name}.off_stripe_satisfied"))
}
pub fn parse(path: PathBuf, text: &str, authenticity: Authenticity) -> Result<Self, String> {
let mut lines = text.lines();
let header = lines.next().unwrap_or_default().trim();
let mut tokens = header.split_whitespace();
if tokens.next() != Some(HEADER) {
return Err(format!(
"{} does not begin with `{HEADER}`; it is not a layout config",
path.display()
));
}
let version: u32 = tokens
.next()
.and_then(|v| v.parse().ok())
.ok_or_else(|| format!("{} carries no format version", path.display()))?;
if version != VERSION {
return Err(format!(
"{} is format version {version}; this build reads version {VERSION} only",
path.display()
));
}
let mut fields = BTreeMap::new();
for line in lines {
let line = line.trim_end_matches('\r');
if line.trim().is_empty() || line.starts_with('#') {
continue;
}
let Some((key, value)) = line.split_once('=') else {
continue;
};
fields.insert(key.to_owned(), unescape(value));
}
let note = fields.get("auth.note").cloned().unwrap_or_default();
Ok(LayoutConfig {
path,
version,
authenticity,
note,
fields,
})
}
}
fn unescape(value: &str) -> String {
let mut out = String::with_capacity(value.len());
let mut chars = value.chars();
while let Some(c) = chars.next() {
if c != '\\' {
out.push(c);
continue;
}
match chars.next() {
Some('n') => out.push('\n'),
Some('\\') => out.push('\\'),
Some(other) => {
out.push('\\');
out.push(other);
}
None => out.push('\\'),
}
}
out
}
#[derive(Clone, Debug)]
pub enum Load {
Found(Box<LayoutConfig>),
Absent(String),
}
impl Load {
pub fn config(&self) -> Option<&LayoutConfig> {
match self {
Load::Found(c) => Some(c),
Load::Absent(_) => None,
}
}
pub fn absent_reason(&self) -> Option<&str> {
match self {
Load::Absent(why) => Some(why),
Load::Found(_) => None,
}
}
}
pub fn config_path() -> PathBuf {
std::env::var_os(CONFIG_ENV)
.filter(|v| !v.is_empty())
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from(DEFAULT_PATH))
}
pub fn load() -> Load {
load_from(&config_path(), enrolled_root_key().as_ref())
}
pub fn load_from(path: &Path, key: Option<&[u8; 32]>) -> Load {
let text = match std::fs::read_to_string(path) {
Ok(text) => text,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
return Load::Absent(format!(
"no layout config at {}. An appliance installed before the config existed has \
none, and nothing here infers the installer's decisions from the current \
machine",
path.display()
))
}
Err(err) => return Load::Absent(format!("{} could not be read: {err}", path.display())),
};
let authenticity = match verify(&text, key) {
Ok(a) => a,
Err(why) => {
return Load::Absent(format!(
"{} is present but does not verify ({why}); it is treated as absent rather \
than trusted",
path.display()
))
}
};
match LayoutConfig::parse(path.to_path_buf(), &text, authenticity) {
Ok(config) => Load::Found(Box::new(config)),
Err(why) => Load::Absent(why),
}
}
pub fn enrolled_root_key() -> Option<[u8; 32]> {
let spec = std::env::var(ROOT_KEY_ENV).ok()?;
let spec = spec.trim();
if spec.len() != 64 {
return None;
}
let mut out = [0u8; 32];
for (i, byte) in out.iter_mut().enumerate() {
*byte = u8::from_str_radix(spec.get(i * 2..i * 2 + 2)?, 16).ok()?;
}
Some(out)
}
pub fn verify(sealed: &str, pubkey: Option<&[u8; 32]>) -> Result<Authenticity, String> {
let header = sealed.lines().next().unwrap_or_default();
let want = format!("{HEADER} {VERSION}");
if header != want {
return Err(format!(
"first line is {header:?}, not {want:?} — refusing to read a shape this version \
does not know"
));
}
let (body, auth) = sealed
.split_once(AUTH_MARKER)
.ok_or_else(|| "no auth block — it was never sealed".to_string())?;
let field =
|k: &str| -> Option<&str> { auth.lines().find_map(|l| l.strip_prefix(&format!("{k}="))) };
let want_hash = field("auth.blake3").ok_or_else(|| "no auth.blake3".to_string())?;
let got = blake3::hash(body.as_bytes()).to_hex().to_string();
if got != want_hash {
return Err(format!(
"body blake3 is {got}, the file claims {want_hash} — it has been altered since it \
was written"
));
}
let sig_hex = field("auth.signature").unwrap_or_default();
if sig_hex.is_empty() {
return Ok(Authenticity::IntegrityOnly);
}
let Some(key) = pubkey else {
return Ok(Authenticity::IntegrityOnly);
};
let sig_bytes: [u8; 64] = decode_hex(sig_hex)
.and_then(|v| v.try_into().ok())
.ok_or_else(|| "auth.signature is not 64 hex bytes".to_string())?;
let vk = ed25519_dalek::VerifyingKey::from_bytes(key)
.map_err(|err| format!("the enrolled pubkey is malformed: {err}"))?;
if vk.is_weak() {
return Err(
"the enrolled pubkey is a small-order point — such a key verifies a forged \
signature over ANY config, so it is refused rather than trusted"
.to_string(),
);
}
use ed25519_dalek::Verifier as _;
vk.verify(
&signing_message(body),
&ed25519_dalek::Signature::from_bytes(&sig_bytes),
)
.map_err(|_| {
"the signature does not verify against the enrolled root key — the config is NOT the \
one the installer signed"
.to_string()
})?;
Ok(Authenticity::Verified)
}
pub fn signing_message(body: &str) -> Vec<u8> {
let mut m = Vec::with_capacity(DOMAIN.len() + 32);
m.extend_from_slice(DOMAIN);
m.extend_from_slice(blake3::hash(body.as_bytes()).as_bytes());
m
}
fn decode_hex(s: &str) -> Option<Vec<u8>> {
if s.len() % 2 != 0 {
return None;
}
(0..s.len() / 2)
.map(|i| u8::from_str_radix(s.get(i * 2..i * 2 + 2)?, 16).ok())
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
const BODY: &str = concat!(
"korp-layout-config 1\n",
"install.unix=1789000000\n",
"install.product=gunnar\n",
"install.mode=fresh\n",
"boot.dev=/dev/vda\n",
"boot.why=largest-group-remainder\n",
"boot.p3_dev=/dev/vda3\n",
"data.mountpoint=/data\n",
"data.shape=raid0\n",
"data.striped=yes\n",
"data.count=4\n",
"data.0.dev=/dev/vdb\n",
"data.3.dev=/dev/vde\n",
"serialdat.mountpoint=/serialdat\n",
"serialdat.class=p3\n",
"serialdat.dev=/dev/vda3\n",
"serialdat.off_stripe=yes\n",
"unused.count=1\n",
"unused.0.name=vdf\n",
"unused.0.why=unequal, not used\n",
"dir.users_dir=repos\n",
"dir.count=8\n",
"dir.repos.path=/data/repos\n",
"dir.repos.device=data\n",
"dir.repos.off_stripe_required=no\n",
"dir.repos.off_stripe_satisfied=yes\n",
"dir.meta.path=/serialdat/meta\n",
"dir.meta.device=serialdat\n",
"dir.meta.off_stripe_required=yes\n",
"dir.meta.off_stripe_satisfied=yes\n",
"dir.log.path=/data/log\n",
"dir.log.device=serialdat\n",
"dir.log.off_stripe_required=yes\n",
"dir.log.off_stripe_satisfied=yes\n",
"threshold.rootfs_measured_bytes=12163481\n",
);
fn seal_unsigned(body: &str) -> String {
format!(
"{body}auth.domain=korp.layout-config.v1\nauth.blake3={}\nauth.signature=\n\
auth.pubkey=\nauth.note=NOT SIGNED: no ed25519 root key was enrolled at build \
time (KORP_ROOT_SIGNING_KEY unset)\n",
blake3::hash(body.as_bytes()).to_hex()
)
}
fn seal_signed(body: &str, seed: &[u8; 32]) -> (String, [u8; 32]) {
use ed25519_dalek::Signer;
let sk = ed25519_dalek::SigningKey::from_bytes(seed);
let sig = sk.sign(&signing_message(body));
let hex = |b: &[u8]| b.iter().map(|x| format!("{x:02x}")).collect::<String>();
(
format!(
"{body}auth.domain=korp.layout-config.v1\nauth.blake3={}\nauth.signature={}\n\
auth.pubkey={}\nauth.note=\n",
blake3::hash(body.as_bytes()).to_hex(),
hex(&sig.to_bytes()),
hex(sk.verifying_key().as_bytes())
),
sk.verifying_key().to_bytes(),
)
}
#[test]
fn the_config_parses_into_its_keys() {
let c = LayoutConfig::parse(
PathBuf::from(DEFAULT_PATH),
&seal_unsigned(BODY),
Authenticity::IntegrityOnly,
)
.expect("the sample parses");
assert_eq!(c.version(), 1);
assert_eq!(c.get("boot.why"), Some("largest-group-remainder"));
assert_eq!(c.count("data"), 4);
assert_eq!(c.get("data.3.dev"), Some("/dev/vde"));
assert_eq!(c.get("unused.0.why"), Some("unequal, not used"));
assert_eq!(c.u64("threshold.rootfs_measured_bytes"), Some(12_163_481));
}
#[test]
fn the_dir_parameters_are_what_the_app_is_told() {
let c = LayoutConfig::parse(
PathBuf::from(DEFAULT_PATH),
&seal_unsigned(BODY),
Authenticity::IntegrityOnly,
)
.expect("parses");
assert_eq!(c.dir_path("log"), Some(PathBuf::from("/data/log")));
assert_eq!(c.dir_path("meta"), Some(PathBuf::from("/serialdat/meta")));
assert_eq!(c.dir_path("repos"), Some(PathBuf::from("/data/repos")));
assert_eq!(c.dir_requires_off_stripe("log"), Some(true));
assert_eq!(c.dir_requires_off_stripe("repos"), Some(false));
assert_eq!(c.dir_off_stripe_satisfied("log"), Some(true));
assert_eq!(c.dir_path("nothing-like-this"), None);
}
#[test]
fn an_absent_key_is_none_and_never_a_zero() {
let c = LayoutConfig::parse(
PathBuf::from("/x"),
&seal_unsigned(BODY),
Authenticity::IntegrityOnly,
)
.expect("parses");
assert_eq!(c.get("data.bytes"), None);
assert_eq!(c.u64("data.bytes"), None);
assert_eq!(
c.u64("boot.why"),
None,
"a value that is not a number is not one"
);
assert_eq!(c.yes_no("boot.why"), None);
assert_eq!(c.count("nothing"), 0);
}
#[test]
fn version_1_may_gain_keys_but_never_change_version_silently() {
let grown = format!("{BODY}future.key=from a later installer\n");
let c = LayoutConfig::parse(
PathBuf::from("/x"),
&seal_unsigned(&grown),
Authenticity::IntegrityOnly,
)
.expect("parses");
assert_eq!(c.get("future.key"), Some("from a later installer"));
let err = LayoutConfig::parse(
PathBuf::from("/x"),
"korp-layout-config 2\nboot.dev=/dev/vda\n",
Authenticity::IntegrityOnly,
)
.expect_err("version 2 must be refused");
assert!(err.contains("version 2"), "{err}");
}
#[test]
fn escaped_values_come_back_whole() {
assert_eq!(
unescape("two\\nlines and a \\\\ backslash"),
"two\nlines and a \\ backslash"
);
}
#[test]
fn an_empty_signature_is_integrity_only_and_never_verified() {
let a = verify(&seal_unsigned(BODY), None).expect("an unsigned config still holds");
assert_eq!(a, Authenticity::IntegrityOnly);
assert!(!a.is_verified());
assert_eq!(a.word(), "INTEGRITY ONLY");
}
#[test]
fn an_empty_signature_with_a_key_enrolled_is_still_integrity_only() {
let (_, key) = seal_signed(BODY, &[7u8; 32]);
assert_eq!(
verify(&seal_unsigned(BODY), Some(&key)),
Ok(Authenticity::IntegrityOnly)
);
}
#[test]
fn a_real_signature_against_the_enrolled_key_verifies() {
let (sealed, key) = seal_signed(BODY, &[9u8; 32]);
let a = verify(&sealed, Some(&key)).expect("it must verify");
assert_eq!(a, Authenticity::Verified);
assert_eq!(a.word(), "verified");
}
#[test]
fn a_signature_that_does_not_verify_is_an_error_not_a_downgrade() {
let (sealed, _) = seal_signed(BODY, &[9u8; 32]);
let (_, other_key) = seal_signed(BODY, &[11u8; 32]);
let err = verify(&sealed, Some(&other_key)).expect_err("it must not pass");
assert!(err.contains("does not verify"), "{err}");
}
#[test]
fn an_altered_body_is_refused_by_name() {
let sealed = seal_unsigned(BODY).replace("/dev/vdb", "/dev/sdz");
let err = verify(&sealed, None).expect_err("the digest must catch it");
assert!(err.contains("altered since it was written"), "{err}");
}
#[test]
fn something_that_was_never_sealed_is_refused() {
let err = verify(BODY, None).expect_err("no auth block");
assert!(err.contains("never sealed"), "{err}");
}
#[test]
fn an_absent_config_names_where_it_looked_and_is_not_an_error() {
let dir = tempfile::tempdir().expect("a temp dir");
let load = load_from(&dir.path().join("layout.conf"), None);
let why = load.absent_reason().expect("absent");
assert!(why.contains("no layout config at"), "{why}");
assert!(why.contains("layout.conf"), "{why}");
assert!(load.config().is_none());
}
#[test]
fn a_sealed_config_on_disk_loads_as_integrity_only() {
let dir = tempfile::tempdir().expect("a temp dir");
let path = dir.path().join("layout.conf");
std::fs::write(&path, seal_unsigned(BODY)).expect("write");
let load = load_from(&path, None);
let c = load.config().expect("found");
assert_eq!(c.authenticity(), Authenticity::IntegrityOnly);
assert!(!c.authenticity().is_verified());
assert!(c.note().contains("NOT SIGNED"), "{}", c.note());
assert_eq!(c.dir_path("meta"), Some(PathBuf::from("/serialdat/meta")));
}
#[test]
fn a_tampered_config_is_absent_rather_than_trusted() {
let dir = tempfile::tempdir().expect("a temp dir");
let path = dir.path().join("layout.conf");
std::fs::write(&path, seal_unsigned(BODY).replace("/dev/vdb", "/dev/sdz")).expect("write");
let load = load_from(&path, None);
assert!(
load.config().is_none(),
"not one value may be read out of it"
);
let why = load.absent_reason().expect("absent");
assert!(why.contains("does not verify"), "{why}");
assert!(why.contains("altered"), "{why}");
assert!(why.contains("rather than trusted"), "{why}");
}
}