use std::sync::Arc;
use boatramp_core::env::EnvSource;
use boatramp_core::envelope::KeyEnvelope;
use boatramp_core::kv::KvStore;
use boatramp_core::project::ProjectRef;
use boatramp_core::secret_store::SecretStore;
#[derive(Clone)]
pub struct SealedS3Credential {
access_key_id: String,
secret_access_key: String,
}
impl SealedS3Credential {
#[cfg_attr(not(feature = "s3"), allow(dead_code))]
pub fn access_key_id(&self) -> &str {
&self.access_key_id
}
#[cfg_attr(not(feature = "s3"), allow(dead_code))]
pub fn secret_access_key(&self) -> &str {
&self.secret_access_key
}
#[cfg_attr(not(feature = "s3"), allow(dead_code))]
pub fn as_pair(&self) -> (String, String) {
(self.access_key_id.clone(), self.secret_access_key.clone())
}
}
impl std::fmt::Debug for SealedS3Credential {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
#[cfg(feature = "s3-sealed-cred-gate-mutation")]
if gate_mutation::env_on("BOATRAMP_S3SEALED_MUTATE_PLAIN_DEBUG") {
return f
.debug_struct("SealedS3Credential")
.field("access_key_id", &self.access_key_id)
.field("secret_access_key", &self.secret_access_key)
.finish();
}
f.debug_struct("SealedS3Credential")
.field("access_key_id", &self.access_key_id)
.field("secret_access_key", &"<redacted>")
.finish()
}
}
#[cfg(feature = "s3-sealed-cred-gate-mutation")]
mod gate_mutation {
pub(super) fn env_on(name: &str) -> bool {
std::env::var(name)
.map(|v| !v.is_empty() && v != "0")
.unwrap_or(false)
}
}
#[derive(Debug, PartialEq, Eq)]
pub enum S3CredentialError {
EmptyAccessKeyId,
EmptySecret,
BoatrampRefOnCluster(String),
NoEnvelope,
MissingBoatrampSecret(String),
EnvRefNotPermitted(String),
EnvVarUnset(String),
UnsupportedScheme(String),
Backend(String),
NotUtf8,
}
impl std::fmt::Display for S3CredentialError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::EmptyAccessKeyId => write!(
f,
"[serve.s3_credential]: `access_key_id` must not be empty"
),
Self::EmptySecret => write!(
f,
"[serve.s3_credential]: the resolved `secret_access_key` is empty — refusing to build \
a credential that would fail SigV4 signing at request-time (403); seal a non-empty \
secret or set the env var to a non-empty value"
),
Self::BoatrampRefOnCluster(name) => write!(
f,
"[serve.s3_credential]: `secret_access_key` → boatramp:{name} is not supported on a \
cluster (the control-plane KV isn't available at blob-build time); use `env:<VAR>`"
),
Self::NoEnvelope => write!(
f,
"[serve.s3_credential]: `secret_access_key` is a sealed `boatramp:`/`env:` ref but no \
`[secrets]` envelope is configured — refusing to fall back to the ambient AWS env \
chain (fail-closed); configure `[secrets]` or remove the source"
),
Self::MissingBoatrampSecret(name) => write!(
f,
"[serve.s3_credential]: `secret_access_key` → boatramp:{name} is not set in the \
sealed secret store (default project); seal it with `boatramp secrets set`"
),
Self::EnvRefNotPermitted(var) => write!(
f,
"[serve.s3_credential]: `secret_access_key` host-env ref {var:?} is not permitted \
under the multi-tenant posture (it would read the operator's environment); enable \
`allow_env_secret_refs` or use a `boatramp:<name>` sealed ref"
),
Self::EnvVarUnset(var) => write!(
f,
"[serve.s3_credential]: `secret_access_key` env var {var:?} is not set"
),
Self::UnsupportedScheme(scheme) => write!(
f,
"[serve.s3_credential]: `secret_access_key` uses the {scheme:?} scheme, which is not \
supported (use `boatramp:<name>` or `env:<VAR>`)"
),
Self::Backend(msg) => write!(f, "[serve.s3_credential]: {msg}"),
Self::NotUtf8 => write!(
f,
"[serve.s3_credential]: the resolved `secret_access_key` is not valid UTF-8"
),
}
}
}
impl std::error::Error for S3CredentialError {}
enum SecretRef<'a> {
Env(&'a str),
Boatramp(&'a str),
Unsupported(&'a str),
}
fn parse_secret_ref(secret_ref: &str) -> SecretRef<'_> {
match secret_ref.split_once(':') {
Some(("env", host_var)) => SecretRef::Env(host_var),
Some(("boatramp", name)) => SecretRef::Boatramp(name),
Some((scheme, _)) => SecretRef::Unsupported(scheme),
None => SecretRef::Env(secret_ref),
}
}
pub async fn resolve_s3_credential(
config: &crate::config::S3CredentialConfig,
kv: Arc<dyn KvStore>,
envelope: Option<Arc<dyn KeyEnvelope>>,
allow_env_secret_refs: bool,
env_source: &dyn EnvSource,
) -> Result<SealedS3Credential, S3CredentialError> {
let access_key_id = config.access_key_id.trim();
if access_key_id.is_empty() {
return Err(S3CredentialError::EmptyAccessKeyId);
}
let secret_access_key = match parse_secret_ref(&config.secret_access_key) {
SecretRef::Boatramp(name) => {
#[cfg(feature = "s3-sealed-cred-gate-mutation")]
if gate_mutation::env_on("BOATRAMP_S3SEALED_MUTATE_READ_ENV") {
return Ok(SealedS3Credential {
access_key_id: access_key_id.to_string(),
secret_access_key: env_source.get("AWS_SECRET_ACCESS_KEY").unwrap_or_default(),
});
}
#[cfg(feature = "s3-sealed-cred-gate-mutation")]
if envelope.is_none() && gate_mutation::env_on("BOATRAMP_S3SEALED_MUTATE_ENV_FALLBACK")
{
return Ok(SealedS3Credential {
access_key_id: access_key_id.to_string(),
secret_access_key: env_source.get("AWS_SECRET_ACCESS_KEY").unwrap_or_default(),
});
}
let envelope = envelope.ok_or(S3CredentialError::NoEnvelope)?;
let store = SecretStore::new(kv, envelope);
match store.get(ProjectRef::DEFAULT, name).await {
Ok(Some(bytes)) => {
String::from_utf8(bytes).map_err(|_| S3CredentialError::NotUtf8)?
}
Ok(None) => {
return Err(S3CredentialError::MissingBoatrampSecret(name.to_string()));
}
Err(e) => return Err(S3CredentialError::Backend(e.to_string())),
}
}
SecretRef::Env(host_var) => {
if !allow_env_secret_refs {
return Err(S3CredentialError::EnvRefNotPermitted(host_var.to_string()));
}
env_source
.get(host_var)
.ok_or_else(|| S3CredentialError::EnvVarUnset(host_var.to_string()))?
}
SecretRef::Unsupported(scheme) => {
return Err(S3CredentialError::UnsupportedScheme(scheme.to_string()));
}
};
if secret_access_key.trim().is_empty() {
return Err(S3CredentialError::EmptySecret);
}
Ok(SealedS3Credential {
access_key_id: access_key_id.to_string(),
secret_access_key,
})
}
pub fn boatramp_ref_name(secret_ref: &str) -> Option<&str> {
match parse_secret_ref(secret_ref) {
SecretRef::Boatramp(name) => Some(name),
SecretRef::Env(_) | SecretRef::Unsupported(_) => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
use boatramp_core::env::MapEnv;
use boatramp_core::kv::MemoryKv;
struct IdentityEnvelope;
#[async_trait::async_trait]
impl KeyEnvelope for IdentityEnvelope {
async fn wrap(
&self,
plaintext: &[u8],
) -> Result<Vec<u8>, boatramp_core::envelope::EnvelopeError> {
Ok(plaintext.to_vec())
}
async fn unwrap(
&self,
sealed: &[u8],
) -> Result<Vec<u8>, boatramp_core::envelope::EnvelopeError> {
Ok(sealed.to_vec())
}
}
async fn seed_boatramp_secret(kv: &Arc<dyn KvStore>, name: &str, value: &str) {
let store = SecretStore::new(kv.clone(), Arc::new(IdentityEnvelope));
store
.set(ProjectRef::DEFAULT, name, value.as_bytes())
.await
.expect("seal secret");
}
fn cfg(access_key_id: &str, secret_ref: &str) -> crate::config::S3CredentialConfig {
crate::config::S3CredentialConfig {
access_key_id: access_key_id.to_string(),
secret_access_key: secret_ref.to_string(),
}
}
#[tokio::test]
async fn boatramp_ref_resolves_via_the_envelope() {
let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
seed_boatramp_secret(&kv, "tigris-key", "SEALED-SECRET-VALUE").await;
let env = MapEnv::new().with("AWS_SECRET_ACCESS_KEY", "AMBIENT-ENV-VALUE");
let resolved = resolve_s3_credential(
&cfg("AKID-PUBLIC", "boatramp:tigris-key"),
kv,
Some(Arc::new(IdentityEnvelope)),
false,
&env,
)
.await
.expect("resolve");
assert_eq!(resolved.access_key_id(), "AKID-PUBLIC");
assert_eq!(resolved.secret_access_key(), "SEALED-SECRET-VALUE");
}
#[tokio::test]
async fn boatramp_ref_with_no_envelope_fails_closed() {
let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
let env = MapEnv::new().with("AWS_SECRET_ACCESS_KEY", "AMBIENT-ENV-VALUE");
let err = resolve_s3_credential(
&cfg("AKID-PUBLIC", "boatramp:tigris-key"),
kv,
None,
true,
&env,
)
.await
.expect_err("must fail closed with no envelope");
assert_eq!(err, S3CredentialError::NoEnvelope);
}
#[tokio::test]
async fn missing_boatramp_secret_is_an_error_not_a_fallback() {
let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
let env = MapEnv::new().with("AWS_SECRET_ACCESS_KEY", "AMBIENT-ENV-VALUE");
let err = resolve_s3_credential(
&cfg("AKID-PUBLIC", "boatramp:absent"),
kv,
Some(Arc::new(IdentityEnvelope)),
true,
&env,
)
.await
.expect_err("absent sealed secret is an error");
assert_eq!(
err,
S3CredentialError::MissingBoatrampSecret("absent".to_string())
);
}
#[tokio::test]
async fn env_ref_is_posture_gated() {
let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
let env = MapEnv::new().with("MY_S3_SECRET", "ENV-SECRET-VALUE");
let err = resolve_s3_credential(
&cfg("AKID-PUBLIC", "env:MY_S3_SECRET"),
kv.clone(),
None,
false,
&env,
)
.await
.expect_err("env ref refused under strict posture");
assert_eq!(
err,
S3CredentialError::EnvRefNotPermitted("MY_S3_SECRET".to_string())
);
let ok = resolve_s3_credential(
&cfg("AKID-PUBLIC", "env:MY_S3_SECRET"),
kv,
None,
true,
&env,
)
.await
.expect("env ref permitted under dev posture");
assert_eq!(ok.secret_access_key(), "ENV-SECRET-VALUE");
}
#[tokio::test]
async fn empty_access_key_id_is_refused() {
let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
let env = MapEnv::new();
let err = resolve_s3_credential(
&cfg("", "boatramp:tigris-key"),
kv,
Some(Arc::new(IdentityEnvelope)),
true,
&env,
)
.await
.expect_err("empty access-key-id refused");
assert_eq!(err, S3CredentialError::EmptyAccessKeyId);
}
#[test]
fn debug_redacts_the_secret() {
let cred = SealedS3Credential {
access_key_id: "AKID-PUBLIC".to_string(),
secret_access_key: "SUPER-SECRET-DO-NOT-LOG".to_string(),
};
let dbg = format!("{cred:?}");
assert!(
!dbg.contains("SUPER-SECRET-DO-NOT-LOG"),
"the secret access key must be redacted from Debug: {dbg}"
);
assert!(
dbg.contains("<redacted>"),
"redaction marker present: {dbg}"
);
assert!(
dbg.contains("AKID-PUBLIC"),
"the access-key id is public: {dbg}"
);
}
#[tokio::test]
async fn empty_resolved_secret_fails_closed() {
let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
let env = MapEnv::new().with("EMPTY_S3_SECRET", "");
let err = resolve_s3_credential(
&cfg("AKID-PUBLIC", "env:EMPTY_S3_SECRET"),
kv.clone(),
None,
true,
&env,
)
.await
.expect_err("empty env secret must fail closed");
assert_eq!(err, S3CredentialError::EmptySecret);
let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
seed_boatramp_secret(&kv, "empty-sealed", "").await;
let err = resolve_s3_credential(
&cfg("AKID-PUBLIC", "boatramp:empty-sealed"),
kv,
Some(Arc::new(IdentityEnvelope)),
false,
&MapEnv::new(),
)
.await
.expect_err("empty sealed secret must fail closed");
assert_eq!(err, S3CredentialError::EmptySecret);
}
#[test]
fn boatramp_ref_name_is_a_scheme_check() {
assert_eq!(boatramp_ref_name("boatramp:tigris-key"), Some("tigris-key"));
assert_eq!(boatramp_ref_name("env:AWS_SECRET_ACCESS_KEY"), None);
assert_eq!(boatramp_ref_name("BARE_VAR"), None);
assert_eq!(boatramp_ref_name("vault:x"), None);
}
#[test]
fn parse_secret_ref_matches_the_server_scheme() {
assert!(matches!(
parse_secret_ref("boatramp:x"),
SecretRef::Boatramp("x")
));
assert!(matches!(parse_secret_ref("env:VAR"), SecretRef::Env("VAR")));
assert!(matches!(parse_secret_ref("BARE"), SecretRef::Env("BARE")));
assert!(matches!(
parse_secret_ref("vault:x"),
SecretRef::Unsupported("vault")
));
}
}
#[cfg(all(test, feature = "s3-sealed-cred-gate-mutation"))]
mod gate {
use super::*;
use boatramp_core::env::MapEnv;
use boatramp_core::kv::MemoryKv;
struct IdentityEnvelope;
#[async_trait::async_trait]
impl KeyEnvelope for IdentityEnvelope {
async fn wrap(
&self,
plaintext: &[u8],
) -> Result<Vec<u8>, boatramp_core::envelope::EnvelopeError> {
Ok(plaintext.to_vec())
}
async fn unwrap(
&self,
sealed: &[u8],
) -> Result<Vec<u8>, boatramp_core::envelope::EnvelopeError> {
Ok(sealed.to_vec())
}
}
const SEALED: &str = "SEALED-TIGRIS-SECRET";
const AMBIENT_ENV: &str = "AMBIENT-AWS-SECRET";
async fn seeded_kv() -> Arc<dyn KvStore> {
let kv: Arc<dyn KvStore> = Arc::new(MemoryKv::new());
SecretStore::new(kv.clone(), Arc::new(IdentityEnvelope))
.set(ProjectRef::DEFAULT, "tigris-key", SEALED.as_bytes())
.await
.expect("seal");
kv
}
fn cfg(secret_ref: &str) -> crate::config::S3CredentialConfig {
crate::config::S3CredentialConfig {
access_key_id: "AKID-PUBLIC".to_string(),
secret_access_key: secret_ref.to_string(),
}
}
async fn invariant_1_sealed_not_env() {
let kv = seeded_kv().await;
let env = MapEnv::new().with("AWS_SECRET_ACCESS_KEY", AMBIENT_ENV);
let cred = resolve_s3_credential(
&cfg("boatramp:tigris-key"),
kv,
Some(Arc::new(IdentityEnvelope)),
false,
&env,
)
.await
.expect("resolve sealed");
assert_eq!(
cred.secret_access_key(),
SEALED,
"the SEALED credential must be used, not the ambient env value"
);
assert_ne!(
cred.secret_access_key(),
AMBIENT_ENV,
"must not read the env"
);
let opts = boatramp_storage::S3Options {
bucket: "b".to_string(),
endpoint: None,
region: None,
force_path_style: false,
credential: Some(cred.as_pair()),
};
assert_eq!(
opts.credential
.as_ref()
.map(|(id, s)| (id.as_str(), s.as_str())),
Some(("AKID-PUBLIC", SEALED)),
"S3Options.credential carries the sealed pair the S3 client will sign with"
);
}
async fn invariant_2_no_envelope_fails_closed() {
let kv = seeded_kv().await;
let env = MapEnv::new().with("AWS_SECRET_ACCESS_KEY", AMBIENT_ENV);
let result = resolve_s3_credential(&cfg("boatramp:tigris-key"), kv, None, true, &env).await;
match result {
Err(S3CredentialError::NoEnvelope) => {}
Ok(_) => panic!(
"a sealed ref with no envelope must FAIL CLOSED, not fall back to the env chain"
),
Err(other) => panic!("expected NoEnvelope, got {other:?}"),
}
}
async fn invariant_3_debug_redacts() {
let kv = seeded_kv().await;
let env = MapEnv::new();
let cred = resolve_s3_credential(
&cfg("boatramp:tigris-key"),
kv,
Some(Arc::new(IdentityEnvelope)),
false,
&env,
)
.await
.expect("resolve");
let dbg = format!("{cred:?}");
assert!(
!dbg.contains(SEALED),
"the secret must be redacted from Debug: {dbg}"
);
}
async fn invariant_4_absent_source_is_ambient() {
let opts = boatramp_storage::S3Options {
bucket: "b".to_string(),
endpoint: None,
region: None,
force_path_style: false,
credential: None,
};
assert!(
opts.credential.is_none(),
"absent source ⇒ no explicit provider ⇒ the ambient AWS env chain (unchanged)"
);
}
#[tokio::test]
async fn s3_sealed_cred_sourcing_gate() {
invariant_1_sealed_not_env().await;
invariant_2_no_envelope_fails_closed().await;
invariant_3_debug_redacts().await;
invariant_4_absent_source_is_ambient().await;
println!(
"S3 SEALED-CRED SOURCING OK: the node-level base S3 credential is sourced from the \
[secrets] sealed store (KeyEnvelope), injected into the S3 blob backend + AWS cloud \
minter, fail-closed with no envelope, and redacted from Debug. Mutation-verified: \
BOATRAMP_S3SEALED_MUTATE_{{READ_ENV,ENV_FALLBACK,PLAIN_DEBUG}}=1 each FAIL this gate."
);
}
}