1use 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}