diff --git a/crates/thumos/build.rs b/crates/thumos/build.rs index 3943e5fb..cb7b1389 100644 --- a/crates/thumos/build.rs +++ b/crates/thumos/build.rs @@ -70,6 +70,9 @@ const RFC8032_TEST_PUBLIC_KEYS: [[u8; KEY_LEN]; 5] = [ /// Env var naming the provisioned public-key file (64 hex chars). const KEY_ENV: &str = "THUMOS_BOOT_KEY_PUB"; +/// Env var naming the provisioning-authority public-key file (64 hex chars). +const PROVISION_KEY_ENV: &str = "THUMOS_PROVISION_KEY_PUB"; + fn main() { println!("cargo:rerun-if-env-changed={KEY_ENV}"); @@ -102,14 +105,7 @@ fn main() { Err(_) => (dev_key, dev_pub_path.clone()), }; - if RFC8032_TEST_PUBLIC_KEYS.contains(&key) { - die(&format!( - "#233: {} is an RFC 8032 section 7.1 test-vector public key -- its private \ - half is published in the RFC, so this anchor is forgeable by anyone. \ - Refused in every configuration.", - key_path.display() - )); - } + reject_unusable_anchor(&key, &key_path, "#233"); if production && key == dev_key { die(&format!( "#233: {} is the committed dev key -- its seed is public by design and it \ @@ -117,14 +113,6 @@ fn main() { key_path.display() )); } - if VerifyingKey::from_bytes(&key).is_err() { - die(&format!( - "#233: {} is not a decompressable Ed25519 point -- a corrupted anchor \ - would make every image unverifiable.", - key_path.display() - )); - } - // WHY: the dev seed is emitted (test-only) so host tests can sign // round-trips against the real embedded anchor; its derivation is // re-checked here so a corrupted committed keypair fails the build, not @@ -148,18 +136,23 @@ fn main() { die(&format!("#233: cannot write boot_key.rs: {e}")); } + emit_provision_key(&out_dir, production, &key); + generate_initramfs(&manifest_dir, &out_dir); } /// Compile the userspace /init (#474) to a static armv7a ELF linked at -/// 0x40100000 (`kconfig::KERNEL_END`) and wrap it in a newc CPIO the kernel -/// embeds and mounts as the image-resident boot root ramfs. +/// `board::USER_TEXT_BASE` (`0x7FF0_0000`, see init/init.ld) and wrap it in a +/// newc CPIO the kernel embeds and mounts as the image-resident boot root +/// ramfs. /// /// WHY rustc-direct (not a sub-crate): /init is one `no_std` `no_main` file, /// so a raw rustc invocation for armv7a-none-eabi produces the `ET_EXEC` ELF /// `elf::load` parses without a nested cargo build or workspace membership. -/// -Ttext places all `PT_LOAD` >= `KERNEL_END` so the identity-mapping loader -/// writes them into the sanctioned user-DRAM window [`KERNEL_END`, `RAM_END`). +/// init.ld places all `PT_LOAD` at `USER_TEXT_BASE` so the identity-mapping +/// loader writes them into the sanctioned user-DRAM window +/// [`KERNEL_END`, `RAM_END`) -- the top 1 MB of it, which the kernel maps +/// executable and excludes from the page allocator. /// Compile one userspace program (init.rs / init2.rs) to a static armv7a ELF /// linked by init.ld (#474/#489). `variant_cfg` optionally adds a /// `thumos_init_` cfg. @@ -563,3 +556,113 @@ fn emit_kernel_window(manifest_dir: &Path, out_dir: &Path) { // link.ld reaches the fragment through the linker's search path. println!("cargo:rustc-link-arg=-L{}", out_dir.display()); } + +/// Emit the provisioning trust anchor as a generated source file (#869). +/// +/// The same treatment #233 gives the boot anchor, for the second anchor in the +/// image. It had been RFC 8032 test vector 2 -- whose private half is published +/// in the RFC -- while the module doc described operator offline custody. A +/// comment cannot turn a public vector into an authentic anchor, and +/// `try_finalize` verified against it as the sole authenticity gate. +/// +/// WHY a non-production build gets NO anchor rather than a committed dev one: +/// the provisioning path is not needed to boot, so there is nothing to keep +/// working. `None` makes the refusal structural -- there is no key to verify +/// against, so no surface can present a bundle as operator-authenticated, and +/// that holds without anyone remembering to check a flag. Tests inject their +/// own key through `Provisioner::new_with_key`, which is why removing the +/// constant costs them nothing. +fn emit_provision_key(out_dir: &Path, production: bool, boot_key: &[u8; KEY_LEN]) { + println!("cargo:rerun-if-env-changed={PROVISION_KEY_ENV}"); + + let anchor = match env::var(PROVISION_KEY_ENV) { + Ok(path) => { + let path = PathBuf::from(path); + println!("cargo:rerun-if-changed={}", path.display()); + let key = read_hex_key(&path); + reject_unusable_anchor(&key, &path, "#869"); + // WHY refuse the boot key specifically: kernel-image authenticity + // and provisioning-bundle authenticity are separate trust domains, + // and one key serving both collapses them -- an authority able to + // sign an image could then also sign credentials, which is not the + // delegation anyone chose. + if &key == boot_key { + die(&format!( + "#869: {} is this image's BOOT anchor. Kernel-image authenticity and \ + provisioning-bundle authenticity are separate trust domains; reusing one \ + key for both silently merges them.", + path.display() + )); + } + Some(key) + } + Err(_) if production => die(&format!( + "#869: a production image needs a provisioning trust anchor. Set \ + {PROVISION_KEY_ENV} to the hex-encoded Ed25519 public key file produced by the \ + operator-accepted authority, or the device would accept credential bundles \ + signed by nobody in particular. No production key is ever committed to this repo." + )), + Err(_) => None, + }; + + let rendered = render_provision_key_rs(anchor.as_ref(), production); + if let Err(e) = fs::write(out_dir.join("provision_key.rs"), rendered) { + die(&format!("#869: cannot write provision_key.rs: {e}")); + } +} + +/// Refuse anchors that cannot authenticate anyone, whatever they are for. +/// +/// One implementation for both anchors (#233 boot, #869 provisioning): the +/// reasons a key is unusable do not depend on which trust domain it serves, and +/// a second copy would be a second thing to keep in step with the RFC list. +fn reject_unusable_anchor(key: &[u8; KEY_LEN], key_path: &Path, issue: &str) { + if RFC8032_TEST_PUBLIC_KEYS.contains(key) { + die(&format!( + "{issue}: {} is an RFC 8032 section 7.1 test-vector public key -- its private \ + half is published in the RFC, so this anchor is forgeable by anyone. \ + Refused in every configuration.", + key_path.display() + )); + } + if VerifyingKey::from_bytes(key).is_err() { + die(&format!( + "{issue}: {} is not a decompressable Ed25519 point -- a corrupted anchor \ + would make every signature unverifiable.", + key_path.display() + )); + } +} + +/// Render `provision_key.rs`. +fn render_provision_key_rs(anchor: Option<&[u8; KEY_LEN]>, production: bool) -> String { + let mut out = String::new(); + out.push_str( + "// GENERATED by build.rs (#869). Do not edit.\n\ + //\n\ + // `None` means this build carries no provisioning trust anchor, so every\n\ + // bundle is refused. That is the non-production state and it is structural:\n\ + // there is no key to verify against rather than a weak key that verifies.\n", + ); + match anchor { + Some(key) => { + let _ = writeln!( + out, + "pub(crate) const PROVISION_PUBLIC_KEY: Option<[u8; {KEY_LEN}]> = Some([{}]);", + byte_list(key) + ); + } + None => { + let _ = writeln!( + out, + "pub(crate) const PROVISION_PUBLIC_KEY: Option<[u8; {KEY_LEN}]> = None;" + ); + } + } + let _ = writeln!( + out, + "/// Whether this image was built as the shippable artifact.\n\ + pub(crate) const PROVISION_KEY_IS_PRODUCTION: bool = {production};" + ); + out +} diff --git a/crates/thumos/src/provision.rs b/crates/thumos/src/provision.rs index d6db0f6c..480505c6 100644 --- a/crates/thumos/src/provision.rs +++ b/crates/thumos/src/provision.rs @@ -16,10 +16,11 @@ //! (everything before the checksum) and is integrity-only: it travels inside //! the same untrusted bundle it protects, so it catches corruption but proves //! nothing about origin. The Ed25519 signature covers the same magic + length -//! + payload region and is intended to be the authenticity gate. The compiled -//! [`PROVISION_PUBLIC_KEY`] is currently RFC 8032 Test 2, whose private key is -//! public; it provides no operator authenticity. #869 owns production key -//! injection, test-key rejection, identity, freshness, and replay semantics. +//! + payload region and is the authenticity gate. [`PROVISION_PUBLIC_KEY`] is +//! generated by `build.rs` (#869): a production build must be given one +//! through `THUMOS_PROVISION_KEY_PUB` or it fails to build, and any other +//! build has `None` -- no anchor, so every bundle is refused. #869 still owns +//! command identity, freshness, and replay semantics. //! //! ## Provisioning flow //! @@ -79,19 +80,21 @@ const MAX_PAYLOAD_SIZE: usize = 65_536; /// signature. const RECV_BUF_CAPACITY: usize = HEADER_SIZE + MAX_PAYLOAD_SIZE + SHA256_LEN + SIGNATURE_LEN; -/// Embedded Ed25519 public key for provisioning bundle authenticity. -/// -/// TODO(#869)[deliberate-prudent]: this is the RFC 8032 section 7.1 Test 2 -/// public key, NOT a real trust anchor. It must be replaced with the -/// production provisioning key injected by an operator-accepted authority -/// before any live integration. The current corresponding private key is -/// published in the RFC, so this constant authenticates nobody. -/// Deliberately a distinct key from the boot key: provisioning-bundle -/// authenticity and kernel-image authenticity are separate trust domains. -const PROVISION_PUBLIC_KEY: [u8; secure_boot::PUBLIC_KEY_LEN] = [ - 0x3d, 0x40, 0x17, 0xc3, 0xe8, 0x43, 0x89, 0x5a, 0x92, 0xb7, 0x0a, 0xa7, 0x4d, 0x1b, 0x7e, 0xbc, - 0x9c, 0x98, 0x2c, 0xcf, 0x2e, 0xc4, 0x96, 0x8c, 0xc0, 0xcd, 0x55, 0xf1, 0x2a, 0xf4, 0x66, 0x0c, -]; +// PROVISION_PUBLIC_KEY / PROVISION_KEY_IS_PRODUCTION, generated by build.rs +// (#869). A plain comment, not a doc comment: `include!` is not an item and +// cannot carry one. +// +// `None` means this build carries no provisioning anchor and refuses every +// bundle. That is the non-production state, and it is structural rather than +// enforced: there is nothing to verify against, so no path can present a +// bundle as operator-authenticated. It replaced RFC 8032 test vector 2, whose +// private half is published in the RFC -- a key that authenticated anybody +// while the doc beside it described operator offline custody. +// +// build.rs also refuses a provisioning anchor equal to the boot anchor: +// kernel-image authenticity and provisioning-bundle authenticity are separate +// trust domains, and one key serving both silently merges them. +include!(concat!(env!("OUT_DIR"), "/provision_key.rs")); // --------------------------------------------------------------------------- // Error types @@ -110,6 +113,11 @@ pub enum ProvisionError { ChecksumMismatch, /// The Ed25519 signature did not verify against [`PROVISION_PUBLIC_KEY`]. SignatureInvalid, + /// This build carries no provisioning trust anchor, so nothing can be + /// authenticated. Distinct from [`Self::SignatureInvalid`], which means a + /// signature was checked and did not match -- here none was checked, and a + /// caller must not report the bundle as having failed verification. + NoTrustAnchor, /// Postcard deserialization failed. DeserializeError, /// Postcard serialization failed while encoding a [`ProvisionBundle`] @@ -130,6 +138,7 @@ impl fmt::Display for ProvisionError { Self::PayloadTooLarge => write!(f, "provision payload exceeds maximum size"), Self::ChecksumMismatch => write!(f, "provision checksum mismatch"), Self::SignatureInvalid => write!(f, "provision bundle signature invalid"), + Self::NoTrustAnchor => write!(f, "no provisioning trust anchor in this build"), Self::DeserializeError => write!(f, "provision bundle deserialization failed"), Self::SerializeError(cause) => { write!(f, "provision bundle serialization failed: {cause}") @@ -243,17 +252,19 @@ pub(crate) struct Provisioner { payload_len: Option, /// Successfully deserialized bundle (set after finalize). bundle: Option, - /// Ed25519 public key used to verify bundle signatures. Always - /// `PROVISION_PUBLIC_KEY` outside tests; test-injectable via - /// [`Provisioner::new_with_key`] to exercise verification against a + /// Ed25519 public key used to verify bundle signatures. `None` when this + /// build carries no provisioning anchor, in which case every bundle is + /// refused. Outside tests this is `PROVISION_PUBLIC_KEY`; test-injectable + /// via [`Provisioner::new_with_key`] to exercise verification against a /// locally generated keypair. - provision_public_key: [u8; secure_boot::PUBLIC_KEY_LEN], + provision_public_key: Option<[u8; secure_boot::PUBLIC_KEY_LEN]>, } impl Provisioner { /// Create a new provisioner in the [`ProvisionState::Waiting`] state. /// - /// Verifies bundle signatures against [`PROVISION_PUBLIC_KEY`]. + /// Verifies bundle signatures against [`PROVISION_PUBLIC_KEY`], and refuses + /// every bundle when this build has none. #[must_use] pub(crate) fn new() -> Self { Self { @@ -279,7 +290,7 @@ impl Provisioner { buffer: Vec::new(), payload_len: None, bundle: None, - provision_public_key, + provision_public_key: Some(provision_public_key), } } @@ -412,13 +423,16 @@ impl Provisioner { return Err(ProvisionError::ChecksumMismatch); } - // Verify the Ed25519 signature over the same region. This is the - // intended authenticity gate. With the compiled RFC test key, anyone - // knows the matching private key; #869 must provision and bind the - // real authority before this path becomes reachable. + // Verify the Ed25519 signature over the same region: the authenticity + // gate. A build with no anchor refuses here and says so distinctly -- + // reporting SignatureInvalid would claim a signature was checked and + // rejected, when none was checked at all (#869). + let anchor = self + .provision_public_key + .ok_or(ProvisionError::NoTrustAnchor)?; let mut signature = [0u8; SIGNATURE_LEN]; signature.copy_from_slice(&self.buffer[signature_start..signature_start + SIGNATURE_LEN]); - secure_boot::verify_message_signature(data_region, &signature, &self.provision_public_key) + secure_boot::verify_message_signature(data_region, &signature, &anchor) .map_err(|_| ProvisionError::SignatureInvalid)?; // Deserialize the postcard payload. @@ -458,9 +472,10 @@ impl fmt::Display for Provisioner { /// Returns the complete byte sequence: magic + length + postcard payload + /// SHA-256 checksum + Ed25519 signature. `signature` must be computed by /// the caller over the magic + length + payload bytes (the same region -/// [`Provisioner::try_finalize`] verifies). The accepted production workflow -/// must inject a non-test trust anchor and keep its signing key under the -/// operator-approved custody defined by #869. This function never touches +/// [`Provisioner::try_finalize`] verifies). A production build is given its +/// trust anchor through `THUMOS_PROVISION_KEY_PUB` or fails to build (#869); +/// the matching signing key stays under operator custody and never appears +/// here. This function never touches /// signing-key material itself — it only appends a caller-supplied /// signature — so the menos-side provisioning tool (or a test, via a /// locally generated keypair) computes the signature with its own crypto @@ -509,6 +524,97 @@ pub(crate) fn encode_bundle( mod tests { use ed25519_dalek::{Signer, SigningKey}; + #[test] + fn a_build_with_no_anchor_refuses_a_correctly_signed_bundle() { + // The non-production state, exercised through the real entry points + // rather than by constructing the struct by hand. The bundle here is + // validly signed -- by somebody -- and must still be refused, because + // this build trusts nobody. + let bundle = provision_bundle_with_cross_signing(); + let wire = encode_bundle_signed(&bundle); + + let mut prov = Provisioner::new(); + prov.receive_chunk(&wire); + + assert_eq!( + prov.finalize(), + Err(ProvisionError::NoTrustAnchor), + "a bundle correctly signed by someone must still be refused when this \ + build carries no anchor" + ); + } + + #[test] + fn no_anchor_is_distinguishable_from_a_rejected_signature() { + // A caller reporting SignatureInvalid for a missing anchor would be + // claiming a verification happened. An operator would then hunt a + // corrupted bundle instead of a build that trusts nobody -- so the two + // must not be the same value. + let bundle = provision_bundle_with_cross_signing(); + + let no_anchor = { + let mut prov = Provisioner::new(); + prov.receive_chunk(&encode_bundle_signed(&bundle)); + prov.finalize() + }; + let wrong_signer = { + // Trusts the harness key, but the bundle is signed by a different one. + let mut prov = harness_provisioner(); + prov.receive_chunk(&encode_bundle_with( + &bundle, + &SigningKey::from_bytes(&[0x99; 32]), + )); + prov.finalize() + }; + + assert_eq!(no_anchor, Err(ProvisionError::NoTrustAnchor)); + assert_eq!(wrong_signer, Err(ProvisionError::SignatureInvalid)); + assert_ne!(no_anchor, wrong_signer); + } + + #[test] + // WHY the allow: these constants are generated per build, so clippy sees a + // fixed value for THIS compilation and calls the assertion constant. Pinning + // that per-build value is exactly the test's job, and it stays a runtime + // #[test] rather than a const assert so it remains a counted, individually + // reportable entry in the suite -- the same shape secure_boot.rs uses for the + // boot anchor. + #[allow(clippy::assertions_on_constants)] + fn the_rfc_test_vector_is_not_this_build_anchor() { + // The exact key this replaced: RFC 8032 test vector 2, whose private + // half is published in the RFC. build.rs refuses it for either anchor + // in every configuration; this pins that no build ships it embedded. + const RFC8032_TEST2: [u8; secure_boot::PUBLIC_KEY_LEN] = [ + 0x3d, 0x40, 0x17, 0xc3, 0xe8, 0x43, 0x89, 0x5a, 0x92, 0xb7, 0x0a, 0xa7, 0x4d, 0x1b, + 0x7e, 0xbc, 0x9c, 0x98, 0x2c, 0xcf, 0x2e, 0xc4, 0x96, 0x8c, 0xc0, 0xcd, 0x55, 0xf1, + 0x2a, 0xf4, 0x66, 0x0c, + ]; + assert_ne!( + PROVISION_PUBLIC_KEY, + Some(RFC8032_TEST2), + "the provisioning anchor must never be a published test vector" + ); + } + + #[test] + // WHY the allow: these constants are generated per build, so clippy sees a + // fixed value for THIS compilation and calls the assertion constant. Pinning + // that per-build value is exactly the test's job, and it stays a runtime + // #[test] rather than a const assert so it remains a counted, individually + // reportable entry in the suite -- the same shape secure_boot.rs uses for the + // boot anchor. + #[allow(clippy::assertions_on_constants)] + fn a_host_test_build_carries_no_provisioning_anchor() { + // Host tests build without `production`, so there is no anchor and the + // path is inert. If this ever fails, some build is embedding a + // provisioning key by default -- which is the shape #869 removed. + assert_eq!( + PROVISION_PUBLIC_KEY, None, + "a non-production build must trust no provisioning authority" + ); + assert!(!PROVISION_KEY_IS_PRODUCTION); + } + use super::*; /// Fixed, deterministic Ed25519 signing key for provisioning tests. @@ -997,7 +1103,7 @@ mod tests { } // ----------------------------------------------------------------------- - // Signature-verification mechanics (historical #270); production anchor #869. + // Signature-verification mechanics (historical #270). // ----------------------------------------------------------------------- #[test] diff --git a/docs/target-test-ledger.toml b/docs/target-test-ledger.toml index 761fac38..551a1851 100644 --- a/docs/target-test-ledger.toml +++ b/docs/target-test-ledger.toml @@ -479,7 +479,7 @@ witness = "boot.sh+fork.sh+exec.sh+forkexec.sh+guard.sh+brk.sh+crashloop.sh" [[module]] name = "provision" -tests = 29 +tests = 33 mechanism = "host" [[module]] diff --git a/scripts/kernel-clippy.sh b/scripts/kernel-clippy.sh index edf73fd4..ff09fe16 100755 --- a/scripts/kernel-clippy.sh +++ b/scripts/kernel-clippy.sh @@ -126,12 +126,21 @@ PRODUCTION_KEY_DIR="" # the 9 feature passes compiled clean, then the script still exited 1). # Call ensure_production_key as a bare statement so the assignment lands # in this shell, then read PRODUCTION_KEY_DIR directly. +# +# WHY two keys (#869): the provisioning anchor is refused the same way the boot +# anchor is, and build.rs additionally refuses a provisioning key EQUAL to the +# boot key -- kernel-image authenticity and provisioning-bundle authenticity are +# separate trust domains. Two independent ephemeral keys are therefore the only +# shape that builds, which is the point: the harness cannot accidentally prove +# the guard passes by handing it one key twice. ensure_production_key() { if [[ -z "$PRODUCTION_KEY_DIR" ]]; then PRODUCTION_KEY_DIR=$(mktemp -d) - openssl genpkey -algorithm ed25519 -out "$PRODUCTION_KEY_DIR/ci-boot.pem" 2>/dev/null - openssl pkey -in "$PRODUCTION_KEY_DIR/ci-boot.pem" -pubout -outform DER \ - | tail -c 32 | od -An -tx1 | tr -d ' \n' > "$PRODUCTION_KEY_DIR/ci-boot.pub" + for role in boot provision; do + openssl genpkey -algorithm ed25519 -out "$PRODUCTION_KEY_DIR/ci-$role.pem" 2>/dev/null + openssl pkey -in "$PRODUCTION_KEY_DIR/ci-$role.pem" -pubout -outform DER \ + | tail -c 32 | od -An -tx1 | tr -d ' \n' > "$PRODUCTION_KEY_DIR/ci-$role.pub" + done fi } cleanup() { @@ -164,7 +173,10 @@ for i in "${!PASS_TAGS[@]}"; do rc=0 if [[ "$tag" = "production" ]]; then ensure_production_key - out=$(cd "$KERNEL_DIR" && THUMOS_BOOT_KEY_PUB="$PRODUCTION_KEY_DIR/ci-boot.pub" cargo clippy --bin thumos --tests --locked \ + out=$(cd "$KERNEL_DIR" \ + && THUMOS_BOOT_KEY_PUB="$PRODUCTION_KEY_DIR/ci-boot.pub" \ + THUMOS_PROVISION_KEY_PUB="$PRODUCTION_KEY_DIR/ci-provision.pub" \ + cargo clippy --bin thumos --tests --locked \ --features "$features" --target i686-unknown-linux-gnu -- -D warnings 2>&1) || rc=$? elif [ -n "$features" ]; then out=$(cd "$KERNEL_DIR" && cargo clippy --bin thumos --tests --locked \ diff --git a/scripts/witness/trust-anchor.sh b/scripts/witness/trust-anchor.sh index 0fd3c93b..ca2edfa1 100755 --- a/scripts/witness/trust-anchor.sh +++ b/scripts/witness/trust-anchor.sh @@ -1,12 +1,18 @@ #!/usr/bin/env bash set -euo pipefail -# witness/trust-anchor.sh — trust-anchor build guard (#233), verbatim from +# witness/trust-anchor.sh — trust-anchor build guard (#233, #869), verbatim from # ci.yml. A production image must FAIL to build without a provisioned key, # must REFUSE the committed (deliberately public) dev key, and must BUILD # with a real key — proven here with an ephemeral one. Dev/qemu/host builds # keep building keylessly. # +# The image carries TWO anchors and each is proved separately: the boot anchor +# (#233) authenticates the kernel image, and the provisioning anchor (#869) +# authenticates credential bundles. They are separate trust domains, so build.rs +# also refuses a provisioning key equal to the boot key — proved below, because +# a guard nobody exercises is a guard nobody knows is wired. +# # WHY --locked (#757): crates/thumos keeps its own lockfile; without --locked # a manifest/lock disagreement here is silently resolved and rewritten # instead of failing the build. @@ -32,7 +38,28 @@ grep -q 'THUMOS_BOOT_KEY_PUB' "$work/keyless-err.log" || { echo 'FAIL: keyless p if THUMOS_BOOT_KEY_PUB=keys/dev/boot-dev.pub cargo check --release --target armv7a-none-eabi --locked --features production 2>"$work/devkey-err.log"; then echo 'FAIL: production build accepted the dev key'; exit 1 fi -openssl genpkey -algorithm ed25519 -out "$work/ci-boot.pem" -openssl pkey -in "$work/ci-boot.pem" -pubout -outform DER | tail -c 32 | od -An -tx1 | tr -d ' \n' > "$work/ci-boot.pub" -THUMOS_BOOT_KEY_PUB="$work/ci-boot.pub" cargo check --release --target armv7a-none-eabi --locked --features production +mint_key() { + openssl genpkey -algorithm ed25519 -out "$work/ci-$1.pem" + openssl pkey -in "$work/ci-$1.pem" -pubout -outform DER \ + | tail -c 32 | od -An -tx1 | tr -d ' \n' > "$work/ci-$1.pub" +} +mint_key boot +mint_key provision + +# #869: a production image needs a provisioning anchor too. Checked with a valid +# boot key already in hand, so a failure here can only be the provisioning +# guard. +if THUMOS_BOOT_KEY_PUB="$work/ci-boot.pub" cargo check --release --target armv7a-none-eabi --locked --features production 2>"$work/no-provision-err.log"; then + echo 'FAIL: production build succeeded without THUMOS_PROVISION_KEY_PUB'; exit 1 +fi +grep -q 'THUMOS_PROVISION_KEY_PUB' "$work/no-provision-err.log" || { echo 'FAIL: missing-provisioning-anchor error lacks the provisioning message'; cat "$work/no-provision-err.log"; exit 1; } + +# #869: the two anchors are separate trust domains. One key serving both would +# let an authority able to sign an image also sign credentials, which is not the +# delegation anyone chose — so build.rs refuses it and this proves it does. +if THUMOS_BOOT_KEY_PUB="$work/ci-boot.pub" THUMOS_PROVISION_KEY_PUB="$work/ci-boot.pub" cargo check --release --target armv7a-none-eabi --locked --features production 2>"$work/same-key-err.log"; then + echo 'FAIL: production build accepted the boot key as the provisioning anchor'; exit 1 +fi + +THUMOS_BOOT_KEY_PUB="$work/ci-boot.pub" THUMOS_PROVISION_KEY_PUB="$work/ci-provision.pub" cargo check --release --target armv7a-none-eabi --locked --features production echo "trust-anchor witness: PASS"