1use std::collections::BTreeMap;
99use std::path::{Path, PathBuf};
100
101pub const DEFAULT_PATH: &str = "/boot/korp/layout.conf";
104
105pub const CONFIG_ENV: &str = "GUNNAR_LAYOUT_CONFIG";
110
111pub const ROOT_KEY_ENV: &str = "GUNNAR_LAYOUT_ROOT_KEY";
118
119pub const HEADER: &str = "korp-layout-config";
121
122pub const VERSION: u32 = 1;
124
125pub const DOMAIN: &[u8] = b"korp.layout-config.v1\0";
129
130const AUTH_MARKER: &str = "auth.domain=";
133
134#[derive(Clone, Copy, Debug, PartialEq, Eq)]
141pub enum Authenticity {
142 Verified,
145 IntegrityOnly,
150}
151
152impl Authenticity {
153 pub fn word(self) -> &'static str {
157 match self {
158 Authenticity::Verified => "verified",
159 Authenticity::IntegrityOnly => "INTEGRITY ONLY",
160 }
161 }
162
163 pub fn is_verified(self) -> bool {
166 matches!(self, Authenticity::Verified)
167 }
168}
169
170#[derive(Clone, Debug)]
173pub struct LayoutConfig {
174 path: PathBuf,
175 version: u32,
176 authenticity: Authenticity,
177 note: String,
180 fields: BTreeMap<String, String>,
181}
182
183impl LayoutConfig {
184 pub fn path(&self) -> &Path {
185 &self.path
186 }
187
188 pub fn version(&self) -> u32 {
189 self.version
190 }
191
192 pub fn authenticity(&self) -> Authenticity {
193 self.authenticity
194 }
195
196 pub fn note(&self) -> &str {
198 &self.note
199 }
200
201 pub fn get(&self, key: &str) -> Option<&str> {
204 self.fields
205 .get(key)
206 .map(String::as_str)
207 .filter(|v| !v.is_empty())
208 }
209
210 pub fn u64(&self, key: &str) -> Option<u64> {
214 self.get(key).and_then(|v| v.parse().ok())
215 }
216
217 pub fn yes_no(&self, key: &str) -> Option<bool> {
219 match self.get(key)? {
220 "yes" => Some(true),
221 "no" => Some(false),
222 _ => None,
223 }
224 }
225
226 pub fn count(&self, prefix: &str) -> usize {
228 self.u64(&format!("{prefix}.count")).unwrap_or(0) as usize
229 }
230
231 pub fn fields(&self) -> impl Iterator<Item = (&str, &str)> {
233 self.fields.iter().map(|(k, v)| (k.as_str(), v.as_str()))
234 }
235
236 pub fn dir_path(&self, name: &str) -> Option<PathBuf> {
238 self.get(&format!("dir.{name}.path")).map(PathBuf::from)
239 }
240
241 pub fn dir_requires_off_stripe(&self, name: &str) -> Option<bool> {
243 self.yes_no(&format!("dir.{name}.off_stripe_required"))
244 }
245
246 pub fn dir_off_stripe_satisfied(&self, name: &str) -> Option<bool> {
249 self.yes_no(&format!("dir.{name}.off_stripe_satisfied"))
250 }
251
252 pub fn parse(path: PathBuf, text: &str, authenticity: Authenticity) -> Result<Self, String> {
257 let mut lines = text.lines();
258 let header = lines.next().unwrap_or_default().trim();
259 let mut tokens = header.split_whitespace();
260 if tokens.next() != Some(HEADER) {
261 return Err(format!(
262 "{} does not begin with `{HEADER}`; it is not a layout config",
263 path.display()
264 ));
265 }
266 let version: u32 = tokens
267 .next()
268 .and_then(|v| v.parse().ok())
269 .ok_or_else(|| format!("{} carries no format version", path.display()))?;
270 if version != VERSION {
271 return Err(format!(
272 "{} is format version {version}; this build reads version {VERSION} only",
273 path.display()
274 ));
275 }
276 let mut fields = BTreeMap::new();
277 for line in lines {
278 let line = line.trim_end_matches('\r');
279 if line.trim().is_empty() || line.starts_with('#') {
280 continue;
281 }
282 let Some((key, value)) = line.split_once('=') else {
283 continue;
284 };
285 fields.insert(key.to_owned(), unescape(value));
286 }
287 let note = fields.get("auth.note").cloned().unwrap_or_default();
288 Ok(LayoutConfig {
289 path,
290 version,
291 authenticity,
292 note,
293 fields,
294 })
295 }
296}
297
298fn unescape(value: &str) -> String {
301 let mut out = String::with_capacity(value.len());
302 let mut chars = value.chars();
303 while let Some(c) = chars.next() {
304 if c != '\\' {
305 out.push(c);
306 continue;
307 }
308 match chars.next() {
309 Some('n') => out.push('\n'),
310 Some('\\') => out.push('\\'),
311 Some(other) => {
312 out.push('\\');
313 out.push(other);
314 }
315 None => out.push('\\'),
316 }
317 }
318 out
319}
320
321#[derive(Clone, Debug)]
323pub enum Load {
324 Found(Box<LayoutConfig>),
325 Absent(String),
329}
330
331impl Load {
332 pub fn config(&self) -> Option<&LayoutConfig> {
333 match self {
334 Load::Found(c) => Some(c),
335 Load::Absent(_) => None,
336 }
337 }
338
339 pub fn absent_reason(&self) -> Option<&str> {
340 match self {
341 Load::Absent(why) => Some(why),
342 Load::Found(_) => None,
343 }
344 }
345}
346
347pub fn config_path() -> PathBuf {
351 std::env::var_os(CONFIG_ENV)
352 .filter(|v| !v.is_empty())
353 .map(PathBuf::from)
354 .unwrap_or_else(|| PathBuf::from(DEFAULT_PATH))
355}
356
357pub fn load() -> Load {
362 load_from(&config_path(), enrolled_root_key().as_ref())
363}
364
365pub fn load_from(path: &Path, key: Option<&[u8; 32]>) -> Load {
368 let text = match std::fs::read_to_string(path) {
369 Ok(text) => text,
370 Err(err) if err.kind() == std::io::ErrorKind::NotFound => {
371 return Load::Absent(format!(
372 "no layout config at {}. An appliance installed before the config existed has \
373 none, and nothing here infers the installer's decisions from the current \
374 machine",
375 path.display()
376 ))
377 }
378 Err(err) => {
379 return Load::Absent(format!("{} could not be read: {err}", path.display()))
380 }
381 };
382 let authenticity = match verify(&text, key) {
386 Ok(a) => a,
387 Err(why) => {
388 return Load::Absent(format!(
389 "{} is present but does not verify ({why}); it is treated as absent rather \
390 than trusted",
391 path.display()
392 ))
393 }
394 };
395 match LayoutConfig::parse(path.to_path_buf(), &text, authenticity) {
396 Ok(config) => Load::Found(Box::new(config)),
397 Err(why) => Load::Absent(why),
398 }
399}
400
401pub fn enrolled_root_key() -> Option<[u8; 32]> {
407 let spec = std::env::var(ROOT_KEY_ENV).ok()?;
408 let spec = spec.trim();
409 if spec.len() != 64 {
410 return None;
411 }
412 let mut out = [0u8; 32];
413 for (i, byte) in out.iter_mut().enumerate() {
414 *byte = u8::from_str_radix(spec.get(i * 2..i * 2 + 2)?, 16).ok()?;
415 }
416 Some(out)
417}
418
419pub fn verify(sealed: &str, pubkey: Option<&[u8; 32]>) -> Result<Authenticity, String> {
424 let header = sealed.lines().next().unwrap_or_default();
425 let want = format!("{HEADER} {VERSION}");
426 if header != want {
427 return Err(format!(
428 "first line is {header:?}, not {want:?} — refusing to read a shape this version \
429 does not know"
430 ));
431 }
432 let (body, auth) = sealed
433 .split_once(AUTH_MARKER)
434 .ok_or_else(|| "no auth block — it was never sealed".to_string())?;
435 let field = |k: &str| -> Option<&str> {
436 auth.lines()
437 .find_map(|l| l.strip_prefix(&format!("{k}=")))
438 };
439 let want_hash = field("auth.blake3").ok_or_else(|| "no auth.blake3".to_string())?;
440 let got = blake3::hash(body.as_bytes()).to_hex().to_string();
441 if got != want_hash {
442 return Err(format!(
443 "body blake3 is {got}, the file claims {want_hash} — it has been altered since it \
444 was written"
445 ));
446 }
447 let sig_hex = field("auth.signature").unwrap_or_default();
448 if sig_hex.is_empty() {
450 return Ok(Authenticity::IntegrityOnly);
451 }
452 let Some(key) = pubkey else {
455 return Ok(Authenticity::IntegrityOnly);
456 };
457 let sig_bytes: [u8; 64] = decode_hex(sig_hex)
458 .and_then(|v| v.try_into().ok())
459 .ok_or_else(|| "auth.signature is not 64 hex bytes".to_string())?;
460 let vk = ed25519_dalek::VerifyingKey::from_bytes(key)
461 .map_err(|err| format!("the enrolled pubkey is malformed: {err}"))?;
462 if vk.is_weak() {
465 return Err(
466 "the enrolled pubkey is a small-order point — such a key verifies a forged \
467 signature over ANY config, so it is refused rather than trusted"
468 .to_string(),
469 );
470 }
471 use ed25519_dalek::Verifier as _;
472 vk.verify(
473 &signing_message(body),
474 &ed25519_dalek::Signature::from_bytes(&sig_bytes),
475 )
476 .map_err(|_| {
477 "the signature does not verify against the enrolled root key — the config is NOT the \
478 one the installer signed"
479 .to_string()
480 })?;
481 Ok(Authenticity::Verified)
482}
483
484pub fn signing_message(body: &str) -> Vec<u8> {
488 let mut m = Vec::with_capacity(DOMAIN.len() + 32);
489 m.extend_from_slice(DOMAIN);
490 m.extend_from_slice(blake3::hash(body.as_bytes()).as_bytes());
491 m
492}
493
494fn decode_hex(s: &str) -> Option<Vec<u8>> {
495 if s.len() % 2 != 0 {
496 return None;
497 }
498 (0..s.len() / 2)
499 .map(|i| u8::from_str_radix(s.get(i * 2..i * 2 + 2)?, 16).ok())
500 .collect()
501}
502
503#[cfg(test)]
504mod tests {
505 use super::*;
506
507 const BODY: &str = concat!(
508 "korp-layout-config 1\n",
509 "install.unix=1789000000\n",
510 "install.product=gunnar\n",
511 "install.mode=fresh\n",
512 "boot.dev=/dev/vda\n",
513 "boot.why=largest-group-remainder\n",
514 "boot.p3_dev=/dev/vda3\n",
515 "data.mountpoint=/data\n",
516 "data.shape=raid0\n",
517 "data.striped=yes\n",
518 "data.count=4\n",
519 "data.0.dev=/dev/vdb\n",
520 "data.3.dev=/dev/vde\n",
521 "serialdat.mountpoint=/serialdat\n",
522 "serialdat.class=p3\n",
523 "serialdat.dev=/dev/vda3\n",
524 "serialdat.off_stripe=yes\n",
525 "unused.count=1\n",
526 "unused.0.name=vdf\n",
527 "unused.0.why=unequal, not used\n",
528 "dir.users_dir=repos\n",
529 "dir.count=8\n",
530 "dir.repos.path=/data/repos\n",
531 "dir.repos.device=data\n",
532 "dir.repos.off_stripe_required=no\n",
533 "dir.repos.off_stripe_satisfied=yes\n",
534 "dir.meta.path=/serialdat/meta\n",
535 "dir.meta.device=serialdat\n",
536 "dir.meta.off_stripe_required=yes\n",
537 "dir.meta.off_stripe_satisfied=yes\n",
538 "dir.log.path=/data/log\n",
539 "dir.log.device=serialdat\n",
540 "dir.log.off_stripe_required=yes\n",
541 "dir.log.off_stripe_satisfied=yes\n",
542 "threshold.rootfs_measured_bytes=12163481\n",
543 );
544
545 fn seal_unsigned(body: &str) -> String {
548 format!(
549 "{body}auth.domain=korp.layout-config.v1\nauth.blake3={}\nauth.signature=\n\
550 auth.pubkey=\nauth.note=NOT SIGNED: no ed25519 root key was enrolled at build \
551 time (KORP_ROOT_SIGNING_KEY unset)\n",
552 blake3::hash(body.as_bytes()).to_hex()
553 )
554 }
555
556 fn seal_signed(body: &str, seed: &[u8; 32]) -> (String, [u8; 32]) {
557 use ed25519_dalek::Signer;
558 let sk = ed25519_dalek::SigningKey::from_bytes(seed);
559 let sig = sk.sign(&signing_message(body));
560 let hex = |b: &[u8]| b.iter().map(|x| format!("{x:02x}")).collect::<String>();
561 (
562 format!(
563 "{body}auth.domain=korp.layout-config.v1\nauth.blake3={}\nauth.signature={}\n\
564 auth.pubkey={}\nauth.note=\n",
565 blake3::hash(body.as_bytes()).to_hex(),
566 hex(&sig.to_bytes()),
567 hex(sk.verifying_key().as_bytes())
568 ),
569 sk.verifying_key().to_bytes(),
570 )
571 }
572
573 #[test]
576 fn the_config_parses_into_its_keys() {
577 let c = LayoutConfig::parse(
578 PathBuf::from(DEFAULT_PATH),
579 &seal_unsigned(BODY),
580 Authenticity::IntegrityOnly,
581 )
582 .expect("the sample parses");
583 assert_eq!(c.version(), 1);
584 assert_eq!(c.get("boot.why"), Some("largest-group-remainder"));
585 assert_eq!(c.count("data"), 4);
586 assert_eq!(c.get("data.3.dev"), Some("/dev/vde"));
587 assert_eq!(c.get("unused.0.why"), Some("unequal, not used"));
588 assert_eq!(c.u64("threshold.rootfs_measured_bytes"), Some(12_163_481));
589 }
590
591 #[test]
593 fn the_dir_parameters_are_what_the_app_is_told() {
594 let c = LayoutConfig::parse(
595 PathBuf::from(DEFAULT_PATH),
596 &seal_unsigned(BODY),
597 Authenticity::IntegrityOnly,
598 )
599 .expect("parses");
600 assert_eq!(c.dir_path("log"), Some(PathBuf::from("/data/log")));
604 assert_eq!(c.dir_path("meta"), Some(PathBuf::from("/serialdat/meta")));
606 assert_eq!(c.dir_path("repos"), Some(PathBuf::from("/data/repos")));
607 assert_eq!(c.dir_requires_off_stripe("log"), Some(true));
608 assert_eq!(c.dir_requires_off_stripe("repos"), Some(false));
609 assert_eq!(c.dir_off_stripe_satisfied("log"), Some(true));
610 assert_eq!(c.dir_path("nothing-like-this"), None);
611 }
612
613 #[test]
615 fn an_absent_key_is_none_and_never_a_zero() {
616 let c = LayoutConfig::parse(
617 PathBuf::from("/x"),
618 &seal_unsigned(BODY),
619 Authenticity::IntegrityOnly,
620 )
621 .expect("parses");
622 assert_eq!(c.get("data.bytes"), None);
623 assert_eq!(c.u64("data.bytes"), None);
624 assert_eq!(c.u64("boot.why"), None, "a value that is not a number is not one");
625 assert_eq!(c.yes_no("boot.why"), None);
626 assert_eq!(c.count("nothing"), 0);
627 }
628
629 #[test]
631 fn version_1_may_gain_keys_but_never_change_version_silently() {
632 let grown = format!("{BODY}future.key=from a later installer\n");
633 let c = LayoutConfig::parse(
634 PathBuf::from("/x"),
635 &seal_unsigned(&grown),
636 Authenticity::IntegrityOnly,
637 )
638 .expect("parses");
639 assert_eq!(c.get("future.key"), Some("from a later installer"));
640
641 let err = LayoutConfig::parse(
642 PathBuf::from("/x"),
643 "korp-layout-config 2\nboot.dev=/dev/vda\n",
644 Authenticity::IntegrityOnly,
645 )
646 .expect_err("version 2 must be refused");
647 assert!(err.contains("version 2"), "{err}");
648 }
649
650 #[test]
651 fn escaped_values_come_back_whole() {
652 assert_eq!(unescape("two\\nlines and a \\\\ backslash"), "two\nlines and a \\ backslash");
653 }
654
655 #[test]
661 fn an_empty_signature_is_integrity_only_and_never_verified() {
662 let a = verify(&seal_unsigned(BODY), None).expect("an unsigned config still holds");
663 assert_eq!(a, Authenticity::IntegrityOnly);
664 assert!(!a.is_verified());
665 assert_eq!(a.word(), "INTEGRITY ONLY");
666 }
667
668 #[test]
671 fn an_empty_signature_with_a_key_enrolled_is_still_integrity_only() {
672 let (_, key) = seal_signed(BODY, &[7u8; 32]);
673 assert_eq!(
674 verify(&seal_unsigned(BODY), Some(&key)),
675 Ok(Authenticity::IntegrityOnly)
676 );
677 }
678
679 #[test]
680 fn a_real_signature_against_the_enrolled_key_verifies() {
681 let (sealed, key) = seal_signed(BODY, &[9u8; 32]);
682 let a = verify(&sealed, Some(&key)).expect("it must verify");
683 assert_eq!(a, Authenticity::Verified);
684 assert_eq!(a.word(), "verified");
685 }
686
687 #[test]
690 fn a_signature_that_does_not_verify_is_an_error_not_a_downgrade() {
691 let (sealed, _) = seal_signed(BODY, &[9u8; 32]);
692 let (_, other_key) = seal_signed(BODY, &[11u8; 32]);
693 let err = verify(&sealed, Some(&other_key)).expect_err("it must not pass");
694 assert!(err.contains("does not verify"), "{err}");
695 }
696
697 #[test]
700 fn an_altered_body_is_refused_by_name() {
701 let sealed = seal_unsigned(BODY).replace("/dev/vdb", "/dev/sdz");
702 let err = verify(&sealed, None).expect_err("the digest must catch it");
703 assert!(err.contains("altered since it was written"), "{err}");
704 }
705
706 #[test]
707 fn something_that_was_never_sealed_is_refused() {
708 let err = verify(BODY, None).expect_err("no auth block");
709 assert!(err.contains("never sealed"), "{err}");
710 }
711
712 #[test]
715 fn an_absent_config_names_where_it_looked_and_is_not_an_error() {
716 let dir = tempfile::tempdir().expect("a temp dir");
717 let load = load_from(&dir.path().join("layout.conf"), None);
718 let why = load.absent_reason().expect("absent");
719 assert!(why.contains("no layout config at"), "{why}");
720 assert!(why.contains("layout.conf"), "{why}");
721 assert!(load.config().is_none());
722 }
723
724 #[test]
725 fn a_sealed_config_on_disk_loads_as_integrity_only() {
726 let dir = tempfile::tempdir().expect("a temp dir");
727 let path = dir.path().join("layout.conf");
728 std::fs::write(&path, seal_unsigned(BODY)).expect("write");
729 let load = load_from(&path, None);
730 let c = load.config().expect("found");
731 assert_eq!(c.authenticity(), Authenticity::IntegrityOnly);
732 assert!(!c.authenticity().is_verified());
733 assert!(c.note().contains("NOT SIGNED"), "{}", c.note());
734 assert_eq!(c.dir_path("meta"), Some(PathBuf::from("/serialdat/meta")));
735 }
736
737 #[test]
741 fn a_tampered_config_is_absent_rather_than_trusted() {
742 let dir = tempfile::tempdir().expect("a temp dir");
743 let path = dir.path().join("layout.conf");
744 std::fs::write(&path, seal_unsigned(BODY).replace("/dev/vdb", "/dev/sdz"))
745 .expect("write");
746 let load = load_from(&path, None);
747 assert!(load.config().is_none(), "not one value may be read out of it");
748 let why = load.absent_reason().expect("absent");
749 assert!(why.contains("does not verify"), "{why}");
750 assert!(why.contains("altered"), "{why}");
751 assert!(why.contains("rather than trusted"), "{why}");
752 }
753}