boatramp_node/s3_credential.rs
1//! Node-level **base S3 credential** sourcing (#505).
2//!
3//! The S3 blob object backend ([`boatramp_storage::S3Storage`]) and the AWS blob-upload cloud minter
4//! (`boatramp-server` `AwsBlobUploadMinter`) historically take their base AWS credential from the
5//! ambient env chain (`AWS_ACCESS_KEY_ID`/`AWS_SECRET_ACCESS_KEY`). This module lets an operator source
6//! that base credential from boatramp's `[secrets]` sealed store instead — ONE shared node-level source
7//! ([`crate::config::S3CredentialConfig`]) consumed by BOTH, since it is the same bucket key
8//! (construens' Tigris move wants exactly this).
9//!
10//! The `access_key_id` is a public identifier (plain config). The `secret_access_key` is a **secret
11//! reference** in the same scheme the guest `secrets` map uses (`parse_secret_ref` in
12//! `boatramp-server`), resolved here at serve startup:
13//!
14//! - `boatramp:<name>` — the project-scoped sealed [`SecretStore`](boatramp_core::secret_store::SecretStore)
15//! under the **reserved default project** (multi-tenant-safe: never the host env). Requires a
16//! `[secrets]` [`KeyEnvelope`] — this mirrors [`ManagedSqlCredentials`](crate::managed_sql) exactly
17//! (KV + envelope, `unwrap` the sealed bytes).
18//! - `env:<VAR>` / a bare `<VAR>` — the **operator's** own environment. Honored ONLY when the security
19//! posture's `allow_env_secret_refs` is set (single-tenant/dev), refused fail-closed otherwise — the
20//! same gate `resolve_secret_env` applies to a guest `env:` ref.
21//!
22//! **Fail-closed:** a configured `secret_access_key` ref with NO `[secrets]` envelope is a startup
23//! error — we do NOT silently fall back to the ambient env chain, which would mask a misconfig
24//! (the whole point of the sealed source is to STOP reading the base key from the env). The resolved
25//! plaintext is held in memory only inside a redacted [`SealedS3Credential`] (never `Debug`-printed in
26//! clear, never logged/argv/git).
27
28use 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/// A resolved base S3 credential held in memory. `access_key_id` is a public identifier; the
37/// `secret_access_key` is confidential and is **redacted** from `Debug` (mirroring
38/// `S3IngressSecret`/`azure_core::Secret`), so a struct-`Debug` in a log or a panic message can never
39/// leak it. Cheap to clone (two `String`s) so both consumers (backend + minter) share it.
40#[derive(Clone)]
41pub struct SealedS3Credential {
42 access_key_id: String,
43 secret_access_key: String,
44}
45
46impl SealedS3Credential {
47 /// The public access-key id (safe to log / put in an ARN's session name, etc.).
48 #[cfg_attr(not(feature = "s3"), allow(dead_code))]
49 pub fn access_key_id(&self) -> &str {
50 &self.access_key_id
51 }
52
53 /// The confidential secret access key. Kept behind a method (not a public field) so a caller must
54 /// ask for it explicitly; it is redacted from `Debug`.
55 #[cfg_attr(not(feature = "s3"), allow(dead_code))]
56 pub fn secret_access_key(&self) -> &str {
57 &self.secret_access_key
58 }
59
60 /// The `(access_key_id, secret_access_key)` pair the storage/minter build paths inject into an AWS
61 /// credentials provider. Consumes clones — the secret stays owned by the caller's provider.
62 #[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
68/// `Debug` redacts the secret so it never lands in a log / panic message. The access-key id is a public
69/// identifier so it is shown; the secret is elided (mirrors `azure_core::Secret` / `S3IngressSecret`,
70/// which derive no plaintext `Debug`). The redaction test in this module + the mutation gate assert the
71/// secret substring is absent from `{:?}`.
72impl std::fmt::Debug for SealedS3Credential {
73 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
74 // GATE MUTATION SEAM (invariant 3): a plain `Debug` that prints the secret in clear — the exact
75 // regression the redaction guards against. Only under the gate feature + the env var.
76 #[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/// The gate-mutation seams (#505 `S3 SEALED-CRED SOURCING OK`). Compiled ONLY under the
92/// `s3-sealed-cred-gate-mutation` feature (the CI gate lane). Each `BOATRAMP_S3SEALED_MUTATE_*` env var
93/// makes the resolver/`Debug` behave like a specific broken implementation, so the CI gate proves each
94/// security check is load-bearing (every mutation MUST fail the gate).
95#[cfg(feature = "s3-sealed-cred-gate-mutation")]
96mod gate_mutation {
97 /// Whether a mutation env var is set (non-empty and not `0`).
98 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/// A failure resolving the node-level base S3 credential. Stringly at the boundary (the caller maps it
106/// into its `serve` error), but the variants keep the failure kinds distinct so the mutation gate can
107/// assert on the fail-closed one.
108#[derive(Debug, PartialEq, Eq)]
109pub enum S3CredentialError {
110 /// `access_key_id` is empty (a misconfigured source — refuse rather than mint a broken credential).
111 EmptyAccessKeyId,
112 /// The resolved `secret_access_key` is empty (an `env:VAR=""` or a `boatramp:` secret sealed as
113 /// empty bytes) — **fail closed at startup** rather than mint a static provider that fails SigV4
114 /// at request-time (403). Symmetric to [`Self::EmptyAccessKeyId`].
115 EmptySecret,
116 /// A `boatramp:<name>` node-cred ref on the CLUSTER path — refused **structurally** (a scheme
117 /// check, not an incidental empty-store miss): the replicated control-plane KV that holds the
118 /// sealed store is not available at blob-build time on a cluster. Use `env:<VAR>` instead.
119 BoatrampRefOnCluster(String),
120 /// The `secret_access_key` is a `boatramp:`/`env:` sealed ref but no `[secrets]` envelope is
121 /// configured — **fail closed** (never a silent env fallback that would mask the misconfig).
122 NoEnvelope,
123 /// A `boatramp:` ref, but the sealed secret is not present in the store under the default project.
124 MissingBoatrampSecret(String),
125 /// An `env:`/bare host-env ref refused because the posture's `allow_env_secret_refs` is off (the
126 /// config author's env is the operator's namespace — the same gate a guest `env:` ref hits).
127 EnvRefNotPermitted(String),
128 /// An `env:`/bare host-env ref whose var is unset.
129 EnvVarUnset(String),
130 /// A reserved-but-unimplemented scheme (a colon-bearing value whose scheme is not `env`/`boatramp`).
131 UnsupportedScheme(String),
132 /// A KV / envelope (unseal) backend failure — detail is logged, not surfaced to a client.
133 Backend(String),
134 /// A resolved secret that is not valid UTF-8 (an AWS secret access key is ASCII).
135 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
193/// A parsed `secret_access_key` reference — the SAME scheme `parse_secret_ref` implements in
194/// `boatramp-server` (kept in lockstep; a colon-free value is a bare host-env var name for back-compat,
195/// `env:` is the explicit host-env form, `boatramp:` the sealed store, any other `scheme:` is reserved).
196enum SecretRef<'a> {
197 /// A bare `VAR` or explicit `env:VAR` — the operator's own environment (posture-gated).
198 Env(&'a str),
199 /// `boatramp:NAME` — the project-scoped sealed store.
200 Boatramp(&'a str),
201 /// A reserved-but-unimplemented scheme.
202 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
214/// Resolve the node-level base S3 credential from [`config`](crate::config::S3CredentialConfig).
215///
216/// The `access_key_id` is copied through (a public identifier). The `secret_access_key` **reference** is
217/// resolved:
218///
219/// - `boatramp:<name>` → unsealed from the project-scoped [`SecretStore`] under
220/// [`ProjectRef::DEFAULT`] (the reserved operator/node scope) via the `[secrets]` `envelope`. A
221/// `None` envelope is [`S3CredentialError::NoEnvelope`] (fail-closed); an absent secret is
222/// [`S3CredentialError::MissingBoatrampSecret`].
223/// - `env:<VAR>` / bare `<VAR>` → read from `env_source`, but ONLY when `allow_env_secret_refs` is set
224/// (else [`S3CredentialError::EnvRefNotPermitted`], fail-closed like a guest `env:` ref).
225///
226/// The result holds the plaintext in memory inside a redacted [`SealedS3Credential`].
227///
228/// The `boatramp:<name>` ref is resolved under [`ProjectRef::DEFAULT`] (the reserved operator/node
229/// scope). A **per-project** blob backend (PLAN-per-project-blob-backend) instead calls
230/// [`resolve_s3_credential_for_project`] so its `boatramp:` ref unseals from THAT project's own sealed
231/// store — binding the credential to the project, never the node default.
232pub async fn resolve_s3_credential(
233 config: &crate::config::S3CredentialConfig,
234 kv: Arc<dyn KvStore>,
235 envelope: Option<Arc<dyn KeyEnvelope>>,
236 allow_env_secret_refs: bool,
237 env_source: &dyn EnvSource,
238) -> Result<SealedS3Credential, S3CredentialError> {
239 resolve_s3_credential_for_project(
240 config,
241 ProjectRef::DEFAULT,
242 kv,
243 envelope,
244 allow_env_secret_refs,
245 env_source,
246 )
247 .await
248}
249
250/// Resolve a base S3 credential (as [`resolve_s3_credential`]) but keyed to a specific `project`
251/// (PLAN-per-project-blob-backend): a `boatramp:<name>` secret ref unseals from THAT project's sealed
252/// [`SecretStore`] subtree rather than [`ProjectRef::DEFAULT`]. Used by the per-project blob-backend
253/// wiring so project ACME's backend credential is sealed in ACME's own secret store (invariant 1 —
254/// authority bound to the project). `env:`/bare refs are project-independent (the operator's own
255/// environment), posture-gated identically. `project` is the serve's host-resolved project, never guest
256/// input.
257pub async fn resolve_s3_credential_for_project(
258 config: &crate::config::S3CredentialConfig,
259 project: ProjectRef<'_>,
260 kv: Arc<dyn KvStore>,
261 envelope: Option<Arc<dyn KeyEnvelope>>,
262 allow_env_secret_refs: bool,
263 env_source: &dyn EnvSource,
264) -> Result<SealedS3Credential, S3CredentialError> {
265 let access_key_id = config.access_key_id.trim();
266 if access_key_id.is_empty() {
267 return Err(S3CredentialError::EmptyAccessKeyId);
268 }
269 let secret_access_key = match parse_secret_ref(&config.secret_access_key) {
270 SecretRef::Boatramp(name) => {
271 // GATE MUTATION SEAM (invariant 1): read the AMBIENT env instead of the sealed store — the
272 // "ignore the sealed source / read env" regression. The gate asserts the SEALED value is
273 // used, so this must fail it.
274 #[cfg(feature = "s3-sealed-cred-gate-mutation")]
275 if gate_mutation::env_on("BOATRAMP_S3SEALED_MUTATE_READ_ENV") {
276 return Ok(SealedS3Credential {
277 access_key_id: access_key_id.to_string(),
278 secret_access_key: env_source.get("AWS_SECRET_ACCESS_KEY").unwrap_or_default(),
279 });
280 }
281 // GATE MUTATION SEAM (invariant 2): silently fall back to the ambient env when no envelope
282 // is configured, INSTEAD of failing closed — the exact misconfig-masking regression. The
283 // gate asserts a no-envelope sealed ref errors, so this must fail it.
284 #[cfg(feature = "s3-sealed-cred-gate-mutation")]
285 if envelope.is_none() && gate_mutation::env_on("BOATRAMP_S3SEALED_MUTATE_ENV_FALLBACK")
286 {
287 return Ok(SealedS3Credential {
288 access_key_id: access_key_id.to_string(),
289 secret_access_key: env_source.get("AWS_SECRET_ACCESS_KEY").unwrap_or_default(),
290 });
291 }
292 // Fail closed BEFORE touching the store: a sealed ref with no envelope must never fall back
293 // to the ambient env chain (that would mask the misconfig this feature exists to remove).
294 let envelope = envelope.ok_or(S3CredentialError::NoEnvelope)?;
295 let store = SecretStore::new(kv, envelope);
296 match store.get(project, name).await {
297 Ok(Some(bytes)) => {
298 String::from_utf8(bytes).map_err(|_| S3CredentialError::NotUtf8)?
299 }
300 Ok(None) => {
301 return Err(S3CredentialError::MissingBoatrampSecret(name.to_string()));
302 }
303 Err(e) => return Err(S3CredentialError::Backend(e.to_string())),
304 }
305 }
306 SecretRef::Env(host_var) => {
307 // The operator's own namespace — permitted only when the config author IS the operator.
308 // Fail closed under the multi-tenant posture (matches the guest `env:` ref gate). NOTE: an
309 // `env:`/bare source still requires NOTHING of the envelope — but it is still a "sealed
310 // source is configured" case, so it does NOT silently degrade to the ambient AWS chain (the
311 // operator asked for THIS var explicitly).
312 if !allow_env_secret_refs {
313 return Err(S3CredentialError::EnvRefNotPermitted(host_var.to_string()));
314 }
315 env_source
316 .get(host_var)
317 .ok_or_else(|| S3CredentialError::EnvVarUnset(host_var.to_string()))?
318 }
319 SecretRef::Unsupported(scheme) => {
320 return Err(S3CredentialError::UnsupportedScheme(scheme.to_string()));
321 }
322 };
323 // Fail closed on an EMPTY resolved secret (an `env:VAR=""` or a `boatramp:` secret sealed as empty
324 // bytes): a static provider built from an empty secret would fail SigV4 at request-time (403)
325 // instead of at startup. Symmetric to the `access_key_id` empty check above.
326 if secret_access_key.trim().is_empty() {
327 return Err(S3CredentialError::EmptySecret);
328 }
329 Ok(SealedS3Credential {
330 access_key_id: access_key_id.to_string(),
331 secret_access_key,
332 })
333}
334
335/// Whether `secret_ref` is a `boatramp:<name>` sealed-store reference. The cluster serve path uses this
336/// for a STRUCTURAL up-front refusal ([`S3CredentialError::BoatrampRefOnCluster`]) — the replicated
337/// control-plane KV that backs the sealed store does not exist at blob-build time on a cluster, so a
338/// `boatramp:` ref must be rejected by a scheme check (not left to fail incidentally against an empty
339/// stand-in store). `env:`/bare refs return `None` here so they resolve normally and surface their own
340/// errors unwrapped.
341pub fn boatramp_ref_name(secret_ref: &str) -> Option<&str> {
342 match parse_secret_ref(secret_ref) {
343 SecretRef::Boatramp(name) => Some(name),
344 SecretRef::Env(_) | SecretRef::Unsupported(_) => None,
345 }
346}
347
348#[cfg(test)]
349mod tests {
350 use super::*;
351 use boatramp_core::env::MapEnv;
352 use boatramp_core::kv::MemoryKv;
353
354 /// A trivial passthrough envelope for tests: seal/unseal are identity, so a `SecretStore` round-trip
355 /// exercises the KV keying + the resolver's unseal path without a real KEK.
356 struct IdentityEnvelope;
357
358 #[async_trait::async_trait]
359 impl KeyEnvelope for IdentityEnvelope {
360 async fn wrap(
361 &self,
362 plaintext: &[u8],
363 ) -> Result<Vec<u8>, boatramp_core::envelope::EnvelopeError> {
364 Ok(plaintext.to_vec())
365 }
366 async fn unwrap(
367 &self,
368 sealed: &[u8],
369 ) -> Result<Vec<u8>, boatramp_core::envelope::EnvelopeError> {
370 Ok(sealed.to_vec())
371 }
372 }
373
374 async fn seed_boatramp_secret(kv: &Arc<dyn KvStore>, name: &str, value: &str) {
375 let store = SecretStore::new(kv.clone(), Arc::new(IdentityEnvelope));
376 store
377 .set(ProjectRef::DEFAULT, name, value.as_bytes())
378 .await
379 .expect("seal secret");
380 }
381
382 fn cfg(access_key_id: &str, secret_ref: &str) -> crate::config::S3CredentialConfig {
383 crate::config::S3CredentialConfig {
384 access_key_id: access_key_id.to_string(),
385 secret_access_key: secret_ref.to_string(),
386 }
387 }
388
389 #[tokio::test]
390 async fn boatramp_ref_resolves_via_the_envelope() {
391 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
392 seed_boatramp_secret(&kv, "tigris-key", "SEALED-SECRET-VALUE").await;
393 let env = MapEnv::new().with("AWS_SECRET_ACCESS_KEY", "AMBIENT-ENV-VALUE");
394 let resolved = resolve_s3_credential(
395 &cfg("AKID-PUBLIC", "boatramp:tigris-key"),
396 kv,
397 Some(Arc::new(IdentityEnvelope)),
398 false,
399 &env,
400 )
401 .await
402 .expect("resolve");
403 assert_eq!(resolved.access_key_id(), "AKID-PUBLIC");
404 // The SEALED value is used — NOT the ambient env value (which the resolver never reads for a
405 // boatramp: ref). This is invariant 1.
406 assert_eq!(resolved.secret_access_key(), "SEALED-SECRET-VALUE");
407 }
408
409 #[tokio::test]
410 async fn boatramp_ref_with_no_envelope_fails_closed() {
411 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
412 // A sealed ref is configured but NO envelope — must fail closed (invariant 2), NOT fall back to
413 // the ambient env chain.
414 let env = MapEnv::new().with("AWS_SECRET_ACCESS_KEY", "AMBIENT-ENV-VALUE");
415 let err = resolve_s3_credential(
416 &cfg("AKID-PUBLIC", "boatramp:tigris-key"),
417 kv,
418 None,
419 true,
420 &env,
421 )
422 .await
423 .expect_err("must fail closed with no envelope");
424 assert_eq!(err, S3CredentialError::NoEnvelope);
425 }
426
427 #[tokio::test]
428 async fn missing_boatramp_secret_is_an_error_not_a_fallback() {
429 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
430 let env = MapEnv::new().with("AWS_SECRET_ACCESS_KEY", "AMBIENT-ENV-VALUE");
431 let err = resolve_s3_credential(
432 &cfg("AKID-PUBLIC", "boatramp:absent"),
433 kv,
434 Some(Arc::new(IdentityEnvelope)),
435 true,
436 &env,
437 )
438 .await
439 .expect_err("absent sealed secret is an error");
440 assert_eq!(
441 err,
442 S3CredentialError::MissingBoatrampSecret("absent".to_string())
443 );
444 }
445
446 #[tokio::test]
447 async fn env_ref_is_posture_gated() {
448 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
449 let env = MapEnv::new().with("MY_S3_SECRET", "ENV-SECRET-VALUE");
450 // Refused under the strict posture (allow_env_secret_refs = false).
451 let err = resolve_s3_credential(
452 &cfg("AKID-PUBLIC", "env:MY_S3_SECRET"),
453 kv.clone(),
454 None,
455 false,
456 &env,
457 )
458 .await
459 .expect_err("env ref refused under strict posture");
460 assert_eq!(
461 err,
462 S3CredentialError::EnvRefNotPermitted("MY_S3_SECRET".to_string())
463 );
464 // Permitted under the dev posture.
465 let ok = resolve_s3_credential(
466 &cfg("AKID-PUBLIC", "env:MY_S3_SECRET"),
467 kv,
468 None,
469 true,
470 &env,
471 )
472 .await
473 .expect("env ref permitted under dev posture");
474 assert_eq!(ok.secret_access_key(), "ENV-SECRET-VALUE");
475 }
476
477 #[tokio::test]
478 async fn empty_access_key_id_is_refused() {
479 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
480 let env = MapEnv::new();
481 let err = resolve_s3_credential(
482 &cfg("", "boatramp:tigris-key"),
483 kv,
484 Some(Arc::new(IdentityEnvelope)),
485 true,
486 &env,
487 )
488 .await
489 .expect_err("empty access-key-id refused");
490 assert_eq!(err, S3CredentialError::EmptyAccessKeyId);
491 }
492
493 #[test]
494 fn debug_redacts_the_secret() {
495 let cred = SealedS3Credential {
496 access_key_id: "AKID-PUBLIC".to_string(),
497 secret_access_key: "SUPER-SECRET-DO-NOT-LOG".to_string(),
498 };
499 let dbg = format!("{cred:?}");
500 // Invariant 3: the secret NEVER appears in Debug (a plain-`Debug` derive would leak it).
501 assert!(
502 !dbg.contains("SUPER-SECRET-DO-NOT-LOG"),
503 "the secret access key must be redacted from Debug: {dbg}"
504 );
505 assert!(
506 dbg.contains("<redacted>"),
507 "redaction marker present: {dbg}"
508 );
509 // The public id is fine to show.
510 assert!(
511 dbg.contains("AKID-PUBLIC"),
512 "the access-key id is public: {dbg}"
513 );
514 }
515
516 #[tokio::test]
517 async fn empty_resolved_secret_fails_closed() {
518 // (a) an `env:VAR` whose value is the empty string — fail closed at startup, not a 403 at
519 // request-time.
520 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
521 let env = MapEnv::new().with("EMPTY_S3_SECRET", "");
522 let err = resolve_s3_credential(
523 &cfg("AKID-PUBLIC", "env:EMPTY_S3_SECRET"),
524 kv.clone(),
525 None,
526 true,
527 &env,
528 )
529 .await
530 .expect_err("empty env secret must fail closed");
531 assert_eq!(err, S3CredentialError::EmptySecret);
532
533 // (b) a `boatramp:` secret sealed as empty bytes — same fail-closed outcome.
534 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
535 seed_boatramp_secret(&kv, "empty-sealed", "").await;
536 let err = resolve_s3_credential(
537 &cfg("AKID-PUBLIC", "boatramp:empty-sealed"),
538 kv,
539 Some(Arc::new(IdentityEnvelope)),
540 false,
541 &MapEnv::new(),
542 )
543 .await
544 .expect_err("empty sealed secret must fail closed");
545 assert_eq!(err, S3CredentialError::EmptySecret);
546 }
547
548 #[test]
549 fn boatramp_ref_name_is_a_scheme_check() {
550 // The structural scheme check the cluster serve path uses: only a `boatramp:` ref returns a
551 // name (⇒ refused on a cluster); `env:`/bare/unsupported return `None` (⇒ resolve normally).
552 assert_eq!(boatramp_ref_name("boatramp:tigris-key"), Some("tigris-key"));
553 assert_eq!(boatramp_ref_name("env:AWS_SECRET_ACCESS_KEY"), None);
554 assert_eq!(boatramp_ref_name("BARE_VAR"), None);
555 assert_eq!(boatramp_ref_name("vault:x"), None);
556 }
557
558 #[test]
559 fn parse_secret_ref_matches_the_server_scheme() {
560 assert!(matches!(
561 parse_secret_ref("boatramp:x"),
562 SecretRef::Boatramp("x")
563 ));
564 assert!(matches!(parse_secret_ref("env:VAR"), SecretRef::Env("VAR")));
565 assert!(matches!(parse_secret_ref("BARE"), SecretRef::Env("BARE")));
566 assert!(matches!(
567 parse_secret_ref("vault:x"),
568 SecretRef::Unsupported("vault")
569 ));
570 }
571}
572
573// ================================================================================================
574// The #505 mutation-verified gate battery: `S3 SEALED-CRED SOURCING OK`.
575//
576// One `#[tokio::test]` runs every invariant, then prints the marker. Compiled ONLY under the
577// `s3-sealed-cred-gate-mutation` feature (the CI gate lane); each `BOATRAMP_S3SEALED_MUTATE_*` env var
578// neuters exactly ONE invariant (via the seams above), so a clean run reaches the marker while every
579// mutation PANICS before it — proving each check is load-bearing. See the ci.yml gate step.
580// ================================================================================================
581#[cfg(all(test, feature = "s3-sealed-cred-gate-mutation"))]
582mod gate {
583 use super::*;
584 use boatramp_core::env::MapEnv;
585 use boatramp_core::kv::MemoryKv;
586
587 struct IdentityEnvelope;
588 #[async_trait::async_trait]
589 impl KeyEnvelope for IdentityEnvelope {
590 async fn wrap(
591 &self,
592 plaintext: &[u8],
593 ) -> Result<Vec<u8>, boatramp_core::envelope::EnvelopeError> {
594 Ok(plaintext.to_vec())
595 }
596 async fn unwrap(
597 &self,
598 sealed: &[u8],
599 ) -> Result<Vec<u8>, boatramp_core::envelope::EnvelopeError> {
600 Ok(sealed.to_vec())
601 }
602 }
603
604 /// The sealed value the gate seeds + expects; the ambient env value it must NEVER pick instead.
605 const SEALED: &str = "SEALED-TIGRIS-SECRET";
606 const AMBIENT_ENV: &str = "AMBIENT-AWS-SECRET";
607
608 async fn seeded_kv() -> Arc<dyn KvStore> {
609 let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
610 SecretStore::new(kv.clone(), Arc::new(IdentityEnvelope))
611 .set(ProjectRef::DEFAULT, "tigris-key", SEALED.as_bytes())
612 .await
613 .expect("seal");
614 kv
615 }
616
617 fn cfg(secret_ref: &str) -> crate::config::S3CredentialConfig {
618 crate::config::S3CredentialConfig {
619 access_key_id: "AKID-PUBLIC".to_string(),
620 secret_access_key: secret_ref.to_string(),
621 }
622 }
623
624 // Invariant 1: a `boatramp:` source resolves to the SEALED value, and that value is what the blob
625 // backend injects (`S3Options.credential`) — NOT the ambient env value. Mutation READ_ENV neuters it.
626 async fn invariant_1_sealed_not_env() {
627 let kv = seeded_kv().await;
628 let env = MapEnv::new().with("AWS_SECRET_ACCESS_KEY", AMBIENT_ENV);
629 let cred = resolve_s3_credential(
630 &cfg("boatramp:tigris-key"),
631 kv,
632 Some(Arc::new(IdentityEnvelope)),
633 false,
634 &env,
635 )
636 .await
637 .expect("resolve sealed");
638 assert_eq!(
639 cred.secret_access_key(),
640 SEALED,
641 "the SEALED credential must be used, not the ambient env value"
642 );
643 assert_ne!(
644 cred.secret_access_key(),
645 AMBIENT_ENV,
646 "must not read the env"
647 );
648 // The blob backend injects EXACTLY this pair into `S3Options.credential` (the build path is
649 // `credential: args.s3_credential.as_ref().map(|c| c.as_pair())`), so the built S3 client signs
650 // with the sealed key. Assert the pair the backend would receive.
651 let opts = boatramp_storage::S3Options {
652 bucket: "b".to_string(),
653 endpoint: None,
654 region: None,
655 force_path_style: false,
656 credential: Some(cred.as_pair()),
657 };
658 assert_eq!(
659 opts.credential
660 .as_ref()
661 .map(|(id, s)| (id.as_str(), s.as_str())),
662 Some(("AKID-PUBLIC", SEALED)),
663 "S3Options.credential carries the sealed pair the S3 client will sign with"
664 );
665 }
666
667 // Invariant 2: a `boatramp:` sealed ref with NO `[secrets]` envelope FAILS CLOSED — never a silent
668 // env fallback. Mutation ENV_FALLBACK neuters it (returns Ok).
669 async fn invariant_2_no_envelope_fails_closed() {
670 let kv = seeded_kv().await;
671 let env = MapEnv::new().with("AWS_SECRET_ACCESS_KEY", AMBIENT_ENV);
672 let result = resolve_s3_credential(&cfg("boatramp:tigris-key"), kv, None, true, &env).await;
673 match result {
674 Err(S3CredentialError::NoEnvelope) => {}
675 Ok(_) => panic!(
676 "a sealed ref with no envelope must FAIL CLOSED, not fall back to the env chain"
677 ),
678 Err(other) => panic!("expected NoEnvelope, got {other:?}"),
679 }
680 }
681
682 // Invariant 3: the secret NEVER appears in `Debug`. Mutation PLAIN_DEBUG neuters the redaction.
683 async fn invariant_3_debug_redacts() {
684 let kv = seeded_kv().await;
685 let env = MapEnv::new();
686 let cred = resolve_s3_credential(
687 &cfg("boatramp:tigris-key"),
688 kv,
689 Some(Arc::new(IdentityEnvelope)),
690 false,
691 &env,
692 )
693 .await
694 .expect("resolve");
695 let dbg = format!("{cred:?}");
696 assert!(
697 !dbg.contains(SEALED),
698 "the secret must be redacted from Debug: {dbg}"
699 );
700 }
701
702 // Invariant 4: an ABSENT source ⇒ the ambient AWS env chain (non-breaking) — `S3Options.credential`
703 // stays `None` so the SDK resolves the ambient chain (the historical default). No mutation neuters a
704 // positive path; the assertion documents the non-breaking contract.
705 async fn invariant_4_absent_source_is_ambient() {
706 // When no `[serve.s3_credential]` is configured, the blob backend leaves `credential` = None.
707 let opts = boatramp_storage::S3Options {
708 bucket: "b".to_string(),
709 endpoint: None,
710 region: None,
711 force_path_style: false,
712 credential: None,
713 };
714 assert!(
715 opts.credential.is_none(),
716 "absent source ⇒ no explicit provider ⇒ the ambient AWS env chain (unchanged)"
717 );
718 }
719
720 #[tokio::test]
721 async fn s3_sealed_cred_sourcing_gate() {
722 invariant_1_sealed_not_env().await;
723 invariant_2_no_envelope_fails_closed().await;
724 invariant_3_debug_redacts().await;
725 invariant_4_absent_source_is_ambient().await;
726 // Reached only on a clean, fully-passing run — a mutation env var panics one invariant above.
727 println!(
728 "S3 SEALED-CRED SOURCING OK: the node-level base S3 credential is sourced from the \
729 [secrets] sealed store (KeyEnvelope), injected into the S3 blob backend + AWS cloud \
730 minter, fail-closed with no envelope, and redacted from Debug. Mutation-verified: \
731 BOATRAMP_S3SEALED_MUTATE_{{READ_ENV,ENV_FALLBACK,PLAIN_DEBUG}}=1 each FAIL this gate."
732 );
733 }
734}