sdd-layer 0.18.0

Spec-Driven Development CLI and agent harness
//! Verificação de assinatura de callback do Slack (SDD-OAD-014, anti-spoof).
//!
//! O Slack assina cada requisição com `v0=HMAC-SHA256(signing_secret,
//! "v0:{timestamp}:{body}")` no header `X-Slack-Signature`. Verificamos a
//! assinatura (comparação de tempo constante) e a janela de timestamp
//! (anti-replay) antes de aceitar qualquer decisão de gate vinda do Slack.
//!
//! HMAC-SHA256 é implementado sobre `sha2` (já nas dependências) — sem crate
//! extra. A função é a barreira primária do canal Slack (RF-07 / segurança A3):
//! uma decisão por Slack só vira `Decision { channel: Slack }` (via
//! `approval::apply_decision`) depois de passar por aqui. O endpoint de webhook
//! que a invoca é a etapa de wiring/deploy.

use sha2::{Digest, Sha256};

const BLOCK_SIZE: usize = 64;

/// Janela padrão de tolerância do timestamp (segundos) — recomendação do Slack.
pub const DEFAULT_MAX_SKEW_SECS: i64 = 300;

/// Resultado da verificação de assinatura.
#[derive(Debug, PartialEq, Eq)]
pub enum SignatureError {
    StaleTimestamp,
    Mismatch,
}

impl std::fmt::Display for SignatureError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Self::StaleTimestamp => write!(f, "timestamp fora da janela (possível replay)"),
            Self::Mismatch => write!(f, "assinatura Slack inválida"),
        }
    }
}

impl std::error::Error for SignatureError {}

/// Verifica a assinatura de um callback do Slack.
/// - `now`/`timestamp` em epoch seconds; rejeita se |now - timestamp| > skew.
/// - Comparação de tempo constante contra timing attacks.
// Invocada pelo endpoint de webhook (wiring/deploy); testada isoladamente.
#[allow(dead_code)]
pub fn verify_slack_signature(
    signing_secret: &str,
    timestamp: i64,
    body: &str,
    signature_header: &str,
    now: i64,
    max_skew_secs: i64,
) -> Result<(), SignatureError> {
    if (now - timestamp).abs() > max_skew_secs {
        return Err(SignatureError::StaleTimestamp);
    }
    let basestring = format!("v0:{timestamp}:{body}");
    let expected = format!(
        "v0={}",
        to_hex(&hmac_sha256(
            signing_secret.as_bytes(),
            basestring.as_bytes()
        ))
    );
    if constant_time_eq(expected.as_bytes(), signature_header.as_bytes()) {
        Ok(())
    } else {
        Err(SignatureError::Mismatch)
    }
}

fn hmac_sha256(key: &[u8], message: &[u8]) -> [u8; 32] {
    let mut block_key = [0u8; BLOCK_SIZE];
    if key.len() > BLOCK_SIZE {
        let digest = Sha256::digest(key);
        block_key[..digest.len()].copy_from_slice(&digest);
    } else {
        block_key[..key.len()].copy_from_slice(key);
    }

    let mut ipad = [0x36u8; BLOCK_SIZE];
    let mut opad = [0x5cu8; BLOCK_SIZE];
    for i in 0..BLOCK_SIZE {
        ipad[i] ^= block_key[i];
        opad[i] ^= block_key[i];
    }

    let mut inner = Sha256::new();
    inner.update(ipad);
    inner.update(message);
    let inner_digest = inner.finalize();

    let mut outer = Sha256::new();
    outer.update(opad);
    outer.update(inner_digest);
    let outer_digest = outer.finalize();

    let mut out = [0u8; 32];
    out.copy_from_slice(&outer_digest);
    out
}

fn to_hex(bytes: &[u8]) -> String {
    let mut s = String::with_capacity(bytes.len() * 2);
    for b in bytes {
        s.push_str(&format!("{b:02x}"));
    }
    s
}

fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
    if a.len() != b.len() {
        return false;
    }
    let mut diff = 0u8;
    for (x, y) in a.iter().zip(b.iter()) {
        diff |= x ^ y;
    }
    diff == 0
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn hmac_matches_rfc4231_test_case_2() {
        // RFC 4231, Test Case 2.
        let mac = hmac_sha256(b"Jefe", b"what do ya want for nothing?");
        assert_eq!(
            to_hex(&mac),
            "5bdcc146bf60754e6a042426089575c75a003f089d2739839dec58b964ec3843"
        );
    }

    fn sign(secret: &str, ts: i64, body: &str) -> String {
        format!(
            "v0={}",
            to_hex(&hmac_sha256(
                secret.as_bytes(),
                format!("v0:{ts}:{body}").as_bytes()
            ))
        )
    }

    #[test]
    fn valid_signature_within_window_passes() {
        let secret = "8f742231b10e8888abcd99yyyzzz85a5";
        let body = "payload=approve&id=DEM-1";
        let sig = sign(secret, 1000, body);
        assert!(
            verify_slack_signature(secret, 1000, body, &sig, 1100, DEFAULT_MAX_SKEW_SECS).is_ok()
        );
    }

    #[test]
    fn tampered_body_is_rejected() {
        let secret = "secret";
        let sig = sign(secret, 1000, "original");
        let err =
            verify_slack_signature(secret, 1000, "tampered", &sig, 1000, DEFAULT_MAX_SKEW_SECS)
                .unwrap_err();
        assert_eq!(err, SignatureError::Mismatch);
    }

    #[test]
    fn wrong_secret_is_rejected() {
        let sig = sign("secret-a", 1000, "body");
        assert_eq!(
            verify_slack_signature("secret-b", 1000, "body", &sig, 1000, DEFAULT_MAX_SKEW_SECS),
            Err(SignatureError::Mismatch)
        );
    }

    #[test]
    fn stale_timestamp_is_rejected_anti_replay() {
        let secret = "secret";
        let body = "body";
        let sig = sign(secret, 1000, body);
        // now muito além da janela.
        let err =
            verify_slack_signature(secret, 1000, body, &sig, 1000 + 999, DEFAULT_MAX_SKEW_SECS)
                .unwrap_err();
        assert_eq!(err, SignatureError::StaleTimestamp);
    }

    #[test]
    fn length_mismatch_is_constant_time_false() {
        assert!(!constant_time_eq(b"abc", b"abcd"));
        assert!(constant_time_eq(b"abc", b"abc"));
    }
}