1use std::time::Duration;
22#[cfg(feature = "ibct")]
23use std::time::{SystemTime, UNIX_EPOCH};
24
25use serde::{Deserialize, Serialize};
26use thiserror::Error;
27
28#[cfg(feature = "ibct")]
29use hmac::{Hmac, KeyInit, Mac};
30#[cfg(feature = "ibct")]
31use sha2::Sha256;
32
33#[cfg(feature = "ibct")]
35const CLOCK_SKEW_GRACE_SECS: u64 = 30;
36
37#[derive(Debug, Error)]
39pub enum IbctError {
40 #[error("IBCT signature invalid")]
41 InvalidSignature,
42
43 #[error("IBCT expired (expires_at={expires_at}, now={now})")]
44 Expired { expires_at: u64, now: u64 },
45
46 #[error("IBCT endpoint mismatch: expected {expected}, got {got}")]
47 EndpointMismatch { expected: String, got: String },
48
49 #[error("IBCT task_id mismatch: expected {expected}, got {got}")]
50 TaskMismatch { expected: String, got: String },
51
52 #[error("IBCT key_id '{key_id}' not found in the configured key set")]
53 UnknownKeyId { key_id: String },
54
55 #[error("IBCT feature not enabled (compile with feature 'ibct')")]
56 FeatureDisabled,
57
58 #[error("base64 decode error: {0}")]
59 Base64(#[from] base64_compat::DecodeError),
60
61 #[error("JSON error: {0}")]
62 Json(#[from] serde_json::Error),
63}
64
65#[derive(Debug, Clone, Serialize, Deserialize)]
70pub struct IbctKey {
71 pub key_id: String,
73 #[serde(with = "hex_bytes")]
75 pub key_bytes: Vec<u8>,
76}
77
78#[derive(Debug, Clone, Serialize, Deserialize)]
80pub struct Ibct {
81 pub key_id: String,
83 pub task_id: String,
85 pub endpoint: String,
87 pub issued_at: u64,
89 pub expires_at: u64,
91 pub signature: String,
93}
94
95impl Ibct {
96 #[allow(clippy::needless_return)]
102 pub fn issue(
103 task_id: &str,
104 endpoint: &str,
105 ttl: Duration,
106 key: &IbctKey,
107 ) -> Result<Self, IbctError> {
108 #[cfg(not(feature = "ibct"))]
109 {
110 let _ = (task_id, endpoint, ttl, key);
111 return Err(IbctError::FeatureDisabled);
112 }
113 #[cfg(feature = "ibct")]
114 {
115 let now = unix_now();
116 let expires_at = now + ttl.as_secs();
117 let signature = sign(
118 &key.key_bytes,
119 &key.key_id,
120 task_id,
121 endpoint,
122 now,
123 expires_at,
124 );
125 Ok(Self {
126 key_id: key.key_id.clone(),
127 task_id: task_id.to_owned(),
128 endpoint: endpoint.to_owned(),
129 issued_at: now,
130 expires_at,
131 signature,
132 })
133 }
134 }
135
136 #[allow(clippy::needless_return)]
145 pub fn verify(
146 &self,
147 keys: &[IbctKey],
148 expected_endpoint: &str,
149 expected_task_id: &str,
150 ) -> Result<(), IbctError> {
151 #[cfg(not(feature = "ibct"))]
152 {
153 let _ = (keys, expected_endpoint, expected_task_id);
154 return Err(IbctError::FeatureDisabled);
155 }
156 #[cfg(feature = "ibct")]
157 {
158 let key = keys
159 .iter()
160 .find(|k| k.key_id == self.key_id)
161 .ok_or_else(|| IbctError::UnknownKeyId {
162 key_id: self.key_id.clone(),
163 })?;
164
165 if verify_signature(
168 &key.key_bytes,
169 &self.key_id,
170 &self.task_id,
171 &self.endpoint,
172 self.issued_at,
173 self.expires_at,
174 &self.signature,
175 )
176 .is_err()
177 {
178 return Err(IbctError::InvalidSignature);
179 }
180
181 let now = unix_now();
182 if now > self.expires_at + CLOCK_SKEW_GRACE_SECS {
183 return Err(IbctError::Expired {
184 expires_at: self.expires_at,
185 now,
186 });
187 }
188
189 if self.endpoint != expected_endpoint {
190 return Err(IbctError::EndpointMismatch {
191 expected: expected_endpoint.to_owned(),
192 got: self.endpoint.clone(),
193 });
194 }
195
196 if self.task_id != expected_task_id {
197 return Err(IbctError::TaskMismatch {
198 expected: expected_task_id.to_owned(),
199 got: self.task_id.clone(),
200 });
201 }
202
203 Ok(())
204 }
205 }
206
207 pub fn encode(&self) -> Result<String, serde_json::Error> {
213 let json = serde_json::to_vec(self)?;
214 Ok(base64_compat::encode(&json))
215 }
216
217 pub fn decode(s: &str) -> Result<Self, IbctError> {
223 let bytes = base64_compat::decode(s)?;
224 let token = serde_json::from_slice(&bytes)?;
225 Ok(token)
226 }
227}
228
229#[cfg(feature = "ibct")]
230fn sign(
231 key_bytes: &[u8],
232 key_id: &str,
233 task_id: &str,
234 endpoint: &str,
235 issued_at: u64,
236 expires_at: u64,
237) -> String {
238 type HmacSha256 = Hmac<Sha256>;
239 let msg = format!("{key_id}|{task_id}|{endpoint}|{issued_at}|{expires_at}");
240 let mut mac = HmacSha256::new_from_slice(key_bytes).expect("HMAC accepts any key length");
241 mac.update(msg.as_bytes());
242 hex::encode(mac.finalize().into_bytes())
243}
244
245#[cfg(feature = "ibct")]
254fn verify_signature(
255 key_bytes: &[u8],
256 key_id: &str,
257 task_id: &str,
258 endpoint: &str,
259 issued_at: u64,
260 expires_at: u64,
261 signature_hex: &str,
262) -> Result<(), ()> {
263 type HmacSha256 = Hmac<Sha256>;
264 let decoded = hex::decode(signature_hex).map_err(|_| ())?;
265 let msg = format!("{key_id}|{task_id}|{endpoint}|{issued_at}|{expires_at}");
266 let mut mac = HmacSha256::new_from_slice(key_bytes).expect("HMAC accepts any key length");
267 mac.update(msg.as_bytes());
268 mac.verify_slice(&decoded).map_err(|_| ())
269}
270
271#[cfg(feature = "ibct")]
272fn unix_now() -> u64 {
273 SystemTime::now()
274 .duration_since(UNIX_EPOCH)
275 .unwrap_or(Duration::ZERO)
276 .as_secs()
277}
278
279mod hex_bytes {
281 use serde::{Deserialize, Deserializer, Serializer};
282
283 pub fn serialize<S: Serializer>(bytes: &Vec<u8>, ser: S) -> Result<S::Ok, S::Error> {
284 ser.serialize_str(&hex::encode(bytes))
285 }
286
287 pub fn deserialize<'de, D: Deserializer<'de>>(de: D) -> Result<Vec<u8>, D::Error> {
288 let s = String::deserialize(de)?;
289 hex::decode(&s).map_err(serde::de::Error::custom)
290 }
291}
292
293mod base64_compat {
298 use base64::Engine as _;
299
300 pub use base64::DecodeError;
301
302 pub fn encode(input: &[u8]) -> String {
303 base64::engine::general_purpose::STANDARD.encode(input)
304 }
305
306 pub fn decode(input: &str) -> Result<Vec<u8>, DecodeError> {
307 base64::engine::general_purpose::STANDARD.decode(input)
308 }
309}
310
311#[cfg(test)]
312mod tests {
313 #[cfg(feature = "ibct")]
314 use super::*;
315
316 #[cfg(feature = "ibct")]
317 fn test_key() -> IbctKey {
318 IbctKey {
319 key_id: "k1".into(),
320 key_bytes: b"super-secret-key-for-testing-only".to_vec(),
321 }
322 }
323
324 #[cfg(feature = "ibct")]
325 #[test]
326 fn issue_and_verify_round_trip() {
327 let key = test_key();
328 let token = Ibct::issue(
329 "task-123",
330 "https://agent.example.com",
331 Duration::from_secs(300),
332 &key,
333 )
334 .unwrap();
335 assert!(
336 token
337 .verify(&[key], "https://agent.example.com", "task-123")
338 .is_ok()
339 );
340 }
341
342 #[cfg(feature = "ibct")]
343 #[test]
344 fn verify_rejects_wrong_endpoint() {
345 let key = test_key();
346 let token = Ibct::issue(
347 "task-123",
348 "https://agent.example.com",
349 Duration::from_secs(300),
350 &key,
351 )
352 .unwrap();
353 let err = token
354 .verify(&[key], "https://evil.example.com", "task-123")
355 .unwrap_err();
356 assert!(matches!(err, IbctError::EndpointMismatch { .. }));
357 }
358
359 #[cfg(feature = "ibct")]
360 #[test]
361 fn verify_rejects_wrong_task() {
362 let key = test_key();
363 let token = Ibct::issue(
364 "task-123",
365 "https://agent.example.com",
366 Duration::from_secs(300),
367 &key,
368 )
369 .unwrap();
370 let err = token
371 .verify(&[key], "https://agent.example.com", "task-999")
372 .unwrap_err();
373 assert!(matches!(err, IbctError::TaskMismatch { .. }));
374 }
375
376 #[cfg(feature = "ibct")]
377 #[test]
378 fn verify_rejects_tampered_signature() {
379 let key = test_key();
380 let mut token = Ibct::issue(
381 "task-123",
382 "https://agent.example.com",
383 Duration::from_secs(300),
384 &key,
385 )
386 .unwrap();
387 token.signature = "deadbeef".repeat(8);
388 let err = token
389 .verify(&[key], "https://agent.example.com", "task-123")
390 .unwrap_err();
391 assert!(matches!(err, IbctError::InvalidSignature));
392 }
393
394 #[cfg(feature = "ibct")]
395 #[test]
396 fn verify_rejects_unknown_key_id() {
397 let key = test_key();
398 let token = Ibct::issue(
399 "task-123",
400 "https://agent.example.com",
401 Duration::from_secs(300),
402 &key,
403 )
404 .unwrap();
405 let other_key = IbctKey {
406 key_id: "k99".into(),
407 key_bytes: b"other".to_vec(),
408 };
409 let err = token
410 .verify(&[other_key], "https://agent.example.com", "task-123")
411 .unwrap_err();
412 assert!(matches!(err, IbctError::UnknownKeyId { .. }));
413 }
414
415 #[cfg(feature = "ibct")]
416 #[test]
417 fn encode_decode_round_trip() {
418 let key = test_key();
419 let token = Ibct::issue(
420 "task-abc",
421 "https://agent.example.com",
422 Duration::from_secs(60),
423 &key,
424 )
425 .unwrap();
426 let encoded = token.encode().unwrap();
427 let decoded = Ibct::decode(&encoded).unwrap();
428 assert_eq!(decoded.task_id, "task-abc");
429 assert_eq!(decoded.key_id, "k1");
430 }
431
432 #[cfg(feature = "ibct")]
433 #[test]
434 fn verify_rejects_expired_token() {
435 let key = test_key();
436 let now = std::time::SystemTime::now()
438 .duration_since(std::time::UNIX_EPOCH)
439 .unwrap()
440 .as_secs();
441 let expired_at = now.saturating_sub(120);
443 let issued_at = expired_at.saturating_sub(300);
444 #[cfg(feature = "ibct")]
446 let signature = {
447 use hmac::{Hmac, KeyInit, Mac};
448 use sha2::Sha256;
449 type HmacSha256 = Hmac<Sha256>;
450 let msg = format!(
451 "{}|{}|{}|{}|{}",
452 key.key_id, "task-expired", "https://agent.example.com", issued_at, expired_at
453 );
454 let mut mac =
455 HmacSha256::new_from_slice(&key.key_bytes).expect("HMAC accepts any key length");
456 mac.update(msg.as_bytes());
457 hex::encode(mac.finalize().into_bytes())
458 };
459 let token = Ibct {
460 key_id: key.key_id.clone(),
461 task_id: "task-expired".into(),
462 endpoint: "https://agent.example.com".into(),
463 issued_at,
464 expires_at: expired_at,
465 signature,
466 };
467 let err = token
468 .verify(&[key], "https://agent.example.com", "task-expired")
469 .unwrap_err();
470 assert!(
471 matches!(err, IbctError::Expired { .. }),
472 "expected Expired, got {err:?}"
473 );
474 }
475
476 #[cfg(feature = "ibct")]
477 #[test]
478 fn key_rotation_verifies_with_old_key() {
479 let old_key = IbctKey {
480 key_id: "k1".into(),
481 key_bytes: b"old-key".to_vec(),
482 };
483 let new_key = IbctKey {
484 key_id: "k2".into(),
485 key_bytes: b"new-key".to_vec(),
486 };
487 let token = Ibct::issue(
488 "task-1",
489 "https://agent.example.com",
490 Duration::from_secs(300),
491 &old_key,
492 )
493 .unwrap();
494 assert!(
496 token
497 .verify(&[old_key, new_key], "https://agent.example.com", "task-1")
498 .is_ok()
499 );
500 }
501}