diff --git a/bins/bounty-challenge/src/main.rs b/bins/bounty-challenge/src/main.rs index f8467886c..5b2a28de1 100644 --- a/bins/bounty-challenge/src/main.rs +++ b/bins/bounty-challenge/src/main.rs @@ -93,10 +93,7 @@ fn main() -> ExitCode { } fn run(cli: &Cli) -> Result<(), String> { - let sk = match &cli.challenge_sk_file { - Some(p) => Some(load_challenge_secret(p).map_err(|e| format!("challenge sk: {e}"))?), - None => None, - }; + let sk = load_optional_sk(cli.challenge_sk_file.as_deref()); let admin_hashes = load_admin_hashes(cli.admin_tokens_file.as_deref()); let session_secret = load_session_secret(cli.session_secret_file.as_deref())?; // The CLI flag and the env var are the same knob; `resolve_scoring_backend` @@ -193,6 +190,23 @@ fn build_emitter( )))) } +fn load_optional_sk(path: Option<&std::path::Path>) -> Option<[u8; 32]> { + let p = path?; + match load_challenge_secret(p) { + Ok(k) => Some(k), + Err(e) => { + // Compose always sets BASE_CHALLENGE_SK_FILE; remote-deploy may + // materialize an empty placeholder so Docker does not create a + // directory. That must not take /health down. + tracing::warn!( + "challenge sk: {e}; bounty cannot sign leaves, so nothing will be emitted \ + and POST /v1/admin/seal will answer 409 while bounty holds a paid trust-root row" + ); + None + } + } +} + fn load_admin_hashes(path: Option<&std::path::Path>) -> Vec { let Some(p) = path else { return Vec::new(); @@ -209,13 +223,20 @@ fn load_admin_hashes(path: Option<&std::path::Path>) -> Vec { fn load_session_secret(path: Option<&std::path::Path>) -> Result, String> { if let Some(p) = path { - let bytes = std::fs::read(p).map_err(|e| format!("session secret: {e}"))?; - if bytes.is_empty() { - return Err("session secret file is empty".into()); + match std::fs::read(p) { + Ok(bytes) if !bytes.is_empty() => return Ok(bytes), + Ok(_) => tracing::warn!( + "BOUNTY_SESSION_SECRET_FILE is empty; using an ephemeral secret \ + (pairing sessions will not survive restart)" + ), + Err(e) => tracing::warn!( + "session secret: {e}; using an ephemeral secret \ + (pairing sessions will not survive restart)" + ), } - return Ok(bytes); } - // Dev / CI: ephemeral secret. Production should set BOUNTY_SESSION_SECRET_FILE. + // Dev / CI / empty host placeholder. Production should persist a + // non-empty BOUNTY_SESSION_SECRET_FILE so pairing survives restart. let mut out = vec![0u8; 32]; getrandom_fill(&mut out)?; Ok(out) @@ -297,4 +318,54 @@ mod tests { assert_eq!(resolve_scoring_backend(), ScoringBackend::Unconfigured); std::env::remove_var("BOUNTY_FORCE_SIM"); } + + /// Compose always sets `BASE_CHALLENGE_SK_FILE`. remote-deploy may leave an + /// empty placeholder so Docker does not create a directory at that path. + /// That must not exit 1 — `/health` has to come up so routing smoke works. + #[test] + fn an_empty_or_missing_challenge_sk_file_does_not_abort_boot() { + let dir = std::env::temp_dir().join(format!( + "bounty-sk-{}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("clock") + .as_nanos() + )); + std::fs::create_dir_all(&dir).expect("dir"); + let empty = dir.join("sk"); + std::fs::write(&empty, []).expect("write"); + assert!(load_optional_sk(Some(&empty)).is_none()); + assert!(load_optional_sk(Some(&dir.join("missing"))).is_none()); + assert!(load_optional_sk(None).is_none()); + let _ = std::fs::remove_dir_all(&dir); + } + + /// Same placeholder footgun for `BOUNTY_SESSION_SECRET_FILE`: empty or + /// missing must yield an ephemeral secret so pair HMAC still has bytes. + #[test] + fn an_empty_or_missing_session_secret_file_falls_back_to_ephemeral() { + let dir = std::env::temp_dir().join(format!( + "bounty-sess-{}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .expect("clock") + .as_nanos() + )); + std::fs::create_dir_all(&dir).expect("dir"); + let empty = dir.join("session_secret"); + std::fs::write(&empty, []).expect("write"); + let a = load_session_secret(Some(&empty)).expect("empty file"); + let b = load_session_secret(Some(&dir.join("missing"))).expect("missing file"); + let c = load_session_secret(None).expect("unset"); + assert_eq!(a.len(), 32); + assert_eq!(b.len(), 32); + assert_eq!(c.len(), 32); + let present = dir.join("real"); + std::fs::write(&present, b"persisted-session-secret").expect("write"); + assert_eq!( + load_session_secret(Some(&present)).expect("present"), + b"persisted-session-secret" + ); + let _ = std::fs::remove_dir_all(&dir); + } } diff --git a/bins/proof-challenge/src/main.rs b/bins/proof-challenge/src/main.rs index 9a5937052..00fa74dc1 100644 --- a/bins/proof-challenge/src/main.rs +++ b/bins/proof-challenge/src/main.rs @@ -87,7 +87,15 @@ fn main() -> ExitCode { fn run(cli: &Cli) -> Result<(), String> { if let Some(p) = &cli.challenge_sk_file { - let _sk = load_challenge_secret(p).map_err(|e| format!("challenge sk: {e}"))?; + if let Err(e) = load_challenge_secret(p) { + // Compose always sets BASE_CHALLENGE_SK_FILE; remote-deploy may + // materialize an empty placeholder so Docker does not create a + // directory. Missing/invalid key must not take /health down. + tracing::warn!( + "challenge sk: {e}; leaf signing unavailable until BASE_CHALLENGE_SK_FILE is a \ + 32-byte mini-secret (or 64 hex chars)" + ); + } } let pin = load_pin(cli.pin_file.as_deref())?; let backend = if cli.force_sim { @@ -448,4 +456,51 @@ mod tests { } static LIUM_ENV: std::sync::Mutex<()> = std::sync::Mutex::new(()); + + /// Compose always points `PROOF_PIN_FILE` at the committed pin. Empty + /// `[inference].model` / `base_url` is pre-launch fail-closed (503), not a + /// boot reject — same as an empty eval digest. + #[test] + fn committed_pin_boots_with_empty_inference_model() { + let path = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../config/proof-pin.toml"); + let pin = load_pin(Some(&path)).expect("committed pin must boot"); + assert!( + pin.inference.model.trim().is_empty(), + "empty model is pre-launch 503" + ); + assert!( + pin.inference.base_url.trim().is_empty(), + "url is secret-backed" + ); + assert!(pin.proxy_model.trim().is_empty()); + } + + /// Compose sets `PROOF_INFERENCE_OFFER_FILE`. A missing/closed offer is + /// `can_score=false` / submit 503 — it must not `exit 1`. + #[test] + fn compose_inference_offer_env_parses_and_a_missing_file_is_not_a_boot_error() { + let _guard = OFFER_ENV + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + std::env::set_var( + "PROOF_INFERENCE_OFFER_FILE", + "/run/base/proof/inference_offer.json", + ); + let cli = Cli::try_parse_from(["proof-challenge"]) + .unwrap_or_else(|e| panic!("PROOF_INFERENCE_OFFER_FILE broke parsing: {e}")); + assert_eq!( + cli.inference_offer_file.as_deref(), + Some(Path::new("/run/base/proof/inference_offer.json")) + ); + let pin = load_pin(None).expect("default pin"); + let err = load_offer(&pin, cli.inference_offer_file.as_deref()) + .expect_err("missing offer is unavailable, not a panic"); + assert!( + err.contains("read") || err.contains("PROOF_INFERENCE_OFFER_FILE"), + "{err}" + ); + std::env::remove_var("PROOF_INFERENCE_OFFER_FILE"); + } + + static OFFER_ENV: std::sync::Mutex<()> = std::sync::Mutex::new(()); } diff --git a/deploy/scripts/remote-deploy.sh b/deploy/scripts/remote-deploy.sh index 2641fceb0..6500c6e93 100755 --- a/deploy/scripts/remote-deploy.sh +++ b/deploy/scripts/remote-deploy.sh @@ -186,9 +186,11 @@ rsync -az --delete \ # directory with real files: compose would otherwise create directories where # the container expects files. # Same footgun for file mounts: if bounty_sk/proof_sk is missing, Docker -# creates *directories* at those paths and the challenge bin fails with -# "Is a directory" / "secret file missing". Materialize empty files when -# absent; if a directory already poisoned the path, replace it with a file. +# creates *directories* at those paths. Materialize empty files when absent +# (the bins warn and still serve /health; they must not exit 1). If a +# directory already poisoned the path, replace it with a file. +# session_secret must be non-empty bytes — an empty placeholder used to +# crash bounty-challenge at boot. Fill from urandom when missing or 0-length. ssh_h "mkdir -p '$REMOTE_DIR/deploy/env' '$REMOTE_DIR/deploy/secrets/lium' \ '$REMOTE_DIR/deploy/secrets/bounty' \ '$REMOTE_DIR/deploy/secrets/proof' \ @@ -198,7 +200,9 @@ ssh_h "mkdir -p '$REMOTE_DIR/deploy/env' '$REMOTE_DIR/deploy/secrets/lium' \ [ -e '$REMOTE_DIR/deploy/secrets/lium/'\$f ] || : > '$REMOTE_DIR/deploy/secrets/lium/'\$f; \ done \ && [ -e '$REMOTE_DIR/deploy/secrets/bounty/admin_tokens' ] || : > '$REMOTE_DIR/deploy/secrets/bounty/admin_tokens' \ - && [ -e '$REMOTE_DIR/deploy/secrets/bounty/session_secret' ] || : > '$REMOTE_DIR/deploy/secrets/bounty/session_secret' \ + && if [ ! -s '$REMOTE_DIR/deploy/secrets/bounty/session_secret' ]; then \ + dd if=/dev/urandom bs=32 count=1 status=none of='$REMOTE_DIR/deploy/secrets/bounty/session_secret'; \ + fi \ && [ -e '$REMOTE_DIR/deploy/secrets/proof/admin_tokens' ] || : > '$REMOTE_DIR/deploy/secrets/proof/admin_tokens' \ && [ -e '$REMOTE_DIR/deploy/secrets/proof/topics.json' ] || echo '[]' > '$REMOTE_DIR/deploy/secrets/proof/topics.json' \ && [ -e '$REMOTE_DIR/deploy/secrets/proof/holdouts.json' ] || echo '{}' > '$REMOTE_DIR/deploy/secrets/proof/holdouts.json' \ diff --git a/deploy/secrets/README.md b/deploy/secrets/README.md index 15fd607b0..8336d98b6 100644 --- a/deploy/secrets/README.md +++ b/deploy/secrets/README.md @@ -39,7 +39,7 @@ chmod 0400 deploy/secrets/gateway_admin_token | `proof/inference_api_key` | proof-challenge | Provider API key for the eval image. **Never commit, never log.** Mode **0400**, uid **65532** | | `proof/inference_base_url` | proof-challenge | Optional secret-backed origin (`PROOF_INFERENCE_BASE_URL_FILE`) when pin `[inference].base_url` and the topic omit one. **Never commit, never log.** Mode **0400**, uid **65532** | | `bounty/admin_tokens` | bounty-challenge | Operator bearer for `POST /v1/admin/adjudicate` | -| `bounty/session_secret` | bounty-challenge | Pairing session HMAC secret | +| `bounty/session_secret` | bounty-challenge | Pairing session HMAC secret. Empty/missing no longer crashes boot (`/health` stays up; pairing will not survive restart). `remote-deploy.sh` fills a 32-byte value from urandom when the file is missing or 0-length. | ## Other