1use boatramp_core::time::now_unix;
13use std::collections::HashMap;
14use std::sync::{Arc, Mutex};
15
16use axum::body::Body;
17use axum::extract::{Request, State};
18use axum::http::{header, HeaderName, StatusCode};
19use axum::middleware::Next;
20use axum::response::{IntoResponse, Response};
21
22use boatramp_core::authz::{self, AuthzPolicy, Right};
23use boatramp_core::cedar::CompiledCedar;
24use boatramp_core::cose::{self, PopClaims, TokenError, TokenPublicKey, POP_MAX_BODY_HASH_BYTES};
25use boatramp_core::kv::KvStore;
26
27const POP_HEADER: HeaderName = HeaderName::from_static("boatramp-pop");
30
31use authz::ROOT_ANCHOR_PREFIX;
32
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub enum ChannelBearer {
37 Valid,
39 HolderBound,
42 Invalid,
44}
45
46#[derive(Clone, Default)]
50pub struct Auth {
51 inner: Option<Arc<AuthInner>>,
52}
53
54struct AuthInner {
55 public: TokenPublicKey,
56 kv: Arc<dyn KvStore>,
57 pop_origin: Option<String>,
60 require_pop: bool,
64 replay: PopReplayCache,
66}
67
68#[derive(Clone, Default)]
76struct PopReplayCache {
77 seen: Arc<Mutex<HashMap<String, u64>>>,
78}
79
80impl PopReplayCache {
81 fn new() -> Self {
82 Self {
83 seen: Arc::new(Mutex::new(HashMap::new())),
84 }
85 }
86
87 fn check_and_insert(&self, jti: &str, now: u64) -> bool {
91 let ttl = cose::POP_WINDOW_SECS + cose::POP_SKEW_SECS;
92 let expiry = now.saturating_add(ttl);
93 let mut seen = self.seen.lock().expect("pop replay cache mutex poisoned");
94 seen.retain(|_, exp| *exp > now);
95 if seen.contains_key(jti) {
96 return false;
97 }
98 seen.insert(jti.to_string(), expiry);
99 true
100 }
101}
102
103impl Auth {
104 pub fn disabled() -> Self {
106 Self::default()
107 }
108
109 pub fn with_key(public: TokenPublicKey, kv: Arc<dyn KvStore>) -> Self {
113 Self {
114 inner: Some(Arc::new(AuthInner {
115 public,
116 kv,
117 pop_origin: None,
118 require_pop: false,
119 replay: PopReplayCache::new(),
120 })),
121 }
122 }
123
124 pub fn with_pop(self, pop_origin: Option<String>, require_pop: bool) -> Self {
130 match self.inner {
131 Some(inner) => Self {
132 inner: Some(Arc::new(AuthInner {
133 public: inner.public.clone(),
134 kv: inner.kv.clone(),
135 pop_origin,
136 require_pop,
137 replay: PopReplayCache::new(),
138 })),
139 },
140 None => Self { inner: None },
141 }
142 }
143
144 pub fn is_disabled(&self) -> bool {
146 self.inner.is_none()
147 }
148
149 pub fn public_key(&self) -> Option<TokenPublicKey> {
152 self.inner.as_ref().map(|i| i.public.clone())
153 }
154
155 pub async fn verify_bearer(&self, bearer: &str) -> bool {
159 let Some(inner) = &self.inner else {
160 return false;
161 };
162 let Ok(verified) = inner.verify_credential_any(bearer, now_unix()).await else {
163 return false;
164 };
165 !inner.is_revoked(&verified.cti).await
166 }
167
168 pub async fn classify_channel_bearer(&self, bearer: &str) -> ChannelBearer {
175 let Some(inner) = &self.inner else {
176 return ChannelBearer::Invalid;
177 };
178 let Ok(verified) = inner.verify_credential_any(bearer, now_unix()).await else {
179 return ChannelBearer::Invalid;
180 };
181 if inner.is_revoked(&verified.cti).await {
182 return ChannelBearer::Invalid;
183 }
184 if verified.leaf_cnf.is_some() {
185 return ChannelBearer::HolderBound;
186 }
187 ChannelBearer::Valid
188 }
189
190 pub async fn verify_join_token(&self, token: &str, now: u64) -> Result<String, TokenError> {
198 let Some(inner) = self.inner.as_ref() else {
199 return Err(TokenError::Invalid("auth disabled".into()));
200 };
201 match cose::verify_join(token, &inner.public, now) {
202 Ok(jti) => Ok(jti),
203 Err(primary_err) => {
204 for anchor in inner.rotation_anchors().await {
205 if let Ok(jti) = cose::verify_join(token, &anchor, now) {
206 return Ok(jti);
207 }
208 }
209 Err(primary_err)
210 }
211 }
212 }
213
214 pub async fn verify_bearer_roles(&self, bearer: &str) -> Option<Vec<authz::GrantedRole>> {
220 let inner = self.inner.as_ref()?;
221 let verified = inner.verify_credential_any(bearer, now_unix()).await.ok()?;
222 if inner.is_revoked(&verified.cti).await {
223 return None;
224 }
225 Some(verified.roles)
226 }
227
228 async fn authorize(
233 &self,
234 bearer: &str,
235 method: &str,
236 path: &str,
237 pop_proof: Option<&str>,
238 body_hash: Option<String>,
239 ) -> Result<(), Reject> {
240 let inner = self
241 .inner
242 .as_ref()
243 .expect("authorize called on disabled auth");
244 let Some(required) = Right::required(method, path) else {
247 return Ok(());
248 };
249 let now = now_unix();
250 let verified = inner
251 .verify_credential_any(bearer, now)
252 .await
253 .map_err(Reject::from_token_err)?;
254 if inner.is_revoked(&verified.cti).await {
255 return Err(Reject::forbidden("token revoked\n"));
256 }
257 match &verified.leaf_cnf {
263 Some(leaf_cnf) => {
264 inner.verify_pop(leaf_cnf, pop_proof, method, path, bearer, body_hash, now)?;
265 }
266 None if inner.require_pop => {
267 return Err(Reject::unauthorized(
268 "proof-of-possession required: present a holder-bound (cnf) token\n",
269 ));
270 }
271 None => {}
272 }
273 if !verified.caveats.allows(&required, now) {
276 return Err(Reject::forbidden(
277 "token not authorized for this resource\n",
278 ));
279 }
280 let (policy, compiled) = inner.policy().await;
281 let roles = policy.normalize_grants(&verified.roles);
285 if compiled.authorize(&roles, &required) {
286 Ok(())
287 } else {
288 Err(Reject::forbidden(
289 "token not authorized for this resource\n",
290 ))
291 }
292 }
293}
294
295impl AuthInner {
296 async fn is_revoked(&self, cti: &str) -> bool {
298 matches!(self.kv.get(&authz::revoked_key(cti)).await, Ok(Some(_)))
299 }
300
301 async fn verify_credential_any(
308 &self,
309 bearer: &str,
310 now: u64,
311 ) -> Result<cose::VerifiedChain, TokenError> {
312 match cose::verify_credential(bearer, &self.public, now) {
313 Ok(v) => Ok(v),
314 Err(primary_err) => {
315 for anchor in self.rotation_anchors().await {
316 if let Ok(v) = cose::verify_credential(bearer, &anchor, now) {
317 return Ok(v);
318 }
319 }
320 Err(primary_err)
321 }
322 }
323 }
324
325 async fn rotation_anchors(&self) -> Vec<TokenPublicKey> {
328 self.kv
329 .list_prefix(ROOT_ANCHOR_PREFIX)
330 .await
331 .unwrap_or_default()
332 .iter()
333 .filter_map(|k| k.strip_prefix(ROOT_ANCHOR_PREFIX))
334 .filter_map(|hex| TokenPublicKey::from_hex(hex).ok())
335 .collect()
336 }
337
338 #[allow(clippy::too_many_arguments)]
344 fn verify_pop(
345 &self,
346 leaf_cnf: &str,
347 proof: Option<&str>,
348 method: &str,
349 path: &str,
350 bearer: &str,
351 body_hash: Option<String>,
352 now: u64,
353 ) -> Result<(), Reject> {
354 let Some(proof) = proof else {
355 return Err(Reject::unauthorized(
356 "missing proof-of-possession (Boatramp-PoP header)\n",
357 ));
358 };
359 let Some(aud) = self.pop_origin.clone() else {
363 tracing::warn!(
364 "a holder-bound (cnf) token was presented but `pop_origin` is not \
365 configured; rejecting — set [serve] pop_origin in boatramp.cfg"
366 );
367 return Err(Reject::unauthorized(
368 "proof-of-possession not configured on this server\n",
369 ));
370 };
371 let holder = TokenPublicKey::from_hex(leaf_cnf)
372 .map_err(|_| Reject::unauthorized("invalid holder key\n"))?;
373 let expected = PopClaims {
374 htm: method.to_string(),
375 htp: cose::canon_pop_path(path),
376 aud,
377 ath: cose::pop_sha256_hex(bearer.as_bytes()),
378 bh: body_hash,
379 };
380 let jti = cose::verify_pop(proof, &holder, now, &expected).map_err(|err| match err {
381 TokenError::Expired => Reject::unauthorized("proof-of-possession expired\n"),
382 _ => Reject::unauthorized("invalid proof-of-possession\n"),
383 })?;
384 if !self.replay.check_and_insert(&jti, now) {
385 return Err(Reject::unauthorized("proof-of-possession replayed\n"));
386 }
387 Ok(())
388 }
389
390 async fn policy(&self) -> (AuthzPolicy, CompiledCedar) {
398 let stored = match self.kv.get(authz::POLICY_KEY).await {
399 Ok(Some(bytes)) => match serde_json::from_slice::<AuthzPolicy>(&bytes) {
400 Ok(p) => Some(p),
401 Err(err) => {
402 tracing::warn!(%err, "authz/policy is malformed; using the default policy");
403 None
404 }
405 },
406 Ok(None) => None,
407 Err(err) => {
408 tracing::warn!(%err, "could not read authz/policy; using the default policy");
409 None
410 }
411 };
412 let policy = stored.unwrap_or_else(AuthzPolicy::default_policy);
413 match CompiledCedar::compile(&policy) {
414 Ok(c) => (policy, c),
415 Err(err) => {
416 tracing::warn!(%err, "authz/policy failed to compile; using the default policy");
417 let default = AuthzPolicy::default_policy();
418 let compiled =
419 CompiledCedar::compile(&default).expect("the default policy always compiles");
420 (default, compiled)
421 }
422 }
423 }
424}
425
426struct Reject {
428 status: StatusCode,
429 body: &'static str,
430}
431
432impl Reject {
433 fn unauthorized(body: &'static str) -> Self {
434 Self {
435 status: StatusCode::UNAUTHORIZED,
436 body,
437 }
438 }
439 fn forbidden(body: &'static str) -> Self {
440 Self {
441 status: StatusCode::FORBIDDEN,
442 body,
443 }
444 }
445 fn from_token_err(err: TokenError) -> Self {
449 match err {
450 TokenError::Expired => Self::unauthorized("token expired\n"),
451 _ => Self::unauthorized("invalid token\n"),
452 }
453 }
454}
455
456pub async fn require_auth(State(auth): State<Auth>, request: Request, next: Next) -> Response {
458 if auth.is_disabled() {
459 return next.run(request).await;
460 }
461 let Some(bearer) = bearer_token(&request) else {
462 return (StatusCode::UNAUTHORIZED, "missing bearer token\n").into_response();
463 };
464 let method = request.method().as_str().to_owned();
465 let path = match request.extensions().get::<crate::OriginalPath>() {
470 Some(original) => original.0.clone(),
471 None => request.uri().path().to_owned(),
472 };
473 let pop_proof = pop_header(&request);
474
475 let is_write = !matches!(method.as_str(), "GET" | "HEAD" | "OPTIONS" | "TRACE");
481 let (request, body_hash) = if is_write {
482 match buffer_body_for_pop(request).await {
483 Ok(pair) => pair,
484 Err(response) => return response,
485 }
486 } else {
487 (request, None)
488 };
489
490 match auth
491 .authorize(&bearer, &method, &path, pop_proof.as_deref(), body_hash)
492 .await
493 {
494 Ok(()) => next.run(request).await,
495 Err(reject) => (reject.status, reject.body).into_response(),
496 }
497}
498
499async fn buffer_body_for_pop(request: Request) -> Result<(Request, Option<String>), Response> {
506 let within_bound = request
507 .headers()
508 .get(header::CONTENT_LENGTH)
509 .and_then(|v| v.to_str().ok())
510 .and_then(|v| v.parse::<usize>().ok())
511 .is_some_and(|len| len <= POP_MAX_BODY_HASH_BYTES);
512 if !within_bound {
513 return Ok((request, None));
514 }
515 let (parts, body) = request.into_parts();
516 let bytes = match axum::body::to_bytes(body, POP_MAX_BODY_HASH_BYTES).await {
517 Ok(bytes) => bytes,
518 Err(_) => {
521 return Err((StatusCode::BAD_REQUEST, "could not read request body\n").into_response())
522 }
523 };
524 let body_hash = if bytes.is_empty() {
525 None
526 } else {
527 Some(cose::pop_sha256_hex(&bytes))
528 };
529 Ok((Request::from_parts(parts, Body::from(bytes)), body_hash))
530}
531
532fn bearer_token(request: &Request) -> Option<String> {
533 let value = request
534 .headers()
535 .get(header::AUTHORIZATION)?
536 .to_str()
537 .ok()?;
538 value.strip_prefix("Bearer ").map(str::to_string)
539}
540
541fn pop_header(request: &Request) -> Option<String> {
543 request
544 .headers()
545 .get(&POP_HEADER)?
546 .to_str()
547 .ok()
548 .map(str::to_string)
549}
550
551#[cfg(test)]
552mod tests {
553 use super::*;
554 use boatramp_core::authz::GrantedRole;
555 use boatramp_core::cose::{Claims, LocalSigner, Signer, TokenAlg};
556 use boatramp_core::kv::MemoryKv;
557
558 const ORIGIN: &str = "https://cp.example.com";
559 const PATH: &str = "/api/sites";
562
563 fn holder() -> LocalSigner {
564 LocalSigner::generate(TokenAlg::Es256)
565 }
566
567 fn admin_claims(now: u64) -> Claims {
568 Claims {
569 roles: vec![GrantedRole::global("admin")],
570 kind: "role".into(),
571 ttl_secs: Some(3600),
572 now_unix: now,
573 }
574 }
575
576 fn auth_with(root: &LocalSigner, require_pop: bool) -> Auth {
579 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
580 Auth::with_key(root.public_key(), kv).with_pop(Some(ORIGIN.to_string()), require_pop)
581 }
582
583 async fn proof(
585 holder: &LocalSigner,
586 token: &str,
587 htm: &str,
588 path: &str,
589 aud: &str,
590 bh: Option<String>,
591 now: u64,
592 ) -> String {
593 cose::mint_pop(
594 &PopClaims {
595 htm: htm.to_string(),
596 htp: cose::canon_pop_path(path),
597 aud: aud.to_string(),
598 ath: cose::pop_sha256_hex(token.as_bytes()),
599 bh,
600 },
601 holder,
602 now,
603 )
604 .await
605 .unwrap()
606 }
607
608 #[tokio::test]
613 async fn rotation_anchor_is_make_before_break() {
614 use boatramp_core::kv::KvStore;
615 let primary = holder();
616 let new_root = holder();
617 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
618 let auth = Auth::with_key(primary.public_key(), kv.clone());
619 let now = now_unix();
620
621 let new_token = cose::mint(&admin_claims(now), &new_root).await.unwrap();
622 assert!(!auth.verify_bearer(&new_token).await);
624
625 let anchor = authz::root_anchor_key(&new_root.public_key().to_hex());
627 kv.put(&anchor, Vec::new()).await.unwrap();
628 assert!(
629 auth.verify_bearer(&new_token).await,
630 "new-root token now verifies"
631 );
632 let primary_token = cose::mint(&admin_claims(now), &primary).await.unwrap();
634 assert!(auth.verify_bearer(&primary_token).await);
635
636 kv.delete(&anchor).await.unwrap();
638 assert!(!auth.verify_bearer(&new_token).await);
639 assert!(
640 auth.verify_bearer(&primary_token).await,
641 "primary still valid"
642 );
643 }
644
645 #[tokio::test]
646 async fn plain_bearer_is_authorized_without_a_proof() {
647 let root = holder();
648 let auth = auth_with(&root, false);
649 let now = now_unix();
650 let token = cose::mint(&admin_claims(now), &root).await.unwrap();
651 assert!(auth
653 .authorize(&token, "GET", PATH, None, None)
654 .await
655 .is_ok());
656 }
657
658 #[tokio::test]
659 async fn cnf_token_requires_a_valid_proof() {
660 let root = holder();
661 let h = holder();
662 let auth = auth_with(&root, false);
663 let now = now_unix();
664 let token = cose::mint_delegatable(&admin_claims(now), &h.public_key(), &root)
665 .await
666 .unwrap();
667
668 let rej = auth
670 .authorize(&token, "GET", PATH, None, None)
671 .await
672 .unwrap_err();
673 assert_eq!(rej.status, StatusCode::UNAUTHORIZED);
674
675 let p = proof(&h, &token, "GET", PATH, ORIGIN, None, now).await;
677 assert!(auth
678 .authorize(&token, "GET", PATH, Some(&p), None)
679 .await
680 .is_ok());
681 }
682
683 #[tokio::test]
684 async fn proof_bound_to_the_wrong_facts_is_rejected() {
685 let root = holder();
686 let h = holder();
687 let auth = auth_with(&root, false);
688 let now = now_unix();
689 let token = cose::mint_delegatable(&admin_claims(now), &h.public_key(), &root)
690 .await
691 .unwrap();
692 let body = cose::pop_sha256_hex(b"the-real-body");
693
694 let wrong_method = proof(&h, &token, "PUT", PATH, ORIGIN, None, now).await;
696 assert!(auth
697 .authorize(&token, "GET", PATH, Some(&wrong_method), None)
698 .await
699 .is_err());
700
701 let wrong_path = proof(&h, &token, "GET", "/api/certs", ORIGIN, None, now).await;
703 assert!(auth
704 .authorize(&token, "GET", PATH, Some(&wrong_path), None)
705 .await
706 .is_err());
707
708 let wrong_aud = proof(
710 &h,
711 &token,
712 "GET",
713 PATH,
714 "https://evil.example.com",
715 None,
716 now,
717 )
718 .await;
719 assert!(auth
720 .authorize(&token, "GET", PATH, Some(&wrong_aud), None)
721 .await
722 .is_err());
723
724 let other = cose::mint_delegatable(&admin_claims(now), &h.public_key(), &root)
726 .await
727 .unwrap();
728 let wrong_ath = proof(&h, &other, "GET", PATH, ORIGIN, None, now).await;
729 assert!(auth
730 .authorize(&token, "GET", PATH, Some(&wrong_ath), None)
731 .await
732 .is_err());
733
734 let bound = proof(&h, &token, "PUT", PATH, ORIGIN, Some(body.clone()), now).await;
736 let tampered = cose::pop_sha256_hex(b"a-different-body");
737 assert!(auth
738 .authorize(&token, "PUT", PATH, Some(&bound), Some(tampered))
739 .await
740 .is_err());
741 assert!(auth
743 .authorize(&token, "PUT", PATH, Some(&bound), Some(body))
744 .await
745 .is_ok());
746 }
747
748 #[tokio::test]
749 async fn require_pop_forbids_a_plain_bearer_fleetwide() {
750 let root = holder();
751 let h = holder();
752 let auth = auth_with(&root, true); let now = now_unix();
754
755 let plain = cose::mint(&admin_claims(now), &root).await.unwrap();
757 let rej = auth
758 .authorize(&plain, "GET", PATH, None, None)
759 .await
760 .unwrap_err();
761 assert_eq!(rej.status, StatusCode::UNAUTHORIZED);
762
763 let token = cose::mint_delegatable(&admin_claims(now), &h.public_key(), &root)
765 .await
766 .unwrap();
767 let p = proof(&h, &token, "GET", PATH, ORIGIN, None, now).await;
768 assert!(auth
769 .authorize(&token, "GET", PATH, Some(&p), None)
770 .await
771 .is_ok());
772 }
773
774 #[tokio::test]
775 async fn aud_comes_from_config_never_a_request_header() {
776 let root = holder();
780 let h = holder();
781 let now = now_unix();
782 let token = cose::mint_delegatable(&admin_claims(now), &h.public_key(), &root)
783 .await
784 .unwrap();
785
786 let auth = auth_with(&root, false); let good = proof(&h, &token, "GET", PATH, ORIGIN, None, now).await;
788 assert!(auth
789 .authorize(&token, "GET", PATH, Some(&good), None)
790 .await
791 .is_ok());
792
793 let unset: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
795 let auth_unset = Auth::with_key(root.public_key(), unset).with_pop(None, false);
796 assert!(auth_unset
797 .authorize(&token, "GET", PATH, Some(&good), None)
798 .await
799 .is_err());
800 }
801
802 #[tokio::test]
803 async fn proof_verifies_against_the_leaf_cnf_not_the_root() {
804 let root = holder();
808 let h1 = holder();
809 let h2 = holder();
810 let now = now_unix();
811 let base = cose::mint_delegatable(&admin_claims(now), &h1.public_key(), &root)
812 .await
813 .unwrap();
814 let chain = cose::attenuate(&base, &h1, &Default::default(), Some(&h2.public_key()), now)
815 .await
816 .unwrap();
817
818 let auth = auth_with(&root, false);
819 let leaf_proof = proof(&h2, &chain, "GET", PATH, ORIGIN, None, now).await;
821 assert!(auth
822 .authorize(&chain, "GET", PATH, Some(&leaf_proof), None)
823 .await
824 .is_ok());
825 let stale_proof = proof(&h1, &chain, "GET", PATH, ORIGIN, None, now).await;
827 assert!(auth
828 .authorize(&chain, "GET", PATH, Some(&stale_proof), None)
829 .await
830 .is_err());
831 }
832
833 #[tokio::test]
834 async fn a_proof_cannot_be_replayed_on_the_same_node() {
835 let root = holder();
836 let h = holder();
837 let auth = auth_with(&root, false);
838 let now = now_unix();
839 let token = cose::mint_delegatable(&admin_claims(now), &h.public_key(), &root)
840 .await
841 .unwrap();
842 let p = proof(&h, &token, "GET", PATH, ORIGIN, None, now).await;
843
844 assert!(auth
846 .authorize(&token, "GET", PATH, Some(&p), None)
847 .await
848 .is_ok());
849 let rej = auth
850 .authorize(&token, "GET", PATH, Some(&p), None)
851 .await
852 .unwrap_err();
853 assert_eq!(rej.status, StatusCode::UNAUTHORIZED);
854 }
855
856 #[tokio::test]
857 async fn buffer_body_hashes_small_writes_and_streams_large_ones() {
858 let payload = b"{\"config\":true}".to_vec();
860 let req = Request::builder()
861 .method("PUT")
862 .uri(PATH)
863 .header(header::CONTENT_LENGTH, payload.len())
864 .body(Body::from(payload.clone()))
865 .unwrap();
866 let (rebuilt, hash) = buffer_body_for_pop(req).await.unwrap();
867 assert_eq!(hash, Some(cose::pop_sha256_hex(&payload)));
868 let echoed = axum::body::to_bytes(rebuilt.into_body(), usize::MAX)
869 .await
870 .unwrap();
871 assert_eq!(echoed.as_ref(), payload.as_slice());
872
873 let big = Request::builder()
875 .method("PUT")
876 .uri(PATH)
877 .header(header::CONTENT_LENGTH, POP_MAX_BODY_HASH_BYTES + 1)
878 .body(Body::empty())
879 .unwrap();
880 let (_, hash) = buffer_body_for_pop(big).await.unwrap();
881 assert_eq!(hash, None);
882
883 let empty = Request::builder()
885 .method("DELETE")
886 .uri(PATH)
887 .header(header::CONTENT_LENGTH, 0)
888 .body(Body::empty())
889 .unwrap();
890 let (_, hash) = buffer_body_for_pop(empty).await.unwrap();
891 assert_eq!(hash, None);
892 }
893}