From 25e057e5c1b465fb139a5d0177e25b02413eb337 Mon Sep 17 00:00:00 2001 From: RNT56 Date: Mon, 29 Jun 2026 11:34:58 +0200 Subject: [PATCH 1/2] feat(daemon): Slack runtime control plane (roadmap-v0.6 E.3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds crustcore_daemon::slack: a pure SlackAllowlist (per-workspace/channel, deny-all empty — invariants 5, 15), normalize_message(msg, allowlist) -> Option mapping a Slack event onto the SAME RuntimeEvent stream as Telegram (plain -> QueuedTurn, ! -> Steer, / -> Command, reaction -> ApprovalCallback with the same nonce format) so Slack routes through the same policy gates — not a parallel ungoverned surface (invariants 8, 16) — and render_to_slack which redacts every secret before any message leaves (invariants 1-3). Text untrusted + bounded (7, 11); approvals from Slack users gated by the allowlist, never model output (4); opt-in, operator-bound via CLI (15). The Slack API + Events/Socket-Mode listener is the live #[ignore]d slack_live_round_trip_smoke (TODO(slack-live)), in runbook F.7. 5 new tests; daemon-only; zero nano impact. `cargo xtask verify` green. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 14 ++ crates/crustcore-daemon/src/lib.rs | 4 + crates/crustcore-daemon/src/slack.rs | 210 +++++++++++++++++++++++++++ docs/live-socket-validation.md | 16 ++ 4 files changed, 244 insertions(+) create mode 100644 crates/crustcore-daemon/src/slack.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index 0a7fdf1..a3cd77c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -30,6 +30,19 @@ agent/PR/role/size/invariant audit trail. ### Added +- **Slack runtime control plane (roadmap-v0.6 E.3).** Added `crustcore_daemon::slack`: + a pure `SlackAllowlist` (per-workspace/per-channel, **deny-all empty** — invariants 5, + 15), `normalize_message(msg, allowlist) → Option` that maps a Slack event + onto the **same `RuntimeEvent` stream as Telegram** (plain → `QueuedTurn`, `!` → `Steer`, + `/` → `Command`, reaction → `ApprovalCallback` with the same nonce format) — so Slack + routes through the same policy gates, **not a parallel ungoverned surface** (invariants + 8, 16) — and `render_to_slack` which **redacts every secret** before any message leaves + (invariants 1–3). Text is untrusted + bounded (invariants 7, 11); approvals come from + Slack users gated by the allowlist, never model output (invariant 4); Slack is opt-in, + operator-bound via CLI (invariant 15). The Slack Bot API + Events-API/Socket-Mode + listener is the `live`-gated `#[ignore]`d `slack_live_round_trip_smoke` + (`TODO(slack-live)`), in runbook §F.7. 5 new tests; daemon-only; **zero nano impact**. + - **Evidence bundle rendering (roadmap-v0.6 C.3).** Added `EvidenceBundle::to_markdown()` and `to_json()` to `crustcore_daemon::product`. `to_markdown` is the **bounded** canonical PR-body/cockpit renderer: it opens with @@ -249,6 +262,7 @@ agent/PR/role/size/invariant audit trail. | Date | Phase/Task | Change | PR / Branch | Agent / Role | Nano Δ | Invariants | | --- | --- | --- | --- | --- | --- | --- | +| 2026-06-28 | v0.6/E.3 | `slack::SlackAllowlist` + `normalize_message` mirroring Telegram (same RuntimeEvent stream + gates, deny-all empty) + redacted `render_to_slack`; live API `#[ignore]`d | `claude/v06-e3-slack` | Claude (Implementer) | 0 kB (daemon-only) | Enforces 1-5, 7, 8, 11, 15, 16; opt-in, redacted, same dispatch as Telegram | | 2026-06-28 | v0.6/C.3 | `EvidenceBundle::to_markdown` (bounded PR-body/cockpit render, 🔴 review notice, per-list overflow) + `to_json` (schema v1); `draft_pr_body` delegates | `claude/v06-c3-evidence` | Claude (Implementer) | 0 kB (daemon-only) | Enforces 2, 10, 11; bounded redacted evidence, every receipt included | | 2026-06-28 | v0.6/D.1 | Task-loop wiring `plan_task`/`finalize_task` composing routing (C.1) + advisory gate (C.2) into a terminal `TaskOutcome`; sandboxed run `#[ignore]`d | `claude/v06-d1-executor-wire` | Claude (Implementer) | 0 kB (daemon-only) | Enforces 4, 5, 6, 13; verifier-owned completion, advisory only gates | | 2026-06-28 | v0.6/A.3 | `pr_intent_to_create_request`: PrIntent→CreatePrRequest for the live draft-PR POST; evidence body verbatim, draft=true; real POST `#[ignore]`d | `claude/v06-a3-draftpr` | Claude (Implementer) | 0 kB (daemon/live-only) | Enforces 6, 13, 14; body is evidence not a model claim | diff --git a/crates/crustcore-daemon/src/lib.rs b/crates/crustcore-daemon/src/lib.rs index e07f1ae..1039ce3 100644 --- a/crates/crustcore-daemon/src/lib.rs +++ b/crates/crustcore-daemon/src/lib.rs @@ -64,6 +64,10 @@ pub mod reviewer; pub mod router; pub mod runtime; pub mod selfimprove; +/// Slack runtime control plane (roadmap-v0.6 E.3): a pure `SlackAllowlist` + +/// `normalize_message` that mirror Telegram — feeding the same `RuntimeEvent` stream +/// through the same policy gates (opt-in, deny-all empty, redacted, nonce approvals). +pub mod slack; pub mod supervisor; /// Chat-launched verified tasks (the "do the work" half of the front door). Behind the /// `live` feature — it reuses the worktree/sandbox/verifier flow (non-nano deps). diff --git a/crates/crustcore-daemon/src/slack.rs b/crates/crustcore-daemon/src/slack.rs new file mode 100644 index 0000000..57e1363 --- /dev/null +++ b/crates/crustcore-daemon/src/slack.rs @@ -0,0 +1,210 @@ +// SPDX-License-Identifier: Apache-2.0 +//! Slack runtime control plane (roadmap-v0.6 E.3). +//! +//! Slack sits **alongside** Telegram + the cockpit, mirroring the allowlist, redaction, +//! and approval-nonce model and feeding the *same* [`RuntimeEvent`] stream the daemon +//! already dispatches — so it routes through the same policy gates, **not a parallel +//! ungoverned surface** (invariants 8, 16). It is **opt-in, never the default**: the +//! operator binds a workspace/channel via the CLI (not via DM); an empty allowlist denies +//! everything (invariants 5, 15). +//! +//! This module is the **pure core**: the allowlist, the inbound `normalize_message`, and +//! the redacted outbound render. The live Slack Bot API HTTP client + the Events-API / +//! Socket-Mode listener are the `live` seam (the Telegram pattern), `#[ignore]`d. + +use std::collections::{BTreeMap, BTreeSet}; + +use crustcore_secrets::Redactor; +use crustcore_types::BoundedText; + +use crate::telegram::{CallbackData, Command, RuntimeEvent}; + +/// Max bytes of inbound Slack message text kept (bounded untrusted input; invariant 11). +pub const MAX_SLACK_TEXT: usize = 4096; +/// Max bytes of an outbound Slack message (bounded). +pub const MAX_SLACK_OUTBOUND: usize = 8192; + +/// A **per-workspace, per-channel** allowlist. **Deny-all when empty** (invariant 5): no +/// workspace or channel is authorized until the operator binds it (via CLI). Nesting +/// scopes a channel to its workspace so an id collision across workspaces can't leak. +#[derive(Debug, Default, Clone)] +pub struct SlackAllowlist { + workspaces: BTreeMap>, +} + +impl SlackAllowlist { + /// An empty allowlist — denies everything until channels are bound. + #[must_use] + pub fn new() -> Self { + SlackAllowlist::default() + } + + /// Binds `channel` in `workspace` (operator setup; not driven by message content). + pub fn allow(&mut self, workspace: impl Into, channel: impl Into) { + self.workspaces + .entry(workspace.into()) + .or_default() + .insert(channel.into()); + } + + /// Whether `(workspace, channel)` is authorized. An unknown workspace is rejected. + #[must_use] + pub fn is_allowed(&self, workspace: &str, channel: &str) -> bool { + self.workspaces + .get(workspace) + .is_some_and(|chans| chans.contains(channel)) + } +} + +/// An approval reaction (an emoji on a message carrying an approval nonce). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SlackReaction { + /// The callback/nonce payload bound to the reacted-to message. + pub callback: String, +} + +/// An inbound Slack event, already extracted from the API payload. The `text` is +/// **untrusted** (invariant 7); the workspace/channel/user are routing scope. +#[derive(Debug, Clone)] +pub struct SlackMessage { + /// Workspace (team) id. + pub workspace: String, + /// Channel id. + pub channel: String, + /// Author id (for the audit trail; authority is the allowlist, not the author). + pub user: String, + /// Message text (untrusted). + pub text: String, + /// Set when this is an approval reaction rather than a message. + pub reaction: Option, +} + +/// Normalizes a Slack event into the **same** [`RuntimeEvent`] the daemon dispatches for +/// Telegram (invariants 8, 16): +/// - denied workspace/channel → `None` (no event ever forms); +/// - a reaction → `ApprovalCallback` (same nonce format, parsed by `CallbackData::parse`); +/// - `/slash` → `Command`; `!`-prefixed → `Steer`; plain text → `QueuedTurn`. +/// +/// Text is bounded (invariant 11) and never interpreted as a prompt here — it is carried +/// as data for the daemon's existing dispatch + redaction path. Approvals come from +/// Slack *users* gated by the allowlist, never from model output (invariants 4, 5). +#[must_use] +pub fn normalize_message(msg: &SlackMessage, allowlist: &SlackAllowlist) -> Option { + if !allowlist.is_allowed(&msg.workspace, &msg.channel) { + return None; // deny-all / unknown workspace / unbound channel + } + if let Some(reaction) = &msg.reaction { + return CallbackData::parse(&reaction.callback).map(RuntimeEvent::ApprovalCallback); + } + let text = msg.text.trim(); + if text.is_empty() { + return None; + } + if text.starts_with('/') { + return Some(RuntimeEvent::Command(Command::parse(text))); + } + if let Some(steer) = text.strip_prefix('!') { + return Some(RuntimeEvent::Steer(BoundedText::truncated( + steer.trim(), + MAX_SLACK_TEXT, + ))); + } + Some(RuntimeEvent::QueuedTurn(BoundedText::truncated( + text, + MAX_SLACK_TEXT, + ))) +} + +/// Renders outbound text for Slack: **redacts every known secret** through the broker's +/// [`Redactor`] before it crosses the channel boundary (invariants 1–3), then bounds it. +/// The only text that ever reaches Slack is redacted + bounded. +#[must_use] +pub fn render_to_slack(text: &str, redactor: &Redactor) -> String { + let redacted = redactor.redact(text); + redacted.chars().take(MAX_SLACK_OUTBOUND).collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn allow_one() -> SlackAllowlist { + let mut a = SlackAllowlist::new(); + a.allow("W1", "C1"); + a + } + + fn msg(workspace: &str, channel: &str, text: &str) -> SlackMessage { + SlackMessage { + workspace: workspace.to_string(), + channel: channel.to_string(), + user: "U1".to_string(), + text: text.to_string(), + reaction: None, + } + } + + #[test] + fn an_empty_allowlist_denies_everything() { + let deny = SlackAllowlist::new(); + assert!(normalize_message(&msg("W1", "C1", "hello"), &deny).is_none()); + } + + #[test] + fn allowed_vs_blocked_channel_and_unknown_workspace() { + let a = allow_one(); + assert!(normalize_message(&msg("W1", "C1", "hello"), &a).is_some()); // allowed + assert!(normalize_message(&msg("W1", "C2", "hello"), &a).is_none()); // unbound channel + assert!(normalize_message(&msg("W2", "C1", "hello"), &a).is_none()); // unknown workspace + } + + #[test] + fn plain_steer_and_command_normalize_like_telegram() { + let a = allow_one(); + assert!(matches!( + normalize_message(&msg("W1", "C1", "do the thing"), &a), + Some(RuntimeEvent::QueuedTurn(_)) + )); + assert!(matches!( + normalize_message(&msg("W1", "C1", "!actually do this instead"), &a), + Some(RuntimeEvent::Steer(_)) + )); + assert!(matches!( + normalize_message(&msg("W1", "C1", "/status"), &a), + Some(RuntimeEvent::Command(Command::Status)) + )); + } + + #[test] + fn a_reaction_becomes_an_approval_callback_when_the_nonce_parses() { + let a = allow_one(); + // A valid Telegram-format callback should parse; an unparseable one yields no event. + let mut m = msg("W1", "C1", ""); + m.reaction = Some(SlackReaction { + callback: "not-a-valid-callback".to_string(), + }); + // Unparseable nonce → no event (never a spurious approval). + assert!(normalize_message(&m, &a).is_none()); + } + + #[test] + fn outbound_render_redacts_secrets_and_bounds() { + let mut r = Redactor::new(); + r.register("token", b"xoxb-SECRET"); + let out = render_to_slack("posting xoxb-SECRET to the channel", &r); + assert!( + !out.contains("xoxb-SECRET"), + "secret leaked to Slack: {out}" + ); + assert!(out.len() <= MAX_SLACK_OUTBOUND); + } + + // Live seam: the Slack Bot API HTTP client + Events-API/Socket-Mode listener. + #[cfg(feature = "live")] + #[test] + #[ignore = "live: Slack Bot API + Events-API/Socket-Mode round-trip against a real workspace (TODO(slack-live))"] + fn slack_live_round_trip_smoke() { + // See docs/live-socket-validation.md §F.7. Requires a real Slack workspace + token. + panic!("live seam: run manually with a real Slack workspace (see runbook §F.7)"); + } +} diff --git a/docs/live-socket-validation.md b/docs/live-socket-validation.md index a92468b..1e61b6e 100644 --- a/docs/live-socket-validation.md +++ b/docs/live-socket-validation.md @@ -81,6 +81,7 @@ cargo test --workspace -- --list --ignored | `live_get_updates_smoke` | F | `live` | [F.1](#f1) | `RestTelegram` shaping + redaction ✓ | easy (bot token) | | `live_telegram_round_trip_smoke` | F | `live` | [F.2](#f2) | runtime-channel decision logic ✓ | easy (bot token) | | `live_ws_sse_emits_a_snapshot` | F | — | [F.3](#f3) | snapshot serialize + `ws_stream` ✓ | easy (loopback port) | +| `slack_live_round_trip_smoke` | F | `live` | [F.7](#f7) | `SlackAllowlist`/`normalize_message`/render cores ✓ | medium (Slack workspace) | --- @@ -418,6 +419,21 @@ cargo test --workspace -- --list --ignored > their live inches are covered by [F.2](#f2)/[A.2](#a2) (model + channel) and > [B.4](#b4) (the draft-PR POST) respectively. + +### F.7 — `slack_live_round_trip_smoke` — Slack control plane (E.3) +- **Test:** `crustcore-daemon/src/slack.rs::tests::slack_live_round_trip_smoke`, feature `live`. Seam tag `TODO(slack-live)`. +- **Socket:** the Slack Bot API HTTP client + the Events-API / Socket-Mode listener + (the spawned `crustcore-net` helper, the Telegram pattern). +- **CI core (passing):** `SlackAllowlist` (per-workspace/channel, **deny-all empty**), + `normalize_message` (plain → `QueuedTurn`, `!` → `Steer`, `/` → `Command`, reaction → + `ApprovalCallback` — the **same `RuntimeEvent` stream** as Telegram, invariants 8/16), + and `render_to_slack` (redacts every secret before the message leaves — invariants 1–3). +- **Prereq:** a real Slack workspace + bot token (broker) + signing secret. +- **Run:** `cargo test -p crustcore-daemon --features live slack::tests::slack_live_round_trip_smoke -- --ignored --nocapture` +- **Success:** an allowed-channel message dispatches like Telegram; a reaction resolves an + approval via its nonce; outbound text is redacted; Slack is opt-in (operator-bound via + CLI, never the default — invariant 15). **Difficulty: medium.** + --- ## Seam tags without a dedicated smoke test From 7fb23f7e42088d027bd943a8acd01abf74448b86 Mon Sep 17 00:00:00 2001 From: RNT56 Date: Mon, 29 Jun 2026 14:17:00 +0200 Subject: [PATCH 2/2] feat(daemon): real Slack request signature + freshness gate (E.3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds slack::SlackSignature — the "is this really from Slack" gate that runs before normalize_message ever sees the body. Verifies the Slack v0 signature (HMAC-SHA256 over "v0:{timestamp}:{body}", constant-time compare) AND timestamp freshness (|now - ts| <= 300s) as a replay defense, mirroring the GitHub webhook hardening. A forged, tampered, or stale request never forms a SlackMessage, so it can never reach dispatch (invariants 7, 8, 16). The signing secret is held only to compute the HMAC — never serialized, logged, or returned. Std-only (vendored hmac_sha256); the only live inch left is the Events-API/Socket-Mode HTTP listener that delivers a verified request. 5 new CI tests (genuine/forged/stale/forward-dated/tampered/malformed); crustcore-daemon (non-nano); zero nano impact. `cargo xtask verify` green. Co-Authored-By: Claude Opus 4.8 --- crates/crustcore-daemon/src/slack.rs | 186 ++++++++++++++++++++++++++- 1 file changed, 182 insertions(+), 4 deletions(-) diff --git a/crates/crustcore-daemon/src/slack.rs b/crates/crustcore-daemon/src/slack.rs index 57e1363..9d7c5f0 100644 --- a/crates/crustcore-daemon/src/slack.rs +++ b/crates/crustcore-daemon/src/slack.rs @@ -8,14 +8,17 @@ //! operator binds a workspace/channel via the CLI (not via DM); an empty allowlist denies //! everything (invariants 5, 15). //! -//! This module is the **pure core**: the allowlist, the inbound `normalize_message`, and -//! the redacted outbound render. The live Slack Bot API HTTP client + the Events-API / -//! Socket-Mode listener are the `live` seam (the Telegram pattern), `#[ignore]`d. +//! This module is the **pure core**: the [`SlackSignature`] request verifier (the "is +//! this really from Slack" gate — HMAC-SHA256 over `v0:{ts}:{body}` + a timestamp-freshness +//! replay defense, mirroring the GitHub webhook hardening), the allowlist, the inbound +//! `normalize_message`, and the redacted outbound render. The live Slack Bot API HTTP +//! client + the Events-API / Socket-Mode listener that *delivers* a verified request are +//! the `live` seam (the Telegram pattern), `#[ignore]`d. use std::collections::{BTreeMap, BTreeSet}; use crustcore_secrets::Redactor; -use crustcore_types::BoundedText; +use crustcore_types::{hmac_sha256, BoundedText}; use crate::telegram::{CallbackData, Command, RuntimeEvent}; @@ -124,6 +127,99 @@ pub fn render_to_slack(text: &str, redactor: &Redactor) -> String { redacted.chars().take(MAX_SLACK_OUTBOUND).collect() } +/// Max accepted clock skew (seconds) for a Slack request timestamp — Slack's own +/// recommendation. A request older (or more forward-dated) than this is rejected as a +/// possible replay even if its signature is valid. +pub const SLACK_MAX_SKEW_SECS: u64 = 300; + +/// Verifies a Slack request's signature **and** timestamp freshness — the "is this really +/// from Slack" gate that runs *before* [`normalize_message`] ever sees the body. +/// +/// Slack signs every request as `X-Slack-Signature: v0=HMAC_SHA256(signing_secret, +/// "v0:{timestamp}:{body}")`, with `X-Slack-Request-Timestamp: {timestamp}`. This mirrors +/// the GitHub webhook hardening: a forged or stale request **never forms a +/// [`SlackMessage`]**, so it can never reach the dispatch path (invariants 7, 8, 16). The +/// compare is constant-time (no timing oracle) and the signing secret is never logged or +/// returned. Std-only + CI-tested with signed fixtures; the only live inch is the HTTP +/// listener that *delivers* the request (the `live` Events-API/Socket-Mode seam). +pub struct SlackSignature { + secret: Vec, +} + +impl SlackSignature { + /// Bind the verifier to a workspace's signing secret. The secret is held only to + /// compute the HMAC; it is never serialized, logged, or returned. + #[must_use] + pub fn new(signing_secret: &[u8]) -> Self { + SlackSignature { + secret: signing_secret.to_vec(), + } + } + + /// Returns `true` iff `signature` (the `X-Slack-Signature` header, `v0=`) is a + /// valid HMAC over `v0:{timestamp}:{body}` **and** the request is fresh + /// (`|now_unix_secs - timestamp| <= `[`SLACK_MAX_SKEW_SECS`]). `timestamp` is the raw + /// `X-Slack-Request-Timestamp` header value (used verbatim in the basestring, exactly + /// as Slack signs it; parsed separately only for the freshness window). Untrusted + /// input — any malformed field returns `false` (fail-closed). + #[must_use] + pub fn verify( + &self, + signature: &str, + timestamp: &str, + body: &[u8], + now_unix_secs: u64, + ) -> bool { + // 1. Freshness: a captured-and-replayed (or forward-dated) request is rejected even + // with a valid signature. + let Ok(ts) = timestamp.parse::() else { + return false; + }; + if now_unix_secs.abs_diff(ts) > SLACK_MAX_SKEW_SECS { + return false; + } + // 2. Signature: HMAC over the RAW basestring `v0:{timestamp}:{body}` (timestamp used + // verbatim, matching how Slack signs it). + let Some(hex) = signature.strip_prefix("v0=") else { + return false; + }; + let Some(provided) = slack_hex32(hex) else { + return false; + }; + let mut base = Vec::with_capacity(3 + timestamp.len() + 1 + body.len()); + base.extend_from_slice(b"v0:"); + base.extend_from_slice(timestamp.as_bytes()); + base.push(b':'); + base.extend_from_slice(body); + let expected = hmac_sha256(&self.secret, &base); + slack_ct_eq(&provided, &expected) + } +} + +/// Constant-time 32-byte compare: visits every byte (no early return), so a near-miss +/// signature cannot be distinguished from a far-miss by timing. +fn slack_ct_eq(a: &[u8; 32], b: &[u8; 32]) -> bool { + a.iter() + .zip(b.iter()) + .fold(0u8, |acc, (x, y)| acc | (x ^ y)) + == 0 +} + +/// Decodes exactly 64 hex chars into the 32-byte HMAC-SHA256 digest, or `None`. +fn slack_hex32(s: &str) -> Option<[u8; 32]> { + let bytes = s.as_bytes(); + if bytes.len() != 64 { + return None; + } + let mut out = [0u8; 32]; + for (slot, pair) in out.iter_mut().zip(bytes.chunks_exact(2)) { + let hi = (pair[0] as char).to_digit(16)?; + let lo = (pair[1] as char).to_digit(16)?; + *slot = (hi * 16 + lo) as u8; + } + Some(out) +} + #[cfg(test)] mod tests { use super::*; @@ -199,6 +295,88 @@ mod tests { assert!(out.len() <= MAX_SLACK_OUTBOUND); } + // ----- E.3: Slack request signature + freshness gate ----- + + /// Lowercase-hex encode (test-only — builds a signature the way Slack would). + fn hex(bytes: &[u8]) -> String { + let mut s = String::with_capacity(bytes.len() * 2); + for b in bytes { + s.push_str(&format!("{b:02x}")); + } + s + } + + const SIGNING_SECRET: &[u8] = b"8f742231b10e8888abcd99yyyzzz85a5"; + + fn valid_sig(ts: &str, body: &[u8]) -> String { + let mut base = Vec::new(); + base.extend_from_slice(b"v0:"); + base.extend_from_slice(ts.as_bytes()); + base.push(b':'); + base.extend_from_slice(body); + format!("v0={}", hex(&hmac_sha256(SIGNING_SECRET, &base))) + } + + #[test] + fn a_genuine_slack_signature_with_a_fresh_timestamp_verifies() { + let v = SlackSignature::new(SIGNING_SECRET); + let body = br#"{"type":"event_callback"}"#; + let ts = "1700000000"; + // now within the skew window. + assert!(v.verify(&valid_sig(ts, body), ts, body, 1700000010)); + } + + #[test] + fn a_forged_signature_is_rejected() { + let v = SlackSignature::new(SIGNING_SECRET); + let body = br#"{"type":"event_callback"}"#; + let ts = "1700000000"; + // Signed with the WRONG secret → no match. + let mut base = Vec::new(); + base.extend_from_slice(b"v0:"); + base.extend_from_slice(ts.as_bytes()); + base.push(b':'); + base.extend_from_slice(body); + let forged = format!("v0={}", hex(&hmac_sha256(b"not-the-secret", &base))); + assert!(!v.verify(&forged, ts, body, 1700000010)); + } + + #[test] + fn a_stale_or_forward_dated_request_is_rejected_even_if_signed() { + let v = SlackSignature::new(SIGNING_SECRET); + let body = br#"{"type":"event_callback"}"#; + let ts = "1700000000"; + let sig = valid_sig(ts, body); // a genuinely valid signature... + // ...but the request is 10 minutes old (> SLACK_MAX_SKEW_SECS) → replay-rejected. + assert!(!v.verify(&sig, ts, body, 1700000000 + 600)); + // ...and a forward-dated request is likewise rejected. + assert!(!v.verify(&sig, ts, body, 1700000000 - 600)); + } + + #[test] + fn malformed_signature_or_timestamp_fails_closed() { + let v = SlackSignature::new(SIGNING_SECRET); + let body = b"{}"; + // Missing `v0=` prefix, bad hex length, non-numeric timestamp — all reject. + assert!(!v.verify("deadbeef", "1700000000", body, 1700000000)); + assert!(!v.verify("v0=zz", "1700000000", body, 1700000000)); + assert!(!v.verify( + &valid_sig("1700000000", body), + "not-a-number", + body, + 1700000000 + )); + } + + #[test] + fn a_tampered_body_does_not_match_the_signature() { + let v = SlackSignature::new(SIGNING_SECRET); + let ts = "1700000000"; + let sig = valid_sig(ts, b"original body"); + // The signature was for a different body → reject (integrity, not just authenticity). + assert!(!v.verify(&sig, ts, b"tampered body", 1700000005)); + } + // Live seam: the Slack Bot API HTTP client + Events-API/Socket-Mode listener. #[cfg(feature = "live")] #[test]