1use std::sync::Arc;
29
30use boatramp_core::env::EnvSource;
31use boatramp_core::envelope::KeyEnvelope;
32use boatramp_core::kv::KvStore;
33use boatramp_core::project::ProjectRef;
34use boatramp_core::secret_store::SecretStore;
35
36#[derive(Clone)]
41pub struct SealedS3Credential {
42 access_key_id: String,
43 secret_access_key: String,
44}
45
46impl SealedS3Credential {
47 #[cfg_attr(not(feature = "s3"), allow(dead_code))]
49 pub fn access_key_id(&self) -> &str {
50 &self.access_key_id
51 }
52
53 #[cfg_attr(not(feature = "s3"), allow(dead_code))]
56 pub fn secret_access_key(&self) -> &str {
57 &self.secret_access_key
58 }
59
60 #[cfg_attr(not(feature = "s3"), allow(dead_code))]
63 pub fn as_pair(&self) -> (String, String) {
64 (self.access_key_id.clone(), self.secret_access_key.clone())
65 }
66}
67
68impl std::fmt::Debug for SealedS3Credential {
73 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
74 #[cfg(feature = "s3-sealed-cred-gate-mutation")]
77 if gate_mutation::env_on("BOATRAMP_S3SEALED_MUTATE_PLAIN_DEBUG") {
78 return f
79 .debug_struct("SealedS3Credential")
80 .field("access_key_id", &self.access_key_id)
81 .field("secret_access_key", &self.secret_access_key)
82 .finish();
83 }
84 f.debug_struct("SealedS3Credential")
85 .field("access_key_id", &self.access_key_id)
86 .field("secret_access_key", &"<redacted>")
87 .finish()
88 }
89}
90
91#[cfg(feature = "s3-sealed-cred-gate-mutation")]
96mod gate_mutation {
97 pub(super) fn env_on(name: &str) -> bool {
99 std::env::var(name)
100 .map(|v| !v.is_empty() && v != "0")
101 .unwrap_or(false)
102 }
103}
104
105#[derive(Debug, PartialEq, Eq)]
109pub enum S3CredentialError {
110 EmptyAccessKeyId,
112 EmptySecret,
116 BoatrampRefOnCluster(String),
120 NoEnvelope,
123 MissingBoatrampSecret(String),
125 EnvRefNotPermitted(String),
128 EnvVarUnset(String),
130 UnsupportedScheme(String),
132 Backend(String),
134 NotUtf8,
136}
137
138impl std::fmt::Display for S3CredentialError {
139 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
140 match self {
141 Self::EmptyAccessKeyId => write!(
142 f,
143 "[serve.s3_credential]: `access_key_id` must not be empty"
144 ),
145 Self::EmptySecret => write!(
146 f,
147 "[serve.s3_credential]: the resolved `secret_access_key` is empty — refusing to build \
148 a credential that would fail SigV4 signing at request-time (403); seal a non-empty \
149 secret or set the env var to a non-empty value"
150 ),
151 Self::BoatrampRefOnCluster(name) => write!(
152 f,
153 "[serve.s3_credential]: `secret_access_key` → boatramp:{name} is not supported on a \
154 cluster (the control-plane KV isn't available at blob-build time); use `env:<VAR>`"
155 ),
156 Self::NoEnvelope => write!(
157 f,
158 "[serve.s3_credential]: `secret_access_key` is a sealed `boatramp:`/`env:` ref but no \
159 `[secrets]` envelope is configured — refusing to fall back to the ambient AWS env \
160 chain (fail-closed); configure `[secrets]` or remove the source"
161 ),
162 Self::MissingBoatrampSecret(name) => write!(
163 f,
164 "[serve.s3_credential]: `secret_access_key` → boatramp:{name} is not set in the \
165 sealed secret store (default project); seal it with `boatramp secrets set`"
166 ),
167 Self::EnvRefNotPermitted(var) => write!(
168 f,
169 "[serve.s3_credential]: `secret_access_key` host-env ref {var:?} is not permitted \
170 under the multi-tenant posture (it would read the operator's environment); enable \
171 `allow_env_secret_refs` or use a `boatramp:<name>` sealed ref"
172 ),
173 Self::EnvVarUnset(var) => write!(
174 f,
175 "[serve.s3_credential]: `secret_access_key` env var {var:?} is not set"
176 ),
177 Self::UnsupportedScheme(scheme) => write!(
178 f,
179 "[serve.s3_credential]: `secret_access_key` uses the {scheme:?} scheme, which is not \
180 supported (use `boatramp:<name>` or `env:<VAR>`)"
181 ),
182 Self::Backend(msg) => write!(f, "[serve.s3_credential]: {msg}"),
183 Self::NotUtf8 => write!(
184 f,
185 "[serve.s3_credential]: the resolved `secret_access_key` is not valid UTF-8"
186 ),
187 }
188 }
189}
190
191impl std::error::Error for S3CredentialError {}
192
193enum SecretRef<'a> {
197 Env(&'a str),
199 Boatramp(&'a str),
201 Unsupported(&'a str),
203}
204
205fn parse_secret_ref(secret_ref: &str) -> SecretRef<'_> {
206 match secret_ref.split_once(':') {
207 Some(("env", host_var)) => SecretRef::Env(host_var),
208 Some(("boatramp", name)) => SecretRef::Boatramp(name),
209 Some((scheme, _)) => SecretRef::Unsupported(scheme),
210 None => SecretRef::Env(secret_ref),
211 }
212}
213
214pub async fn resolve_s3_credential(
228 config: &crate::config::S3CredentialConfig,
229 kv: Arc<dyn KvStore>,
230 envelope: Option<Arc<dyn KeyEnvelope>>,
231 allow_env_secret_refs: bool,
232 env_source: &dyn EnvSource,
233) -> Result<SealedS3Credential, S3CredentialError> {
234 let access_key_id = config.access_key_id.trim();
235 if access_key_id.is_empty() {
236 return Err(S3CredentialError::EmptyAccessKeyId);
237 }
238 let secret_access_key = match parse_secret_ref(&config.secret_access_key) {
239 SecretRef::Boatramp(name) => {
240 #[cfg(feature = "s3-sealed-cred-gate-mutation")]
244 if gate_mutation::env_on("BOATRAMP_S3SEALED_MUTATE_READ_ENV") {
245 return Ok(SealedS3Credential {
246 access_key_id: access_key_id.to_string(),
247 secret_access_key: env_source.get("AWS_SECRET_ACCESS_KEY").unwrap_or_default(),
248 });
249 }
250 #[cfg(feature = "s3-sealed-cred-gate-mutation")]
254 if envelope.is_none() && gate_mutation::env_on("BOATRAMP_S3SEALED_MUTATE_ENV_FALLBACK")
255 {
256 return Ok(SealedS3Credential {
257 access_key_id: access_key_id.to_string(),
258 secret_access_key: env_source.get("AWS_SECRET_ACCESS_KEY").unwrap_or_default(),
259 });
260 }
261 let envelope = envelope.ok_or(S3CredentialError::NoEnvelope)?;
264 let store = SecretStore::new(kv, envelope);
265 match store.get(ProjectRef::DEFAULT, name).await {
266 Ok(Some(bytes)) => {
267 String::from_utf8(bytes).map_err(|_| S3CredentialError::NotUtf8)?
268 }
269 Ok(None) => {
270 return Err(S3CredentialError::MissingBoatrampSecret(name.to_string()));
271 }
272 Err(e) => return Err(S3CredentialError::Backend(e.to_string())),
273 }
274 }
275 SecretRef::Env(host_var) => {
276 if !allow_env_secret_refs {
282 return Err(S3CredentialError::EnvRefNotPermitted(host_var.to_string()));
283 }
284 env_source
285 .get(host_var)
286 .ok_or_else(|| S3CredentialError::EnvVarUnset(host_var.to_string()))?
287 }
288 SecretRef::Unsupported(scheme) => {
289 return Err(S3CredentialError::UnsupportedScheme(scheme.to_string()));
290 }
291 };
292 if secret_access_key.trim().is_empty() {
296 return Err(S3CredentialError::EmptySecret);
297 }
298 Ok(SealedS3Credential {
299 access_key_id: access_key_id.to_string(),
300 secret_access_key,
301 })
302}
303
304pub fn boatramp_ref_name(secret_ref: &str) -> Option<&str> {
311 match parse_secret_ref(secret_ref) {
312 SecretRef::Boatramp(name) => Some(name),
313 SecretRef::Env(_) | SecretRef::Unsupported(_) => None,
314 }
315}
316
317#[cfg(test)]
318mod tests {
319 use super::*;
320 use boatramp_core::env::MapEnv;
321 use boatramp_core::kv::MemoryKv;
322
323 struct IdentityEnvelope;
326
327 #[async_trait::async_trait]
328 impl KeyEnvelope for IdentityEnvelope {
329 async fn wrap(
330 &self,
331 plaintext: &[u8],
332 ) -> Result<Vec<u8>, boatramp_core::envelope::EnvelopeError> {
333 Ok(plaintext.to_vec())
334 }
335 async fn unwrap(
336 &self,
337 sealed: &[u8],
338 ) -> Result<Vec<u8>, boatramp_core::envelope::EnvelopeError> {
339 Ok(sealed.to_vec())
340 }
341 }
342
343 async fn seed_boatramp_secret(kv: &Arc<dyn KvStore>, name: &str, value: &str) {
344 let store = SecretStore::new(kv.clone(), Arc::new(IdentityEnvelope));
345 store
346 .set(ProjectRef::DEFAULT, name, value.as_bytes())
347 .await
348 .expect("seal secret");
349 }
350
351 fn cfg(access_key_id: &str, secret_ref: &str) -> crate::config::S3CredentialConfig {
352 crate::config::S3CredentialConfig {
353 access_key_id: access_key_id.to_string(),
354 secret_access_key: secret_ref.to_string(),
355 }
356 }
357
358 #[tokio::test]
359 async fn boatramp_ref_resolves_via_the_envelope() {
360 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
361 seed_boatramp_secret(&kv, "tigris-key", "SEALED-SECRET-VALUE").await;
362 let env = MapEnv::new().with("AWS_SECRET_ACCESS_KEY", "AMBIENT-ENV-VALUE");
363 let resolved = resolve_s3_credential(
364 &cfg("AKID-PUBLIC", "boatramp:tigris-key"),
365 kv,
366 Some(Arc::new(IdentityEnvelope)),
367 false,
368 &env,
369 )
370 .await
371 .expect("resolve");
372 assert_eq!(resolved.access_key_id(), "AKID-PUBLIC");
373 assert_eq!(resolved.secret_access_key(), "SEALED-SECRET-VALUE");
376 }
377
378 #[tokio::test]
379 async fn boatramp_ref_with_no_envelope_fails_closed() {
380 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
381 let env = MapEnv::new().with("AWS_SECRET_ACCESS_KEY", "AMBIENT-ENV-VALUE");
384 let err = resolve_s3_credential(
385 &cfg("AKID-PUBLIC", "boatramp:tigris-key"),
386 kv,
387 None,
388 true,
389 &env,
390 )
391 .await
392 .expect_err("must fail closed with no envelope");
393 assert_eq!(err, S3CredentialError::NoEnvelope);
394 }
395
396 #[tokio::test]
397 async fn missing_boatramp_secret_is_an_error_not_a_fallback() {
398 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
399 let env = MapEnv::new().with("AWS_SECRET_ACCESS_KEY", "AMBIENT-ENV-VALUE");
400 let err = resolve_s3_credential(
401 &cfg("AKID-PUBLIC", "boatramp:absent"),
402 kv,
403 Some(Arc::new(IdentityEnvelope)),
404 true,
405 &env,
406 )
407 .await
408 .expect_err("absent sealed secret is an error");
409 assert_eq!(
410 err,
411 S3CredentialError::MissingBoatrampSecret("absent".to_string())
412 );
413 }
414
415 #[tokio::test]
416 async fn env_ref_is_posture_gated() {
417 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
418 let env = MapEnv::new().with("MY_S3_SECRET", "ENV-SECRET-VALUE");
419 let err = resolve_s3_credential(
421 &cfg("AKID-PUBLIC", "env:MY_S3_SECRET"),
422 kv.clone(),
423 None,
424 false,
425 &env,
426 )
427 .await
428 .expect_err("env ref refused under strict posture");
429 assert_eq!(
430 err,
431 S3CredentialError::EnvRefNotPermitted("MY_S3_SECRET".to_string())
432 );
433 let ok = resolve_s3_credential(
435 &cfg("AKID-PUBLIC", "env:MY_S3_SECRET"),
436 kv,
437 None,
438 true,
439 &env,
440 )
441 .await
442 .expect("env ref permitted under dev posture");
443 assert_eq!(ok.secret_access_key(), "ENV-SECRET-VALUE");
444 }
445
446 #[tokio::test]
447 async fn empty_access_key_id_is_refused() {
448 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
449 let env = MapEnv::new();
450 let err = resolve_s3_credential(
451 &cfg("", "boatramp:tigris-key"),
452 kv,
453 Some(Arc::new(IdentityEnvelope)),
454 true,
455 &env,
456 )
457 .await
458 .expect_err("empty access-key-id refused");
459 assert_eq!(err, S3CredentialError::EmptyAccessKeyId);
460 }
461
462 #[test]
463 fn debug_redacts_the_secret() {
464 let cred = SealedS3Credential {
465 access_key_id: "AKID-PUBLIC".to_string(),
466 secret_access_key: "SUPER-SECRET-DO-NOT-LOG".to_string(),
467 };
468 let dbg = format!("{cred:?}");
469 assert!(
471 !dbg.contains("SUPER-SECRET-DO-NOT-LOG"),
472 "the secret access key must be redacted from Debug: {dbg}"
473 );
474 assert!(
475 dbg.contains("<redacted>"),
476 "redaction marker present: {dbg}"
477 );
478 assert!(
480 dbg.contains("AKID-PUBLIC"),
481 "the access-key id is public: {dbg}"
482 );
483 }
484
485 #[tokio::test]
486 async fn empty_resolved_secret_fails_closed() {
487 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
490 let env = MapEnv::new().with("EMPTY_S3_SECRET", "");
491 let err = resolve_s3_credential(
492 &cfg("AKID-PUBLIC", "env:EMPTY_S3_SECRET"),
493 kv.clone(),
494 None,
495 true,
496 &env,
497 )
498 .await
499 .expect_err("empty env secret must fail closed");
500 assert_eq!(err, S3CredentialError::EmptySecret);
501
502 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
504 seed_boatramp_secret(&kv, "empty-sealed", "").await;
505 let err = resolve_s3_credential(
506 &cfg("AKID-PUBLIC", "boatramp:empty-sealed"),
507 kv,
508 Some(Arc::new(IdentityEnvelope)),
509 false,
510 &MapEnv::new(),
511 )
512 .await
513 .expect_err("empty sealed secret must fail closed");
514 assert_eq!(err, S3CredentialError::EmptySecret);
515 }
516
517 #[test]
518 fn boatramp_ref_name_is_a_scheme_check() {
519 assert_eq!(boatramp_ref_name("boatramp:tigris-key"), Some("tigris-key"));
522 assert_eq!(boatramp_ref_name("env:AWS_SECRET_ACCESS_KEY"), None);
523 assert_eq!(boatramp_ref_name("BARE_VAR"), None);
524 assert_eq!(boatramp_ref_name("vault:x"), None);
525 }
526
527 #[test]
528 fn parse_secret_ref_matches_the_server_scheme() {
529 assert!(matches!(
530 parse_secret_ref("boatramp:x"),
531 SecretRef::Boatramp("x")
532 ));
533 assert!(matches!(parse_secret_ref("env:VAR"), SecretRef::Env("VAR")));
534 assert!(matches!(parse_secret_ref("BARE"), SecretRef::Env("BARE")));
535 assert!(matches!(
536 parse_secret_ref("vault:x"),
537 SecretRef::Unsupported("vault")
538 ));
539 }
540}
541
542#[cfg(all(test, feature = "s3-sealed-cred-gate-mutation"))]
551mod gate {
552 use super::*;
553 use boatramp_core::env::MapEnv;
554 use boatramp_core::kv::MemoryKv;
555
556 struct IdentityEnvelope;
557 #[async_trait::async_trait]
558 impl KeyEnvelope for IdentityEnvelope {
559 async fn wrap(
560 &self,
561 plaintext: &[u8],
562 ) -> Result<Vec<u8>, boatramp_core::envelope::EnvelopeError> {
563 Ok(plaintext.to_vec())
564 }
565 async fn unwrap(
566 &self,
567 sealed: &[u8],
568 ) -> Result<Vec<u8>, boatramp_core::envelope::EnvelopeError> {
569 Ok(sealed.to_vec())
570 }
571 }
572
573 const SEALED: &str = "SEALED-TIGRIS-SECRET";
575 const AMBIENT_ENV: &str = "AMBIENT-AWS-SECRET";
576
577 async fn seeded_kv() -> Arc<dyn KvStore> {
578 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
579 SecretStore::new(kv.clone(), Arc::new(IdentityEnvelope))
580 .set(ProjectRef::DEFAULT, "tigris-key", SEALED.as_bytes())
581 .await
582 .expect("seal");
583 kv
584 }
585
586 fn cfg(secret_ref: &str) -> crate::config::S3CredentialConfig {
587 crate::config::S3CredentialConfig {
588 access_key_id: "AKID-PUBLIC".to_string(),
589 secret_access_key: secret_ref.to_string(),
590 }
591 }
592
593 async fn invariant_1_sealed_not_env() {
596 let kv = seeded_kv().await;
597 let env = MapEnv::new().with("AWS_SECRET_ACCESS_KEY", AMBIENT_ENV);
598 let cred = resolve_s3_credential(
599 &cfg("boatramp:tigris-key"),
600 kv,
601 Some(Arc::new(IdentityEnvelope)),
602 false,
603 &env,
604 )
605 .await
606 .expect("resolve sealed");
607 assert_eq!(
608 cred.secret_access_key(),
609 SEALED,
610 "the SEALED credential must be used, not the ambient env value"
611 );
612 assert_ne!(
613 cred.secret_access_key(),
614 AMBIENT_ENV,
615 "must not read the env"
616 );
617 let opts = boatramp_storage::S3Options {
621 bucket: "b".to_string(),
622 endpoint: None,
623 region: None,
624 force_path_style: false,
625 credential: Some(cred.as_pair()),
626 };
627 assert_eq!(
628 opts.credential
629 .as_ref()
630 .map(|(id, s)| (id.as_str(), s.as_str())),
631 Some(("AKID-PUBLIC", SEALED)),
632 "S3Options.credential carries the sealed pair the S3 client will sign with"
633 );
634 }
635
636 async fn invariant_2_no_envelope_fails_closed() {
639 let kv = seeded_kv().await;
640 let env = MapEnv::new().with("AWS_SECRET_ACCESS_KEY", AMBIENT_ENV);
641 let result = resolve_s3_credential(&cfg("boatramp:tigris-key"), kv, None, true, &env).await;
642 match result {
643 Err(S3CredentialError::NoEnvelope) => {}
644 Ok(_) => panic!(
645 "a sealed ref with no envelope must FAIL CLOSED, not fall back to the env chain"
646 ),
647 Err(other) => panic!("expected NoEnvelope, got {other:?}"),
648 }
649 }
650
651 async fn invariant_3_debug_redacts() {
653 let kv = seeded_kv().await;
654 let env = MapEnv::new();
655 let cred = resolve_s3_credential(
656 &cfg("boatramp:tigris-key"),
657 kv,
658 Some(Arc::new(IdentityEnvelope)),
659 false,
660 &env,
661 )
662 .await
663 .expect("resolve");
664 let dbg = format!("{cred:?}");
665 assert!(
666 !dbg.contains(SEALED),
667 "the secret must be redacted from Debug: {dbg}"
668 );
669 }
670
671 async fn invariant_4_absent_source_is_ambient() {
675 let opts = boatramp_storage::S3Options {
677 bucket: "b".to_string(),
678 endpoint: None,
679 region: None,
680 force_path_style: false,
681 credential: None,
682 };
683 assert!(
684 opts.credential.is_none(),
685 "absent source ⇒ no explicit provider ⇒ the ambient AWS env chain (unchanged)"
686 );
687 }
688
689 #[tokio::test]
690 async fn s3_sealed_cred_sourcing_gate() {
691 invariant_1_sealed_not_env().await;
692 invariant_2_no_envelope_fails_closed().await;
693 invariant_3_debug_redacts().await;
694 invariant_4_absent_source_is_ambient().await;
695 println!(
697 "S3 SEALED-CRED SOURCING OK: the node-level base S3 credential is sourced from the \
698 [secrets] sealed store (KeyEnvelope), injected into the S3 blob backend + AWS cloud \
699 minter, fail-closed with no envelope, and redacted from Debug. Mutation-verified: \
700 BOATRAMP_S3SEALED_MUTATE_{{READ_ENV,ENV_FALLBACK,PLAIN_DEBUG}}=1 each FAIL this gate."
701 );
702 }
703}