use crate::policy::{ApproveVerifier, CapabilityClass, Verdict};
use agent_mesh_protocol::Fingerprint;
use serde::{Deserialize, Serialize};
const BINDING_DOMAIN: &[u8] = b"agent-bridle:operator:principal-binding:v1";
const CHALLENGE_DOMAIN: &[u8] = b"agent-bridle:operator:permission-challenge:v1";
fn push_field(buf: &mut Vec<u8>, field: &[u8]) {
buf.extend_from_slice(&(field.len() as u64).to_le_bytes());
buf.extend_from_slice(field);
}
fn verify_hex_sig(
payload: &[u8],
sig: &Option<String>,
verifier: &dyn ApproveVerifier,
) -> Result<(), String> {
let hex = sig.as_ref().ok_or_else(|| "unsigned".to_string())?;
let raw = crate::policy::hex_decode(hex)?;
verifier.verify(payload, &raw)
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct HumanPrincipal {
pub issuer: String,
pub subject: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub email: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub groups: Vec<String>,
}
impl HumanPrincipal {
pub fn key(&self) -> (&str, &str) {
(self.issuer.as_str(), self.subject.as_str())
}
pub fn is_member_of(&self, group: &str) -> bool {
self.groups.iter().any(|g| g == group)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PrincipalBinding {
pub human: HumanPrincipal,
pub mesh_agent_fingerprint: Fingerprint,
pub issued_generation: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sig: Option<String>,
}
impl PrincipalBinding {
pub fn new(
human: HumanPrincipal,
mesh_agent_fingerprint: Fingerprint,
issued_generation: u64,
) -> Self {
Self {
human,
mesh_agent_fingerprint,
issued_generation,
sig: None,
}
}
pub fn signing_payload(&self) -> Vec<u8> {
let mut p = Vec::new();
p.extend_from_slice(BINDING_DOMAIN);
push_field(&mut p, self.human.issuer.as_bytes());
push_field(&mut p, self.human.subject.as_bytes());
push_field(&mut p, &self.mesh_agent_fingerprint.0);
p.extend_from_slice(&self.issued_generation.to_le_bytes());
p
}
pub fn signed(mut self, sig: [u8; 64]) -> Self {
self.sig = Some(crate::policy::hex_encode(&sig));
self
}
pub fn verify(&self, verifier: &dyn ApproveVerifier) -> Result<(), String> {
verify_hex_sig(&self.signing_payload(), &self.sig, verifier)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum RequestedCapability {
Exec,
FsRead,
FsWrite,
Net,
RemoteTool,
GitWrite,
}
impl RequestedCapability {
pub fn as_str(self) -> &'static str {
match self {
Self::Exec => "exec",
Self::FsRead => "fs_read",
Self::FsWrite => "fs_write",
Self::Net => "net",
Self::RemoteTool => "remote_tool",
Self::GitWrite => "git_write",
}
}
pub fn to_capability_class(self) -> Option<CapabilityClass> {
match self {
Self::Exec => Some(CapabilityClass::Exec),
Self::FsRead | Self::FsWrite => Some(CapabilityClass::Fs),
Self::Net => Some(CapabilityClass::Net),
Self::RemoteTool | Self::GitWrite => None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum DangerTier {
Low,
Medium,
High,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PermissionChallenge {
pub request_id: String,
pub capability: RequestedCapability,
pub target: String,
pub danger: DangerTier,
pub mesh_agent_fingerprint: Fingerprint,
pub expires_at_unix: i64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub sig: Option<String>,
}
impl PermissionChallenge {
pub fn new(
request_id: impl Into<String>,
capability: RequestedCapability,
target: impl Into<String>,
danger: DangerTier,
mesh_agent_fingerprint: Fingerprint,
expires_at_unix: i64,
) -> Self {
Self {
request_id: request_id.into(),
capability,
target: target.into(),
danger,
mesh_agent_fingerprint,
expires_at_unix,
sig: None,
}
}
pub fn signing_payload(&self) -> Vec<u8> {
let mut p = Vec::new();
p.extend_from_slice(CHALLENGE_DOMAIN);
push_field(&mut p, self.request_id.as_bytes());
push_field(&mut p, self.capability.as_str().as_bytes());
push_field(&mut p, self.target.as_bytes());
push_field(&mut p, danger_tag(self.danger).as_bytes());
push_field(&mut p, &self.mesh_agent_fingerprint.0);
p.extend_from_slice(&self.expires_at_unix.to_le_bytes());
p
}
pub fn signed(mut self, sig: [u8; 64]) -> Self {
self.sig = Some(crate::policy::hex_encode(&sig));
self
}
pub fn verify(&self, verifier: &dyn ApproveVerifier) -> Result<(), String> {
verify_hex_sig(&self.signing_payload(), &self.sig, verifier)
}
pub fn digest(&self) -> Fingerprint {
Fingerprint::of_bytes(&self.signing_payload())
}
pub fn is_expired(&self, now_unix: i64) -> bool {
now_unix >= self.expires_at_unix
}
}
fn danger_tag(d: DangerTier) -> &'static str {
match d {
DangerTier::Low => "low",
DangerTier::Medium => "medium",
DangerTier::High => "high",
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum DecisionVerdict {
Deny,
AllowOnce,
AllowSession,
ApproveDurable,
}
impl DecisionVerdict {
pub fn durable_policy_verdict(self) -> Option<Verdict> {
match self {
Self::ApproveDurable => Some(Verdict::Approve),
_ => None,
}
}
pub fn is_durable(self) -> bool {
matches!(self, Self::ApproveDurable)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct OperatorDecision {
pub request_id: String,
pub challenge_digest: Fingerprint,
pub verdict: DecisionVerdict,
#[serde(default)]
pub step_up_owed: bool,
pub decided_by: HumanPrincipal,
}
impl OperatorDecision {
pub fn for_challenge(
challenge: &PermissionChallenge,
verdict: DecisionVerdict,
decided_by: HumanPrincipal,
) -> Self {
Self {
request_id: challenge.request_id.clone(),
challenge_digest: challenge.digest(),
verdict,
step_up_owed: false,
decided_by,
}
}
pub fn requiring_step_up(mut self) -> Self {
self.step_up_owed = true;
self
}
pub fn answers(&self, challenge: &PermissionChallenge) -> bool {
self.request_id == challenge.request_id && self.challenge_digest == challenge.digest()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn fp(seed: u8) -> Fingerprint {
Fingerprint::of_bytes(&[seed; 8])
}
fn a_human() -> HumanPrincipal {
HumanPrincipal {
issuer: "https://idp.example".into(),
subject: "sub-123".into(),
email: Some("op@example.com".into()),
groups: vec!["newt-operators".into()],
}
}
#[test]
fn human_principal_is_keyed_by_issuer_subject_never_email() {
let h = a_human();
assert_eq!(h.key(), ("https://idp.example", "sub-123"));
assert!(h.is_member_of("newt-operators"));
assert!(!h.is_member_of("admins"));
let mut h2 = h.clone();
h2.email = Some("changed@example.com".into());
assert_eq!(h.key(), h2.key());
}
#[test]
fn requested_capability_maps_onto_the_durable_vocab() {
use RequestedCapability::*;
assert_eq!(Exec.to_capability_class(), Some(CapabilityClass::Exec));
assert_eq!(FsRead.to_capability_class(), Some(CapabilityClass::Fs));
assert_eq!(FsWrite.to_capability_class(), Some(CapabilityClass::Fs));
assert_eq!(Net.to_capability_class(), Some(CapabilityClass::Net));
assert_eq!(RemoteTool.to_capability_class(), None);
assert_eq!(GitWrite.to_capability_class(), None);
assert_eq!(FsWrite.as_str(), "fs_write");
assert_eq!(RemoteTool.as_str(), "remote_tool");
}
#[test]
fn signing_payloads_are_field_boundary_safe() {
let a = PrincipalBinding::new(
HumanPrincipal {
issuer: "ab".into(),
subject: "c".into(),
email: None,
groups: vec![],
},
fp(1),
0,
);
let b = PrincipalBinding::new(
HumanPrincipal {
issuer: "a".into(),
subject: "bc".into(),
email: None,
groups: vec![],
},
fp(1),
0,
);
assert_ne!(a.signing_payload(), b.signing_payload());
let mut c = a.clone();
c.issued_generation = 1;
assert_ne!(a.signing_payload(), c.signing_payload());
}
#[test]
fn challenge_expiry_is_fail_closed_at_the_boundary() {
let c = PermissionChallenge::new(
"r1",
RequestedCapability::Exec,
"bash",
DangerTier::High,
fp(2),
100,
);
assert!(!c.is_expired(99), "before the deadline: live");
assert!(c.is_expired(100), "AT the deadline: expired (fail-closed)");
assert!(c.is_expired(101), "after: expired");
}
#[test]
fn decision_binds_to_exactly_its_challenge() {
let c = PermissionChallenge::new(
"r1",
RequestedCapability::Exec,
"bash",
DangerTier::High,
fp(3),
100,
);
let d = OperatorDecision::for_challenge(&c, DecisionVerdict::AllowOnce, a_human());
assert!(d.answers(&c), "answers its own challenge");
let mut tampered = c.clone();
tampered.target = "rm".into();
assert_eq!(tampered.request_id, c.request_id);
assert!(
!d.answers(&tampered),
"digest mismatch ⇒ not answered (no lift)"
);
let other = PermissionChallenge::new(
"r2",
RequestedCapability::Exec,
"bash",
DangerTier::High,
fp(3),
100,
);
assert!(!d.answers(&other));
}
#[test]
fn decision_verdict_only_approve_durable_is_durable() {
use DecisionVerdict::*;
assert_eq!(
ApproveDurable.durable_policy_verdict(),
Some(Verdict::Approve)
);
assert!(ApproveDurable.is_durable());
for ephemeral in [Deny, AllowOnce, AllowSession] {
assert_eq!(
ephemeral.durable_policy_verdict(),
None,
"{ephemeral:?} is ephemeral"
);
assert!(!ephemeral.is_durable());
}
}
#[test]
fn step_up_owed_is_off_by_default_and_opt_in() {
let c = PermissionChallenge::new(
"r1",
RequestedCapability::Net,
"example.com",
DangerTier::Medium,
fp(4),
100,
);
let d = OperatorDecision::for_challenge(&c, DecisionVerdict::AllowSession, a_human());
assert!(!d.step_up_owed);
assert!(d.requiring_step_up().step_up_owed);
}
}
#[cfg(all(test, feature = "verifier-ed25519"))]
mod signed_tests {
use super::*;
use crate::policy::Ed25519ApproveVerifier;
use ed25519_dalek::{Signer, SigningKey};
fn key() -> (SigningKey, Ed25519ApproveVerifier) {
let sk = SigningKey::from_bytes(&[7u8; 32]);
let verifier = Ed25519ApproveVerifier {
verifying_key: sk.verifying_key().to_bytes(),
};
(sk, verifier)
}
#[test]
fn principal_binding_sign_verify_roundtrip_rejects_tamper() {
let (sk, verifier) = key();
let human = HumanPrincipal {
issuer: "https://idp.example".into(),
subject: "sub-1".into(),
email: None,
groups: vec![],
};
let binding = PrincipalBinding::new(human, Fingerprint::of_bytes(&[9u8; 8]), 3);
let sig = sk.sign(&binding.signing_payload()).to_bytes();
let signed = binding.clone().signed(sig);
assert!(
signed.verify(&verifier).is_ok(),
"genuine signature verifies"
);
assert!(
binding.verify(&verifier).is_err(),
"unsigned is fail-closed"
);
let mut tampered = signed.clone();
tampered.issued_generation = 4;
assert!(
tampered.verify(&verifier).is_err(),
"tampered generation rejected"
);
let mut tampered2 = signed;
tampered2.mesh_agent_fingerprint = Fingerprint::of_bytes(&[1u8; 8]);
assert!(
tampered2.verify(&verifier).is_err(),
"tampered agent rejected"
);
}
#[test]
fn challenge_sign_verify_roundtrip_rejects_tamper() {
let (sk, verifier) = key();
let c = PermissionChallenge::new(
"req-1",
RequestedCapability::Exec,
"bash",
DangerTier::High,
Fingerprint::of_bytes(&[9u8; 8]),
100,
);
let sig = sk.sign(&c.signing_payload()).to_bytes();
let signed = c.signed(sig);
assert!(signed.verify(&verifier).is_ok());
let mut swapped = signed.clone();
swapped.target = "rm".into();
assert!(
swapped.verify(&verifier).is_err(),
"target substitution rejected"
);
let other = Ed25519ApproveVerifier {
verifying_key: SigningKey::from_bytes(&[8u8; 32])
.verifying_key()
.to_bytes(),
};
assert!(signed.verify(&other).is_err(), "wrong signer rejected");
}
}