1
0
Fork 0
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:
Jason Hall 2026-05-02 13:50:03 -04:00
parent 1ab146d685
commit 5be27a560c
10 changed files with 512 additions and 40 deletions

View file

@ -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"

View file

@ -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

View file

@ -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.

View file

@ -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

1 # Partition table for OTA. Two equal app slots (ota_0 / ota_1), no
4 #
5 # Flash layout (4 MB total):
6 # 0x001000-0x008FFF bootloader (~28 KB)
7 # 0x008000-0x008FFF partition table itself (4 KB; overlap with above is intentional in IDF layout) # 0x008000-0x008FFF partition table itself (4 KB)
8 # 0x009000-0x00EFFF nvs (24 KB)
9 # 0x00F000-0x010FFF otadata (8 KB) - which slot to boot
10 # 0x011000-0x011FFF phy_init (4 KB)
11 # 0x020000-0x19FFFF ota_0 (1.5 MB) # 0x020000-0x1DFFFF ota_0 (1.75 MB)
12 # 0x1A0000-0x31FFFF ota_1 (1.5 MB) # 0x1E0000-0x39FFFF ota_1 (1.75 MB)
13 # 0x320000-0x3FFFFF unused (~900 KB headroom) # 0x3A0000-0x3FFFFF unused (384 KB)
14 #
15 # Bumped from 1.5 MB slots to 1.75 MB when phase 4a (cosign signature
16 # verification) pushed the firmware past 1.5 MB. Repartitioning is a
17 # USB-only migration (`make flash-all`).
18 #
19 # Name, Type, SubType, Offset, Size
20 nvs, data, nvs, 0x9000, 0x6000
21 otadata, data, ota, 0xf000, 0x2000
22 phy_init, data, phy, 0x11000, 0x1000
23 ota_0, app, ota_0, 0x20000, 0x180000 ota_0, app, ota_0, 0x20000, 0x1C0000
24 ota_1, app, ota_1, 0x1A0000, 0x180000 ota_1, app, ota_1, 0x1E0000, 0x1C0000

View file

@ -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");

View file

@ -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
View 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
View 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");

View 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
View 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-----