Skip to main content

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`].
227pub 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            // GATE MUTATION SEAM (invariant 1): read the AMBIENT env instead of the sealed store — the
241            // "ignore the sealed source / read env" regression. The gate asserts the SEALED value is
242            // used, so this must fail it.
243            #[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            // GATE MUTATION SEAM (invariant 2): silently fall back to the ambient env when no envelope
251            // is configured, INSTEAD of failing closed — the exact misconfig-masking regression. The
252            // gate asserts a no-envelope sealed ref errors, so this must fail it.
253            #[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            // Fail closed BEFORE touching the store: a sealed ref with no envelope must never fall back
262            // to the ambient env chain (that would mask the misconfig this feature exists to remove).
263            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            // The operator's own namespace — permitted only when the config author IS the operator.
277            // Fail closed under the multi-tenant posture (matches the guest `env:` ref gate). NOTE: an
278            // `env:`/bare source still requires NOTHING of the envelope — but it is still a "sealed
279            // source is configured" case, so it does NOT silently degrade to the ambient AWS chain (the
280            // operator asked for THIS var explicitly).
281            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    // Fail closed on an EMPTY resolved secret (an `env:VAR=""` or a `boatramp:` secret sealed as empty
293    // bytes): a static provider built from an empty secret would fail SigV4 at request-time (403)
294    // instead of at startup. Symmetric to the `access_key_id` empty check above.
295    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
304/// Whether `secret_ref` is a `boatramp:<name>` sealed-store reference. The cluster serve path uses this
305/// for a STRUCTURAL up-front refusal ([`S3CredentialError::BoatrampRefOnCluster`]) — the replicated
306/// control-plane KV that backs the sealed store does not exist at blob-build time on a cluster, so a
307/// `boatramp:` ref must be rejected by a scheme check (not left to fail incidentally against an empty
308/// stand-in store). `env:`/bare refs return `None` here so they resolve normally and surface their own
309/// errors unwrapped.
310pub 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    /// A trivial passthrough envelope for tests: seal/unseal are identity, so a `SecretStore` round-trip
324    /// exercises the KV keying + the resolver's unseal path without a real KEK.
325    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        // The SEALED value is used — NOT the ambient env value (which the resolver never reads for a
374        // boatramp: ref). This is invariant 1.
375        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        // A sealed ref is configured but NO envelope — must fail closed (invariant 2), NOT fall back to
382        // the ambient env chain.
383        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        // Refused under the strict posture (allow_env_secret_refs = false).
420        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        // Permitted under the dev posture.
434        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        // Invariant 3: the secret NEVER appears in Debug (a plain-`Debug` derive would leak it).
470        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        // The public id is fine to show.
479        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        // (a) an `env:VAR` whose value is the empty string — fail closed at startup, not a 403 at
488        // request-time.
489        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        // (b) a `boatramp:` secret sealed as empty bytes — same fail-closed outcome.
503        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        // The structural scheme check the cluster serve path uses: only a `boatramp:` ref returns a
520        // name (⇒ refused on a cluster); `env:`/bare/unsupported return `None` (⇒ resolve normally).
521        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// ================================================================================================
543// The #505 mutation-verified gate battery: `S3 SEALED-CRED SOURCING OK`.
544//
545// One `#[tokio::test]` runs every invariant, then prints the marker. Compiled ONLY under the
546// `s3-sealed-cred-gate-mutation` feature (the CI gate lane); each `BOATRAMP_S3SEALED_MUTATE_*` env var
547// neuters exactly ONE invariant (via the seams above), so a clean run reaches the marker while every
548// mutation PANICS before it — proving each check is load-bearing. See the ci.yml gate step.
549// ================================================================================================
550#[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    /// The sealed value the gate seeds + expects; the ambient env value it must NEVER pick instead.
574    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    // Invariant 1: a `boatramp:` source resolves to the SEALED value, and that value is what the blob
594    // backend injects (`S3Options.credential`) — NOT the ambient env value. Mutation READ_ENV neuters it.
595    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        // The blob backend injects EXACTLY this pair into `S3Options.credential` (the build path is
618        // `credential: args.s3_credential.as_ref().map(|c| c.as_pair())`), so the built S3 client signs
619        // with the sealed key. Assert the pair the backend would receive.
620        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    // Invariant 2: a `boatramp:` sealed ref with NO `[secrets]` envelope FAILS CLOSED — never a silent
637    // env fallback. Mutation ENV_FALLBACK neuters it (returns Ok).
638    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    // Invariant 3: the secret NEVER appears in `Debug`. Mutation PLAIN_DEBUG neuters the redaction.
652    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    // Invariant 4: an ABSENT source ⇒ the ambient AWS env chain (non-breaking) — `S3Options.credential`
672    // stays `None` so the SDK resolves the ambient chain (the historical default). No mutation neuters a
673    // positive path; the assertion documents the non-breaking contract.
674    async fn invariant_4_absent_source_is_ambient() {
675        // When no `[serve.s3_credential]` is configured, the blob backend leaves `credential` = None.
676        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        // Reached only on a clean, fully-passing run — a mutation env var panics one invariant above.
696        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}