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