1use murmur_artifact::{
26 security_warning_link, NetworkAuthentication, RuntimeManifest, DOOR_SCOPES,
27 OPERATOR_CREDENTIAL, W_SEC_032,
28};
29use serde_json::{json, Value};
30
31use crate::mac_token::{self, MintKey};
32use crate::resource_plane::ResourceResponse;
33
34pub const DOOR_TOKEN_TAG: &str = "mdt1";
36
37pub const DOOR_TOKEN_DOMAIN: &[u8] = b"murmur-door-token-v1";
40
41pub const DOOR_TOKEN_ENV: &str = "MURMUR_DOOR_TOKEN";
44
45pub const RESOURCES_FILES_SCOPE: &str = "resources/files";
47
48const BEARER_SCHEME: &str = "bearer";
50
51#[derive(Clone, PartialEq, Eq)]
58pub struct DoorToken(String);
59
60impl DoorToken {
61 pub fn new(token: String) -> Self {
62 Self(token)
63 }
64
65 pub fn expose(&self) -> &str {
67 &self.0
68 }
69}
70
71impl std::fmt::Debug for DoorToken {
72 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
73 f.write_str("DoorToken(<redacted>)")
74 }
75}
76
77pub fn bearer_header(token: &DoorToken) -> String {
79 format!("Bearer {}", token.expose())
80}
81
82pub(crate) fn bearer_token(authorization: &str) -> Option<&str> {
86 let (scheme, token) = authorization
87 .trim()
88 .split_once(|c: char| c.is_ascii_whitespace())?;
89 scheme
90 .eq_ignore_ascii_case(BEARER_SCHEME)
91 .then(|| token.trim())
92}
93
94pub struct DoorAuth {
98 key: MintKey,
99 tokens: Vec<(String, DoorToken)>,
101}
102
103impl std::fmt::Debug for DoorAuth {
104 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
105 f.debug_struct("DoorAuth")
106 .field(
107 "credentials",
108 &self
109 .tokens
110 .iter()
111 .map(|(name, _)| name.as_str())
112 .collect::<Vec<_>>(),
113 )
114 .finish_non_exhaustive()
115 }
116}
117
118impl DoorAuth {
119 pub fn mint(authentication: &NetworkAuthentication) -> Result<Self, String> {
124 let key = MintKey::generate()?;
125 let operator_scopes: Vec<String> = DOOR_SCOPES.iter().map(|s| s.to_string()).collect();
126 let mut tokens = vec![(
127 OPERATOR_CREDENTIAL.to_string(),
128 seal(&key, OPERATOR_CREDENTIAL, &operator_scopes),
129 )];
130 for credential in &authentication.credentials {
131 tokens.push((
132 credential.name.clone(),
133 seal(&key, &credential.name, &credential.scopes),
134 ));
135 }
136 Ok(Self { key, tokens })
137 }
138
139 pub fn operator_token(&self) -> &DoorToken {
141 &self.tokens[0].1
142 }
143
144 pub fn tokens(&self) -> &[(String, DoorToken)] {
146 &self.tokens
147 }
148
149 pub fn verify(&self, authorization_headers: &[&str]) -> Result<DoorGrant, AuthRefusal> {
156 let header = match authorization_headers {
157 [] => return Err(AuthRefusal::Missing),
158 [header] => header,
159 _ => return Err(AuthRefusal::Invalid),
160 };
161 let token = bearer_token(header).ok_or(AuthRefusal::Invalid)?;
162 let payload = mac_token::open(&self.key, DOOR_TOKEN_TAG, DOOR_TOKEN_DOMAIN, token, &[])
163 .map_err(|_| AuthRefusal::Invalid)?;
164 let payload: Value = serde_json::from_slice(&payload).map_err(|_| AuthRefusal::Invalid)?;
165 let credential = payload["credential"]
166 .as_str()
167 .ok_or(AuthRefusal::Invalid)?
168 .to_string();
169 let scopes = payload["scopes"]
170 .as_array()
171 .ok_or(AuthRefusal::Invalid)?
172 .iter()
173 .map(|scope| {
174 scope
175 .as_str()
176 .filter(|scope| DOOR_SCOPES.contains(scope))
177 .map(str::to_string)
178 .ok_or(AuthRefusal::Invalid)
179 })
180 .collect::<Result<Vec<_>, _>>()?;
181 Ok(DoorGrant { credential, scopes })
182 }
183}
184
185fn seal(key: &MintKey, credential: &str, scopes: &[String]) -> DoorToken {
186 let payload = json!({"credential": credential, "scopes": scopes});
187 DoorToken(mac_token::seal(
188 key,
189 DOOR_TOKEN_TAG,
190 DOOR_TOKEN_DOMAIN,
191 payload.to_string().as_bytes(),
192 &[],
193 ))
194}
195
196#[derive(Debug, Clone, PartialEq, Eq)]
198pub struct DoorGrant {
199 credential: String,
200 scopes: Vec<String>,
201}
202
203impl DoorGrant {
204 pub fn credential(&self) -> &str {
206 &self.credential
207 }
208
209 pub fn allows(&self, scope: &str) -> bool {
211 self.scopes.iter().any(|held| held == scope)
212 }
213
214 pub fn require(&self, scope: &str) -> Result<(), AuthRefusal> {
216 if self.allows(scope) {
217 Ok(())
218 } else {
219 Err(AuthRefusal::InsufficientScope {
220 credential: self.credential.clone(),
221 scope: scope.to_string(),
222 })
223 }
224 }
225}
226
227#[derive(Debug, Clone, PartialEq, Eq)]
229pub enum AuthRefusal {
230 Missing,
232 Invalid,
235 InsufficientScope { credential: String, scope: String },
237}
238
239impl AuthRefusal {
240 pub fn status(&self) -> u16 {
241 match self {
242 AuthRefusal::Missing | AuthRefusal::Invalid => 401,
243 AuthRefusal::InsufficientScope { .. } => 403,
244 }
245 }
246
247 pub fn code(&self) -> &'static str {
249 match self {
250 AuthRefusal::Missing => "unauthenticated",
251 AuthRefusal::Invalid => "invalid_token",
252 AuthRefusal::InsufficientScope { .. } => "insufficient_scope",
253 }
254 }
255
256 pub fn challenge(&self, realm: &str) -> String {
258 match self {
259 AuthRefusal::Missing => format!("Bearer realm=\"{realm}\""),
260 AuthRefusal::Invalid => format!("Bearer realm=\"{realm}\", error=\"invalid_token\""),
261 AuthRefusal::InsufficientScope { scope, .. } => {
262 format!("Bearer realm=\"{realm}\", error=\"insufficient_scope\", scope=\"{scope}\"")
263 }
264 }
265 }
266
267 pub fn message(&self) -> String {
270 match self {
271 AuthRefusal::Missing => "this door takes a token this capsule's runtime minted, as \
272 Authorization: Bearer <token>"
273 .to_string(),
274 AuthRefusal::Invalid => {
275 "the Authorization header does not present a token this session minted".to_string()
276 }
277 AuthRefusal::InsufficientScope { credential, scope } => {
278 format!("credential '{credential}' does not reach {scope}")
279 }
280 }
281 }
282
283 pub fn response(&self, realm: &str) -> ResourceResponse {
286 let body = json!({"error": self.code(), "message": self.message()});
287 ResourceResponse::framed(
288 self.status(),
289 vec![
290 ("content-type".to_string(), "application/json".to_string()),
291 ("www-authenticate".to_string(), self.challenge(realm)),
292 ],
293 serde_json::to_vec(&body).unwrap_or_default(),
294 )
295 }
296}
297
298pub fn is_loopback_bind(addr: &str) -> bool {
304 let addr = addr.trim();
305 if addr.eq_ignore_ascii_case("localhost") {
306 return true;
307 }
308 let bare = addr
309 .strip_prefix('[')
310 .and_then(|rest| rest.strip_suffix(']'))
311 .unwrap_or(addr);
312 match bare.parse::<std::net::IpAddr>() {
313 Ok(std::net::IpAddr::V4(v4)) => v4.is_loopback(),
314 Ok(std::net::IpAddr::V6(v6)) => v6 == std::net::Ipv6Addr::LOCALHOST,
315 Err(_) => false,
316 }
317}
318
319#[derive(Debug, Clone, Copy)]
321pub enum DoorExposure<'a> {
322 Bound(&'a str),
324 Published(&'a str),
326}
327
328impl DoorExposure<'_> {
329 fn phrase(&self) -> String {
330 match self {
331 DoorExposure::Bound(addr) => format!("bound to {addr}"),
332 DoorExposure::Published(url) => format!("published at {url}"),
333 }
334 }
335}
336
337#[derive(Debug, Clone, PartialEq, Eq)]
340pub struct PublicDoorDisclosure {
341 pub methods: Vec<&'static str>,
342 pub tools: Vec<String>,
343 pub shell: bool,
344 pub network: bool,
345 pub planes: Vec<&'static str>,
346}
347
348impl PublicDoorDisclosure {
349 pub fn of(manifest: &RuntimeManifest) -> Self {
353 let acceptance = manifest
354 .lifecycle
355 .as_ref()
356 .map(|lifecycle| lifecycle.task_acceptance.clone())
357 .unwrap_or_default();
358 let capabilities = manifest.capabilities.as_ref();
359 let exports = manifest.exports.as_ref();
360 let planes = [
361 (exports.is_some_and(|e| e.files.is_some()), "files"),
362 (
363 exports.is_some_and(|e| e.peer_files.is_some()),
364 "peer_files",
365 ),
366 ]
367 .into_iter()
368 .filter_map(|(declared, name)| declared.then_some(name))
369 .collect();
370 Self {
371 methods: crate::identity::served_methods(&acceptance, false),
372 tools: manifest
373 .artifacts
374 .iter()
375 .filter(|artifact| artifact.runtime.is_llm_visible())
376 .map(|artifact| artifact.name.clone())
377 .collect(),
378 shell: capabilities
379 .and_then(|c| c.shell.as_ref())
380 .is_some_and(|shell| !shell.allow.is_empty()),
381 network: capabilities
382 .and_then(|c| c.network.as_ref())
383 .is_some_and(|network| !network.allow.is_empty()),
384 planes,
385 }
386 }
387}
388
389pub fn render_public_door_warning(
391 exposure: DoorExposure<'_>,
392 disclosure: &PublicDoorDisclosure,
393) -> String {
394 format!(
395 "warning[{W_SEC_032}]: the door is {} and network.authentication is not declared — any \
396 caller that reaches it can call {}, and the agent card publishes to any A2A client the \
397 session id, tools [{}], shell: {}, network: {} and planes [{}] ({})",
398 exposure.phrase(),
399 disclosure.methods.join(", "),
400 disclosure.tools.join(", "),
401 disclosure.shell,
402 disclosure.network,
403 disclosure.planes.join(", "),
404 security_warning_link(W_SEC_032),
405 )
406}
407
408pub fn public_door_warning(
411 manifest: &RuntimeManifest,
412 exposure: DoorExposure<'_>,
413) -> Option<String> {
414 let authenticated = manifest
415 .network
416 .as_ref()
417 .is_some_and(|network| network.authentication.is_some());
418 if authenticated || manifest.inference.is_none() {
419 return None;
420 }
421 Some(render_public_door_warning(
422 exposure,
423 &PublicDoorDisclosure::of(manifest),
424 ))
425}
426
427pub fn public_door_bind_warning(manifest: &RuntimeManifest, bind_addr: &str) -> Option<String> {
430 if is_loopback_bind(bind_addr) {
431 return None;
432 }
433 public_door_warning(manifest, DoorExposure::Bound(bind_addr))
434}
435
436pub fn door_posture(manifest: &RuntimeManifest) -> String {
439 match manifest
440 .network
441 .as_ref()
442 .and_then(|network| network.authentication.as_ref())
443 {
444 None => "door: public (no network.authentication)".to_string(),
445 Some(authentication) => {
446 let mut credentials = vec![OPERATOR_CREDENTIAL.to_string()];
447 credentials.extend(authentication.credentials.iter().map(|credential| {
448 format!("{} ({})", credential.name, credential.scopes.join(", "))
449 }));
450 format!("door: bearer — {}", credentials.join(", "))
451 }
452 }
453}
454
455#[cfg(test)]
456mod tests {
457 use super::*;
458 use murmur_artifact::{AuthenticationScheme, DoorCredential};
459
460 fn authentication() -> NetworkAuthentication {
461 NetworkAuthentication {
462 scheme: AuthenticationScheme::Bearer,
463 credentials: vec![
464 DoorCredential {
465 name: "reader".to_string(),
466 scopes: vec!["resources/files".to_string()],
467 },
468 DoorCredential {
469 name: "watcher".to_string(),
470 scopes: vec!["tasks/get".to_string(), "stream/watch".to_string()],
471 },
472 ],
473 }
474 }
475
476 fn header(token: &DoorToken) -> String {
477 bearer_header(token)
478 }
479
480 #[test]
481 fn door_auth_mints_operator_first_then_declared_credentials_by_name() {
482 let auth = DoorAuth::mint(&authentication()).unwrap();
483 let names: Vec<&str> = auth.tokens().iter().map(|(n, _)| n.as_str()).collect();
484 assert_eq!(names, ["operator", "reader", "watcher"]);
485 assert_eq!(auth.operator_token(), &auth.tokens()[0].1);
486 for (_, token) in auth.tokens() {
487 assert!(token.expose().starts_with("mdt1."), "{}", token.expose());
488 }
489 }
490
491 #[test]
492 fn door_auth_token_payload_is_credential_and_scopes() {
493 let auth = DoorAuth::mint(&authentication()).unwrap();
494 let payload = |token: &DoorToken| -> Value {
495 serde_json::from_slice(
496 &mac_token::payload_segment(DOOR_TOKEN_TAG, token.expose()).unwrap(),
497 )
498 .unwrap()
499 };
500 assert_eq!(
501 payload(auth.operator_token()),
502 json!({"credential": "operator", "scopes": DOOR_SCOPES})
503 );
504 assert_eq!(
505 payload(&auth.tokens()[2].1),
506 json!({"credential": "watcher", "scopes": ["tasks/get", "stream/watch"]})
507 );
508 let raw = mac_token::payload_segment(DOOR_TOKEN_TAG, auth.tokens()[1].1.expose()).unwrap();
509 assert_eq!(
510 String::from_utf8(raw).unwrap(),
511 r#"{"credential":"reader","scopes":["resources/files"]}"#
512 );
513 }
514
515 #[test]
516 fn door_auth_tokens_open_under_the_door_family_with_nothing_bound() {
517 let auth = DoorAuth::mint(&authentication()).unwrap();
518 let token = auth.operator_token().expose();
519 assert!(mac_token::open(&auth.key, DOOR_TOKEN_TAG, DOOR_TOKEN_DOMAIN, token, &[]).is_ok());
520 assert!(mac_token::open(
521 &auth.key,
522 DOOR_TOKEN_TAG,
523 b"murmur-control-token-v1",
524 token,
525 &[]
526 )
527 .is_err());
528 }
529
530 #[test]
531 fn door_auth_verify_grants_each_token_its_scopes() {
532 let auth = DoorAuth::mint(&authentication()).unwrap();
533 let operator = auth.verify(&[&header(auth.operator_token())]).unwrap();
534 assert_eq!(operator.credential(), "operator");
535 for scope in DOOR_SCOPES {
536 assert!(operator.allows(scope), "{scope}");
537 }
538 let watcher = auth.verify(&[&header(&auth.tokens()[2].1)]).unwrap();
539 assert_eq!(watcher.credential(), "watcher");
540 assert!(watcher.allows("tasks/get"));
541 assert!(watcher.allows("stream/watch"));
542 assert!(!watcher.allows("message/send"));
543 assert_eq!(
544 watcher.require("message/send"),
545 Err(AuthRefusal::InsufficientScope {
546 credential: "watcher".to_string(),
547 scope: "message/send".to_string(),
548 })
549 );
550 assert_eq!(watcher.require("tasks/get"), Ok(()));
551 }
552
553 #[test]
554 fn door_auth_verify_refusals() {
555 let auth = DoorAuth::mint(&authentication()).unwrap();
556 let other = DoorAuth::mint(&authentication()).unwrap();
557 let token = auth.operator_token().expose().to_string();
558
559 assert_eq!(auth.verify(&[]), Err(AuthRefusal::Missing));
560 let good = format!("Bearer {token}");
561 assert_eq!(auth.verify(&[&good, &good]), Err(AuthRefusal::Invalid));
562 for header in [
563 format!("Basic {token}"),
564 format!("Token {token}"),
565 token.clone(),
566 "Bearer".to_string(),
567 "Bearer ".to_string(),
568 "Bearer garbage".to_string(),
569 format!("Bearer {}", other.operator_token().expose()),
570 format!("Bearer {}", token.to_uppercase()),
571 format!("Bearer {}x", token),
572 ] {
573 assert_eq!(
574 auth.verify(&[&header]),
575 Err(AuthRefusal::Invalid),
576 "{header}"
577 );
578 }
579 for scheme in ["bearer", "BEARER", "BeArEr"] {
581 assert!(
582 auth.verify(&[&format!("{scheme} {token}")]).is_ok(),
583 "{scheme}"
584 );
585 }
586 }
587
588 #[test]
589 fn door_auth_verify_refuses_a_sealed_payload_with_a_scope_outside_the_set() {
590 let auth = DoorAuth::mint(&authentication()).unwrap();
591 let forged = seal(&auth.key, "watcher", &["tasks/list".to_string()]);
592 assert_eq!(auth.verify(&[&header(&forged)]), Err(AuthRefusal::Invalid));
593 let not_json = mac_token::seal(&auth.key, DOOR_TOKEN_TAG, DOOR_TOKEN_DOMAIN, b"[]", &[]);
594 assert_eq!(
595 auth.verify(&[&format!("Bearer {not_json}")]),
596 Err(AuthRefusal::Invalid)
597 );
598 }
599
600 #[test]
601 fn door_auth_debug_prints_no_token() {
602 let auth = DoorAuth::mint(&authentication()).unwrap();
603 let token = auth.operator_token().clone();
604 assert_eq!(format!("{token:?}"), "DoorToken(<redacted>)");
605 let debug = format!("{auth:?}");
606 for (_, token) in auth.tokens() {
607 assert!(!debug.contains(token.expose()), "{debug}");
608 }
609 assert!(debug.contains("watcher"), "{debug}");
610 }
611
612 #[test]
613 fn door_auth_refusal_responses() {
614 let realm = "my-agent";
615 let cases = [
616 (
617 AuthRefusal::Missing,
618 401,
619 "Bearer realm=\"my-agent\"",
620 "unauthenticated",
621 ),
622 (
623 AuthRefusal::Invalid,
624 401,
625 "Bearer realm=\"my-agent\", error=\"invalid_token\"",
626 "invalid_token",
627 ),
628 (
629 AuthRefusal::InsufficientScope {
630 credential: "watcher".to_string(),
631 scope: "message/send".to_string(),
632 },
633 403,
634 "Bearer realm=\"my-agent\", error=\"insufficient_scope\", scope=\"message/send\"",
635 "insufficient_scope",
636 ),
637 ];
638 for (refusal, status, challenge, code) in cases {
639 let response = refusal.response(realm);
640 assert_eq!(response.status, status);
641 assert!(response
642 .headers
643 .contains(&("www-authenticate".to_string(), challenge.to_string())));
644 let body: Value = serde_json::from_slice(&response.body).unwrap();
645 assert_eq!(body["error"], code);
646 assert!(body["message"].is_string());
647 }
648 let body: Value = serde_json::from_slice(
649 &AuthRefusal::InsufficientScope {
650 credential: "watcher".to_string(),
651 scope: "message/send".to_string(),
652 }
653 .response(realm)
654 .body,
655 )
656 .unwrap();
657 assert_eq!(
658 body["message"],
659 "credential 'watcher' does not reach message/send"
660 );
661 }
662
663 #[test]
666 fn door_auth_scopes_are_the_gated_methods_and_the_resource_plane() {
667 use crate::identity::DoorMethod;
668 let mut from_methods: Vec<&str> = DoorMethod::ALL
669 .into_iter()
670 .filter(|method| *method != DoorMethod::GetAuthenticatedExtendedCard)
671 .map(DoorMethod::wire_name)
672 .collect();
673 from_methods.push(RESOURCES_FILES_SCOPE);
674 for scope in &from_methods {
675 assert!(DOOR_SCOPES.contains(scope), "{scope} is not in DOOR_SCOPES");
676 }
677 for scope in DOOR_SCOPES {
678 assert!(
679 from_methods.contains(scope),
680 "{scope} names no gated method or plane"
681 );
682 }
683 assert_eq!(from_methods.len(), DOOR_SCOPES.len());
684 for method in DoorMethod::ALL {
685 assert_eq!(
686 method.scope(),
687 (method != DoorMethod::GetAuthenticatedExtendedCard).then(|| method.wire_name())
688 );
689 }
690 }
691
692 #[test]
693 fn public_door_warning_is_loopback_bind() {
694 for addr in [
695 "127.0.0.1",
696 "127.1.2.3",
697 "127.255.255.255",
698 "::1",
699 "[::1]",
700 "localhost",
701 "LOCALHOST",
702 ] {
703 assert!(is_loopback_bind(addr), "{addr}");
704 }
705 for addr in [
706 "0.0.0.0",
707 "::",
708 "[::]",
709 "10.0.0.1",
710 "192.168.1.2",
711 "128.0.0.1",
712 "example.com",
713 "localhost.localdomain",
714 "::2",
715 "",
716 ] {
717 assert!(!is_loopback_bind(addr), "{addr}");
718 }
719 }
720
721 fn manifest(extra: &str) -> RuntimeManifest {
722 RuntimeManifest::from_yaml_str(&format!(
723 "name: my-agent\nversion: 0.1.0\nartifacts:\n - name: bash\n version: 1.0.0\n \
724 runtime: tool\n - name: drv\n version: 1.0.0\n runtime: driver\n gateway:\n \
725 endpoint: https://api.example.com\n api_key: k\n\
726 inference:\n transport: http\n model: m\n driver:\n artifact: drv\n\
727 capabilities:\n shell:\n allow: [git]\n network:\n allow: [api.example.com]\n\
728 exports:\n files:\n root: out\n mode: read-only\n{extra}"
729 ))
730 .unwrap()
731 }
732
733 #[test]
734 fn public_door_warning_text() {
735 let line = public_door_bind_warning(&manifest(""), "0.0.0.0").expect("a warning");
736 assert_eq!(
737 line,
738 format!(
739 "warning[W-SEC-032]: the door is bound to 0.0.0.0 and network.authentication is \
740 not declared — any caller that reaches it can call message/send, message/stream, \
741 stream/watch, tasks/get, tasks/cancel, session/stop, and the agent card publishes \
742 to any A2A client the session id, tools [bash], shell: true, network: true and \
743 planes [files] ({})",
744 security_warning_link(W_SEC_032)
745 )
746 );
747 let published = public_door_warning(
748 &manifest("lifecycle:\n task_acceptance: none\n"),
749 DoorExposure::Published("http://203.0.113.9:41873"),
750 )
751 .unwrap();
752 assert!(
753 published.contains("published at http://203.0.113.9:41873"),
754 "{published}"
755 );
756 assert!(
757 published.contains("can call stream/watch, tasks/get, tasks/cancel, session/stop,"),
758 "{published}"
759 );
760 }
761
762 #[test]
763 fn public_door_warning_is_silent_for_loopback_and_authenticated_doors() {
764 assert_eq!(public_door_bind_warning(&manifest(""), "127.0.0.1"), None);
765 assert_eq!(public_door_bind_warning(&manifest(""), "localhost"), None);
766 let authenticated = manifest("network:\n authentication:\n scheme: bearer\n");
767 assert_eq!(public_door_bind_warning(&authenticated, "0.0.0.0"), None);
768 assert_eq!(
769 public_door_warning(&authenticated, DoorExposure::Published("http://h:1")),
770 None
771 );
772 }
773
774 #[test]
775 fn public_door_warning_posture_line() {
776 assert_eq!(
777 door_posture(&manifest("")),
778 "door: public (no network.authentication)"
779 );
780 let authenticated = manifest(
781 "network:\n authentication:\n scheme: bearer\n credentials:\n \
782 watcher: {scopes: [tasks/get, stream/watch]}\n reader: {scopes: [resources/files]}\n",
783 );
784 assert_eq!(
785 door_posture(&authenticated),
786 "door: bearer — operator, reader (resources/files), watcher (tasks/get, stream/watch)"
787 );
788 }
789}