use agent_mesh_protocol::Fingerprint;
use serde::{Deserialize, Serialize};
use crate::{ToolContext, ToolError};
const CHALLENGE_DOMAIN: &[u8] = b"agent-bridle/step-up/v1";
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Presence {
#[default]
None,
Prompt,
Passkey,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct ContentId([u8; 32]);
impl ContentId {
#[must_use]
pub fn of_bytes(data: &[u8]) -> Self {
Self(Fingerprint::of_bytes(data).0)
}
#[must_use]
pub fn as_bytes(&self) -> &[u8; 32] {
&self.0
}
#[must_use]
pub fn to_hex(&self) -> String {
self.0.iter().map(|b| format!("{b:02x}")).collect()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct Challenge([u8; 32]);
impl Challenge {
#[must_use]
pub fn bind(action: &ContentId, generation: u64, nonce: &[u8; 32]) -> Self {
let mut buf = Vec::with_capacity(CHALLENGE_DOMAIN.len() + 32 + 8 + 32);
buf.extend_from_slice(CHALLENGE_DOMAIN);
buf.extend_from_slice(action.as_bytes());
buf.extend_from_slice(&generation.to_le_bytes());
buf.extend_from_slice(nonce);
Self(Fingerprint::of_bytes(&buf).0)
}
#[must_use]
pub fn as_bytes(&self) -> &[u8; 32] {
&self.0
}
}
#[derive(Debug, Clone)]
pub struct CallRequest {
pub tool: String,
pub args: serde_json::Value,
pub resource: String,
}
impl CallRequest {
#[must_use]
pub fn new(
tool: impl Into<String>,
args: serde_json::Value,
resource: impl Into<String>,
) -> Self {
Self {
tool: tool.into(),
args,
resource: resource.into(),
}
}
#[must_use]
pub fn unspecified(tool: impl Into<String>) -> Self {
Self {
tool: tool.into(),
args: serde_json::Value::Null,
resource: String::new(),
}
}
#[must_use]
pub fn content_id(&self) -> ContentId {
let canonical = (
self.tool.as_str(),
canonical_json(&self.args),
self.resource.as_str(),
);
let bytes = serde_json::to_vec(&canonical)
.expect("a (str, Value, str) tuple is always JSON-serializable");
ContentId::of_bytes(&bytes)
}
}
fn canonical_json(value: &serde_json::Value) -> serde_json::Value {
match value {
serde_json::Value::Object(map) => {
let mut keys: Vec<&String> = map.keys().collect();
keys.sort();
let mut sorted = serde_json::Map::new();
for k in keys {
sorted.insert(k.clone(), canonical_json(&map[k]));
}
serde_json::Value::Object(sorted)
}
serde_json::Value::Array(items) => {
serde_json::Value::Array(items.iter().map(canonical_json).collect())
}
other => other.clone(),
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct AttestRequirement {
pub presence: Presence,
pub record: bool,
pub freshness_generations: u64,
}
impl AttestRequirement {
pub const NONE: Self = Self {
presence: Presence::None,
record: false,
freshness_generations: 0,
};
#[must_use]
pub fn presence(presence: Presence) -> Self {
Self {
presence,
record: false,
freshness_generations: 0,
}
}
#[must_use]
pub fn passkey_recorded() -> Self {
Self {
presence: Presence::Passkey,
record: true,
freshness_generations: 0,
}
}
#[must_use]
pub fn demands_gesture(&self) -> bool {
self.presence > Presence::None
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Discharge {
pub presence: Presence,
pub credential_id: Vec<u8>,
pub challenge: [u8; 32],
pub signature: Vec<u8>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub authenticator_data: Option<Vec<u8>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub client_data_json: Option<Vec<u8>>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Attestation {
pub schema: String,
pub tool: String,
pub resource: String,
pub challenge: [u8; 32],
pub generation: u64,
pub credential_id: Vec<u8>,
pub signature: Vec<u8>,
pub presence: Presence,
}
impl Attestation {
pub const SCHEMA: &'static str = "agent-bridle/attestation/v1";
#[must_use]
pub fn from_verified(
tool: &str,
resource: &str,
discharge: &Discharge,
generation: u64,
) -> Self {
Self {
schema: Self::SCHEMA.to_string(),
tool: tool.to_string(),
resource: resource.to_string(),
challenge: discharge.challenge,
generation,
credential_id: discharge.credential_id.clone(),
signature: discharge.signature.clone(),
presence: discharge.presence,
}
}
#[must_use]
pub fn content_id(&self) -> ContentId {
let bytes = serde_json::to_vec(self).expect("Attestation is always JSON-serializable");
ContentId::of_bytes(&bytes)
}
}
pub trait DischargeVerifier {
fn verify(
&self,
discharge: &Discharge,
required: &AttestRequirement,
expected: &Challenge,
) -> Result<(), String>;
}
#[cfg(feature = "verifier-ed25519")]
#[derive(Debug, Default, Clone, Copy)]
pub struct Ed25519Verifier;
#[cfg(feature = "verifier-ed25519")]
impl DischargeVerifier for Ed25519Verifier {
fn verify(
&self,
discharge: &Discharge,
required: &AttestRequirement,
expected: &Challenge,
) -> Result<(), String> {
use ed25519_dalek::{Signature, VerifyingKey};
if discharge.presence < required.presence {
return Err(format!(
"presence {:?} is below required {:?}",
discharge.presence, required.presence
));
}
if &discharge.challenge != expected.as_bytes() {
return Err("discharge does not answer this action's challenge".into());
}
let vk_bytes: [u8; 32] = discharge
.credential_id
.as_slice()
.try_into()
.map_err(|_| "credential id is not a 32-byte ed25519 key".to_string())?;
let vk = VerifyingKey::from_bytes(&vk_bytes).map_err(|e| e.to_string())?;
let sig_bytes: [u8; 64] = discharge
.signature
.as_slice()
.try_into()
.map_err(|_| "signature is not 64 bytes".to_string())?;
let sig = Signature::from_bytes(&sig_bytes);
vk.verify_strict(expected.as_bytes(), &sig)
.map_err(|e| e.to_string())
}
}
#[cfg(feature = "verifier-webauthn")]
fn base64url_nopad(bytes: &[u8]) -> String {
const T: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4);
for chunk in bytes.chunks(3) {
let b0 = chunk[0];
let b1 = chunk.get(1).copied().unwrap_or(0);
let b2 = chunk.get(2).copied().unwrap_or(0);
let n = (u32::from(b0) << 16) | (u32::from(b1) << 8) | u32::from(b2);
out.push(T[((n >> 18) & 0x3f) as usize] as char);
out.push(T[((n >> 12) & 0x3f) as usize] as char);
if chunk.len() > 1 {
out.push(T[((n >> 6) & 0x3f) as usize] as char);
}
if chunk.len() > 2 {
out.push(T[(n & 0x3f) as usize] as char);
}
}
out
}
#[cfg(feature = "verifier-webauthn")]
#[derive(Debug, Default, Clone, Copy)]
pub struct WebAuthnVerifier;
#[cfg(feature = "verifier-webauthn")]
impl DischargeVerifier for WebAuthnVerifier {
fn verify(
&self,
discharge: &Discharge,
required: &AttestRequirement,
expected: &Challenge,
) -> Result<(), String> {
use ed25519_dalek::{Signature, VerifyingKey};
use sha2::{Digest, Sha256};
if discharge.presence < required.presence {
return Err(format!(
"presence {:?} is below required {:?}",
discharge.presence, required.presence
));
}
let auth_data = discharge
.authenticator_data
.as_deref()
.ok_or("WebAuthn assertion is missing authenticatorData")?;
let client_data = discharge
.client_data_json
.as_deref()
.ok_or("WebAuthn assertion is missing clientDataJSON")?;
if auth_data.len() < 37 {
return Err("authenticatorData is too short (need ≥ 37 bytes)".into());
}
let flags = auth_data[32];
let up = flags & 0x01 != 0; let uv = flags & 0x04 != 0; if !up {
return Err("authenticatorData User-Presence (UP) flag is not set".into());
}
if required.presence >= Presence::Passkey && !uv {
return Err(
"authenticatorData User-Verification (UV) flag is required for Passkey but not set"
.into(),
);
}
#[derive(serde::Deserialize)]
struct ClientData {
#[serde(rename = "type")]
typ: String,
challenge: String,
}
let cd: ClientData = serde_json::from_slice(client_data)
.map_err(|e| format!("clientDataJSON does not parse: {e}"))?;
if cd.typ != "webauthn.get" {
return Err(format!(
"clientDataJSON.type is {:?}, expected \"webauthn.get\"",
cd.typ
));
}
if cd.challenge != base64url_nopad(expected.as_bytes()) {
return Err("assertion does not answer this action's challenge".into());
}
if &discharge.challenge != expected.as_bytes() {
return Err("discharge challenge does not match the gate-recomputed challenge".into());
}
let vk_bytes: [u8; 32] = discharge
.credential_id
.as_slice()
.try_into()
.map_err(|_| "credential id is not a 32-byte ed25519 key".to_string())?;
let vk = VerifyingKey::from_bytes(&vk_bytes).map_err(|e| e.to_string())?;
let sig_bytes: [u8; 64] = discharge
.signature
.as_slice()
.try_into()
.map_err(|_| "signature is not 64 bytes".to_string())?;
let sig = Signature::from_bytes(&sig_bytes);
let mut signed = Vec::with_capacity(auth_data.len() + 32);
signed.extend_from_slice(auth_data);
signed.extend_from_slice(&Sha256::digest(client_data));
vk.verify_strict(&signed, &sig).map_err(|e| e.to_string())
}
}
pub trait DischargeProvider {
fn obtain(
&self,
request: &CallRequest,
required: &AttestRequirement,
generation: u64,
nonce: &[u8; 32],
) -> Result<Discharge, String>;
}
pub struct DischargeAttempt<'a> {
pub nonce: [u8; 32],
pub discharge: &'a Discharge,
pub verifier: &'a dyn DischargeVerifier,
}
#[derive(Debug)]
pub enum Decision {
Allow(ToolContext),
Deny(ToolError),
NeedsDischarge(AttestRequirement),
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Rule {
pub selector: String,
pub requirement: AttestRequirement,
}
impl Rule {
fn matches(&self, request: &CallRequest) -> bool {
let (tool, resource_glob) = match self.selector.split_once(':') {
Some((tool, glob)) => (tool, Some(glob)),
None => (self.selector.as_str(), None),
};
if tool != request.tool {
return false;
}
match resource_glob {
None => true,
Some(glob) => glob_prefix_match(glob, &request.resource),
}
}
}
fn glob_prefix_match(pattern: &str, text: &str) -> bool {
if let Some(prefix) = pattern.strip_suffix('*') {
let prefix = prefix.strip_suffix('*').unwrap_or(prefix);
text.starts_with(prefix)
} else {
pattern == text
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct StepUpPolicy {
pub rules: Vec<Rule>,
pub default: AttestRequirement,
}
impl StepUpPolicy {
pub const EMPTY: Self = Self {
rules: Vec::new(),
default: AttestRequirement::NONE,
};
#[must_use]
pub fn new(rules: Vec<Rule>, default: AttestRequirement) -> Self {
Self { rules, default }
}
#[must_use]
pub fn required_for(&self, request: &CallRequest) -> AttestRequirement {
let mut best: Option<&Rule> = None;
for rule in &self.rules {
if rule.matches(request) && best.is_none_or(|b| rule.selector.len() > b.selector.len())
{
best = Some(rule);
}
}
best.map_or_else(|| self.default.clone(), |r| r.requirement.clone())
}
}
impl Default for StepUpPolicy {
fn default() -> Self {
Self::EMPTY
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{Caveats, CountBound, Gate, Tool, ToolResult};
#[cfg(feature = "verifier-ed25519")]
use crate::ToolError;
#[cfg(any(feature = "verifier-ed25519", feature = "verifier-webauthn"))]
use ed25519_dalek::{Signer, SigningKey};
struct NamedTool(&'static str);
#[async_trait::async_trait]
impl Tool for NamedTool {
fn name(&self) -> &str {
self.0
}
fn schema(&self) -> serde_json::Value {
serde_json::json!({})
}
async fn invoke(
&self,
_args: serde_json::Value,
_cx: &ToolContext,
) -> ToolResult<serde_json::Value> {
Ok(serde_json::Value::Null)
}
}
#[cfg(feature = "verifier-ed25519")]
fn test_key() -> SigningKey {
SigningKey::from_bytes(&[7u8; 32])
}
#[cfg(feature = "verifier-ed25519")]
fn sign_discharge(
key: &SigningKey,
request: &CallRequest,
generation: u64,
nonce: &[u8; 32],
presence: Presence,
) -> Discharge {
let challenge = Challenge::bind(&request.content_id(), generation, nonce);
let sig = key.sign(challenge.as_bytes());
Discharge {
presence,
credential_id: key.verifying_key().to_bytes().to_vec(),
challenge: *challenge.as_bytes(),
signature: sig.to_bytes().to_vec(),
authenticator_data: None,
client_data_json: None,
}
}
fn push_policy() -> StepUpPolicy {
StepUpPolicy::new(
vec![Rule {
selector: "git.push:github.com/org/*".to_string(),
requirement: AttestRequirement::passkey_recorded(),
}],
AttestRequirement::NONE,
)
}
fn push_request() -> CallRequest {
CallRequest::new(
"git.push",
serde_json::json!({"ref": "refs/heads/main"}),
"github.com/org/repo",
)
}
#[test]
fn presence_is_totally_ordered_none_prompt_passkey() {
assert!(Presence::None < Presence::Prompt);
assert!(Presence::Prompt < Presence::Passkey);
}
#[test]
fn content_id_is_deterministic_and_argument_order_independent() {
let a = CallRequest::new(
"email.send",
serde_json::json!({"to": "x", "subj": "y"}),
"r",
);
let b = CallRequest::new(
"email.send",
serde_json::json!({"subj": "y", "to": "x"}),
"r",
);
assert_eq!(
a.content_id(),
b.content_id(),
"key order must not change identity"
);
let c = CallRequest::new(
"email.send",
serde_json::json!({"to": "z", "subj": "y"}),
"r",
);
assert_ne!(a.content_id(), c.content_id(), "different args must differ");
}
#[test]
fn needs_discharge_does_not_mint_or_charge() {
let gate = Gate::with_budget(0, CountBound::AtMost(1));
let granted = Caveats::top();
let tool = NamedTool("git.push");
match gate.evaluate(&tool, &granted, &push_request(), &push_policy()) {
Decision::NeedsDischarge(req) => assert_eq!(req.presence, Presence::Passkey),
other => panic!("expected NeedsDischarge, got {other:?}"),
}
let free = NamedTool("free.tool");
match gate.evaluate(
&free,
&granted,
&CallRequest::unspecified("free.tool"),
&StepUpPolicy::EMPTY,
) {
Decision::Allow(cx) => assert!(cx.caveats().leq(&granted)),
other => panic!("expected Allow, got {other:?}"),
}
}
#[cfg(feature = "verifier-ed25519")]
#[test]
fn valid_discharge_mints_and_records() {
let gate = Gate::new(0);
let granted = Caveats::top();
let tool = NamedTool("git.push");
let req = push_request();
let nonce = [9u8; 32];
let discharge = sign_discharge(&test_key(), &req, 0, &nonce, Presence::Passkey);
let attempt = DischargeAttempt {
nonce,
discharge: &discharge,
verifier: &Ed25519Verifier,
};
let (cx, attestation) = gate
.authorize_with_discharge(&tool, &granted, &req, &push_policy(), &attempt)
.expect("valid discharge authorizes");
assert!(cx.caveats().leq(&granted));
let attestation = attestation.expect("passkey+record must produce an attestation");
assert_eq!(attestation.tool, "git.push");
assert_eq!(attestation.resource, "github.com/org/repo");
assert_eq!(attestation.content_id(), attestation.content_id());
}
#[cfg(feature = "verifier-ed25519")]
struct MockProvider {
key: SigningKey,
presence: Presence,
}
#[cfg(feature = "verifier-ed25519")]
impl DischargeProvider for MockProvider {
fn obtain(
&self,
request: &CallRequest,
_required: &AttestRequirement,
generation: u64,
nonce: &[u8; 32],
) -> Result<Discharge, String> {
Ok(sign_discharge(
&self.key,
request,
generation,
nonce,
self.presence,
))
}
}
#[cfg(feature = "verifier-ed25519")]
struct FailingProvider;
#[cfg(feature = "verifier-ed25519")]
impl DischargeProvider for FailingProvider {
fn obtain(
&self,
_request: &CallRequest,
_required: &AttestRequirement,
_generation: u64,
_nonce: &[u8; 32],
) -> Result<Discharge, String> {
Err("ceremony failed: human declined".into())
}
}
#[cfg(feature = "verifier-ed25519")]
#[test]
fn provider_obtain_then_authorize_mints_and_records() {
let gate = Gate::new(0);
let granted = Caveats::top();
let tool = NamedTool("git.push");
let provider = MockProvider {
key: test_key(),
presence: Presence::Passkey,
};
let (cx, attestation) = gate
.authorize_step_up(
&tool,
&granted,
&push_request(),
&push_policy(),
&provider,
&Ed25519Verifier,
[11u8; 32],
)
.expect("a valid provider discharge authorizes");
assert!(cx.caveats().leq(&granted));
let attestation = attestation.expect("passkey+record must produce an attestation");
assert_eq!(attestation.tool, "git.push");
}
#[cfg(feature = "verifier-ed25519")]
#[test]
fn provider_error_fails_closed() {
let gate = Gate::with_budget(0, CountBound::AtMost(1));
let granted = Caveats::top();
let tool = NamedTool("git.push");
let err = gate
.authorize_step_up(
&tool,
&granted,
&push_request(),
&push_policy(),
&FailingProvider,
&Ed25519Verifier,
[12u8; 32],
)
.expect_err("a failed ceremony is fail-closed");
assert!(matches!(err, ToolError::Denied { .. }));
let free = NamedTool("free.tool");
match gate.evaluate(
&free,
&granted,
&CallRequest::unspecified("free.tool"),
&StepUpPolicy::EMPTY,
) {
Decision::Allow(cx) => assert!(cx.caveats().leq(&granted)),
other => panic!("expected Allow, got {other:?}"),
}
}
#[cfg(feature = "verifier-ed25519")]
#[test]
fn provider_below_required_presence_is_denied() {
let gate = Gate::new(0);
let granted = Caveats::top();
let tool = NamedTool("git.push");
let provider = MockProvider {
key: test_key(),
presence: Presence::Prompt,
};
let err = gate
.authorize_step_up(
&tool,
&granted,
&push_request(),
&push_policy(),
&provider,
&Ed25519Verifier,
[13u8; 32],
)
.expect_err("a Prompt provider cannot satisfy a Passkey policy");
assert!(matches!(err, ToolError::Denied { .. }));
}
#[cfg(feature = "verifier-ed25519")]
#[test]
fn authorize_step_up_without_gesture_degenerates_to_authorize() {
let gate = Gate::new(0);
let granted = Caveats::top();
let free = NamedTool("free.tool");
let provider = MockProvider {
key: test_key(),
presence: Presence::Passkey,
};
let (cx, attestation) = gate
.authorize_step_up(
&free,
&granted,
&CallRequest::unspecified("free.tool"),
&StepUpPolicy::EMPTY,
&provider,
&Ed25519Verifier,
[14u8; 32],
)
.expect("no gesture owed → ordinary authorize");
assert!(cx.caveats().leq(&granted));
assert!(attestation.is_none(), "no step-up → no attestation");
}
#[cfg(feature = "verifier-ed25519")]
#[test]
fn wrong_challenge_is_denied() {
let gate = Gate::new(0);
let granted = Caveats::top();
let tool = NamedTool("git.push");
let req = push_request();
let discharge = sign_discharge(&test_key(), &req, 0, &[1u8; 32], Presence::Passkey);
let attempt = DischargeAttempt {
nonce: [2u8; 32], discharge: &discharge,
verifier: &Ed25519Verifier,
};
let err = gate
.authorize_with_discharge(&tool, &granted, &req, &push_policy(), &attempt)
.expect_err("mismatched challenge must be denied");
assert!(matches!(err, ToolError::Denied { .. }));
}
#[cfg(feature = "verifier-ed25519")]
#[test]
fn presence_too_weak_fails_closed() {
let gate = Gate::new(0);
let granted = Caveats::top();
let tool = NamedTool("git.push");
let req = push_request();
let nonce = [4u8; 32];
let discharge = sign_discharge(&test_key(), &req, 0, &nonce, Presence::Prompt);
let attempt = DischargeAttempt {
nonce,
discharge: &discharge,
verifier: &Ed25519Verifier,
};
let err = gate
.authorize_with_discharge(&tool, &granted, &req, &push_policy(), &attempt)
.expect_err("Prompt cannot satisfy Passkey");
assert!(matches!(err, ToolError::Denied { .. }));
}
#[cfg(feature = "verifier-ed25519")]
#[test]
fn no_authenticator_presence_none_cannot_satisfy_passkey() {
let gate = Gate::new(0);
let granted = Caveats::top();
let tool = NamedTool("git.push");
let req = push_request();
let nonce = [5u8; 32];
let discharge = sign_discharge(&test_key(), &req, 0, &nonce, Presence::None);
let attempt = DischargeAttempt {
nonce,
discharge: &discharge,
verifier: &Ed25519Verifier,
};
let err = gate
.authorize_with_discharge(&tool, &granted, &req, &push_policy(), &attempt)
.expect_err("Presence::None cannot satisfy Passkey");
assert!(matches!(err, ToolError::Denied { .. }));
}
#[cfg(feature = "verifier-ed25519")]
#[test]
fn ed25519_verifier_accepts_valid_and_rejects_wrong_challenge() {
let gate = Gate::new(0);
let granted = Caveats::top();
let tool = NamedTool("git.push");
let req = push_request();
let nonce = [21u8; 32];
let ok = sign_discharge(&test_key(), &req, 0, &nonce, Presence::Passkey);
let attempt = DischargeAttempt {
nonce,
discharge: &ok,
verifier: &Ed25519Verifier,
};
let (cx, attestation) = gate
.authorize_with_discharge(&tool, &granted, &req, &push_policy(), &attempt)
.expect("a valid ed25519 discharge authorizes");
assert!(cx.caveats().leq(&granted));
assert!(
attestation.is_some(),
"passkey+record produces an attestation"
);
let bad = sign_discharge(&test_key(), &req, 0, &[99u8; 32], Presence::Passkey);
let bad_attempt = DischargeAttempt {
nonce: [22u8; 32], discharge: &bad,
verifier: &Ed25519Verifier,
};
let err = gate
.authorize_with_discharge(&tool, &granted, &req, &push_policy(), &bad_attempt)
.expect_err("wrong challenge is denied");
assert!(matches!(err, ToolError::Denied { .. }));
let weak_nonce = [23u8; 32];
let weak = sign_discharge(&test_key(), &req, 0, &weak_nonce, Presence::Prompt);
let weak_attempt = DischargeAttempt {
nonce: weak_nonce,
discharge: &weak,
verifier: &Ed25519Verifier,
};
let err = gate
.authorize_with_discharge(&tool, &granted, &req, &push_policy(), &weak_attempt)
.expect_err("Prompt cannot satisfy Passkey");
assert!(matches!(err, ToolError::Denied { .. }));
}
#[cfg(feature = "verifier-ed25519")]
#[test]
fn discharge_is_single_use_replay_is_denied() {
let gate = Gate::with_budget(0, CountBound::AtMost(2));
let granted = Caveats::top();
let tool = NamedTool("git.push");
let req = push_request();
let nonce = [31u8; 32];
let discharge = sign_discharge(&test_key(), &req, 0, &nonce, Presence::Passkey);
let attempt = DischargeAttempt {
nonce,
discharge: &discharge,
verifier: &Ed25519Verifier,
};
gate.authorize_with_discharge(&tool, &granted, &req, &push_policy(), &attempt)
.expect("first discharge authorizes");
let err = gate
.authorize_with_discharge(&tool, &granted, &req, &push_policy(), &attempt)
.expect_err("replay of the same discharge is denied");
assert!(matches!(err, ToolError::Denied { .. }));
let free = NamedTool("free.tool");
match gate.evaluate(
&free,
&granted,
&CallRequest::unspecified("free.tool"),
&StepUpPolicy::EMPTY,
) {
Decision::Allow(_) => {}
other => panic!("expected Allow (budget survived the replay), got {other:?}"),
}
}
#[cfg(feature = "verifier-ed25519")]
#[test]
fn freshness_generations_zero_requires_current_generation() {
let gate = Gate::new(1); let granted = Caveats::top();
let tool = NamedTool("git.push");
let req = push_request();
let nonce = [32u8; 32];
let stale = sign_discharge(&test_key(), &req, 0, &nonce, Presence::Passkey);
let attempt = DischargeAttempt {
nonce,
discharge: &stale,
verifier: &Ed25519Verifier,
};
let err = gate
.authorize_with_discharge(&tool, &granted, &req, &push_policy(), &attempt)
.expect_err("a stale discharge fails freshness_generations: 0");
assert!(matches!(err, ToolError::Denied { .. }));
}
#[cfg(feature = "verifier-ed25519")]
#[test]
fn freshness_generations_window_accepts_recent() {
let gate = Gate::new(1);
let granted = Caveats::top();
let tool = NamedTool("git.push");
let req = push_request();
let policy = StepUpPolicy::new(
vec![Rule {
selector: "git.push:github.com/org/*".to_string(),
requirement: AttestRequirement {
presence: Presence::Passkey,
record: true,
freshness_generations: 1,
},
}],
AttestRequirement::NONE,
);
let nonce = [33u8; 32];
let recent = sign_discharge(&test_key(), &req, 0, &nonce, Presence::Passkey);
let attempt = DischargeAttempt {
nonce,
discharge: &recent,
verifier: &Ed25519Verifier,
};
let (cx, attestation) = gate
.authorize_with_discharge(&tool, &granted, &req, &policy, &attempt)
.expect("a one-generation-old discharge is within the window");
assert!(cx.caveats().leq(&granted));
assert!(attestation.is_some());
}
#[cfg(feature = "verifier-ed25519")]
#[test]
fn discharge_single_use_is_concurrency_safe() {
use std::sync::Arc;
use std::thread;
let gate = Arc::new(Gate::with_budget(0, CountBound::AtMost(2)));
let granted = Caveats::top();
let tool = NamedTool("git.push");
let req = push_request();
let policy = push_policy();
let nonce = [34u8; 32];
let discharge = sign_discharge(&test_key(), &req, 0, &nonce, Presence::Passkey);
let (r1, r2) = thread::scope(|s| {
let h1 = s.spawn(|| {
let attempt = DischargeAttempt {
nonce,
discharge: &discharge,
verifier: &Ed25519Verifier,
};
gate.authorize_with_discharge(&tool, &granted, &req, &policy, &attempt)
.is_ok()
});
let h2 = s.spawn(|| {
let attempt = DischargeAttempt {
nonce,
discharge: &discharge,
verifier: &Ed25519Verifier,
};
gate.authorize_with_discharge(&tool, &granted, &req, &policy, &attempt)
.is_ok()
});
(h1.join().unwrap(), h2.join().unwrap())
});
assert_eq!(
[r1, r2].iter().filter(|ok| **ok).count(),
1,
"exactly one of two concurrent identical discharges succeeds"
);
}
#[test]
fn policy_most_specific_wins_and_default_applies() {
let policy = StepUpPolicy::new(
vec![
Rule {
selector: "fs.delete:/tmp/*".to_string(),
requirement: AttestRequirement::presence(Presence::Prompt),
},
Rule {
selector: "fs.delete:/tmp/important/*".to_string(),
requirement: AttestRequirement::presence(Presence::Passkey),
},
],
AttestRequirement::NONE,
);
let nested = CallRequest::new("fs.delete", serde_json::Value::Null, "/tmp/important/x");
assert_eq!(policy.required_for(&nested).presence, Presence::Passkey);
let broad = CallRequest::new("fs.delete", serde_json::Value::Null, "/tmp/scratch");
assert_eq!(policy.required_for(&broad).presence, Presence::Prompt);
let other = CallRequest::new("email.read", serde_json::Value::Null, "inbox");
assert_eq!(policy.required_for(&other).presence, Presence::None);
}
#[cfg(feature = "verifier-webauthn")]
const UP: u8 = 0x01; #[cfg(feature = "verifier-webauthn")]
const UV: u8 = 0x04;
#[cfg(feature = "verifier-webauthn")]
fn webauthn_discharge(
key: &SigningKey,
challenge: &Challenge,
presence: Presence,
flags: u8,
) -> Discharge {
use sha2::{Digest, Sha256};
let client_data = format!(
r#"{{"type":"webauthn.get","challenge":"{}","origin":"https://example.org"}}"#,
base64url_nopad(challenge.as_bytes())
)
.into_bytes();
let mut auth_data = vec![0u8; 37];
auth_data[32] = flags;
let mut signed = auth_data.clone();
signed.extend_from_slice(&Sha256::digest(&client_data));
let sig = key.sign(&signed);
Discharge {
presence,
credential_id: key.verifying_key().to_bytes().to_vec(),
challenge: *challenge.as_bytes(),
signature: sig.to_bytes().to_vec(),
authenticator_data: Some(auth_data),
client_data_json: Some(client_data),
}
}
#[cfg(feature = "verifier-webauthn")]
fn webauthn_challenge(nonce: u8) -> Challenge {
Challenge::bind(&push_request().content_id(), 0, &[nonce; 32])
}
#[cfg(feature = "verifier-webauthn")]
#[test]
fn webauthn_accepts_valid_passkey_assertion() {
let key = SigningKey::from_bytes(&[9u8; 32]);
let challenge = webauthn_challenge(3);
let d = webauthn_discharge(&key, &challenge, Presence::Passkey, UP | UV);
assert!(WebAuthnVerifier
.verify(
&d,
&AttestRequirement::presence(Presence::Passkey),
&challenge
)
.is_ok());
}
#[cfg(feature = "verifier-webauthn")]
#[test]
fn webauthn_rejects_tampered_challenge() {
let key = SigningKey::from_bytes(&[9u8; 32]);
let d = webauthn_discharge(&key, &webauthn_challenge(3), Presence::Passkey, UP | UV);
let err = WebAuthnVerifier
.verify(
&d,
&AttestRequirement::presence(Presence::Passkey),
&webauthn_challenge(4),
)
.unwrap_err();
assert!(err.contains("challenge"), "{err}");
}
#[cfg(feature = "verifier-webauthn")]
#[test]
fn webauthn_rejects_cleared_up_flag() {
let key = SigningKey::from_bytes(&[9u8; 32]);
let challenge = webauthn_challenge(3);
let d = webauthn_discharge(&key, &challenge, Presence::Passkey, UV);
let err = WebAuthnVerifier
.verify(
&d,
&AttestRequirement::presence(Presence::Passkey),
&challenge,
)
.unwrap_err();
assert!(err.contains("User-Presence"), "{err}");
}
#[cfg(feature = "verifier-webauthn")]
#[test]
fn webauthn_passkey_requires_uv() {
let key = SigningKey::from_bytes(&[9u8; 32]);
let challenge = webauthn_challenge(3);
let d = webauthn_discharge(&key, &challenge, Presence::Passkey, UP);
let err = WebAuthnVerifier
.verify(
&d,
&AttestRequirement::presence(Presence::Passkey),
&challenge,
)
.unwrap_err();
assert!(err.contains("User-Verification"), "{err}");
}
#[cfg(feature = "verifier-webauthn")]
#[test]
fn webauthn_presence_too_weak_fails_closed() {
let key = SigningKey::from_bytes(&[9u8; 32]);
let challenge = webauthn_challenge(3);
let d = webauthn_discharge(&key, &challenge, Presence::Prompt, UP | UV);
let err = WebAuthnVerifier
.verify(
&d,
&AttestRequirement::presence(Presence::Passkey),
&challenge,
)
.unwrap_err();
assert!(err.contains("presence"), "{err}");
}
#[cfg(feature = "verifier-webauthn")]
#[test]
fn webauthn_rejects_forged_signature() {
let key = SigningKey::from_bytes(&[9u8; 32]);
let challenge = webauthn_challenge(3);
let mut d = webauthn_discharge(&key, &challenge, Presence::Passkey, UP | UV);
d.signature[0] ^= 0xff; assert!(WebAuthnVerifier
.verify(
&d,
&AttestRequirement::presence(Presence::Passkey),
&challenge
)
.is_err());
}
}