use hmac::{Hmac, Mac};
use serde::{Deserialize, Serialize};
use sha2::Sha256;
use std::collections::{HashMap, HashSet};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[error("invalid {kind} id `{got}`: expected `{expected}` prefix")]
pub struct IdParseError {
pub kind: &'static str,
pub expected: &'static str,
pub got: String,
}
macro_rules! id_newtype {
($(#[$meta:meta])* $name:ident, $prefix:literal, $kind:literal) => {
$(#[$meta])*
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(try_from = "String")]
pub struct $name(String);
impl $name {
pub const PREFIX: &'static str = $prefix;
pub fn new() -> Self {
Self(format!(concat!($prefix, "{}"), uid_hex(8)))
}
pub fn parse(s: impl Into<String>) -> Result<Self, IdParseError> {
let s = s.into();
if s.len() > $prefix.len() && s.starts_with($prefix) {
Ok(Self(s))
} else {
Err(IdParseError {
kind: $kind,
expected: $prefix,
got: s,
})
}
}
pub fn as_str(&self) -> &str {
&self.0
}
pub fn into_string(self) -> String {
self.0
}
}
impl Default for $name {
fn default() -> Self {
Self::new()
}
}
impl std::fmt::Display for $name {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
impl TryFrom<String> for $name {
type Error = IdParseError;
fn try_from(s: String) -> Result<Self, IdParseError> {
Self::parse(s)
}
}
impl std::str::FromStr for $name {
type Err = IdParseError;
fn from_str(s: &str) -> Result<Self, IdParseError> {
Self::parse(s)
}
}
impl AsRef<str> for $name {
fn as_ref(&self) -> &str {
&self.0
}
}
impl From<$name> for String {
fn from(id: $name) -> String {
id.0
}
}
};
}
id_newtype!(
StepId, "ST-", "step"
);
id_newtype!(
TaskId, "T-", "task"
);
id_newtype!(
RunId, "R-", "run"
);
id_newtype!(
SessionId, "S-", "session"
);
id_newtype!(
WorkerId, "W-", "worker"
);
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Role {
Operator,
Worker,
Observer,
Senior,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Verb {
StartTask,
DispatchAttempt,
MintWorkerToken,
ReadTaskState,
CancelTask,
QuerySenior,
MarkPass,
MarkBlocked,
AttachSession,
DetachSession,
Heartbeat,
PollTask,
FetchPrompt,
FetchData,
PostResult,
VerifyToken,
EmitOutput,
SubscribeEvents,
ReadTrace,
AnswerQuery,
OverrideVerdict,
PauseLoop,
ResumeLoop,
InjectDirective,
}
#[derive(Debug, Clone)]
pub struct RoleVerbGate {
table: HashMap<Role, HashSet<Verb>>,
}
impl RoleVerbGate {
pub fn new() -> Self {
Self {
table: HashMap::new(),
}
}
pub fn allow(mut self, role: Role, verbs: &[Verb]) -> Self {
let set = self.table.entry(role).or_default();
for v in verbs {
set.insert(*v);
}
self
}
pub fn is_allowed(&self, role: Role, verb: Verb) -> bool {
self.table
.get(&role)
.map(|s| s.contains(&verb))
.unwrap_or(false)
}
}
impl Default for RoleVerbGate {
fn default() -> Self {
default_role_verb_table()
}
}
pub const OPERATOR_VERBS: &[Verb] = &[
Verb::StartTask,
Verb::DispatchAttempt,
Verb::MintWorkerToken,
Verb::ReadTaskState,
Verb::CancelTask,
Verb::QuerySenior,
Verb::MarkPass,
Verb::MarkBlocked,
Verb::AttachSession,
Verb::DetachSession,
Verb::Heartbeat,
Verb::PollTask,
];
pub const WORKER_LEAF_VERBS: &[Verb] = &[
Verb::FetchPrompt,
Verb::FetchData,
Verb::PostResult,
Verb::VerifyToken,
Verb::EmitOutput,
];
pub const WORKER_SWARM_VERBS: &[Verb] = &[
Verb::StartTask,
Verb::DispatchAttempt,
Verb::ReadTaskState,
Verb::PollTask,
Verb::CancelTask,
];
pub const OBSERVER_VERBS: &[Verb] = &[Verb::SubscribeEvents, Verb::ReadTrace, Verb::ReadTaskState];
pub const SENIOR_VERBS: &[Verb] = &[
Verb::AnswerQuery,
Verb::OverrideVerdict,
Verb::PauseLoop,
Verb::ResumeLoop,
Verb::InjectDirective,
];
pub fn default_role_verb_table() -> RoleVerbGate {
RoleVerbGate::new()
.allow(Role::Operator, OPERATOR_VERBS)
.allow(Role::Worker, WORKER_LEAF_VERBS)
.allow(Role::Worker, WORKER_SWARM_VERBS)
.allow(Role::Observer, OBSERVER_VERBS)
.allow(Role::Senior, SENIOR_VERBS)
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct CapToken {
pub agent_id: String,
pub role: Role,
pub scopes: Vec<String>,
pub issued_at: u64,
pub expire_at: u64,
pub max_uses: Option<u32>,
pub nonce: String,
pub sig_hex: String,
}
impl CapToken {
pub fn fingerprint(&self) -> String {
token_fingerprint(&self.nonce)
}
pub fn signing_input(&self) -> Vec<u8> {
let s = format!(
"{}|{:?}|{}|{}|{}|{:?}|{}",
self.agent_id,
self.role,
self.scopes.join(","),
self.issued_at,
self.expire_at,
self.max_uses,
self.nonce,
);
s.into_bytes()
}
pub fn is_expired(&self, now_unix: u64) -> bool {
now_unix >= self.expire_at
}
pub fn encode(&self) -> String {
use base64::Engine as _;
let json = serde_json::to_vec(self).expect("CapToken is always JSON-serializable");
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(json)
}
pub fn decode(s: &str) -> Result<Self, CapTokenDecodeError> {
use base64::Engine as _;
let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
.decode(s)
.map_err(|e| CapTokenDecodeError::Base64(e.to_string()))?;
serde_json::from_slice(&bytes).map_err(|e| CapTokenDecodeError::Json(e.to_string()))
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct WorkerPayload {
pub task_id: StepId,
pub attempt: u32,
pub agent: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub system: Option<String>,
pub prompt: String,
}
#[derive(Debug, thiserror::Error)]
pub enum CapTokenDecodeError {
#[error("base64 decode failed: {0}")]
Base64(String),
#[error("json parse failed: {0}")]
Json(String),
}
#[derive(Debug, Clone)]
pub struct TokenSigner {
secret: Vec<u8>,
}
impl TokenSigner {
pub fn new(secret: impl AsRef<[u8]>) -> Self {
Self {
secret: secret.as_ref().to_vec(),
}
}
pub fn mint(
&self,
agent_id: impl Into<String>,
role: Role,
scopes: Vec<String>,
ttl: Duration,
max_uses: Option<u32>,
) -> CapToken {
let now = now_unix();
let mut token = CapToken {
agent_id: agent_id.into(),
role,
scopes,
issued_at: now,
expire_at: now + ttl.as_secs(),
max_uses,
nonce: secure_hex(16),
sig_hex: String::new(),
};
let mut mac =
Hmac::<Sha256>::new_from_slice(&self.secret).expect("HMAC accepts any key length");
mac.update(&token.signing_input());
let sig = mac.finalize().into_bytes();
token.sig_hex = hex::encode(sig);
token
}
pub fn verify_sig(&self, token: &CapToken) -> bool {
let mut mac =
Hmac::<Sha256>::new_from_slice(&self.secret).expect("HMAC accepts any key length");
mac.update(&token.signing_input());
let expected = mac.finalize().into_bytes();
let Ok(provided) = hex::decode(&token.sig_hex) else {
return false;
};
ct_eq(&expected, &provided)
}
pub fn one_time(
&self,
agent_id: impl Into<String>,
role: Role,
scopes: Vec<String>,
ttl: Duration,
) -> CapToken {
self.mint(agent_id, role, scopes, ttl, Some(1))
}
pub fn session(
&self,
agent_id: impl Into<String>,
role: Role,
scopes: Vec<String>,
ttl: Duration,
) -> CapToken {
self.mint(agent_id, role, scopes, ttl, None)
}
pub fn limited(
&self,
agent_id: impl Into<String>,
role: Role,
scopes: Vec<String>,
ttl: Duration,
max_uses: u32,
) -> CapToken {
self.mint(agent_id, role, scopes, ttl, Some(max_uses))
}
}
pub(crate) fn now_unix() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.expect("system clock is before UNIX_EPOCH")
.as_secs()
}
pub fn uid_hex(bytes: usize) -> String {
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::OnceLock;
static COUNTER: AtomicU64 = AtomicU64::new(0);
static SALT: OnceLock<u128> = OnceLock::new();
let salt = *SALT.get_or_init(|| {
let mut b = [0u8; 16];
getrandom::fill(&mut b).expect("OS RNG unavailable");
u128::from_le_bytes(b)
});
let c = COUNTER.fetch_add(1, Ordering::Relaxed) as u128;
let v = salt ^ c;
let raw = format!("{:032x}", v);
let n = (bytes * 2).min(32);
raw[32 - n..].to_string()
}
pub fn secure_hex(bytes: usize) -> String {
let mut buf = vec![0u8; bytes];
getrandom::fill(&mut buf).expect("OS RNG unavailable");
hex::encode(buf)
}
pub fn token_fingerprint(nonce: &str) -> String {
use sha2::Digest as _;
hex::encode(Sha256::digest(nonce.as_bytes()))
}
pub fn ct_eq(a: &[u8], b: &[u8]) -> bool {
if a.len() != b.len() {
return false;
}
let mut diff: u8 = 0;
for (x, y) in a.iter().zip(b.iter()) {
diff |= x ^ y;
}
diff == 0
}
#[cfg(test)]
mod id_newtype_tests {
use super::*;
#[test]
fn parse_accepts_prefixed_ids() {
assert_eq!(StepId::parse("ST-abc123").unwrap().as_str(), "ST-abc123");
assert_eq!(TaskId::parse("T-abc123").unwrap().as_str(), "T-abc123");
assert_eq!(RunId::parse("R-abc123").unwrap().as_str(), "R-abc123");
assert_eq!(SessionId::parse("S-abc123").unwrap().as_str(), "S-abc123");
assert_eq!(WorkerId::parse("W-abc123").unwrap().as_str(), "W-abc123");
}
#[test]
fn parse_rejects_wrong_prefix_and_empty_suffix() {
assert!(StepId::parse("T-abc").is_err());
assert!(TaskId::parse("ST-abc").is_err());
assert!(
RunId::parse("RK-abc").is_err(),
"resume keys are not run ids"
);
assert!(SessionId::parse("W-abc").is_err());
assert!(WorkerId::parse("S-abc").is_err());
assert!(StepId::parse("st-abc").is_err());
assert!(TaskId::parse("T-").is_err());
assert!(RunId::parse("nope").is_err());
assert!(WorkerId::parse("").is_err());
}
#[test]
fn parse_error_carries_kind_prefix_and_input() {
let err = TaskId::parse("R-xyz").unwrap_err();
assert_eq!(err.kind, "task");
assert_eq!(err.expected, "T-");
assert_eq!(err.got, "R-xyz");
assert_eq!(
err.to_string(),
"invalid task id `R-xyz`: expected `T-` prefix"
);
}
#[test]
fn minted_ids_round_trip_through_parse() {
assert!(StepId::parse(StepId::new().into_string()).is_ok());
assert!(TaskId::parse(TaskId::new().into_string()).is_ok());
assert!(RunId::parse(RunId::new().into_string()).is_ok());
assert!(SessionId::parse(SessionId::new().into_string()).is_ok());
assert!(WorkerId::parse(WorkerId::new().into_string()).is_ok());
}
#[test]
fn serde_wire_format_is_a_plain_string() {
let id = TaskId::parse("T-abc").unwrap();
assert_eq!(
serde_json::to_value(&id).unwrap(),
serde_json::json!("T-abc")
);
let back: TaskId = serde_json::from_value(serde_json::json!("T-abc")).unwrap();
assert_eq!(back, id);
}
#[test]
fn serde_deserialize_validates_prefix() {
let err = serde_json::from_value::<TaskId>(serde_json::json!("ST-abc"));
assert!(err.is_err(), "deserialize must route through parse");
}
}
#[cfg(test)]
mod cap_token_fingerprint_tests {
use super::*;
use std::time::Duration;
#[test]
fn fingerprint_is_sha256_of_nonce_and_not_the_nonce() {
let signer = TokenSigner::new("test-secret");
let token = signer.session("a", Role::Worker, vec!["*".into()], Duration::from_secs(60));
let fp = token.fingerprint();
assert_eq!(fp.len(), 64);
assert_ne!(fp, token.nonce);
assert!(!fp.contains(&token.nonce));
assert_eq!(fp, token_fingerprint(&token.nonce));
assert_eq!(fp, token.fingerprint());
let other = signer.session("a", Role::Worker, vec!["*".into()], Duration::from_secs(60));
assert_ne!(fp, other.fingerprint());
}
}
#[cfg(test)]
mod cap_token_transport_tests {
use super::*;
use std::time::Duration;
#[test]
fn encode_decode_round_trips() {
let signer = TokenSigner::new("test-secret");
let token = signer.session(
"worker-of-task-x",
Role::Worker,
vec!["*".into()],
Duration::from_secs(600),
);
let s = token.encode();
assert!(!s.contains('+'));
assert!(!s.contains('/'));
assert!(!s.contains('='));
let decoded = CapToken::decode(&s).expect("decode ok");
assert_eq!(decoded, token);
assert!(
signer.verify_sig(&decoded),
"HMAC sig still verifies after round-trip"
);
}
#[test]
fn decode_rejects_garbage() {
let err = CapToken::decode("not-base64!!!").expect_err("should fail");
assert!(matches!(err, CapTokenDecodeError::Base64(_)));
}
#[test]
fn decode_rejects_non_token_json() {
use base64::Engine as _;
let bogus = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(b"{\"oops\":1}");
let err = CapToken::decode(&bogus).expect_err("should fail json shape");
assert!(matches!(err, CapTokenDecodeError::Json(_)));
}
}