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
46#[derive(Debug, Error)]
48pub enum IbctError {
49 #[error("IBCT signature invalid")]
52 InvalidSignature,
53
54 #[error("IBCT expired (expires_at={expires_at}, now={now})")]
56 Expired { expires_at: u64, now: u64 },
57
58 #[error("IBCT endpoint mismatch: expected {expected}, got {got}")]
60 EndpointMismatch { expected: String, got: String },
61
62 #[error("IBCT task_id mismatch: expected {expected}, got {got}")]
64 TaskMismatch { expected: String, got: String },
65
66 #[error("IBCT key_id '{key_id}' not found in the configured key set")]
69 UnknownKeyId { key_id: String },
70
71 #[error("IBCT feature not enabled (compile with feature 'ibct')")]
73 FeatureDisabled,
74
75 #[error("base64 decode error: {0}")]
77 Base64(#[from] base64_compat::DecodeError),
78
79 #[error("JSON error: {0}")]
81 Json(#[from] serde_json::Error),
82}
83
84#[derive(Debug, Clone, Serialize, Deserialize)]
89pub struct IbctKey {
90 pub key_id: String,
92 #[serde(with = "hex_bytes")]
94 pub key_bytes: Vec<u8>,
95}
96
97#[derive(Debug, Clone, Serialize, Deserialize)]
99pub struct Ibct {
100 pub key_id: String,
102 pub task_id: String,
104 pub endpoint: String,
106 pub issued_at: u64,
108 pub expires_at: u64,
110 pub signature: String,
112}
113
114impl Ibct {
115 #[allow(clippy::needless_return)]
121 pub fn issue(
122 task_id: &str,
123 endpoint: &str,
124 ttl: Duration,
125 key: &IbctKey,
126 ) -> Result<Self, IbctError> {
127 #[cfg(not(feature = "ibct"))]
128 {
129 let _ = (task_id, endpoint, ttl, key);
130 return Err(IbctError::FeatureDisabled);
131 }
132 #[cfg(feature = "ibct")]
133 {
134 let now = unix_now();
135 let expires_at = now + ttl.as_secs();
136 let signature = sign(
137 &key.key_bytes,
138 &key.key_id,
139 task_id,
140 endpoint,
141 now,
142 expires_at,
143 );
144 Ok(Self {
145 key_id: key.key_id.clone(),
146 task_id: task_id.to_owned(),
147 endpoint: endpoint.to_owned(),
148 issued_at: now,
149 expires_at,
150 signature,
151 })
152 }
153 }
154
155 #[allow(clippy::needless_return)]
164 pub fn verify(
165 &self,
166 keys: &[IbctKey],
167 expected_endpoint: &str,
168 expected_task_id: &str,
169 ) -> Result<(), IbctError> {
170 #[cfg(not(feature = "ibct"))]
171 {
172 let _ = (keys, expected_endpoint, expected_task_id);
173 return Err(IbctError::FeatureDisabled);
174 }
175 #[cfg(feature = "ibct")]
176 {
177 let key = keys
178 .iter()
179 .find(|k| k.key_id == self.key_id)
180 .ok_or_else(|| IbctError::UnknownKeyId {
181 key_id: self.key_id.clone(),
182 })?;
183
184 if verify_signature(
187 &key.key_bytes,
188 &self.key_id,
189 &self.task_id,
190 &self.endpoint,
191 self.issued_at,
192 self.expires_at,
193 &self.signature,
194 )
195 .is_err()
196 {
197 return Err(IbctError::InvalidSignature);
198 }
199
200 let now = unix_now();
201 if now > self.expires_at + CLOCK_SKEW_GRACE_SECS {
202 return Err(IbctError::Expired {
203 expires_at: self.expires_at,
204 now,
205 });
206 }
207
208 if self.endpoint != expected_endpoint {
209 return Err(IbctError::EndpointMismatch {
210 expected: expected_endpoint.to_owned(),
211 got: self.endpoint.clone(),
212 });
213 }
214
215 if self.task_id != expected_task_id {
216 return Err(IbctError::TaskMismatch {
217 expected: expected_task_id.to_owned(),
218 got: self.task_id.clone(),
219 });
220 }
221
222 Ok(())
223 }
224 }
225
226 pub fn encode(&self) -> Result<String, serde_json::Error> {
232 let json = serde_json::to_vec(self)?;
233 Ok(base64_compat::encode(&json))
234 }
235
236 pub fn decode(s: &str) -> Result<Self, IbctError> {
242 let bytes = base64_compat::decode(s)?;
243 let token = serde_json::from_slice(&bytes)?;
244 Ok(token)
245 }
246}
247
248#[cfg(feature = "ibct")]
249fn sign(
250 key_bytes: &[u8],
251 key_id: &str,
252 task_id: &str,
253 endpoint: &str,
254 issued_at: u64,
255 expires_at: u64,
256) -> String {
257 type HmacSha256 = Hmac<Sha256>;
258 let msg = format!("{key_id}|{task_id}|{endpoint}|{issued_at}|{expires_at}");
259 let mut mac = HmacSha256::new_from_slice(key_bytes).expect("HMAC accepts any key length");
260 mac.update(msg.as_bytes());
261 hex::encode(mac.finalize().into_bytes())
262}
263
264#[cfg(feature = "ibct")]
273fn verify_signature(
274 key_bytes: &[u8],
275 key_id: &str,
276 task_id: &str,
277 endpoint: &str,
278 issued_at: u64,
279 expires_at: u64,
280 signature_hex: &str,
281) -> Result<(), ()> {
282 type HmacSha256 = Hmac<Sha256>;
283 let decoded = hex::decode(signature_hex).map_err(|_| ())?;
284 let msg = format!("{key_id}|{task_id}|{endpoint}|{issued_at}|{expires_at}");
285 let mut mac = HmacSha256::new_from_slice(key_bytes).expect("HMAC accepts any key length");
286 mac.update(msg.as_bytes());
287 mac.verify_slice(&decoded).map_err(|_| ())
288}
289
290#[cfg(feature = "ibct")]
291fn unix_now() -> u64 {
292 SystemTime::now()
293 .duration_since(UNIX_EPOCH)
294 .unwrap_or(Duration::ZERO)
295 .as_secs()
296}
297
298mod hex_bytes {
300 use serde::{Deserialize, Deserializer, Serializer};
301
302 pub fn serialize<S: Serializer>(bytes: &Vec<u8>, ser: S) -> Result<S::Ok, S::Error> {
303 ser.serialize_str(&hex::encode(bytes))
304 }
305
306 pub fn deserialize<'de, D: Deserializer<'de>>(de: D) -> Result<Vec<u8>, D::Error> {
307 let s = String::deserialize(de)?;
308 hex::decode(&s).map_err(serde::de::Error::custom)
309 }
310}
311
312mod base64_compat {
317 use base64::Engine as _;
318
319 pub use base64::DecodeError;
320
321 pub fn encode(input: &[u8]) -> String {
322 base64::engine::general_purpose::STANDARD.encode(input)
323 }
324
325 pub fn decode(input: &str) -> Result<Vec<u8>, DecodeError> {
326 base64::engine::general_purpose::STANDARD.decode(input)
327 }
328}
329
330#[cfg(test)]
331mod tests {
332 #[cfg(feature = "ibct")]
333 use super::*;
334
335 #[cfg(feature = "ibct")]
336 fn test_key() -> IbctKey {
337 IbctKey {
338 key_id: "k1".into(),
339 key_bytes: b"super-secret-key-for-testing-only".to_vec(),
340 }
341 }
342
343 #[cfg(feature = "ibct")]
344 #[test]
345 fn issue_and_verify_round_trip() {
346 let key = test_key();
347 let token = Ibct::issue(
348 "task-123",
349 "https://agent.example.com",
350 Duration::from_mins(5),
351 &key,
352 )
353 .unwrap();
354 assert!(
355 token
356 .verify(&[key], "https://agent.example.com", "task-123")
357 .is_ok()
358 );
359 }
360
361 #[cfg(feature = "ibct")]
362 #[test]
363 fn verify_rejects_wrong_endpoint() {
364 let key = test_key();
365 let token = Ibct::issue(
366 "task-123",
367 "https://agent.example.com",
368 Duration::from_mins(5),
369 &key,
370 )
371 .unwrap();
372 let err = token
373 .verify(&[key], "https://evil.example.com", "task-123")
374 .unwrap_err();
375 assert!(matches!(err, IbctError::EndpointMismatch { .. }));
376 }
377
378 #[cfg(feature = "ibct")]
379 #[test]
380 fn verify_rejects_wrong_task() {
381 let key = test_key();
382 let token = Ibct::issue(
383 "task-123",
384 "https://agent.example.com",
385 Duration::from_mins(5),
386 &key,
387 )
388 .unwrap();
389 let err = token
390 .verify(&[key], "https://agent.example.com", "task-999")
391 .unwrap_err();
392 assert!(matches!(err, IbctError::TaskMismatch { .. }));
393 }
394
395 #[cfg(feature = "ibct")]
396 #[test]
397 fn verify_rejects_tampered_signature() {
398 let key = test_key();
399 let mut token = Ibct::issue(
400 "task-123",
401 "https://agent.example.com",
402 Duration::from_mins(5),
403 &key,
404 )
405 .unwrap();
406 token.signature = "deadbeef".repeat(8);
407 let err = token
408 .verify(&[key], "https://agent.example.com", "task-123")
409 .unwrap_err();
410 assert!(matches!(err, IbctError::InvalidSignature));
411 }
412
413 #[cfg(feature = "ibct")]
414 #[test]
415 fn verify_rejects_unknown_key_id() {
416 let key = test_key();
417 let token = Ibct::issue(
418 "task-123",
419 "https://agent.example.com",
420 Duration::from_mins(5),
421 &key,
422 )
423 .unwrap();
424 let other_key = IbctKey {
425 key_id: "k99".into(),
426 key_bytes: b"other".to_vec(),
427 };
428 let err = token
429 .verify(&[other_key], "https://agent.example.com", "task-123")
430 .unwrap_err();
431 assert!(matches!(err, IbctError::UnknownKeyId { .. }));
432 }
433
434 #[cfg(feature = "ibct")]
435 #[test]
436 fn encode_decode_round_trip() {
437 let key = test_key();
438 let token = Ibct::issue(
439 "task-abc",
440 "https://agent.example.com",
441 Duration::from_mins(1),
442 &key,
443 )
444 .unwrap();
445 let encoded = token.encode().unwrap();
446 let decoded = Ibct::decode(&encoded).unwrap();
447 assert_eq!(decoded.task_id, "task-abc");
448 assert_eq!(decoded.key_id, "k1");
449 }
450
451 #[cfg(feature = "ibct")]
452 #[test]
453 fn verify_rejects_expired_token() {
454 let key = test_key();
455 let now = std::time::SystemTime::now()
457 .duration_since(std::time::UNIX_EPOCH)
458 .unwrap()
459 .as_secs();
460 let expired_at = now.saturating_sub(120);
462 let issued_at = expired_at.saturating_sub(300);
463 #[cfg(feature = "ibct")]
465 let signature = {
466 use hmac::{Hmac, KeyInit, Mac};
467 use sha2::Sha256;
468 type HmacSha256 = Hmac<Sha256>;
469 let msg = format!(
470 "{}|{}|{}|{}|{}",
471 key.key_id, "task-expired", "https://agent.example.com", issued_at, expired_at
472 );
473 let mut mac =
474 HmacSha256::new_from_slice(&key.key_bytes).expect("HMAC accepts any key length");
475 mac.update(msg.as_bytes());
476 hex::encode(mac.finalize().into_bytes())
477 };
478 let token = Ibct {
479 key_id: key.key_id.clone(),
480 task_id: "task-expired".into(),
481 endpoint: "https://agent.example.com".into(),
482 issued_at,
483 expires_at: expired_at,
484 signature,
485 };
486 let err = token
487 .verify(&[key], "https://agent.example.com", "task-expired")
488 .unwrap_err();
489 assert!(
490 matches!(err, IbctError::Expired { .. }),
491 "expected Expired, got {err:?}"
492 );
493 }
494
495 #[cfg(feature = "ibct")]
496 #[test]
497 fn key_rotation_verifies_with_old_key() {
498 let old_key = IbctKey {
499 key_id: "k1".into(),
500 key_bytes: b"old-key".to_vec(),
501 };
502 let new_key = IbctKey {
503 key_id: "k2".into(),
504 key_bytes: b"new-key".to_vec(),
505 };
506 let token = Ibct::issue(
507 "task-1",
508 "https://agent.example.com",
509 Duration::from_mins(5),
510 &old_key,
511 )
512 .unwrap();
513 assert!(
515 token
516 .verify(&[old_key, new_key], "https://agent.example.com", "task-1")
517 .is_ok()
518 );
519 }
520}