use std::time::Duration;
#[cfg(feature = "ibct")]
use std::time::{SystemTime, UNIX_EPOCH};
use serde::{Deserialize, Serialize};
use thiserror::Error;
#[cfg(feature = "ibct")]
use hmac::{Hmac, KeyInit, Mac};
#[cfg(feature = "ibct")]
use sha2::Sha256;
#[cfg(feature = "ibct")]
const CLOCK_SKEW_GRACE_SECS: u64 = 30;
pub(crate) fn ibct_scope_origin(endpoint: &str) -> String {
url::Url::parse(endpoint).map_or_else(
|_| endpoint.to_owned(),
|u| u.origin().ascii_serialization(),
)
}
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum IbctError {
#[error("IBCT signature invalid")]
InvalidSignature,
#[error("IBCT expired (expires_at={expires_at}, now={now})")]
Expired { expires_at: u64, now: u64 },
#[error("IBCT endpoint mismatch: expected {expected}, got {got}")]
EndpointMismatch { expected: String, got: String },
#[error("IBCT task_id mismatch: expected {expected}, got {got}")]
TaskMismatch { expected: String, got: String },
#[error("IBCT key_id '{key_id}' not found in the configured key set")]
UnknownKeyId { key_id: String },
#[error("IBCT feature not enabled (compile with feature 'ibct')")]
FeatureDisabled,
#[error("base64 decode error: {0}")]
Base64(#[from] base64_compat::DecodeError),
#[error("JSON error: {0}")]
Json(#[from] serde_json::Error),
}
#[derive(Clone, Deserialize)]
pub struct IbctKey {
pub key_id: String,
#[serde(with = "hex_bytes")]
pub key_bytes: Vec<u8>,
}
impl std::fmt::Debug for IbctKey {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("IbctKey")
.field("key_id", &self.key_id)
.field("key_bytes", &"[REDACTED]")
.finish()
}
}
impl IbctKey {
pub fn from_hex(key_id: impl Into<String>, hex_key: &str) -> Result<Self, hex::FromHexError> {
Ok(Self {
key_id: key_id.into(),
key_bytes: hex::decode(hex_key)?,
})
}
}
impl Serialize for IbctKey {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
use serde::ser::SerializeStruct;
let mut s = serializer.serialize_struct("IbctKey", 2)?;
s.serialize_field("key_id", &self.key_id)?;
s.serialize_field("key_bytes", "[REDACTED]")?;
s.end()
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Ibct {
pub key_id: String,
pub task_id: String,
pub endpoint: String,
pub issued_at: u64,
pub expires_at: u64,
pub signature: String,
}
impl Ibct {
#[allow(clippy::needless_return)]
pub fn issue(
task_id: &str,
endpoint: &str,
ttl: Duration,
key: &IbctKey,
) -> Result<Self, IbctError> {
#[cfg(not(feature = "ibct"))]
{
let _ = (task_id, endpoint, ttl, key);
return Err(IbctError::FeatureDisabled);
}
#[cfg(feature = "ibct")]
{
let now = unix_now();
let expires_at = now + ttl.as_secs();
let signature = sign(
&key.key_bytes,
&key.key_id,
task_id,
endpoint,
now,
expires_at,
);
Ok(Self {
key_id: key.key_id.clone(),
task_id: task_id.to_owned(),
endpoint: endpoint.to_owned(),
issued_at: now,
expires_at,
signature,
})
}
}
#[allow(clippy::needless_return)]
pub fn verify(
&self,
keys: &[IbctKey],
expected_endpoint: &str,
expected_task_id: &str,
) -> Result<(), IbctError> {
#[cfg(not(feature = "ibct"))]
{
let _ = (keys, expected_endpoint, expected_task_id);
return Err(IbctError::FeatureDisabled);
}
#[cfg(feature = "ibct")]
{
let key = keys
.iter()
.find(|k| k.key_id == self.key_id)
.ok_or_else(|| IbctError::UnknownKeyId {
key_id: self.key_id.clone(),
})?;
if verify_signature(
&key.key_bytes,
&self.key_id,
&self.task_id,
&self.endpoint,
self.issued_at,
self.expires_at,
&self.signature,
)
.is_err()
{
return Err(IbctError::InvalidSignature);
}
let now = unix_now();
if now > self.expires_at + CLOCK_SKEW_GRACE_SECS {
return Err(IbctError::Expired {
expires_at: self.expires_at,
now,
});
}
if self.endpoint != expected_endpoint {
return Err(IbctError::EndpointMismatch {
expected: expected_endpoint.to_owned(),
got: self.endpoint.clone(),
});
}
if self.task_id != expected_task_id {
return Err(IbctError::TaskMismatch {
expected: expected_task_id.to_owned(),
got: self.task_id.clone(),
});
}
Ok(())
}
}
pub fn encode(&self) -> Result<String, serde_json::Error> {
let json = serde_json::to_vec(self)?;
Ok(base64_compat::encode(&json))
}
pub fn decode(s: &str) -> Result<Self, IbctError> {
let bytes = base64_compat::decode(s)?;
let token = serde_json::from_slice(&bytes)?;
Ok(token)
}
}
#[cfg(feature = "ibct")]
fn sign(
key_bytes: &[u8],
key_id: &str,
task_id: &str,
endpoint: &str,
issued_at: u64,
expires_at: u64,
) -> String {
type HmacSha256 = Hmac<Sha256>;
let msg = format!("{key_id}|{task_id}|{endpoint}|{issued_at}|{expires_at}");
let mut mac = HmacSha256::new_from_slice(key_bytes).expect("HMAC accepts any key length");
mac.update(msg.as_bytes());
hex::encode(mac.finalize().into_bytes())
}
#[cfg(feature = "ibct")]
fn verify_signature(
key_bytes: &[u8],
key_id: &str,
task_id: &str,
endpoint: &str,
issued_at: u64,
expires_at: u64,
signature_hex: &str,
) -> Result<(), ()> {
type HmacSha256 = Hmac<Sha256>;
let decoded = hex::decode(signature_hex).map_err(|_| ())?;
let msg = format!("{key_id}|{task_id}|{endpoint}|{issued_at}|{expires_at}");
let mut mac = HmacSha256::new_from_slice(key_bytes).expect("HMAC accepts any key length");
mac.update(msg.as_bytes());
mac.verify_slice(&decoded).map_err(|_| ())
}
#[cfg(feature = "ibct")]
fn unix_now() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or(Duration::ZERO)
.as_secs()
}
mod hex_bytes {
use serde::{Deserialize, Deserializer};
pub fn deserialize<'de, D: Deserializer<'de>>(de: D) -> Result<Vec<u8>, D::Error> {
let s = String::deserialize(de)?;
hex::decode(&s).map_err(serde::de::Error::custom)
}
}
mod base64_compat {
use base64::Engine as _;
pub use base64::DecodeError;
pub fn encode(input: &[u8]) -> String {
base64::engine::general_purpose::STANDARD.encode(input)
}
pub fn decode(input: &str) -> Result<Vec<u8>, DecodeError> {
base64::engine::general_purpose::STANDARD.decode(input)
}
}
#[cfg(test)]
mod tests {
#[cfg(feature = "ibct")]
use super::*;
#[cfg(feature = "ibct")]
use std::assert_matches;
#[cfg(feature = "ibct")]
fn test_key() -> IbctKey {
IbctKey {
key_id: "k1".into(),
key_bytes: b"super-secret-key-for-testing-only".to_vec(),
}
}
#[cfg(feature = "ibct")]
#[test]
fn issue_and_verify_round_trip() {
let key = test_key();
let token = Ibct::issue(
"task-123",
"https://agent.example.com",
Duration::from_mins(5),
&key,
)
.unwrap();
assert!(
token
.verify(&[key], "https://agent.example.com", "task-123")
.is_ok()
);
}
#[cfg(feature = "ibct")]
#[test]
fn verify_rejects_wrong_endpoint() {
let key = test_key();
let token = Ibct::issue(
"task-123",
"https://agent.example.com",
Duration::from_mins(5),
&key,
)
.unwrap();
let err = token
.verify(&[key], "https://evil.example.com", "task-123")
.unwrap_err();
assert_matches!(err, IbctError::EndpointMismatch { .. });
}
#[cfg(feature = "ibct")]
#[test]
fn verify_rejects_wrong_task() {
let key = test_key();
let token = Ibct::issue(
"task-123",
"https://agent.example.com",
Duration::from_mins(5),
&key,
)
.unwrap();
let err = token
.verify(&[key], "https://agent.example.com", "task-999")
.unwrap_err();
assert_matches!(err, IbctError::TaskMismatch { .. });
}
#[cfg(feature = "ibct")]
#[test]
fn verify_rejects_tampered_signature() {
let key = test_key();
let mut token = Ibct::issue(
"task-123",
"https://agent.example.com",
Duration::from_mins(5),
&key,
)
.unwrap();
token.signature = "deadbeef".repeat(8);
let err = token
.verify(&[key], "https://agent.example.com", "task-123")
.unwrap_err();
assert_matches!(err, IbctError::InvalidSignature);
}
#[cfg(feature = "ibct")]
#[test]
fn verify_rejects_unknown_key_id() {
let key = test_key();
let token = Ibct::issue(
"task-123",
"https://agent.example.com",
Duration::from_mins(5),
&key,
)
.unwrap();
let other_key = IbctKey {
key_id: "k99".into(),
key_bytes: b"other".to_vec(),
};
let err = token
.verify(&[other_key], "https://agent.example.com", "task-123")
.unwrap_err();
assert_matches!(err, IbctError::UnknownKeyId { .. });
}
#[cfg(feature = "ibct")]
#[test]
fn encode_decode_round_trip() {
let key = test_key();
let token = Ibct::issue(
"task-abc",
"https://agent.example.com",
Duration::from_mins(1),
&key,
)
.unwrap();
let encoded = token.encode().unwrap();
let decoded = Ibct::decode(&encoded).unwrap();
assert_eq!(decoded.task_id, "task-abc");
assert_eq!(decoded.key_id, "k1");
}
#[cfg(feature = "ibct")]
#[test]
fn verify_rejects_expired_token() {
let key = test_key();
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs();
let expired_at = now.saturating_sub(120);
let issued_at = expired_at.saturating_sub(300);
#[cfg(feature = "ibct")]
let signature = {
use hmac::{Hmac, KeyInit, Mac};
use sha2::Sha256;
type HmacSha256 = Hmac<Sha256>;
let msg = format!(
"{}|{}|{}|{}|{}",
key.key_id, "task-expired", "https://agent.example.com", issued_at, expired_at
);
let mut mac =
HmacSha256::new_from_slice(&key.key_bytes).expect("HMAC accepts any key length");
mac.update(msg.as_bytes());
hex::encode(mac.finalize().into_bytes())
};
let token = Ibct {
key_id: key.key_id.clone(),
task_id: "task-expired".into(),
endpoint: "https://agent.example.com".into(),
issued_at,
expires_at: expired_at,
signature,
};
let err = token
.verify(&[key], "https://agent.example.com", "task-expired")
.unwrap_err();
assert!(
matches!(err, IbctError::Expired { .. }),
"expected Expired, got {err:?}"
);
}
#[cfg(feature = "ibct")]
#[test]
fn key_rotation_verifies_with_old_key() {
let old_key = IbctKey {
key_id: "k1".into(),
key_bytes: b"old-key".to_vec(),
};
let new_key = IbctKey {
key_id: "k2".into(),
key_bytes: b"new-key".to_vec(),
};
let token = Ibct::issue(
"task-1",
"https://agent.example.com",
Duration::from_mins(5),
&old_key,
)
.unwrap();
assert!(
token
.verify(&[old_key, new_key], "https://agent.example.com", "task-1")
.is_ok()
);
}
#[cfg(feature = "ibct")]
#[test]
fn ibct_key_debug_redacts_key_bytes() {
let key = test_key();
let debug = format!("{key:?}");
assert!(!debug.contains("super-secret-key-for-testing-only"));
assert!(debug.contains("k1"));
assert!(debug.contains("REDACTED"));
}
#[cfg(feature = "ibct")]
#[test]
fn ibct_key_from_hex_decodes_bytes() {
let key = IbctKey::from_hex("k1", "68656c6c6f").unwrap();
assert_eq!(key.key_id, "k1");
assert_eq!(key.key_bytes, b"hello");
}
#[cfg(feature = "ibct")]
#[test]
fn ibct_key_from_hex_rejects_invalid_hex() {
assert!(IbctKey::from_hex("k1", "not-hex").is_err());
}
#[cfg(feature = "ibct")]
#[test]
fn ibct_scope_origin_strips_explicit_default_port() {
assert_eq!(
ibct_scope_origin("https://agent.example.com:443/a2a"),
"https://agent.example.com"
);
assert_eq!(
ibct_scope_origin("http://agent.example.com:80/a2a/stream"),
"http://agent.example.com"
);
}
#[cfg(feature = "ibct")]
#[test]
fn ibct_key_serialize_redacts_key_bytes() {
let key = test_key();
let json = serde_json::to_string(&key).unwrap();
assert!(!json.contains(&hex::encode(b"super-secret-key-for-testing-only")));
assert!(json.contains("k1"));
assert!(json.contains("REDACTED"));
}
}