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