mirror of
https://github.com/imjasonh/esp32
synced 2026-07-06 23:52:24 +00:00
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) <noreply@anthropic.com>
This commit is contained in:
parent
1ab146d685
commit
5be27a560c
10 changed files with 512 additions and 40 deletions
|
|
@ -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"
|
||||
|
|
|
|||
3
Makefile
3
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
|
||||
|
|
|
|||
81
ota-plan.md
81
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-<digest>.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-<digest>.sig`. One signing per push covers both `:latest`
|
||||
and `:sha-<short>` 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-<digest>.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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
10
src/main.rs
10
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");
|
||||
|
||||
|
|
|
|||
131
src/ota.rs
131
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<NvsDefault>, cfg: &OtaConfig) -> Result<PollOutcome> {
|
||||
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<NvsDefault>, cfg: &OtaConfig) -> Result<PollOutcom
|
|||
bail!("unexpected layer mediaType: {}", layer.media_type);
|
||||
}
|
||||
tracing::info!(
|
||||
digest = %layer.digest,
|
||||
manifest_digest = %format!("sha256:{}", manifest_digest_hex),
|
||||
layer_digest = %layer.digest,
|
||||
size = layer.size,
|
||||
"ota: manifest layer",
|
||||
"ota: manifest",
|
||||
);
|
||||
|
||||
let last = read_string(nvs, NVS_LAST_DIGEST).unwrap_or_default();
|
||||
|
|
@ -196,9 +197,17 @@ fn poll_once(nvs: &mut EspNvs<NvsDefault>, cfg: &OtaConfig) -> Result<PollOutcom
|
|||
tracing::info!(
|
||||
previous = %if last.is_empty() { "<none>" } 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<String> {
|
|||
Ok(resp.token)
|
||||
}
|
||||
|
||||
fn fetch_manifest(repo: &str, tag: &str, token: &str) -> Result<Manifest> {
|
||||
/// 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<Manifest> {
|
|||
],
|
||||
&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-<hex>` and is an image
|
||||
/// index → inner manifest → bundle layer blob.
|
||||
fn fetch_signature_bundle(
|
||||
repo: &str,
|
||||
manifest_digest_hex: &str,
|
||||
token: &str,
|
||||
) -> Result<Vec<u8>> {
|
||||
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<IndexEntry>,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
struct IndexEntry {
|
||||
digest: String,
|
||||
}
|
||||
let inner_digest = if let Ok(idx) = serde_json::from_slice::<Index>(&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<Vec<u8>> {
|
||||
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<u8>) -> Result<()> {
|
||||
|
|
|
|||
258
src/sig.rs
Normal file
258
src/sig.rs
Normal file
|
|
@ -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<DsseSignature>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct DsseSignature {
|
||||
sig: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct InTotoStatement {
|
||||
subject: Vec<InTotoSubject>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct InTotoSubject {
|
||||
digest: serde_json::Map<String, serde_json::Value>,
|
||||
}
|
||||
|
||||
/// 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 <len(t)> <t> <len(p)> <p>"
|
||||
fn pae_dsse_v1(payload_type: &str, payload: &[u8]) -> Vec<u8> {
|
||||
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<String> {
|
||||
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<String> {
|
||||
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<Certificate> {
|
||||
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())
|
||||
}
|
||||
21
src/trust.rs
Normal file
21
src/trust.rs
Normal file
|
|
@ -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");
|
||||
14
trust/fulcio_intermediate.pem
Normal file
14
trust/fulcio_intermediate.pem
Normal file
|
|
@ -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-----
|
||||
13
trust/fulcio_root.pem
Normal file
13
trust/fulcio_root.pem
Normal file
|
|
@ -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-----
|
||||
Loading…
Add table
Add a link
Reference in a new issue