Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 80 additions & 9 deletions bins/bounty-challenge/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down Expand Up @@ -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<String> {
let Some(p) = path else {
return Vec::new();
Expand All @@ -209,13 +223,20 @@ fn load_admin_hashes(path: Option<&std::path::Path>) -> Vec<String> {

fn load_session_secret(path: Option<&std::path::Path>) -> Result<Vec<u8>, 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)
Expand Down Expand Up @@ -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);
}
}
57 changes: 56 additions & 1 deletion bins/proof-challenge/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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(());
}
12 changes: 8 additions & 4 deletions deploy/scripts/remote-deploy.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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' \
Expand All @@ -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' \
Expand Down
2 changes: 1 addition & 1 deletion deploy/secrets/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Loading