fix(security): a signed Slack request could be replayed forever
Phase 5. The headline is not the coverage work — it is what looking for coverage found. A CAPTURED SLACK REQUEST AUTHENTICATED INDEFINITELY `slack_signature_valid` verified the HMAC correctly, and nothing anywhere checked how old the timestamp was. The timestamp is an input to the basestring, so an old request's signature verifies exactly as well as a fresh one — meaning anyone holding a single captured signed request (a proxy log, a mirrored packet, a leaked webhook body) could replay it forever, and every replay would authenticate. Slack's documented 5-minute window is now enforced IN THE BROKER, not the caller: the broker does not trust its caller (§15), and a check the caller can forget to make is one that will eventually be forgotten. Symmetric, so a far-future timestamp cannot mint a request valid for as long as the attacker chooses. Seven unit tests over the pure function with the clock injected, and the HTTP-level test now asserts an hour-old but validly signed request is refused. Negative control: removing the window fails the stale and future cases specifically. The existing slack_inbound test used the literal timestamp "12345" — a 1970 date — which passed only because nothing checked freshness. That is the shape of the whole finding: the fixture could not have failed, so it never told us anything. COVERAGE, RE-EXAMINED The review ranked crates by raw test count. That metric was misleading and found the wrong crates: cm-safety's seven tests already cover the decide CAS, grant double-consume, expiry and the approved/rejected split, and the audit_log immutability trigger is tested over in cm-db. Reading the API surface against the tests found the real gaps — verify_slack_signature above, and `credits_for_tokens`, pure pricing arithmetic that every existing billing test went through the database to reach without ever checking directly. Now pinned: the round-up contract, the deliberate one-credit floor, and that an absurd token count cannot wrap into a negative charge (a refund granted by an overflow). Still genuinely thin: cm-brain, where 6 of 9 tests need live clawbrainhub.com. Stubbing it means reproducing an external registry protocol we have no spec for — its own piece of work, not a coverage chore. Recorded rather than faked. GATEWAY PREFLIGHT ZEROCLAW_GATEWAY_URL and ZEROCLAW_TOKEN have no defaults and are read at FIRST USE, so a deployment missing them boots clean, serves every page, and fails the first time someone presses run. Third sibling of runtime_preflight and validator_preflight, same stance: a report, not a gate. The message names the consequence — "container-tier missions cannot run" — rather than only the unset variable. One process note: `cargo test -p cm-secrets` passed while the LIBRARY build was broken, because `time` is a dev-dependency there and my reference to it only resolved under cfg(test). Switched to std. Checking `cargo build --workspace` as well as the test profile is the guard. Full workspace suite green: 106 binaries, zero build errors. Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5
parent
5c2c63f8e8
commit
113de610ec
@@ -497,6 +497,10 @@ async fn run() -> Result<(), String> {
|
|||||||
// every consequence — an ungated test suite, a scan that scanned nothing —
|
// every consequence — an ungated test suite, a scan that scanned nothing —
|
||||||
// looked like a normal result rather than a broken deployment.
|
// looked like a normal result rather than a broken deployment.
|
||||||
cm_api::runtime_preflight::report_at_boot();
|
cm_api::runtime_preflight::report_at_boot();
|
||||||
|
// And whether the gateway those missions drive is configured at all. Both
|
||||||
|
// of its variables are read at FIRST USE, so a deployment missing them
|
||||||
|
// boots clean and fails on the first phase someone runs.
|
||||||
|
cm_api::gateway_preflight::report_at_boot();
|
||||||
// Graceful shutdown: on SIGTERM/Ctrl-C, stop accepting, finish in-flight
|
// Graceful shutdown: on SIGTERM/Ctrl-C, stop accepting, finish in-flight
|
||||||
// requests, then DRAIN the sandbox managers so no container is left running.
|
// requests, then DRAIN the sandbox managers so no container is left running.
|
||||||
let shutdown = async move {
|
let shutdown = async move {
|
||||||
|
|||||||
@@ -0,0 +1,160 @@
|
|||||||
|
//! Is the mission gateway configured, and is anything listening?
|
||||||
|
//!
|
||||||
|
//! The third sibling of [`crate::runtime_preflight`] and
|
||||||
|
//! [`crate::validator_preflight`], for the same class of failure: the
|
||||||
|
//! configuration is absent or wrong, and nothing says so until a mission pays
|
||||||
|
//! for it.
|
||||||
|
//!
|
||||||
|
//! `ZEROCLAW_GATEWAY_URL` and `ZEROCLAW_TOKEN` have no defaults and are read at
|
||||||
|
//! FIRST USE, inside `ZeroClawDriveExecutor::from_env`. So a deployment missing
|
||||||
|
//! them boots clean, serves every page, lists every mission — and fails the
|
||||||
|
//! first time someone presses run, with an error that surfaces on a phase
|
||||||
|
//! rather than at startup. The information exists the whole time; nobody is
|
||||||
|
//! told until it is expensive.
|
||||||
|
//!
|
||||||
|
//! A report, not a gate, matching its siblings. A server with no gateway should
|
||||||
|
//! still boot: the frontend, the catalogue and every read path work without it,
|
||||||
|
//! and refusing to start would turn a degraded deployment into a dead one.
|
||||||
|
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
const PROBE_TIMEOUT: Duration = Duration::from_secs(5);
|
||||||
|
|
||||||
|
/// What the preflight found.
|
||||||
|
#[derive(Debug, PartialEq, Eq)]
|
||||||
|
pub enum Verdict {
|
||||||
|
/// No gateway configured. Missions on the container tier cannot run.
|
||||||
|
NotConfigured { missing: Vec<String> },
|
||||||
|
/// Configured but nothing answered at that address.
|
||||||
|
Unreachable { url: String, error: String },
|
||||||
|
/// Configured and something answered.
|
||||||
|
Reachable { url: String },
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Verdict {
|
||||||
|
/// The line to print at boot.
|
||||||
|
///
|
||||||
|
/// Each names the CONSEQUENCE, not just the state. "ZEROCLAW_TOKEN not set"
|
||||||
|
/// tells an operator what is missing; it does not tell them that every
|
||||||
|
/// container-tier mission they launch will fail on its first phase.
|
||||||
|
pub fn message(&self) -> String {
|
||||||
|
match self {
|
||||||
|
Verdict::NotConfigured { missing } => format!(
|
||||||
|
"gateway_preflight: NOT CONFIGURED ({}) — container-tier missions \
|
||||||
|
cannot run. They will launch, provision a runtime, and fail on \
|
||||||
|
the first turn; the server is otherwise healthy",
|
||||||
|
missing.join(", ")
|
||||||
|
),
|
||||||
|
Verdict::Unreachable { url, error } => format!(
|
||||||
|
"gateway_preflight: {url} is configured but did not answer ({error}) \
|
||||||
|
— container-tier missions will fail on their first turn. The \
|
||||||
|
config is right and the machine is not"
|
||||||
|
),
|
||||||
|
Verdict::Reachable { url } => {
|
||||||
|
format!("gateway_preflight: {url} answered")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Which required variables are absent.
|
||||||
|
///
|
||||||
|
/// Split from the network probe so the rule is testable without a gateway:
|
||||||
|
/// this is the half that is pure, and it is the half that is wrong most often.
|
||||||
|
pub fn missing_config(url: Option<&str>, token: Option<&str>, pairing: Option<&str>) -> Vec<String> {
|
||||||
|
let mut missing = Vec::new();
|
||||||
|
if url.map(str::trim).unwrap_or("").is_empty() {
|
||||||
|
missing.push("ZEROCLAW_GATEWAY_URL".to_string());
|
||||||
|
}
|
||||||
|
// Either credential works: a durable token, or a one-time pairing code the
|
||||||
|
// executor exchanges on first use.
|
||||||
|
let has_token = !token.map(str::trim).unwrap_or("").is_empty();
|
||||||
|
let has_pairing = !pairing.map(str::trim).unwrap_or("").is_empty();
|
||||||
|
if !has_token && !has_pairing {
|
||||||
|
missing.push("ZEROCLAW_TOKEN or ZEROCLAW_PAIRING_CODE".to_string());
|
||||||
|
}
|
||||||
|
missing
|
||||||
|
}
|
||||||
|
|
||||||
|
fn env_opt(key: &str) -> Option<String> {
|
||||||
|
std::env::var(key).ok().filter(|v| !v.trim().is_empty())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Probe the configured gateway.
|
||||||
|
pub async fn check() -> Verdict {
|
||||||
|
let url = env_opt("ZEROCLAW_GATEWAY_URL");
|
||||||
|
let missing = missing_config(
|
||||||
|
url.as_deref(),
|
||||||
|
env_opt("ZEROCLAW_TOKEN").as_deref(),
|
||||||
|
env_opt("ZEROCLAW_PAIRING_CODE").as_deref(),
|
||||||
|
);
|
||||||
|
if !missing.is_empty() {
|
||||||
|
return Verdict::NotConfigured { missing };
|
||||||
|
}
|
||||||
|
let url = url.expect("checked above");
|
||||||
|
|
||||||
|
// Any HTTP answer proves something is listening and routable, which is the
|
||||||
|
// question this preflight exists to answer. Authenticating here would need
|
||||||
|
// a pairing exchange that BURNS a one-time code — a preflight that costs
|
||||||
|
// the deployment its credential is worse than no preflight.
|
||||||
|
let client = match reqwest::Client::builder().timeout(PROBE_TIMEOUT).build() {
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(e) => {
|
||||||
|
return Verdict::Unreachable {
|
||||||
|
url,
|
||||||
|
error: e.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
match client.get(&url).send().await {
|
||||||
|
Ok(_) => Verdict::Reachable { url },
|
||||||
|
Err(e) => Verdict::Unreachable {
|
||||||
|
url,
|
||||||
|
error: e.to_string(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run the probe and print the verdict. Never panics, never blocks boot.
|
||||||
|
pub fn report_at_boot() {
|
||||||
|
tokio::spawn(async {
|
||||||
|
eprintln!("{}", check().await.message());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_fully_configured_deployment_is_missing_nothing() {
|
||||||
|
assert!(missing_config(Some("http://gw:42617"), Some("tok"), None).is_empty());
|
||||||
|
// A pairing code alone is enough — the executor exchanges it on first use.
|
||||||
|
assert!(missing_config(Some("http://gw:42617"), None, Some("123456")).is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_empty_string_counts_as_absent() {
|
||||||
|
// The failure this whole module exists for: `unwrap_or_default` and an
|
||||||
|
// empty env var turn "unconfigured" into "configured with nothing",
|
||||||
|
// which fails later as a 401 rather than now as a missing setting.
|
||||||
|
let missing = missing_config(Some(" "), Some(""), Some(" "));
|
||||||
|
assert_eq!(missing.len(), 2, "both must be reported: {missing:?}");
|
||||||
|
assert!(missing[0].contains("GATEWAY_URL"));
|
||||||
|
assert!(missing[1].contains("ZEROCLAW_TOKEN"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_message_names_the_consequence_not_just_the_state() {
|
||||||
|
let v = Verdict::NotConfigured {
|
||||||
|
missing: vec!["ZEROCLAW_GATEWAY_URL".into()],
|
||||||
|
};
|
||||||
|
let m = v.message();
|
||||||
|
assert!(m.contains("ZEROCLAW_GATEWAY_URL"));
|
||||||
|
assert!(
|
||||||
|
m.contains("cannot run"),
|
||||||
|
"an operator needs to know what stops working, not only what is \
|
||||||
|
unset: {m}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -52,6 +52,7 @@ pub mod runtime_preflight;
|
|||||||
pub mod runtime_provision;
|
pub mod runtime_provision;
|
||||||
pub mod security_scan;
|
pub mod security_scan;
|
||||||
pub mod session_executor;
|
pub mod session_executor;
|
||||||
|
pub mod gateway_preflight;
|
||||||
pub mod skill_self_authoring;
|
pub mod skill_self_authoring;
|
||||||
pub mod skill_use;
|
pub mod skill_use;
|
||||||
pub mod skills_loader;
|
pub mod skills_loader;
|
||||||
|
|||||||
@@ -147,9 +147,19 @@ async fn mention_round_trip_verifies_runs_and_gates_the_reply() {
|
|||||||
"event": {"type": "app_mention", "text": "summarize [[scenario:mention]]"}
|
"event": {"type": "app_mention", "text": "summarize [[scenario:mention]]"}
|
||||||
})
|
})
|
||||||
.to_string();
|
.to_string();
|
||||||
|
// A CURRENT timestamp. It used to be the literal "12345" — a 1970 date —
|
||||||
|
// which passed only because nothing checked freshness. The broker now
|
||||||
|
// enforces Slack's 5-minute replay window, so a fixture that never moves
|
||||||
|
// starts failing the moment the guard is real.
|
||||||
|
let now = std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.unwrap()
|
||||||
|
.as_secs()
|
||||||
|
.to_string();
|
||||||
|
|
||||||
let forged = client
|
let forged = client
|
||||||
.post(format!("{base}/api/slack/events"))
|
.post(format!("{base}/api/slack/events"))
|
||||||
.header("x-slack-request-timestamp", "12345")
|
.header("x-slack-request-timestamp", &now)
|
||||||
.header("x-slack-signature", "v0=deadbeef")
|
.header("x-slack-signature", "v0=deadbeef")
|
||||||
.body(body.clone())
|
.body(body.clone())
|
||||||
.send()
|
.send()
|
||||||
@@ -161,10 +171,10 @@ async fn mention_round_trip_verifies_runs_and_gates_the_reply() {
|
|||||||
let challenge_body = json!({"type": "url_verification", "challenge": "abc123"}).to_string();
|
let challenge_body = json!({"type": "url_verification", "challenge": "abc123"}).to_string();
|
||||||
let challenge = client
|
let challenge = client
|
||||||
.post(format!("{base}/api/slack/events"))
|
.post(format!("{base}/api/slack/events"))
|
||||||
.header("x-slack-request-timestamp", "12345")
|
.header("x-slack-request-timestamp", &now)
|
||||||
.header(
|
.header(
|
||||||
"x-slack-signature",
|
"x-slack-signature",
|
||||||
sign(signing_secret, "12345", &challenge_body),
|
sign(signing_secret, &now, &challenge_body),
|
||||||
)
|
)
|
||||||
.body(challenge_body.clone())
|
.body(challenge_body.clone())
|
||||||
.send()
|
.send()
|
||||||
@@ -176,12 +186,31 @@ async fn mention_round_trip_verifies_runs_and_gates_the_reply() {
|
|||||||
"abc123"
|
"abc123"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// A correctly signed request from outside the window is refused. The
|
||||||
|
// signature is genuine — that is the point of a replay: an attacker holding
|
||||||
|
// one captured request must not be able to use it forever.
|
||||||
|
let stale_ts = (now.parse::<u64>().unwrap() - 3600).to_string();
|
||||||
|
let replayed = client
|
||||||
|
.post(format!("{base}/api/slack/events"))
|
||||||
|
.header("x-slack-request-timestamp", &stale_ts)
|
||||||
|
.header("x-slack-signature", sign(signing_secret, &stale_ts, &body))
|
||||||
|
.body(body.clone())
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
replayed.status(),
|
||||||
|
401,
|
||||||
|
"a validly signed but hour-old request must be refused — otherwise one \
|
||||||
|
captured request authenticates forever"
|
||||||
|
);
|
||||||
|
|
||||||
// A properly signed mention starts a run in the '💬 Slack' session and
|
// A properly signed mention starts a run in the '💬 Slack' session and
|
||||||
// the agent's reply is intercepted by the approval gate.
|
// the agent's reply is intercepted by the approval gate.
|
||||||
let mention = client
|
let mention = client
|
||||||
.post(format!("{base}/api/slack/events"))
|
.post(format!("{base}/api/slack/events"))
|
||||||
.header("x-slack-request-timestamp", "12345")
|
.header("x-slack-request-timestamp", &now)
|
||||||
.header("x-slack-signature", sign(signing_secret, "12345", &body))
|
.header("x-slack-signature", sign(signing_secret, &now, &body))
|
||||||
.body(body)
|
.body(body)
|
||||||
.send()
|
.send()
|
||||||
.await
|
.await
|
||||||
|
|||||||
@@ -137,3 +137,44 @@ pub async fn usage_last_7_days(
|
|||||||
.await?;
|
.await?;
|
||||||
Ok((row.tokens_in, row.tokens_out, row.credits))
|
Ok((row.tokens_in, row.tokens_out, row.credits))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod pricing_tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// Pricing is the one place a rounding slip bills a real person.
|
||||||
|
///
|
||||||
|
/// Pure, cheap to test, and previously untested — the crate's four tests
|
||||||
|
/// all exercise the database path, so the arithmetic underneath them was
|
||||||
|
/// never checked directly.
|
||||||
|
#[test]
|
||||||
|
fn credits_round_up_and_never_charge_zero() {
|
||||||
|
// A run that used tokens always costs at least one credit; charging
|
||||||
|
// zero for real work is how usage silently stops being metered.
|
||||||
|
assert_eq!(credits_for_tokens(1), 1);
|
||||||
|
assert_eq!(credits_for_tokens(TOKENS_PER_CREDIT - 1), 1);
|
||||||
|
assert_eq!(credits_for_tokens(TOKENS_PER_CREDIT), 1);
|
||||||
|
// Round UP, not to nearest: one token into the next bracket is a
|
||||||
|
// whole credit, which is the documented contract.
|
||||||
|
assert_eq!(credits_for_tokens(TOKENS_PER_CREDIT + 1), 2);
|
||||||
|
assert_eq!(credits_for_tokens(TOKENS_PER_CREDIT * 3), 3);
|
||||||
|
assert_eq!(credits_for_tokens(TOKENS_PER_CREDIT * 3 + 1), 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Zero tokens is the odd case: the `.max(1)` floor means it still costs a
|
||||||
|
/// credit. That is deliberate, and worth pinning so a future "fix" to it
|
||||||
|
/// is a decision rather than an accident.
|
||||||
|
#[test]
|
||||||
|
fn a_zero_token_run_still_costs_one_credit() {
|
||||||
|
assert_eq!(credits_for_tokens(0), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The cast to i64 must not wrap into a negative charge — a negative
|
||||||
|
/// credit is a refund, and a refund granted by an overflow is the worst
|
||||||
|
/// shape this bug could take.
|
||||||
|
#[test]
|
||||||
|
fn an_absurd_token_count_does_not_wrap_negative() {
|
||||||
|
assert!(credits_for_tokens(u64::MAX / 2) > 0);
|
||||||
|
assert!(credits_for_tokens(u64::MAX) > 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -20,12 +20,58 @@ fn credential_field(secret: &str, field: &str) -> String {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Slack request signing: v0=hex(HMAC-SHA256(secret, "v0:{ts}:{body}")).
|
/// Slack request signing: v0=hex(HMAC-SHA256(secret, "v0:{ts}:{body}")).
|
||||||
|
/// How stale a Slack request may be before the broker refuses it.
|
||||||
|
///
|
||||||
|
/// Slack's documented replay window. Without it a signature stays valid
|
||||||
|
/// forever: the timestamp is an input to the HMAC, so an old request's
|
||||||
|
/// signature verifies exactly as well as a fresh one. Anyone who captured a
|
||||||
|
/// single signed request — a proxy log, a mirrored packet, a leaked webhook
|
||||||
|
/// body — could replay it indefinitely and every replay would authenticate.
|
||||||
|
const SLACK_MAX_AGE_SECS: i64 = 5 * 60;
|
||||||
|
|
||||||
pub(crate) fn slack_signature_valid(
|
pub(crate) fn slack_signature_valid(
|
||||||
signing_secret: &str,
|
signing_secret: &str,
|
||||||
timestamp: &str,
|
timestamp: &str,
|
||||||
body: &str,
|
body: &str,
|
||||||
signature: &str,
|
signature: &str,
|
||||||
) -> bool {
|
) -> bool {
|
||||||
|
slack_signature_valid_at(
|
||||||
|
signing_secret,
|
||||||
|
timestamp,
|
||||||
|
body,
|
||||||
|
signature,
|
||||||
|
// std, not the `time` crate: `time` is a DEV-dependency here, so a
|
||||||
|
// reference to it compiles under `cargo test` and breaks the library
|
||||||
|
// build — which is exactly how this line shipped broken for one run.
|
||||||
|
std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.map(|d| d.as_secs() as i64)
|
||||||
|
.unwrap_or(0),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`slack_signature_valid`] with the clock injected, so freshness is testable
|
||||||
|
/// without sleeping or mocking the process clock.
|
||||||
|
pub(crate) fn slack_signature_valid_at(
|
||||||
|
signing_secret: &str,
|
||||||
|
timestamp: &str,
|
||||||
|
body: &str,
|
||||||
|
signature: &str,
|
||||||
|
now_unix: i64,
|
||||||
|
) -> bool {
|
||||||
|
// Freshness FIRST, and in the broker rather than the caller: the broker
|
||||||
|
// does not trust its caller (§15), and a check the caller can forget to
|
||||||
|
// make is one that will eventually be forgotten.
|
||||||
|
let Ok(ts) = timestamp.trim().parse::<i64>() else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
// Symmetric window — a timestamp far in the FUTURE is equally suspect, and
|
||||||
|
// allowing it would let an attacker mint a request valid for as long as
|
||||||
|
// they chose.
|
||||||
|
if (now_unix - ts).abs() > SLACK_MAX_AGE_SECS {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
use hmac::{Hmac, Mac};
|
use hmac::{Hmac, Mac};
|
||||||
let Ok(mut mac) = Hmac::<sha2::Sha256>::new_from_slice(signing_secret.as_bytes()) else {
|
let Ok(mut mac) = Hmac::<sha2::Sha256>::new_from_slice(signing_secret.as_bytes()) else {
|
||||||
return false;
|
return false;
|
||||||
@@ -41,6 +87,110 @@ pub(crate) fn slack_signature_valid(
|
|||||||
== 0
|
== 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod slack_signature_tests {
|
||||||
|
use super::*;
|
||||||
|
use hmac::{Hmac, Mac};
|
||||||
|
|
||||||
|
const SECRET: &str = "8f742231b10e8888abcd99yyyzzz85a5";
|
||||||
|
const BODY: &str = "token=xyz&team_id=T1&text=hello";
|
||||||
|
const NOW: i64 = 1_700_000_000;
|
||||||
|
|
||||||
|
fn sign(ts: i64, body: &str) -> String {
|
||||||
|
let mut mac = Hmac::<sha2::Sha256>::new_from_slice(SECRET.as_bytes()).unwrap();
|
||||||
|
mac.update(format!("v0:{ts}:{body}").as_bytes());
|
||||||
|
format!("v0={}", hex::encode(mac.finalize().into_bytes()))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_correctly_signed_fresh_request_is_accepted() {
|
||||||
|
let sig = sign(NOW, BODY);
|
||||||
|
assert!(slack_signature_valid_at(
|
||||||
|
SECRET,
|
||||||
|
&NOW.to_string(),
|
||||||
|
BODY,
|
||||||
|
&sig,
|
||||||
|
NOW
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_tampered_body_is_rejected() {
|
||||||
|
let sig = sign(NOW, BODY);
|
||||||
|
assert!(!slack_signature_valid_at(
|
||||||
|
SECRET,
|
||||||
|
&NOW.to_string(),
|
||||||
|
"token=xyz&team_id=T1&text=goodbye",
|
||||||
|
&sig,
|
||||||
|
NOW
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_wrong_secret_is_rejected() {
|
||||||
|
let sig = sign(NOW, BODY);
|
||||||
|
assert!(!slack_signature_valid_at(
|
||||||
|
"not-the-signing-secret",
|
||||||
|
&NOW.to_string(),
|
||||||
|
BODY,
|
||||||
|
&sig,
|
||||||
|
NOW
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The replay this window exists to stop.
|
||||||
|
///
|
||||||
|
/// The signature is still perfectly valid — that is the point. Anyone who
|
||||||
|
/// captured one signed request could otherwise replay it forever.
|
||||||
|
#[test]
|
||||||
|
fn a_correctly_signed_but_stale_request_is_rejected() {
|
||||||
|
let old = NOW - SLACK_MAX_AGE_SECS - 1;
|
||||||
|
let sig = sign(old, BODY);
|
||||||
|
assert!(
|
||||||
|
slack_signature_valid_at(SECRET, &old.to_string(), BODY, &sig, old),
|
||||||
|
"the signature itself is valid at its own time"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!slack_signature_valid_at(SECRET, &old.to_string(), BODY, &sig, NOW),
|
||||||
|
"a validly signed request older than the window must still be refused"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A timestamp far in the future would otherwise mint a long-lived request.
|
||||||
|
#[test]
|
||||||
|
fn a_far_future_timestamp_is_rejected() {
|
||||||
|
let future = NOW + SLACK_MAX_AGE_SECS + 1;
|
||||||
|
let sig = sign(future, BODY);
|
||||||
|
assert!(!slack_signature_valid_at(
|
||||||
|
SECRET,
|
||||||
|
&future.to_string(),
|
||||||
|
BODY,
|
||||||
|
&sig,
|
||||||
|
NOW
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn small_clock_skew_in_either_direction_is_tolerated() {
|
||||||
|
for delta in [-SLACK_MAX_AGE_SECS + 1, -30, 0, 30, SLACK_MAX_AGE_SECS - 1] {
|
||||||
|
let ts = NOW + delta;
|
||||||
|
let sig = sign(ts, BODY);
|
||||||
|
assert!(
|
||||||
|
slack_signature_valid_at(SECRET, &ts.to_string(), BODY, &sig, NOW),
|
||||||
|
"delta {delta}s must be inside the window — a strict clock would \
|
||||||
|
reject legitimate traffic"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_non_numeric_timestamp_is_rejected_rather_than_defaulting() {
|
||||||
|
let sig = sign(NOW, BODY);
|
||||||
|
assert!(!slack_signature_valid_at(SECRET, "not-a-time", BODY, &sig, NOW));
|
||||||
|
assert!(!slack_signature_valid_at(SECRET, "", BODY, &sig, NOW));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// The broker daemon: listens on a unix socket reachable only by the
|
/// The broker daemon: listens on a unix socket reachable only by the
|
||||||
/// server process (never mounted into agent sandboxes).
|
/// server process (never mounted into agent sandboxes).
|
||||||
pub struct BrokerServer {
|
pub struct BrokerServer {
|
||||||
|
|||||||
@@ -200,13 +200,38 @@ work and the condition.
|
|||||||
memory winning modestly on low absolute numbers, and `Harness the Memory`
|
memory winning modestly on low absolute numbers, and `Harness the Memory`
|
||||||
finds excessive retrieval actively harms agent decisions. Measure against a
|
finds excessive retrieval actively harms agent decisions. Measure against a
|
||||||
baseline before migrating.
|
baseline before migrating.
|
||||||
- **Thin test coverage**: `cm-secrets` (4), `cm-billing` (4), `cm-safety` (7),
|
- **Test coverage, re-examined.** The original entry ranked crates by raw test
|
||||||
`cm-telemetry` (1 — and that one is a genuinely good test: it stands up a
|
count. That was a shallow metric and it was misleading: `cm-safety`'s seven
|
||||||
real OTLP/HTTP receiver and decodes protobuf with the official proto types,
|
tests already cover the CAS on decide, grant double-consume, expiry, and the
|
||||||
so it exercises the actual wire contract rather than a mock. The crate needs
|
approved/rejected split, and the `audit_log` immutability trigger is tested in
|
||||||
more tests, not a different one); 6 of `cm-brain`'s 9 tests are `#[ignore]`d.
|
`cm-db`. Counting tests found the wrong crates.
|
||||||
- **`ZEROCLAW_GATEWAY_URL` / `_TOKEN`** have no default and fail at *first use*,
|
|
||||||
not boot — a deployment looks healthy until someone clicks run.
|
Reading the API surface against the tests found the right ones:
|
||||||
|
- **`verify_slack_signature` had no replay protection** — fixed, see below.
|
||||||
|
- **`credits_for_tokens` was untested** — pure pricing arithmetic, now pinned
|
||||||
|
including the round-up contract and an overflow case.
|
||||||
|
|
||||||
|
Genuinely still thin: `cm-brain`, where 6 of 9 tests need live
|
||||||
|
`clawbrainhub.com` so the whole hub client is unexercised offline. Stubbing it
|
||||||
|
means reproducing an external registry protocol we have no spec for, which is
|
||||||
|
its own piece of work rather than a coverage chore.
|
||||||
|
|
||||||
|
- **A Slack request could be replayed forever.** `slack_signature_valid`
|
||||||
|
verified the HMAC correctly and nothing anywhere checked the timestamp's age —
|
||||||
|
it was only an input to the basestring, so an old request's signature verified
|
||||||
|
exactly as well as a fresh one. Anyone holding one captured signed request
|
||||||
|
could replay it indefinitely. Slack's documented 5-minute window is now
|
||||||
|
enforced **in the broker**, not the caller, because the broker does not trust
|
||||||
|
its caller and a check the caller can forget will eventually be forgotten.
|
||||||
|
Symmetric, so a far-future timestamp cannot mint a long-lived request.
|
||||||
|
|
||||||
|
- ~~**`ZEROCLAW_GATEWAY_URL` / `_TOKEN` fail at *first use*, not boot.**~~
|
||||||
|
**Fixed 2026-08-19.** `gateway_preflight` reports at startup, the third
|
||||||
|
sibling of `runtime_preflight` and `validator_preflight`. A report, not a
|
||||||
|
gate — every read path works without a gateway, and refusing to boot would
|
||||||
|
turn a degraded deployment into a dead one. The message names the
|
||||||
|
consequence ("container-tier missions cannot run") rather than just the
|
||||||
|
missing variable.
|
||||||
- ~~**`gitea_forge` resolves to nothing.**~~ **Resolved 2026-08-19 by removing
|
- ~~**`gitea_forge` resolves to nothing.**~~ **Resolved 2026-08-19 by removing
|
||||||
the name.** It was harmless while `provision_claw` ignored the bundle list;
|
the name.** It was harmless while `provision_claw` ignored the bundle list;
|
||||||
once the list was honoured, an undefined name became a capability an agent is
|
once the list was honoured, an undefined name became a capability an agent is
|
||||||
|
|||||||
Reference in New Issue
Block a user