Skip to main content

tf_types/
bridge_webhook.rs

1//! Webhook bridge — Rust mirror. HMAC-SHA256, HMAC-SHA1, and ed25519
2//! signature schemes; vendor-event → action mapping; replay-window.
3
4use ed25519_dalek::{Signature, Verifier, VerifyingKey};
5use hmac::{Hmac, Mac};
6use serde::{Deserialize, Serialize};
7use serde_json::{json, Value};
8use sha1::Sha1;
9use sha2::Sha256;
10
11use crate::bridges::{Bridge, BridgeError, BridgeKind};
12
13type HmacSha256 = Hmac<Sha256>;
14type HmacSha1 = Hmac<Sha1>;
15
16#[derive(Clone, Debug, Serialize, Deserialize)]
17pub enum WebhookScheme {
18    HmacSha256,
19    HmacSha1,
20    Ed25519,
21}
22
23#[derive(Clone, Debug)]
24pub struct WebhookBridgeConfig {
25    pub bridge_id: String,
26    pub trust_domain: String,
27    pub vendor: String,
28    pub scheme: WebhookScheme,
29    pub secret: Vec<u8>,
30    pub max_age_seconds: Option<i64>,
31    pub default_risk: Option<String>,
32}
33
34#[derive(Clone, Debug)]
35pub struct VerifyWebhookArgs {
36    pub body: Vec<u8>,
37    pub signature_header: String,
38    pub timestamp_header: Option<String>,
39    pub event_type: String,
40    pub event_id: String,
41    pub received_at: Option<String>,
42}
43
44#[derive(Clone, Debug)]
45pub struct WebhookVerificationResult {
46    pub event: Value,
47    pub capability: Value,
48}
49
50pub struct WebhookBridge {
51    cfg: WebhookBridgeConfig,
52}
53
54impl WebhookBridge {
55    pub fn new(cfg: WebhookBridgeConfig) -> Self {
56        WebhookBridge { cfg }
57    }
58
59    pub fn verify(
60        &self,
61        args: VerifyWebhookArgs,
62    ) -> Result<WebhookVerificationResult, BridgeError> {
63        let now_str = args.received_at.clone().unwrap_or_else(now_iso8601);
64        let ok = match self.cfg.scheme {
65            WebhookScheme::HmacSha256 => {
66                let mut mac = HmacSha256::new_from_slice(&self.cfg.secret)
67                    .map_err(|e| BridgeError::InvalidInput(format!("hmac: {}", e)))?;
68                mac.update(&args.body);
69                let computed = mac.finalize().into_bytes();
70                let expected = hex(&computed);
71                let provided = args
72                    .signature_header
73                    .to_lowercase()
74                    .trim_start_matches("sha256=")
75                    .to_string();
76                constant_time_eq_hex(&expected, &provided)
77            }
78            WebhookScheme::HmacSha1 => {
79                let mut mac = HmacSha1::new_from_slice(&self.cfg.secret)
80                    .map_err(|e| BridgeError::InvalidInput(format!("hmac: {}", e)))?;
81                mac.update(&args.body);
82                let computed = mac.finalize().into_bytes();
83                let expected = hex(&computed);
84                let provided = args
85                    .signature_header
86                    .to_lowercase()
87                    .trim_start_matches("sha1=")
88                    .to_string();
89                constant_time_eq_hex(&expected, &provided)
90            }
91            WebhookScheme::Ed25519 => {
92                let ts = args.timestamp_header.as_ref().ok_or_else(|| {
93                    BridgeError::InvalidInput("ed25519 webhook requires timestamp header".into())
94                })?;
95                let mut payload = Vec::with_capacity(ts.len() + 1 + args.body.len());
96                payload.extend_from_slice(ts.as_bytes());
97                payload.push(b'.');
98                payload.extend_from_slice(&args.body);
99                let sig_bytes = decode_hex(&args.signature_header)
100                    .ok_or_else(|| BridgeError::InvalidInput("signature header not hex".into()))?;
101                let pk_arr: [u8; 32] = self.cfg.secret.as_slice().try_into().map_err(|_| {
102                    BridgeError::InvalidInput("ed25519 public key must be 32 bytes".into())
103                })?;
104                let vk = VerifyingKey::from_bytes(&pk_arr)
105                    .map_err(|e| BridgeError::InvalidInput(format!("ed25519 key: {}", e)))?;
106                let sig = Signature::from_slice(&sig_bytes)
107                    .map_err(|e| BridgeError::InvalidInput(format!("signature parse: {}", e)))?;
108                vk.verify(&payload, &sig).is_ok()
109            }
110        };
111        if !ok {
112            return Err(BridgeError::Rejected(format!(
113                "webhook signature failed ({:?})",
114                self.cfg.scheme
115            )));
116        }
117        let max = self.cfg.max_age_seconds.unwrap_or(300);
118        if let Some(ts) = &args.timestamp_header {
119            let ts_str = if ts.contains('T') {
120                ts.clone()
121            } else {
122                let secs = ts.parse::<i64>().unwrap_or(0);
123                let (y, m, d, h, mi, s) = secs_to_ymdhms(secs);
124                format!("{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z", y, m, d, h, mi, s)
125            };
126            if let (Some(t1), Some(t2)) = (parse_iso8601(&ts_str), parse_iso8601(&now_str)) {
127                let age = (t2 - t1).abs();
128                if age > max {
129                    return Err(BridgeError::Rejected(format!(
130                        "webhook age {}s exceeds {}s",
131                        age, max
132                    )));
133                }
134            }
135        }
136        let actor = format!(
137            "tf:actor:service:{}/{}",
138            self.cfg.trust_domain, self.cfg.vendor
139        );
140        let action = format!(
141            "webhook.{}.{}",
142            self.cfg.vendor,
143            args.event_type.replace(
144                |c: char| !c.is_ascii_alphanumeric() && c != '.' && c != '_' && c != '-',
145                "_"
146            )
147        );
148        let event = json!({
149            "event_version": "1",
150            "id": args.event_id,
151            "type": action,
152            "actor_id": actor,
153            "timestamp": now_str,
154            "level": "L2",
155            "context": {
156                "vendor": self.cfg.vendor,
157                "scheme": format!("{:?}", self.cfg.scheme),
158                "event_type": args.event_type,
159            },
160            "signature": { "algorithm": "ed25519", "signer": actor, "signature": "AAAA" }
161        });
162        let capability = json!({
163            "name": action,
164            "risk": self.cfg.default_risk.clone().unwrap_or_else(|| "R2".to_string()),
165        });
166        Ok(WebhookVerificationResult { event, capability })
167    }
168}
169
170impl Bridge for WebhookBridge {
171    fn bridge_id(&self) -> &str {
172        &self.cfg.bridge_id
173    }
174    fn kind(&self) -> BridgeKind {
175        BridgeKind::Webhook
176    }
177    fn trust_domain(&self) -> &str {
178        &self.cfg.trust_domain
179    }
180}
181
182fn hex(bytes: &[u8]) -> String {
183    bytes.iter().map(|b| format!("{:02x}", b)).collect()
184}
185
186fn decode_hex(s: &str) -> Option<Vec<u8>> {
187    let trimmed = s.trim().to_lowercase().trim_start_matches("0x").to_string();
188    let trimmed = trimmed
189        .trim_start_matches("sha256=")
190        .trim_start_matches("sha1=");
191    if !trimmed.len().is_multiple_of(2) {
192        return None;
193    }
194    let mut out = Vec::with_capacity(trimmed.len() / 2);
195    for i in (0..trimmed.len()).step_by(2) {
196        out.push(u8::from_str_radix(&trimmed[i..i + 2], 16).ok()?);
197    }
198    Some(out)
199}
200
201fn constant_time_eq_hex(a: &str, b: &str) -> bool {
202    if a.len() != b.len() {
203        return false;
204    }
205    let mut diff = 0u8;
206    for (x, y) in a.bytes().zip(b.bytes()) {
207        diff |= x ^ y;
208    }
209    diff == 0
210}
211
212fn parse_iso8601(s: &str) -> Option<i64> {
213    if s.len() < 19 {
214        return None;
215    }
216    let year: i64 = s[..4].parse().ok()?;
217    let month: u32 = s[5..7].parse().ok()?;
218    let day: u32 = s[8..10].parse().ok()?;
219    let hour: u32 = s[11..13].parse().ok()?;
220    let minute: u32 = s[14..16].parse().ok()?;
221    let second: u32 = s[17..19].parse().ok()?;
222    let y = if month <= 2 { year - 1 } else { year };
223    let era = if y >= 0 { y } else { y - 399 } / 400;
224    let yoe = (y - era * 400) as u64;
225    let m = if month > 2 { month - 3 } else { month + 9 };
226    let doy = (153 * m as u64 + 2) / 5 + day as u64 - 1;
227    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
228    let days = era * 146_097 + doe as i64 - 719_468;
229    Some(days * 86_400 + (hour as i64) * 3600 + (minute as i64) * 60 + second as i64)
230}
231
232fn now_iso8601() -> String {
233    let secs = std::time::SystemTime::now()
234        .duration_since(std::time::UNIX_EPOCH)
235        .unwrap_or_default()
236        .as_secs() as i64;
237    let (y, m, d, h, mi, s) = secs_to_ymdhms(secs);
238    format!("{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z", y, m, d, h, mi, s)
239}
240
241fn secs_to_ymdhms(secs: i64) -> (i32, u32, u32, u32, u32, u32) {
242    let days = secs.div_euclid(86_400);
243    let time = secs.rem_euclid(86_400);
244    let hour = (time / 3600) as u32;
245    let minute = ((time % 3600) / 60) as u32;
246    let second = (time % 60) as u32;
247    let z = days + 719_468;
248    let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
249    let doe = (z - era * 146_097) as u64;
250    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
251    let y = yoe as i64 + era * 400;
252    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
253    let mp = (5 * doy + 2) / 153;
254    let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
255    let m = if mp < 10 {
256        (mp + 3) as u32
257    } else {
258        (mp - 9) as u32
259    };
260    let year = if m <= 2 { y + 1 } else { y };
261    (year as i32, m, d, hour, minute, second)
262}