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`].
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}