1use agent_mesh_protocol::Fingerprint;
30use serde::{Deserialize, Serialize};
31
32use crate::{ToolContext, ToolError};
33
34const CHALLENGE_DOMAIN: &[u8] = b"agent-bridle/step-up/v1";
37
38#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default, Serialize, Deserialize)]
48#[serde(rename_all = "snake_case")]
49pub enum Presence {
50 #[default]
52 None,
53 Prompt,
56 Passkey,
59}
60
61#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
66pub struct ContentId([u8; 32]);
67
68impl ContentId {
69 #[must_use]
71 pub fn of_bytes(data: &[u8]) -> Self {
72 Self(Fingerprint::of_bytes(data).0)
73 }
74
75 #[must_use]
77 pub fn as_bytes(&self) -> &[u8; 32] {
78 &self.0
79 }
80
81 #[must_use]
83 pub fn to_hex(&self) -> String {
84 self.0.iter().map(|b| format!("{b:02x}")).collect()
85 }
86}
87
88#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
93pub struct Challenge([u8; 32]);
94
95impl Challenge {
96 #[must_use]
99 pub fn bind(action: &ContentId, generation: u64, nonce: &[u8; 32]) -> Self {
100 let mut buf = Vec::with_capacity(CHALLENGE_DOMAIN.len() + 32 + 8 + 32);
101 buf.extend_from_slice(CHALLENGE_DOMAIN);
102 buf.extend_from_slice(action.as_bytes());
103 buf.extend_from_slice(&generation.to_le_bytes());
104 buf.extend_from_slice(nonce);
105 Self(Fingerprint::of_bytes(&buf).0)
106 }
107
108 #[must_use]
110 pub fn as_bytes(&self) -> &[u8; 32] {
111 &self.0
112 }
113}
114
115#[derive(Debug, Clone)]
122pub struct CallRequest {
123 pub tool: String,
125 pub args: serde_json::Value,
127 pub resource: String,
130}
131
132impl CallRequest {
133 #[must_use]
135 pub fn new(
136 tool: impl Into<String>,
137 args: serde_json::Value,
138 resource: impl Into<String>,
139 ) -> Self {
140 Self {
141 tool: tool.into(),
142 args,
143 resource: resource.into(),
144 }
145 }
146
147 #[must_use]
150 pub fn unspecified(tool: impl Into<String>) -> Self {
151 Self {
152 tool: tool.into(),
153 args: serde_json::Value::Null,
154 resource: String::new(),
155 }
156 }
157
158 #[must_use]
167 pub fn content_id(&self) -> ContentId {
168 let canonical = (
169 self.tool.as_str(),
170 canonical_json(&self.args),
171 self.resource.as_str(),
172 );
173 let bytes = serde_json::to_vec(&canonical)
174 .expect("a (str, Value, str) tuple is always JSON-serializable");
175 ContentId::of_bytes(&bytes)
176 }
177}
178
179fn canonical_json(value: &serde_json::Value) -> serde_json::Value {
182 match value {
183 serde_json::Value::Object(map) => {
184 let mut keys: Vec<&String> = map.keys().collect();
185 keys.sort();
186 let mut sorted = serde_json::Map::new();
187 for k in keys {
188 sorted.insert(k.clone(), canonical_json(&map[k]));
189 }
190 serde_json::Value::Object(sorted)
191 }
192 serde_json::Value::Array(items) => {
193 serde_json::Value::Array(items.iter().map(canonical_json).collect())
194 }
195 other => other.clone(),
196 }
197}
198
199#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
203pub struct AttestRequirement {
204 pub presence: Presence,
206 pub record: bool,
209 pub freshness_generations: u64,
219}
220
221impl AttestRequirement {
222 pub const NONE: Self = Self {
225 presence: Presence::None,
226 record: false,
227 freshness_generations: 0,
228 };
229
230 #[must_use]
233 pub fn presence(presence: Presence) -> Self {
234 Self {
235 presence,
236 record: false,
237 freshness_generations: 0,
238 }
239 }
240
241 #[must_use]
243 pub fn passkey_recorded() -> Self {
244 Self {
245 presence: Presence::Passkey,
246 record: true,
247 freshness_generations: 0,
248 }
249 }
250
251 #[must_use]
253 pub fn demands_gesture(&self) -> bool {
254 self.presence > Presence::None
255 }
256}
257
258#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
263pub struct Discharge {
264 pub presence: Presence,
266 pub credential_id: Vec<u8>,
268 pub challenge: [u8; 32],
270 pub signature: Vec<u8>,
274 #[serde(default, skip_serializing_if = "Option::is_none")]
278 pub authenticator_data: Option<Vec<u8>>,
279 #[serde(default, skip_serializing_if = "Option::is_none")]
283 pub client_data_json: Option<Vec<u8>>,
284}
285
286#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
291pub struct Attestation {
292 pub schema: String,
294 pub tool: String,
296 pub resource: String,
298 pub challenge: [u8; 32],
300 pub generation: u64,
302 pub credential_id: Vec<u8>,
304 pub signature: Vec<u8>,
306 pub presence: Presence,
308}
309
310impl Attestation {
311 pub const SCHEMA: &'static str = "agent-bridle/attestation/v1";
313
314 #[must_use]
318 pub fn from_verified(
319 tool: &str,
320 resource: &str,
321 discharge: &Discharge,
322 generation: u64,
323 ) -> Self {
324 Self {
325 schema: Self::SCHEMA.to_string(),
326 tool: tool.to_string(),
327 resource: resource.to_string(),
328 challenge: discharge.challenge,
329 generation,
330 credential_id: discharge.credential_id.clone(),
331 signature: discharge.signature.clone(),
332 presence: discharge.presence,
333 }
334 }
335
336 #[must_use]
338 pub fn content_id(&self) -> ContentId {
339 let bytes = serde_json::to_vec(self).expect("Attestation is always JSON-serializable");
340 ContentId::of_bytes(&bytes)
341 }
342}
343
344pub trait DischargeVerifier {
348 fn verify(
351 &self,
352 discharge: &Discharge,
353 required: &AttestRequirement,
354 expected: &Challenge,
355 ) -> Result<(), String>;
356}
357
358#[cfg(feature = "verifier-ed25519")]
376#[derive(Debug, Default, Clone, Copy)]
377pub struct Ed25519Verifier;
378
379#[cfg(feature = "verifier-ed25519")]
380impl DischargeVerifier for Ed25519Verifier {
381 fn verify(
382 &self,
383 discharge: &Discharge,
384 required: &AttestRequirement,
385 expected: &Challenge,
386 ) -> Result<(), String> {
387 use ed25519_dalek::{Signature, VerifyingKey};
388 if discharge.presence < required.presence {
391 return Err(format!(
392 "presence {:?} is below required {:?}",
393 discharge.presence, required.presence
394 ));
395 }
396 if &discharge.challenge != expected.as_bytes() {
398 return Err("discharge does not answer this action's challenge".into());
399 }
400 let vk_bytes: [u8; 32] = discharge
401 .credential_id
402 .as_slice()
403 .try_into()
404 .map_err(|_| "credential id is not a 32-byte ed25519 key".to_string())?;
405 let vk = VerifyingKey::from_bytes(&vk_bytes).map_err(|e| e.to_string())?;
406 let sig_bytes: [u8; 64] = discharge
407 .signature
408 .as_slice()
409 .try_into()
410 .map_err(|_| "signature is not 64 bytes".to_string())?;
411 let sig = Signature::from_bytes(&sig_bytes);
412 vk.verify_strict(expected.as_bytes(), &sig)
413 .map_err(|e| e.to_string())
414 }
415}
416
417#[cfg(any(feature = "verifier-webauthn", feature = "verifier-webauthn-es256"))]
422fn base64url_nopad(bytes: &[u8]) -> String {
423 const T: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_";
424 let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4);
425 for chunk in bytes.chunks(3) {
426 let b0 = chunk[0];
427 let b1 = chunk.get(1).copied().unwrap_or(0);
428 let b2 = chunk.get(2).copied().unwrap_or(0);
429 let n = (u32::from(b0) << 16) | (u32::from(b1) << 8) | u32::from(b2);
430 out.push(T[((n >> 18) & 0x3f) as usize] as char);
431 out.push(T[((n >> 12) & 0x3f) as usize] as char);
432 if chunk.len() > 1 {
433 out.push(T[((n >> 6) & 0x3f) as usize] as char);
434 }
435 if chunk.len() > 2 {
436 out.push(T[(n & 0x3f) as usize] as char);
437 }
438 }
439 out
440}
441
442#[cfg(feature = "verifier-webauthn")]
467#[derive(Debug, Default, Clone, Copy)]
468pub struct WebAuthnVerifier;
469
470#[cfg(any(feature = "verifier-webauthn", feature = "verifier-webauthn-es256"))]
478fn webauthn_verify_common(
479 discharge: &Discharge,
480 required: &AttestRequirement,
481 expected: &Challenge,
482) -> Result<Vec<u8>, String> {
483 use sha2::{Digest, Sha256};
484
485 if discharge.presence < required.presence {
488 return Err(format!(
489 "presence {:?} is below required {:?}",
490 discharge.presence, required.presence
491 ));
492 }
493
494 let auth_data = discharge
496 .authenticator_data
497 .as_deref()
498 .ok_or("WebAuthn assertion is missing authenticatorData")?;
499 let client_data = discharge
500 .client_data_json
501 .as_deref()
502 .ok_or("WebAuthn assertion is missing clientDataJSON")?;
503
504 if auth_data.len() < 37 {
506 return Err("authenticatorData is too short (need ≥ 37 bytes)".into());
507 }
508 let flags = auth_data[32];
509 let up = flags & 0x01 != 0; let uv = flags & 0x04 != 0; if !up {
512 return Err("authenticatorData User-Presence (UP) flag is not set".into());
513 }
514 if required.presence >= Presence::Passkey && !uv {
515 return Err(
516 "authenticatorData User-Verification (UV) flag is required for Passkey but not set"
517 .into(),
518 );
519 }
520
521 #[derive(serde::Deserialize)]
523 struct ClientData {
524 #[serde(rename = "type")]
525 typ: String,
526 challenge: String,
527 }
528 let cd: ClientData = serde_json::from_slice(client_data)
529 .map_err(|e| format!("clientDataJSON does not parse: {e}"))?;
530 if cd.typ != "webauthn.get" {
531 return Err(format!(
532 "clientDataJSON.type is {:?}, expected \"webauthn.get\"",
533 cd.typ
534 ));
535 }
536 if cd.challenge != base64url_nopad(expected.as_bytes()) {
539 return Err("assertion does not answer this action's challenge".into());
540 }
541 if &discharge.challenge != expected.as_bytes() {
544 return Err("discharge challenge does not match the gate-recomputed challenge".into());
545 }
546
547 let mut signed = Vec::with_capacity(auth_data.len() + 32);
549 signed.extend_from_slice(auth_data);
550 signed.extend_from_slice(&Sha256::digest(client_data));
551 Ok(signed)
552}
553
554#[cfg(feature = "verifier-webauthn")]
555impl DischargeVerifier for WebAuthnVerifier {
556 fn verify(
557 &self,
558 discharge: &Discharge,
559 required: &AttestRequirement,
560 expected: &Challenge,
561 ) -> Result<(), String> {
562 use ed25519_dalek::{Signature, VerifyingKey};
563 let signed = webauthn_verify_common(discharge, required, expected)?;
565 let vk_bytes: [u8; 32] = discharge
567 .credential_id
568 .as_slice()
569 .try_into()
570 .map_err(|_| "credential id is not a 32-byte ed25519 key".to_string())?;
571 let vk = VerifyingKey::from_bytes(&vk_bytes).map_err(|e| e.to_string())?;
572 let sig_bytes: [u8; 64] = discharge
573 .signature
574 .as_slice()
575 .try_into()
576 .map_err(|_| "signature is not 64 bytes".to_string())?;
577 let sig = Signature::from_bytes(&sig_bytes);
578 vk.verify_strict(&signed, &sig).map_err(|e| e.to_string())
579 }
580}
581
582#[cfg(feature = "verifier-webauthn-es256")]
592#[derive(Debug, Default, Clone, Copy)]
593pub struct WebAuthnEs256Verifier;
594
595#[cfg(feature = "verifier-webauthn-es256")]
596impl DischargeVerifier for WebAuthnEs256Verifier {
597 fn verify(
598 &self,
599 discharge: &Discharge,
600 required: &AttestRequirement,
601 expected: &Challenge,
602 ) -> Result<(), String> {
603 use p256::ecdsa::{signature::Verifier, Signature, VerifyingKey};
604 let signed = webauthn_verify_common(discharge, required, expected)?;
606 let vk = VerifyingKey::from_sec1_bytes(&discharge.credential_id)
609 .map_err(|_| "credential id is not a valid P-256 SEC1 public key".to_string())?;
610 let sig = Signature::from_der(&discharge.signature)
611 .map_err(|_| "signature is not a valid DER ECDSA signature".to_string())?;
612 vk.verify(&signed, &sig).map_err(|e| e.to_string())
613 }
614}
615
616pub trait DischargeProvider {
633 fn obtain(
643 &self,
644 request: &CallRequest,
645 required: &AttestRequirement,
646 generation: u64,
647 nonce: &[u8; 32],
648 ) -> Result<Discharge, String>;
649}
650
651pub struct DischargeAttempt<'a> {
657 pub nonce: [u8; 32],
659 pub discharge: &'a Discharge,
661 pub verifier: &'a dyn DischargeVerifier,
663}
664
665#[derive(Debug)]
669pub enum Decision {
670 Allow(ToolContext),
672 Deny(ToolError),
674 NeedsDischarge(AttestRequirement),
678}
679
680#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
684pub struct Rule {
685 pub selector: String,
688 pub requirement: AttestRequirement,
690}
691
692impl Rule {
693 fn matches(&self, request: &CallRequest) -> bool {
694 let (tool, resource_glob) = match self.selector.split_once(':') {
695 Some((tool, glob)) => (tool, Some(glob)),
696 None => (self.selector.as_str(), None),
697 };
698 if tool != request.tool {
699 return false;
700 }
701 match resource_glob {
702 None => true,
703 Some(glob) => glob_prefix_match(glob, &request.resource),
704 }
705 }
706}
707
708fn glob_prefix_match(pattern: &str, text: &str) -> bool {
710 if let Some(prefix) = pattern.strip_suffix('*') {
711 let prefix = prefix.strip_suffix('*').unwrap_or(prefix);
712 text.starts_with(prefix)
713 } else {
714 pattern == text
715 }
716}
717
718#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
725pub struct StepUpPolicy {
726 pub rules: Vec<Rule>,
728 pub default: AttestRequirement,
730}
731
732impl StepUpPolicy {
733 pub const EMPTY: Self = Self {
736 rules: Vec::new(),
737 default: AttestRequirement::NONE,
738 };
739
740 #[must_use]
742 pub fn new(rules: Vec<Rule>, default: AttestRequirement) -> Self {
743 Self { rules, default }
744 }
745
746 #[must_use]
749 pub fn required_for(&self, request: &CallRequest) -> AttestRequirement {
750 let mut best: Option<&Rule> = None;
751 for rule in &self.rules {
752 if rule.matches(request) && best.is_none_or(|b| rule.selector.len() > b.selector.len())
753 {
754 best = Some(rule);
755 }
756 }
757 best.map_or_else(|| self.default.clone(), |r| r.requirement.clone())
758 }
759}
760
761impl Default for StepUpPolicy {
762 fn default() -> Self {
763 Self::EMPTY
764 }
765}
766
767#[cfg(test)]
768mod tests {
769 use super::*;
770 use crate::{Caveats, CountBound, Gate, Tool, ToolResult};
771 #[cfg(feature = "verifier-ed25519")]
774 use crate::ToolError;
775 #[cfg(any(feature = "verifier-ed25519", feature = "verifier-webauthn"))]
776 use ed25519_dalek::{Signer, SigningKey};
777
778 struct NamedTool(&'static str);
780 #[async_trait::async_trait]
781 impl Tool for NamedTool {
782 fn name(&self) -> &str {
783 self.0
784 }
785 fn schema(&self) -> serde_json::Value {
786 serde_json::json!({})
787 }
788 async fn invoke(
789 &self,
790 _args: serde_json::Value,
791 _cx: &ToolContext,
792 ) -> ToolResult<serde_json::Value> {
793 Ok(serde_json::Value::Null)
794 }
795 }
796
797 #[cfg(feature = "verifier-ed25519")]
803 fn test_key() -> SigningKey {
804 SigningKey::from_bytes(&[7u8; 32])
805 }
806
807 #[cfg(feature = "verifier-ed25519")]
809 fn sign_discharge(
810 key: &SigningKey,
811 request: &CallRequest,
812 generation: u64,
813 nonce: &[u8; 32],
814 presence: Presence,
815 ) -> Discharge {
816 let challenge = Challenge::bind(&request.content_id(), generation, nonce);
817 let sig = key.sign(challenge.as_bytes());
818 Discharge {
819 presence,
820 credential_id: key.verifying_key().to_bytes().to_vec(),
821 challenge: *challenge.as_bytes(),
822 signature: sig.to_bytes().to_vec(),
823 authenticator_data: None,
824 client_data_json: None,
825 }
826 }
827
828 fn push_policy() -> StepUpPolicy {
829 StepUpPolicy::new(
830 vec![Rule {
831 selector: "git.push:github.com/org/*".to_string(),
832 requirement: AttestRequirement::passkey_recorded(),
833 }],
834 AttestRequirement::NONE,
835 )
836 }
837
838 fn push_request() -> CallRequest {
839 CallRequest::new(
840 "git.push",
841 serde_json::json!({"ref": "refs/heads/main"}),
842 "github.com/org/repo",
843 )
844 }
845
846 #[test]
847 fn presence_is_totally_ordered_none_prompt_passkey() {
848 assert!(Presence::None < Presence::Prompt);
849 assert!(Presence::Prompt < Presence::Passkey);
850 }
851
852 #[test]
853 fn content_id_is_deterministic_and_argument_order_independent() {
854 let a = CallRequest::new(
855 "email.send",
856 serde_json::json!({"to": "x", "subj": "y"}),
857 "r",
858 );
859 let b = CallRequest::new(
860 "email.send",
861 serde_json::json!({"subj": "y", "to": "x"}),
862 "r",
863 );
864 assert_eq!(
865 a.content_id(),
866 b.content_id(),
867 "key order must not change identity"
868 );
869 let c = CallRequest::new(
870 "email.send",
871 serde_json::json!({"to": "z", "subj": "y"}),
872 "r",
873 );
874 assert_ne!(a.content_id(), c.content_id(), "different args must differ");
875 }
876
877 #[test]
880 fn needs_discharge_does_not_mint_or_charge() {
881 let gate = Gate::with_budget(0, CountBound::AtMost(1));
882 let granted = Caveats::top();
883 let tool = NamedTool("git.push");
884 match gate.evaluate(&tool, &granted, &push_request(), &push_policy()) {
886 Decision::NeedsDischarge(req) => assert_eq!(req.presence, Presence::Passkey),
887 other => panic!("expected NeedsDischarge, got {other:?}"),
888 }
889 let free = NamedTool("free.tool");
892 match gate.evaluate(
893 &free,
894 &granted,
895 &CallRequest::unspecified("free.tool"),
896 &StepUpPolicy::EMPTY,
897 ) {
898 Decision::Allow(cx) => assert!(cx.caveats().leq(&granted)),
899 other => panic!("expected Allow, got {other:?}"),
900 }
901 }
902
903 #[cfg(feature = "verifier-ed25519")]
906 #[test]
907 fn valid_discharge_mints_and_records() {
908 let gate = Gate::new(0);
909 let granted = Caveats::top();
910 let tool = NamedTool("git.push");
911 let req = push_request();
912 let nonce = [9u8; 32];
913 let discharge = sign_discharge(&test_key(), &req, 0, &nonce, Presence::Passkey);
914 let attempt = DischargeAttempt {
915 nonce,
916 discharge: &discharge,
917 verifier: &Ed25519Verifier,
918 };
919
920 let (cx, attestation) = gate
921 .authorize_with_discharge(&tool, &granted, &req, &push_policy(), &attempt)
922 .expect("valid discharge authorizes");
923 assert!(cx.caveats().leq(&granted));
924 let attestation = attestation.expect("passkey+record must produce an attestation");
925 assert_eq!(attestation.tool, "git.push");
926 assert_eq!(attestation.resource, "github.com/org/repo");
927 assert_eq!(attestation.content_id(), attestation.content_id());
929 }
930
931 #[cfg(feature = "verifier-ed25519")]
935 struct MockProvider {
936 key: SigningKey,
937 presence: Presence,
938 }
939 #[cfg(feature = "verifier-ed25519")]
940 impl DischargeProvider for MockProvider {
941 fn obtain(
942 &self,
943 request: &CallRequest,
944 _required: &AttestRequirement,
945 generation: u64,
946 nonce: &[u8; 32],
947 ) -> Result<Discharge, String> {
948 Ok(sign_discharge(
949 &self.key,
950 request,
951 generation,
952 nonce,
953 self.presence,
954 ))
955 }
956 }
957
958 #[cfg(feature = "verifier-ed25519")]
961 struct FailingProvider;
962 #[cfg(feature = "verifier-ed25519")]
963 impl DischargeProvider for FailingProvider {
964 fn obtain(
965 &self,
966 _request: &CallRequest,
967 _required: &AttestRequirement,
968 _generation: u64,
969 _nonce: &[u8; 32],
970 ) -> Result<Discharge, String> {
971 Err("ceremony failed: human declined".into())
972 }
973 }
974
975 #[cfg(feature = "verifier-ed25519")]
979 #[test]
980 fn provider_obtain_then_authorize_mints_and_records() {
981 let gate = Gate::new(0);
982 let granted = Caveats::top();
983 let tool = NamedTool("git.push");
984 let provider = MockProvider {
985 key: test_key(),
986 presence: Presence::Passkey,
987 };
988 let (cx, attestation) = gate
989 .authorize_step_up(
990 &tool,
991 &granted,
992 &push_request(),
993 &push_policy(),
994 &provider,
995 &Ed25519Verifier,
996 [11u8; 32],
997 )
998 .expect("a valid provider discharge authorizes");
999 assert!(cx.caveats().leq(&granted));
1000 let attestation = attestation.expect("passkey+record must produce an attestation");
1001 assert_eq!(attestation.tool, "git.push");
1002 }
1003
1004 #[cfg(feature = "verifier-ed25519")]
1008 #[test]
1009 fn provider_error_fails_closed() {
1010 let gate = Gate::with_budget(0, CountBound::AtMost(1));
1011 let granted = Caveats::top();
1012 let tool = NamedTool("git.push");
1013 let err = gate
1014 .authorize_step_up(
1015 &tool,
1016 &granted,
1017 &push_request(),
1018 &push_policy(),
1019 &FailingProvider,
1020 &Ed25519Verifier,
1021 [12u8; 32],
1022 )
1023 .expect_err("a failed ceremony is fail-closed");
1024 assert!(matches!(err, ToolError::Denied { .. }));
1025 let free = NamedTool("free.tool");
1027 match gate.evaluate(
1028 &free,
1029 &granted,
1030 &CallRequest::unspecified("free.tool"),
1031 &StepUpPolicy::EMPTY,
1032 ) {
1033 Decision::Allow(cx) => assert!(cx.caveats().leq(&granted)),
1034 other => panic!("expected Allow, got {other:?}"),
1035 }
1036 }
1037
1038 #[cfg(feature = "verifier-ed25519")]
1043 #[test]
1044 fn provider_below_required_presence_is_denied() {
1045 let gate = Gate::new(0);
1046 let granted = Caveats::top();
1047 let tool = NamedTool("git.push");
1048 let provider = MockProvider {
1049 key: test_key(),
1050 presence: Presence::Prompt,
1051 };
1052 let err = gate
1053 .authorize_step_up(
1054 &tool,
1055 &granted,
1056 &push_request(),
1057 &push_policy(),
1058 &provider,
1059 &Ed25519Verifier,
1060 [13u8; 32],
1061 )
1062 .expect_err("a Prompt provider cannot satisfy a Passkey policy");
1063 assert!(matches!(err, ToolError::Denied { .. }));
1064 }
1065
1066 #[cfg(feature = "verifier-ed25519")]
1069 #[test]
1070 fn authorize_step_up_without_gesture_degenerates_to_authorize() {
1071 let gate = Gate::new(0);
1072 let granted = Caveats::top();
1073 let free = NamedTool("free.tool");
1074 let provider = MockProvider {
1075 key: test_key(),
1076 presence: Presence::Passkey,
1077 };
1078 let (cx, attestation) = gate
1079 .authorize_step_up(
1080 &free,
1081 &granted,
1082 &CallRequest::unspecified("free.tool"),
1083 &StepUpPolicy::EMPTY,
1084 &provider,
1085 &Ed25519Verifier,
1086 [14u8; 32],
1087 )
1088 .expect("no gesture owed → ordinary authorize");
1089 assert!(cx.caveats().leq(&granted));
1090 assert!(attestation.is_none(), "no step-up → no attestation");
1091 }
1092
1093 #[cfg(feature = "verifier-ed25519")]
1097 #[test]
1098 fn wrong_challenge_is_denied() {
1099 let gate = Gate::new(0);
1100 let granted = Caveats::top();
1101 let tool = NamedTool("git.push");
1102 let req = push_request();
1103 let discharge = sign_discharge(&test_key(), &req, 0, &[1u8; 32], Presence::Passkey);
1105 let attempt = DischargeAttempt {
1106 nonce: [2u8; 32], discharge: &discharge,
1108 verifier: &Ed25519Verifier,
1109 };
1110 let err = gate
1111 .authorize_with_discharge(&tool, &granted, &req, &push_policy(), &attempt)
1112 .expect_err("mismatched challenge must be denied");
1113 assert!(matches!(err, ToolError::Denied { .. }));
1114 }
1115
1116 #[cfg(feature = "verifier-ed25519")]
1119 #[test]
1120 fn presence_too_weak_fails_closed() {
1121 let gate = Gate::new(0);
1122 let granted = Caveats::top();
1123 let tool = NamedTool("git.push");
1124 let req = push_request();
1125 let nonce = [4u8; 32];
1126 let discharge = sign_discharge(&test_key(), &req, 0, &nonce, Presence::Prompt);
1128 let attempt = DischargeAttempt {
1129 nonce,
1130 discharge: &discharge,
1131 verifier: &Ed25519Verifier,
1132 };
1133 let err = gate
1134 .authorize_with_discharge(&tool, &granted, &req, &push_policy(), &attempt)
1135 .expect_err("Prompt cannot satisfy Passkey");
1136 assert!(matches!(err, ToolError::Denied { .. }));
1137 }
1138
1139 #[cfg(feature = "verifier-ed25519")]
1148 #[test]
1149 fn no_authenticator_presence_none_cannot_satisfy_passkey() {
1150 let gate = Gate::new(0);
1151 let granted = Caveats::top();
1152 let tool = NamedTool("git.push");
1153 let req = push_request();
1154 let nonce = [5u8; 32];
1155 let discharge = sign_discharge(&test_key(), &req, 0, &nonce, Presence::None);
1158 let attempt = DischargeAttempt {
1159 nonce,
1160 discharge: &discharge,
1161 verifier: &Ed25519Verifier,
1162 };
1163 let err = gate
1164 .authorize_with_discharge(&tool, &granted, &req, &push_policy(), &attempt)
1165 .expect_err("Presence::None cannot satisfy Passkey");
1166 assert!(matches!(err, ToolError::Denied { .. }));
1167 }
1168
1169 #[cfg(feature = "verifier-ed25519")]
1175 #[test]
1176 fn ed25519_verifier_accepts_valid_and_rejects_wrong_challenge() {
1177 let gate = Gate::new(0);
1178 let granted = Caveats::top();
1179 let tool = NamedTool("git.push");
1180 let req = push_request();
1181
1182 let nonce = [21u8; 32];
1184 let ok = sign_discharge(&test_key(), &req, 0, &nonce, Presence::Passkey);
1185 let attempt = DischargeAttempt {
1186 nonce,
1187 discharge: &ok,
1188 verifier: &Ed25519Verifier,
1189 };
1190 let (cx, attestation) = gate
1191 .authorize_with_discharge(&tool, &granted, &req, &push_policy(), &attempt)
1192 .expect("a valid ed25519 discharge authorizes");
1193 assert!(cx.caveats().leq(&granted));
1194 assert!(
1195 attestation.is_some(),
1196 "passkey+record produces an attestation"
1197 );
1198
1199 let bad = sign_discharge(&test_key(), &req, 0, &[99u8; 32], Presence::Passkey);
1201 let bad_attempt = DischargeAttempt {
1202 nonce: [22u8; 32], discharge: &bad,
1204 verifier: &Ed25519Verifier,
1205 };
1206 let err = gate
1207 .authorize_with_discharge(&tool, &granted, &req, &push_policy(), &bad_attempt)
1208 .expect_err("wrong challenge is denied");
1209 assert!(matches!(err, ToolError::Denied { .. }));
1210
1211 let weak_nonce = [23u8; 32];
1213 let weak = sign_discharge(&test_key(), &req, 0, &weak_nonce, Presence::Prompt);
1214 let weak_attempt = DischargeAttempt {
1215 nonce: weak_nonce,
1216 discharge: &weak,
1217 verifier: &Ed25519Verifier,
1218 };
1219 let err = gate
1220 .authorize_with_discharge(&tool, &granted, &req, &push_policy(), &weak_attempt)
1221 .expect_err("Prompt cannot satisfy Passkey");
1222 assert!(matches!(err, ToolError::Denied { .. }));
1223 }
1224
1225 #[cfg(feature = "verifier-ed25519")]
1229 #[test]
1230 fn discharge_is_single_use_replay_is_denied() {
1231 let gate = Gate::with_budget(0, CountBound::AtMost(2));
1232 let granted = Caveats::top();
1233 let tool = NamedTool("git.push");
1234 let req = push_request();
1235 let nonce = [31u8; 32];
1236 let discharge = sign_discharge(&test_key(), &req, 0, &nonce, Presence::Passkey);
1237 let attempt = DischargeAttempt {
1238 nonce,
1239 discharge: &discharge,
1240 verifier: &Ed25519Verifier,
1241 };
1242 gate.authorize_with_discharge(&tool, &granted, &req, &push_policy(), &attempt)
1244 .expect("first discharge authorizes");
1245 let err = gate
1247 .authorize_with_discharge(&tool, &granted, &req, &push_policy(), &attempt)
1248 .expect_err("replay of the same discharge is denied");
1249 assert!(matches!(err, ToolError::Denied { .. }));
1250 let free = NamedTool("free.tool");
1252 match gate.evaluate(
1253 &free,
1254 &granted,
1255 &CallRequest::unspecified("free.tool"),
1256 &StepUpPolicy::EMPTY,
1257 ) {
1258 Decision::Allow(_) => {}
1259 other => panic!("expected Allow (budget survived the replay), got {other:?}"),
1260 }
1261 }
1262
1263 #[cfg(feature = "verifier-ed25519")]
1267 #[test]
1268 fn freshness_generations_zero_requires_current_generation() {
1269 let gate = Gate::new(1); let granted = Caveats::top();
1271 let tool = NamedTool("git.push");
1272 let req = push_request();
1273 let nonce = [32u8; 32];
1274 let stale = sign_discharge(&test_key(), &req, 0, &nonce, Presence::Passkey);
1276 let attempt = DischargeAttempt {
1277 nonce,
1278 discharge: &stale,
1279 verifier: &Ed25519Verifier,
1280 };
1281 let err = gate
1282 .authorize_with_discharge(&tool, &granted, &req, &push_policy(), &attempt)
1283 .expect_err("a stale discharge fails freshness_generations: 0");
1284 assert!(matches!(err, ToolError::Denied { .. }));
1285 }
1286
1287 #[cfg(feature = "verifier-ed25519")]
1291 #[test]
1292 fn freshness_generations_window_accepts_recent() {
1293 let gate = Gate::new(1);
1294 let granted = Caveats::top();
1295 let tool = NamedTool("git.push");
1296 let req = push_request();
1297 let policy = StepUpPolicy::new(
1298 vec![Rule {
1299 selector: "git.push:github.com/org/*".to_string(),
1300 requirement: AttestRequirement {
1301 presence: Presence::Passkey,
1302 record: true,
1303 freshness_generations: 1,
1304 },
1305 }],
1306 AttestRequirement::NONE,
1307 );
1308 let nonce = [33u8; 32];
1309 let recent = sign_discharge(&test_key(), &req, 0, &nonce, Presence::Passkey);
1311 let attempt = DischargeAttempt {
1312 nonce,
1313 discharge: &recent,
1314 verifier: &Ed25519Verifier,
1315 };
1316 let (cx, attestation) = gate
1317 .authorize_with_discharge(&tool, &granted, &req, &policy, &attempt)
1318 .expect("a one-generation-old discharge is within the window");
1319 assert!(cx.caveats().leq(&granted));
1320 assert!(attestation.is_some());
1321 }
1322
1323 #[cfg(feature = "verifier-ed25519")]
1326 #[test]
1327 fn discharge_single_use_is_concurrency_safe() {
1328 use std::sync::Arc;
1329 use std::thread;
1330
1331 let gate = Arc::new(Gate::with_budget(0, CountBound::AtMost(2)));
1332 let granted = Caveats::top();
1333 let tool = NamedTool("git.push");
1334 let req = push_request();
1335 let policy = push_policy();
1336 let nonce = [34u8; 32];
1337 let discharge = sign_discharge(&test_key(), &req, 0, &nonce, Presence::Passkey);
1338
1339 let (r1, r2) = thread::scope(|s| {
1340 let h1 = s.spawn(|| {
1341 let attempt = DischargeAttempt {
1342 nonce,
1343 discharge: &discharge,
1344 verifier: &Ed25519Verifier,
1345 };
1346 gate.authorize_with_discharge(&tool, &granted, &req, &policy, &attempt)
1347 .is_ok()
1348 });
1349 let h2 = s.spawn(|| {
1350 let attempt = DischargeAttempt {
1351 nonce,
1352 discharge: &discharge,
1353 verifier: &Ed25519Verifier,
1354 };
1355 gate.authorize_with_discharge(&tool, &granted, &req, &policy, &attempt)
1356 .is_ok()
1357 });
1358 (h1.join().unwrap(), h2.join().unwrap())
1359 });
1360 assert_eq!(
1361 [r1, r2].iter().filter(|ok| **ok).count(),
1362 1,
1363 "exactly one of two concurrent identical discharges succeeds"
1364 );
1365 }
1366
1367 #[test]
1368 fn policy_most_specific_wins_and_default_applies() {
1369 let policy = StepUpPolicy::new(
1370 vec![
1371 Rule {
1372 selector: "fs.delete:/tmp/*".to_string(),
1373 requirement: AttestRequirement::presence(Presence::Prompt),
1374 },
1375 Rule {
1376 selector: "fs.delete:/tmp/important/*".to_string(),
1377 requirement: AttestRequirement::presence(Presence::Passkey),
1378 },
1379 ],
1380 AttestRequirement::NONE,
1381 );
1382 let nested = CallRequest::new("fs.delete", serde_json::Value::Null, "/tmp/important/x");
1384 assert_eq!(policy.required_for(&nested).presence, Presence::Passkey);
1385 let broad = CallRequest::new("fs.delete", serde_json::Value::Null, "/tmp/scratch");
1387 assert_eq!(policy.required_for(&broad).presence, Presence::Prompt);
1388 let other = CallRequest::new("email.read", serde_json::Value::Null, "inbox");
1390 assert_eq!(policy.required_for(&other).presence, Presence::None);
1391 }
1392
1393 #[cfg(feature = "verifier-webauthn")]
1397 const UP: u8 = 0x01; #[cfg(feature = "verifier-webauthn")]
1399 const UV: u8 = 0x04; #[cfg(feature = "verifier-webauthn")]
1407 fn webauthn_discharge(
1408 key: &SigningKey,
1409 challenge: &Challenge,
1410 presence: Presence,
1411 flags: u8,
1412 ) -> Discharge {
1413 use sha2::{Digest, Sha256};
1414 let client_data = format!(
1415 r#"{{"type":"webauthn.get","challenge":"{}","origin":"https://example.org"}}"#,
1416 base64url_nopad(challenge.as_bytes())
1417 )
1418 .into_bytes();
1419 let mut auth_data = vec![0u8; 37];
1421 auth_data[32] = flags;
1422 let mut signed = auth_data.clone();
1423 signed.extend_from_slice(&Sha256::digest(&client_data));
1424 let sig = key.sign(&signed);
1425 Discharge {
1426 presence,
1427 credential_id: key.verifying_key().to_bytes().to_vec(),
1428 challenge: *challenge.as_bytes(),
1429 signature: sig.to_bytes().to_vec(),
1430 authenticator_data: Some(auth_data),
1431 client_data_json: Some(client_data),
1432 }
1433 }
1434
1435 #[cfg(feature = "verifier-webauthn")]
1436 fn webauthn_challenge(nonce: u8) -> Challenge {
1437 Challenge::bind(&push_request().content_id(), 0, &[nonce; 32])
1438 }
1439
1440 #[cfg(feature = "verifier-webauthn")]
1441 #[test]
1442 fn webauthn_accepts_valid_passkey_assertion() {
1443 let key = SigningKey::from_bytes(&[9u8; 32]);
1444 let challenge = webauthn_challenge(3);
1445 let d = webauthn_discharge(&key, &challenge, Presence::Passkey, UP | UV);
1446 assert!(WebAuthnVerifier
1447 .verify(
1448 &d,
1449 &AttestRequirement::presence(Presence::Passkey),
1450 &challenge
1451 )
1452 .is_ok());
1453 }
1454
1455 #[cfg(feature = "verifier-webauthn")]
1456 #[test]
1457 fn webauthn_rejects_tampered_challenge() {
1458 let key = SigningKey::from_bytes(&[9u8; 32]);
1459 let d = webauthn_discharge(&key, &webauthn_challenge(3), Presence::Passkey, UP | UV);
1461 let err = WebAuthnVerifier
1462 .verify(
1463 &d,
1464 &AttestRequirement::presence(Presence::Passkey),
1465 &webauthn_challenge(4),
1466 )
1467 .unwrap_err();
1468 assert!(err.contains("challenge"), "{err}");
1469 }
1470
1471 #[cfg(feature = "verifier-webauthn")]
1472 #[test]
1473 fn webauthn_rejects_cleared_up_flag() {
1474 let key = SigningKey::from_bytes(&[9u8; 32]);
1475 let challenge = webauthn_challenge(3);
1476 let d = webauthn_discharge(&key, &challenge, Presence::Passkey, UV);
1478 let err = WebAuthnVerifier
1479 .verify(
1480 &d,
1481 &AttestRequirement::presence(Presence::Passkey),
1482 &challenge,
1483 )
1484 .unwrap_err();
1485 assert!(err.contains("User-Presence"), "{err}");
1486 }
1487
1488 #[cfg(feature = "verifier-webauthn")]
1489 #[test]
1490 fn webauthn_passkey_requires_uv() {
1491 let key = SigningKey::from_bytes(&[9u8; 32]);
1492 let challenge = webauthn_challenge(3);
1493 let d = webauthn_discharge(&key, &challenge, Presence::Passkey, UP);
1496 let err = WebAuthnVerifier
1497 .verify(
1498 &d,
1499 &AttestRequirement::presence(Presence::Passkey),
1500 &challenge,
1501 )
1502 .unwrap_err();
1503 assert!(err.contains("User-Verification"), "{err}");
1504 }
1505
1506 #[cfg(feature = "verifier-webauthn")]
1509 #[test]
1510 fn webauthn_presence_too_weak_fails_closed() {
1511 let key = SigningKey::from_bytes(&[9u8; 32]);
1512 let challenge = webauthn_challenge(3);
1513 let d = webauthn_discharge(&key, &challenge, Presence::Prompt, UP | UV);
1514 let err = WebAuthnVerifier
1515 .verify(
1516 &d,
1517 &AttestRequirement::presence(Presence::Passkey),
1518 &challenge,
1519 )
1520 .unwrap_err();
1521 assert!(err.contains("presence"), "{err}");
1522 }
1523
1524 #[cfg(feature = "verifier-webauthn")]
1525 #[test]
1526 fn webauthn_rejects_forged_signature() {
1527 let key = SigningKey::from_bytes(&[9u8; 32]);
1528 let challenge = webauthn_challenge(3);
1529 let mut d = webauthn_discharge(&key, &challenge, Presence::Passkey, UP | UV);
1530 d.signature[0] ^= 0xff; assert!(WebAuthnVerifier
1532 .verify(
1533 &d,
1534 &AttestRequirement::presence(Presence::Passkey),
1535 &challenge
1536 )
1537 .is_err());
1538 }
1539
1540 #[cfg(feature = "verifier-webauthn-es256")]
1545 fn es256_key() -> p256::ecdsa::SigningKey {
1546 p256::ecdsa::SigningKey::from_slice(&[0x42u8; 32]).unwrap()
1548 }
1549
1550 #[cfg(feature = "verifier-webauthn-es256")]
1551 fn es256_challenge(nonce: u8) -> Challenge {
1552 Challenge::bind(
1553 &CallRequest::unspecified("es256.test").content_id(),
1554 0,
1555 &[nonce; 32],
1556 )
1557 }
1558
1559 #[cfg(feature = "verifier-webauthn-es256")]
1563 fn webauthn_es256_discharge(
1564 key: &p256::ecdsa::SigningKey,
1565 challenge: &Challenge,
1566 presence: Presence,
1567 flags: u8,
1568 ) -> Discharge {
1569 use p256::ecdsa::{signature::Signer, Signature};
1570 use sha2::{Digest, Sha256};
1571 let client_data = format!(
1572 r#"{{"type":"webauthn.get","challenge":"{}","origin":"https://example.org"}}"#,
1573 base64url_nopad(challenge.as_bytes())
1574 )
1575 .into_bytes();
1576 let mut auth_data = vec![0u8; 37];
1577 auth_data[32] = flags;
1578 let mut signed = auth_data.clone();
1579 signed.extend_from_slice(&Sha256::digest(&client_data));
1580 let sig: Signature = key.sign(&signed);
1581 Discharge {
1582 presence,
1583 credential_id: key
1584 .verifying_key()
1585 .to_encoded_point(false)
1586 .as_bytes()
1587 .to_vec(),
1588 challenge: *challenge.as_bytes(),
1589 signature: sig.to_der().as_bytes().to_vec(),
1590 authenticator_data: Some(auth_data),
1591 client_data_json: Some(client_data),
1592 }
1593 }
1594
1595 #[cfg(feature = "verifier-webauthn-es256")]
1596 #[test]
1597 fn webauthn_es256_accepts_valid_passkey_assertion() {
1598 let key = es256_key();
1599 let challenge = es256_challenge(3);
1600 let d = webauthn_es256_discharge(&key, &challenge, Presence::Passkey, 0x01 | 0x04);
1601 assert!(WebAuthnEs256Verifier
1602 .verify(
1603 &d,
1604 &AttestRequirement::presence(Presence::Passkey),
1605 &challenge
1606 )
1607 .is_ok());
1608 }
1609
1610 #[cfg(feature = "verifier-webauthn-es256")]
1611 #[test]
1612 fn webauthn_es256_rejects_tampered_challenge() {
1613 let key = es256_key();
1614 let d = webauthn_es256_discharge(&key, &es256_challenge(3), Presence::Passkey, 0x05);
1615 let err = WebAuthnEs256Verifier
1616 .verify(
1617 &d,
1618 &AttestRequirement::presence(Presence::Passkey),
1619 &es256_challenge(4),
1620 )
1621 .unwrap_err();
1622 assert!(err.contains("challenge"), "{err}");
1623 }
1624
1625 #[cfg(feature = "verifier-webauthn-es256")]
1626 #[test]
1627 fn webauthn_es256_passkey_requires_uv() {
1628 let key = es256_key();
1629 let challenge = es256_challenge(3);
1630 let d = webauthn_es256_discharge(&key, &challenge, Presence::Passkey, 0x01);
1632 let err = WebAuthnEs256Verifier
1633 .verify(
1634 &d,
1635 &AttestRequirement::presence(Presence::Passkey),
1636 &challenge,
1637 )
1638 .unwrap_err();
1639 assert!(err.contains("User-Verification"), "{err}");
1640 }
1641
1642 #[cfg(feature = "verifier-webauthn-es256")]
1643 #[test]
1644 fn webauthn_es256_rejects_forged_signature() {
1645 let key = es256_key();
1646 let challenge = es256_challenge(3);
1647 let mut d = webauthn_es256_discharge(&key, &challenge, Presence::Passkey, 0x05);
1648 let n = d.signature.len();
1649 d.signature[n - 1] ^= 0xff; assert!(WebAuthnEs256Verifier
1651 .verify(
1652 &d,
1653 &AttestRequirement::presence(Presence::Passkey),
1654 &challenge
1655 )
1656 .is_err());
1657 }
1658
1659 #[cfg(feature = "verifier-webauthn-es256")]
1660 #[test]
1661 fn webauthn_es256_rejects_wrong_key() {
1662 let challenge = es256_challenge(3);
1664 let mut d = webauthn_es256_discharge(&es256_key(), &challenge, Presence::Passkey, 0x05);
1665 let other = p256::ecdsa::SigningKey::from_slice(&[0x43u8; 32]).unwrap();
1666 d.credential_id = other
1667 .verifying_key()
1668 .to_encoded_point(false)
1669 .as_bytes()
1670 .to_vec();
1671 assert!(WebAuthnEs256Verifier
1672 .verify(
1673 &d,
1674 &AttestRequirement::presence(Presence::Passkey),
1675 &challenge
1676 )
1677 .is_err());
1678 }
1679}