From 272a79efc23087229920c0a667a88fea278e063e Mon Sep 17 00:00:00 2001 From: Harjot Gill Date: Mon, 17 Aug 2026 01:48:51 -0700 Subject: [PATCH 1/6] feat: verify signed bucket deployments --- Dockerfile | 2 +- crates/celld/control_plane.rs | 8 +- crates/celld/deploy.rs | 111 ++++++++++++++++- crates/celld/deployment_auth.rs | 205 ++++++++++++++++++++++++++++++++ crates/celld/fleet.rs | 110 +++++++++++++++-- crates/celld/lib.rs | 1 + crates/celld/protocol.rs | 15 +++ docs/README.md | 1 + docs/security.md | 26 ++++ 9 files changed, 465 insertions(+), 14 deletions(-) create mode 100644 crates/celld/deployment_auth.rs diff --git a/Dockerfile b/Dockerfile index b046b2a55..ce168ed83 100644 --- a/Dockerfile +++ b/Dockerfile @@ -15,7 +15,7 @@ RUN --mount=type=cache,id=celld-cargo-registry,target=/usr/local/cargo/registry, --mount=type=cache,id=celld-cargo-git,target=/usr/local/cargo/git,sharing=locked \ --mount=type=cache,id=celld-target-${TARGETARCH},target=/src/target,sharing=locked \ mkdir -p /out && \ - cargo build --profile "${CELLD_PROFILE}" --locked -p celld && \ + CELLD_BUILD_COMMIT="${CELLD_COMMIT}" cargo build --profile "${CELLD_PROFILE}" --locked -p celld && \ install -m 755 "target/${CELLD_PROFILE}/celld" /out/celld # The final image depends on this stage, so a break in the engine's tests or diff --git a/crates/celld/control_plane.rs b/crates/celld/control_plane.rs index 0f46adc14..160fdc248 100644 --- a/crates/celld/control_plane.rs +++ b/crates/celld/control_plane.rs @@ -1817,6 +1817,12 @@ async fn apply_deployment( validate_managed_module_envelope(deployment)?; validate_managed_class_migrations(&deployment.manifest)?; crate::protocol::validate_required_features(&deployment.manifest.required_features)?; + let manifest_bytes = serde_json::to_vec_pretty(&deployment.manifest)?; + crate::deployment_auth::verify( + &deployment.pointer, + &manifest_bytes, + &crate::deployment_auth::configured_verifying_keys()?, + )?; let mut asset_files = 0_u32; let mut asset_bytes = 0_u64; @@ -1967,7 +1973,7 @@ async fn apply_deployment( bucket .put( &format!("{}/manifest.json", deployment.pointer.prefix), - serde_json::to_vec_pretty(&deployment.manifest)?, + manifest_bytes, ) .await?; bucket diff --git a/crates/celld/deploy.rs b/crates/celld/deploy.rs index 7e148fb17..176d3100c 100644 --- a/crates/celld/deploy.rs +++ b/crates/celld/deploy.rs @@ -8,9 +8,9 @@ //! model is refused, never silently dropped. use crate::bucket::Bucket; use crate::protocol::{ - asset_blob_key, AssetConfig, AssetEntry, AssetIndex, AssetManifestRef, DeployPointer, Manifest, - ModuleKind, ModuleRef, Rollout, RunWorkerFirst, FEATURE_ASSETS_V1, FEATURE_SQLITE_VEC_V1, - FEATURE_WASM_V1, + asset_blob_key, AssetConfig, AssetEntry, AssetIndex, AssetManifestRef, DeployPointer, + DeploymentSignature, Manifest, ModuleKind, ModuleRef, Rollout, RunWorkerFirst, + FEATURE_ASSETS_V1, FEATURE_SQLITE_VEC_V1, FEATURE_WASM_V1, }; use anyhow::{anyhow, bail, Context}; use flate2::write::GzEncoder; @@ -53,6 +53,9 @@ pub struct Options { pub endpoint: Option, pub region: Option, pub dry_run: bool, + pub attestation: Option, + pub signing_key: Option, + pub signing_key_id: Option, } pub fn print_help() { @@ -61,7 +64,7 @@ pub fn print_help() { USAGE:\n celld deploy [PROJECT] --bucket [s3://|gs://]NAME[/PREFIX] [OPTIONS]\n\n\ PROJECT is a directory or a Wrangler config; it defaults to the working\n\ directory, where celld looks for wrangler.jsonc or wrangler.json.\n\n\ -OPTIONS:\n --config PATH Same as passing PROJECT positionally\n --bucket [s3://|gs://]NAME[/PREFIX]\n Fleet bucket and prefix; defaults to CELLD_BUCKET.\n gs:// selects a Google Cloud Storage bucket; celld\n then rejects --endpoint and ignores --region\n --endpoint URL S3-compatible endpoint; defaults to S3_ENDPOINT\n --region REGION Storage region; defaults to AWS_REGION\n --dry-run Bundle and print the version without writing\n -h, --help Show this help\n\n\ +OPTIONS:\n --config PATH Same as passing PROJECT positionally\n --bucket [s3://|gs://]NAME[/PREFIX]\n Fleet bucket and prefix; defaults to CELLD_BUCKET.\n gs:// selects a Google Cloud Storage bucket; celld\n then rejects --endpoint and ignores --region\n --endpoint URL S3-compatible endpoint; defaults to S3_ENDPOINT\n --region REGION Storage region; defaults to AWS_REGION\n --dry-run Bundle and print the version without writing\n --attestation PATH JSON string map of release metadata to sign\n --signing-key PATH File containing a base64-encoded Ed25519 seed\n --signing-key-id ID Verification-key ID recorded in the signature\n -h, --help Show this help\n\n\ Credentials come from the standard AWS credential chain, or from Google\n\ Application Default Credentials for a gs:// bucket.\n\n\ Worker projects require `esbuild` on PATH; asset-only projects do not. Static\n\ @@ -81,12 +84,32 @@ pub fn options_from_arguments( endpoint: None, region: None, dry_run: false, + attestation: None, + signing_key: None, + signing_key_id: None, }; let mut arguments = arguments.into_iter(); while let Some(argument) = arguments.next() { match argument.as_str() { "--help" | "-h" => return Ok(None), "--dry-run" => options.dry_run = true, + "--attestation" => { + options.attestation = Some(PathBuf::from( + arguments.next().context("--attestation requires a value")?, + )); + } + "--signing-key" => { + options.signing_key = Some(PathBuf::from( + arguments.next().context("--signing-key requires a value")?, + )); + } + "--signing-key-id" => { + options.signing_key_id = Some( + arguments + .next() + .context("--signing-key-id requires a value")?, + ); + } "--config" => { options.config = Some(PathBuf::from( arguments.next().context("--config requires a value")?, @@ -109,6 +132,16 @@ pub fn options_from_arguments( other => bail!("`celld deploy` takes one project path, and already has one: {other}"), } } + let signing_arguments = [ + options.attestation.is_some(), + options.signing_key.is_some(), + options.signing_key_id.is_some(), + ]; + if signing_arguments.iter().any(|configured| *configured) + && !signing_arguments.iter().all(|configured| *configured) + { + bail!("--attestation, --signing-key, and --signing-key-id must be configured together"); + } Ok(Some(options)) } @@ -151,6 +184,7 @@ pub struct Built { pub modules: Vec<(String, Vec)>, pub assets: Option, pub bundled_in: Duration, + pub deployment_signature: Option, } impl Built { @@ -363,6 +397,36 @@ pub fn build(options: &Options) -> anyhow::Result { }, raw_metadata: project.metadata, }; + let deployment_signature = match ( + options.attestation.as_ref(), + options.signing_key.as_ref(), + options.signing_key_id.as_ref(), + ) { + (Some(attestation_path), Some(signing_key_path), Some(key_id)) => { + let mut metadata: BTreeMap = + serde_json::from_slice(&std::fs::read(attestation_path).with_context(|| { + format!("read deployment attestation {}", attestation_path.display()) + })?) + .context("deployment attestation must be a JSON object of string values")?; + add_runtime_provenance(&mut metadata)?; + let unsigned_pointer = DeployPointer { + script_name: Some(project.script_name.clone()), + version: version.clone(), + prefix: prefix.clone(), + rollout: Rollout { percent: 100 }, + signature: None, + }; + Some(crate::deployment_auth::sign( + &unsigned_pointer, + &serde_json::to_vec_pretty(&manifest)?, + key_id.clone(), + metadata, + &crate::deployment_auth::read_signing_key(signing_key_path)?, + )?) + } + (None, None, None) => None, + _ => unreachable!("signing options are validated while parsing"), + }; Ok(Built { script_name: project.script_name, version, @@ -371,9 +435,29 @@ pub fn build(options: &Options) -> anyhow::Result { modules, assets: built_assets, bundled_in, + deployment_signature, }) } +fn add_runtime_provenance(metadata: &mut BTreeMap) -> anyhow::Result<()> { + for reserved in ["celld_version", "celld_commit"] { + if metadata.contains_key(reserved) { + bail!("deployment attestation field {reserved:?} is assigned by celld"); + } + } + metadata.insert( + "celld_version".to_string(), + env!("CARGO_PKG_VERSION").to_string(), + ); + metadata.insert( + "celld_commit".to_string(), + option_env!("CELLD_BUILD_COMMIT") + .unwrap_or("unknown") + .to_string(), + ); + Ok(()) +} + pub async fn write(bucket: &Bucket, built: &Built) -> anyhow::Result<()> { // Asset bodies are fleet-wide and content-addressed. Finish every body // before publishing the deployment-local index or manifest so a reader @@ -409,6 +493,7 @@ pub async fn write(bucket: &Bucket, built: &Built) -> anyhow::Result<()> { version: built.version.clone(), prefix: built.prefix.clone(), rollout: Rollout { percent: 100 }, + signature: built.deployment_signature.clone(), }; let encoded = serde_json::to_vec_pretty(&pointer)?; // The named pointer resolves service-binding components; the fleet-wide @@ -1255,3 +1340,21 @@ fn strip_jsonc(source: &str) -> String { } out } + +#[cfg(test)] +mod deployment_attestation_tests { + use super::*; + + #[test] + fn runtime_provenance_is_assigned_by_the_binary() { + let mut metadata = BTreeMap::from([("source_commit".to_string(), "abc".to_string())]); + add_runtime_provenance(&mut metadata).unwrap(); + assert_eq!( + metadata.get("celld_version").map(String::as_str), + Some(env!("CARGO_PKG_VERSION")) + ); + assert!(metadata.contains_key("celld_commit")); + + assert!(add_runtime_provenance(&mut metadata).is_err()); + } +} diff --git a/crates/celld/deployment_auth.rs b/crates/celld/deployment_auth.rs new file mode 100644 index 000000000..4e0a51b61 --- /dev/null +++ b/crates/celld/deployment_auth.rs @@ -0,0 +1,205 @@ +// Copyright 2026 Deno Land Inc. Apache-2.0 license. + +//! Optional, fail-closed deployment admission for bucket-backed fleets. + +use crate::protocol::{DeployPointer, DeploymentSignature}; +use anyhow::{bail, Context}; +use base64::Engine as _; +use ed25519_dalek::{Signature, Signer, SigningKey, Verifier, VerifyingKey}; +use serde::Serialize; +use sha2::{Digest, Sha256}; +use std::collections::BTreeMap; +use std::path::Path; + +const SIGNATURE_SCHEMA_VERSION: u32 = 1; +const DOMAIN: &[u8] = b"celld-deployment-attestation-v1\0"; + +#[derive(Serialize)] +struct SignedPayload<'a> { + schema_version: u32, + script_name: &'a Option, + version: &'a str, + prefix: &'a str, + rollout_percent: u8, + manifest_sha256: &'a str, + metadata: &'a BTreeMap, +} + +fn payload(pointer: &DeployPointer, signature: &DeploymentSignature) -> anyhow::Result> { + let mut encoded = DOMAIN.to_vec(); + encoded.extend(serde_json::to_vec(&SignedPayload { + schema_version: signature.schema_version, + script_name: &pointer.script_name, + version: &pointer.version, + prefix: &pointer.prefix, + rollout_percent: pointer.rollout.percent, + manifest_sha256: &signature.manifest_sha256, + metadata: &signature.metadata, + })?); + Ok(encoded) +} + +pub fn manifest_sha256(manifest: &[u8]) -> String { + format!("{:x}", Sha256::digest(manifest)) +} + +pub fn sign( + pointer: &DeployPointer, + manifest: &[u8], + key_id: String, + metadata: BTreeMap, + key: &SigningKey, +) -> anyhow::Result { + if key_id.trim().is_empty() { + bail!("deployment signing key ID must not be empty"); + } + let mut attestation = DeploymentSignature { + schema_version: SIGNATURE_SCHEMA_VERSION, + key_id, + manifest_sha256: manifest_sha256(manifest), + metadata, + ed25519: String::new(), + }; + let signature = key.sign(&payload(pointer, &attestation)?); + attestation.ed25519 = base64::engine::general_purpose::STANDARD.encode(signature.to_bytes()); + Ok(attestation) +} + +pub fn verify( + pointer: &DeployPointer, + manifest: &[u8], + keys: &BTreeMap, +) -> anyhow::Result<()> { + if keys.is_empty() { + return Ok(()); + } + let attestation = pointer.signature.as_ref().context( + "deployment pointer is unsigned but CELLD_DEPLOYMENT_VERIFY_KEYS_FILE is configured", + )?; + if attestation.schema_version != SIGNATURE_SCHEMA_VERSION { + bail!( + "unsupported deployment signature schema {}", + attestation.schema_version + ); + } + let expected_manifest = manifest_sha256(manifest); + if attestation.manifest_sha256 != expected_manifest { + bail!("deployment manifest digest does not match its signed pointer"); + } + let key = keys.get(&attestation.key_id).with_context(|| { + format!( + "deployment signature uses unknown key ID {:?}", + attestation.key_id + ) + })?; + let bytes = base64::engine::general_purpose::STANDARD + .decode(&attestation.ed25519) + .context("decode deployment Ed25519 signature")?; + let signature = + Signature::from_slice(&bytes).context("invalid deployment Ed25519 signature")?; + key.verify(&payload(pointer, attestation)?, &signature) + .context("deployment signature verification failed") +} + +pub fn read_signing_key(path: &Path) -> anyhow::Result { + let encoded = std::fs::read_to_string(path) + .with_context(|| format!("read deployment signing key {}", path.display()))?; + let bytes = base64::engine::general_purpose::STANDARD + .decode(encoded.trim()) + .context("decode deployment signing key")?; + let seed: [u8; 32] = bytes.try_into().map_err(|_| { + anyhow::anyhow!("deployment signing key must be a base64-encoded 32-byte seed") + })?; + Ok(SigningKey::from_bytes(&seed)) +} + +pub fn read_verifying_keys(path: &Path) -> anyhow::Result> { + let encoded = std::fs::read_to_string(path) + .with_context(|| format!("read deployment verification keys {}", path.display()))?; + let values: BTreeMap = + serde_json::from_str(&encoded).context("decode deployment verification key map")?; + if values.is_empty() { + bail!("deployment verification key map must not be empty"); + } + values + .into_iter() + .map(|(id, value)| { + if id.trim().is_empty() { + bail!("deployment verification key ID must not be empty"); + } + let bytes = base64::engine::general_purpose::STANDARD + .decode(value) + .with_context(|| format!("decode deployment verification key {id:?}"))?; + let bytes: [u8; 32] = bytes.try_into().map_err(|_| { + anyhow::anyhow!("deployment verification key {id:?} must contain 32 bytes") + })?; + Ok(( + id, + VerifyingKey::from_bytes(&bytes) + .context("invalid Ed25519 deployment verification key")?, + )) + }) + .collect() +} + +pub fn configured_verifying_keys() -> anyhow::Result> { + match std::env::var_os("CELLD_DEPLOYMENT_VERIFY_KEYS_FILE") { + Some(path) if !path.is_empty() => read_verifying_keys(Path::new(&path)), + _ => Ok(BTreeMap::new()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::protocol::{DeployPointer, Rollout}; + + fn pointer() -> DeployPointer { + DeployPointer { + script_name: Some("knowledge".into()), + version: "abc123".into(), + prefix: "deploy/knowledge/abc123".into(), + rollout: Rollout { percent: 100 }, + signature: None, + } + } + + #[test] + fn signed_pointer_binds_manifest_and_pointer() { + let key = SigningKey::from_bytes(&[7; 32]); + let mut pointer = pointer(); + pointer.signature = Some( + sign( + &pointer, + b"manifest", + "release-2026".into(), + BTreeMap::from([("source_commit".into(), "deadbeef".into())]), + &key, + ) + .unwrap(), + ); + let keys = BTreeMap::from([("release-2026".into(), key.verifying_key())]); + + verify(&pointer, b"manifest", &keys).unwrap(); + assert!(verify(&pointer, b"changed", &keys).is_err()); + pointer.prefix.push_str("-tampered"); + assert!(verify(&pointer, b"manifest", &keys).is_err()); + } + + #[test] + fn configured_verification_rejects_unsigned_and_unknown_keys() { + let key = SigningKey::from_bytes(&[9; 32]); + let keys = BTreeMap::from([("trusted".into(), key.verifying_key())]); + let mut pointer = pointer(); + assert!(verify(&pointer, b"manifest", &keys).is_err()); + + pointer.signature = + Some(sign(&pointer, b"manifest", "other".into(), BTreeMap::new(), &key).unwrap()); + assert!(verify(&pointer, b"manifest", &keys).is_err()); + } + + #[test] + fn unconfigured_generic_node_preserves_unsigned_compatibility() { + verify(&pointer(), b"manifest", &BTreeMap::new()).unwrap(); + } +} diff --git a/crates/celld/fleet.rs b/crates/celld/fleet.rs index 53a71ce5d..d21f75885 100644 --- a/crates/celld/fleet.rs +++ b/crates/celld/fleet.rs @@ -6,8 +6,9 @@ use crate::bucket::{Bucket, CasVerdict}; use crate::deploy; use crate::js::{ModuleSource, WorkerConfigOptions}; use crate::ownership_store::NodeLeaseWire; -use crate::protocol::{DeployPointer, Manifest, ModuleKind}; +use crate::protocol::{DeployPointer, Manifest, ModuleKind, ModuleRef}; use anyhow::{bail, Context}; +use sha2::{Digest, Sha256}; use std::collections::BTreeMap; use std::time::Duration; use tracing::info; @@ -470,13 +471,38 @@ async fn load_worker_from_pointer( ) -> anyhow::Result { let pointer: DeployPointer = serde_json::from_str(&get_string(bucket, pointer_key).await?) .with_context(|| format!("decode {pointer_key}"))?; - let manifest: Manifest = serde_json::from_str( - &get_string(bucket, &format!("{}/manifest.json", pointer.prefix)).await?, - ) - .context("decode deployment manifest")?; + let manifest_bytes = get_bytes(bucket, &format!("{}/manifest.json", pointer.prefix)).await?; + crate::deployment_auth::verify( + &pointer, + &manifest_bytes, + &crate::deployment_auth::configured_verifying_keys()?, + )?; + let manifest: Manifest = + serde_json::from_slice(&manifest_bytes).context("decode deployment manifest")?; + if manifest.version != pointer.version + || pointer + .script_name + .as_ref() + .is_some_and(|script| script != &manifest.script_name) + || pointer.prefix != format!("deploy/{}/{}", manifest.script_name, manifest.version) + { + bail!("deployment pointer and manifest identities are inconsistent"); + } crate::protocol::validate_required_features(&manifest.required_features)?; let src = match manifest.main_module.as_deref() { - Some(main) => get_string(bucket, &format!("{}/{main}", pointer.prefix)).await?, + Some(main) => { + let reference = manifest + .modules + .iter() + .find(|module| module.name == main) + .context("deployment main module is absent from its module list")?; + String::from_utf8( + verified_module(bucket, &pointer.prefix, reference) + .await? + .into(), + ) + .context("deployment main module is not UTF-8")? + } None if manifest.assets.is_some() => { // Ingress is handled by the immutable asset resolver. Keeping a // synthetic Worker makes the runtime construction path uniform @@ -493,8 +519,7 @@ async fn load_worker_from_pointer( .iter() .filter(|module| manifest.main_module.as_deref() != Some(module.name.as_str())) .map(|module| async move { - let key = format!("{prefix}/{}", module.name); - anyhow::Ok((module, get_bytes(bucket, &key).await?)) + anyhow::Ok((module, verified_module(bucket, prefix, module).await?)) }), ) .await?; @@ -566,6 +591,45 @@ async fn load_worker_from_pointer( }) } +async fn verified_module( + bucket: &Bucket, + prefix: &str, + module: &ModuleRef, +) -> anyhow::Result { + let key = format!("{prefix}/{}", module.name); + let bytes = get_bytes(bucket, &key).await?; + validate_module_bytes(module, &bytes)?; + Ok(bytes) +} + +fn validate_module_bytes(module: &ModuleRef, bytes: &[u8]) -> anyhow::Result<()> { + if module.sha256.len() != 16 + || !module + .sha256 + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + { + bail!( + "deployment module {:?} has an invalid manifest digest", + module.name + ); + } + if bytes.len() != module.bytes { + bail!( + "deployment module {:?} size does not match its manifest", + module.name + ); + } + let digest = format!("{:x}", Sha256::digest(bytes)); + if !digest.starts_with(&module.sha256) { + bail!( + "deployment module {:?} digest does not match its manifest", + module.name + ); + } + Ok(()) +} + /// Apply celld's manifest-first precedence for the optional AI binding. pub fn configured_ai_binding(manifest_binding: Option) -> Option { manifest_binding @@ -657,3 +721,33 @@ fn worker_vars(manifest: &Manifest) -> anyhow::Result> { } Ok(vars.into_iter().collect()) } + +#[cfg(test)] +mod deployment_module_tests { + use super::*; + + fn reference(bytes: &[u8]) -> ModuleRef { + ModuleRef { + name: "index.js".to_string(), + bytes: bytes.len(), + sha256: format!("{:x}", Sha256::digest(bytes))[..16].to_string(), + kind: None, + } + } + + #[test] + fn module_bytes_match_the_manifest() { + let module = reference(b"export default {};"); + validate_module_bytes(&module, b"export default {};").unwrap(); + } + + #[test] + fn changed_or_weak_module_digests_are_rejected() { + let module = reference(b"export default {};"); + assert!(validate_module_bytes(&module, b"export default { fetch() {} };").is_err()); + + let mut empty_digest = reference(b"export default {};"); + empty_digest.sha256.clear(); + assert!(validate_module_bytes(&empty_digest, b"export default {};").is_err()); + } +} diff --git a/crates/celld/lib.rs b/crates/celld/lib.rs index 1a8f6f668..9131568f2 100644 --- a/crates/celld/lib.rs +++ b/crates/celld/lib.rs @@ -12,6 +12,7 @@ pub mod bucket; pub mod control_plane; pub mod dead_node_gc; pub mod deploy; +pub mod deployment_auth; pub mod env_vars; /// Test-only SQLite fault injection, ported from celld unchanged. /// diff --git a/crates/celld/protocol.rs b/crates/celld/protocol.rs index f8616041c..1e3ef939d 100644 --- a/crates/celld/protocol.rs +++ b/crates/celld/protocol.rs @@ -166,6 +166,21 @@ pub struct DeployPointer { pub version: String, pub prefix: String, pub rollout: Rollout, + /// Optional operator attestation over this pointer and the exact deployment + /// manifest bytes. A node configured with deployment verification keys + /// rejects pointers without a valid signature. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub signature: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DeploymentSignature { + pub schema_version: u32, + pub key_id: String, + pub manifest_sha256: String, + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub metadata: BTreeMap, + pub ed25519: String, } #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/docs/README.md b/docs/README.md index 86ae27afa..b5c57c4b0 100644 --- a/docs/README.md +++ b/docs/README.md @@ -308,6 +308,7 @@ For the full list, run `celld -h`. This table shows the primary settings: | `CELLD_MAX_RESIDENT_CELLS` | The hard limit for resident cells, enforced at admission | | `CELLD_MAX_RSS_MB` | The memory threshold for pressure shedding, applied to the memory that the cells hold (default: 80% of the available memory; 0 disables the threshold and the absolute cap) | | `CELLD_OUTPUT_GATE` | The default is `1`, so celld proves each write durable before it acknowledges the write. Set `0` to remove the replication wait and accept possible loss of an acknowledged write | +| `CELLD_DEPLOYMENT_VERIFY_KEYS_FILE` | JSON map of release-key IDs to base64 Ed25519 public keys. When set, every bucket deployment pointer must carry a valid signature | | `CELLD_LTX_COMPACTION` | The default is `1`: celld creates additive L1 objects, and a takeover reads tens of objects instead of thousands. Set `0` on every node of a mixed fleet until all nodes can read v0.5.2 block objects, because an old reader cannot take over a cell after its first L1 publication | | `CELLD_LTX_COMPACTION_MIN_TXIDS` | The durable TXID distance that queues an L1 attempt (default: 256) | | `CELLD_LTX_COMPACTIONS` | The node-wide limit for concurrent L1 attempts (default: 2) | diff --git a/docs/security.md b/docs/security.md index 3d4ddd4c1..fe365d352 100644 --- a/docs/security.md +++ b/docs/security.md @@ -94,6 +94,32 @@ A person who holds the bucket credentials controls the fleet. Give each credential access to one fleet bucket only, and replace a credential after a suspected disclosure. +### Require signed deployments + +An operator can make bucket deployment pointers fail closed by setting +`CELLD_DEPLOYMENT_VERIFY_KEYS_FILE` on every node. The file is a JSON object +whose keys are release-key IDs and whose values are base64-encoded 32-byte +Ed25519 public keys. Once configured, a node rejects an unsigned pointer, an +unknown key ID, a changed pointer, and a changed deployment manifest before it +loads Worker code. + +Create a signed pointer with `celld deploy --attestation release.json +--signing-key release.seed --signing-key-id KEY_ID`. The attestation must be a +JSON object of string values. Celld adds its compile-time version and commit; +the input must not assign the reserved `celld_version` or `celld_commit` keys. +The signing-key file contains only a +base64-encoded 32-byte Ed25519 seed; keep it out of the fleet and expose it only +to the deployment job. Nodes need only the public-key map. Rotate by adding the +new public key to every node, signing a deployment with the new key, and then +removing the old key after every supported rollback deployment has been +re-signed or retired. + +The signature binds the fleet pointer, rollout percentage, exact manifest +bytes, and operator metadata. Module and asset hashes remain part of the Celld +manifest validation path. This feature does not make a compromised Worker or a +holder of the fleet bucket credentials harmless; it prevents bucket access +alone from selecting unapproved Worker code when the signing key is separate. + ## Keep one writer for each cell Each cell is a SQLite database with one writer. One node owns a cell at a time, From 96a5b16c8c95fbef5b9d27e09a0debebf696595d Mon Sep 17 00:00:00 2001 From: Harjot Gill Date: Mon, 17 Aug 2026 01:51:31 -0700 Subject: [PATCH 2/6] fix: propagate build revision into runtime --- Dockerfile | 1 + 1 file changed, 1 insertion(+) diff --git a/Dockerfile b/Dockerfile index ce168ed83..84896939b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,6 +5,7 @@ ARG CELLD_COMMIT=unknown FROM rust:${RUST_VERSION}-bookworm AS build ARG TARGETARCH +ARG CELLD_COMMIT # `release` for shipped artifacts; a fast-loop caller passes `lab` to skip # the fat-LTO relink and keep incremental state in the target cache. ARG CELLD_PROFILE=release From 0eaa967206700801ba9dface7256cdf2b0b0516e Mon Sep 17 00:00:00 2001 From: Harjot Gill Date: Mon, 17 Aug 2026 01:53:51 -0700 Subject: [PATCH 3/6] ci: test production image on pull requests --- .github/workflows/ci.yml | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 000000000..51676e4e4 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,24 @@ +name: CI + +on: + pull_request: + push: + branches: [main] + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-24.04 + timeout-minutes: 60 + steps: + - uses: actions/checkout@v6 + - name: Install pinned Rust formatter + run: | + rustup toolchain install 1.97.1 --profile minimal --component rustfmt + rustup default 1.97.1 + - name: Check formatting + run: cargo fmt --all --check + - name: Build the tested production image + run: docker build --build-arg CELLD_COMMIT="$GITHUB_SHA" --build-arg CELLD_VERSION="0.2.1-coderabbit" . From 2dfc21fa9f30abd141c37c18069aa5968eced2e9 Mon Sep 17 00:00:00 2001 From: Harjot Gill Date: Mon, 17 Aug 2026 01:59:53 -0700 Subject: [PATCH 4/6] fix: verify complete module digests --- crates/celld/control_plane.rs | 4 ++-- crates/celld/deploy.rs | 2 +- crates/celld/fleet.rs | 10 +++++++--- 3 files changed, 10 insertions(+), 6 deletions(-) diff --git a/crates/celld/control_plane.rs b/crates/celld/control_plane.rs index 160fdc248..0abfd489e 100644 --- a/crates/celld/control_plane.rs +++ b/crates/celld/control_plane.rs @@ -2034,7 +2034,7 @@ fn validate_managed_module_envelope(deployment: &AgentDeployment) -> anyhow::Res let mut manifest_modules = std::collections::BTreeMap::new(); let mut total_bytes = 0_usize; for module in &deployment.manifest.modules { - if !valid_managed_module_name(&module.name) || !valid_lower_hex(&module.sha256, 16) { + if !valid_managed_module_name(&module.name) || !valid_lower_hex(&module.sha256, 64) { return Err(anyhow!( "control-plane manifest has an invalid module reference: {:?}", module.name @@ -2095,7 +2095,7 @@ fn validate_managed_module_envelope(deployment: &AgentDeployment) -> anyhow::Res let transport = transport_modules .get(name) .expect("module name sets compared above"); - if !transport.sha256.starts_with(&manifest.sha256) { + if transport.sha256 != manifest.sha256 { return Err(anyhow!( "control-plane transport checksum does not match manifest module {name:?}" )); diff --git a/crates/celld/deploy.rs b/crates/celld/deploy.rs index 176d3100c..31b0ad060 100644 --- a/crates/celld/deploy.rs +++ b/crates/celld/deploy.rs @@ -374,7 +374,7 @@ pub fn build(options: &Options) -> anyhow::Result { .map(|(name, bytes)| ModuleRef { name: name.clone(), bytes: bytes.len(), - sha256: format!("{:x}", Sha256::digest(bytes))[..16].to_string(), + sha256: format!("{:x}", Sha256::digest(bytes)), kind: wasm_names.contains(name).then_some(ModuleKind::Wasm), }) .collect(), diff --git a/crates/celld/fleet.rs b/crates/celld/fleet.rs index d21f75885..accc2f7f2 100644 --- a/crates/celld/fleet.rs +++ b/crates/celld/fleet.rs @@ -603,7 +603,7 @@ async fn verified_module( } fn validate_module_bytes(module: &ModuleRef, bytes: &[u8]) -> anyhow::Result<()> { - if module.sha256.len() != 16 + if module.sha256.len() != 64 || !module .sha256 .bytes() @@ -621,7 +621,7 @@ fn validate_module_bytes(module: &ModuleRef, bytes: &[u8]) -> anyhow::Result<()> ); } let digest = format!("{:x}", Sha256::digest(bytes)); - if !digest.starts_with(&module.sha256) { + if digest != module.sha256 { bail!( "deployment module {:?} digest does not match its manifest", module.name @@ -730,7 +730,7 @@ mod deployment_module_tests { ModuleRef { name: "index.js".to_string(), bytes: bytes.len(), - sha256: format!("{:x}", Sha256::digest(bytes))[..16].to_string(), + sha256: format!("{:x}", Sha256::digest(bytes)), kind: None, } } @@ -749,5 +749,9 @@ mod deployment_module_tests { let mut empty_digest = reference(b"export default {};"); empty_digest.sha256.clear(); assert!(validate_module_bytes(&empty_digest, b"export default {};").is_err()); + + let mut truncated_digest = reference(b"export default {};"); + truncated_digest.sha256.truncate(16); + assert!(validate_module_bytes(&truncated_digest, b"export default {};").is_err()); } } From 90f98b685846ecd2c7fe06c60bab3ba00b190df3 Mon Sep 17 00:00:00 2001 From: Harjot Gill Date: Mon, 17 Aug 2026 02:07:25 -0700 Subject: [PATCH 5/6] build: prepare CodeRabbit fork release --- .github/workflows/ci.yml | 4 +++- .github/workflows/release.yml | 4 ++-- Cargo.lock | 2 +- README.md | 24 ++++++------------------ crates/celld/Cargo.toml | 2 +- docs/README.md | 24 ++++++++---------------- 6 files changed, 21 insertions(+), 39 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 51676e4e4..72d117bae 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,4 +21,6 @@ jobs: - name: Check formatting run: cargo fmt --all --check - name: Build the tested production image - run: docker build --build-arg CELLD_COMMIT="$GITHUB_SHA" --build-arg CELLD_VERSION="0.2.1-coderabbit" . + run: | + version=$(sed -n 's/^version = "\(.*\)"$/\1/p' crates/celld/Cargo.toml | head -1) + docker build --build-arg CELLD_COMMIT="$GITHUB_SHA" --build-arg CELLD_VERSION="$version" . diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a3f75e5d7..8b8d3d6bb 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -29,7 +29,7 @@ concurrency: env: RUST_VERSION: 1.97.1 - IMAGE: ghcr.io/denoland/celld + IMAGE: ghcr.io/coderabbitai/celld jobs: build-celld: @@ -302,7 +302,7 @@ jobs: if: github.event.repository.visibility == 'public' uses: actions/attest-build-provenance@v2 with: - subject-name: ghcr.io/denoland/celld + subject-name: ghcr.io/coderabbitai/celld subject-digest: ${{ steps.digest.outputs.digest }} push-to-registry: true diff --git a/Cargo.lock b/Cargo.lock index 45a1ebf3f..e64eefc62 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -288,7 +288,7 @@ dependencies = [ [[package]] name = "celld" -version = "0.2.1" +version = "0.2.1-coderabbit.1" dependencies = [ "aes", "aes-gcm", diff --git a/README.md b/README.md index 90665e65c..9a62277bf 100644 --- a/README.md +++ b/README.md @@ -29,33 +29,21 @@ truth; nodes are replaceable. ## Install -The installer downloads the `celld` binary (provenance is verifiable with -`gh attestation verify`): - -```sh -curl -fsSL https://celld.dev/install.sh | sh -``` - -Put `~/.local/bin` on your `PATH` if the installer asks you to. +Use the release container below, or download a native binary from the +[CodeRabbit fork releases](https://github.com/coderabbitai/celld/releases). +Release provenance is verifiable with +`gh attestation verify --repo coderabbitai/celld`. Worker projects deployed with `celld deploy` need [esbuild](https://esbuild.github.io) on `PATH`; asset-only projects do not. -The installer keeps each release under `~/.local/lib/celld/releases` and points -one symlink at the current one. To remove celld, delete the symlink and the -releases: - -```sh -rm `which celld` && rm -rf ~/.local/lib/celld -``` - ## Container The release image contains the `celld` binary and is published for Linux x86-64 and ARM64: ```sh -docker run --rm ghcr.io/denoland/celld --version +docker run --rm ghcr.io/coderabbitai/celld --version ``` Persist the runtime's local state and pass the standard AWS credential @@ -69,7 +57,7 @@ docker run --rm --network host \ -e AWS_SESSION_TOKEN \ -e CELLD_WATCH=/var/lib/celld/state \ -v celld-state:/var/lib/celld \ - ghcr.io/denoland/celld \ + ghcr.io/coderabbitai/celld \ --bucket s3://my-cells-bucket \ --endpoint https://ACCOUNT.r2.cloudflarestorage.com \ --region auto \ diff --git a/crates/celld/Cargo.toml b/crates/celld/Cargo.toml index 4108a8d49..e7e239494 100644 --- a/crates/celld/Cargo.toml +++ b/crates/celld/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "celld" -version = "0.2.1" +version = "0.2.1-coderabbit.1" edition = "2021" rust-version = "1.94.1" license = "Apache-2.0" diff --git a/docs/README.md b/docs/README.md index b5c57c4b0..a9036e872 100644 --- a/docs/README.md +++ b/docs/README.md @@ -78,22 +78,14 @@ A cell fits a workload that divides into named, stateful units: ## Install -The installer downloads the `celld` binary. Replication occurs in the -celld process. A node does not need an external replicator. If your -project contains Worker code, `celld deploy` needs esbuild. An asset-only -project does not need esbuild. +Replication occurs in the celld process; a node does not need an external +replicator. Use the release container or download an exact native binary from +the [CodeRabbit fork releases](https://github.com/coderabbitai/celld/releases). +Each release has a GitHub Actions build attestation. Verify a downloaded file +with `gh attestation verify --repo coderabbitai/celld`. -```sh -curl -fsSL https://celld.dev/install.sh | sh -``` - -If the installer tells you, add `~/.local/bin` to `PATH`. To install one -exact release, set `CELLD_VERSION` to the tag of that release, for example -`v0.0.1`. To go back to a previous release, run the installer again with -the tag of that release. The releases are on -[GitHub](https://github.com/denoland/celld/releases). Each release has a -GitHub Actions build attestation. To make sure that a downloaded file is -correct, run `gh attestation verify --repo denoland/celld`. +If your project contains Worker code, `celld deploy` needs esbuild. An +asset-only project does not need esbuild. ## Configure object storage @@ -149,7 +141,7 @@ If the project contains Worker code, install `esbuild` on `PATH`. Then run `celld deploy` from an applicable Wrangler project: ```sh -git clone https://github.com/denoland/celld +git clone https://github.com/coderabbitai/celld cd celld/examples/counter celld deploy . \ --bucket "$CELLD_BUCKET" \ From cce2adabbe1e320301aefbac9d044ebd51741035 Mon Sep 17 00:00:00 2001 From: Harjot Gill Date: Mon, 17 Aug 2026 02:07:52 -0700 Subject: [PATCH 6/6] ci: avoid persisting pull request credentials --- .github/workflows/ci.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 72d117bae..f736e5616 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -14,6 +14,8 @@ jobs: timeout-minutes: 60 steps: - uses: actions/checkout@v6 + with: + persist-credentials: false - name: Install pinned Rust formatter run: | rustup toolchain install 1.97.1 --profile minimal --component rustfmt