1use std::collections::VecDeque;
19use std::fmt::{self, Debug};
20use std::sync::{Arc, Mutex as SyncMutex, Weak};
21use std::time::Duration;
22
23use aes_gcm::aead::{Aead, Generate, KeyInit, array::Array};
24use aes_gcm::{Aes128Gcm, Aes256Gcm};
25use asyncband::mutex::Mutex;
26use base64::Engine;
27use base64::engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD};
28use form_urlencoded::Serializer;
29use http::header::{ACCEPT, CONTENT_TYPE};
30use prost::Message;
31use reqsign_core::hash::hex_sha256;
32use reqsign_core::time::Timestamp;
33use reqsign_core::{Context, Error, GrantCredential, Result, SigningCredential};
34use serde::Deserialize;
35use zeroize::{Zeroize, Zeroizing};
36
37use super::CredentialAccessBoundaryGrant;
38use super::sts::{
39 ACCESS_TOKEN_TYPE, MAX_ACCESS_TOKEN_LIFETIME, STS_ENDPOINT, TOKEN_EXCHANGE_GRANT_TYPE,
40 checked_expiration, sts_error,
41};
42use crate::constants::TOKEN_OPERATION_HEADROOM;
43use crate::{Credential, Token};
44
45const ACCESS_BOUNDARY_INTERMEDIARY_TOKEN_TYPE: &str =
46 "urn:ietf:params:oauth:token-type:access_boundary_intermediary_token";
47const TOKEN_EXCHANGE_HEADROOM: Duration = Duration::from_secs(10);
48const MAX_MINIMUM_TOKEN_LIFETIME: Duration = Duration::from_secs(12 * 60 * 60 - 1);
49const DEFAULT_MINIMUM_TOKEN_LIFETIME: Duration = Duration::from_secs(30 * 60);
50const INTERMEDIARY_CACHE_CAPACITY: usize = 64;
51const MAX_SESSION_KEY_ENCODED_BYTES: usize = 64 * 1024;
52const MAX_SESSION_KEYSET_KEYS: usize = 32;
53const AES_GCM_KEY_TYPE_URL: &str = "type.googleapis.com/google.crypto.tink.AesGcmKey";
54const TINK_KEY_STATUS_ENABLED: i32 = 1;
55const TINK_KEY_MATERIAL_SYMMETRIC: i32 = 1;
56const TINK_PREFIX: i32 = 1;
57const LEGACY_PREFIX: i32 = 2;
58const RAW_PREFIX: i32 = 3;
59const CRUNCHY_PREFIX: i32 = 4;
60const AES_GCM_KEY_VERSION: u32 = 0;
61const AES_GCM_NONCE_BYTES: usize = 12;
62
63#[derive(Deserialize)]
64struct StsTokenResponse {
65 access_token: String,
66 issued_token_type: String,
67 token_type: String,
68 #[serde(default)]
69 expires_in: Option<u64>,
70 #[serde(default)]
71 access_boundary_session_key: Option<String>,
72}
73
74#[derive(Clone, PartialEq, Eq)]
75struct IntermediaryCacheKey {
76 endpoint: &'static str,
77 source_authority: String,
78 source_expires_at: Timestamp,
79}
80
81struct IntermediaryCredentials {
82 access_token: Zeroizing<String>,
83 expires_at: Timestamp,
84 aead_key: TinkAesGcmKey,
85}
86
87impl Debug for IntermediaryCredentials {
88 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
89 f.debug_struct("IntermediaryCredentials")
90 .field("access_token", &"REDACTED")
91 .field("expires_at", &self.expires_at)
92 .field("aead_key", &"REDACTED")
93 .finish()
94 }
95}
96
97impl IntermediaryCredentials {
98 fn covers(&self, now: Timestamp, minimum_lifetime: Duration) -> Result<bool> {
99 let required_until = checked_expiration(now, minimum_lifetime)?;
100 Ok(self.expires_at > required_until)
101 }
102}
103
104#[derive(Default)]
105struct IntermediaryCache {
106 entries: VecDeque<(IntermediaryCacheKey, Arc<IntermediaryCredentials>)>,
107}
108
109impl IntermediaryCache {
110 fn find(
111 &mut self,
112 key: &IntermediaryCacheKey,
113 now: Timestamp,
114 minimum_lifetime: Duration,
115 ) -> Result<Option<Arc<IntermediaryCredentials>>> {
116 self.entries
117 .retain(|(_, credentials)| credentials.expires_at > now);
118 let mut index = None;
119 for (candidate_index, (candidate, credentials)) in self.entries.iter().enumerate() {
120 if candidate == key && credentials.covers(now, minimum_lifetime)? {
121 index = Some(candidate_index);
122 break;
123 }
124 }
125 let Some(index) = index else {
126 return Ok(None);
127 };
128
129 let entry = self.entries.remove(index).ok_or_else(|| {
130 Error::unexpected("client-side CAB intermediary cache is inconsistent")
131 })?;
132 let credentials = entry.1.clone();
133 self.entries.push_back(entry);
134 Ok(Some(credentials))
135 }
136
137 fn insert(
138 &mut self,
139 key: IntermediaryCacheKey,
140 credentials: Arc<IntermediaryCredentials>,
141 now: Timestamp,
142 ) {
143 self.entries
144 .retain(|(candidate, cached)| candidate != &key && cached.expires_at > now);
145 while self.entries.len() >= INTERMEDIARY_CACHE_CAPACITY {
146 self.entries.pop_front();
147 }
148 self.entries.push_back((key, credentials));
149 }
150
151 #[cfg(test)]
152 fn len(&self) -> usize {
153 self.entries.len()
154 }
155}
156
157#[derive(Default)]
158struct IntermediaryState {
159 cache: Mutex<IntermediaryCache>,
160 refresh_locks: RefreshLockRegistry,
161}
162
163impl IntermediaryState {
164 fn refresh_lock(&self, key: &IntermediaryCacheKey) -> RefreshLockLease {
165 let mut refresh_locks = self
166 .refresh_locks
167 .lock()
168 .unwrap_or_else(std::sync::PoisonError::into_inner);
169 if let Some(lock) = refresh_locks
170 .iter()
171 .find_map(|(candidate, lock)| (candidate == key).then(|| lock.upgrade()).flatten())
172 {
173 return RefreshLockLease {
174 key: key.clone(),
175 lock,
176 registry: self.refresh_locks.clone(),
177 };
178 }
179
180 let lock = Arc::new(Mutex::new(()));
181 refresh_locks.push((key.clone(), Arc::downgrade(&lock)));
182 RefreshLockLease {
183 key: key.clone(),
184 lock,
185 registry: self.refresh_locks.clone(),
186 }
187 }
188
189 #[cfg(test)]
190 fn refresh_lock_len(&self) -> usize {
191 self.refresh_locks
192 .lock()
193 .unwrap_or_else(std::sync::PoisonError::into_inner)
194 .len()
195 }
196}
197
198struct RefreshLockLease {
199 key: IntermediaryCacheKey,
200 lock: Arc<Mutex<()>>,
201 registry: RefreshLockRegistry,
202}
203
204type RefreshLockRegistry = Arc<SyncMutex<Vec<(IntermediaryCacheKey, Weak<Mutex<()>>)>>>;
205
206impl Drop for RefreshLockLease {
207 fn drop(&mut self) {
208 let mut registry = self
209 .registry
210 .lock()
211 .unwrap_or_else(std::sync::PoisonError::into_inner);
212 if Arc::strong_count(&self.lock) != 1 {
213 return;
214 }
215 registry.retain(|(candidate, weak)| {
216 candidate != &self.key
217 || weak
218 .upgrade()
219 .is_none_or(|lock| !Arc::ptr_eq(&lock, &self.lock))
220 });
221 }
222}
223
224#[derive(Clone)]
294pub struct ClientSideCredentialAccessBoundaryGranter {
295 grant: CredentialAccessBoundaryGrant,
296 minimum_token_lifetime: Duration,
297 intermediary_state: Arc<IntermediaryState>,
298 #[cfg(test)]
299 now: Option<Timestamp>,
300 #[cfg(test)]
301 time_after_request: Option<Timestamp>,
302 #[cfg(test)]
303 time_after_generation: Option<Timestamp>,
304 #[cfg(test)]
305 nonces: Option<Arc<std::sync::Mutex<VecDeque<[u8; AES_GCM_NONCE_BYTES]>>>>,
306}
307
308impl Debug for ClientSideCredentialAccessBoundaryGranter {
309 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
310 f.debug_struct("ClientSideCredentialAccessBoundaryGranter")
311 .finish_non_exhaustive()
312 }
313}
314
315impl ClientSideCredentialAccessBoundaryGranter {
316 pub fn new(grant: CredentialAccessBoundaryGrant) -> Self {
318 Self {
319 grant,
320 minimum_token_lifetime: DEFAULT_MINIMUM_TOKEN_LIFETIME,
321 intermediary_state: Arc::new(IntermediaryState::default()),
322 #[cfg(test)]
323 now: None,
324 #[cfg(test)]
325 time_after_request: None,
326 #[cfg(test)]
327 time_after_generation: None,
328 #[cfg(test)]
329 nonces: None,
330 }
331 }
332
333 pub fn with_grant(mut self, grant: CredentialAccessBoundaryGrant) -> Self {
335 self.grant = grant;
336 self
337 }
338
339 pub fn with_minimum_token_lifetime(mut self, lifetime: Duration) -> Self {
346 self.minimum_token_lifetime = lifetime;
347 self
348 }
349
350 fn now(&self) -> Timestamp {
351 #[cfg(test)]
352 if let Some(now) = self.now {
353 return now;
354 }
355 Timestamp::now()
356 }
357
358 fn time_after_request(&self) -> Timestamp {
359 #[cfg(test)]
360 if let Some(now) = self.time_after_request {
361 return now;
362 }
363 #[cfg(test)]
364 if let Some(now) = self.now {
365 return now;
366 }
367 Timestamp::now()
368 }
369
370 fn time_after_generation(&self) -> Timestamp {
371 #[cfg(test)]
372 if let Some(now) = self.time_after_generation {
373 return now;
374 }
375 #[cfg(test)]
376 if let Some(now) = self.time_after_request {
377 return now;
378 }
379 #[cfg(test)]
380 if let Some(now) = self.now {
381 return now;
382 }
383 Timestamp::now()
384 }
385
386 #[cfg(test)]
387 fn with_time(mut self, now: Timestamp) -> Self {
388 self.now = Some(now);
389 self.time_after_request = Some(now);
390 self.time_after_generation = Some(now);
391 self
392 }
393
394 #[cfg(test)]
395 fn with_time_after_request(mut self, now: Timestamp) -> Self {
396 self.time_after_request = Some(now);
397 self.time_after_generation = Some(now);
398 self
399 }
400
401 #[cfg(test)]
402 fn with_time_after_generation(mut self, now: Timestamp) -> Self {
403 self.time_after_generation = Some(now);
404 self
405 }
406
407 #[cfg(test)]
408 fn with_nonces(mut self, nonces: impl IntoIterator<Item = [u8; AES_GCM_NONCE_BYTES]>) -> Self {
409 self.nonces = Some(Arc::new(std::sync::Mutex::new(
410 nonces.into_iter().collect(),
411 )));
412 self
413 }
414
415 fn effective_minimum_lifetime(&self, expires_in: Option<Duration>) -> Result<Duration> {
416 let requested = expires_in.unwrap_or(self.minimum_token_lifetime);
417 if requested.is_zero() {
418 return Err(Error::request_invalid(
419 "client-side credential access boundary minimum lifetime must be greater than zero and less than twelve hours",
420 ));
421 }
422 let whole_seconds = requested
423 .as_secs()
424 .checked_add(u64::from(requested.subsec_nanos() != 0))
425 .ok_or_else(|| {
426 Error::request_invalid(
427 "client-side credential access boundary minimum lifetime must be greater than zero and less than twelve hours",
428 )
429 })?;
430 let requested = Duration::from_secs(whole_seconds).max(TOKEN_OPERATION_HEADROOM);
431 if requested > MAX_MINIMUM_TOKEN_LIFETIME {
432 return Err(Error::request_invalid(
433 "client-side credential access boundary minimum lifetime must be greater than zero and less than twelve hours",
434 ));
435 }
436 Ok(requested)
437 }
438
439 fn source_token<'a>(
440 &self,
441 credential: &'a Credential,
442 required_until: Timestamp,
443 ) -> Result<&'a Token> {
444 if credential.service_account.is_some() {
445 return Err(Error::credential_invalid(
446 "client-side credential access boundary requires a token-only source credential",
447 ));
448 }
449 let token = credential.token.as_ref().ok_or_else(|| {
450 Error::credential_invalid(
451 "client-side credential access boundary requires an OAuth access token",
452 )
453 })?;
454 if token.access_token.is_empty() {
455 return Err(Error::credential_invalid(
456 "client-side credential access boundary source access token is empty",
457 ));
458 }
459 if token.expires_at.is_none() {
460 return Err(Error::credential_invalid(
461 "client-side credential access boundary source token expiration is required",
462 ));
463 }
464 if !token.is_valid_at(required_until) {
465 return Err(Error::credential_invalid(
466 "source OAuth access token expires before the client-side CAB intermediary exchange can complete",
467 ));
468 }
469 Ok(token)
470 }
471
472 fn build_intermediary_request(
473 &self,
474 source_token: &str,
475 ) -> Result<http::Request<bytes::Bytes>> {
476 let body = Serializer::new(String::new())
477 .append_pair("grant_type", TOKEN_EXCHANGE_GRANT_TYPE)
478 .append_pair(
479 "requested_token_type",
480 ACCESS_BOUNDARY_INTERMEDIARY_TOKEN_TYPE,
481 )
482 .append_pair("subject_token_type", ACCESS_TOKEN_TYPE)
483 .append_pair("subject_token", source_token)
484 .finish();
485
486 http::Request::builder()
487 .method(http::Method::POST)
488 .uri(STS_ENDPOINT)
489 .header(ACCEPT, "application/json")
490 .header(CONTENT_TYPE, "application/x-www-form-urlencoded")
491 .body(body.into_bytes().into())
492 .map_err(|err| {
493 Error::unexpected("failed to build client-side CAB intermediary request")
494 .with_source(err)
495 })
496 }
497
498 fn parse_intermediary_response(
499 &self,
500 response: http::Response<bytes::Bytes>,
501 source: &Token,
502 response_time: Timestamp,
503 minimum_lifetime: Duration,
504 ) -> Result<IntermediaryCredentials> {
505 if response.status() != http::StatusCode::OK {
506 return Err(sts_error(response.status(), response.body()));
507 }
508
509 let mut token_response: StsTokenResponse = serde_json::from_slice(response.body())
510 .map_err(|_| {
511 Error::unexpected("failed to parse client-side CAB intermediary STS response")
512 })?;
513 if token_response.access_token.is_empty()
514 || token_response.issued_token_type != ACCESS_BOUNDARY_INTERMEDIARY_TOKEN_TYPE
515 || token_response.token_type != "Bearer"
516 {
517 return Err(Error::unexpected(
518 "client-side CAB intermediary STS response is malformed",
519 ));
520 }
521 let session_key = Zeroizing::new(
522 token_response
523 .access_boundary_session_key
524 .take()
525 .filter(|value| !value.is_empty())
526 .ok_or_else(|| {
527 Error::unexpected("client-side CAB intermediary STS response is malformed")
528 })?,
529 );
530
531 let source_expiration = source.expires_at.ok_or_else(|| {
532 Error::credential_invalid(
533 "client-side credential access boundary source token expiration is required",
534 )
535 })?;
536 if source_expiration <= response_time {
537 return Err(Error::credential_invalid(
538 "source OAuth access token expired during the client-side CAB intermediary exchange",
539 ));
540 }
541
542 let expires_in = token_response.expires_in.ok_or_else(|| {
543 Error::credential_invalid(
544 "client-side credential access boundary requires a service-account source with an explicit STS expiration",
545 )
546 })?;
547 let expires_in = Duration::from_secs(expires_in);
548 if expires_in.is_zero() || expires_in > MAX_ACCESS_TOKEN_LIFETIME {
549 return Err(Error::unexpected(
550 "client-side CAB intermediary STS expiration is invalid",
551 ));
552 }
553 let expires_at = checked_expiration(response_time, expires_in)?.min(source_expiration);
554 let required_until = checked_expiration(response_time, minimum_lifetime)?;
555 if expires_at <= required_until {
556 return Err(Error::credential_invalid(
557 "client-side CAB intermediary token is not valid for the minimum output lifetime",
558 ));
559 }
560
561 let aead_key = TinkAesGcmKey::parse(&session_key)?;
562 Ok(IntermediaryCredentials {
563 access_token: Zeroizing::new(token_response.access_token),
564 expires_at,
565 aead_key,
566 })
567 }
568
569 fn cache_key(&self, source: &Token) -> Result<IntermediaryCacheKey> {
570 let source_expires_at = source.expires_at.ok_or_else(|| {
571 Error::credential_invalid(
572 "client-side credential access boundary source token expiration is required",
573 )
574 })?;
575 Ok(IntermediaryCacheKey {
576 endpoint: STS_ENDPOINT,
577 source_authority: hex_sha256(source.access_token.as_bytes()),
578 source_expires_at,
579 })
580 }
581
582 async fn intermediary_credentials(
583 &self,
584 ctx: &Context,
585 source: &Token,
586 minimum_lifetime: Duration,
587 ) -> Result<Arc<IntermediaryCredentials>> {
588 let cache_key = self.cache_key(source)?;
589 let now = self.now();
590 if let Some(credentials) =
591 self.intermediary_state
592 .cache
593 .lock()
594 .await
595 .find(&cache_key, now, minimum_lifetime)?
596 {
597 return Ok(credentials);
598 }
599
600 let refresh_lock = self.intermediary_state.refresh_lock(&cache_key);
601 let _refresh_guard = refresh_lock.lock.lock().await;
602 let now = self.now();
603 if let Some(credentials) =
604 self.intermediary_state
605 .cache
606 .lock()
607 .await
608 .find(&cache_key, now, minimum_lifetime)?
609 {
610 return Ok(credentials);
611 }
612
613 let required_until = checked_expiration(
614 now,
615 minimum_lifetime.saturating_add(TOKEN_EXCHANGE_HEADROOM),
616 )?;
617 if !source.is_valid_at(required_until) {
618 return Err(Error::credential_invalid(
619 "source OAuth access token expires before the client-side CAB intermediary exchange can complete",
620 ));
621 }
622
623 let request = self.build_intermediary_request(&source.access_token)?;
624 let response = ctx.http_send(request).await.map_err(|err| {
625 Error::new(
626 err.kind(),
627 "client-side CAB intermediary STS request failed",
628 )
629 .set_retryable(err.is_retryable())
630 })?;
631 let response_time = self.time_after_request();
632 let credentials = Arc::new(self.parse_intermediary_response(
633 response,
634 source,
635 response_time,
636 minimum_lifetime,
637 )?);
638 self.intermediary_state.cache.lock().await.insert(
639 cache_key,
640 credentials.clone(),
641 response_time,
642 );
643 Ok(credentials)
644 }
645
646 fn next_nonce(&self) -> Result<[u8; AES_GCM_NONCE_BYTES]> {
647 #[cfg(test)]
648 if let Some(nonces) = &self.nonces {
649 return nonces
650 .lock()
651 .expect("lock poisoned")
652 .pop_front()
653 .ok_or_else(|| Error::unexpected("test CAB nonce queue is empty"));
654 }
655
656 <[u8; AES_GCM_NONCE_BYTES]>::try_generate()
657 .map_err(|_| Error::unexpected("failed to generate client-side CAB encryption nonce"))
658 }
659
660 #[cfg(test)]
661 async fn cache_len(&self) -> usize {
662 self.intermediary_state.cache.lock().await.len()
663 }
664
665 #[cfg(test)]
666 fn refresh_lock_len(&self) -> usize {
667 self.intermediary_state.refresh_lock_len()
668 }
669}
670
671impl GrantCredential for ClientSideCredentialAccessBoundaryGranter {
672 type Credential = Credential;
673
674 fn required_valid_until(
675 &self,
676 _credential: &Self::Credential,
677 expires_in: Option<Duration>,
678 ) -> Timestamp {
679 let minimum_lifetime = self
680 .effective_minimum_lifetime(expires_in)
681 .unwrap_or(TOKEN_OPERATION_HEADROOM);
682 self.now() + minimum_lifetime + TOKEN_EXCHANGE_HEADROOM
683 }
684
685 async fn grant_credential(
686 &self,
687 ctx: &Context,
688 credential: &Self::Credential,
689 expires_in: Option<Duration>,
690 ) -> Result<Self::Credential> {
691 let minimum_lifetime = self.effective_minimum_lifetime(expires_in)?;
692 let restrictions = serialize_restrictions(&self.grant)?;
693 let required_until = checked_expiration(
694 self.now(),
695 minimum_lifetime.saturating_add(TOKEN_EXCHANGE_HEADROOM),
696 )?;
697 let source = self.source_token(credential, required_until)?;
698 let intermediary = self
699 .intermediary_credentials(ctx, source, minimum_lifetime)
700 .await?;
701
702 let nonce = self.next_nonce()?;
703 let encrypted = intermediary.aead_key.encrypt(&restrictions, &nonce)?;
704 let access_token = format!(
705 "{}.{}",
706 intermediary.access_token.as_str(),
707 URL_SAFE_NO_PAD.encode(encrypted)
708 );
709 let output = Credential::with_token(Token {
710 access_token,
711 expires_at: Some(intermediary.expires_at),
712 });
713
714 let completed_at = self.time_after_generation();
715 let required_until = checked_expiration(completed_at, minimum_lifetime)?;
716 if !output.is_valid_at(required_until) {
717 return Err(Error::credential_invalid(
718 "client-issued CAB token is not valid for the minimum output lifetime after generation",
719 ));
720 }
721 Ok(output)
722 }
723}
724
725struct TinkAesGcmKey {
726 output_prefix: Vec<u8>,
727 key_value: Zeroizing<Vec<u8>>,
728}
729
730impl Debug for TinkAesGcmKey {
731 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
732 f.debug_struct("TinkAesGcmKey")
733 .field("output_prefix", &"REDACTED")
734 .field("key_value", &"REDACTED")
735 .finish()
736 }
737}
738
739impl TinkAesGcmKey {
740 fn parse(session_key: &str) -> Result<Self> {
741 if session_key.len() > MAX_SESSION_KEY_ENCODED_BYTES {
742 return Err(Error::unexpected(
743 "client-side CAB session key is malformed",
744 ));
745 }
746 let decoded = STANDARD
747 .decode(session_key)
748 .map(Zeroizing::new)
749 .map_err(|_| Error::unexpected("client-side CAB session key is malformed"))?;
750 let mut keyset = TinkKeyset::decode(decoded.as_slice())
751 .map_err(|_| Error::unexpected("client-side CAB session key is malformed"))?;
752 if keyset.keys.is_empty() || keyset.keys.len() > MAX_SESSION_KEYSET_KEYS {
753 zeroize_keyset(&mut keyset);
754 return Err(Error::unexpected(
755 "client-side CAB session key is malformed",
756 ));
757 }
758
759 let primary_indexes = keyset
760 .keys
761 .iter()
762 .enumerate()
763 .filter_map(|(index, key)| (key.key_id == keyset.primary_key_id).then_some(index))
764 .collect::<Vec<_>>();
765 if primary_indexes.len() != 1 {
766 zeroize_keyset(&mut keyset);
767 return Err(Error::unexpected(
768 "client-side CAB session key is malformed",
769 ));
770 }
771 let mut primary = keyset.keys.swap_remove(primary_indexes[0]);
772 zeroize_keyset(&mut keyset);
773 if primary.status != TINK_KEY_STATUS_ENABLED {
774 zeroize_key(&mut primary);
775 return Err(Error::unexpected(
776 "client-side CAB session key is malformed",
777 ));
778 }
779
780 let output_prefix = match primary.output_prefix_type {
781 TINK_PREFIX => {
782 let mut prefix = Vec::with_capacity(5);
783 prefix.push(1);
784 prefix.extend_from_slice(&primary.key_id.to_be_bytes());
785 prefix
786 }
787 LEGACY_PREFIX | CRUNCHY_PREFIX => {
788 let mut prefix = Vec::with_capacity(5);
789 prefix.push(0);
790 prefix.extend_from_slice(&primary.key_id.to_be_bytes());
791 prefix
792 }
793 RAW_PREFIX => Vec::new(),
794 _ => {
795 zeroize_key(&mut primary);
796 return Err(Error::unexpected(
797 "client-side CAB session key is malformed",
798 ));
799 }
800 };
801
802 let mut key_data = primary
803 .key_data
804 .take()
805 .ok_or_else(|| Error::unexpected("client-side CAB session key is malformed"))?;
806 if key_data.type_url != AES_GCM_KEY_TYPE_URL
807 || key_data.key_material_type != TINK_KEY_MATERIAL_SYMMETRIC
808 {
809 key_data.value.zeroize();
810 return Err(Error::unexpected(
811 "client-side CAB session key uses an unsupported AEAD key",
812 ));
813 }
814 let aes_key = AesGcmKeyProto::decode(key_data.value.as_slice());
815 key_data.value.zeroize();
816 let mut aes_key =
817 aes_key.map_err(|_| Error::unexpected("client-side CAB session key is malformed"))?;
818 if aes_key.version != AES_GCM_KEY_VERSION || !matches!(aes_key.key_value.len(), 16 | 32) {
819 aes_key.key_value.zeroize();
820 return Err(Error::unexpected(
821 "client-side CAB session key uses an unsupported AEAD key",
822 ));
823 }
824
825 Ok(Self {
826 output_prefix,
827 key_value: Zeroizing::new(aes_key.key_value),
828 })
829 }
830
831 fn encrypt(&self, restrictions: &[u8], nonce: &[u8; AES_GCM_NONCE_BYTES]) -> Result<Vec<u8>> {
832 let ciphertext = match self.key_value.len() {
833 16 => Aes128Gcm::new_from_slice(&self.key_value)
834 .map_err(|_| Error::unexpected("failed to initialize client-side CAB encryption"))?
835 .encrypt(&Array(*nonce), restrictions),
836 32 => Aes256Gcm::new_from_slice(&self.key_value)
837 .map_err(|_| Error::unexpected("failed to initialize client-side CAB encryption"))?
838 .encrypt(&Array(*nonce), restrictions),
839 _ => {
840 return Err(Error::unexpected(
841 "client-side CAB session key uses an unsupported AEAD key",
842 ));
843 }
844 }
845 .map_err(|_| Error::unexpected("failed to encrypt client-side CAB restrictions"))?;
846
847 let mut encrypted =
848 Vec::with_capacity(self.output_prefix.len() + nonce.len() + ciphertext.len());
849 encrypted.extend_from_slice(&self.output_prefix);
850 encrypted.extend_from_slice(nonce);
851 encrypted.extend_from_slice(&ciphertext);
852 Ok(encrypted)
853 }
854}
855
856fn zeroize_keyset(keyset: &mut TinkKeyset) {
857 for key in &mut keyset.keys {
858 zeroize_key(key);
859 }
860}
861
862fn zeroize_key(key: &mut TinkKeysetKey) {
863 if let Some(key_data) = &mut key.key_data {
864 key_data.value.zeroize();
865 }
866}
867
868fn serialize_restrictions(grant: &CredentialAccessBoundaryGrant) -> Result<Vec<u8>> {
869 grant.validate()?;
870 let rules = grant
871 .rules
872 .iter()
873 .map(|rule| {
874 let available_resource = format!(
875 "//storage.googleapis.com/projects/_/buckets/{}",
876 rule.bucket
877 );
878 let available_permissions = rule
879 .permissions
880 .roles()?
881 .into_iter()
882 .map(str::to_owned)
883 .collect();
884 let compiled_availability_condition = rule
885 .object_prefix
886 .as_deref()
887 .map(|prefix| prefix_condition_expr(&rule.bucket, prefix));
888 Ok(ClientSideAccessBoundaryRule {
889 available_resource,
890 available_permissions,
891 compiled_availability_condition,
892 })
893 })
894 .collect::<Result<Vec<_>>>()?;
895 Ok(ClientSideAccessBoundary {
896 access_boundary_rules: rules,
897 }
898 .encode_to_vec())
899}
900
901fn prefix_condition_expr(bucket: &str, prefix: &str) -> CelExpr {
902 let object_resource_prefix = format!("projects/_/buckets/{bucket}/objects/{prefix}");
903 let resource_starts_with = call(
904 2,
905 Some(select(3, ident(4, "resource"), "name")),
906 "startsWith",
907 vec![string_constant(5, object_resource_prefix)],
908 );
909 let list_prefix = call(
910 6,
911 Some(call(
912 7,
913 Some(ident(8, "api")),
914 "getAttribute",
915 vec![
916 string_constant(9, "storage.googleapis.com/objectListPrefix"),
917 string_constant(10, ""),
918 ],
919 )),
920 "startsWith",
921 vec![string_constant(11, prefix)],
922 );
923 call(1, None, "_||_", vec![resource_starts_with, list_prefix])
924}
925
926fn ident(id: i64, name: impl Into<String>) -> CelExpr {
927 CelExpr {
928 id,
929 ident_expr: Some(CelIdent { name: name.into() }),
930 ..Default::default()
931 }
932}
933
934fn select(id: i64, operand: CelExpr, field: impl Into<String>) -> CelExpr {
935 CelExpr {
936 id,
937 select_expr: Some(CelSelect {
938 operand: Some(Box::new(operand)),
939 field: field.into(),
940 test_only: false,
941 }),
942 ..Default::default()
943 }
944}
945
946fn call(
947 id: i64,
948 target: Option<CelExpr>,
949 function: impl Into<String>,
950 args: Vec<CelExpr>,
951) -> CelExpr {
952 CelExpr {
953 id,
954 call_expr: Some(CelCall {
955 target: target.map(Box::new),
956 function: function.into(),
957 args,
958 }),
959 ..Default::default()
960 }
961}
962
963fn string_constant(id: i64, value: impl Into<String>) -> CelExpr {
964 CelExpr {
965 id,
966 const_expr: Some(CelConstant {
967 string_value: Some(value.into()),
968 }),
969 ..Default::default()
970 }
971}
972
973#[derive(Clone, PartialEq, Message)]
974struct ClientSideAccessBoundary {
975 #[prost(message, repeated, tag = "1")]
976 access_boundary_rules: Vec<ClientSideAccessBoundaryRule>,
977}
978
979#[derive(Clone, PartialEq, Message)]
980struct ClientSideAccessBoundaryRule {
981 #[prost(string, tag = "1")]
982 available_resource: String,
983 #[prost(string, repeated, tag = "2")]
984 available_permissions: Vec<String>,
985 #[prost(message, optional, tag = "4")]
986 compiled_availability_condition: Option<CelExpr>,
987}
988
989#[derive(Clone, PartialEq, Message)]
990struct CelExpr {
991 #[prost(int64, tag = "2")]
992 id: i64,
993 #[prost(message, optional, tag = "3")]
994 const_expr: Option<CelConstant>,
995 #[prost(message, optional, tag = "4")]
996 ident_expr: Option<CelIdent>,
997 #[prost(message, optional, tag = "5")]
998 select_expr: Option<CelSelect>,
999 #[prost(message, optional, tag = "6")]
1000 call_expr: Option<CelCall>,
1001}
1002
1003#[derive(Clone, PartialEq, Message)]
1004struct CelIdent {
1005 #[prost(string, tag = "1")]
1006 name: String,
1007}
1008
1009#[derive(Clone, PartialEq, Message)]
1010struct CelSelect {
1011 #[prost(message, optional, boxed, tag = "1")]
1012 operand: Option<Box<CelExpr>>,
1013 #[prost(string, tag = "2")]
1014 field: String,
1015 #[prost(bool, tag = "3")]
1016 test_only: bool,
1017}
1018
1019#[derive(Clone, PartialEq, Message)]
1020struct CelCall {
1021 #[prost(message, optional, boxed, tag = "1")]
1022 target: Option<Box<CelExpr>>,
1023 #[prost(string, tag = "2")]
1024 function: String,
1025 #[prost(message, repeated, tag = "3")]
1026 args: Vec<CelExpr>,
1027}
1028
1029#[derive(Clone, PartialEq, Message)]
1030struct CelConstant {
1031 #[prost(string, optional, tag = "6")]
1032 string_value: Option<String>,
1033}
1034
1035#[derive(Clone, PartialEq, Message)]
1036struct TinkKeyset {
1037 #[prost(uint32, tag = "1")]
1038 primary_key_id: u32,
1039 #[prost(message, repeated, tag = "2")]
1040 keys: Vec<TinkKeysetKey>,
1041}
1042
1043#[derive(Clone, PartialEq, Message)]
1044struct TinkKeysetKey {
1045 #[prost(message, optional, tag = "1")]
1046 key_data: Option<TinkKeyData>,
1047 #[prost(int32, tag = "2")]
1048 status: i32,
1049 #[prost(uint32, tag = "3")]
1050 key_id: u32,
1051 #[prost(int32, tag = "4")]
1052 output_prefix_type: i32,
1053}
1054
1055#[derive(Clone, PartialEq, Message)]
1056struct TinkKeyData {
1057 #[prost(string, tag = "1")]
1058 type_url: String,
1059 #[prost(bytes = "vec", tag = "2")]
1060 value: Vec<u8>,
1061 #[prost(int32, tag = "3")]
1062 key_material_type: i32,
1063}
1064
1065#[derive(Clone, PartialEq, Message)]
1066struct AesGcmKeyProto {
1067 #[prost(uint32, tag = "1")]
1068 version: u32,
1069 #[prost(bytes = "vec", tag = "3")]
1070 key_value: Vec<u8>,
1071}
1072
1073#[cfg(test)]
1074mod tests {
1075 use std::collections::{BTreeMap, HashSet, VecDeque};
1076 use std::fmt::Formatter;
1077 use std::sync::atomic::{AtomicUsize, Ordering};
1078 use std::sync::{Arc, Mutex as StdMutex};
1079
1080 use asyncband::semaphore::Semaphore;
1081 use bytes::Bytes;
1082 use http::header::{AUTHORIZATION, HeaderMap};
1083 use reqsign_core::{ErrorKind, Granter, HttpSend, ProvideCredential, Signer, time::Timestamp};
1084
1085 use super::*;
1086 use crate::{CredentialAccessBoundaryPermissions, RequestSigner, ServiceAccount};
1087
1088 const GOOGLE_AUTH_LIBRARY_SESSION_KEY: &str = concat!(
1092 "CPaEhYsKEmQKWAowdHlwZS5nb29nbGVhcGlzLmNvbS9nb29nbGUuY3J5cHRvLnRpbmsuQW",
1093 "VzR2NtS2V5EiIaIMx8syvGIGGu5yvrdq/I0Q9ZWIR1oqJXFnDFxHuwX4SEGAEQARj2hIWLCiAB"
1094 );
1095
1096 #[derive(Clone)]
1097 struct CapturedRequest {
1098 method: http::Method,
1099 uri: http::Uri,
1100 headers: HeaderMap,
1101 body: Vec<u8>,
1102 }
1103
1104 impl Debug for CapturedRequest {
1105 fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
1106 f.debug_struct("CapturedRequest")
1107 .field("method", &self.method)
1108 .field("uri", &self.uri)
1109 .field("headers", &"REDACTED")
1110 .field("body", &"REDACTED")
1111 .finish()
1112 }
1113 }
1114
1115 #[derive(Clone)]
1116 struct RequestGate {
1117 started: Arc<Semaphore>,
1118 release: Arc<Semaphore>,
1119 }
1120
1121 impl RequestGate {
1122 async fn wait_started(&self) {
1123 self.started.acquire(1).await.forget();
1124 }
1125
1126 fn release_one(&self) {
1127 self.release.release(1);
1128 }
1129 }
1130
1131 #[derive(Clone)]
1132 struct MockHttpSend {
1133 calls: Arc<AtomicUsize>,
1134 requests: Arc<StdMutex<Vec<CapturedRequest>>>,
1135 responses: Arc<StdMutex<VecDeque<http::Response<Bytes>>>>,
1136 gate: Option<RequestGate>,
1137 }
1138
1139 impl Debug for MockHttpSend {
1140 fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
1141 f.debug_struct("MockHttpSend").finish_non_exhaustive()
1142 }
1143 }
1144
1145 impl MockHttpSend {
1146 fn new(responses: impl IntoIterator<Item = http::Response<Bytes>>) -> Self {
1147 Self {
1148 calls: Arc::new(AtomicUsize::new(0)),
1149 requests: Arc::new(StdMutex::new(Vec::new())),
1150 responses: Arc::new(StdMutex::new(responses.into_iter().collect())),
1151 gate: None,
1152 }
1153 }
1154
1155 fn gated(
1156 responses: impl IntoIterator<Item = http::Response<Bytes>>,
1157 ) -> (Self, RequestGate) {
1158 let gate = RequestGate {
1159 started: Arc::new(Semaphore::new(0)),
1160 release: Arc::new(Semaphore::new(0)),
1161 };
1162 let mut http = Self::new(responses);
1163 http.gate = Some(gate.clone());
1164 (http, gate)
1165 }
1166
1167 fn requests(&self) -> Vec<CapturedRequest> {
1168 self.requests.lock().expect("lock poisoned").clone()
1169 }
1170 }
1171
1172 impl HttpSend for MockHttpSend {
1173 async fn http_send(&self, request: http::Request<Bytes>) -> Result<http::Response<Bytes>> {
1174 self.calls.fetch_add(1, Ordering::SeqCst);
1175 let (parts, body) = request.into_parts();
1176 self.requests
1177 .lock()
1178 .expect("lock poisoned")
1179 .push(CapturedRequest {
1180 method: parts.method,
1181 uri: parts.uri,
1182 headers: parts.headers,
1183 body: body.to_vec(),
1184 });
1185 if let Some(gate) = &self.gate {
1186 gate.started.release(1);
1187 gate.release.acquire(1).await.forget();
1188 }
1189 self.responses
1190 .lock()
1191 .expect("lock poisoned")
1192 .pop_front()
1193 .ok_or_else(|| Error::unexpected("mock response queue is empty"))
1194 }
1195 }
1196
1197 #[derive(Debug)]
1198 struct SecretTransportError;
1199
1200 impl HttpSend for SecretTransportError {
1201 async fn http_send(&self, _request: http::Request<Bytes>) -> Result<http::Response<Bytes>> {
1202 Err(
1203 Error::unexpected("transport retained subject_token=source-secret")
1204 .set_retryable(true),
1205 )
1206 }
1207 }
1208
1209 #[derive(Clone)]
1210 struct FixedCredentialProvider {
1211 credential: Credential,
1212 calls: Arc<AtomicUsize>,
1213 }
1214
1215 impl Debug for FixedCredentialProvider {
1216 fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
1217 f.debug_struct("FixedCredentialProvider")
1218 .finish_non_exhaustive()
1219 }
1220 }
1221
1222 impl FixedCredentialProvider {
1223 fn new(credential: Credential) -> (Self, Arc<AtomicUsize>) {
1224 let calls = Arc::new(AtomicUsize::new(0));
1225 (
1226 Self {
1227 credential,
1228 calls: calls.clone(),
1229 },
1230 calls,
1231 )
1232 }
1233 }
1234
1235 impl ProvideCredential for FixedCredentialProvider {
1236 type Credential = Credential;
1237
1238 async fn provide_credential(&self, _ctx: &Context) -> Result<Option<Self::Credential>> {
1239 self.calls.fetch_add(1, Ordering::SeqCst);
1240 Ok(Some(self.credential.clone()))
1241 }
1242 }
1243
1244 fn timestamp(value: &str) -> Timestamp {
1245 value.parse().expect("timestamp must be valid")
1246 }
1247
1248 fn source_token(access_token: &str, expires_at: Option<Timestamp>) -> Credential {
1249 Credential::with_token(Token {
1250 access_token: access_token.to_string(),
1251 expires_at,
1252 })
1253 }
1254
1255 fn viewer_bucket_grant() -> CredentialAccessBoundaryGrant {
1256 CredentialAccessBoundaryGrant::for_bucket(
1257 "example-bucket",
1258 CredentialAccessBoundaryPermissions::OBJECT_VIEWER,
1259 )
1260 }
1261
1262 fn response(status: http::StatusCode, body: impl Into<Bytes>) -> http::Response<Bytes> {
1263 http::Response::builder()
1264 .status(status)
1265 .body(body.into())
1266 .expect("response must build")
1267 }
1268
1269 fn success_response(
1270 intermediary_token: &str,
1271 expires_in: Option<u64>,
1272 ) -> http::Response<Bytes> {
1273 success_response_with_key(
1274 intermediary_token,
1275 GOOGLE_AUTH_LIBRARY_SESSION_KEY,
1276 expires_in,
1277 )
1278 }
1279
1280 fn success_response_with_key(
1281 intermediary_token: &str,
1282 session_key: &str,
1283 expires_in: Option<u64>,
1284 ) -> http::Response<Bytes> {
1285 let mut value = serde_json::json!({
1286 "access_token": intermediary_token,
1287 "issued_token_type": ACCESS_BOUNDARY_INTERMEDIARY_TOKEN_TYPE,
1288 "token_type": "Bearer",
1289 "access_boundary_session_key": session_key,
1290 });
1291 if let Some(expires_in) = expires_in {
1292 value["expires_in"] = expires_in.into();
1293 }
1294 response(
1295 http::StatusCode::OK,
1296 serde_json::to_vec(&value).expect("response JSON must serialize"),
1297 )
1298 }
1299
1300 fn form_fields(request: &CapturedRequest) -> BTreeMap<String, String> {
1301 form_urlencoded::parse(&request.body).into_owned().collect()
1302 }
1303
1304 fn output_token(credential: &Credential) -> &Token {
1305 assert!(credential.service_account.is_none());
1306 credential
1307 .token
1308 .as_ref()
1309 .expect("granted credential must contain a token")
1310 }
1311
1312 fn decrypt_restrictions(
1313 credential: &Credential,
1314 ) -> (String, [u8; AES_GCM_NONCE_BYTES], ClientSideAccessBoundary) {
1315 let token = &output_token(credential).access_token;
1316 let (intermediary, encoded_restrictions) = token
1317 .split_once('.')
1318 .expect("client-issued token must contain two parts");
1319 let encrypted = URL_SAFE_NO_PAD
1320 .decode(encoded_restrictions)
1321 .expect("restrictions must be unpadded base64url");
1322 let key = TinkAesGcmKey::parse(GOOGLE_AUTH_LIBRARY_SESSION_KEY)
1323 .expect("official session key must parse");
1324 assert!(encrypted.starts_with(&key.output_prefix));
1325 let nonce_offset = key.output_prefix.len();
1326 let nonce: [u8; AES_GCM_NONCE_BYTES] = encrypted
1327 [nonce_offset..nonce_offset + AES_GCM_NONCE_BYTES]
1328 .try_into()
1329 .expect("nonce must have the expected length");
1330 let ciphertext = &encrypted[nonce_offset + AES_GCM_NONCE_BYTES..];
1331 let plaintext = match key.key_value.len() {
1332 16 => Aes128Gcm::new_from_slice(&key.key_value)
1333 .expect("AES-128 key must initialize")
1334 .decrypt(&Array(nonce), ciphertext),
1335 32 => Aes256Gcm::new_from_slice(&key.key_value)
1336 .expect("AES-256 key must initialize")
1337 .decrypt(&Array(nonce), ciphertext),
1338 _ => panic!("unexpected test key length"),
1339 }
1340 .expect("restrictions must decrypt");
1341 let restrictions = ClientSideAccessBoundary::decode(plaintext.as_slice())
1342 .expect("restrictions must be a CAB protobuf");
1343 (intermediary.to_string(), nonce, restrictions)
1344 }
1345
1346 fn encoded_tink_keyset(
1347 key_id: u32,
1348 status: i32,
1349 output_prefix_type: i32,
1350 type_url: &str,
1351 material_type: i32,
1352 version: u32,
1353 key_value: Vec<u8>,
1354 ) -> String {
1355 let value = AesGcmKeyProto { version, key_value }.encode_to_vec();
1356 STANDARD.encode(
1357 TinkKeyset {
1358 primary_key_id: key_id,
1359 keys: vec![TinkKeysetKey {
1360 key_data: Some(TinkKeyData {
1361 type_url: type_url.to_string(),
1362 value,
1363 key_material_type: material_type,
1364 }),
1365 status,
1366 key_id,
1367 output_prefix_type,
1368 }],
1369 }
1370 .encode_to_vec(),
1371 )
1372 }
1373
1374 #[test]
1375 fn matches_google_auth_library_tink_aes_gcm_vector() {
1376 let key = TinkAesGcmKey::parse(GOOGLE_AUTH_LIBRARY_SESSION_KEY)
1377 .expect("official session key must parse");
1378 assert_eq!(
1379 key.output_prefix,
1380 [0x01, 0xa1, 0x61, 0x42, 0x76],
1381 "Tink prefix must contain the official primary key id"
1382 );
1383 assert_eq!(
1384 key.key_value.as_slice(),
1385 &[
1386 0xcc, 0x7c, 0xb3, 0x2b, 0xc6, 0x20, 0x61, 0xae, 0xe7, 0x2b, 0xeb, 0x76, 0xaf, 0xc8,
1387 0xd1, 0x0f, 0x59, 0x58, 0x84, 0x75, 0xa2, 0xa2, 0x57, 0x16, 0x70, 0xc5, 0xc4, 0x7b,
1388 0xb0, 0x5f, 0x84, 0x84,
1389 ]
1390 );
1391
1392 let nonce = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11];
1393 let encrypted = key
1394 .encrypt(b"restriction", &nonce)
1395 .expect("vector must encrypt");
1396 assert_eq!(
1397 URL_SAFE_NO_PAD.encode(encrypted),
1398 "AaFhQnYAAQIDBAUGBwgJCgvIc9p0EXthr8WYQl6sKvdE-kCKA-SIx7I0T1E"
1399 );
1400 }
1401
1402 #[test]
1403 fn refresh_lock_registry_does_not_retain_sequential_partitions() {
1404 let state = IntermediaryState::default();
1405 let expires_at = timestamp("2030-01-01T01:00:00Z");
1406 for index in 0..1024 {
1407 let lease = state.refresh_lock(&IntermediaryCacheKey {
1408 endpoint: STS_ENDPOINT,
1409 source_authority: format!("source-{index}"),
1410 source_expires_at: expires_at,
1411 });
1412 assert_eq!(state.refresh_lock_len(), 1);
1413 drop(lease);
1414 assert_eq!(state.refresh_lock_len(), 0);
1415 }
1416 }
1417
1418 #[tokio::test]
1419 async fn sends_exact_official_intermediary_exchange_shape() {
1420 let now = timestamp("2030-01-01T00:00:00Z");
1421 let source_expiry = timestamp("2030-01-01T02:00:00Z");
1422 let http = MockHttpSend::new([success_response("intermediary-token", Some(3600))]);
1423 let operation = ClientSideCredentialAccessBoundaryGranter::new(viewer_bucket_grant())
1424 .with_time(now)
1425 .with_nonces([[0; AES_GCM_NONCE_BYTES]]);
1426 let source = source_token("source+token/with=reserved", Some(source_expiry));
1427
1428 operation
1429 .grant_credential(&Context::new().with_http_send(http.clone()), &source, None)
1430 .await
1431 .expect("client-side grant must succeed");
1432
1433 let requests = http.requests();
1434 assert_eq!(requests.len(), 1);
1435 let request = &requests[0];
1436 assert_eq!(request.method, http::Method::POST);
1437 assert_eq!(request.uri, STS_ENDPOINT);
1438 assert_eq!(request.headers[ACCEPT], "application/json");
1439 assert_eq!(
1440 request.headers[CONTENT_TYPE],
1441 "application/x-www-form-urlencoded"
1442 );
1443 assert!(!request.headers.contains_key(AUTHORIZATION));
1444 assert_eq!(
1445 String::from_utf8(request.body.clone()).expect("form body must be UTF-8"),
1446 concat!(
1447 "grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Atoken-exchange",
1448 "&requested_token_type=urn%3Aietf%3Aparams%3Aoauth%3Atoken-type%3A",
1449 "access_boundary_intermediary_token",
1450 "&subject_token_type=urn%3Aietf%3Aparams%3Aoauth%3Atoken-type%3Aaccess_token",
1451 "&subject_token=source%2Btoken%2Fwith%3Dreserved"
1452 )
1453 );
1454 let fields = form_fields(request);
1455 assert_eq!(fields.len(), 4);
1456 assert_eq!(fields["grant_type"], TOKEN_EXCHANGE_GRANT_TYPE);
1457 assert_eq!(
1458 fields["requested_token_type"],
1459 ACCESS_BOUNDARY_INTERMEDIARY_TOKEN_TYPE
1460 );
1461 assert_eq!(fields["subject_token_type"], ACCESS_TOKEN_TYPE);
1462 assert_eq!(fields["subject_token"], "source+token/with=reserved");
1463 assert!(!fields.contains_key("options"));
1464 assert!(!fields.contains_key("audience"));
1465 assert!(!fields.contains_key("scope"));
1466 }
1467
1468 #[tokio::test]
1469 async fn serializes_typed_prefix_grant_as_compiled_cel_protobuf() {
1470 let now = timestamp("2030-01-01T00:00:00Z");
1471 let grant = CredentialAccessBoundaryGrant::for_object_prefix(
1472 "example-bucket",
1473 "customer \"雪\"/",
1474 CredentialAccessBoundaryPermissions::OBJECT_VIEWER
1475 | CredentialAccessBoundaryPermissions::OBJECT_CREATOR,
1476 );
1477 let operation = ClientSideCredentialAccessBoundaryGranter::new(grant)
1478 .with_time(now)
1479 .with_nonces([[7; AES_GCM_NONCE_BYTES]]);
1480 let http = MockHttpSend::new([success_response("intermediary", Some(3600))]);
1481 let output = operation
1482 .grant_credential(
1483 &Context::new().with_http_send(http),
1484 &source_token("source", Some(now + Duration::from_secs(2 * 60 * 60))),
1485 None,
1486 )
1487 .await
1488 .expect("client-side grant must succeed");
1489
1490 let (intermediary, nonce, restrictions) = decrypt_restrictions(&output);
1491 assert_eq!(intermediary, "intermediary");
1492 assert_eq!(nonce, [7; AES_GCM_NONCE_BYTES]);
1493 assert_eq!(restrictions.access_boundary_rules.len(), 1);
1494 let rule = &restrictions.access_boundary_rules[0];
1495 assert_eq!(
1496 rule.available_resource,
1497 "//storage.googleapis.com/projects/_/buckets/example-bucket"
1498 );
1499 assert_eq!(
1500 rule.available_permissions,
1501 [
1502 "inRole:roles/storage.objectViewer",
1503 "inRole:roles/storage.objectCreator",
1504 ]
1505 );
1506
1507 let root = rule
1508 .compiled_availability_condition
1509 .as_ref()
1510 .expect("prefix rule must have a compiled condition");
1511 let root_call = root.call_expr.as_ref().expect("root must be a call");
1512 assert_eq!(root_call.function, "_||_");
1513 assert!(root_call.target.is_none());
1514 assert_eq!(root_call.args.len(), 2);
1515
1516 let resource_call = root_call.args[0]
1517 .call_expr
1518 .as_ref()
1519 .expect("first branch must be a call");
1520 assert_eq!(resource_call.function, "startsWith");
1521 let resource_select = resource_call
1522 .target
1523 .as_deref()
1524 .and_then(|expr| expr.select_expr.as_ref())
1525 .expect("resource startsWith target must be a select");
1526 assert_eq!(resource_select.field, "name");
1527 assert_eq!(
1528 resource_select
1529 .operand
1530 .as_deref()
1531 .and_then(|expr| expr.ident_expr.as_ref())
1532 .map(|ident| ident.name.as_str()),
1533 Some("resource")
1534 );
1535 assert_eq!(
1536 resource_call.args[0]
1537 .const_expr
1538 .as_ref()
1539 .and_then(|constant| constant.string_value.as_deref()),
1540 Some("projects/_/buckets/example-bucket/objects/customer \"雪\"/")
1541 );
1542
1543 let list_call = root_call.args[1]
1544 .call_expr
1545 .as_ref()
1546 .expect("second branch must be a call");
1547 assert_eq!(list_call.function, "startsWith");
1548 let get_attribute = list_call
1549 .target
1550 .as_deref()
1551 .and_then(|expr| expr.call_expr.as_ref())
1552 .expect("list startsWith target must call getAttribute");
1553 assert_eq!(get_attribute.function, "getAttribute");
1554 assert_eq!(
1555 get_attribute
1556 .target
1557 .as_deref()
1558 .and_then(|expr| expr.ident_expr.as_ref())
1559 .map(|ident| ident.name.as_str()),
1560 Some("api")
1561 );
1562 assert_eq!(
1563 get_attribute.args[0]
1564 .const_expr
1565 .as_ref()
1566 .and_then(|constant| constant.string_value.as_deref()),
1567 Some("storage.googleapis.com/objectListPrefix")
1568 );
1569 assert_eq!(
1570 get_attribute.args[1]
1571 .const_expr
1572 .as_ref()
1573 .and_then(|constant| constant.string_value.as_deref()),
1574 Some("")
1575 );
1576 assert_eq!(
1577 list_call.args[0]
1578 .const_expr
1579 .as_ref()
1580 .and_then(|constant| constant.string_value.as_deref()),
1581 Some("customer \"雪\"/")
1582 );
1583 }
1584
1585 #[tokio::test]
1586 async fn bucket_wide_and_multiple_rules_preserve_union_order() {
1587 let now = timestamp("2030-01-01T00:00:00Z");
1588 let grant = viewer_bucket_grant().with_object_prefix_rule(
1589 "second-bucket",
1590 "tenant/",
1591 CredentialAccessBoundaryPermissions::OBJECT_USER,
1592 );
1593 let operation = ClientSideCredentialAccessBoundaryGranter::new(grant)
1594 .with_time(now)
1595 .with_nonces([[9; AES_GCM_NONCE_BYTES]]);
1596 let output = operation
1597 .grant_credential(
1598 &Context::new().with_http_send(MockHttpSend::new([success_response(
1599 "intermediary",
1600 Some(3600),
1601 )])),
1602 &source_token("source", Some(now + Duration::from_secs(2 * 60 * 60))),
1603 None,
1604 )
1605 .await
1606 .expect("client-side grant must succeed");
1607
1608 let (_, _, restrictions) = decrypt_restrictions(&output);
1609 assert_eq!(restrictions.access_boundary_rules.len(), 2);
1610 assert_eq!(
1611 restrictions.access_boundary_rules[0].available_resource,
1612 "//storage.googleapis.com/projects/_/buckets/example-bucket"
1613 );
1614 assert!(
1615 restrictions.access_boundary_rules[0]
1616 .compiled_availability_condition
1617 .is_none()
1618 );
1619 assert_eq!(
1620 restrictions.access_boundary_rules[1].available_resource,
1621 "//storage.googleapis.com/projects/_/buckets/second-bucket"
1622 );
1623 assert!(
1624 restrictions.access_boundary_rules[1]
1625 .compiled_availability_condition
1626 .is_some()
1627 );
1628 }
1629
1630 #[tokio::test]
1631 async fn rejects_invalid_grant_before_io() {
1632 let now = timestamp("2030-01-01T00:00:00Z");
1633 let http = MockHttpSend::new([]);
1634 let operation = ClientSideCredentialAccessBoundaryGranter::new(
1635 CredentialAccessBoundaryGrant::for_object_prefix(
1636 "example-bucket",
1637 "",
1638 CredentialAccessBoundaryPermissions::OBJECT_VIEWER,
1639 ),
1640 )
1641 .with_time(now);
1642 let err = operation
1643 .grant_credential(
1644 &Context::new().with_http_send(http.clone()),
1645 &source_token("source", Some(now + Duration::from_secs(2 * 60 * 60))),
1646 None,
1647 )
1648 .await
1649 .expect_err("invalid grant must fail");
1650
1651 assert_eq!(err.kind(), ErrorKind::RequestInvalid);
1652 assert_eq!(http.calls.load(Ordering::SeqCst), 0);
1653 }
1654
1655 #[tokio::test]
1656 async fn rejects_incompatible_sources_and_lifetimes_before_io() {
1657 let now = timestamp("2030-01-01T00:00:00Z");
1658 let http = MockHttpSend::new([]);
1659 let ctx = Context::new().with_http_send(http.clone());
1660 let operation =
1661 ClientSideCredentialAccessBoundaryGranter::new(viewer_bucket_grant()).with_time(now);
1662 let valid_token = Token {
1663 access_token: "source".to_string(),
1664 expires_at: Some(now + Duration::from_secs(2 * 60 * 60)),
1665 };
1666 let invalid_sources = [
1667 Credential::with_service_account(ServiceAccount {
1668 private_key: "private".to_string(),
1669 client_email: "service@example.com".to_string(),
1670 }),
1671 Credential {
1672 service_account: Some(ServiceAccount {
1673 private_key: "private".to_string(),
1674 client_email: "service@example.com".to_string(),
1675 }),
1676 token: Some(valid_token),
1677 signer_email: None,
1678 },
1679 source_token("", Some(now + Duration::from_secs(2 * 60 * 60))),
1680 source_token("source", None),
1681 source_token("source", Some(now + DEFAULT_MINIMUM_TOKEN_LIFETIME)),
1682 ];
1683 for source in invalid_sources {
1684 let err = operation
1685 .grant_credential(&ctx, &source, None)
1686 .await
1687 .expect_err("incompatible source must fail");
1688 assert_eq!(err.kind(), ErrorKind::CredentialInvalid);
1689 }
1690
1691 let valid_source = source_token("source", Some(now + Duration::from_secs(13 * 60 * 60)));
1692 for lifetime in [
1693 Duration::ZERO,
1694 MAX_ACCESS_TOKEN_LIFETIME,
1695 MAX_ACCESS_TOKEN_LIFETIME + Duration::from_secs(1),
1696 ] {
1697 let err = operation
1698 .grant_credential(&ctx, &valid_source, Some(lifetime))
1699 .await
1700 .expect_err("invalid minimum lifetime must fail");
1701 assert_eq!(err.kind(), ErrorKind::RequestInvalid);
1702 }
1703 assert_eq!(http.calls.load(Ordering::SeqCst), 0);
1704 }
1705
1706 #[test]
1707 fn validates_exact_minimum_lifetime_boundary_after_rounding() {
1708 let operation = ClientSideCredentialAccessBoundaryGranter::new(viewer_bucket_grant());
1709 assert_eq!(
1710 operation
1711 .effective_minimum_lifetime(Some(MAX_MINIMUM_TOKEN_LIFETIME))
1712 .expect("the largest feasible whole-second lifetime must be accepted"),
1713 MAX_MINIMUM_TOKEN_LIFETIME
1714 );
1715
1716 for lifetime in [
1717 MAX_MINIMUM_TOKEN_LIFETIME + Duration::from_nanos(1),
1718 MAX_ACCESS_TOKEN_LIFETIME,
1719 Duration::MAX,
1720 ] {
1721 let err = operation
1722 .effective_minimum_lifetime(Some(lifetime))
1723 .expect_err("a lifetime that rounds to twelve hours must fail");
1724 assert_eq!(err.kind(), ErrorKind::RequestInvalid);
1725 }
1726 }
1727
1728 #[tokio::test]
1729 async fn accepts_maximum_intermediary_and_minimum_lifetime_boundaries() {
1730 let request_time = timestamp("2030-01-01T00:00:00Z");
1731 let response_time = timestamp("2030-01-01T00:00:01Z");
1732 let operation = ClientSideCredentialAccessBoundaryGranter::new(viewer_bucket_grant())
1733 .with_time(request_time)
1734 .with_time_after_request(response_time)
1735 .with_nonces([[4; AES_GCM_NONCE_BYTES]]);
1736 let output = operation
1737 .grant_credential(
1738 &Context::new().with_http_send(MockHttpSend::new([success_response(
1739 "intermediary",
1740 Some(MAX_ACCESS_TOKEN_LIFETIME.as_secs()),
1741 )])),
1742 &source_token(
1743 "source",
1744 Some(request_time + Duration::from_secs(13 * 60 * 60)),
1745 ),
1746 Some(MAX_MINIMUM_TOKEN_LIFETIME),
1747 )
1748 .await
1749 .expect("maximum feasible lifetime must succeed");
1750
1751 assert_eq!(
1752 output_token(&output).expires_at,
1753 Some(timestamp("2030-01-01T12:00:01Z"))
1754 );
1755 }
1756
1757 #[tokio::test]
1758 async fn anchors_clamps_and_requires_intermediary_expiration() {
1759 let request_time = timestamp("2030-01-01T00:00:00Z");
1760 let response_time = timestamp("2030-01-01T00:00:02Z");
1761 let source_expiry = timestamp("2030-01-01T02:00:00Z");
1762 let operation = ClientSideCredentialAccessBoundaryGranter::new(viewer_bucket_grant())
1763 .with_time(request_time)
1764 .with_time_after_request(response_time)
1765 .with_nonces([
1766 [1; AES_GCM_NONCE_BYTES],
1767 [2; AES_GCM_NONCE_BYTES],
1768 [3; AES_GCM_NONCE_BYTES],
1769 ]);
1770 let source = source_token("source", Some(source_expiry));
1771
1772 let explicit = operation
1773 .grant_credential(
1774 &Context::new().with_http_send(MockHttpSend::new([success_response(
1775 "explicit",
1776 Some(3600),
1777 )])),
1778 &source,
1779 None,
1780 )
1781 .await
1782 .expect("explicit expiration must succeed");
1783 assert_eq!(
1784 output_token(&explicit).expires_at,
1785 Some(timestamp("2030-01-01T01:00:02Z"))
1786 );
1787
1788 let missing_expiration_operation =
1789 ClientSideCredentialAccessBoundaryGranter::new(viewer_bucket_grant())
1790 .with_time(request_time)
1791 .with_time_after_request(response_time);
1792 let err = missing_expiration_operation
1793 .grant_credential(
1794 &Context::new()
1795 .with_http_send(MockHttpSend::new([success_response("inherited", None)])),
1796 &source,
1797 None,
1798 )
1799 .await
1800 .expect_err("client-issued CAB requires a service-account STS expiration");
1801 assert_eq!(err.kind(), ErrorKind::CredentialInvalid);
1802
1803 let clamped_operation =
1804 ClientSideCredentialAccessBoundaryGranter::new(viewer_bucket_grant())
1805 .with_time(request_time)
1806 .with_time_after_request(response_time)
1807 .with_nonces([[3; AES_GCM_NONCE_BYTES]]);
1808 let clamped = clamped_operation
1809 .grant_credential(
1810 &Context::new().with_http_send(MockHttpSend::new([success_response(
1811 "clamped",
1812 Some(3 * 60 * 60),
1813 )])),
1814 &source,
1815 None,
1816 )
1817 .await
1818 .expect("STS expiration must clamp to source expiration");
1819 assert_eq!(output_token(&clamped).expires_at, Some(source_expiry));
1820 }
1821
1822 #[tokio::test]
1823 async fn rejects_malformed_expiration_and_sts_responses() {
1824 let now = timestamp("2030-01-01T00:00:00Z");
1825 let source = source_token("source", Some(now + Duration::from_secs(2 * 60 * 60)));
1826 let malformed = [
1827 success_response("zero", Some(0)),
1828 success_response("too-long", Some(MAX_ACCESS_TOKEN_LIFETIME.as_secs() + 1)),
1829 response(
1830 http::StatusCode::OK,
1831 format!(
1832 r#"{{"access_token":"negative","issued_token_type":"{ACCESS_BOUNDARY_INTERMEDIARY_TOKEN_TYPE}","token_type":"Bearer","expires_in":-1,"access_boundary_session_key":"{GOOGLE_AUTH_LIBRARY_SESSION_KEY}"}}"#
1833 ),
1834 ),
1835 response(
1836 http::StatusCode::OK,
1837 br#"{"access_token":"missing-fields"}"#.as_slice(),
1838 ),
1839 success_response_with_key("bad-key", "not base64!", Some(3600)),
1840 ];
1841
1842 for response in malformed {
1843 let http = MockHttpSend::new([response]);
1844 let err = ClientSideCredentialAccessBoundaryGranter::new(viewer_bucket_grant())
1845 .with_time(now)
1846 .grant_credential(&Context::new().with_http_send(http), &source, None)
1847 .await
1848 .expect_err("malformed response must fail");
1849 assert_eq!(err.kind(), ErrorKind::Unexpected);
1850 let debug = format!("{err:?}");
1851 assert!(!debug.contains("bad-key"));
1852 assert!(!debug.contains(GOOGLE_AUTH_LIBRARY_SESSION_KEY));
1853 }
1854 }
1855
1856 #[tokio::test]
1857 async fn validates_tink_keyset_shape_and_algorithm() {
1858 let now = timestamp("2030-01-01T00:00:00Z");
1859 let source = source_token("source", Some(now + Duration::from_secs(2 * 60 * 60)));
1860 let keys = [
1861 encoded_tink_keyset(
1862 7,
1863 0,
1864 TINK_PREFIX,
1865 AES_GCM_KEY_TYPE_URL,
1866 TINK_KEY_MATERIAL_SYMMETRIC,
1867 0,
1868 vec![1; 32],
1869 ),
1870 encoded_tink_keyset(
1871 7,
1872 TINK_KEY_STATUS_ENABLED,
1873 0,
1874 AES_GCM_KEY_TYPE_URL,
1875 TINK_KEY_MATERIAL_SYMMETRIC,
1876 0,
1877 vec![1; 32],
1878 ),
1879 encoded_tink_keyset(
1880 7,
1881 TINK_KEY_STATUS_ENABLED,
1882 TINK_PREFIX,
1883 "type.googleapis.com/google.crypto.tink.ChaCha20Poly1305Key",
1884 TINK_KEY_MATERIAL_SYMMETRIC,
1885 0,
1886 vec![1; 32],
1887 ),
1888 encoded_tink_keyset(
1889 7,
1890 TINK_KEY_STATUS_ENABLED,
1891 TINK_PREFIX,
1892 AES_GCM_KEY_TYPE_URL,
1893 0,
1894 0,
1895 vec![1; 32],
1896 ),
1897 encoded_tink_keyset(
1898 7,
1899 TINK_KEY_STATUS_ENABLED,
1900 TINK_PREFIX,
1901 AES_GCM_KEY_TYPE_URL,
1902 TINK_KEY_MATERIAL_SYMMETRIC,
1903 1,
1904 vec![1; 32],
1905 ),
1906 encoded_tink_keyset(
1907 7,
1908 TINK_KEY_STATUS_ENABLED,
1909 TINK_PREFIX,
1910 AES_GCM_KEY_TYPE_URL,
1911 TINK_KEY_MATERIAL_SYMMETRIC,
1912 0,
1913 vec![1; 24],
1914 ),
1915 ];
1916
1917 for key in keys {
1918 let err =
1919 ClientSideCredentialAccessBoundaryGranter::new(viewer_bucket_grant())
1920 .with_time(now)
1921 .grant_credential(
1922 &Context::new().with_http_send(MockHttpSend::new([
1923 success_response_with_key("intermediary", &key, Some(3600)),
1924 ])),
1925 &source,
1926 None,
1927 )
1928 .await
1929 .expect_err("invalid keyset must fail");
1930 assert_eq!(err.kind(), ErrorKind::Unexpected);
1931 assert!(!format!("{err:?}").contains(&key));
1932 }
1933 }
1934
1935 #[tokio::test]
1936 async fn checks_post_io_and_post_generation_validity() {
1937 let now = timestamp("2030-01-01T00:00:00Z");
1938 let source_expiry = now + Duration::from_secs(60 * 60);
1939
1940 let post_io = ClientSideCredentialAccessBoundaryGranter::new(viewer_bucket_grant())
1941 .with_time(now)
1942 .with_time_after_request(now + Duration::from_secs(31 * 60));
1943 let err = post_io
1944 .grant_credential(
1945 &Context::new().with_http_send(MockHttpSend::new([success_response(
1946 "intermediary",
1947 Some(3600),
1948 )])),
1949 &source_token("source", Some(source_expiry)),
1950 None,
1951 )
1952 .await
1953 .expect_err("intermediary must cover the minimum after I/O");
1954 assert_eq!(err.kind(), ErrorKind::CredentialInvalid);
1955
1956 let post_generation = ClientSideCredentialAccessBoundaryGranter::new(viewer_bucket_grant())
1957 .with_time(now)
1958 .with_time_after_generation(now + Duration::from_secs(30 * 60))
1959 .with_nonces([[4; AES_GCM_NONCE_BYTES]]);
1960 let err = post_generation
1961 .grant_credential(
1962 &Context::new().with_http_send(MockHttpSend::new([success_response(
1963 "intermediary",
1964 Some(60 * 60),
1965 )])),
1966 &source_token("source", Some(now + Duration::from_secs(2 * 60 * 60))),
1967 None,
1968 )
1969 .await
1970 .expect_err("output must cover the minimum after local generation");
1971 assert_eq!(err.kind(), ErrorKind::CredentialInvalid);
1972 }
1973
1974 #[tokio::test]
1975 async fn reuses_intermediary_but_never_caches_outputs() {
1976 let now = timestamp("2030-01-01T00:00:00Z");
1977 let http = MockHttpSend::new([success_response("intermediary", Some(3600))]);
1978 let operation = ClientSideCredentialAccessBoundaryGranter::new(viewer_bucket_grant())
1979 .with_time(now)
1980 .with_nonces([[1; AES_GCM_NONCE_BYTES], [2; AES_GCM_NONCE_BYTES]]);
1981 let source = source_token("source", Some(now + Duration::from_secs(2 * 60 * 60)));
1982 let ctx = Context::new().with_http_send(http.clone());
1983
1984 let first = operation
1985 .grant_credential(&ctx, &source, None)
1986 .await
1987 .expect("first grant must succeed");
1988 let second = operation
1989 .grant_credential(&ctx, &source, None)
1990 .await
1991 .expect("second grant must succeed");
1992
1993 assert_eq!(http.calls.load(Ordering::SeqCst), 1);
1994 assert_eq!(operation.cache_len().await, 1);
1995 assert_ne!(
1996 output_token(&first).access_token,
1997 output_token(&second).access_token
1998 );
1999 assert_eq!(
2000 output_token(&first).expires_at,
2001 output_token(&second).expires_at
2002 );
2003 assert_eq!(decrypt_restrictions(&first).1, [1; AES_GCM_NONCE_BYTES]);
2004 assert_eq!(decrypt_restrictions(&second).1, [2; AES_GCM_NONCE_BYTES]);
2005 }
2006
2007 #[tokio::test]
2008 async fn refreshes_only_when_cached_intermediary_cannot_cover_minimum() {
2009 let now = timestamp("2030-01-01T00:00:00Z");
2010 let http = MockHttpSend::new([
2011 success_response("intermediary-1", Some(3600)),
2012 success_response("intermediary-2", Some(3600)),
2013 ]);
2014 let operation = ClientSideCredentialAccessBoundaryGranter::new(viewer_bucket_grant())
2015 .with_time(now)
2016 .with_nonces([
2017 [1; AES_GCM_NONCE_BYTES],
2018 [2; AES_GCM_NONCE_BYTES],
2019 [3; AES_GCM_NONCE_BYTES],
2020 ]);
2021 let source = source_token("source", Some(now + Duration::from_secs(3 * 60 * 60)));
2022 let ctx = Context::new().with_http_send(http.clone());
2023
2024 let first = operation
2025 .grant_credential(&ctx, &source, None)
2026 .await
2027 .expect("initial grant must succeed");
2028 let still_fresh = operation
2029 .clone()
2030 .with_time(now + Duration::from_secs(20 * 60))
2031 .grant_credential(&ctx, &source, None)
2032 .await
2033 .expect("fresh intermediary must be reused");
2034 let refreshed = operation
2035 .clone()
2036 .with_time(now + Duration::from_secs(31 * 60))
2037 .grant_credential(&ctx, &source, None)
2038 .await
2039 .expect("near-expiry intermediary must refresh");
2040
2041 assert_eq!(http.calls.load(Ordering::SeqCst), 2);
2042 assert_eq!(decrypt_restrictions(&first).0, "intermediary-1");
2043 assert_eq!(decrypt_restrictions(&still_fresh).0, "intermediary-1");
2044 assert_eq!(decrypt_restrictions(&refreshed).0, "intermediary-2");
2045 }
2046
2047 #[tokio::test]
2048 async fn failed_refresh_never_returns_insufficient_cached_output() {
2049 let now = timestamp("2030-01-01T00:00:00Z");
2050 let http = MockHttpSend::new([
2051 success_response("intermediary-1", Some(3600)),
2052 response(
2053 http::StatusCode::SERVICE_UNAVAILABLE,
2054 r#"{"error":"backend_error","error_description":"do not return stale material"}"#,
2055 ),
2056 ]);
2057 let operation = ClientSideCredentialAccessBoundaryGranter::new(viewer_bucket_grant())
2058 .with_time(now)
2059 .with_nonces([[1; AES_GCM_NONCE_BYTES]]);
2060 let source = source_token("source", Some(now + Duration::from_secs(3 * 60 * 60)));
2061 let ctx = Context::new().with_http_send(http.clone());
2062 operation
2063 .grant_credential(&ctx, &source, None)
2064 .await
2065 .expect("initial grant must succeed");
2066
2067 let err = operation
2068 .clone()
2069 .with_time(now + Duration::from_secs(31 * 60))
2070 .grant_credential(&ctx, &source, None)
2071 .await
2072 .expect_err("failed refresh must not return cached output");
2073 assert_eq!(err.kind(), ErrorKind::Unexpected);
2074 assert!(err.is_retryable());
2075 assert_eq!(http.calls.load(Ordering::SeqCst), 2);
2076 assert!(!format!("{err:?}").contains("do not return stale material"));
2077 }
2078
2079 #[tokio::test]
2080 async fn partitions_and_bounds_intermediary_cache() {
2081 let now = timestamp("2030-01-01T00:00:00Z");
2082 let responses = (0..INTERMEDIARY_CACHE_CAPACITY + 2)
2083 .map(|index| success_response(&format!("intermediary-{index}"), Some(3600)))
2084 .collect::<Vec<_>>();
2085 let http = MockHttpSend::new(responses);
2086 let operation = ClientSideCredentialAccessBoundaryGranter::new(viewer_bucket_grant())
2087 .with_time(now)
2088 .with_nonces(
2089 (0..INTERMEDIARY_CACHE_CAPACITY + 2)
2090 .map(|index| [index as u8; AES_GCM_NONCE_BYTES]),
2091 );
2092 let ctx = Context::new().with_http_send(http.clone());
2093 let source_expiry = now + Duration::from_secs(2 * 60 * 60);
2094
2095 for index in 0..=INTERMEDIARY_CACHE_CAPACITY {
2096 operation
2097 .grant_credential(
2098 &ctx,
2099 &source_token(&format!("source-{index}"), Some(source_expiry)),
2100 None,
2101 )
2102 .await
2103 .expect("partitioned grant must succeed");
2104 }
2105 assert_eq!(
2106 http.calls.load(Ordering::SeqCst),
2107 INTERMEDIARY_CACHE_CAPACITY + 1
2108 );
2109 assert_eq!(operation.cache_len().await, INTERMEDIARY_CACHE_CAPACITY);
2110
2111 operation
2112 .grant_credential(&ctx, &source_token("source-0", Some(source_expiry)), None)
2113 .await
2114 .expect("oldest authority must be fetched after eviction");
2115 assert_eq!(
2116 http.calls.load(Ordering::SeqCst),
2117 INTERMEDIARY_CACHE_CAPACITY + 2
2118 );
2119 assert_eq!(operation.cache_len().await, INTERMEDIARY_CACHE_CAPACITY);
2120
2121 let a = IntermediaryCacheKey {
2122 endpoint: STS_ENDPOINT,
2123 source_authority: "authority".to_string(),
2124 source_expires_at: source_expiry,
2125 };
2126 let b = IntermediaryCacheKey {
2127 endpoint: "https://sts.example.invalid/v1/token",
2128 source_authority: "authority".to_string(),
2129 source_expires_at: source_expiry,
2130 };
2131 assert!(a != b, "endpoint identity must partition cache keys");
2132 }
2133
2134 #[tokio::test]
2135 async fn source_authority_partition_reuses_each_matching_entry() {
2136 let now = timestamp("2030-01-01T00:00:00Z");
2137 let http = MockHttpSend::new([
2138 success_response("intermediary-a", Some(3600)),
2139 success_response("intermediary-b", Some(3600)),
2140 ]);
2141 let operation = ClientSideCredentialAccessBoundaryGranter::new(viewer_bucket_grant())
2142 .with_time(now)
2143 .with_nonces([
2144 [1; AES_GCM_NONCE_BYTES],
2145 [2; AES_GCM_NONCE_BYTES],
2146 [3; AES_GCM_NONCE_BYTES],
2147 ]);
2148 let ctx = Context::new().with_http_send(http.clone());
2149 let expiry = now + Duration::from_secs(2 * 60 * 60);
2150 let source_a = source_token("source-a", Some(expiry));
2151 let source_b = source_token("source-b", Some(expiry));
2152
2153 let first_a = operation
2154 .grant_credential(&ctx, &source_a, None)
2155 .await
2156 .expect("source A must succeed");
2157 let output_b = operation
2158 .grant_credential(&ctx, &source_b, None)
2159 .await
2160 .expect("source B must succeed");
2161 let second_a = operation
2162 .grant_credential(&ctx, &source_a, None)
2163 .await
2164 .expect("source A cache entry must be reused");
2165
2166 assert_eq!(http.calls.load(Ordering::SeqCst), 2);
2167 assert_eq!(operation.cache_len().await, 2);
2168 assert_eq!(decrypt_restrictions(&first_a).0, "intermediary-a");
2169 assert_eq!(decrypt_restrictions(&output_b).0, "intermediary-b");
2170 assert_eq!(decrypt_restrictions(&second_a).0, "intermediary-a");
2171 }
2172
2173 #[tokio::test]
2174 async fn unrelated_source_partitions_refresh_independently() {
2175 let now = timestamp("2030-01-01T00:00:00Z");
2176 let (http, gate) = MockHttpSend::gated([
2177 success_response("intermediary-a", Some(3600)),
2178 success_response("intermediary-b", Some(3600)),
2179 ]);
2180 let operation = ClientSideCredentialAccessBoundaryGranter::new(viewer_bucket_grant())
2181 .with_time(now)
2182 .with_nonces([[1; AES_GCM_NONCE_BYTES], [2; AES_GCM_NONCE_BYTES]]);
2183 let ctx = Context::new().with_http_send(http.clone());
2184 let expiry = now + Duration::from_secs(2 * 60 * 60);
2185
2186 let first = tokio::spawn({
2187 let operation = operation.clone();
2188 let ctx = ctx.clone();
2189 async move {
2190 operation
2191 .grant_credential(&ctx, &source_token("source-a", Some(expiry)), None)
2192 .await
2193 }
2194 });
2195 gate.wait_started().await;
2196
2197 let second = tokio::spawn({
2198 let operation = operation.clone();
2199 let ctx = ctx.clone();
2200 async move {
2201 operation
2202 .grant_credential(&ctx, &source_token("source-b", Some(expiry)), None)
2203 .await
2204 }
2205 });
2206 let second_started =
2207 tokio::time::timeout(Duration::from_secs(1), gate.wait_started()).await;
2208 gate.release_one();
2209 gate.release_one();
2210 assert!(
2211 second_started.is_ok(),
2212 "an unrelated source partition must not wait for the first STS exchange"
2213 );
2214
2215 first
2216 .await
2217 .expect("first grant task must not panic")
2218 .expect("first source partition must succeed");
2219 second
2220 .await
2221 .expect("second grant task must not panic")
2222 .expect("second source partition must succeed");
2223 assert_eq!(http.calls.load(Ordering::SeqCst), 2);
2224 assert_eq!(operation.cache_len().await, 2);
2225 assert_eq!(operation.refresh_lock_len(), 0);
2226 }
2227
2228 #[tokio::test]
2229 async fn concurrent_grants_share_successful_intermediary_refresh() {
2230 let now = timestamp("2030-01-01T00:00:00Z");
2231 let (http, gate) = MockHttpSend::gated([success_response("intermediary", Some(3600))]);
2232 let operation = ClientSideCredentialAccessBoundaryGranter::new(viewer_bucket_grant())
2233 .with_time(now)
2234 .with_nonces((0..8).map(|index| [index; AES_GCM_NONCE_BYTES]));
2235 let ctx = Context::new().with_http_send(http.clone());
2236 let source = source_token("source", Some(now + Duration::from_secs(2 * 60 * 60)));
2237 let mut tasks = Vec::new();
2238 for _ in 0..8 {
2239 let operation = operation.clone();
2240 let ctx = ctx.clone();
2241 let source = source.clone();
2242 tasks.push(tokio::spawn(async move {
2243 operation.grant_credential(&ctx, &source, None).await
2244 }));
2245 }
2246
2247 gate.wait_started().await;
2248 for _ in 0..4 {
2249 tokio::task::yield_now().await;
2250 }
2251 assert_eq!(http.calls.load(Ordering::SeqCst), 1);
2252 gate.release_one();
2253
2254 let mut tokens = HashSet::new();
2255 for task in tasks {
2256 let output = task
2257 .await
2258 .expect("grant task must not panic")
2259 .expect("concurrent grant must succeed");
2260 tokens.insert(output_token(&output).access_token.clone());
2261 }
2262 assert_eq!(tokens.len(), 8);
2263 assert_eq!(http.calls.load(Ordering::SeqCst), 1);
2264 assert_eq!(operation.cache_len().await, 1);
2265 assert_eq!(operation.refresh_lock_len(), 0);
2266 }
2267
2268 #[tokio::test]
2269 async fn waiter_retries_after_serialized_refresh_failure() {
2270 let now = timestamp("2030-01-01T00:00:00Z");
2271 let (http, gate) = MockHttpSend::gated([
2272 response(
2273 http::StatusCode::SERVICE_UNAVAILABLE,
2274 r#"{"error":"backend_error"}"#,
2275 ),
2276 success_response("intermediary", Some(3600)),
2277 ]);
2278 let operation = ClientSideCredentialAccessBoundaryGranter::new(viewer_bucket_grant())
2279 .with_time(now)
2280 .with_nonces([[3; AES_GCM_NONCE_BYTES]]);
2281 let ctx = Context::new().with_http_send(http.clone());
2282 let source = source_token("source", Some(now + Duration::from_secs(2 * 60 * 60)));
2283
2284 let leader = tokio::spawn({
2285 let operation = operation.clone();
2286 let ctx = ctx.clone();
2287 let source = source.clone();
2288 async move { operation.grant_credential(&ctx, &source, None).await }
2289 });
2290 gate.wait_started().await;
2291 let waiter = tokio::spawn({
2292 let operation = operation.clone();
2293 let ctx = ctx.clone();
2294 async move { operation.grant_credential(&ctx, &source, None).await }
2295 });
2296 for _ in 0..4 {
2297 tokio::task::yield_now().await;
2298 }
2299 assert_eq!(http.calls.load(Ordering::SeqCst), 1);
2300
2301 gate.release_one();
2302 let err = leader
2303 .await
2304 .expect("leader task must not panic")
2305 .expect_err("leader refresh must fail");
2306 assert_eq!(err.kind(), ErrorKind::Unexpected);
2307 assert!(err.is_retryable());
2308
2309 gate.wait_started().await;
2310 assert_eq!(http.calls.load(Ordering::SeqCst), 2);
2311 gate.release_one();
2312 let output = waiter
2313 .await
2314 .expect("waiter task must not panic")
2315 .expect("waiter must retry and succeed");
2316 assert_eq!(decrypt_restrictions(&output).0, "intermediary");
2317 assert_eq!(operation.cache_len().await, 1);
2318 assert_eq!(operation.refresh_lock_len(), 0);
2319 }
2320
2321 #[tokio::test]
2322 async fn cancelled_refresh_releases_partition_lock_without_caching_partial_state() {
2323 let now = timestamp("2030-01-01T00:00:00Z");
2324 let (http, gate) = MockHttpSend::gated([success_response("intermediary", Some(3600))]);
2325 let operation = ClientSideCredentialAccessBoundaryGranter::new(viewer_bucket_grant())
2326 .with_time(now)
2327 .with_nonces([[5; AES_GCM_NONCE_BYTES]]);
2328 let ctx = Context::new().with_http_send(http.clone());
2329 let source = source_token("source", Some(now + Duration::from_secs(2 * 60 * 60)));
2330
2331 let cancelled = {
2332 let operation = operation.clone();
2333 let ctx = ctx.clone();
2334 let source = source.clone();
2335 tokio::spawn(async move { operation.grant_credential(&ctx, &source, None).await })
2336 };
2337 gate.wait_started().await;
2338 cancelled.abort();
2339 assert!(
2340 cancelled
2341 .await
2342 .expect_err("task must be cancelled")
2343 .is_cancelled()
2344 );
2345 assert_eq!(operation.cache_len().await, 0);
2346 assert_eq!(operation.refresh_lock_len(), 0);
2347
2348 gate.release_one();
2349 let output = tokio::time::timeout(
2350 Duration::from_secs(2),
2351 operation.grant_credential(&ctx, &source, None),
2352 )
2353 .await
2354 .expect("retry must not deadlock")
2355 .expect("retry after cancellation must succeed");
2356 assert_eq!(decrypt_restrictions(&output).0, "intermediary");
2357 assert_eq!(http.calls.load(Ordering::SeqCst), 2);
2358 assert_eq!(operation.cache_len().await, 1);
2359 assert_eq!(operation.refresh_lock_len(), 0);
2360 }
2361
2362 #[tokio::test]
2363 async fn with_grant_shares_intermediary_for_distinct_authorization() {
2364 let now = timestamp("2030-01-01T00:00:00Z");
2365 let http = MockHttpSend::new([success_response("intermediary", Some(3600))]);
2366 let bucket = ClientSideCredentialAccessBoundaryGranter::new(viewer_bucket_grant())
2367 .with_time(now)
2368 .with_nonces([[1; AES_GCM_NONCE_BYTES], [2; AES_GCM_NONCE_BYTES]]);
2369 let prefix = bucket
2370 .clone()
2371 .with_grant(CredentialAccessBoundaryGrant::for_object_prefix(
2372 "example-bucket",
2373 "tenant/",
2374 CredentialAccessBoundaryPermissions::OBJECT_VIEWER,
2375 ));
2376 let ctx = Context::new().with_http_send(http.clone());
2377 let source = source_token("source", Some(now + Duration::from_secs(2 * 60 * 60)));
2378
2379 let bucket_output = bucket
2380 .grant_credential(&ctx, &source, None)
2381 .await
2382 .expect("bucket grant must succeed");
2383 let prefix_output = prefix
2384 .grant_credential(&ctx, &source, None)
2385 .await
2386 .expect("prefix grant must succeed");
2387 assert_eq!(http.calls.load(Ordering::SeqCst), 1);
2388 assert!(
2389 decrypt_restrictions(&bucket_output).2.access_boundary_rules[0]
2390 .compiled_availability_condition
2391 .is_none()
2392 );
2393 assert!(
2394 decrypt_restrictions(&prefix_output).2.access_boundary_rules[0]
2395 .compiled_availability_condition
2396 .is_some()
2397 );
2398 }
2399
2400 #[tokio::test]
2401 async fn granter_lifecycle_caches_source_and_intermediary_but_not_outputs() {
2402 let now = Timestamp::now();
2403 let source = source_token("source", Some(now + Duration::from_secs(2 * 60 * 60)));
2404 let (provider, provider_calls) = FixedCredentialProvider::new(source);
2405 let http = MockHttpSend::new([success_response("intermediary", Some(3600))]);
2406 let granter = Granter::new(
2407 Context::new().with_http_send(http.clone()),
2408 provider,
2409 ClientSideCredentialAccessBoundaryGranter::new(viewer_bucket_grant())
2410 .with_nonces([[1; AES_GCM_NONCE_BYTES], [2; AES_GCM_NONCE_BYTES]]),
2411 );
2412
2413 let first = granter.grant(None).await.expect("first grant must succeed");
2414 let second = granter
2415 .grant(None)
2416 .await
2417 .expect("second grant must succeed");
2418 assert_eq!(provider_calls.load(Ordering::SeqCst), 1);
2419 assert_eq!(http.calls.load(Ordering::SeqCst), 1);
2420 assert_ne!(
2421 output_token(&first).access_token,
2422 output_token(&second).access_token
2423 );
2424 }
2425
2426 #[tokio::test]
2427 async fn generated_token_is_consumed_by_existing_google_signer() {
2428 let now = Timestamp::now();
2429 let operation = ClientSideCredentialAccessBoundaryGranter::new(viewer_bucket_grant())
2430 .with_nonces([[8; AES_GCM_NONCE_BYTES]]);
2431 let output = operation
2432 .grant_credential(
2433 &Context::new().with_http_send(MockHttpSend::new([success_response(
2434 "intermediary",
2435 Some(3600),
2436 )])),
2437 &source_token("source", Some(now + Duration::from_secs(2 * 60 * 60))),
2438 None,
2439 )
2440 .await
2441 .expect("client-side grant must succeed");
2442 let expected = format!("Bearer {}", output_token(&output).access_token);
2443 let (provider, _) = FixedCredentialProvider::new(output);
2444 let signer = Signer::new(Context::new(), provider, RequestSigner::new("storage"));
2445 let mut parts =
2446 http::Request::get("https://storage.googleapis.com/example-bucket/customer/object")
2447 .body(())
2448 .expect("request must build")
2449 .into_parts()
2450 .0;
2451
2452 signer
2453 .sign(&mut parts, None)
2454 .await
2455 .expect("existing signer must consume client-issued CAB token");
2456 assert_eq!(parts.headers[AUTHORIZATION], expected);
2457 assert!(parts.headers[AUTHORIZATION].is_sensitive());
2458 }
2459
2460 #[tokio::test]
2461 async fn sts_and_transport_errors_are_semantic_and_redacted() {
2462 let now = timestamp("2030-01-01T00:00:00Z");
2463 let source = source_token(
2464 "source-secret",
2465 Some(now + Duration::from_secs(2 * 60 * 60)),
2466 );
2467 let operation =
2468 ClientSideCredentialAccessBoundaryGranter::new(viewer_bucket_grant()).with_time(now);
2469 let err = operation
2470 .grant_credential(
2471 &Context::new().with_http_send(MockHttpSend::new([response(
2472 http::StatusCode::BAD_REQUEST,
2473 r#"{"error":"invalid_grant","error_description":"source-secret raw-response-secret"}"#,
2474 )])),
2475 &source,
2476 None,
2477 )
2478 .await
2479 .expect_err("STS error must fail");
2480 assert_eq!(err.kind(), ErrorKind::CredentialInvalid);
2481 assert!(format!("{err:?}").contains("invalid_grant"));
2482 assert!(!format!("{err:?}").contains("source-secret"));
2483 assert!(!format!("{err:?}").contains("raw-response-secret"));
2484
2485 let err = operation
2486 .grant_credential(
2487 &Context::new().with_http_send(SecretTransportError),
2488 &source,
2489 None,
2490 )
2491 .await
2492 .expect_err("transport error must fail");
2493 assert_eq!(err.kind(), ErrorKind::Unexpected);
2494 assert!(err.is_retryable());
2495 assert!(!format!("{err:?}").contains("source-secret"));
2496 assert!(!format!("{err:?}").contains("transport retained"));
2497 }
2498
2499 #[tokio::test]
2500 async fn debug_redacts_grant_request_and_all_credential_material() {
2501 let now = timestamp("2030-01-01T00:00:00Z");
2502 let grant = CredentialAccessBoundaryGrant::for_object_prefix(
2503 "sensitive-bucket",
2504 "sensitive/prefix",
2505 CredentialAccessBoundaryPermissions::OBJECT_ADMIN,
2506 );
2507 let operation = ClientSideCredentialAccessBoundaryGranter::new(grant.clone())
2508 .with_time(now)
2509 .with_nonces([[6; AES_GCM_NONCE_BYTES]]);
2510 let source = source_token(
2511 "source-secret",
2512 Some(now + Duration::from_secs(2 * 60 * 60)),
2513 );
2514 let http = MockHttpSend::new([success_response("intermediary-secret", Some(3600))]);
2515 let output = operation
2516 .grant_credential(&Context::new().with_http_send(http.clone()), &source, None)
2517 .await
2518 .expect("client-side grant must succeed");
2519 let request = http
2520 .requests()
2521 .into_iter()
2522 .next()
2523 .expect("request must be captured");
2524 let key =
2525 TinkAesGcmKey::parse(GOOGLE_AUTH_LIBRARY_SESSION_KEY).expect("official key must parse");
2526
2527 for (debug, secret) in [
2528 (format!("{grant:?}"), "sensitive-bucket"),
2529 (format!("{grant:?}"), "sensitive/prefix"),
2530 (format!("{operation:?}"), "sensitive-bucket"),
2531 (format!("{source:?}"), "source-secret"),
2532 (format!("{request:?}"), "source-secret"),
2533 (format!("{output:?}"), "intermediary-secret"),
2534 (format!("{key:?}"), "cc7c"),
2535 (
2536 format!("{:?}", CredentialAccessBoundaryPermissions::OBJECT_ADMIN),
2537 "storage.objectAdmin",
2538 ),
2539 ] {
2540 assert!(!debug.contains(secret), "{debug}");
2541 }
2542 assert_eq!(
2543 format!("{operation:?}"),
2544 "ClientSideCredentialAccessBoundaryGranter { .. }"
2545 );
2546 }
2547}