1use std::time::Duration;
31#[cfg(feature = "ibct")]
32use std::time::{SystemTime, UNIX_EPOCH};
33
34use serde::{Deserialize, Serialize};
35use thiserror::Error;
36
37#[cfg(feature = "ibct")]
38use hmac::{Hmac, KeyInit, Mac};
39#[cfg(feature = "ibct")]
40use sha2::Sha256;
41
42#[cfg(feature = "ibct")]
44const CLOCK_SKEW_GRACE_SECS: u64 = 30;
45
46pub(crate) fn ibct_scope_origin(endpoint: &str) -> String {
69 url::Url::parse(endpoint).map_or_else(
70 |_| endpoint.to_owned(),
71 |u| u.origin().ascii_serialization(),
72 )
73}
74
75#[derive(Debug, Error)]
77#[non_exhaustive]
78pub enum IbctError {
79 #[error("IBCT signature invalid")]
82 InvalidSignature,
83
84 #[error("IBCT expired (expires_at={expires_at}, now={now})")]
86 Expired { expires_at: u64, now: u64 },
87
88 #[error("IBCT endpoint mismatch: expected {expected}, got {got}")]
90 EndpointMismatch { expected: String, got: String },
91
92 #[error("IBCT task_id mismatch: expected {expected}, got {got}")]
94 TaskMismatch { expected: String, got: String },
95
96 #[error("IBCT key_id '{key_id}' not found in the configured key set")]
99 UnknownKeyId { key_id: String },
100
101 #[error("IBCT feature not enabled (compile with feature 'ibct')")]
103 FeatureDisabled,
104
105 #[error("base64 decode error: {0}")]
107 Base64(#[from] base64_compat::DecodeError),
108
109 #[error("JSON error: {0}")]
111 Json(#[from] serde_json::Error),
112}
113
114#[derive(Clone, Deserialize)]
122pub struct IbctKey {
123 pub key_id: String,
125 #[serde(with = "hex_bytes")]
127 pub key_bytes: Vec<u8>,
128}
129
130impl std::fmt::Debug for IbctKey {
131 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
132 f.debug_struct("IbctKey")
133 .field("key_id", &self.key_id)
134 .field("key_bytes", &"[REDACTED]")
135 .finish()
136 }
137}
138
139impl IbctKey {
140 pub fn from_hex(key_id: impl Into<String>, hex_key: &str) -> Result<Self, hex::FromHexError> {
158 Ok(Self {
159 key_id: key_id.into(),
160 key_bytes: hex::decode(hex_key)?,
161 })
162 }
163}
164
165impl Serialize for IbctKey {
166 fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
167 use serde::ser::SerializeStruct;
168 let mut s = serializer.serialize_struct("IbctKey", 2)?;
169 s.serialize_field("key_id", &self.key_id)?;
170 s.serialize_field("key_bytes", "[REDACTED]")?;
171 s.end()
172 }
173}
174
175#[derive(Debug, Clone, Serialize, Deserialize)]
177pub struct Ibct {
178 pub key_id: String,
180 pub task_id: String,
182 pub endpoint: String,
184 pub issued_at: u64,
186 pub expires_at: u64,
188 pub signature: String,
190}
191
192impl Ibct {
193 #[allow(clippy::needless_return)]
199 pub fn issue(
200 task_id: &str,
201 endpoint: &str,
202 ttl: Duration,
203 key: &IbctKey,
204 ) -> Result<Self, IbctError> {
205 #[cfg(not(feature = "ibct"))]
206 {
207 let _ = (task_id, endpoint, ttl, key);
208 return Err(IbctError::FeatureDisabled);
209 }
210 #[cfg(feature = "ibct")]
211 {
212 let now = unix_now();
213 let expires_at = now + ttl.as_secs();
214 let signature = sign(
215 &key.key_bytes,
216 &key.key_id,
217 task_id,
218 endpoint,
219 now,
220 expires_at,
221 );
222 Ok(Self {
223 key_id: key.key_id.clone(),
224 task_id: task_id.to_owned(),
225 endpoint: endpoint.to_owned(),
226 issued_at: now,
227 expires_at,
228 signature,
229 })
230 }
231 }
232
233 #[allow(clippy::needless_return)]
242 pub fn verify(
243 &self,
244 keys: &[IbctKey],
245 expected_endpoint: &str,
246 expected_task_id: &str,
247 ) -> Result<(), IbctError> {
248 #[cfg(not(feature = "ibct"))]
249 {
250 let _ = (keys, expected_endpoint, expected_task_id);
251 return Err(IbctError::FeatureDisabled);
252 }
253 #[cfg(feature = "ibct")]
254 {
255 let key = keys
256 .iter()
257 .find(|k| k.key_id == self.key_id)
258 .ok_or_else(|| IbctError::UnknownKeyId {
259 key_id: self.key_id.clone(),
260 })?;
261
262 if verify_signature(
265 &key.key_bytes,
266 &self.key_id,
267 &self.task_id,
268 &self.endpoint,
269 self.issued_at,
270 self.expires_at,
271 &self.signature,
272 )
273 .is_err()
274 {
275 return Err(IbctError::InvalidSignature);
276 }
277
278 let now = unix_now();
279 if now > self.expires_at + CLOCK_SKEW_GRACE_SECS {
280 return Err(IbctError::Expired {
281 expires_at: self.expires_at,
282 now,
283 });
284 }
285
286 if self.endpoint != expected_endpoint {
287 return Err(IbctError::EndpointMismatch {
288 expected: expected_endpoint.to_owned(),
289 got: self.endpoint.clone(),
290 });
291 }
292
293 if self.task_id != expected_task_id {
294 return Err(IbctError::TaskMismatch {
295 expected: expected_task_id.to_owned(),
296 got: self.task_id.clone(),
297 });
298 }
299
300 Ok(())
301 }
302 }
303
304 pub fn encode(&self) -> Result<String, serde_json::Error> {
310 let json = serde_json::to_vec(self)?;
311 Ok(base64_compat::encode(&json))
312 }
313
314 pub fn decode(s: &str) -> Result<Self, IbctError> {
320 let bytes = base64_compat::decode(s)?;
321 let token = serde_json::from_slice(&bytes)?;
322 Ok(token)
323 }
324}
325
326#[cfg(feature = "ibct")]
327fn sign(
328 key_bytes: &[u8],
329 key_id: &str,
330 task_id: &str,
331 endpoint: &str,
332 issued_at: u64,
333 expires_at: u64,
334) -> String {
335 type HmacSha256 = Hmac<Sha256>;
336 let msg = format!("{key_id}|{task_id}|{endpoint}|{issued_at}|{expires_at}");
337 let mut mac = HmacSha256::new_from_slice(key_bytes).expect("HMAC accepts any key length");
338 mac.update(msg.as_bytes());
339 hex::encode(mac.finalize().into_bytes())
340}
341
342#[cfg(feature = "ibct")]
351fn verify_signature(
352 key_bytes: &[u8],
353 key_id: &str,
354 task_id: &str,
355 endpoint: &str,
356 issued_at: u64,
357 expires_at: u64,
358 signature_hex: &str,
359) -> Result<(), ()> {
360 type HmacSha256 = Hmac<Sha256>;
361 let decoded = hex::decode(signature_hex).map_err(|_| ())?;
362 let msg = format!("{key_id}|{task_id}|{endpoint}|{issued_at}|{expires_at}");
363 let mut mac = HmacSha256::new_from_slice(key_bytes).expect("HMAC accepts any key length");
364 mac.update(msg.as_bytes());
365 mac.verify_slice(&decoded).map_err(|_| ())
366}
367
368#[cfg(feature = "ibct")]
369fn unix_now() -> u64 {
370 SystemTime::now()
371 .duration_since(UNIX_EPOCH)
372 .unwrap_or(Duration::ZERO)
373 .as_secs()
374}
375
376mod hex_bytes {
378 use serde::{Deserialize, Deserializer};
379
380 pub fn deserialize<'de, D: Deserializer<'de>>(de: D) -> Result<Vec<u8>, D::Error> {
381 let s = String::deserialize(de)?;
382 hex::decode(&s).map_err(serde::de::Error::custom)
383 }
384}
385
386mod base64_compat {
391 use base64::Engine as _;
392
393 pub use base64::DecodeError;
394
395 pub fn encode(input: &[u8]) -> String {
396 base64::engine::general_purpose::STANDARD.encode(input)
397 }
398
399 pub fn decode(input: &str) -> Result<Vec<u8>, DecodeError> {
400 base64::engine::general_purpose::STANDARD.decode(input)
401 }
402}
403
404#[cfg(test)]
405mod tests {
406 #[cfg(feature = "ibct")]
407 use super::*;
408 #[cfg(feature = "ibct")]
409 use std::assert_matches;
410
411 #[cfg(feature = "ibct")]
412 fn test_key() -> IbctKey {
413 IbctKey {
414 key_id: "k1".into(),
415 key_bytes: b"super-secret-key-for-testing-only".to_vec(),
416 }
417 }
418
419 #[cfg(feature = "ibct")]
420 #[test]
421 fn issue_and_verify_round_trip() {
422 let key = test_key();
423 let token = Ibct::issue(
424 "task-123",
425 "https://agent.example.com",
426 Duration::from_mins(5),
427 &key,
428 )
429 .unwrap();
430 assert!(
431 token
432 .verify(&[key], "https://agent.example.com", "task-123")
433 .is_ok()
434 );
435 }
436
437 #[cfg(feature = "ibct")]
438 #[test]
439 fn verify_rejects_wrong_endpoint() {
440 let key = test_key();
441 let token = Ibct::issue(
442 "task-123",
443 "https://agent.example.com",
444 Duration::from_mins(5),
445 &key,
446 )
447 .unwrap();
448 let err = token
449 .verify(&[key], "https://evil.example.com", "task-123")
450 .unwrap_err();
451 assert_matches!(err, IbctError::EndpointMismatch { .. });
452 }
453
454 #[cfg(feature = "ibct")]
455 #[test]
456 fn verify_rejects_wrong_task() {
457 let key = test_key();
458 let token = Ibct::issue(
459 "task-123",
460 "https://agent.example.com",
461 Duration::from_mins(5),
462 &key,
463 )
464 .unwrap();
465 let err = token
466 .verify(&[key], "https://agent.example.com", "task-999")
467 .unwrap_err();
468 assert_matches!(err, IbctError::TaskMismatch { .. });
469 }
470
471 #[cfg(feature = "ibct")]
472 #[test]
473 fn verify_rejects_tampered_signature() {
474 let key = test_key();
475 let mut token = Ibct::issue(
476 "task-123",
477 "https://agent.example.com",
478 Duration::from_mins(5),
479 &key,
480 )
481 .unwrap();
482 token.signature = "deadbeef".repeat(8);
483 let err = token
484 .verify(&[key], "https://agent.example.com", "task-123")
485 .unwrap_err();
486 assert_matches!(err, IbctError::InvalidSignature);
487 }
488
489 #[cfg(feature = "ibct")]
490 #[test]
491 fn verify_rejects_unknown_key_id() {
492 let key = test_key();
493 let token = Ibct::issue(
494 "task-123",
495 "https://agent.example.com",
496 Duration::from_mins(5),
497 &key,
498 )
499 .unwrap();
500 let other_key = IbctKey {
501 key_id: "k99".into(),
502 key_bytes: b"other".to_vec(),
503 };
504 let err = token
505 .verify(&[other_key], "https://agent.example.com", "task-123")
506 .unwrap_err();
507 assert_matches!(err, IbctError::UnknownKeyId { .. });
508 }
509
510 #[cfg(feature = "ibct")]
511 #[test]
512 fn encode_decode_round_trip() {
513 let key = test_key();
514 let token = Ibct::issue(
515 "task-abc",
516 "https://agent.example.com",
517 Duration::from_mins(1),
518 &key,
519 )
520 .unwrap();
521 let encoded = token.encode().unwrap();
522 let decoded = Ibct::decode(&encoded).unwrap();
523 assert_eq!(decoded.task_id, "task-abc");
524 assert_eq!(decoded.key_id, "k1");
525 }
526
527 #[cfg(feature = "ibct")]
528 #[test]
529 fn verify_rejects_expired_token() {
530 let key = test_key();
531 let now = std::time::SystemTime::now()
533 .duration_since(std::time::UNIX_EPOCH)
534 .unwrap()
535 .as_secs();
536 let expired_at = now.saturating_sub(120);
538 let issued_at = expired_at.saturating_sub(300);
539 #[cfg(feature = "ibct")]
541 let signature = {
542 use hmac::{Hmac, KeyInit, Mac};
543 use sha2::Sha256;
544 type HmacSha256 = Hmac<Sha256>;
545 let msg = format!(
546 "{}|{}|{}|{}|{}",
547 key.key_id, "task-expired", "https://agent.example.com", issued_at, expired_at
548 );
549 let mut mac =
550 HmacSha256::new_from_slice(&key.key_bytes).expect("HMAC accepts any key length");
551 mac.update(msg.as_bytes());
552 hex::encode(mac.finalize().into_bytes())
553 };
554 let token = Ibct {
555 key_id: key.key_id.clone(),
556 task_id: "task-expired".into(),
557 endpoint: "https://agent.example.com".into(),
558 issued_at,
559 expires_at: expired_at,
560 signature,
561 };
562 let err = token
563 .verify(&[key], "https://agent.example.com", "task-expired")
564 .unwrap_err();
565 assert!(
566 matches!(err, IbctError::Expired { .. }),
567 "expected Expired, got {err:?}"
568 );
569 }
570
571 #[cfg(feature = "ibct")]
572 #[test]
573 fn key_rotation_verifies_with_old_key() {
574 let old_key = IbctKey {
575 key_id: "k1".into(),
576 key_bytes: b"old-key".to_vec(),
577 };
578 let new_key = IbctKey {
579 key_id: "k2".into(),
580 key_bytes: b"new-key".to_vec(),
581 };
582 let token = Ibct::issue(
583 "task-1",
584 "https://agent.example.com",
585 Duration::from_mins(5),
586 &old_key,
587 )
588 .unwrap();
589 assert!(
591 token
592 .verify(&[old_key, new_key], "https://agent.example.com", "task-1")
593 .is_ok()
594 );
595 }
596
597 #[cfg(feature = "ibct")]
598 #[test]
599 fn ibct_key_debug_redacts_key_bytes() {
600 let key = test_key();
601 let debug = format!("{key:?}");
602 assert!(!debug.contains("super-secret-key-for-testing-only"));
603 assert!(debug.contains("k1"));
604 assert!(debug.contains("REDACTED"));
605 }
606
607 #[cfg(feature = "ibct")]
608 #[test]
609 fn ibct_key_from_hex_decodes_bytes() {
610 let key = IbctKey::from_hex("k1", "68656c6c6f").unwrap();
611 assert_eq!(key.key_id, "k1");
612 assert_eq!(key.key_bytes, b"hello");
613 }
614
615 #[cfg(feature = "ibct")]
616 #[test]
617 fn ibct_key_from_hex_rejects_invalid_hex() {
618 assert!(IbctKey::from_hex("k1", "not-hex").is_err());
619 }
620
621 #[cfg(feature = "ibct")]
632 #[test]
633 fn ibct_scope_origin_strips_explicit_default_port() {
634 assert_eq!(
635 ibct_scope_origin("https://agent.example.com:443/a2a"),
636 "https://agent.example.com"
637 );
638 assert_eq!(
639 ibct_scope_origin("http://agent.example.com:80/a2a/stream"),
640 "http://agent.example.com"
641 );
642 }
643
644 #[cfg(feature = "ibct")]
645 #[test]
646 fn ibct_key_serialize_redacts_key_bytes() {
647 let key = test_key();
648 let json = serde_json::to_string(&key).unwrap();
649 assert!(!json.contains(&hex::encode(b"super-secret-key-for-testing-only")));
650 assert!(json.contains("k1"));
651 assert!(json.contains("REDACTED"));
652 }
653}