From eed830e1ee5b9a020ea04101a0ac83e885ed721d Mon Sep 17 00:00:00 2001 From: archipelago Date: Sat, 4 Jul 2026 18:11:32 -0400 Subject: [PATCH] feat(security): enforce declared cosign image signatures at the pull sites MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New container::image_verify gates PodmanClient::pull_image and the dev-only DockerRuntime::pull_image. Signature claims classify three ways: absent/empty (pull unverified, logged), the literal 'cosign://...' placeholder every fleet manifest carries today (same — enforcement stays dormant until the signing ceremony ships real values), or a declared signature, which must verify via 'cosign verify --key /etc/archipelago/cosign.pub --insecure-ignore-tlog=true' (plus --allow-insecure-registry --allow-http-registry for the HTTP mirror; flags checked against cosign's own docs) before anything is fetched. Missing key, missing cosign binary, timeout, or verification failure all hard-fail the pull — a declared signature cannot be skipped on either runtime. Key path overridable via ARCHIPELAGO_COSIGN_PUBKEY for tests/staging. Deletes security::ImageVerifier: zero callers, blocking std::process::Command on would-be async paths, and a fantasy 'cosign verify --signature' invocation (that flag belongs to verify-blob). Activation ships with the Workstream B ceremony, in order: pin cosign.pub on nodes + install cosign, then publish real image_signature values in the catalog. Tests: archipelago-container 58/58 (5 new), archipelago container:: 159/159, security check clean. Co-Authored-By: Claude Fable 5 --- core/container/src/image_verify.rs | 207 +++++++++++++++++++++++++++ core/container/src/lib.rs | 1 + core/container/src/podman_client.rs | 12 +- core/container/src/runtime.rs | 7 +- core/security/src/image_verifier.rs | 111 -------------- core/security/src/lib.rs | 2 - docs/1.8.0-RELEASE-HARDENING-PLAN.md | 18 ++- 7 files changed, 238 insertions(+), 120 deletions(-) create mode 100644 core/container/src/image_verify.rs delete mode 100644 core/security/src/image_verifier.rs diff --git a/core/container/src/image_verify.rs b/core/container/src/image_verify.rs new file mode 100644 index 00000000..64b4e1f9 --- /dev/null +++ b/core/container/src/image_verify.rs @@ -0,0 +1,207 @@ +//! Container image signature verification (cosign). +//! +//! The manifest/catalog `image_signature` field is a *claim* that the image +//! is signed with the fleet's cosign key. Verification runs at the pull +//! choke points (`PodmanClient::pull_image`, `DockerRuntime::pull_image`); +//! a declared signature that cannot be verified hard-fails the pull. +//! +//! Every manifest has carried the literal placeholder `cosign://...` since +//! the field was introduced — that means "not signed yet" and is treated as +//! no claim, so enforcement stays dormant until the signing ceremony +//! publishes real signatures AND nodes carry the pinned cosign public key. +//! Ship order matters: key + cosign binary reach the fleet first, real +//! signature values in the catalog come after. + +use anyhow::{bail, Context, Result}; +use std::path::PathBuf; + +/// The literal placeholder every pre-ceremony manifest carries. +pub const SIGNATURE_PLACEHOLDER: &str = "cosign://..."; + +/// Env override for the pinned cosign public key path (tests, staging). +pub const COSIGN_PUBKEY_ENV: &str = "ARCHIPELAGO_COSIGN_PUBKEY"; + +const DEFAULT_PUBKEY_PATH: &str = "/etc/archipelago/cosign.pub"; + +const COSIGN_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(60); + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SignatureClaim { + /// No signature declared (field absent or empty). + None, + /// The literal `cosign://...` placeholder — manifest predates real signing. + Placeholder, + /// A real declared signature reference; MUST verify or the pull fails. + Declared(String), +} + +pub fn classify_signature(signature: Option<&str>) -> SignatureClaim { + match signature.map(str::trim) { + None | Some("") => SignatureClaim::None, + Some(SIGNATURE_PLACEHOLDER) => SignatureClaim::Placeholder, + Some(s) => SignatureClaim::Declared(s.to_string()), + } +} + +fn pinned_pubkey_path() -> PathBuf { + std::env::var(COSIGN_PUBKEY_ENV) + .map(PathBuf::from) + .unwrap_or_else(|_| PathBuf::from(DEFAULT_PUBKEY_PATH)) +} + +/// Verify a declared image signature with the fleet's pinned cosign key. +/// Any failure — missing key, missing cosign binary, verification error — +/// is a hard error: an image that CLAIMS to be signed must never be pulled +/// on a node that can't prove the claim. +pub async fn verify_declared_signature( + image: &str, + sig_ref: &str, + allow_insecure_registry: bool, +) -> Result<()> { + verify_with_key_path(image, sig_ref, &pinned_pubkey_path(), allow_insecure_registry).await +} + +async fn verify_with_key_path( + image: &str, + sig_ref: &str, + key_path: &std::path::Path, + allow_insecure_registry: bool, +) -> Result<()> { + if !key_path.exists() { + bail!( + "Image '{image}' declares signature '{sig_ref}' but the pinned cosign \ + public key is missing at {} (override with {COSIGN_PUBKEY_ENV}). \ + Refusing to pull an image whose signature claim cannot be verified.", + key_path.display() + ); + } + + // Self-managed key => signatures aren't in the public Rekor transparency + // log, so tlog verification must be disabled explicitly (cosign v2 + // defaults it on and would fail every private-key signature otherwise). + let mut cmd = tokio::process::Command::new("cosign"); + cmd.arg("verify") + .arg("--key") + .arg(key_path) + .arg("--insecure-ignore-tlog=true"); + if allow_insecure_registry { + // podman's --tls-verify=false covers both plain HTTP and bad TLS; + // cosign splits those into two flags — pass both to match. + cmd.arg("--allow-insecure-registry"); + cmd.arg("--allow-http-registry"); + } + cmd.arg(image); + + let output = tokio::time::timeout(COSIGN_TIMEOUT, cmd.output()) + .await + .map_err(|_| { + anyhow::anyhow!( + "cosign verify timed out after {}s for image '{image}'", + COSIGN_TIMEOUT.as_secs() + ) + })? + .with_context(|| { + format!( + "Failed to run cosign for image '{image}' which declares signature \ + '{sig_ref}' — is cosign installed? A declared signature cannot be \ + skipped." + ) + })?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + bail!( + "Signature verification FAILED for image '{image}' (declared: '{sig_ref}'): \ + {stderr}" + ); + } + + tracing::info!("cosign signature verified for image {image}"); + Ok(()) +} + +/// Shared pull-site gate: decide whether the pull may proceed. +/// Returns Ok(()) for unsigned/placeholder claims (with a log line) and only +/// after successful cosign verification for declared ones. +pub async fn enforce_signature_claim( + image: &str, + signature: Option<&str>, + allow_insecure_registry: bool, +) -> Result<()> { + match classify_signature(signature) { + SignatureClaim::None => { + tracing::debug!("image {image}: no signature declared, pulling unverified"); + Ok(()) + } + SignatureClaim::Placeholder => { + tracing::debug!( + "image {image}: signature is the pre-ceremony placeholder, pulling unverified" + ); + Ok(()) + } + SignatureClaim::Declared(sig_ref) => { + verify_declared_signature(image, &sig_ref, allow_insecure_registry).await + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn absent_and_empty_signatures_are_no_claim() { + assert_eq!(classify_signature(None), SignatureClaim::None); + assert_eq!(classify_signature(Some("")), SignatureClaim::None); + assert_eq!(classify_signature(Some(" ")), SignatureClaim::None); + } + + #[test] + fn literal_placeholder_is_not_a_claim() { + assert_eq!( + classify_signature(Some("cosign://...")), + SignatureClaim::Placeholder + ); + assert_eq!( + classify_signature(Some(" cosign://... ")), + SignatureClaim::Placeholder + ); + } + + #[test] + fn real_values_are_declared_claims() { + assert_eq!( + classify_signature(Some("cosign://sha256-abc.sig")), + SignatureClaim::Declared("cosign://sha256-abc.sig".to_string()) + ); + // Unknown schemes still count as a claim — better to fail closed on + // a value we don't understand than to pull unverified. + assert_eq!( + classify_signature(Some("sigstore://whatever")), + SignatureClaim::Declared("sigstore://whatever".to_string()) + ); + } + + #[tokio::test] + async fn declared_signature_without_pinned_key_hard_fails() { + let err = verify_with_key_path( + "registry.example/app:1.0", + "cosign://sha256-abc.sig", + std::path::Path::new("/nonexistent/cosign.pub"), + false, + ) + .await + .unwrap_err(); + assert!(err.to_string().contains("pinned cosign public key is missing")); + } + + #[tokio::test] + async fn unsigned_and_placeholder_claims_pass_the_gate() { + enforce_signature_claim("registry.example/app:1.0", None, false) + .await + .unwrap(); + enforce_signature_claim("registry.example/app:1.0", Some("cosign://..."), false) + .await + .unwrap(); + } +} diff --git a/core/container/src/lib.rs b/core/container/src/lib.rs index 05d337e6..02f01e23 100644 --- a/core/container/src/lib.rs +++ b/core/container/src/lib.rs @@ -1,5 +1,6 @@ pub mod bitcoin_simulator; pub mod health_monitor; +pub mod image_verify; pub mod manifest; pub mod podman_client; pub mod port_manager; diff --git a/core/container/src/podman_client.rs b/core/container/src/podman_client.rs index 927d409c..fb93058d 100644 --- a/core/container/src/podman_client.rs +++ b/core/container/src/podman_client.rs @@ -252,7 +252,17 @@ impl PodmanClient { // ─── Container Operations ──────────────────────────────────── - pub async fn pull_image(&self, image: &str, _signature: Option<&str>) -> Result<()> { + pub async fn pull_image(&self, image: &str, signature: Option<&str>) -> Result<()> { + // A declared (non-placeholder) signature must verify before we fetch + // anything; placeholder/absent claims pull unverified until the + // signing ceremony ships real signatures (see image_verify). + crate::image_verify::enforce_signature_claim( + image, + signature, + image_uses_insecure_registry(image), + ) + .await?; + // Image pull uses CLI — it's a streaming operation that the API handles differently let mut cmd = tokio::process::Command::new("podman"); cmd.arg("pull"); diff --git a/core/container/src/runtime.rs b/core/container/src/runtime.rs index 6f668534..97ca4f26 100644 --- a/core/container/src/runtime.rs +++ b/core/container/src/runtime.rs @@ -546,7 +546,12 @@ impl DockerRuntime { #[async_trait] impl ContainerRuntime for DockerRuntime { - async fn pull_image(&self, image: &str, _signature: Option<&str>) -> Result<()> { + async fn pull_image(&self, image: &str, signature: Option<&str>) -> Result<()> { + // Same signature gate as the podman path — the docker fallback is + // dev-only, but a declared signature must never be skippable by + // switching runtimes. + crate::image_verify::enforce_signature_claim(image, signature, false).await?; + let mut cmd = self.docker_async(); cmd.arg("pull").arg(image); diff --git a/core/security/src/image_verifier.rs b/core/security/src/image_verifier.rs deleted file mode 100644 index b6c6f2bf..00000000 --- a/core/security/src/image_verifier.rs +++ /dev/null @@ -1,111 +0,0 @@ -// Container image signature verification using Cosign -// Verifies that container images are signed and trusted - -use anyhow::{Context, Result}; -use std::process::Command; -use tracing::{info, warn}; - -pub struct ImageVerifier { - cosign_public_key: Option, - require_signatures: bool, -} - -impl ImageVerifier { - pub fn new(cosign_public_key: Option) -> Self { - Self { - cosign_public_key, - require_signatures: false, - } - } - - /// Create a verifier that requires all images to be signed. - pub fn new_strict(cosign_public_key: Option) -> Self { - Self { - cosign_public_key, - require_signatures: true, - } - } - - /// Verify a container image signature - pub async fn verify_image(&self, image: &str, signature: Option<&str>) -> Result { - if signature.is_none() && self.cosign_public_key.is_none() { - if self.require_signatures { - return Err(anyhow::anyhow!( - "Image '{}' has no signature and no cosign key is configured. \ - All container images must be signed for production use.", - image - )); - } - warn!("No signature provided for image: {}", image); - return Ok(false); - } - - // Check if cosign is available - let cosign_available = Command::new("cosign").arg("version").output().is_ok(); - - if !cosign_available { - if self.require_signatures { - return Err(anyhow::anyhow!( - "Cosign binary not found. Install cosign to verify container image signatures." - )); - } - warn!("Cosign not available, skipping signature verification"); - return Ok(false); - } - - // If public key is provided, use it for verification - if let Some(ref public_key) = self.cosign_public_key { - let output = Command::new("cosign") - .arg("verify") - .arg("--key") - .arg(public_key) - .arg(image) - .output() - .context("Failed to run cosign verify")?; - - if output.status.success() { - info!("Image signature verified: {}", image); - return Ok(true); - } else { - let stderr = String::from_utf8_lossy(&output.stderr); - return Err(anyhow::anyhow!("Signature verification failed: {}", stderr)); - } - } - - // If signature URL is provided, verify using that - if let Some(sig_url) = signature { - if sig_url.starts_with("cosign://") { - // Extract signature reference - let sig_ref = sig_url.strip_prefix("cosign://").unwrap(); - let output = Command::new("cosign") - .arg("verify") - .arg("--signature") - .arg(sig_ref) - .arg(image) - .output() - .context("Failed to run cosign verify")?; - - if output.status.success() { - info!("Image signature verified: {}", image); - return Ok(true); - } else { - let stderr = String::from_utf8_lossy(&output.stderr); - return Err(anyhow::anyhow!("Signature verification failed: {}", stderr)); - } - } - } - - Ok(false) - } - - /// Check if an image has a signature - pub async fn has_signature(&self, image: &str) -> bool { - // Try to find signature in registry - let output = Command::new("cosign") - .arg("triangulate") - .arg(image) - .output(); - - output.is_ok() && output.unwrap().status.success() - } -} diff --git a/core/security/src/lib.rs b/core/security/src/lib.rs index 48134224..a64872c1 100644 --- a/core/security/src/lib.rs +++ b/core/security/src/lib.rs @@ -1,7 +1,5 @@ pub mod container_policies; -pub mod image_verifier; pub mod secrets_manager; pub use container_policies::ContainerPolicyGenerator; -pub use image_verifier::ImageVerifier; pub use secrets_manager::SecretsManager; diff --git a/docs/1.8.0-RELEASE-HARDENING-PLAN.md b/docs/1.8.0-RELEASE-HARDENING-PLAN.md index efc2b125..dd9ab0cd 100644 --- a/docs/1.8.0-RELEASE-HARDENING-PLAN.md +++ b/docs/1.8.0-RELEASE-HARDENING-PLAN.md @@ -58,11 +58,19 @@ arbitrary app catalog to the entire fleet — fully unattended under `scripts/sign-manifest.sh` exists for re-signs. **Still open:** move the mirror to HTTPS + pinned cert (tracked with the next item); flip unsigned-manual-apply → hard-reject once the fleet is on a pinned-anchor binary. -- [ ] 🔴 **Implement container image signature verification (cosign).** - `container/src/podman_client.rs:255` — `pull_image(.., _signature)` silently discards - the signature that the manifest threads all the way down - (`prod_orchestrator.rs:1978/2435`). Wire `sigstore-rs`/`cosign verify` (or - `podman pull --signature-policy`); hard-fail when a declared signature doesn't verify. +- [x] 🔴 **Implement container image signature verification (cosign).** DONE 2026-07-04 + (code path; enforcement dormant until the ceremony): new `container::image_verify` + gates BOTH pull sites (`PodmanClient::pull_image` + the dev-only `DockerRuntime`). + Claims classify as None / the literal `cosign://...` placeholder (every fleet + manifest today → pull proceeds, logged) / Declared → `cosign verify --key + /etc/archipelago/cosign.pub --insecure-ignore-tlog=true` (+ both insecure-registry + flags for the HTTP mirror; flags verified against cosign docs), hard-fail on missing + key, missing cosign binary, timeout, or bad signature — a declared signature can + never be skipped, on either runtime. Key path overridable via + `ARCHIPELAGO_COSIGN_PUBKEY`. Deleted the caller-less, blocking, wrong-CLI + `security::ImageVerifier`. **Activation = ceremony work**: pin cosign.pub on nodes + + install cosign + publish real `image_signature` values (in that order); tracked with + the Workstream B signing ceremony item. - [ ] 🟠 **Move the image mirror to HTTPS; drop `--tls-verify=false`.** `podman_client.rs:641` `INSECURE_REGISTRY_HOSTS = ["146.59.87.168:3000"]` + `config.rs:104,124` allowlist pull images over unauthenticated HTTP. Remove the raw-IP