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) => return Load::Absent(format!("{} could not be read: {err}", path.display())),
379 };
380 let authenticity = match verify(&text, key) {
384 Ok(a) => a,
385 Err(why) => {
386 return Load::Absent(format!(
387 "{} is present but does not verify ({why}); it is treated as absent rather \
388 than trusted",
389 path.display()
390 ))
391 }
392 };
393 match LayoutConfig::parse(path.to_path_buf(), &text, authenticity) {
394 Ok(config) => Load::Found(Box::new(config)),
395 Err(why) => Load::Absent(why),
396 }
397}
398
399pub fn enrolled_root_key() -> Option<[u8; 32]> {
405 let spec = std::env::var(ROOT_KEY_ENV).ok()?;
406 let spec = spec.trim();
407 if spec.len() != 64 {
408 return None;
409 }
410 let mut out = [0u8; 32];
411 for (i, byte) in out.iter_mut().enumerate() {
412 *byte = u8::from_str_radix(spec.get(i * 2..i * 2 + 2)?, 16).ok()?;
413 }
414 Some(out)
415}
416
417pub fn verify(sealed: &str, pubkey: Option<&[u8; 32]>) -> Result<Authenticity, String> {
422 let header = sealed.lines().next().unwrap_or_default();
423 let want = format!("{HEADER} {VERSION}");
424 if header != want {
425 return Err(format!(
426 "first line is {header:?}, not {want:?} — refusing to read a shape this version \
427 does not know"
428 ));
429 }
430 let (body, auth) = sealed
431 .split_once(AUTH_MARKER)
432 .ok_or_else(|| "no auth block — it was never sealed".to_string())?;
433 let field =
434 |k: &str| -> Option<&str> { auth.lines().find_map(|l| l.strip_prefix(&format!("{k}="))) };
435 let want_hash = field("auth.blake3").ok_or_else(|| "no auth.blake3".to_string())?;
436 let got = blake3::hash(body.as_bytes()).to_hex().to_string();
437 if got != want_hash {
438 return Err(format!(
439 "body blake3 is {got}, the file claims {want_hash} — it has been altered since it \
440 was written"
441 ));
442 }
443 let sig_hex = field("auth.signature").unwrap_or_default();
444 if sig_hex.is_empty() {
446 return Ok(Authenticity::IntegrityOnly);
447 }
448 let Some(key) = pubkey else {
451 return Ok(Authenticity::IntegrityOnly);
452 };
453 let sig_bytes: [u8; 64] = decode_hex(sig_hex)
454 .and_then(|v| v.try_into().ok())
455 .ok_or_else(|| "auth.signature is not 64 hex bytes".to_string())?;
456 let vk = ed25519_dalek::VerifyingKey::from_bytes(key)
457 .map_err(|err| format!("the enrolled pubkey is malformed: {err}"))?;
458 if vk.is_weak() {
461 return Err(
462 "the enrolled pubkey is a small-order point — such a key verifies a forged \
463 signature over ANY config, so it is refused rather than trusted"
464 .to_string(),
465 );
466 }
467 use ed25519_dalek::Verifier as _;
468 vk.verify(
469 &signing_message(body),
470 &ed25519_dalek::Signature::from_bytes(&sig_bytes),
471 )
472 .map_err(|_| {
473 "the signature does not verify against the enrolled root key — the config is NOT the \
474 one the installer signed"
475 .to_string()
476 })?;
477 Ok(Authenticity::Verified)
478}
479
480pub fn signing_message(body: &str) -> Vec<u8> {
484 let mut m = Vec::with_capacity(DOMAIN.len() + 32);
485 m.extend_from_slice(DOMAIN);
486 m.extend_from_slice(blake3::hash(body.as_bytes()).as_bytes());
487 m
488}
489
490fn decode_hex(s: &str) -> Option<Vec<u8>> {
491 if s.len() % 2 != 0 {
492 return None;
493 }
494 (0..s.len() / 2)
495 .map(|i| u8::from_str_radix(s.get(i * 2..i * 2 + 2)?, 16).ok())
496 .collect()
497}
498
499#[cfg(test)]
500mod tests {
501 use super::*;
502
503 const BODY: &str = concat!(
504 "korp-layout-config 1\n",
505 "install.unix=1789000000\n",
506 "install.product=gunnar\n",
507 "install.mode=fresh\n",
508 "boot.dev=/dev/vda\n",
509 "boot.why=largest-group-remainder\n",
510 "boot.p3_dev=/dev/vda3\n",
511 "data.mountpoint=/data\n",
512 "data.shape=raid0\n",
513 "data.striped=yes\n",
514 "data.count=4\n",
515 "data.0.dev=/dev/vdb\n",
516 "data.3.dev=/dev/vde\n",
517 "serialdat.mountpoint=/serialdat\n",
518 "serialdat.class=p3\n",
519 "serialdat.dev=/dev/vda3\n",
520 "serialdat.off_stripe=yes\n",
521 "unused.count=1\n",
522 "unused.0.name=vdf\n",
523 "unused.0.why=unequal, not used\n",
524 "dir.users_dir=repos\n",
525 "dir.count=8\n",
526 "dir.repos.path=/data/repos\n",
527 "dir.repos.device=data\n",
528 "dir.repos.off_stripe_required=no\n",
529 "dir.repos.off_stripe_satisfied=yes\n",
530 "dir.meta.path=/serialdat/meta\n",
531 "dir.meta.device=serialdat\n",
532 "dir.meta.off_stripe_required=yes\n",
533 "dir.meta.off_stripe_satisfied=yes\n",
534 "dir.log.path=/data/log\n",
535 "dir.log.device=serialdat\n",
536 "dir.log.off_stripe_required=yes\n",
537 "dir.log.off_stripe_satisfied=yes\n",
538 "threshold.rootfs_measured_bytes=12163481\n",
539 );
540
541 fn seal_unsigned(body: &str) -> String {
544 format!(
545 "{body}auth.domain=korp.layout-config.v1\nauth.blake3={}\nauth.signature=\n\
546 auth.pubkey=\nauth.note=NOT SIGNED: no ed25519 root key was enrolled at build \
547 time (KORP_ROOT_SIGNING_KEY unset)\n",
548 blake3::hash(body.as_bytes()).to_hex()
549 )
550 }
551
552 fn seal_signed(body: &str, seed: &[u8; 32]) -> (String, [u8; 32]) {
553 use ed25519_dalek::Signer;
554 let sk = ed25519_dalek::SigningKey::from_bytes(seed);
555 let sig = sk.sign(&signing_message(body));
556 let hex = |b: &[u8]| b.iter().map(|x| format!("{x:02x}")).collect::<String>();
557 (
558 format!(
559 "{body}auth.domain=korp.layout-config.v1\nauth.blake3={}\nauth.signature={}\n\
560 auth.pubkey={}\nauth.note=\n",
561 blake3::hash(body.as_bytes()).to_hex(),
562 hex(&sig.to_bytes()),
563 hex(sk.verifying_key().as_bytes())
564 ),
565 sk.verifying_key().to_bytes(),
566 )
567 }
568
569 #[test]
572 fn the_config_parses_into_its_keys() {
573 let c = LayoutConfig::parse(
574 PathBuf::from(DEFAULT_PATH),
575 &seal_unsigned(BODY),
576 Authenticity::IntegrityOnly,
577 )
578 .expect("the sample parses");
579 assert_eq!(c.version(), 1);
580 assert_eq!(c.get("boot.why"), Some("largest-group-remainder"));
581 assert_eq!(c.count("data"), 4);
582 assert_eq!(c.get("data.3.dev"), Some("/dev/vde"));
583 assert_eq!(c.get("unused.0.why"), Some("unequal, not used"));
584 assert_eq!(c.u64("threshold.rootfs_measured_bytes"), Some(12_163_481));
585 }
586
587 #[test]
589 fn the_dir_parameters_are_what_the_app_is_told() {
590 let c = LayoutConfig::parse(
591 PathBuf::from(DEFAULT_PATH),
592 &seal_unsigned(BODY),
593 Authenticity::IntegrityOnly,
594 )
595 .expect("parses");
596 assert_eq!(c.dir_path("log"), Some(PathBuf::from("/data/log")));
600 assert_eq!(c.dir_path("meta"), Some(PathBuf::from("/serialdat/meta")));
602 assert_eq!(c.dir_path("repos"), Some(PathBuf::from("/data/repos")));
603 assert_eq!(c.dir_requires_off_stripe("log"), Some(true));
604 assert_eq!(c.dir_requires_off_stripe("repos"), Some(false));
605 assert_eq!(c.dir_off_stripe_satisfied("log"), Some(true));
606 assert_eq!(c.dir_path("nothing-like-this"), None);
607 }
608
609 #[test]
611 fn an_absent_key_is_none_and_never_a_zero() {
612 let c = LayoutConfig::parse(
613 PathBuf::from("/x"),
614 &seal_unsigned(BODY),
615 Authenticity::IntegrityOnly,
616 )
617 .expect("parses");
618 assert_eq!(c.get("data.bytes"), None);
619 assert_eq!(c.u64("data.bytes"), None);
620 assert_eq!(
621 c.u64("boot.why"),
622 None,
623 "a value that is not a number is not one"
624 );
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!(
653 unescape("two\\nlines and a \\\\ backslash"),
654 "two\nlines and a \\ backslash"
655 );
656 }
657
658 #[test]
664 fn an_empty_signature_is_integrity_only_and_never_verified() {
665 let a = verify(&seal_unsigned(BODY), None).expect("an unsigned config still holds");
666 assert_eq!(a, Authenticity::IntegrityOnly);
667 assert!(!a.is_verified());
668 assert_eq!(a.word(), "INTEGRITY ONLY");
669 }
670
671 #[test]
674 fn an_empty_signature_with_a_key_enrolled_is_still_integrity_only() {
675 let (_, key) = seal_signed(BODY, &[7u8; 32]);
676 assert_eq!(
677 verify(&seal_unsigned(BODY), Some(&key)),
678 Ok(Authenticity::IntegrityOnly)
679 );
680 }
681
682 #[test]
683 fn a_real_signature_against_the_enrolled_key_verifies() {
684 let (sealed, key) = seal_signed(BODY, &[9u8; 32]);
685 let a = verify(&sealed, Some(&key)).expect("it must verify");
686 assert_eq!(a, Authenticity::Verified);
687 assert_eq!(a.word(), "verified");
688 }
689
690 #[test]
693 fn a_signature_that_does_not_verify_is_an_error_not_a_downgrade() {
694 let (sealed, _) = seal_signed(BODY, &[9u8; 32]);
695 let (_, other_key) = seal_signed(BODY, &[11u8; 32]);
696 let err = verify(&sealed, Some(&other_key)).expect_err("it must not pass");
697 assert!(err.contains("does not verify"), "{err}");
698 }
699
700 #[test]
703 fn an_altered_body_is_refused_by_name() {
704 let sealed = seal_unsigned(BODY).replace("/dev/vdb", "/dev/sdz");
705 let err = verify(&sealed, None).expect_err("the digest must catch it");
706 assert!(err.contains("altered since it was written"), "{err}");
707 }
708
709 #[test]
710 fn something_that_was_never_sealed_is_refused() {
711 let err = verify(BODY, None).expect_err("no auth block");
712 assert!(err.contains("never sealed"), "{err}");
713 }
714
715 #[test]
718 fn an_absent_config_names_where_it_looked_and_is_not_an_error() {
719 let dir = tempfile::tempdir().expect("a temp dir");
720 let load = load_from(&dir.path().join("layout.conf"), None);
721 let why = load.absent_reason().expect("absent");
722 assert!(why.contains("no layout config at"), "{why}");
723 assert!(why.contains("layout.conf"), "{why}");
724 assert!(load.config().is_none());
725 }
726
727 #[test]
728 fn a_sealed_config_on_disk_loads_as_integrity_only() {
729 let dir = tempfile::tempdir().expect("a temp dir");
730 let path = dir.path().join("layout.conf");
731 std::fs::write(&path, seal_unsigned(BODY)).expect("write");
732 let load = load_from(&path, None);
733 let c = load.config().expect("found");
734 assert_eq!(c.authenticity(), Authenticity::IntegrityOnly);
735 assert!(!c.authenticity().is_verified());
736 assert!(c.note().contains("NOT SIGNED"), "{}", c.note());
737 assert_eq!(c.dir_path("meta"), Some(PathBuf::from("/serialdat/meta")));
738 }
739
740 #[test]
744 fn a_tampered_config_is_absent_rather_than_trusted() {
745 let dir = tempfile::tempdir().expect("a temp dir");
746 let path = dir.path().join("layout.conf");
747 std::fs::write(&path, seal_unsigned(BODY).replace("/dev/vdb", "/dev/sdz")).expect("write");
748 let load = load_from(&path, None);
749 assert!(
750 load.config().is_none(),
751 "not one value may be read out of it"
752 );
753 let why = load.absent_reason().expect("absent");
754 assert!(why.contains("does not verify"), "{why}");
755 assert!(why.contains("altered"), "{why}");
756 assert!(why.contains("rather than trusted"), "{why}");
757 }
758}