1use murmur_artifact::{
32 security_warning_link, NetworkAuthentication, RuntimeManifest, DOOR_SCOPES,
33 OPERATOR_CREDENTIAL, W_SEC_032,
34};
35use serde_json::{json, Value};
36
37use crate::formation_credentials::{
38 FormationRefusal, FormationVerifier, FORMATION_CALL_SCOPES, FORMATION_CREDENTIAL_PREFIX,
39 FORMATION_TOKEN_TAG,
40};
41use crate::mac_token::{self, MintKey};
42use crate::resource_plane::ResourceResponse;
43
44pub const DOOR_TOKEN_TAG: &str = "mdt1";
46
47pub const DOOR_TOKEN_DOMAIN: &[u8] = b"murmur-door-token-v1";
50
51pub const DOOR_TOKEN_ENV: &str = "MURMUR_DOOR_TOKEN";
54
55pub const RESOURCES_FILES_SCOPE: &str = "resources/files";
57
58const BEARER_SCHEME: &str = "bearer";
60
61#[derive(Clone, PartialEq, Eq)]
68pub struct DoorToken(String);
69
70impl DoorToken {
71 pub fn new(token: String) -> Self {
72 Self(token)
73 }
74
75 pub fn expose(&self) -> &str {
77 &self.0
78 }
79}
80
81impl std::fmt::Debug for DoorToken {
82 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
83 f.write_str("DoorToken(<redacted>)")
84 }
85}
86
87pub fn bearer_header(token: &DoorToken) -> String {
89 format!("Bearer {}", token.expose())
90}
91
92pub(crate) fn bearer_token(authorization: &str) -> Option<&str> {
96 let (scheme, token) = authorization
97 .trim()
98 .split_once(|c: char| c.is_ascii_whitespace())?;
99 scheme
100 .eq_ignore_ascii_case(BEARER_SCHEME)
101 .then(|| token.trim())
102}
103
104pub struct DoorAuth {
109 key: MintKey,
110 tokens: Vec<(String, DoorToken)>,
112 formation: Option<FormationVerifier>,
113}
114
115impl std::fmt::Debug for DoorAuth {
116 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
117 f.debug_struct("DoorAuth")
118 .field(
119 "credentials",
120 &self
121 .tokens
122 .iter()
123 .map(|(name, _)| name.as_str())
124 .collect::<Vec<_>>(),
125 )
126 .field("formation", &self.formation)
127 .finish_non_exhaustive()
128 }
129}
130
131impl DoorAuth {
132 pub fn mint(authentication: &NetworkAuthentication) -> Result<Self, String> {
137 let key = MintKey::generate()?;
138 let operator_scopes: Vec<String> = DOOR_SCOPES.iter().map(|s| s.to_string()).collect();
139 let mut tokens = vec![(
140 OPERATOR_CREDENTIAL.to_string(),
141 seal(&key, OPERATOR_CREDENTIAL, &operator_scopes),
142 )];
143 for credential in &authentication.credentials {
144 tokens.push((
145 credential.name.clone(),
146 seal(&key, &credential.name, &credential.scopes),
147 ));
148 }
149 Ok(Self {
150 key,
151 tokens,
152 formation: None,
153 })
154 }
155
156 pub fn with_formation(mut self, verifier: FormationVerifier) -> Self {
158 self.formation = Some(verifier);
159 self
160 }
161
162 pub fn operator_token(&self) -> &DoorToken {
164 &self.tokens[0].1
165 }
166
167 pub fn tokens(&self) -> &[(String, DoorToken)] {
169 &self.tokens
170 }
171
172 pub fn verify(&self, authorization_headers: &[&str]) -> Result<DoorGrant, AuthRefusal> {
181 let header = match authorization_headers {
182 [] => return Err(AuthRefusal::Missing),
183 [header] => header,
184 _ => return Err(AuthRefusal::Invalid),
185 };
186 let token = bearer_token(header).ok_or(AuthRefusal::Invalid)?;
187 if token.split('.').next() == Some(FORMATION_TOKEN_TAG) {
188 return self.verify_formation(token);
189 }
190 let payload = mac_token::open(&self.key, DOOR_TOKEN_TAG, DOOR_TOKEN_DOMAIN, token, &[])
191 .map_err(|_| AuthRefusal::Invalid)?;
192 let payload: Value = serde_json::from_slice(&payload).map_err(|_| AuthRefusal::Invalid)?;
193 let credential = payload["credential"]
194 .as_str()
195 .ok_or(AuthRefusal::Invalid)?
196 .to_string();
197 let scopes = payload["scopes"]
198 .as_array()
199 .ok_or(AuthRefusal::Invalid)?
200 .iter()
201 .map(|scope| {
202 scope
203 .as_str()
204 .filter(|scope| DOOR_SCOPES.contains(scope))
205 .map(str::to_string)
206 .ok_or(AuthRefusal::Invalid)
207 })
208 .collect::<Result<Vec<_>, _>>()?;
209 Ok(DoorGrant {
210 credential,
211 scopes,
212 formation_caller: None,
213 })
214 }
215
216 fn verify_formation(&self, token: &str) -> Result<DoorGrant, AuthRefusal> {
217 let verifier = self.formation.as_ref().ok_or(AuthRefusal::Invalid)?;
218 match verifier.verify(token) {
219 Ok(caller) => Ok(DoorGrant {
220 credential: format!("{FORMATION_CREDENTIAL_PREFIX}{}", caller.from()),
221 scopes: FORMATION_CALL_SCOPES
222 .iter()
223 .map(|s| s.to_string())
224 .collect(),
225 formation_caller: Some(caller.from().to_string()),
226 }),
227 Err(FormationRefusal::Invalid) => Err(AuthRefusal::Invalid),
228 Err(FormationRefusal::NotPermitted { caller }) => {
229 Err(AuthRefusal::NotPermitted { caller })
230 }
231 }
232 }
233}
234
235fn seal(key: &MintKey, credential: &str, scopes: &[String]) -> DoorToken {
236 let payload = json!({"credential": credential, "scopes": scopes});
237 DoorToken(mac_token::seal(
238 key,
239 DOOR_TOKEN_TAG,
240 DOOR_TOKEN_DOMAIN,
241 payload.to_string().as_bytes(),
242 &[],
243 ))
244}
245
246#[derive(Debug, Clone, PartialEq, Eq)]
248pub struct DoorGrant {
249 credential: String,
250 scopes: Vec<String>,
251 formation_caller: Option<String>,
253}
254
255impl DoorGrant {
256 pub fn credential(&self) -> &str {
259 &self.credential
260 }
261
262 pub fn formation_caller(&self) -> Option<&str> {
265 self.formation_caller.as_deref()
266 }
267
268 pub fn allows(&self, scope: &str) -> bool {
270 self.scopes.iter().any(|held| held == scope)
271 }
272
273 pub fn require(&self, scope: &str) -> Result<(), AuthRefusal> {
275 if self.allows(scope) {
276 Ok(())
277 } else {
278 Err(AuthRefusal::InsufficientScope {
279 credential: self.credential.clone(),
280 scope: scope.to_string(),
281 })
282 }
283 }
284}
285
286#[derive(Debug, Clone, PartialEq, Eq)]
288pub enum AuthRefusal {
289 Missing,
291 Invalid,
294 InsufficientScope { credential: String, scope: String },
296 NotPermitted { caller: String },
299}
300
301impl AuthRefusal {
302 pub fn status(&self) -> u16 {
303 match self {
304 AuthRefusal::Missing | AuthRefusal::Invalid => 401,
305 AuthRefusal::InsufficientScope { .. } | AuthRefusal::NotPermitted { .. } => 403,
306 }
307 }
308
309 pub fn code(&self) -> &'static str {
311 match self {
312 AuthRefusal::Missing => "unauthenticated",
313 AuthRefusal::Invalid => "invalid_token",
314 AuthRefusal::InsufficientScope { .. } => "insufficient_scope",
315 AuthRefusal::NotPermitted { .. } => "not_permitted",
316 }
317 }
318
319 pub fn challenge(&self, realm: &str) -> String {
321 match self {
322 AuthRefusal::Missing => format!("Bearer realm=\"{realm}\""),
323 AuthRefusal::Invalid => format!("Bearer realm=\"{realm}\", error=\"invalid_token\""),
324 AuthRefusal::InsufficientScope { scope, .. } => {
325 format!("Bearer realm=\"{realm}\", error=\"insufficient_scope\", scope=\"{scope}\"")
326 }
327 AuthRefusal::NotPermitted { .. } => {
328 format!("Bearer realm=\"{realm}\", error=\"insufficient_scope\"")
329 }
330 }
331 }
332
333 pub fn message(&self) -> String {
337 match self {
338 AuthRefusal::Missing => "this door takes a token this capsule's runtime minted, as \
339 Authorization: Bearer <token>"
340 .to_string(),
341 AuthRefusal::Invalid => {
342 "the Authorization header does not present a token this session minted".to_string()
343 }
344 AuthRefusal::InsufficientScope { credential, scope } => {
345 format!("credential '{credential}' does not reach {scope}")
346 }
347 AuthRefusal::NotPermitted { caller } => {
348 format!("member '{caller}' may not call this capsule")
349 }
350 }
351 }
352
353 pub fn response(&self, realm: &str) -> ResourceResponse {
356 let body = json!({"error": self.code(), "message": self.message()});
357 ResourceResponse::framed(
358 self.status(),
359 vec![
360 ("content-type".to_string(), "application/json".to_string()),
361 ("www-authenticate".to_string(), self.challenge(realm)),
362 ],
363 serde_json::to_vec(&body).unwrap_or_default(),
364 )
365 }
366}
367
368pub fn is_loopback_bind(addr: &str) -> bool {
374 let addr = addr.trim();
375 if addr.eq_ignore_ascii_case("localhost") {
376 return true;
377 }
378 let bare = addr
379 .strip_prefix('[')
380 .and_then(|rest| rest.strip_suffix(']'))
381 .unwrap_or(addr);
382 match bare.parse::<std::net::IpAddr>() {
383 Ok(std::net::IpAddr::V4(v4)) => v4.is_loopback(),
384 Ok(std::net::IpAddr::V6(v6)) => v6 == std::net::Ipv6Addr::LOCALHOST,
385 Err(_) => false,
386 }
387}
388
389#[derive(Debug, Clone, Copy)]
391pub enum DoorExposure<'a> {
392 Bound(&'a str),
394 Published(&'a str),
396}
397
398impl DoorExposure<'_> {
399 fn phrase(&self) -> String {
400 match self {
401 DoorExposure::Bound(addr) => format!("bound to {addr}"),
402 DoorExposure::Published(url) => format!("published at {url}"),
403 }
404 }
405}
406
407#[derive(Debug, Clone, PartialEq, Eq)]
410pub struct PublicDoorDisclosure {
411 pub methods: Vec<&'static str>,
412 pub tools: Vec<String>,
413 pub shell: bool,
414 pub network: bool,
415 pub planes: Vec<&'static str>,
416}
417
418impl PublicDoorDisclosure {
419 pub fn of(manifest: &RuntimeManifest) -> Self {
423 let acceptance = manifest
424 .lifecycle
425 .as_ref()
426 .map(|lifecycle| lifecycle.task_acceptance.clone())
427 .unwrap_or_default();
428 let capabilities = manifest.capabilities.as_ref();
429 let exports = manifest.exports.as_ref();
430 let planes = [
431 (exports.is_some_and(|e| e.files.is_some()), "files"),
432 (
433 exports.is_some_and(|e| e.peer_files.is_some()),
434 "peer_files",
435 ),
436 ]
437 .into_iter()
438 .filter_map(|(declared, name)| declared.then_some(name))
439 .collect();
440 Self {
441 methods: crate::identity::served_methods(&acceptance, false),
442 tools: manifest
443 .artifacts
444 .iter()
445 .filter(|artifact| artifact.runtime.is_llm_visible())
446 .map(|artifact| artifact.name.clone())
447 .collect(),
448 shell: capabilities
449 .and_then(|c| c.shell.as_ref())
450 .is_some_and(|shell| !shell.allow.is_empty()),
451 network: capabilities
452 .and_then(|c| c.network.as_ref())
453 .is_some_and(|network| !network.allow.is_empty()),
454 planes,
455 }
456 }
457}
458
459pub fn render_public_door_warning(
461 exposure: DoorExposure<'_>,
462 disclosure: &PublicDoorDisclosure,
463) -> String {
464 format!(
465 "warning[{W_SEC_032}]: the door is {} and network.authentication is not declared — any \
466 caller that reaches it can call {}, and the agent card publishes to any A2A client the \
467 session id, tools [{}], shell: {}, network: {} and planes [{}] ({})",
468 exposure.phrase(),
469 disclosure.methods.join(", "),
470 disclosure.tools.join(", "),
471 disclosure.shell,
472 disclosure.network,
473 disclosure.planes.join(", "),
474 security_warning_link(W_SEC_032),
475 )
476}
477
478pub fn public_door_warning(
481 manifest: &RuntimeManifest,
482 exposure: DoorExposure<'_>,
483) -> Option<String> {
484 let authenticated = manifest
485 .network
486 .as_ref()
487 .is_some_and(|network| network.authentication.is_some());
488 if authenticated || manifest.inference.is_none() {
489 return None;
490 }
491 Some(render_public_door_warning(
492 exposure,
493 &PublicDoorDisclosure::of(manifest),
494 ))
495}
496
497pub fn public_door_bind_warning(manifest: &RuntimeManifest, bind_addr: &str) -> Option<String> {
500 if is_loopback_bind(bind_addr) {
501 return None;
502 }
503 public_door_warning(manifest, DoorExposure::Bound(bind_addr))
504}
505
506pub fn door_posture(manifest: &RuntimeManifest) -> String {
509 match manifest
510 .network
511 .as_ref()
512 .and_then(|network| network.authentication.as_ref())
513 {
514 None => "door: public (no network.authentication)".to_string(),
515 Some(authentication) => {
516 let mut credentials = vec![OPERATOR_CREDENTIAL.to_string()];
517 credentials.extend(authentication.credentials.iter().map(|credential| {
518 format!("{} ({})", credential.name, credential.scopes.join(", "))
519 }));
520 format!("door: bearer — {}", credentials.join(", "))
521 }
522 }
523}
524
525#[cfg(test)]
526mod tests {
527 use super::*;
528 use murmur_artifact::{AuthenticationScheme, DoorCredential};
529
530 fn authentication() -> NetworkAuthentication {
531 NetworkAuthentication {
532 scheme: AuthenticationScheme::Bearer,
533 credentials: vec![
534 DoorCredential {
535 name: "reader".to_string(),
536 scopes: vec!["resources/files".to_string()],
537 },
538 DoorCredential {
539 name: "watcher".to_string(),
540 scopes: vec!["tasks/get".to_string(), "stream/watch".to_string()],
541 },
542 ],
543 }
544 }
545
546 fn header(token: &DoorToken) -> String {
547 bearer_header(token)
548 }
549
550 #[test]
551 fn door_auth_mints_operator_first_then_declared_credentials_by_name() {
552 let auth = DoorAuth::mint(&authentication()).unwrap();
553 let names: Vec<&str> = auth.tokens().iter().map(|(n, _)| n.as_str()).collect();
554 assert_eq!(names, ["operator", "reader", "watcher"]);
555 assert_eq!(auth.operator_token(), &auth.tokens()[0].1);
556 for (_, token) in auth.tokens() {
557 assert!(token.expose().starts_with("mdt1."), "{}", token.expose());
558 }
559 }
560
561 #[test]
562 fn door_auth_token_payload_is_credential_and_scopes() {
563 let auth = DoorAuth::mint(&authentication()).unwrap();
564 let payload = |token: &DoorToken| -> Value {
565 serde_json::from_slice(
566 &mac_token::payload_segment(DOOR_TOKEN_TAG, token.expose()).unwrap(),
567 )
568 .unwrap()
569 };
570 assert_eq!(
571 payload(auth.operator_token()),
572 json!({"credential": "operator", "scopes": DOOR_SCOPES})
573 );
574 assert_eq!(
575 payload(&auth.tokens()[2].1),
576 json!({"credential": "watcher", "scopes": ["tasks/get", "stream/watch"]})
577 );
578 let raw = mac_token::payload_segment(DOOR_TOKEN_TAG, auth.tokens()[1].1.expose()).unwrap();
579 assert_eq!(
580 String::from_utf8(raw).unwrap(),
581 r#"{"credential":"reader","scopes":["resources/files"]}"#
582 );
583 }
584
585 #[test]
586 fn door_auth_tokens_open_under_the_door_family_with_nothing_bound() {
587 let auth = DoorAuth::mint(&authentication()).unwrap();
588 let token = auth.operator_token().expose();
589 assert!(mac_token::open(&auth.key, DOOR_TOKEN_TAG, DOOR_TOKEN_DOMAIN, token, &[]).is_ok());
590 assert!(mac_token::open(
591 &auth.key,
592 DOOR_TOKEN_TAG,
593 b"murmur-control-token-v1",
594 token,
595 &[]
596 )
597 .is_err());
598 }
599
600 #[test]
601 fn door_auth_verify_grants_each_token_its_scopes() {
602 let auth = DoorAuth::mint(&authentication()).unwrap();
603 let operator = auth.verify(&[&header(auth.operator_token())]).unwrap();
604 assert_eq!(operator.credential(), "operator");
605 for scope in DOOR_SCOPES {
606 assert!(operator.allows(scope), "{scope}");
607 }
608 let watcher = auth.verify(&[&header(&auth.tokens()[2].1)]).unwrap();
609 assert_eq!(watcher.credential(), "watcher");
610 assert!(watcher.allows("tasks/get"));
611 assert!(watcher.allows("stream/watch"));
612 assert!(!watcher.allows("message/send"));
613 assert_eq!(
614 watcher.require("message/send"),
615 Err(AuthRefusal::InsufficientScope {
616 credential: "watcher".to_string(),
617 scope: "message/send".to_string(),
618 })
619 );
620 assert_eq!(watcher.require("tasks/get"), Ok(()));
621 }
622
623 #[test]
624 fn door_auth_verify_refusals() {
625 let auth = DoorAuth::mint(&authentication()).unwrap();
626 let other = DoorAuth::mint(&authentication()).unwrap();
627 let token = auth.operator_token().expose().to_string();
628
629 assert_eq!(auth.verify(&[]), Err(AuthRefusal::Missing));
630 let good = format!("Bearer {token}");
631 assert_eq!(auth.verify(&[&good, &good]), Err(AuthRefusal::Invalid));
632 for header in [
633 format!("Basic {token}"),
634 format!("Token {token}"),
635 token.clone(),
636 "Bearer".to_string(),
637 "Bearer ".to_string(),
638 "Bearer garbage".to_string(),
639 format!("Bearer {}", other.operator_token().expose()),
640 format!("Bearer {}", token.to_uppercase()),
641 format!("Bearer {}x", token),
642 ] {
643 assert_eq!(
644 auth.verify(&[&header]),
645 Err(AuthRefusal::Invalid),
646 "{header}"
647 );
648 }
649 for scheme in ["bearer", "BEARER", "BeArEr"] {
651 assert!(
652 auth.verify(&[&format!("{scheme} {token}")]).is_ok(),
653 "{scheme}"
654 );
655 }
656 }
657
658 #[test]
659 fn door_auth_verify_refuses_a_sealed_payload_with_a_scope_outside_the_set() {
660 let auth = DoorAuth::mint(&authentication()).unwrap();
661 let forged = seal(&auth.key, "watcher", &["tasks/list".to_string()]);
662 assert_eq!(auth.verify(&[&header(&forged)]), Err(AuthRefusal::Invalid));
663 let not_json = mac_token::seal(&auth.key, DOOR_TOKEN_TAG, DOOR_TOKEN_DOMAIN, b"[]", &[]);
664 assert_eq!(
665 auth.verify(&[&format!("Bearer {not_json}")]),
666 Err(AuthRefusal::Invalid)
667 );
668 }
669
670 #[test]
671 fn door_auth_debug_prints_no_token() {
672 let auth = DoorAuth::mint(&authentication()).unwrap();
673 let token = auth.operator_token().clone();
674 assert_eq!(format!("{token:?}"), "DoorToken(<redacted>)");
675 let debug = format!("{auth:?}");
676 for (_, token) in auth.tokens() {
677 assert!(!debug.contains(token.expose()), "{debug}");
678 }
679 assert!(debug.contains("watcher"), "{debug}");
680 }
681
682 #[test]
683 fn door_auth_refusal_responses() {
684 let realm = "my-agent";
685 let cases = [
686 (
687 AuthRefusal::Missing,
688 401,
689 "Bearer realm=\"my-agent\"",
690 "unauthenticated",
691 ),
692 (
693 AuthRefusal::Invalid,
694 401,
695 "Bearer realm=\"my-agent\", error=\"invalid_token\"",
696 "invalid_token",
697 ),
698 (
699 AuthRefusal::InsufficientScope {
700 credential: "watcher".to_string(),
701 scope: "message/send".to_string(),
702 },
703 403,
704 "Bearer realm=\"my-agent\", error=\"insufficient_scope\", scope=\"message/send\"",
705 "insufficient_scope",
706 ),
707 ];
708 for (refusal, status, challenge, code) in cases {
709 let response = refusal.response(realm);
710 assert_eq!(response.status, status);
711 assert!(response
712 .headers
713 .contains(&("www-authenticate".to_string(), challenge.to_string())));
714 let body: Value = serde_json::from_slice(&response.body).unwrap();
715 assert_eq!(body["error"], code);
716 assert!(body["message"].is_string());
717 }
718 let body: Value = serde_json::from_slice(
719 &AuthRefusal::InsufficientScope {
720 credential: "watcher".to_string(),
721 scope: "message/send".to_string(),
722 }
723 .response(realm)
724 .body,
725 )
726 .unwrap();
727 assert_eq!(
728 body["message"],
729 "credential 'watcher' does not reach message/send"
730 );
731 }
732
733 fn formation_door(
734 authority: &crate::formation_credentials::FormationAuthority,
735 member: &str,
736 ) -> DoorAuth {
737 DoorAuth::mint(&authentication())
738 .unwrap()
739 .with_formation(authority.verifier_for(member))
740 }
741
742 #[test]
743 fn door_auth_formation_tokens_reach_their_audience_with_three_scopes() {
744 let authority = crate::formation_credentials::FormationAuthority::for_test();
745 let coder = formation_door(&authority, "coder");
746 let token = authority.mint("planner", "coder");
747 let grant = coder
748 .verify(&[&format!("Bearer {}", token.expose())])
749 .unwrap();
750 assert_eq!(grant.credential(), "member:planner");
751 assert_eq!(grant.formation_caller(), Some("planner"));
752 assert_eq!(
753 FORMATION_CALL_SCOPES,
754 ["message/send", "tasks/get", "tasks/cancel"]
755 );
756 for scope in DOOR_SCOPES {
757 assert_eq!(
758 grant.allows(scope),
759 FORMATION_CALL_SCOPES.contains(scope),
760 "{scope}"
761 );
762 }
763 assert_eq!(
764 grant.require("session/stop"),
765 Err(AuthRefusal::InsufficientScope {
766 credential: "member:planner".to_string(),
767 scope: "session/stop".to_string(),
768 })
769 );
770 assert!(!grant.allows("message/stream"));
771 assert_eq!(
772 grant.require("message/stream"),
773 Err(AuthRefusal::InsufficientScope {
774 credential: "member:planner".to_string(),
775 scope: "message/stream".to_string(),
776 })
777 );
778 let operator = coder.verify(&[&header(coder.operator_token())]).unwrap();
779 assert_eq!(operator.formation_caller(), None);
780
781 let reviewer = formation_door(&authority, "reviewer");
782 let refused = reviewer
783 .verify(&[&format!("Bearer {}", token.expose())])
784 .unwrap_err();
785 assert_eq!(
786 refused,
787 AuthRefusal::NotPermitted {
788 caller: "planner".to_string()
789 }
790 );
791 let response = refused.response("reviewer-capsule");
792 assert_eq!(response.status, 403);
793 let body: Value = serde_json::from_slice(&response.body).unwrap();
794 assert_eq!(body["error"], "not_permitted");
795 assert_eq!(
796 body["message"],
797 "member 'planner' may not call this capsule"
798 );
799 assert!(response.headers.contains(&(
800 "www-authenticate".to_string(),
801 "Bearer realm=\"reviewer-capsule\", error=\"insufficient_scope\"".to_string()
802 )));
803 }
804
805 #[test]
808 fn door_auth_formation_forgeries_are_the_ordinary_invalid() {
809 let authority = crate::formation_credentials::FormationAuthority::for_test();
810 let other = crate::formation_credentials::FormationAuthority::for_test();
811 let coder = formation_door(&authority, "coder");
812 let garbage = coder.verify(&["Bearer garbage"]).unwrap_err();
813 assert_eq!(garbage, AuthRefusal::Invalid);
814 let genuine = authority.mint("planner", "coder");
815 for header in [
816 format!("Bearer {}", other.mint("planner", "coder").expose()),
817 format!("Bearer {}x", genuine.expose()),
818 "Bearer mft1.x.y".to_string(),
819 ] {
820 assert_eq!(
821 coder.verify(&[&header]),
822 Err(AuthRefusal::Invalid),
823 "{header}"
824 );
825 }
826 let plain = DoorAuth::mint(&authentication()).unwrap();
827 assert_eq!(
828 plain.verify(&[&format!("Bearer {}", genuine.expose())]),
829 Err(AuthRefusal::Invalid)
830 );
831 assert_eq!(
832 AuthRefusal::Invalid.response("r").body,
833 garbage.response("r").body
834 );
835 let debug = format!("{coder:?}");
836 assert!(!debug.contains("mft1."), "{debug}");
837 assert!(!debug.contains(&authority.verify_key().render()), "{debug}");
838 assert!(debug.contains("coder"), "{debug}");
839 }
840
841 #[test]
844 fn door_auth_scopes_are_the_gated_methods_and_the_resource_plane() {
845 use crate::identity::DoorMethod;
846 let mut from_methods: Vec<&str> = DoorMethod::ALL
847 .into_iter()
848 .filter(|method| *method != DoorMethod::GetAuthenticatedExtendedCard)
849 .map(DoorMethod::wire_name)
850 .collect();
851 from_methods.push(RESOURCES_FILES_SCOPE);
852 for scope in &from_methods {
853 assert!(DOOR_SCOPES.contains(scope), "{scope} is not in DOOR_SCOPES");
854 }
855 for scope in DOOR_SCOPES {
856 assert!(
857 from_methods.contains(scope),
858 "{scope} names no gated method or plane"
859 );
860 }
861 assert_eq!(from_methods.len(), DOOR_SCOPES.len());
862 for method in DoorMethod::ALL {
863 assert_eq!(
864 method.scope(),
865 (method != DoorMethod::GetAuthenticatedExtendedCard).then(|| method.wire_name())
866 );
867 }
868 }
869
870 #[test]
871 fn public_door_warning_is_loopback_bind() {
872 for addr in [
873 "127.0.0.1",
874 "127.1.2.3",
875 "127.255.255.255",
876 "::1",
877 "[::1]",
878 "localhost",
879 "LOCALHOST",
880 ] {
881 assert!(is_loopback_bind(addr), "{addr}");
882 }
883 for addr in [
884 "0.0.0.0",
885 "::",
886 "[::]",
887 "10.0.0.1",
888 "192.168.1.2",
889 "128.0.0.1",
890 "example.com",
891 "localhost.localdomain",
892 "::2",
893 "",
894 ] {
895 assert!(!is_loopback_bind(addr), "{addr}");
896 }
897 }
898
899 fn manifest(extra: &str) -> RuntimeManifest {
900 RuntimeManifest::from_yaml_str(&format!(
901 "name: my-agent\nversion: 0.1.0\nartifacts:\n - name: bash\n version: 1.0.0\n \
902 runtime: tool\n - name: drv\n version: 1.0.0\n runtime: driver\n gateway:\n \
903 endpoint: https://api.example.com\n api_key: k\n\
904 inference:\n transport: http\n model: m\n driver:\n artifact: drv\n\
905 capabilities:\n shell:\n allow: [git]\n network:\n allow: [api.example.com]\n\
906 exports:\n files:\n root: out\n mode: read-only\n{extra}"
907 ))
908 .unwrap()
909 }
910
911 #[test]
912 fn public_door_warning_text() {
913 let line = public_door_bind_warning(&manifest(""), "0.0.0.0").expect("a warning");
914 assert_eq!(
915 line,
916 format!(
917 "warning[W-SEC-032]: the door is bound to 0.0.0.0 and network.authentication is \
918 not declared — any caller that reaches it can call message/send, message/stream, \
919 stream/watch, tasks/get, tasks/cancel, session/stop, and the agent card publishes \
920 to any A2A client the session id, tools [bash], shell: true, network: true and \
921 planes [files] ({})",
922 security_warning_link(W_SEC_032)
923 )
924 );
925 let published = public_door_warning(
926 &manifest("lifecycle:\n task_acceptance: none\n"),
927 DoorExposure::Published("http://203.0.113.9:41873"),
928 )
929 .unwrap();
930 assert!(
931 published.contains("published at http://203.0.113.9:41873"),
932 "{published}"
933 );
934 assert!(
935 published.contains("can call stream/watch, tasks/get, tasks/cancel, session/stop,"),
936 "{published}"
937 );
938 }
939
940 #[test]
941 fn public_door_warning_is_silent_for_loopback_and_authenticated_doors() {
942 assert_eq!(public_door_bind_warning(&manifest(""), "127.0.0.1"), None);
943 assert_eq!(public_door_bind_warning(&manifest(""), "localhost"), None);
944 let authenticated = manifest("network:\n authentication:\n scheme: bearer\n");
945 assert_eq!(public_door_bind_warning(&authenticated, "0.0.0.0"), None);
946 assert_eq!(
947 public_door_warning(&authenticated, DoorExposure::Published("http://h:1")),
948 None
949 );
950 }
951
952 #[test]
953 fn public_door_warning_posture_line() {
954 assert_eq!(
955 door_posture(&manifest("")),
956 "door: public (no network.authentication)"
957 );
958 let authenticated = manifest(
959 "network:\n authentication:\n scheme: bearer\n credentials:\n \
960 watcher: {scopes: [tasks/get, stream/watch]}\n reader: {scopes: [resources/files]}\n",
961 );
962 assert_eq!(
963 door_posture(&authenticated),
964 "door: bearer — operator, reader (resources/files), watcher (tasks/get, stream/watch)"
965 );
966 }
967}