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 base64::Engine;
26use base64::engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD};
27use form_urlencoded::Serializer;
28use futures::lock::Mutex;
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 bytes::Bytes;
1081 use http::header::{AUTHORIZATION, HeaderMap};
1082 use reqsign_core::{ErrorKind, Granter, HttpSend, ProvideCredential, Signer, time::Timestamp};
1083 use tokio::sync::Semaphore;
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
1124 .acquire()
1125 .await
1126 .expect("started semaphore must remain open")
1127 .forget();
1128 }
1129
1130 fn release_one(&self) {
1131 self.release.add_permits(1);
1132 }
1133 }
1134
1135 #[derive(Clone)]
1136 struct MockHttpSend {
1137 calls: Arc<AtomicUsize>,
1138 requests: Arc<StdMutex<Vec<CapturedRequest>>>,
1139 responses: Arc<StdMutex<VecDeque<http::Response<Bytes>>>>,
1140 gate: Option<RequestGate>,
1141 }
1142
1143 impl Debug for MockHttpSend {
1144 fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
1145 f.debug_struct("MockHttpSend").finish_non_exhaustive()
1146 }
1147 }
1148
1149 impl MockHttpSend {
1150 fn new(responses: impl IntoIterator<Item = http::Response<Bytes>>) -> Self {
1151 Self {
1152 calls: Arc::new(AtomicUsize::new(0)),
1153 requests: Arc::new(StdMutex::new(Vec::new())),
1154 responses: Arc::new(StdMutex::new(responses.into_iter().collect())),
1155 gate: None,
1156 }
1157 }
1158
1159 fn gated(
1160 responses: impl IntoIterator<Item = http::Response<Bytes>>,
1161 ) -> (Self, RequestGate) {
1162 let gate = RequestGate {
1163 started: Arc::new(Semaphore::new(0)),
1164 release: Arc::new(Semaphore::new(0)),
1165 };
1166 let mut http = Self::new(responses);
1167 http.gate = Some(gate.clone());
1168 (http, gate)
1169 }
1170
1171 fn requests(&self) -> Vec<CapturedRequest> {
1172 self.requests.lock().expect("lock poisoned").clone()
1173 }
1174 }
1175
1176 impl HttpSend for MockHttpSend {
1177 async fn http_send(&self, request: http::Request<Bytes>) -> Result<http::Response<Bytes>> {
1178 self.calls.fetch_add(1, Ordering::SeqCst);
1179 let (parts, body) = request.into_parts();
1180 self.requests
1181 .lock()
1182 .expect("lock poisoned")
1183 .push(CapturedRequest {
1184 method: parts.method,
1185 uri: parts.uri,
1186 headers: parts.headers,
1187 body: body.to_vec(),
1188 });
1189 if let Some(gate) = &self.gate {
1190 gate.started.add_permits(1);
1191 gate.release
1192 .acquire()
1193 .await
1194 .expect("release semaphore must remain open")
1195 .forget();
1196 }
1197 self.responses
1198 .lock()
1199 .expect("lock poisoned")
1200 .pop_front()
1201 .ok_or_else(|| Error::unexpected("mock response queue is empty"))
1202 }
1203 }
1204
1205 #[derive(Debug)]
1206 struct SecretTransportError;
1207
1208 impl HttpSend for SecretTransportError {
1209 async fn http_send(&self, _request: http::Request<Bytes>) -> Result<http::Response<Bytes>> {
1210 Err(
1211 Error::unexpected("transport retained subject_token=source-secret")
1212 .set_retryable(true),
1213 )
1214 }
1215 }
1216
1217 #[derive(Clone)]
1218 struct FixedCredentialProvider {
1219 credential: Credential,
1220 calls: Arc<AtomicUsize>,
1221 }
1222
1223 impl Debug for FixedCredentialProvider {
1224 fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
1225 f.debug_struct("FixedCredentialProvider")
1226 .finish_non_exhaustive()
1227 }
1228 }
1229
1230 impl FixedCredentialProvider {
1231 fn new(credential: Credential) -> (Self, Arc<AtomicUsize>) {
1232 let calls = Arc::new(AtomicUsize::new(0));
1233 (
1234 Self {
1235 credential,
1236 calls: calls.clone(),
1237 },
1238 calls,
1239 )
1240 }
1241 }
1242
1243 impl ProvideCredential for FixedCredentialProvider {
1244 type Credential = Credential;
1245
1246 async fn provide_credential(&self, _ctx: &Context) -> Result<Option<Self::Credential>> {
1247 self.calls.fetch_add(1, Ordering::SeqCst);
1248 Ok(Some(self.credential.clone()))
1249 }
1250 }
1251
1252 fn timestamp(value: &str) -> Timestamp {
1253 value.parse().expect("timestamp must be valid")
1254 }
1255
1256 fn source_token(access_token: &str, expires_at: Option<Timestamp>) -> Credential {
1257 Credential::with_token(Token {
1258 access_token: access_token.to_string(),
1259 expires_at,
1260 })
1261 }
1262
1263 fn viewer_bucket_grant() -> CredentialAccessBoundaryGrant {
1264 CredentialAccessBoundaryGrant::for_bucket(
1265 "example-bucket",
1266 CredentialAccessBoundaryPermissions::OBJECT_VIEWER,
1267 )
1268 }
1269
1270 fn response(status: http::StatusCode, body: impl Into<Bytes>) -> http::Response<Bytes> {
1271 http::Response::builder()
1272 .status(status)
1273 .body(body.into())
1274 .expect("response must build")
1275 }
1276
1277 fn success_response(
1278 intermediary_token: &str,
1279 expires_in: Option<u64>,
1280 ) -> http::Response<Bytes> {
1281 success_response_with_key(
1282 intermediary_token,
1283 GOOGLE_AUTH_LIBRARY_SESSION_KEY,
1284 expires_in,
1285 )
1286 }
1287
1288 fn success_response_with_key(
1289 intermediary_token: &str,
1290 session_key: &str,
1291 expires_in: Option<u64>,
1292 ) -> http::Response<Bytes> {
1293 let mut value = serde_json::json!({
1294 "access_token": intermediary_token,
1295 "issued_token_type": ACCESS_BOUNDARY_INTERMEDIARY_TOKEN_TYPE,
1296 "token_type": "Bearer",
1297 "access_boundary_session_key": session_key,
1298 });
1299 if let Some(expires_in) = expires_in {
1300 value["expires_in"] = expires_in.into();
1301 }
1302 response(
1303 http::StatusCode::OK,
1304 serde_json::to_vec(&value).expect("response JSON must serialize"),
1305 )
1306 }
1307
1308 fn form_fields(request: &CapturedRequest) -> BTreeMap<String, String> {
1309 form_urlencoded::parse(&request.body).into_owned().collect()
1310 }
1311
1312 fn output_token(credential: &Credential) -> &Token {
1313 assert!(credential.service_account.is_none());
1314 credential
1315 .token
1316 .as_ref()
1317 .expect("granted credential must contain a token")
1318 }
1319
1320 fn decrypt_restrictions(
1321 credential: &Credential,
1322 ) -> (String, [u8; AES_GCM_NONCE_BYTES], ClientSideAccessBoundary) {
1323 let token = &output_token(credential).access_token;
1324 let (intermediary, encoded_restrictions) = token
1325 .split_once('.')
1326 .expect("client-issued token must contain two parts");
1327 let encrypted = URL_SAFE_NO_PAD
1328 .decode(encoded_restrictions)
1329 .expect("restrictions must be unpadded base64url");
1330 let key = TinkAesGcmKey::parse(GOOGLE_AUTH_LIBRARY_SESSION_KEY)
1331 .expect("official session key must parse");
1332 assert!(encrypted.starts_with(&key.output_prefix));
1333 let nonce_offset = key.output_prefix.len();
1334 let nonce: [u8; AES_GCM_NONCE_BYTES] = encrypted
1335 [nonce_offset..nonce_offset + AES_GCM_NONCE_BYTES]
1336 .try_into()
1337 .expect("nonce must have the expected length");
1338 let ciphertext = &encrypted[nonce_offset + AES_GCM_NONCE_BYTES..];
1339 let plaintext = match key.key_value.len() {
1340 16 => Aes128Gcm::new_from_slice(&key.key_value)
1341 .expect("AES-128 key must initialize")
1342 .decrypt(&Array(nonce), ciphertext),
1343 32 => Aes256Gcm::new_from_slice(&key.key_value)
1344 .expect("AES-256 key must initialize")
1345 .decrypt(&Array(nonce), ciphertext),
1346 _ => panic!("unexpected test key length"),
1347 }
1348 .expect("restrictions must decrypt");
1349 let restrictions = ClientSideAccessBoundary::decode(plaintext.as_slice())
1350 .expect("restrictions must be a CAB protobuf");
1351 (intermediary.to_string(), nonce, restrictions)
1352 }
1353
1354 fn encoded_tink_keyset(
1355 key_id: u32,
1356 status: i32,
1357 output_prefix_type: i32,
1358 type_url: &str,
1359 material_type: i32,
1360 version: u32,
1361 key_value: Vec<u8>,
1362 ) -> String {
1363 let value = AesGcmKeyProto { version, key_value }.encode_to_vec();
1364 STANDARD.encode(
1365 TinkKeyset {
1366 primary_key_id: key_id,
1367 keys: vec![TinkKeysetKey {
1368 key_data: Some(TinkKeyData {
1369 type_url: type_url.to_string(),
1370 value,
1371 key_material_type: material_type,
1372 }),
1373 status,
1374 key_id,
1375 output_prefix_type,
1376 }],
1377 }
1378 .encode_to_vec(),
1379 )
1380 }
1381
1382 #[test]
1383 fn matches_google_auth_library_tink_aes_gcm_vector() {
1384 let key = TinkAesGcmKey::parse(GOOGLE_AUTH_LIBRARY_SESSION_KEY)
1385 .expect("official session key must parse");
1386 assert_eq!(
1387 key.output_prefix,
1388 [0x01, 0xa1, 0x61, 0x42, 0x76],
1389 "Tink prefix must contain the official primary key id"
1390 );
1391 assert_eq!(
1392 key.key_value.as_slice(),
1393 &[
1394 0xcc, 0x7c, 0xb3, 0x2b, 0xc6, 0x20, 0x61, 0xae, 0xe7, 0x2b, 0xeb, 0x76, 0xaf, 0xc8,
1395 0xd1, 0x0f, 0x59, 0x58, 0x84, 0x75, 0xa2, 0xa2, 0x57, 0x16, 0x70, 0xc5, 0xc4, 0x7b,
1396 0xb0, 0x5f, 0x84, 0x84,
1397 ]
1398 );
1399
1400 let nonce = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11];
1401 let encrypted = key
1402 .encrypt(b"restriction", &nonce)
1403 .expect("vector must encrypt");
1404 assert_eq!(
1405 URL_SAFE_NO_PAD.encode(encrypted),
1406 "AaFhQnYAAQIDBAUGBwgJCgvIc9p0EXthr8WYQl6sKvdE-kCKA-SIx7I0T1E"
1407 );
1408 }
1409
1410 #[test]
1411 fn refresh_lock_registry_does_not_retain_sequential_partitions() {
1412 let state = IntermediaryState::default();
1413 let expires_at = timestamp("2030-01-01T01:00:00Z");
1414 for index in 0..1024 {
1415 let lease = state.refresh_lock(&IntermediaryCacheKey {
1416 endpoint: STS_ENDPOINT,
1417 source_authority: format!("source-{index}"),
1418 source_expires_at: expires_at,
1419 });
1420 assert_eq!(state.refresh_lock_len(), 1);
1421 drop(lease);
1422 assert_eq!(state.refresh_lock_len(), 0);
1423 }
1424 }
1425
1426 #[tokio::test]
1427 async fn sends_exact_official_intermediary_exchange_shape() {
1428 let now = timestamp("2030-01-01T00:00:00Z");
1429 let source_expiry = timestamp("2030-01-01T02:00:00Z");
1430 let http = MockHttpSend::new([success_response("intermediary-token", Some(3600))]);
1431 let operation = ClientSideCredentialAccessBoundaryGranter::new(viewer_bucket_grant())
1432 .with_time(now)
1433 .with_nonces([[0; AES_GCM_NONCE_BYTES]]);
1434 let source = source_token("source+token/with=reserved", Some(source_expiry));
1435
1436 operation
1437 .grant_credential(&Context::new().with_http_send(http.clone()), &source, None)
1438 .await
1439 .expect("client-side grant must succeed");
1440
1441 let requests = http.requests();
1442 assert_eq!(requests.len(), 1);
1443 let request = &requests[0];
1444 assert_eq!(request.method, http::Method::POST);
1445 assert_eq!(request.uri, STS_ENDPOINT);
1446 assert_eq!(request.headers[ACCEPT], "application/json");
1447 assert_eq!(
1448 request.headers[CONTENT_TYPE],
1449 "application/x-www-form-urlencoded"
1450 );
1451 assert!(!request.headers.contains_key(AUTHORIZATION));
1452 assert_eq!(
1453 String::from_utf8(request.body.clone()).expect("form body must be UTF-8"),
1454 concat!(
1455 "grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Atoken-exchange",
1456 "&requested_token_type=urn%3Aietf%3Aparams%3Aoauth%3Atoken-type%3A",
1457 "access_boundary_intermediary_token",
1458 "&subject_token_type=urn%3Aietf%3Aparams%3Aoauth%3Atoken-type%3Aaccess_token",
1459 "&subject_token=source%2Btoken%2Fwith%3Dreserved"
1460 )
1461 );
1462 let fields = form_fields(request);
1463 assert_eq!(fields.len(), 4);
1464 assert_eq!(fields["grant_type"], TOKEN_EXCHANGE_GRANT_TYPE);
1465 assert_eq!(
1466 fields["requested_token_type"],
1467 ACCESS_BOUNDARY_INTERMEDIARY_TOKEN_TYPE
1468 );
1469 assert_eq!(fields["subject_token_type"], ACCESS_TOKEN_TYPE);
1470 assert_eq!(fields["subject_token"], "source+token/with=reserved");
1471 assert!(!fields.contains_key("options"));
1472 assert!(!fields.contains_key("audience"));
1473 assert!(!fields.contains_key("scope"));
1474 }
1475
1476 #[tokio::test]
1477 async fn serializes_typed_prefix_grant_as_compiled_cel_protobuf() {
1478 let now = timestamp("2030-01-01T00:00:00Z");
1479 let grant = CredentialAccessBoundaryGrant::for_object_prefix(
1480 "example-bucket",
1481 "customer \"雪\"/",
1482 CredentialAccessBoundaryPermissions::OBJECT_VIEWER
1483 | CredentialAccessBoundaryPermissions::OBJECT_CREATOR,
1484 );
1485 let operation = ClientSideCredentialAccessBoundaryGranter::new(grant)
1486 .with_time(now)
1487 .with_nonces([[7; AES_GCM_NONCE_BYTES]]);
1488 let http = MockHttpSend::new([success_response("intermediary", Some(3600))]);
1489 let output = operation
1490 .grant_credential(
1491 &Context::new().with_http_send(http),
1492 &source_token("source", Some(now + Duration::from_secs(2 * 60 * 60))),
1493 None,
1494 )
1495 .await
1496 .expect("client-side grant must succeed");
1497
1498 let (intermediary, nonce, restrictions) = decrypt_restrictions(&output);
1499 assert_eq!(intermediary, "intermediary");
1500 assert_eq!(nonce, [7; AES_GCM_NONCE_BYTES]);
1501 assert_eq!(restrictions.access_boundary_rules.len(), 1);
1502 let rule = &restrictions.access_boundary_rules[0];
1503 assert_eq!(
1504 rule.available_resource,
1505 "//storage.googleapis.com/projects/_/buckets/example-bucket"
1506 );
1507 assert_eq!(
1508 rule.available_permissions,
1509 [
1510 "inRole:roles/storage.objectViewer",
1511 "inRole:roles/storage.objectCreator",
1512 ]
1513 );
1514
1515 let root = rule
1516 .compiled_availability_condition
1517 .as_ref()
1518 .expect("prefix rule must have a compiled condition");
1519 let root_call = root.call_expr.as_ref().expect("root must be a call");
1520 assert_eq!(root_call.function, "_||_");
1521 assert!(root_call.target.is_none());
1522 assert_eq!(root_call.args.len(), 2);
1523
1524 let resource_call = root_call.args[0]
1525 .call_expr
1526 .as_ref()
1527 .expect("first branch must be a call");
1528 assert_eq!(resource_call.function, "startsWith");
1529 let resource_select = resource_call
1530 .target
1531 .as_deref()
1532 .and_then(|expr| expr.select_expr.as_ref())
1533 .expect("resource startsWith target must be a select");
1534 assert_eq!(resource_select.field, "name");
1535 assert_eq!(
1536 resource_select
1537 .operand
1538 .as_deref()
1539 .and_then(|expr| expr.ident_expr.as_ref())
1540 .map(|ident| ident.name.as_str()),
1541 Some("resource")
1542 );
1543 assert_eq!(
1544 resource_call.args[0]
1545 .const_expr
1546 .as_ref()
1547 .and_then(|constant| constant.string_value.as_deref()),
1548 Some("projects/_/buckets/example-bucket/objects/customer \"雪\"/")
1549 );
1550
1551 let list_call = root_call.args[1]
1552 .call_expr
1553 .as_ref()
1554 .expect("second branch must be a call");
1555 assert_eq!(list_call.function, "startsWith");
1556 let get_attribute = list_call
1557 .target
1558 .as_deref()
1559 .and_then(|expr| expr.call_expr.as_ref())
1560 .expect("list startsWith target must call getAttribute");
1561 assert_eq!(get_attribute.function, "getAttribute");
1562 assert_eq!(
1563 get_attribute
1564 .target
1565 .as_deref()
1566 .and_then(|expr| expr.ident_expr.as_ref())
1567 .map(|ident| ident.name.as_str()),
1568 Some("api")
1569 );
1570 assert_eq!(
1571 get_attribute.args[0]
1572 .const_expr
1573 .as_ref()
1574 .and_then(|constant| constant.string_value.as_deref()),
1575 Some("storage.googleapis.com/objectListPrefix")
1576 );
1577 assert_eq!(
1578 get_attribute.args[1]
1579 .const_expr
1580 .as_ref()
1581 .and_then(|constant| constant.string_value.as_deref()),
1582 Some("")
1583 );
1584 assert_eq!(
1585 list_call.args[0]
1586 .const_expr
1587 .as_ref()
1588 .and_then(|constant| constant.string_value.as_deref()),
1589 Some("customer \"雪\"/")
1590 );
1591 }
1592
1593 #[tokio::test]
1594 async fn bucket_wide_and_multiple_rules_preserve_union_order() {
1595 let now = timestamp("2030-01-01T00:00:00Z");
1596 let grant = viewer_bucket_grant().with_object_prefix_rule(
1597 "second-bucket",
1598 "tenant/",
1599 CredentialAccessBoundaryPermissions::OBJECT_USER,
1600 );
1601 let operation = ClientSideCredentialAccessBoundaryGranter::new(grant)
1602 .with_time(now)
1603 .with_nonces([[9; AES_GCM_NONCE_BYTES]]);
1604 let output = operation
1605 .grant_credential(
1606 &Context::new().with_http_send(MockHttpSend::new([success_response(
1607 "intermediary",
1608 Some(3600),
1609 )])),
1610 &source_token("source", Some(now + Duration::from_secs(2 * 60 * 60))),
1611 None,
1612 )
1613 .await
1614 .expect("client-side grant must succeed");
1615
1616 let (_, _, restrictions) = decrypt_restrictions(&output);
1617 assert_eq!(restrictions.access_boundary_rules.len(), 2);
1618 assert_eq!(
1619 restrictions.access_boundary_rules[0].available_resource,
1620 "//storage.googleapis.com/projects/_/buckets/example-bucket"
1621 );
1622 assert!(
1623 restrictions.access_boundary_rules[0]
1624 .compiled_availability_condition
1625 .is_none()
1626 );
1627 assert_eq!(
1628 restrictions.access_boundary_rules[1].available_resource,
1629 "//storage.googleapis.com/projects/_/buckets/second-bucket"
1630 );
1631 assert!(
1632 restrictions.access_boundary_rules[1]
1633 .compiled_availability_condition
1634 .is_some()
1635 );
1636 }
1637
1638 #[tokio::test]
1639 async fn rejects_invalid_grant_before_io() {
1640 let now = timestamp("2030-01-01T00:00:00Z");
1641 let http = MockHttpSend::new([]);
1642 let operation = ClientSideCredentialAccessBoundaryGranter::new(
1643 CredentialAccessBoundaryGrant::for_object_prefix(
1644 "example-bucket",
1645 "",
1646 CredentialAccessBoundaryPermissions::OBJECT_VIEWER,
1647 ),
1648 )
1649 .with_time(now);
1650 let err = operation
1651 .grant_credential(
1652 &Context::new().with_http_send(http.clone()),
1653 &source_token("source", Some(now + Duration::from_secs(2 * 60 * 60))),
1654 None,
1655 )
1656 .await
1657 .expect_err("invalid grant must fail");
1658
1659 assert_eq!(err.kind(), ErrorKind::RequestInvalid);
1660 assert_eq!(http.calls.load(Ordering::SeqCst), 0);
1661 }
1662
1663 #[tokio::test]
1664 async fn rejects_incompatible_sources_and_lifetimes_before_io() {
1665 let now = timestamp("2030-01-01T00:00:00Z");
1666 let http = MockHttpSend::new([]);
1667 let ctx = Context::new().with_http_send(http.clone());
1668 let operation =
1669 ClientSideCredentialAccessBoundaryGranter::new(viewer_bucket_grant()).with_time(now);
1670 let valid_token = Token {
1671 access_token: "source".to_string(),
1672 expires_at: Some(now + Duration::from_secs(2 * 60 * 60)),
1673 };
1674 let invalid_sources = [
1675 Credential::with_service_account(ServiceAccount {
1676 private_key: "private".to_string(),
1677 client_email: "service@example.com".to_string(),
1678 }),
1679 Credential {
1680 service_account: Some(ServiceAccount {
1681 private_key: "private".to_string(),
1682 client_email: "service@example.com".to_string(),
1683 }),
1684 token: Some(valid_token),
1685 },
1686 source_token("", Some(now + Duration::from_secs(2 * 60 * 60))),
1687 source_token("source", None),
1688 source_token("source", Some(now + DEFAULT_MINIMUM_TOKEN_LIFETIME)),
1689 ];
1690 for source in invalid_sources {
1691 let err = operation
1692 .grant_credential(&ctx, &source, None)
1693 .await
1694 .expect_err("incompatible source must fail");
1695 assert_eq!(err.kind(), ErrorKind::CredentialInvalid);
1696 }
1697
1698 let valid_source = source_token("source", Some(now + Duration::from_secs(13 * 60 * 60)));
1699 for lifetime in [
1700 Duration::ZERO,
1701 MAX_ACCESS_TOKEN_LIFETIME,
1702 MAX_ACCESS_TOKEN_LIFETIME + Duration::from_secs(1),
1703 ] {
1704 let err = operation
1705 .grant_credential(&ctx, &valid_source, Some(lifetime))
1706 .await
1707 .expect_err("invalid minimum lifetime must fail");
1708 assert_eq!(err.kind(), ErrorKind::RequestInvalid);
1709 }
1710 assert_eq!(http.calls.load(Ordering::SeqCst), 0);
1711 }
1712
1713 #[test]
1714 fn validates_exact_minimum_lifetime_boundary_after_rounding() {
1715 let operation = ClientSideCredentialAccessBoundaryGranter::new(viewer_bucket_grant());
1716 assert_eq!(
1717 operation
1718 .effective_minimum_lifetime(Some(MAX_MINIMUM_TOKEN_LIFETIME))
1719 .expect("the largest feasible whole-second lifetime must be accepted"),
1720 MAX_MINIMUM_TOKEN_LIFETIME
1721 );
1722
1723 for lifetime in [
1724 MAX_MINIMUM_TOKEN_LIFETIME + Duration::from_nanos(1),
1725 MAX_ACCESS_TOKEN_LIFETIME,
1726 Duration::MAX,
1727 ] {
1728 let err = operation
1729 .effective_minimum_lifetime(Some(lifetime))
1730 .expect_err("a lifetime that rounds to twelve hours must fail");
1731 assert_eq!(err.kind(), ErrorKind::RequestInvalid);
1732 }
1733 }
1734
1735 #[tokio::test]
1736 async fn accepts_maximum_intermediary_and_minimum_lifetime_boundaries() {
1737 let request_time = timestamp("2030-01-01T00:00:00Z");
1738 let response_time = timestamp("2030-01-01T00:00:01Z");
1739 let operation = ClientSideCredentialAccessBoundaryGranter::new(viewer_bucket_grant())
1740 .with_time(request_time)
1741 .with_time_after_request(response_time)
1742 .with_nonces([[4; AES_GCM_NONCE_BYTES]]);
1743 let output = operation
1744 .grant_credential(
1745 &Context::new().with_http_send(MockHttpSend::new([success_response(
1746 "intermediary",
1747 Some(MAX_ACCESS_TOKEN_LIFETIME.as_secs()),
1748 )])),
1749 &source_token(
1750 "source",
1751 Some(request_time + Duration::from_secs(13 * 60 * 60)),
1752 ),
1753 Some(MAX_MINIMUM_TOKEN_LIFETIME),
1754 )
1755 .await
1756 .expect("maximum feasible lifetime must succeed");
1757
1758 assert_eq!(
1759 output_token(&output).expires_at,
1760 Some(timestamp("2030-01-01T12:00:01Z"))
1761 );
1762 }
1763
1764 #[tokio::test]
1765 async fn anchors_clamps_and_requires_intermediary_expiration() {
1766 let request_time = timestamp("2030-01-01T00:00:00Z");
1767 let response_time = timestamp("2030-01-01T00:00:02Z");
1768 let source_expiry = timestamp("2030-01-01T02:00:00Z");
1769 let operation = ClientSideCredentialAccessBoundaryGranter::new(viewer_bucket_grant())
1770 .with_time(request_time)
1771 .with_time_after_request(response_time)
1772 .with_nonces([
1773 [1; AES_GCM_NONCE_BYTES],
1774 [2; AES_GCM_NONCE_BYTES],
1775 [3; AES_GCM_NONCE_BYTES],
1776 ]);
1777 let source = source_token("source", Some(source_expiry));
1778
1779 let explicit = operation
1780 .grant_credential(
1781 &Context::new().with_http_send(MockHttpSend::new([success_response(
1782 "explicit",
1783 Some(3600),
1784 )])),
1785 &source,
1786 None,
1787 )
1788 .await
1789 .expect("explicit expiration must succeed");
1790 assert_eq!(
1791 output_token(&explicit).expires_at,
1792 Some(timestamp("2030-01-01T01:00:02Z"))
1793 );
1794
1795 let missing_expiration_operation =
1796 ClientSideCredentialAccessBoundaryGranter::new(viewer_bucket_grant())
1797 .with_time(request_time)
1798 .with_time_after_request(response_time);
1799 let err = missing_expiration_operation
1800 .grant_credential(
1801 &Context::new()
1802 .with_http_send(MockHttpSend::new([success_response("inherited", None)])),
1803 &source,
1804 None,
1805 )
1806 .await
1807 .expect_err("client-issued CAB requires a service-account STS expiration");
1808 assert_eq!(err.kind(), ErrorKind::CredentialInvalid);
1809
1810 let clamped_operation =
1811 ClientSideCredentialAccessBoundaryGranter::new(viewer_bucket_grant())
1812 .with_time(request_time)
1813 .with_time_after_request(response_time)
1814 .with_nonces([[3; AES_GCM_NONCE_BYTES]]);
1815 let clamped = clamped_operation
1816 .grant_credential(
1817 &Context::new().with_http_send(MockHttpSend::new([success_response(
1818 "clamped",
1819 Some(3 * 60 * 60),
1820 )])),
1821 &source,
1822 None,
1823 )
1824 .await
1825 .expect("STS expiration must clamp to source expiration");
1826 assert_eq!(output_token(&clamped).expires_at, Some(source_expiry));
1827 }
1828
1829 #[tokio::test]
1830 async fn rejects_malformed_expiration_and_sts_responses() {
1831 let now = timestamp("2030-01-01T00:00:00Z");
1832 let source = source_token("source", Some(now + Duration::from_secs(2 * 60 * 60)));
1833 let malformed = [
1834 success_response("zero", Some(0)),
1835 success_response("too-long", Some(MAX_ACCESS_TOKEN_LIFETIME.as_secs() + 1)),
1836 response(
1837 http::StatusCode::OK,
1838 format!(
1839 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}"}}"#
1840 ),
1841 ),
1842 response(
1843 http::StatusCode::OK,
1844 br#"{"access_token":"missing-fields"}"#.as_slice(),
1845 ),
1846 success_response_with_key("bad-key", "not base64!", Some(3600)),
1847 ];
1848
1849 for response in malformed {
1850 let http = MockHttpSend::new([response]);
1851 let err = ClientSideCredentialAccessBoundaryGranter::new(viewer_bucket_grant())
1852 .with_time(now)
1853 .grant_credential(&Context::new().with_http_send(http), &source, None)
1854 .await
1855 .expect_err("malformed response must fail");
1856 assert_eq!(err.kind(), ErrorKind::Unexpected);
1857 let debug = format!("{err:?}");
1858 assert!(!debug.contains("bad-key"));
1859 assert!(!debug.contains(GOOGLE_AUTH_LIBRARY_SESSION_KEY));
1860 }
1861 }
1862
1863 #[tokio::test]
1864 async fn validates_tink_keyset_shape_and_algorithm() {
1865 let now = timestamp("2030-01-01T00:00:00Z");
1866 let source = source_token("source", Some(now + Duration::from_secs(2 * 60 * 60)));
1867 let keys = [
1868 encoded_tink_keyset(
1869 7,
1870 0,
1871 TINK_PREFIX,
1872 AES_GCM_KEY_TYPE_URL,
1873 TINK_KEY_MATERIAL_SYMMETRIC,
1874 0,
1875 vec![1; 32],
1876 ),
1877 encoded_tink_keyset(
1878 7,
1879 TINK_KEY_STATUS_ENABLED,
1880 0,
1881 AES_GCM_KEY_TYPE_URL,
1882 TINK_KEY_MATERIAL_SYMMETRIC,
1883 0,
1884 vec![1; 32],
1885 ),
1886 encoded_tink_keyset(
1887 7,
1888 TINK_KEY_STATUS_ENABLED,
1889 TINK_PREFIX,
1890 "type.googleapis.com/google.crypto.tink.ChaCha20Poly1305Key",
1891 TINK_KEY_MATERIAL_SYMMETRIC,
1892 0,
1893 vec![1; 32],
1894 ),
1895 encoded_tink_keyset(
1896 7,
1897 TINK_KEY_STATUS_ENABLED,
1898 TINK_PREFIX,
1899 AES_GCM_KEY_TYPE_URL,
1900 0,
1901 0,
1902 vec![1; 32],
1903 ),
1904 encoded_tink_keyset(
1905 7,
1906 TINK_KEY_STATUS_ENABLED,
1907 TINK_PREFIX,
1908 AES_GCM_KEY_TYPE_URL,
1909 TINK_KEY_MATERIAL_SYMMETRIC,
1910 1,
1911 vec![1; 32],
1912 ),
1913 encoded_tink_keyset(
1914 7,
1915 TINK_KEY_STATUS_ENABLED,
1916 TINK_PREFIX,
1917 AES_GCM_KEY_TYPE_URL,
1918 TINK_KEY_MATERIAL_SYMMETRIC,
1919 0,
1920 vec![1; 24],
1921 ),
1922 ];
1923
1924 for key in keys {
1925 let err =
1926 ClientSideCredentialAccessBoundaryGranter::new(viewer_bucket_grant())
1927 .with_time(now)
1928 .grant_credential(
1929 &Context::new().with_http_send(MockHttpSend::new([
1930 success_response_with_key("intermediary", &key, Some(3600)),
1931 ])),
1932 &source,
1933 None,
1934 )
1935 .await
1936 .expect_err("invalid keyset must fail");
1937 assert_eq!(err.kind(), ErrorKind::Unexpected);
1938 assert!(!format!("{err:?}").contains(&key));
1939 }
1940 }
1941
1942 #[tokio::test]
1943 async fn checks_post_io_and_post_generation_validity() {
1944 let now = timestamp("2030-01-01T00:00:00Z");
1945 let source_expiry = now + Duration::from_secs(60 * 60);
1946
1947 let post_io = ClientSideCredentialAccessBoundaryGranter::new(viewer_bucket_grant())
1948 .with_time(now)
1949 .with_time_after_request(now + Duration::from_secs(31 * 60));
1950 let err = post_io
1951 .grant_credential(
1952 &Context::new().with_http_send(MockHttpSend::new([success_response(
1953 "intermediary",
1954 Some(3600),
1955 )])),
1956 &source_token("source", Some(source_expiry)),
1957 None,
1958 )
1959 .await
1960 .expect_err("intermediary must cover the minimum after I/O");
1961 assert_eq!(err.kind(), ErrorKind::CredentialInvalid);
1962
1963 let post_generation = ClientSideCredentialAccessBoundaryGranter::new(viewer_bucket_grant())
1964 .with_time(now)
1965 .with_time_after_generation(now + Duration::from_secs(30 * 60))
1966 .with_nonces([[4; AES_GCM_NONCE_BYTES]]);
1967 let err = post_generation
1968 .grant_credential(
1969 &Context::new().with_http_send(MockHttpSend::new([success_response(
1970 "intermediary",
1971 Some(60 * 60),
1972 )])),
1973 &source_token("source", Some(now + Duration::from_secs(2 * 60 * 60))),
1974 None,
1975 )
1976 .await
1977 .expect_err("output must cover the minimum after local generation");
1978 assert_eq!(err.kind(), ErrorKind::CredentialInvalid);
1979 }
1980
1981 #[tokio::test]
1982 async fn reuses_intermediary_but_never_caches_outputs() {
1983 let now = timestamp("2030-01-01T00:00:00Z");
1984 let http = MockHttpSend::new([success_response("intermediary", Some(3600))]);
1985 let operation = ClientSideCredentialAccessBoundaryGranter::new(viewer_bucket_grant())
1986 .with_time(now)
1987 .with_nonces([[1; AES_GCM_NONCE_BYTES], [2; AES_GCM_NONCE_BYTES]]);
1988 let source = source_token("source", Some(now + Duration::from_secs(2 * 60 * 60)));
1989 let ctx = Context::new().with_http_send(http.clone());
1990
1991 let first = operation
1992 .grant_credential(&ctx, &source, None)
1993 .await
1994 .expect("first grant must succeed");
1995 let second = operation
1996 .grant_credential(&ctx, &source, None)
1997 .await
1998 .expect("second grant must succeed");
1999
2000 assert_eq!(http.calls.load(Ordering::SeqCst), 1);
2001 assert_eq!(operation.cache_len().await, 1);
2002 assert_ne!(
2003 output_token(&first).access_token,
2004 output_token(&second).access_token
2005 );
2006 assert_eq!(
2007 output_token(&first).expires_at,
2008 output_token(&second).expires_at
2009 );
2010 assert_eq!(decrypt_restrictions(&first).1, [1; AES_GCM_NONCE_BYTES]);
2011 assert_eq!(decrypt_restrictions(&second).1, [2; AES_GCM_NONCE_BYTES]);
2012 }
2013
2014 #[tokio::test]
2015 async fn refreshes_only_when_cached_intermediary_cannot_cover_minimum() {
2016 let now = timestamp("2030-01-01T00:00:00Z");
2017 let http = MockHttpSend::new([
2018 success_response("intermediary-1", Some(3600)),
2019 success_response("intermediary-2", Some(3600)),
2020 ]);
2021 let operation = ClientSideCredentialAccessBoundaryGranter::new(viewer_bucket_grant())
2022 .with_time(now)
2023 .with_nonces([
2024 [1; AES_GCM_NONCE_BYTES],
2025 [2; AES_GCM_NONCE_BYTES],
2026 [3; AES_GCM_NONCE_BYTES],
2027 ]);
2028 let source = source_token("source", Some(now + Duration::from_secs(3 * 60 * 60)));
2029 let ctx = Context::new().with_http_send(http.clone());
2030
2031 let first = operation
2032 .grant_credential(&ctx, &source, None)
2033 .await
2034 .expect("initial grant must succeed");
2035 let still_fresh = operation
2036 .clone()
2037 .with_time(now + Duration::from_secs(20 * 60))
2038 .grant_credential(&ctx, &source, None)
2039 .await
2040 .expect("fresh intermediary must be reused");
2041 let refreshed = operation
2042 .clone()
2043 .with_time(now + Duration::from_secs(31 * 60))
2044 .grant_credential(&ctx, &source, None)
2045 .await
2046 .expect("near-expiry intermediary must refresh");
2047
2048 assert_eq!(http.calls.load(Ordering::SeqCst), 2);
2049 assert_eq!(decrypt_restrictions(&first).0, "intermediary-1");
2050 assert_eq!(decrypt_restrictions(&still_fresh).0, "intermediary-1");
2051 assert_eq!(decrypt_restrictions(&refreshed).0, "intermediary-2");
2052 }
2053
2054 #[tokio::test]
2055 async fn failed_refresh_never_returns_insufficient_cached_output() {
2056 let now = timestamp("2030-01-01T00:00:00Z");
2057 let http = MockHttpSend::new([
2058 success_response("intermediary-1", Some(3600)),
2059 response(
2060 http::StatusCode::SERVICE_UNAVAILABLE,
2061 r#"{"error":"backend_error","error_description":"do not return stale material"}"#,
2062 ),
2063 ]);
2064 let operation = ClientSideCredentialAccessBoundaryGranter::new(viewer_bucket_grant())
2065 .with_time(now)
2066 .with_nonces([[1; AES_GCM_NONCE_BYTES]]);
2067 let source = source_token("source", Some(now + Duration::from_secs(3 * 60 * 60)));
2068 let ctx = Context::new().with_http_send(http.clone());
2069 operation
2070 .grant_credential(&ctx, &source, None)
2071 .await
2072 .expect("initial grant must succeed");
2073
2074 let err = operation
2075 .clone()
2076 .with_time(now + Duration::from_secs(31 * 60))
2077 .grant_credential(&ctx, &source, None)
2078 .await
2079 .expect_err("failed refresh must not return cached output");
2080 assert_eq!(err.kind(), ErrorKind::Unexpected);
2081 assert!(err.is_retryable());
2082 assert_eq!(http.calls.load(Ordering::SeqCst), 2);
2083 assert!(!format!("{err:?}").contains("do not return stale material"));
2084 }
2085
2086 #[tokio::test]
2087 async fn partitions_and_bounds_intermediary_cache() {
2088 let now = timestamp("2030-01-01T00:00:00Z");
2089 let responses = (0..INTERMEDIARY_CACHE_CAPACITY + 2)
2090 .map(|index| success_response(&format!("intermediary-{index}"), Some(3600)))
2091 .collect::<Vec<_>>();
2092 let http = MockHttpSend::new(responses);
2093 let operation = ClientSideCredentialAccessBoundaryGranter::new(viewer_bucket_grant())
2094 .with_time(now)
2095 .with_nonces(
2096 (0..INTERMEDIARY_CACHE_CAPACITY + 2)
2097 .map(|index| [index as u8; AES_GCM_NONCE_BYTES]),
2098 );
2099 let ctx = Context::new().with_http_send(http.clone());
2100 let source_expiry = now + Duration::from_secs(2 * 60 * 60);
2101
2102 for index in 0..=INTERMEDIARY_CACHE_CAPACITY {
2103 operation
2104 .grant_credential(
2105 &ctx,
2106 &source_token(&format!("source-{index}"), Some(source_expiry)),
2107 None,
2108 )
2109 .await
2110 .expect("partitioned grant must succeed");
2111 }
2112 assert_eq!(
2113 http.calls.load(Ordering::SeqCst),
2114 INTERMEDIARY_CACHE_CAPACITY + 1
2115 );
2116 assert_eq!(operation.cache_len().await, INTERMEDIARY_CACHE_CAPACITY);
2117
2118 operation
2119 .grant_credential(&ctx, &source_token("source-0", Some(source_expiry)), None)
2120 .await
2121 .expect("oldest authority must be fetched after eviction");
2122 assert_eq!(
2123 http.calls.load(Ordering::SeqCst),
2124 INTERMEDIARY_CACHE_CAPACITY + 2
2125 );
2126 assert_eq!(operation.cache_len().await, INTERMEDIARY_CACHE_CAPACITY);
2127
2128 let a = IntermediaryCacheKey {
2129 endpoint: STS_ENDPOINT,
2130 source_authority: "authority".to_string(),
2131 source_expires_at: source_expiry,
2132 };
2133 let b = IntermediaryCacheKey {
2134 endpoint: "https://sts.example.invalid/v1/token",
2135 source_authority: "authority".to_string(),
2136 source_expires_at: source_expiry,
2137 };
2138 assert!(a != b, "endpoint identity must partition cache keys");
2139 }
2140
2141 #[tokio::test]
2142 async fn source_authority_partition_reuses_each_matching_entry() {
2143 let now = timestamp("2030-01-01T00:00:00Z");
2144 let http = MockHttpSend::new([
2145 success_response("intermediary-a", Some(3600)),
2146 success_response("intermediary-b", Some(3600)),
2147 ]);
2148 let operation = ClientSideCredentialAccessBoundaryGranter::new(viewer_bucket_grant())
2149 .with_time(now)
2150 .with_nonces([
2151 [1; AES_GCM_NONCE_BYTES],
2152 [2; AES_GCM_NONCE_BYTES],
2153 [3; AES_GCM_NONCE_BYTES],
2154 ]);
2155 let ctx = Context::new().with_http_send(http.clone());
2156 let expiry = now + Duration::from_secs(2 * 60 * 60);
2157 let source_a = source_token("source-a", Some(expiry));
2158 let source_b = source_token("source-b", Some(expiry));
2159
2160 let first_a = operation
2161 .grant_credential(&ctx, &source_a, None)
2162 .await
2163 .expect("source A must succeed");
2164 let output_b = operation
2165 .grant_credential(&ctx, &source_b, None)
2166 .await
2167 .expect("source B must succeed");
2168 let second_a = operation
2169 .grant_credential(&ctx, &source_a, None)
2170 .await
2171 .expect("source A cache entry must be reused");
2172
2173 assert_eq!(http.calls.load(Ordering::SeqCst), 2);
2174 assert_eq!(operation.cache_len().await, 2);
2175 assert_eq!(decrypt_restrictions(&first_a).0, "intermediary-a");
2176 assert_eq!(decrypt_restrictions(&output_b).0, "intermediary-b");
2177 assert_eq!(decrypt_restrictions(&second_a).0, "intermediary-a");
2178 }
2179
2180 #[tokio::test]
2181 async fn unrelated_source_partitions_refresh_independently() {
2182 let now = timestamp("2030-01-01T00:00:00Z");
2183 let (http, gate) = MockHttpSend::gated([
2184 success_response("intermediary-a", Some(3600)),
2185 success_response("intermediary-b", Some(3600)),
2186 ]);
2187 let operation = ClientSideCredentialAccessBoundaryGranter::new(viewer_bucket_grant())
2188 .with_time(now)
2189 .with_nonces([[1; AES_GCM_NONCE_BYTES], [2; AES_GCM_NONCE_BYTES]]);
2190 let ctx = Context::new().with_http_send(http.clone());
2191 let expiry = now + Duration::from_secs(2 * 60 * 60);
2192
2193 let first = tokio::spawn({
2194 let operation = operation.clone();
2195 let ctx = ctx.clone();
2196 async move {
2197 operation
2198 .grant_credential(&ctx, &source_token("source-a", Some(expiry)), None)
2199 .await
2200 }
2201 });
2202 gate.wait_started().await;
2203
2204 let second = tokio::spawn({
2205 let operation = operation.clone();
2206 let ctx = ctx.clone();
2207 async move {
2208 operation
2209 .grant_credential(&ctx, &source_token("source-b", Some(expiry)), None)
2210 .await
2211 }
2212 });
2213 let second_started =
2214 tokio::time::timeout(Duration::from_secs(1), gate.wait_started()).await;
2215 gate.release_one();
2216 gate.release_one();
2217 assert!(
2218 second_started.is_ok(),
2219 "an unrelated source partition must not wait for the first STS exchange"
2220 );
2221
2222 first
2223 .await
2224 .expect("first grant task must not panic")
2225 .expect("first source partition must succeed");
2226 second
2227 .await
2228 .expect("second grant task must not panic")
2229 .expect("second source partition must succeed");
2230 assert_eq!(http.calls.load(Ordering::SeqCst), 2);
2231 assert_eq!(operation.cache_len().await, 2);
2232 assert_eq!(operation.refresh_lock_len(), 0);
2233 }
2234
2235 #[tokio::test]
2236 async fn concurrent_grants_share_successful_intermediary_refresh() {
2237 let now = timestamp("2030-01-01T00:00:00Z");
2238 let (http, gate) = MockHttpSend::gated([success_response("intermediary", Some(3600))]);
2239 let operation = ClientSideCredentialAccessBoundaryGranter::new(viewer_bucket_grant())
2240 .with_time(now)
2241 .with_nonces((0..8).map(|index| [index; AES_GCM_NONCE_BYTES]));
2242 let ctx = Context::new().with_http_send(http.clone());
2243 let source = source_token("source", Some(now + Duration::from_secs(2 * 60 * 60)));
2244 let mut tasks = Vec::new();
2245 for _ in 0..8 {
2246 let operation = operation.clone();
2247 let ctx = ctx.clone();
2248 let source = source.clone();
2249 tasks.push(tokio::spawn(async move {
2250 operation.grant_credential(&ctx, &source, None).await
2251 }));
2252 }
2253
2254 gate.wait_started().await;
2255 for _ in 0..4 {
2256 tokio::task::yield_now().await;
2257 }
2258 assert_eq!(http.calls.load(Ordering::SeqCst), 1);
2259 gate.release_one();
2260
2261 let mut tokens = HashSet::new();
2262 for task in tasks {
2263 let output = task
2264 .await
2265 .expect("grant task must not panic")
2266 .expect("concurrent grant must succeed");
2267 tokens.insert(output_token(&output).access_token.clone());
2268 }
2269 assert_eq!(tokens.len(), 8);
2270 assert_eq!(http.calls.load(Ordering::SeqCst), 1);
2271 assert_eq!(operation.cache_len().await, 1);
2272 assert_eq!(operation.refresh_lock_len(), 0);
2273 }
2274
2275 #[tokio::test]
2276 async fn waiter_retries_after_serialized_refresh_failure() {
2277 let now = timestamp("2030-01-01T00:00:00Z");
2278 let (http, gate) = MockHttpSend::gated([
2279 response(
2280 http::StatusCode::SERVICE_UNAVAILABLE,
2281 r#"{"error":"backend_error"}"#,
2282 ),
2283 success_response("intermediary", Some(3600)),
2284 ]);
2285 let operation = ClientSideCredentialAccessBoundaryGranter::new(viewer_bucket_grant())
2286 .with_time(now)
2287 .with_nonces([[3; AES_GCM_NONCE_BYTES]]);
2288 let ctx = Context::new().with_http_send(http.clone());
2289 let source = source_token("source", Some(now + Duration::from_secs(2 * 60 * 60)));
2290
2291 let leader = tokio::spawn({
2292 let operation = operation.clone();
2293 let ctx = ctx.clone();
2294 let source = source.clone();
2295 async move { operation.grant_credential(&ctx, &source, None).await }
2296 });
2297 gate.wait_started().await;
2298 let waiter = tokio::spawn({
2299 let operation = operation.clone();
2300 let ctx = ctx.clone();
2301 async move { operation.grant_credential(&ctx, &source, None).await }
2302 });
2303 for _ in 0..4 {
2304 tokio::task::yield_now().await;
2305 }
2306 assert_eq!(http.calls.load(Ordering::SeqCst), 1);
2307
2308 gate.release_one();
2309 let err = leader
2310 .await
2311 .expect("leader task must not panic")
2312 .expect_err("leader refresh must fail");
2313 assert_eq!(err.kind(), ErrorKind::Unexpected);
2314 assert!(err.is_retryable());
2315
2316 gate.wait_started().await;
2317 assert_eq!(http.calls.load(Ordering::SeqCst), 2);
2318 gate.release_one();
2319 let output = waiter
2320 .await
2321 .expect("waiter task must not panic")
2322 .expect("waiter must retry and succeed");
2323 assert_eq!(decrypt_restrictions(&output).0, "intermediary");
2324 assert_eq!(operation.cache_len().await, 1);
2325 assert_eq!(operation.refresh_lock_len(), 0);
2326 }
2327
2328 #[tokio::test]
2329 async fn cancelled_refresh_releases_partition_lock_without_caching_partial_state() {
2330 let now = timestamp("2030-01-01T00:00:00Z");
2331 let (http, gate) = MockHttpSend::gated([success_response("intermediary", Some(3600))]);
2332 let operation = ClientSideCredentialAccessBoundaryGranter::new(viewer_bucket_grant())
2333 .with_time(now)
2334 .with_nonces([[5; AES_GCM_NONCE_BYTES]]);
2335 let ctx = Context::new().with_http_send(http.clone());
2336 let source = source_token("source", Some(now + Duration::from_secs(2 * 60 * 60)));
2337
2338 let cancelled = {
2339 let operation = operation.clone();
2340 let ctx = ctx.clone();
2341 let source = source.clone();
2342 tokio::spawn(async move { operation.grant_credential(&ctx, &source, None).await })
2343 };
2344 gate.wait_started().await;
2345 cancelled.abort();
2346 assert!(
2347 cancelled
2348 .await
2349 .expect_err("task must be cancelled")
2350 .is_cancelled()
2351 );
2352 assert_eq!(operation.cache_len().await, 0);
2353 assert_eq!(operation.refresh_lock_len(), 0);
2354
2355 gate.release_one();
2356 let output = tokio::time::timeout(
2357 Duration::from_secs(2),
2358 operation.grant_credential(&ctx, &source, None),
2359 )
2360 .await
2361 .expect("retry must not deadlock")
2362 .expect("retry after cancellation must succeed");
2363 assert_eq!(decrypt_restrictions(&output).0, "intermediary");
2364 assert_eq!(http.calls.load(Ordering::SeqCst), 2);
2365 assert_eq!(operation.cache_len().await, 1);
2366 assert_eq!(operation.refresh_lock_len(), 0);
2367 }
2368
2369 #[tokio::test]
2370 async fn with_grant_shares_intermediary_for_distinct_authorization() {
2371 let now = timestamp("2030-01-01T00:00:00Z");
2372 let http = MockHttpSend::new([success_response("intermediary", Some(3600))]);
2373 let bucket = ClientSideCredentialAccessBoundaryGranter::new(viewer_bucket_grant())
2374 .with_time(now)
2375 .with_nonces([[1; AES_GCM_NONCE_BYTES], [2; AES_GCM_NONCE_BYTES]]);
2376 let prefix = bucket
2377 .clone()
2378 .with_grant(CredentialAccessBoundaryGrant::for_object_prefix(
2379 "example-bucket",
2380 "tenant/",
2381 CredentialAccessBoundaryPermissions::OBJECT_VIEWER,
2382 ));
2383 let ctx = Context::new().with_http_send(http.clone());
2384 let source = source_token("source", Some(now + Duration::from_secs(2 * 60 * 60)));
2385
2386 let bucket_output = bucket
2387 .grant_credential(&ctx, &source, None)
2388 .await
2389 .expect("bucket grant must succeed");
2390 let prefix_output = prefix
2391 .grant_credential(&ctx, &source, None)
2392 .await
2393 .expect("prefix grant must succeed");
2394 assert_eq!(http.calls.load(Ordering::SeqCst), 1);
2395 assert!(
2396 decrypt_restrictions(&bucket_output).2.access_boundary_rules[0]
2397 .compiled_availability_condition
2398 .is_none()
2399 );
2400 assert!(
2401 decrypt_restrictions(&prefix_output).2.access_boundary_rules[0]
2402 .compiled_availability_condition
2403 .is_some()
2404 );
2405 }
2406
2407 #[tokio::test]
2408 async fn granter_lifecycle_caches_source_and_intermediary_but_not_outputs() {
2409 let now = Timestamp::now();
2410 let source = source_token("source", Some(now + Duration::from_secs(2 * 60 * 60)));
2411 let (provider, provider_calls) = FixedCredentialProvider::new(source);
2412 let http = MockHttpSend::new([success_response("intermediary", Some(3600))]);
2413 let granter = Granter::new(
2414 Context::new().with_http_send(http.clone()),
2415 provider,
2416 ClientSideCredentialAccessBoundaryGranter::new(viewer_bucket_grant())
2417 .with_nonces([[1; AES_GCM_NONCE_BYTES], [2; AES_GCM_NONCE_BYTES]]),
2418 );
2419
2420 let first = granter.grant(None).await.expect("first grant must succeed");
2421 let second = granter
2422 .grant(None)
2423 .await
2424 .expect("second grant must succeed");
2425 assert_eq!(provider_calls.load(Ordering::SeqCst), 1);
2426 assert_eq!(http.calls.load(Ordering::SeqCst), 1);
2427 assert_ne!(
2428 output_token(&first).access_token,
2429 output_token(&second).access_token
2430 );
2431 }
2432
2433 #[tokio::test]
2434 async fn generated_token_is_consumed_by_existing_google_signer() {
2435 let now = Timestamp::now();
2436 let operation = ClientSideCredentialAccessBoundaryGranter::new(viewer_bucket_grant())
2437 .with_nonces([[8; AES_GCM_NONCE_BYTES]]);
2438 let output = operation
2439 .grant_credential(
2440 &Context::new().with_http_send(MockHttpSend::new([success_response(
2441 "intermediary",
2442 Some(3600),
2443 )])),
2444 &source_token("source", Some(now + Duration::from_secs(2 * 60 * 60))),
2445 None,
2446 )
2447 .await
2448 .expect("client-side grant must succeed");
2449 let expected = format!("Bearer {}", output_token(&output).access_token);
2450 let (provider, _) = FixedCredentialProvider::new(output);
2451 let signer = Signer::new(Context::new(), provider, RequestSigner::new("storage"));
2452 let mut parts =
2453 http::Request::get("https://storage.googleapis.com/example-bucket/customer/object")
2454 .body(())
2455 .expect("request must build")
2456 .into_parts()
2457 .0;
2458
2459 signer
2460 .sign(&mut parts, None)
2461 .await
2462 .expect("existing signer must consume client-issued CAB token");
2463 assert_eq!(parts.headers[AUTHORIZATION], expected);
2464 assert!(parts.headers[AUTHORIZATION].is_sensitive());
2465 }
2466
2467 #[tokio::test]
2468 async fn sts_and_transport_errors_are_semantic_and_redacted() {
2469 let now = timestamp("2030-01-01T00:00:00Z");
2470 let source = source_token(
2471 "source-secret",
2472 Some(now + Duration::from_secs(2 * 60 * 60)),
2473 );
2474 let operation =
2475 ClientSideCredentialAccessBoundaryGranter::new(viewer_bucket_grant()).with_time(now);
2476 let err = operation
2477 .grant_credential(
2478 &Context::new().with_http_send(MockHttpSend::new([response(
2479 http::StatusCode::BAD_REQUEST,
2480 r#"{"error":"invalid_grant","error_description":"source-secret raw-response-secret"}"#,
2481 )])),
2482 &source,
2483 None,
2484 )
2485 .await
2486 .expect_err("STS error must fail");
2487 assert_eq!(err.kind(), ErrorKind::CredentialInvalid);
2488 assert!(format!("{err:?}").contains("invalid_grant"));
2489 assert!(!format!("{err:?}").contains("source-secret"));
2490 assert!(!format!("{err:?}").contains("raw-response-secret"));
2491
2492 let err = operation
2493 .grant_credential(
2494 &Context::new().with_http_send(SecretTransportError),
2495 &source,
2496 None,
2497 )
2498 .await
2499 .expect_err("transport error must fail");
2500 assert_eq!(err.kind(), ErrorKind::Unexpected);
2501 assert!(err.is_retryable());
2502 assert!(!format!("{err:?}").contains("source-secret"));
2503 assert!(!format!("{err:?}").contains("transport retained"));
2504 }
2505
2506 #[tokio::test]
2507 async fn debug_redacts_grant_request_and_all_credential_material() {
2508 let now = timestamp("2030-01-01T00:00:00Z");
2509 let grant = CredentialAccessBoundaryGrant::for_object_prefix(
2510 "sensitive-bucket",
2511 "sensitive/prefix",
2512 CredentialAccessBoundaryPermissions::OBJECT_ADMIN,
2513 );
2514 let operation = ClientSideCredentialAccessBoundaryGranter::new(grant.clone())
2515 .with_time(now)
2516 .with_nonces([[6; AES_GCM_NONCE_BYTES]]);
2517 let source = source_token(
2518 "source-secret",
2519 Some(now + Duration::from_secs(2 * 60 * 60)),
2520 );
2521 let http = MockHttpSend::new([success_response("intermediary-secret", Some(3600))]);
2522 let output = operation
2523 .grant_credential(&Context::new().with_http_send(http.clone()), &source, None)
2524 .await
2525 .expect("client-side grant must succeed");
2526 let request = http
2527 .requests()
2528 .into_iter()
2529 .next()
2530 .expect("request must be captured");
2531 let key =
2532 TinkAesGcmKey::parse(GOOGLE_AUTH_LIBRARY_SESSION_KEY).expect("official key must parse");
2533
2534 for (debug, secret) in [
2535 (format!("{grant:?}"), "sensitive-bucket"),
2536 (format!("{grant:?}"), "sensitive/prefix"),
2537 (format!("{operation:?}"), "sensitive-bucket"),
2538 (format!("{source:?}"), "source-secret"),
2539 (format!("{request:?}"), "source-secret"),
2540 (format!("{output:?}"), "intermediary-secret"),
2541 (format!("{key:?}"), "cc7c"),
2542 (
2543 format!("{:?}", CredentialAccessBoundaryPermissions::OBJECT_ADMIN),
2544 "storage.objectAdmin",
2545 ),
2546 ] {
2547 assert!(!debug.contains(secret), "{debug}");
2548 }
2549 assert_eq!(
2550 format!("{operation:?}"),
2551 "ClientSideCredentialAccessBoundaryGranter { .. }"
2552 );
2553 }
2554}