From 5be27a560ca814e7cfa2966a4ed4595e27635e74 Mon Sep 17 00:00:00 2001 From: Jason Hall Date: Sat, 2 May 2026 13:50:03 -0400 Subject: [PATCH] Add cosign signature verification (OTA phase 4a) Each OTA fetch is now gated on a Sigstore Bundle signature whose Fulcio cert identifies a member of an allowlist hardcoded in src/trust.rs (currently imjasonh@gmail.com / accounts.google.com). The allowlist cannot be changed via OTA -- only by editing source and reflashing over USB. Publisher (Makefile): make publish now runs cosign sign --yes after the publisher push. Cosign keyless OIDC pops a browser the first time; subsequent signs reuse the cached token within ~10min. Firmware: - src/trust.rs: TRUSTED_IDENTITIES list + bundled Sigstore root and intermediate CA PEMs (trust/fulcio_root.pem, fulcio_intermediate.pem). - src/sig.rs: parse Sigstore Bundle v0.3 (DSSE envelope), verify (a) Fulcio cert SAN email + OID 1.3.6.1.4.1.57264.1.1 issuer match the allowlist, (b) leaf cert chains to bundled Sigstore root via P-384 ECDSA-SHA384, (c) DSSE signature verifies via P-256 ECDSA-SHA256 over the PAE, (d) in-toto Statement subject digest binds to our manifest digest. - src/ota.rs: fetch_manifest now returns the manifest's own SHA256 (the digest cosign signed), not just the parsed body. New fetch_signature_bundle walks the OCI 1.1 referrers layout cosign uses (image index -> inner manifest -> bundle blob). Sig is fetched and verified before the firmware download starts. Crates: p256, p384, x509-cert (with pem feature), base64. Adds ~350 KB to the firmware -- repartitioned ota slots from 1.5MB to 1.75MB to fit (USB-only migration). Bumped OTA thread stack to 48KB for cert-parsing headroom. Verified end-to-end: Jason signed :latest with cosign keyless, device polled, all four verification steps passed (identity, chain, DSSE sig, in-toto binding), then downloaded and applied as before. Phase 4b (Rekor SET / transparency log inclusion proof) and 4c (operational hardening) remain. Co-Authored-By: Claude Opus 4.7 (1M context) --- Cargo.toml | 5 + Makefile | 3 + ota-plan.md | 81 ++++++++--- partitions.csv | 16 ++- src/main.rs | 10 +- src/ota.rs | 131 +++++++++++++++-- src/sig.rs | 258 ++++++++++++++++++++++++++++++++++ src/trust.rs | 21 +++ trust/fulcio_intermediate.pem | 14 ++ trust/fulcio_root.pem | 13 ++ 10 files changed, 512 insertions(+), 40 deletions(-) create mode 100644 src/sig.rs create mode 100644 src/trust.rs create mode 100644 trust/fulcio_intermediate.pem create mode 100644 trust/fulcio_root.pem diff --git a/Cargo.toml b/Cargo.toml index 2ddbfe3..e5ef597 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -33,6 +33,11 @@ embedded-svc = "0.28" serde = { version = "1", features = ["derive"] } serde_json = "1" sha2 = "0.10" +# Phase 4a: cosign Sigstore Bundle verification. +p256 = { version = "0.13", features = ["ecdsa"] } # leaf signs DSSE +p384 = { version = "0.13", features = ["ecdsa"] } # intermediate signs leaf +x509-cert = { version = "0.2", features = ["pem"] } +base64 = "0.22" [build-dependencies] embuild = "0.33" diff --git a/Makefile b/Makefile index 117fbe8..3443904 100644 --- a/Makefile +++ b/Makefile @@ -123,6 +123,9 @@ publish: $(FW_BIN) check-gh-env --bin $(CURDIR)/$(FW_BIN) \ --repo $(OCI_REPO) \ --git-sha $(GIT_SHA) + @echo ">>> Signing $(OCI_REPO):latest with cosign (keyless OIDC)" + . ./gh.env && COSIGN_REGISTRY_USERNAME=imjasonh COSIGN_REGISTRY_PASSWORD="$$GH_TOKEN" \ + cosign sign --yes $(OCI_REPO):latest # Pull the latest artifact from OCI_REPO and verify its layer SHA matches # our locally-built firmware. Confirms the round trip works and exercises diff --git a/ota-plan.md b/ota-plan.md index 58b8427..2541cfe 100644 --- a/ota-plan.md +++ b/ota-plan.md @@ -198,26 +198,69 @@ Deferred (low value, or needs hardware/provisioning we don't have yet): from outside we'd need a serial console command, a small HTTP endpoint on the device, or a config-as-OCI-artifact channel. -### Phase 4 — keyless cosign + Rekor (future) +### Phase 4 — keyless cosign signing + on-device verification -- Publisher signs with `cosign sign --identity-token` (keyless via OIDC - → Fulcio → Rekor). -- Cosign stores the signature as a sibling OCI artifact tagged - `sha256-.sig`. -- Device fetches the `.sig`, parses the cosign payload, verifies ECDSA - P-256 signature against the Fulcio cert, checks the Rekor inclusion - proof, and validates the OIDC identity claim against an allowlist - embedded at compile time. -- Crates: `p256`, `sha2`, `serde_json`. Investigate `sigstore-rs` for - Rekor pieces; may need to port a minimal verifier if its deps - conflict with the IDF environment. +Split into three sub-phases because the full thing is a real subproject. + +#### Phase 4a — sign + verify signature, no Rekor + +- **Publisher**: `cosign sign` runs after each `make publish` push. + Keyless OIDC flow (browser the first time, cached after). Sigs are + uploaded to GHCR as a sibling OCI artifact at tag + `sha256-.sig`. One signing per push covers both `:latest` + and `:sha-` since they resolve to the same digest. +- **Trust roots in firmware**: `src/trust.rs` carries: + - `TRUSTED_IDENTITIES: &[(&str, &str)]` — allowlist of (email, + issuer) tuples. Initial: `[("imjasonh@gmail.com", + "https://accounts.google.com")]`. Editing requires source change + and USB reflash; OTA cannot change this. + - Sigstore root CA cert PEM, `include_str!`'d at build time. +- **Device verification flow**, before download in `poll_once`: + 1. Fetch the sig artifact at `sha256-.sig`. + 2. Parse its layer's annotations: `dev.cosignproject.cosign/signature` + (base64 ECDSA), `dev.sigstore.cosign/certificate` (PEM Fulcio cert + chain). + 3. Fetch the layer payload blob (the JSON to-be-signed). + 4. Parse the leaf signing cert. Extract the email (SAN + rfc822Name) and OIDC issuer (cert extension OID + `1.3.6.1.4.1.57264.1.1` legacy or `.1.8` for v2). + 5. Reject if the (email, issuer) tuple isn't in + `TRUSTED_IDENTITIES`. + 6. Verify the cert chains to the bundled Sigstore root. + 7. Verify the ECDSA-P256 signature over the payload bytes using + the cert's public key. + 8. Parse the payload JSON (`type:"cosign container image + signature"`); confirm `critical.image.docker-manifest-digest` + matches the manifest digest we're about to apply. + 9. Only then proceed with the download/write/reboot. +- **Defer to phase 4b**: cert-validity-window check needs a trusted + timestamp (Rekor SET) to make sense, since Fulcio certs are 10-min + short-lived. +- **Crates added**: `p256` (ECDSA verify), `x509-cert` + `der` (cert + parsing), `base64` (annotation decoding), `pem` (PEM block parsing). + Roughly +100 KB of firmware. + +#### Phase 4b — Rekor SET verification + +- Add the Signed Entry Timestamp check from cosign's bundle annotation + (or fetched from Rekor directly). Verify SET signed by Rekor's + bundled public key. Use the SET timestamp to enforce the cert + validity window. +- Bundle Rekor's public key alongside the Sigstore root. + +#### Phase 4c — operational hardening + +- Better verification-failure logs (exact field that failed). +- Support the multiple cosign annotation/bundle formats that exist + across cosign versions. +- Metrics counter for verify-pass / verify-fail / sig-not-found. ## Order of work -1. Phase 0 (partition table + flash-all). Small, well-scoped. -2. Phase 1 publisher. Independently testable: push an artifact, pull it - with `oras pull`, confirm bytes match. -3. Phase 2 firmware OTA loop. Highest risk for bricking; this is where - layer-1 rollback earns its keep. -4. Phase 3 polish. -5. Phase 4 signing as a separate effort. +1. Phase 0 (partition table + flash-all). ✅ +2. Phase 1 publisher. ✅ +3. Phase 2 firmware OTA loop. ✅ +4. Phase 3 polish. ✅ (partial; some deferred) +5. Phase 4a signing + verify (no Rekor). +6. Phase 4b Rekor SET verification. +7. Phase 4c operational hardening. diff --git a/partitions.csv b/partitions.csv index f31839c..2e389dd 100644 --- a/partitions.csv +++ b/partitions.csv @@ -4,17 +4,21 @@ # # Flash layout (4 MB total): # 0x001000-0x008FFF bootloader (~28 KB) -# 0x008000-0x008FFF partition table itself (4 KB; overlap with above is intentional in IDF layout) +# 0x008000-0x008FFF partition table itself (4 KB) # 0x009000-0x00EFFF nvs (24 KB) # 0x00F000-0x010FFF otadata (8 KB) - which slot to boot # 0x011000-0x011FFF phy_init (4 KB) -# 0x020000-0x19FFFF ota_0 (1.5 MB) -# 0x1A0000-0x31FFFF ota_1 (1.5 MB) -# 0x320000-0x3FFFFF unused (~900 KB headroom) +# 0x020000-0x1DFFFF ota_0 (1.75 MB) +# 0x1E0000-0x39FFFF ota_1 (1.75 MB) +# 0x3A0000-0x3FFFFF unused (384 KB) +# +# Bumped from 1.5 MB slots to 1.75 MB when phase 4a (cosign signature +# verification) pushed the firmware past 1.5 MB. Repartitioning is a +# USB-only migration (`make flash-all`). # # Name, Type, SubType, Offset, Size nvs, data, nvs, 0x9000, 0x6000 otadata, data, ota, 0xf000, 0x2000 phy_init, data, phy, 0x11000, 0x1000 -ota_0, app, ota_0, 0x20000, 0x180000 -ota_1, app, ota_1, 0x1A0000, 0x180000 +ota_0, app, ota_0, 0x20000, 0x1C0000 +ota_1, app, ota_1, 0x1E0000, 0x1C0000 diff --git a/src/main.rs b/src/main.rs index 6ec3e34..1d7e6e9 100644 --- a/src/main.rs +++ b/src/main.rs @@ -12,6 +12,8 @@ use std::ffi::CStr; use std::time::Duration; mod ota; +mod sig; +mod trust; const SSID: &str = env!("WIFI_SSID"); const PASS: &str = env!("WIFI_PASS"); @@ -66,10 +68,10 @@ fn main() -> Result<()> { let ota_nvs = nvs.clone(); std::thread::Builder::new() - // OTA work is HTTPS + JSON parse + SHA256 on a streaming download; - // mbedtls alone wants several KB. 32 KB has headroom; observed - // failures at 8 KB. - .stack_size(32 * 1024) + // HTTPS + JSON + SHA256 is ~32 KB; phase 4a adds X.509 parsing + // and ECDSA P-256/P-384 verification on top, which want more. + // 48 KB is observed-safe with headroom. + .stack_size(48 * 1024) .spawn(move || ota::run(ota_nvs, FW_VERSION)) .expect("spawn ota thread"); diff --git a/src/ota.rs b/src/ota.rs index 8f1063f..a3c2a26 100644 --- a/src/ota.rs +++ b/src/ota.rs @@ -132,9 +132,11 @@ pub fn run(nvs_partition: EspDefaultNvsPartition, fw_version: &str) -> ! { } Err(e) => { consecutive_failures += 1; + // {:#} renders the anyhow chain on one line: + // "outer: middle: root cause" tracing::warn!( failures = consecutive_failures, - error = %e, + error = %format!("{:#}", e), "ota: poll failed", ); } @@ -170,11 +172,9 @@ enum PollOutcome { fn poll_once(nvs: &mut EspNvs, cfg: &OtaConfig) -> Result { let token = fetch_anon_token(&cfg.repo)?; - // Manifest fetch. We re-fetch every poll (no If-None-Match yet — the - // ESP HTTP client doesn't make that trivial); we do compare the layer - // digest from the manifest against last_applied_digest before - // downloading the (much larger) layer. - let manifest = fetch_manifest(&cfg.repo, &cfg.tag, &token)?; + // Fetch manifest and compute its SHA256 — that's the manifest digest + // cosign signed (and that we'll need for the bundle lookup). + let (manifest, manifest_digest_hex) = fetch_manifest(&cfg.repo, &cfg.tag, &token)?; let layer = manifest .layers .into_iter() @@ -184,9 +184,10 @@ fn poll_once(nvs: &mut EspNvs, cfg: &OtaConfig) -> Result, cfg: &OtaConfig) -> Result" } else { &last }, new = %layer.digest, - "ota: new digest, downloading", + "ota: new digest, verifying signature before download", ); + // Phase 4a: fetch and verify the cosign signature bundle BEFORE the + // (much larger) firmware download. Refuses unknown signers. + let bundle = fetch_signature_bundle(&cfg.repo, &manifest_digest_hex, &token) + .context("fetch signature bundle")?; + crate::sig::verify_bundle(&bundle, &manifest_digest_hex) + .context("verify signature bundle")?; + tracing::info!("ota: signature verified, proceeding with download"); + download_and_apply(&cfg.repo, &layer, &token)?; // Persist as pending; main.rs will promote to last_digest after the @@ -224,7 +233,9 @@ fn fetch_anon_token(repo: &str) -> Result { Ok(resp.token) } -fn fetch_manifest(repo: &str, tag: &str, token: &str) -> Result { +/// Returns the parsed manifest plus the hex SHA256 of the manifest bytes +/// (the canonical digest that cosign signed). +fn fetch_manifest(repo: &str, tag: &str, token: &str) -> Result<(Manifest, String)> { let repo_path = repo .strip_prefix("ghcr.io/") .ok_or_else(|| anyhow!("only ghcr.io is supported for now (got {})", repo))?; @@ -243,9 +254,107 @@ fn fetch_manifest(repo: &str, tag: &str, token: &str) -> Result { ], &mut buf, )?; + let digest_hex = format!("{:x}", Sha256::digest(&buf)); let m: Manifest = serde_json::from_slice(&buf) .with_context(|| format!("parse manifest JSON ({} bytes)", buf.len()))?; - Ok(m) + Ok((m, digest_hex)) +} + +/// Fetch the cosign Sigstore bundle for the artifact at the given +/// manifest digest. Walks the OCI 1.1 referrers layout cosign uses: +/// the bundle artifact lives at tag `sha256-` and is an image +/// index → inner manifest → bundle layer blob. +fn fetch_signature_bundle( + repo: &str, + manifest_digest_hex: &str, + token: &str, +) -> Result> { + let repo_path = repo + .strip_prefix("ghcr.io/") + .ok_or_else(|| anyhow!("only ghcr.io is supported"))?; + let auth = format!("Bearer {}", token); + let bundle_tag = format!("sha256-{}", manifest_digest_hex); + + // 1. Outer index + let url1 = format!("https://ghcr.io/v2/{}/manifests/{}", repo_path, bundle_tag); + let mut buf1 = Vec::with_capacity(1024); + fetch_to_buf( + &url1, + &[ + ("authorization", auth.as_str()), + ( + "accept", + "application/vnd.oci.image.index.v1+json,application/vnd.oci.image.manifest.v1+json", + ), + ], + &mut buf1, + ) + .context("fetch sig outer manifest/index")?; + + #[derive(Deserialize)] + struct Index { + manifests: Vec, + } + #[derive(Deserialize)] + struct IndexEntry { + digest: String, + } + let inner_digest = if let Ok(idx) = serde_json::from_slice::(&buf1) { + idx.manifests + .first() + .ok_or_else(|| anyhow!("sig index has no manifests"))? + .digest + .clone() + } else { + // Fallback: outer is already the manifest itself + let m: Manifest = serde_json::from_slice(&buf1) + .context("sig outer is neither index nor manifest")?; + return blob_for_sigstore_bundle(&m, repo_path, &auth); + }; + + // 2. Inner manifest + let url2 = format!("https://ghcr.io/v2/{}/manifests/{}", repo_path, inner_digest); + let mut buf2 = Vec::with_capacity(2048); + fetch_to_buf( + &url2, + &[ + ("authorization", auth.as_str()), + ( + "accept", + "application/vnd.oci.image.manifest.v1+json", + ), + ], + &mut buf2, + ) + .context("fetch sig inner manifest")?; + let inner: Manifest = + serde_json::from_slice(&buf2).context("parse sig inner manifest JSON")?; + + blob_for_sigstore_bundle(&inner, repo_path, &auth) +} + +fn blob_for_sigstore_bundle( + m: &Manifest, + repo_path: &str, + auth: &str, +) -> Result> { + let layer = m + .layers + .iter() + .find(|l| l.media_type.starts_with("application/vnd.dev.sigstore.bundle.")) + .ok_or_else(|| anyhow!("sig manifest has no Sigstore bundle layer"))?; + let url = format!("https://ghcr.io/v2/{}/blobs/{}", repo_path, layer.digest); + let mut buf = Vec::with_capacity(layer.size as usize + 256); + fetch_to_buf( + &url, + &[ + ("authorization", auth), + ("accept", "application/octet-stream"), + ], + &mut buf, + ) + .context("fetch sig bundle blob")?; + Ok(buf) } fn fetch_to_buf(url: &str, headers: &[(&str, &str)], buf: &mut Vec) -> Result<()> { diff --git a/src/sig.rs b/src/sig.rs new file mode 100644 index 0000000..46e0233 --- /dev/null +++ b/src/sig.rs @@ -0,0 +1,258 @@ +//! Cosign Sigstore Bundle (v0.3) verification for OTA artifacts. +//! +//! Phase 4a scope: verify the DSSE signature, the cert chain to the +//! bundled Sigstore intermediate (and root), and the cert's identity +//! against `trust::TRUSTED_IDENTITIES`. Also verifies the in-toto +//! Statement payload binds to our manifest digest. +//! +//! Phase 4b will add the Rekor SET (transparency log) check and a +//! validity-window check using the Rekor-attested signing time. + +use anyhow::{anyhow, bail, Context, Result}; +use base64::Engine; +use p256::ecdsa::signature::Verifier as _; +use serde::Deserialize; +use sha2::{Digest, Sha256, Sha384}; +use x509_cert::der::{oid::ObjectIdentifier, Decode, Encode}; +use x509_cert::ext::pkix::name::GeneralName; +use x509_cert::ext::pkix::SubjectAltName; +use x509_cert::Certificate; + +use crate::trust; + +// X.509 OID for Sigstore's "OIDC issuer (legacy)" extension. The value +// is the raw issuer URL bytes (not DER-wrapped). Fulcio also emits +// .1.8 (a UTF8String DER wrapper); we use the legacy form because it's +// trivial to parse. +const OID_OIDC_ISSUER_V1: &str = "1.3.6.1.4.1.57264.1.1"; +// Standard SAN OID +const OID_SAN: &str = "2.5.29.17"; + +#[derive(Deserialize)] +struct Bundle { + #[serde(rename = "verificationMaterial")] + verification_material: VerificationMaterial, + #[serde(rename = "dsseEnvelope")] + dsse_envelope: DsseEnvelope, +} + +#[derive(Deserialize)] +struct VerificationMaterial { + certificate: CertWrapper, +} + +#[derive(Deserialize)] +struct CertWrapper { + #[serde(rename = "rawBytes")] + raw_bytes: String, +} + +#[derive(Deserialize)] +struct DsseEnvelope { + payload: String, + #[serde(rename = "payloadType")] + payload_type: String, + signatures: Vec, +} + +#[derive(Deserialize)] +struct DsseSignature { + sig: String, +} + +#[derive(Deserialize)] +struct InTotoStatement { + subject: Vec, +} + +#[derive(Deserialize)] +struct InTotoSubject { + digest: serde_json::Map, +} + +/// Verify a Sigstore bundle JSON against an expected manifest digest. +/// `expected_manifest_digest_hex` is the hex string (no `sha256:`). +pub fn verify_bundle(bundle_json: &[u8], expected_manifest_digest_hex: &str) -> Result<()> { + let bundle: Bundle = serde_json::from_slice(bundle_json).context("parse bundle JSON")?; + + let cert_der = b64_std() + .decode(&bundle.verification_material.certificate.raw_bytes) + .context("base64-decode leaf cert")?; + let leaf = Certificate::from_der(&cert_der).context("parse leaf cert DER")?; + + let email = extract_san_email(&leaf).context("extract SAN email")?; + let issuer = extract_oidc_issuer_v1(&leaf).context("extract OIDC issuer")?; + if !trust::TRUSTED_IDENTITIES + .iter() + .any(|(e, i)| *e == email && *i == issuer) + { + bail!("untrusted identity: email={} issuer={}", email, issuer); + } + tracing::info!(email = %email, issuer = %issuer, "ota: signer identity OK"); + + verify_chain(&leaf).context("cert chain verification")?; + tracing::info!("ota: cert chain to Sigstore root OK"); + + let sig_bytes = b64_std() + .decode(&bundle.dsse_envelope.signatures[0].sig) + .context("base64-decode DSSE signature")?; + let payload_bytes = b64_std() + .decode(&bundle.dsse_envelope.payload) + .context("base64-decode DSSE payload")?; + let pae = pae_dsse_v1(&bundle.dsse_envelope.payload_type, &payload_bytes); + + verify_p256_ecdsa(&leaf, &pae, &sig_bytes).context("DSSE signature verify")?; + tracing::info!("ota: DSSE signature verified"); + + let stmt: InTotoStatement = serde_json::from_slice(&payload_bytes) + .context("parse in-toto Statement payload")?; + let subj = stmt + .subject + .first() + .ok_or_else(|| anyhow!("in-toto Statement has no subject"))?; + let actual_digest_hex = subj + .digest + .get("sha256") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow!("in-toto subject has no sha256 digest"))?; + if actual_digest_hex != expected_manifest_digest_hex { + bail!( + "in-toto subject digest mismatch: signed={} expected={}", + actual_digest_hex, + expected_manifest_digest_hex + ); + } + tracing::info!( + digest = %actual_digest_hex, + "ota: in-toto subject binds to our manifest digest", + ); + + Ok(()) +} + +fn b64_std() -> base64::engine::GeneralPurpose { + base64::engine::general_purpose::STANDARD +} + +/// DSSE Pre-Authentication Encoding (https://github.com/secure-systems-lab/dsse). +/// PAE("DSSEv1", payloadType, payload) = "DSSEv1

