diff --git a/src/cli/src/commands/create.rs b/src/cli/src/commands/create.rs index 4f0ee51..ac4ce80 100644 --- a/src/cli/src/commands/create.rs +++ b/src/cli/src/commands/create.rs @@ -157,14 +157,17 @@ pub async fn execute(args: CreateArgs) -> Result<(), Box> }; let record_for_cleanup = record.clone(); - let mut state = StateFile::load_default()?; - if let Err(error) = state.add(record) { + // Atomic append under the state lock so concurrent `create`/`run` cannot + // lose records (load_default()+add() is a lost-update race). + if let Err(error) = StateFile::add_record(record) { + let mut state = StateFile::load_default()?; crate::cleanup::cleanup_partial_box_record(&record_for_cleanup, Some(&mut state)); return Err(error.into()); } // Attach named volumes to this box if let Err(error) = super::volume::attach_volumes(&volume_names, &box_id) { + let mut state = StateFile::load_default()?; crate::cleanup::cleanup_partial_box_record(&record_for_cleanup, Some(&mut state)); return Err(error); } diff --git a/src/cli/src/commands/monitor.rs b/src/cli/src/commands/monitor.rs index 269c22f..db82bd6 100644 --- a/src/cli/src/commands/monitor.rs +++ b/src/cli/src/commands/monitor.rs @@ -153,7 +153,7 @@ pub async fn execute(args: MonitorArgs) -> Result<(), Box /// Single poll iteration: load state, find dead boxes, restart eligible ones. /// Also checks for unhealthy boxes that have a restart policy. async fn poll_once(tracker: &mut BackoffTracker) -> Result<(), Box> { - let mut state = StateFile::load_default()?; + let state = StateFile::load_default()?; // Track active boxes for stability detection. for record in state.records() { @@ -162,7 +162,7 @@ async fn poll_once(tracker: &mut BackoffTracker) -> Result<(), Box Result<(), Box(()) + })?; } else { tracker.mark_dead(&box_id); println!("{}", restart_log_line(&record, RestartReason::Dead)); @@ -214,15 +217,20 @@ async fn poll_once(tracker: &mut BackoffTracker) -> Result<(), Box { - // Update record to running - if let Some(rec) = state.find_by_id_mut(&box_id) { - boot::apply_boot_result(rec, result, boot::RestartCountUpdate::Increment); - } - state.save()?; + // Re-load fresh under the lock and apply only this box's + // restart fields, returning the new restart count for logging. + let new_count = StateFile::modify(|s| { + let count = if let Some(rec) = s.find_by_id_mut(&box_id) { + boot::apply_boot_result(rec, result, boot::RestartCountUpdate::Increment); + rec.restart_count + } else { + 0 + }; + Ok::(count) + })?; tracker.record_attempt(&box_id); println!( - "monitor: box {name} ({short_id}) restarted (count: {})", - state.find_by_id(&box_id).map_or(0, |r| r.restart_count), + "monitor: box {name} ({short_id}) restarted (count: {new_count})", name = record.name, short_id = record.short_id, ); @@ -288,7 +296,7 @@ fn format_exit_code(exit_code: Option) -> String { } #[cfg(not(windows))] -async fn run_due_health_checks(state: &mut StateFile) -> Result<(), Box> { +async fn run_due_health_checks(state: &StateFile) -> Result<(), Box> { let now = chrono::Utc::now(); let probes: Vec<_> = state .records() @@ -316,17 +324,21 @@ async fn run_due_health_checks(state: &mut StateFile) -> Result<(), Box(()) + })?; } - state.save()?; Ok(()) } #[cfg(windows)] -async fn run_due_health_checks(_state: &mut StateFile) -> Result<(), Box> { +async fn run_due_health_checks(_state: &StateFile) -> Result<(), Box> { Ok(()) } diff --git a/src/cli/src/commands/rm.rs b/src/cli/src/commands/rm.rs index 34a89ae..0716e76 100644 --- a/src/cli/src/commands/rm.rs +++ b/src/cli/src/commands/rm.rs @@ -62,8 +62,9 @@ fn rm_one( let name = record.name.clone(); cleanup::cleanup_removed_box(&record); - // Remove from state - state.remove(&box_id)?; + // Remove from state atomically under the lock (avoids clobbering concurrent + // monitor/CLI writers that rewrite the whole record vector). + StateFile::remove_record(&box_id)?; println!("{name}"); Ok(()) diff --git a/src/cli/src/health.rs b/src/cli/src/health.rs index 74e62c1..4d23c69 100644 --- a/src/cli/src/health.rs +++ b/src/cli/src/health.rs @@ -64,19 +64,23 @@ async fn run_health_loop(box_id: String, exec_socket_path: PathBuf, hc: HealthCh let healthy = run_probe(&exec_socket_path, &hc.cmd, timeout_ns).await; - let Ok(mut state) = StateFile::load_default() else { - continue; - }; - let Some(record) = state.find_by_id_mut(&box_id) else { - break; // Box removed from state - }; - if record.status != "running" { - break; + // Reload fresh under the state lock and apply ONLY this box's health + // fields, so concurrent monitor/CLI writers are not clobbered. + let keep_going = StateFile::modify(|state| { + let Some(record) = state.find_by_id_mut(&box_id) else { + return Ok::(false); // box removed + }; + if record.status != "running" { + return Ok(false); // box stopped + } + apply_probe_result(record, healthy, chrono::Utc::now()); + Ok(true) + }); + match keep_going { + Ok(true) => {} + Ok(false) => break, + Err(_) => continue, } - - apply_probe_result(record, healthy, chrono::Utc::now()); - - let _ = state.save(); } } diff --git a/src/cli/src/state/file.rs b/src/cli/src/state/file.rs index 616c973..62be7f6 100644 --- a/src/cli/src/state/file.rs +++ b/src/cli/src/state/file.rs @@ -41,8 +41,16 @@ impl StateFile { Self::load(&home.join("boxes.json")) } - /// Save state to disk atomically (write to .tmp, then rename). + /// Save state to disk atomically under the cross-process state lock. pub fn save(&self) -> Result<(), std::io::Error> { + let _lock = super::lock::StateLock::acquire()?; + self.write_to_disk() + } + + /// Atomic write (tmp + rename) WITHOUT taking the state lock. Callers that + /// already hold the lock (`save`, `modify`, and `reconcile` which runs + /// inside `load`) use this to avoid re-locking (`flock` is not reentrant). + fn write_to_disk(&self) -> Result<(), std::io::Error> { let data = serde_json::to_string_pretty(&self.records).map_err(std::io::Error::other)?; let tmp_path = self.path.with_extension("json.tmp"); std::fs::write(&tmp_path, &data)?; @@ -50,6 +58,46 @@ impl StateFile { Ok(()) } + /// Atomically apply `f` to the on-disk state under the exclusive + /// cross-process lock: load fresh → mutate → save, all while the lock is + /// held. This is the race-free read-modify-write primitive — every writer + /// should mutate through it (or, for async work, snapshot inputs before the + /// await and call `modify` afterward to re-apply only its owned fields), so + /// the monitor/compose/health/CLI cannot clobber each other. + /// + /// `f` MUST be synchronous and MUST NOT `.await` (holding an OS lock across + /// a task yield would serialize or deadlock the async runtime). + pub fn modify(f: impl FnOnce(&mut StateFile) -> Result) -> Result + where + E: From, + { + let _lock = super::lock::StateLock::acquire()?; + let mut sf = Self::load_default()?; + let out = f(&mut sf)?; + sf.write_to_disk()?; + Ok(out) + } + + /// Append a record atomically under the state lock (load fresh → push → + /// save). Use this instead of `load_default()? + add()` so concurrent + /// appends/removals cannot lose records. + pub fn add_record(record: BoxRecord) -> Result<(), std::io::Error> { + Self::modify(|sf| { + sf.records.push(record); + Ok::<(), std::io::Error>(()) + }) + } + + /// Remove a record by id atomically under the state lock. Returns whether a + /// record was removed. + pub fn remove_record(id: &str) -> Result { + Self::modify(|sf| { + let before = sf.records.len(); + sf.records.retain(|r| r.id != id); + Ok::(sf.records.len() < before) + }) + } + /// Add a record and persist. pub fn add(&mut self, record: BoxRecord) -> Result<(), std::io::Error> { self.records.push(record); @@ -158,7 +206,9 @@ impl StateFile { } if changed { - let _ = self.save(); + // reconcile runs inside `load`, which `modify` calls while holding + // the state lock; use the unlocked write to avoid re-locking. + let _ = self.write_to_disk(); } restart_candidates diff --git a/src/cli/src/state/lock.rs b/src/cli/src/state/lock.rs new file mode 100644 index 0000000..75d300c --- /dev/null +++ b/src/cli/src/state/lock.rs @@ -0,0 +1,49 @@ +//! Cross-process advisory lock for the box state file. + +/// RAII exclusive advisory lock guarding `boxes.json` mutations. +/// +/// Held for the duration of a [`StateFile::modify`](super::StateFile::modify) +/// (and each [`save`](super::StateFile::save)) so concurrent processes — the +/// `monitor` daemon, `compose`, per-box health checkers, and plain CLI +/// commands — cannot interleave a read-modify-write and clobber each other's +/// fields (`save` rewrites the whole record vector). +/// +/// The lock lives on a sibling `boxes.json.lock` file, never on `boxes.json` +/// itself (whose atomic tmp+rename would swap the inode out from under a held +/// lock). `flock` is released automatically when the holder exits or crashes, +/// so a killed monitor/CLI never leaves a stale lock. +pub(crate) struct StateLock { + #[cfg(unix)] + _file: std::fs::File, +} + +impl StateLock { + /// Acquire the exclusive advisory lock, blocking until it is available. + #[cfg(unix)] + pub(crate) fn acquire() -> std::io::Result { + use std::os::unix::io::AsRawFd; + + let path = a3s_box_core::dirs_home().join("boxes.json.lock"); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + let file = std::fs::OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(false) + .open(&path)?; + // Blocking exclusive advisory lock; released when `file` drops. + if unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX) } != 0 { + return Err(std::io::Error::last_os_error()); + } + Ok(Self { _file: file }) + } + + /// Non-Unix fallback: the atomic tmp+rename in `save` still prevents torn + /// reads; multi-writer concurrency is not a supported Windows scenario. + #[cfg(not(unix))] + pub(crate) fn acquire() -> std::io::Result { + Ok(Self {}) + } +} diff --git a/src/cli/src/state/mod.rs b/src/cli/src/state/mod.rs index a7eec9a..93a2c4b 100644 --- a/src/cli/src/state/mod.rs +++ b/src/cli/src/state/mod.rs @@ -4,6 +4,7 @@ //! On every load, dead active PIDs are reconciled to mark boxes as dead. mod file; +mod lock; pub(crate) mod policy; #[cfg(test)] mod tests; diff --git a/src/cli/tests/core_smoke.rs b/src/cli/tests/core_smoke.rs index 2e6739b..e7477a7 100644 --- a/src/cli/tests/core_smoke.rs +++ b/src/cli/tests/core_smoke.rs @@ -324,7 +324,7 @@ impl CoreSmoke { fn open_pty() -> Result<(RawFd, RawFd), String> { let mut master: libc::c_int = -1; let mut slave: libc::c_int = -1; - let mut winsize = libc::winsize { + let winsize = libc::winsize { ws_row: 24, ws_col: 80, ws_xpixel: 0, @@ -337,7 +337,7 @@ impl CoreSmoke { &mut slave, std::ptr::null_mut(), std::ptr::null_mut(), - &mut winsize, + &winsize, ) }; if rc != 0 { diff --git a/src/runtime/src/oci/signing/crypto.rs b/src/runtime/src/oci/signing/crypto.rs new file mode 100644 index 0000000..f089ea0 --- /dev/null +++ b/src/runtime/src/oci/signing/crypto.rs @@ -0,0 +1,243 @@ +//! Cryptographic primitives and Fulcio X.509 helpers for image signature verification. + +use base64::Engine; +use der::Decode; + +use super::FULCIO_ISSUER_OID; + +/// Verify the OIDC issuer in a Fulcio certificate matches the expected value. +/// +/// The issuer is stored in a custom X.509 extension with OID 1.3.6.1.4.1.57264.1.1. +pub(super) fn verify_fulcio_issuer( + cert: &x509_cert::Certificate, + expected_issuer: &str, +) -> std::result::Result<(), String> { + let issuer_oid = der::asn1::ObjectIdentifier::new(FULCIO_ISSUER_OID) + .map_err(|e| format!("Failed to construct Fulcio issuer OID: {}", e))?; + + let extensions = cert + .tbs_certificate + .extensions + .as_ref() + .ok_or("Certificate has no extensions")?; + + for ext in extensions.iter() { + if ext.extn_id == issuer_oid { + // The extension value is a DER-encoded UTF8String or OCTET STRING containing the issuer + let issuer_value = + if let Ok(utf8) = der::asn1::Utf8StringRef::from_der(ext.extn_value.as_bytes()) { + utf8.to_string() + } else { + // Fallback: treat as raw UTF-8 bytes + String::from_utf8(ext.extn_value.as_bytes().to_vec()) + .map_err(|e| format!("Fulcio issuer extension is not valid UTF-8: {}", e))? + }; + + if issuer_value == expected_issuer { + return Ok(()); + } else { + return Err(format!( + "expected '{}', got '{}'", + expected_issuer, issuer_value + )); + } + } + } + + Err("Fulcio issuer extension (OID 1.3.6.1.4.1.57264.1.1) not found in certificate".into()) +} + +/// Verify the identity (email or URI) in a Fulcio certificate's Subject Alternative Name. +pub(super) fn verify_fulcio_identity( + cert: &x509_cert::Certificate, + expected_identity: &str, +) -> std::result::Result<(), String> { + use x509_cert::ext::pkix::SubjectAltName; + + let extensions = cert + .tbs_certificate + .extensions + .as_ref() + .ok_or("Certificate has no extensions")?; + + // Find the SAN extension (OID 2.5.29.17) + let san_oid = der::asn1::ObjectIdentifier::new("2.5.29.17") + .map_err(|e| format!("Failed to construct SAN OID: {}", e))?; + + for ext in extensions.iter() { + if ext.extn_id == san_oid { + let san = SubjectAltName::from_der(ext.extn_value.as_bytes()) + .map_err(|e| format!("Failed to parse SAN extension: {}", e))?; + + for name in san.0.iter() { + match name { + x509_cert::ext::pkix::name::GeneralName::Rfc822Name(email) => { + let email_str: &str = email.as_ref(); + if email_str == expected_identity { + return Ok(()); + } + } + x509_cert::ext::pkix::name::GeneralName::UniformResourceIdentifier(uri) => { + let uri_str: &str = uri.as_ref(); + if uri_str == expected_identity { + return Ok(()); + } + } + _ => continue, + } + } + + // Collect found identities for error message + let found: Vec = san + .0 + .iter() + .filter_map(|n| match n { + x509_cert::ext::pkix::name::GeneralName::Rfc822Name(e) => Some(e.to_string()), + x509_cert::ext::pkix::name::GeneralName::UniformResourceIdentifier(u) => { + Some(u.to_string()) + } + _ => None, + }) + .collect(); + + return Err(format!( + "expected '{}', found [{}]", + expected_identity, + found.join(", ") + )); + } + } + + Err("Subject Alternative Name extension not found in certificate".into()) +} + +/// Extract the public key bytes (SEC1 uncompressed point) from an X.509 certificate. +pub(super) fn extract_cert_public_key(cert: &x509_cert::Certificate) -> std::result::Result, String> { + cert.tbs_certificate + .subject_public_key_info + .subject_public_key + .as_bytes() + .map(|b| b.to_vec()) + .ok_or_else(|| "Failed to extract public key bytes from certificate".to_string()) +} + +/// Decode a PEM block into DER bytes. +pub(super) fn pem_to_der(pem_str: &str) -> std::result::Result, String> { + // Find the first PEM block + let begin = pem_str + .find("-----BEGIN ") + .ok_or("No PEM begin marker found")?; + let begin_end = pem_str[begin..] + .find("-----\n") + .or_else(|| pem_str[begin..].find("-----\r\n")) + .ok_or("Malformed PEM begin marker")? + + begin + + 6; // skip past "-----\n" + + let end = pem_str[begin_end..] + .find("-----END ") + .ok_or("No PEM end marker found")? + + begin_end; + + let b64: String = pem_str[begin_end..end] + .chars() + .filter(|c| !c.is_whitespace()) + .collect(); + + base64_decode(&b64).map_err(|e| format!("Failed to decode PEM base64: {}", e)) +} +/// Parse a PEM-encoded public key (ECDSA P-256) into raw SEC1 bytes. +/// +/// Supports both "PUBLIC KEY" (SPKI/PKIX) and "EC PUBLIC KEY" (SEC1) PEM formats. +pub(super) fn parse_pem_public_key(pem_bytes: &[u8]) -> std::result::Result, String> { + let pem_str = std::str::from_utf8(pem_bytes) + .map_err(|e| format!("PEM file is not valid UTF-8: {}", e))?; + + // Extract the base64 content between PEM headers + let (begin_marker, end_marker) = if pem_str.contains("BEGIN PUBLIC KEY") { + ("-----BEGIN PUBLIC KEY-----", "-----END PUBLIC KEY-----") + } else if pem_str.contains("BEGIN EC PUBLIC KEY") { + ( + "-----BEGIN EC PUBLIC KEY-----", + "-----END EC PUBLIC KEY-----", + ) + } else { + return Err("Unsupported PEM format: expected PUBLIC KEY or EC PUBLIC KEY".to_string()); + }; + + let start = pem_str + .find(begin_marker) + .ok_or("Missing PEM begin marker")? + + begin_marker.len(); + let end = pem_str.find(end_marker).ok_or("Missing PEM end marker")?; + + let b64: String = pem_str[start..end] + .chars() + .filter(|c| !c.is_whitespace()) + .collect(); + + let der_bytes = + base64_decode(&b64).map_err(|e| format!("Failed to decode PEM base64: {}", e))?; + + // If SPKI format, extract the public key bytes from the SubjectPublicKeyInfo structure + if begin_marker.contains("BEGIN PUBLIC KEY") { + extract_spki_public_key(&der_bytes) + } else { + // SEC1 format — the DER bytes are the raw EC point + Ok(der_bytes) + } +} + +/// Extract the public key bytes from a DER-encoded SubjectPublicKeyInfo. +pub(super) fn extract_spki_public_key(der: &[u8]) -> std::result::Result, String> { + use spki::SubjectPublicKeyInfo; + + let spki = + SubjectPublicKeyInfo::, der::asn1::BitStringRef<'_>>::from_der(der) + .map_err(|e| format!("Failed to parse SPKI: {}", e))?; + + spki.subject_public_key + .as_bytes() + .map(|b| b.to_vec()) + .ok_or_else(|| "Failed to extract public key bytes from SPKI".to_string()) +} + +/// Verify an ECDSA P-256 signature over a message. +pub(super) fn verify_ecdsa_p256( + public_key_bytes: &[u8], + message: &[u8], + signature: &[u8], +) -> std::result::Result<(), String> { + use p256::ecdsa::{signature::Verifier, Signature, VerifyingKey}; + + let verifying_key = VerifyingKey::from_sec1_bytes(public_key_bytes) + .map_err(|e| format!("Invalid P-256 public key: {}", e))?; + + // Cosign produces DER-encoded signatures. Try DER first, then fixed-size. + let result = if let Ok(sig) = p256::ecdsa::DerSignature::from_bytes(signature) { + verifying_key.verify(message, &sig) + } else if signature.len() == 64 { + let sig = Signature::from_slice(signature) + .map_err(|e| format!("Invalid P-256 signature: {}", e))?; + verifying_key.verify(message, &sig) + } else { + return Err(format!( + "Unrecognized signature format ({} bytes)", + signature.len() + )); + }; + + result.map_err(|_| "ECDSA P-256 signature verification failed".to_string()) +} + +/// Decode a base64 string (standard alphabet with padding). +pub(super) fn base64_decode(input: &str) -> std::result::Result, String> { + base64::engine::general_purpose::STANDARD + .decode(input.trim()) + .map_err(|e| format!("base64 decode error: {}", e)) +} + +/// Encode bytes to base64 (standard alphabet with padding). +pub(super) fn base64_encode(input: &[u8]) -> String { + base64::engine::general_purpose::STANDARD.encode(input) +} diff --git a/src/runtime/src/oci/signing.rs b/src/runtime/src/oci/signing/mod.rs similarity index 71% rename from src/runtime/src/oci/signing.rs rename to src/runtime/src/oci/signing/mod.rs index 0ed9d00..0b02619 100644 --- a/src/runtime/src/oci/signing.rs +++ b/src/runtime/src/oci/signing/mod.rs @@ -5,6 +5,9 @@ //! - Keyless: verify Fulcio certificate identity (OIDC issuer + SAN) and signature use a3s_box_core::error::{BoxError, Result}; +// `base64::Engine` is only needed by the test module now that the base64 +// helpers moved to `crypto`; `der::Decode` is still used by cert parsing here. +#[cfg(test)] use base64::Engine; use der::Decode; use oci_distribution::client::ClientConfig; @@ -13,6 +16,15 @@ use oci_distribution::secrets::RegistryAuth; use oci_distribution::{Client, Reference}; use serde::{Deserialize, Serialize}; +mod crypto; +mod sign; + +use crypto::*; +pub use sign::{sign_image, SignResult}; +#[cfg(test)] +use sign::{extract_pem_content, parse_pem_private_key}; + + /// Image signature verification policy. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] pub enum SignaturePolicy { @@ -447,147 +459,6 @@ async fn verify_cosign_keyless( /// Fulcio OIDC issuer extension OID: 1.3.6.1.4.1.57264.1.1 const FULCIO_ISSUER_OID: &str = "1.3.6.1.4.1.57264.1.1"; -/// Verify the OIDC issuer in a Fulcio certificate matches the expected value. -/// -/// The issuer is stored in a custom X.509 extension with OID 1.3.6.1.4.1.57264.1.1. -fn verify_fulcio_issuer( - cert: &x509_cert::Certificate, - expected_issuer: &str, -) -> std::result::Result<(), String> { - let issuer_oid = der::asn1::ObjectIdentifier::new(FULCIO_ISSUER_OID) - .map_err(|e| format!("Failed to construct Fulcio issuer OID: {}", e))?; - - let extensions = cert - .tbs_certificate - .extensions - .as_ref() - .ok_or("Certificate has no extensions")?; - - for ext in extensions.iter() { - if ext.extn_id == issuer_oid { - // The extension value is a DER-encoded UTF8String or OCTET STRING containing the issuer - let issuer_value = - if let Ok(utf8) = der::asn1::Utf8StringRef::from_der(ext.extn_value.as_bytes()) { - utf8.to_string() - } else { - // Fallback: treat as raw UTF-8 bytes - String::from_utf8(ext.extn_value.as_bytes().to_vec()) - .map_err(|e| format!("Fulcio issuer extension is not valid UTF-8: {}", e))? - }; - - if issuer_value == expected_issuer { - return Ok(()); - } else { - return Err(format!( - "expected '{}', got '{}'", - expected_issuer, issuer_value - )); - } - } - } - - Err("Fulcio issuer extension (OID 1.3.6.1.4.1.57264.1.1) not found in certificate".into()) -} - -/// Verify the identity (email or URI) in a Fulcio certificate's Subject Alternative Name. -fn verify_fulcio_identity( - cert: &x509_cert::Certificate, - expected_identity: &str, -) -> std::result::Result<(), String> { - use x509_cert::ext::pkix::SubjectAltName; - - let extensions = cert - .tbs_certificate - .extensions - .as_ref() - .ok_or("Certificate has no extensions")?; - - // Find the SAN extension (OID 2.5.29.17) - let san_oid = der::asn1::ObjectIdentifier::new("2.5.29.17") - .map_err(|e| format!("Failed to construct SAN OID: {}", e))?; - - for ext in extensions.iter() { - if ext.extn_id == san_oid { - let san = SubjectAltName::from_der(ext.extn_value.as_bytes()) - .map_err(|e| format!("Failed to parse SAN extension: {}", e))?; - - for name in san.0.iter() { - match name { - x509_cert::ext::pkix::name::GeneralName::Rfc822Name(email) => { - let email_str: &str = email.as_ref(); - if email_str == expected_identity { - return Ok(()); - } - } - x509_cert::ext::pkix::name::GeneralName::UniformResourceIdentifier(uri) => { - let uri_str: &str = uri.as_ref(); - if uri_str == expected_identity { - return Ok(()); - } - } - _ => continue, - } - } - - // Collect found identities for error message - let found: Vec = san - .0 - .iter() - .filter_map(|n| match n { - x509_cert::ext::pkix::name::GeneralName::Rfc822Name(e) => Some(e.to_string()), - x509_cert::ext::pkix::name::GeneralName::UniformResourceIdentifier(u) => { - Some(u.to_string()) - } - _ => None, - }) - .collect(); - - return Err(format!( - "expected '{}', found [{}]", - expected_identity, - found.join(", ") - )); - } - } - - Err("Subject Alternative Name extension not found in certificate".into()) -} - -/// Extract the public key bytes (SEC1 uncompressed point) from an X.509 certificate. -fn extract_cert_public_key(cert: &x509_cert::Certificate) -> std::result::Result, String> { - cert.tbs_certificate - .subject_public_key_info - .subject_public_key - .as_bytes() - .map(|b| b.to_vec()) - .ok_or_else(|| "Failed to extract public key bytes from certificate".to_string()) -} - -/// Decode a PEM block into DER bytes. -fn pem_to_der(pem_str: &str) -> std::result::Result, String> { - // Find the first PEM block - let begin = pem_str - .find("-----BEGIN ") - .ok_or("No PEM begin marker found")?; - let begin_end = pem_str[begin..] - .find("-----\n") - .or_else(|| pem_str[begin..].find("-----\r\n")) - .ok_or("Malformed PEM begin marker")? - + begin - + 6; // skip past "-----\n" - - let end = pem_str[begin_end..] - .find("-----END ") - .ok_or("No PEM end marker found")? - + begin_end; - - let b64: String = pem_str[begin_end..end] - .chars() - .filter(|c| !c.is_whitespace()) - .collect(); - - base64_decode(&b64).map_err(|e| format!("Failed to decode PEM base64: {}", e)) -} /// Cosign signature envelope stored in the OCI layer. #[derive(Debug, Serialize, Deserialize)] @@ -598,282 +469,6 @@ struct CosignSignatureEnvelope { signature: String, } -/// Parse a PEM-encoded public key (ECDSA P-256) into raw SEC1 bytes. -/// -/// Supports both "PUBLIC KEY" (SPKI/PKIX) and "EC PUBLIC KEY" (SEC1) PEM formats. -fn parse_pem_public_key(pem_bytes: &[u8]) -> std::result::Result, String> { - let pem_str = std::str::from_utf8(pem_bytes) - .map_err(|e| format!("PEM file is not valid UTF-8: {}", e))?; - - // Extract the base64 content between PEM headers - let (begin_marker, end_marker) = if pem_str.contains("BEGIN PUBLIC KEY") { - ("-----BEGIN PUBLIC KEY-----", "-----END PUBLIC KEY-----") - } else if pem_str.contains("BEGIN EC PUBLIC KEY") { - ( - "-----BEGIN EC PUBLIC KEY-----", - "-----END EC PUBLIC KEY-----", - ) - } else { - return Err("Unsupported PEM format: expected PUBLIC KEY or EC PUBLIC KEY".to_string()); - }; - - let start = pem_str - .find(begin_marker) - .ok_or("Missing PEM begin marker")? - + begin_marker.len(); - let end = pem_str.find(end_marker).ok_or("Missing PEM end marker")?; - - let b64: String = pem_str[start..end] - .chars() - .filter(|c| !c.is_whitespace()) - .collect(); - - let der_bytes = - base64_decode(&b64).map_err(|e| format!("Failed to decode PEM base64: {}", e))?; - - // If SPKI format, extract the public key bytes from the SubjectPublicKeyInfo structure - if begin_marker.contains("BEGIN PUBLIC KEY") { - extract_spki_public_key(&der_bytes) - } else { - // SEC1 format — the DER bytes are the raw EC point - Ok(der_bytes) - } -} - -/// Extract the public key bytes from a DER-encoded SubjectPublicKeyInfo. -fn extract_spki_public_key(der: &[u8]) -> std::result::Result, String> { - use spki::SubjectPublicKeyInfo; - - let spki = - SubjectPublicKeyInfo::, der::asn1::BitStringRef<'_>>::from_der(der) - .map_err(|e| format!("Failed to parse SPKI: {}", e))?; - - spki.subject_public_key - .as_bytes() - .map(|b| b.to_vec()) - .ok_or_else(|| "Failed to extract public key bytes from SPKI".to_string()) -} - -/// Verify an ECDSA P-256 signature over a message. -fn verify_ecdsa_p256( - public_key_bytes: &[u8], - message: &[u8], - signature: &[u8], -) -> std::result::Result<(), String> { - use p256::ecdsa::{signature::Verifier, Signature, VerifyingKey}; - - let verifying_key = VerifyingKey::from_sec1_bytes(public_key_bytes) - .map_err(|e| format!("Invalid P-256 public key: {}", e))?; - - // Cosign produces DER-encoded signatures. Try DER first, then fixed-size. - let result = if let Ok(sig) = p256::ecdsa::DerSignature::from_bytes(signature) { - verifying_key.verify(message, &sig) - } else if signature.len() == 64 { - let sig = Signature::from_slice(signature) - .map_err(|e| format!("Invalid P-256 signature: {}", e))?; - verifying_key.verify(message, &sig) - } else { - return Err(format!( - "Unrecognized signature format ({} bytes)", - signature.len() - )); - }; - - result.map_err(|_| "ECDSA P-256 signature verification failed".to_string()) -} - -/// Decode a base64 string (standard alphabet with padding). -fn base64_decode(input: &str) -> std::result::Result, String> { - base64::engine::general_purpose::STANDARD - .decode(input.trim()) - .map_err(|e| format!("base64 decode error: {}", e)) -} - -/// Encode bytes to base64 (standard alphabet with padding). -fn base64_encode(input: &[u8]) -> String { - base64::engine::general_purpose::STANDARD.encode(input) -} - -/// Result of a successful image signing operation. -#[derive(Debug, Clone)] -pub struct SignResult { - /// The signature tag pushed to the registry (e.g., "sha256-abc123.sig"). - pub signature_tag: String, -} - -/// Sign an image after push using a PEM-encoded ECDSA P-256 private key. -/// -/// Creates a cosign-compatible signature artifact and pushes it to the registry -/// as a separate image with the `.sig` tag convention. -/// -/// # Arguments -/// * `private_key_path` - Path to PEM-encoded ECDSA P-256 private key -/// * `registry` - Registry hostname (e.g., "ghcr.io") -/// * `repository` - Repository path (e.g., "myorg/myimage") -/// * `manifest_digest` - Digest of the pushed manifest (e.g., "sha256:abc123...") -/// * `docker_reference` - Full image reference (e.g., "ghcr.io/myorg/myimage:latest") -pub async fn sign_image( - private_key_path: &str, - registry: &str, - repository: &str, - manifest_digest: &str, - docker_reference: &str, -) -> Result { - use p256::ecdsa::signature::Signer; - - // 1. Read and parse the private key - let pem_bytes = std::fs::read(private_key_path).map_err(|e| { - BoxError::OciImageError(format!( - "Failed to read signing key '{}': {}", - private_key_path, e - )) - })?; - let signing_key = parse_pem_private_key(&pem_bytes) - .map_err(|e| BoxError::OciImageError(format!("Failed to parse signing key: {}", e)))?; - - // 2. Build the SimpleSigning payload - let payload = CosignPayload { - critical: CosignCritical { - identity: CosignIdentity { - docker_reference: docker_reference.to_string(), - }, - image: CosignImage { - docker_manifest_digest: manifest_digest.to_string(), - }, - sig_type: "cosign container image signature".to_string(), - }, - optional: serde_json::json!({}), - }; - let payload_bytes = serde_json::to_vec(&payload).map_err(|e| { - BoxError::SerializationError(format!("Failed to serialize cosign payload: {}", e)) - })?; - - // 3. Sign the payload with ECDSA P-256 - let signature: p256::ecdsa::DerSignature = signing_key.sign(&payload_bytes); - - // 4. Build the cosign signature envelope - let envelope = CosignSignatureEnvelope { - payload: base64_encode(&payload_bytes), - signature: base64_encode(signature.as_bytes()), - }; - let envelope_bytes = serde_json::to_vec(&envelope).map_err(|e| { - BoxError::SerializationError(format!("Failed to serialize signature envelope: {}", e)) - })?; - - // 5. Push the signature as an OCI image with the .sig tag - let sig_tag = cosign_signature_tag(manifest_digest); - let sig_reference_str = format!("{}/{}:{}", registry, repository, sig_tag); - - let sig_reference: Reference = - sig_reference_str - .parse() - .map_err(|e| BoxError::RegistryError { - registry: registry.to_string(), - message: format!("Invalid signature reference: {}", e), - })?; - - let config = oci_distribution::client::ClientConfig { - protocol: oci_distribution::client::ClientProtocol::Https, - ..Default::default() - }; - let client = Client::new(config); - - // The signature layer uses the cosign media type - let sig_layer = oci_distribution::client::ImageLayer::new( - envelope_bytes, - "application/vnd.dev.cosign.simplesigning.v1+json".to_string(), - None, - ); - - // Empty config for the signature image - let sig_config = oci_distribution::client::Config::new( - b"{}".to_vec(), - "application/vnd.oci.image.config.v1+json".to_string(), - None, - ); - - client - .push( - &sig_reference, - &[sig_layer], - sig_config, - &RegistryAuth::Anonymous, - None, - ) - .await - .map_err(|e| BoxError::RegistryError { - registry: registry.to_string(), - message: format!("Failed to push signature artifact: {}", e), - })?; - - tracing::info!( - digest = %manifest_digest, - signature_tag = %sig_tag, - "Image signed and signature pushed" - ); - - Ok(SignResult { - signature_tag: sig_tag, - }) -} - -/// Parse a PEM-encoded ECDSA P-256 private key. -/// -/// Supports "EC PRIVATE KEY" (SEC1) and "PRIVATE KEY" (PKCS#8) PEM formats. -fn parse_pem_private_key(pem_bytes: &[u8]) -> std::result::Result { - let pem_str = std::str::from_utf8(pem_bytes) - .map_err(|e| format!("PEM file is not valid UTF-8: {}", e))?; - - let der_bytes = if pem_str.contains("BEGIN EC PRIVATE KEY") { - // SEC1 format - extract_pem_content( - pem_str, - "-----BEGIN EC PRIVATE KEY-----", - "-----END EC PRIVATE KEY-----", - )? - } else if pem_str.contains("BEGIN PRIVATE KEY") { - // PKCS#8 format - extract_pem_content( - pem_str, - "-----BEGIN PRIVATE KEY-----", - "-----END PRIVATE KEY-----", - )? - } else { - return Err("Unsupported PEM format: expected EC PRIVATE KEY or PRIVATE KEY".to_string()); - }; - - // Try SEC1 first, then PKCS#8 - if let Ok(key) = p256::SecretKey::from_sec1_der(&der_bytes) { - return Ok(p256::ecdsa::SigningKey::from(key)); - } - - // Try PKCS#8 - use p256::pkcs8::DecodePrivateKey; - p256::SecretKey::from_pkcs8_der(&der_bytes) - .map(p256::ecdsa::SigningKey::from) - .map_err(|e| format!("Failed to parse P-256 private key: {}", e)) -} - -/// Extract base64 content between PEM markers. -fn extract_pem_content( - pem_str: &str, - begin_marker: &str, - end_marker: &str, -) -> std::result::Result, String> { - let start = pem_str - .find(begin_marker) - .ok_or("Missing PEM begin marker")? - + begin_marker.len(); - let end = pem_str.find(end_marker).ok_or("Missing PEM end marker")?; - - let b64: String = pem_str[start..end] - .chars() - .filter(|c| !c.is_whitespace()) - .collect(); - - base64_decode(&b64).map_err(|e| format!("Failed to decode PEM base64: {}", e)) -} - #[cfg(test)] mod tests { use super::*; @@ -1456,3 +1051,4 @@ mod tests { assert_eq!(result.signature_tag, "sha256-abc123.sig"); } } + diff --git a/src/runtime/src/oci/signing/sign.rs b/src/runtime/src/oci/signing/sign.rs new file mode 100644 index 0000000..172d719 --- /dev/null +++ b/src/runtime/src/oci/signing/sign.rs @@ -0,0 +1,191 @@ +//! Image signing (cosign-compatible): create and push a signature artifact. + +use a3s_box_core::error::{BoxError, Result}; +use oci_distribution::secrets::RegistryAuth; +use oci_distribution::{Client, Reference}; + +use super::crypto::{base64_decode, base64_encode}; +use super::{ + cosign_signature_tag, CosignCritical, CosignIdentity, CosignImage, CosignPayload, + CosignSignatureEnvelope, +}; + +/// Result of a successful image signing operation. +#[derive(Debug, Clone)] +pub struct SignResult { + /// The signature tag pushed to the registry (e.g., "sha256-abc123.sig"). + pub signature_tag: String, +} + +/// Sign an image after push using a PEM-encoded ECDSA P-256 private key. +/// +/// Creates a cosign-compatible signature artifact and pushes it to the registry +/// as a separate image with the `.sig` tag convention. +/// +/// # Arguments +/// * `private_key_path` - Path to PEM-encoded ECDSA P-256 private key +/// * `registry` - Registry hostname (e.g., "ghcr.io") +/// * `repository` - Repository path (e.g., "myorg/myimage") +/// * `manifest_digest` - Digest of the pushed manifest (e.g., "sha256:abc123...") +/// * `docker_reference` - Full image reference (e.g., "ghcr.io/myorg/myimage:latest") +pub async fn sign_image( + private_key_path: &str, + registry: &str, + repository: &str, + manifest_digest: &str, + docker_reference: &str, +) -> Result { + use p256::ecdsa::signature::Signer; + + // 1. Read and parse the private key + let pem_bytes = std::fs::read(private_key_path).map_err(|e| { + BoxError::OciImageError(format!( + "Failed to read signing key '{}': {}", + private_key_path, e + )) + })?; + let signing_key = parse_pem_private_key(&pem_bytes) + .map_err(|e| BoxError::OciImageError(format!("Failed to parse signing key: {}", e)))?; + + // 2. Build the SimpleSigning payload + let payload = CosignPayload { + critical: CosignCritical { + identity: CosignIdentity { + docker_reference: docker_reference.to_string(), + }, + image: CosignImage { + docker_manifest_digest: manifest_digest.to_string(), + }, + sig_type: "cosign container image signature".to_string(), + }, + optional: serde_json::json!({}), + }; + let payload_bytes = serde_json::to_vec(&payload).map_err(|e| { + BoxError::SerializationError(format!("Failed to serialize cosign payload: {}", e)) + })?; + + // 3. Sign the payload with ECDSA P-256 + let signature: p256::ecdsa::DerSignature = signing_key.sign(&payload_bytes); + + // 4. Build the cosign signature envelope + let envelope = CosignSignatureEnvelope { + payload: base64_encode(&payload_bytes), + signature: base64_encode(signature.as_bytes()), + }; + let envelope_bytes = serde_json::to_vec(&envelope).map_err(|e| { + BoxError::SerializationError(format!("Failed to serialize signature envelope: {}", e)) + })?; + + // 5. Push the signature as an OCI image with the .sig tag + let sig_tag = cosign_signature_tag(manifest_digest); + let sig_reference_str = format!("{}/{}:{}", registry, repository, sig_tag); + + let sig_reference: Reference = + sig_reference_str + .parse() + .map_err(|e| BoxError::RegistryError { + registry: registry.to_string(), + message: format!("Invalid signature reference: {}", e), + })?; + + let config = oci_distribution::client::ClientConfig { + protocol: oci_distribution::client::ClientProtocol::Https, + ..Default::default() + }; + let client = Client::new(config); + + // The signature layer uses the cosign media type + let sig_layer = oci_distribution::client::ImageLayer::new( + envelope_bytes, + "application/vnd.dev.cosign.simplesigning.v1+json".to_string(), + None, + ); + + // Empty config for the signature image + let sig_config = oci_distribution::client::Config::new( + b"{}".to_vec(), + "application/vnd.oci.image.config.v1+json".to_string(), + None, + ); + + client + .push( + &sig_reference, + &[sig_layer], + sig_config, + &RegistryAuth::Anonymous, + None, + ) + .await + .map_err(|e| BoxError::RegistryError { + registry: registry.to_string(), + message: format!("Failed to push signature artifact: {}", e), + })?; + + tracing::info!( + digest = %manifest_digest, + signature_tag = %sig_tag, + "Image signed and signature pushed" + ); + + Ok(SignResult { + signature_tag: sig_tag, + }) +} + +/// Parse a PEM-encoded ECDSA P-256 private key. +/// +/// Supports "EC PRIVATE KEY" (SEC1) and "PRIVATE KEY" (PKCS#8) PEM formats. +pub(super) fn parse_pem_private_key(pem_bytes: &[u8]) -> std::result::Result { + let pem_str = std::str::from_utf8(pem_bytes) + .map_err(|e| format!("PEM file is not valid UTF-8: {}", e))?; + + let der_bytes = if pem_str.contains("BEGIN EC PRIVATE KEY") { + // SEC1 format + extract_pem_content( + pem_str, + "-----BEGIN EC PRIVATE KEY-----", + "-----END EC PRIVATE KEY-----", + )? + } else if pem_str.contains("BEGIN PRIVATE KEY") { + // PKCS#8 format + extract_pem_content( + pem_str, + "-----BEGIN PRIVATE KEY-----", + "-----END PRIVATE KEY-----", + )? + } else { + return Err("Unsupported PEM format: expected EC PRIVATE KEY or PRIVATE KEY".to_string()); + }; + + // Try SEC1 first, then PKCS#8 + if let Ok(key) = p256::SecretKey::from_sec1_der(&der_bytes) { + return Ok(p256::ecdsa::SigningKey::from(key)); + } + + // Try PKCS#8 + use p256::pkcs8::DecodePrivateKey; + p256::SecretKey::from_pkcs8_der(&der_bytes) + .map(p256::ecdsa::SigningKey::from) + .map_err(|e| format!("Failed to parse P-256 private key: {}", e)) +} + +/// Extract base64 content between PEM markers. +pub(super) fn extract_pem_content( + pem_str: &str, + begin_marker: &str, + end_marker: &str, +) -> std::result::Result, String> { + let start = pem_str + .find(begin_marker) + .ok_or("Missing PEM begin marker")? + + begin_marker.len(); + let end = pem_str.find(end_marker).ok_or("Missing PEM end marker")?; + + let b64: String = pem_str[start..end] + .chars() + .filter(|c| !c.is_whitespace()) + .collect(); + + base64_decode(&b64).map_err(|e| format!("Failed to decode PEM base64: {}", e)) +} diff --git a/src/runtime/src/oci/store.rs b/src/runtime/src/oci/store.rs index 72bc800..ff62e64 100644 --- a/src/runtime/src/oci/store.rs +++ b/src/runtime/src/oci/store.rs @@ -252,14 +252,27 @@ impl ImageStore { let data = serde_json::to_string_pretty(&store_index)?; let index_path = self.store_dir.join("index.json"); - - tokio::fs::write(&index_path, data).await.map_err(|e| { + // Write atomically (tmp + rename) so a concurrent reader (e.g. another + // process running `create`/`run`) never observes a truncated/empty + // index.json mid-write — which previously surfaced as + // "Failed to parse image store index: EOF". + let tmp_path = self.store_dir.join("index.json.tmp"); + tokio::fs::write(&tmp_path, data).await.map_err(|e| { BoxError::OciImageError(format!( "Failed to write image store index {}: {}", - index_path.display(), + tmp_path.display(), e )) })?; + tokio::fs::rename(&tmp_path, &index_path) + .await + .map_err(|e| { + BoxError::OciImageError(format!( + "Failed to commit image store index {}: {}", + index_path.display(), + e + )) + })?; Ok(()) }