" +fn pae_dsse_v1(payload_type: &str, payload: &[u8]) -> Vec { + let mut out = Vec::with_capacity(64 + payload_type.len() + payload.len()); + out.extend_from_slice(b"DSSEv1 "); + out.extend_from_slice(payload_type.len().to_string().as_bytes()); + out.push(b' '); + out.extend_from_slice(payload_type.as_bytes()); + out.push(b' '); + out.extend_from_slice(payload.len().to_string().as_bytes()); + out.push(b' '); + out.extend_from_slice(payload); + out +} + +fn extract_san_email(cert: &Certificate) -> Result { + let san_oid: ObjectIdentifier = OID_SAN.parse().unwrap(); + let extensions = cert + .tbs_certificate + .extensions + .as_ref() + .ok_or_else(|| anyhow!("no extensions"))?; + for ext in extensions { + if ext.extn_id == san_oid { + let san = SubjectAltName::from_der(ext.extn_value.as_bytes()) + .context("parse SubjectAltName")?; + for name in &san.0 { + if let GeneralName::Rfc822Name(email) = name { + return Ok(email.to_string()); + } + } + bail!("SAN extension has no rfc822Name (email)"); + } + } + bail!("no SubjectAltName extension") +} + +fn extract_oidc_issuer_v1(cert: &Certificate) -> Result { + let oid: ObjectIdentifier = OID_OIDC_ISSUER_V1.parse().unwrap(); + let extensions = cert + .tbs_certificate + .extensions + .as_ref() + .ok_or_else(|| anyhow!("no extensions"))?; + for ext in extensions { + if ext.extn_id == oid { + let bytes = ext.extn_value.as_bytes(); + return String::from_utf8(bytes.to_vec()).context("issuer is not UTF-8"); + } + } + bail!("no OIDC issuer (1.3.6.1.4.1.57264.1.1) extension") +} + +/// Verify leaf was signed by the bundled Sigstore intermediate, and +/// that the bundled intermediate was signed by the bundled root. +fn verify_chain(leaf: &Certificate) -> Result<()> { + let intermediate = pem_to_cert(trust::SIGSTORE_INTERMEDIATE_PEM)?; + let root = pem_to_cert(trust::SIGSTORE_ROOT_PEM)?; + + verify_signed_by_p384(leaf, &intermediate).context("leaf -> intermediate")?; + verify_signed_by_p384(&intermediate, &root).context("intermediate -> root")?; + Ok(()) +} + +fn pem_to_cert(pem: &str) -> Result { + let (label, der) = x509_cert::der::pem::decode_vec(pem.as_bytes()) + .map_err(|e| anyhow!("decode PEM: {}", e))?; + if label != "CERTIFICATE" { + bail!("unexpected PEM label: {}", label); + } + Certificate::from_der(&der).context("parse PEM-decoded cert DER") +} + +/// Verify `child.signature` is a valid P-384 ECDSA-SHA384 signature +/// over `child.tbs_certificate` made with `parent`'s public key. +fn verify_signed_by_p384(child: &Certificate, parent: &Certificate) -> Result<()> { + use p384::ecdsa::{signature::Verifier, Signature, VerifyingKey}; + + let parent_pubkey_bytes = parent + .tbs_certificate + .subject_public_key_info + .subject_public_key + .raw_bytes(); + let key = VerifyingKey::from_sec1_bytes(parent_pubkey_bytes) + .context("parse parent P-384 pubkey")?; + + let tbs_der = child + .tbs_certificate + .to_der() + .context("re-serialize TBS to DER")?; + let sig = Signature::from_der(child.signature.raw_bytes()) + .context("parse child cert ECDSA signature")?; + + // p384's Verifier hashes with SHA-384 internally for ECDSA-P384. + key.verify(&tbs_der, &sig) + .context("ECDSA-P384 verify failed")?; + Ok(()) +} + +/// Verify a P-256 ECDSA signature using the leaf cert's public key. +fn verify_p256_ecdsa(cert: &Certificate, message: &[u8], sig_der: &[u8]) -> Result<()> { + use p256::ecdsa::{Signature, VerifyingKey}; + + let pubkey_bytes = cert + .tbs_certificate + .subject_public_key_info + .subject_public_key + .raw_bytes(); + let key = VerifyingKey::from_sec1_bytes(pubkey_bytes) + .context("parse leaf P-256 pubkey")?; + let sig = Signature::from_der(sig_der).context("parse DSSE ECDSA signature")?; + key.verify(message, &sig).context("ECDSA-P256 verify failed")?; + Ok(()) +} + +// Quiet the Sha256/Sha384 imports if not used elsewhere in this file +// after refactoring; they're imported eagerly in case future helpers +// need to hash explicitly. +#[allow(dead_code)] +fn _unused_keep_imports() -> (Sha256, Sha384) { + (Sha256::new(), Sha384::new()) +} diff --git a/src/trust.rs b/src/trust.rs new file mode 100644 index 0000000..a2adc7b --- /dev/null +++ b/src/trust.rs @@ -0,0 +1,21 @@ +//! Compile-time trust configuration for OTA signing verification. +//! +//! These values cannot be changed via OTA — only by editing the source +//! and reflashing over USB. That's the whole point: a compromised +//! signing identity must not be able to update its own allowlist. + +/// Allowlist of (email, issuer) pairs the OTA verifier will accept as +/// signers. Identity comes from the Fulcio cert's SAN rfc822Name (email) +/// and the OIDC-issuer extension OID 1.3.6.1.4.1.57264.1.1 (issuer URL). +pub const TRUSTED_IDENTITIES: &[(&str, &str)] = &[ + ("imjasonh@gmail.com", "https://accounts.google.com"), +]; + +/// Sigstore Public Good Instance Fulcio intermediate CA (v1). Used to +/// verify the leaf signing cert was issued by Sigstore. +pub const SIGSTORE_INTERMEDIATE_PEM: &str = + include_str!("../trust/fulcio_intermediate.pem"); + +/// Sigstore Public Good Instance Fulcio root CA (v1). Used to verify +/// the bundled intermediate hasn't been swapped (defense in depth). +pub const SIGSTORE_ROOT_PEM: &str = include_str!("../trust/fulcio_root.pem"); diff --git a/trust/fulcio_intermediate.pem b/trust/fulcio_intermediate.pem new file mode 100644 index 0000000..6d1c298 --- /dev/null +++ b/trust/fulcio_intermediate.pem @@ -0,0 +1,14 @@ +-----BEGIN CERTIFICATE----- +MIICGjCCAaGgAwIBAgIUALnViVfnU0brJasmRkHrn/UnfaQwCgYIKoZIzj0EAwMw +KjEVMBMGA1UEChMMc2lnc3RvcmUuZGV2MREwDwYDVQQDEwhzaWdzdG9yZTAeFw0y +MjA0MTMyMDA2MTVaFw0zMTEwMDUxMzU2NThaMDcxFTATBgNVBAoTDHNpZ3N0b3Jl +LmRldjEeMBwGA1UEAxMVc2lnc3RvcmUtaW50ZXJtZWRpYXRlMHYwEAYHKoZIzj0C +AQYFK4EEACIDYgAE8RVS/ysH+NOvuDZyPIZtilgUF9NlarYpAd9HP1vBBH1U5CV7 +7LSS7s0ZiH4nE7Hv7ptS6LvvR/STk798LVgMzLlJ4HeIfF3tHSaexLcYpSASr1kS +0N/RgBJz/9jWCiXno3sweTAOBgNVHQ8BAf8EBAMCAQYwEwYDVR0lBAwwCgYIKwYB +BQUHAwMwEgYDVR0TAQH/BAgwBgEB/wIBADAdBgNVHQ4EFgQU39Ppz1YkEZb5qNjp +KFWixi4YZD8wHwYDVR0jBBgwFoAUWMAeX5FFpWapesyQoZMi0CrFxfowCgYIKoZI +zj0EAwMDZwAwZAIwPCsQK4DYiZYDPIaDi5HFKnfxXx6ASSVmERfsynYBiX2X6SJR +nZU84/9DZdnFvvxmAjBOt6QpBlc4J/0DxvkTCqpclvziL6BCCPnjdlIB3Pu3BxsP +mygUY7Ii2zbdCdliiow= +-----END CERTIFICATE----- \ No newline at end of file diff --git a/trust/fulcio_root.pem b/trust/fulcio_root.pem new file mode 100644 index 0000000..3afc46b --- /dev/null +++ b/trust/fulcio_root.pem @@ -0,0 +1,13 @@ +-----BEGIN CERTIFICATE----- +MIIB9zCCAXygAwIBAgIUALZNAPFdxHPwjeDloDwyYChAO/4wCgYIKoZIzj0EAwMw +KjEVMBMGA1UEChMMc2lnc3RvcmUuZGV2MREwDwYDVQQDEwhzaWdzdG9yZTAeFw0y +MTEwMDcxMzU2NTlaFw0zMTEwMDUxMzU2NThaMCoxFTATBgNVBAoTDHNpZ3N0b3Jl +LmRldjERMA8GA1UEAxMIc2lnc3RvcmUwdjAQBgcqhkjOPQIBBgUrgQQAIgNiAAT7 +XeFT4rb3PQGwS4IajtLk3/OlnpgangaBclYpsYBr5i+4ynB07ceb3LP0OIOZdxex +X69c5iVuyJRQ+Hz05yi+UF3uBWAlHpiS5sh0+H2GHE7SXrk1EC5m1Tr19L9gg92j +YzBhMA4GA1UdDwEB/wQEAwIBBjAPBgNVHRMBAf8EBTADAQH/MB0GA1UdDgQWBBRY +wB5fkUWlZql6zJChkyLQKsXF+jAfBgNVHSMEGDAWgBRYwB5fkUWlZql6zJChkyLQ +KsXF+jAKBggqhkjOPQQDAwNpADBmAjEAj1nHeXZp+13NWBNa+EDsDP8G1WWg1tCM +WP/WHPqpaVo0jhsweNFZgSs0eE7wYI4qAjEA2WB9ot98sIkoF3vZYdd3/VtWB5b9 +TNMea7Ix/stJ5TfcLLeABLE4BNJOsQ4vnBHJ +-----END CERTIFICATE----- \ No newline at end of file