use std::collections::{BTreeMap, HashMap, VecDeque};
use std::sync::Arc;
use async_trait::async_trait;
use base64::Engine;
use cloacina_workflow::secret::{SecretResolver, SecretResolverError};
use crate::crypto::envelope::{
self, generate_ephemeral_keypair, EnvelopeError, EphemeralPrivateKey,
};
use crate::fleet::protocol::{EphemeralKeyEntry, WrappedSecret};
#[derive(Debug, thiserror::Error)]
pub enum FleetSecretError {
#[error("resolve failed for '{name}': {source}")]
Resolve {
name: String,
#[source]
source: SecretResolverError,
},
#[error(transparent)]
Envelope(#[from] EnvelopeError),
#[error("malformed wrapped-secret encoding for '{0}'")]
Encoding(String),
#[error("unwrapped payload for '{0}' was not a valid field map")]
Payload(String),
}
pub fn secret_aad(execution_id: &str, name: &str) -> Vec<u8> {
format!("{execution_id}/{name}").into_bytes()
}
pub async fn resolve_and_wrap_secrets(
resolver: &dyn SecretResolver,
names: &[String],
execution_id: &str,
recipient_public_key: &[u8],
) -> Result<Vec<WrappedSecret>, FleetSecretError> {
let mut out = Vec::with_capacity(names.len());
for name in names {
let fields = resolver
.resolve(name)
.await
.map_err(|source| FleetSecretError::Resolve {
name: name.clone(),
source,
})?;
out.push(wrap_field_map(
name,
&fields,
execution_id,
recipient_public_key,
)?);
}
Ok(out)
}
pub fn wrap_field_map(
name: &str,
fields: &BTreeMap<String, String>,
execution_id: &str,
recipient_public_key: &[u8],
) -> Result<WrappedSecret, FleetSecretError> {
let plaintext =
serde_json::to_vec(fields).map_err(|_| FleetSecretError::Payload(name.to_string()))?;
let aad = secret_aad(execution_id, name);
let (enc, ciphertext) = envelope::wrap(recipient_public_key, &plaintext, &aad)?;
let b64 = base64::engine::general_purpose::STANDARD;
Ok(WrappedSecret {
name: name.to_string(),
enc_b64: b64.encode(enc),
ciphertext_b64: b64.encode(ciphertext),
})
}
#[derive(Clone, Default)]
pub struct InMemorySecretResolver {
secrets: HashMap<String, BTreeMap<String, String>>,
}
impl std::fmt::Debug for InMemorySecretResolver {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let names: Vec<&String> = self.secrets.keys().collect();
f.debug_struct("InMemorySecretResolver")
.field("names", &names)
.finish()
}
}
impl InMemorySecretResolver {
pub fn new(secrets: HashMap<String, BTreeMap<String, String>>) -> Self {
Self { secrets }
}
pub fn empty() -> Self {
Self::default()
}
pub fn from_wrapped(
private_key: &EphemeralPrivateKey,
wrapped: &[WrappedSecret],
execution_id: &str,
) -> Result<Self, FleetSecretError> {
let b64 = base64::engine::general_purpose::STANDARD;
let mut secrets = HashMap::with_capacity(wrapped.len());
for w in wrapped {
let enc = b64
.decode(&w.enc_b64)
.map_err(|_| FleetSecretError::Encoding(w.name.clone()))?;
let ciphertext = b64
.decode(&w.ciphertext_b64)
.map_err(|_| FleetSecretError::Encoding(w.name.clone()))?;
let aad = secret_aad(execution_id, &w.name);
let plaintext = envelope::unwrap(private_key, &enc, &ciphertext, &aad)?;
let fields: BTreeMap<String, String> = serde_json::from_slice(&plaintext)
.map_err(|_| FleetSecretError::Payload(w.name.clone()))?;
secrets.insert(w.name.clone(), fields);
}
Ok(Self { secrets })
}
pub fn len(&self) -> usize {
self.secrets.len()
}
pub fn is_empty(&self) -> bool {
self.secrets.is_empty()
}
pub fn into_arc(self) -> Arc<dyn SecretResolver> {
Arc::new(self)
}
}
#[async_trait]
impl SecretResolver for InMemorySecretResolver {
async fn resolve(&self, name: &str) -> Result<BTreeMap<String, String>, SecretResolverError> {
self.secrets
.get(name)
.cloned()
.ok_or_else(|| SecretResolverError::NotFound(name.to_string()))
}
}
pub fn secret_ref_names(context: &serde_json::Value) -> Vec<String> {
let mut names = std::collections::BTreeSet::new();
if let Some(serde_json::Value::Object(map)) =
context.get(cloacina_workflow::secret::SECRET_REFS_KEY)
{
for v in map.values() {
if let serde_json::Value::String(name) = v {
names.insert(name.clone());
}
}
}
names.into_iter().collect()
}
pub fn decode_pool_public_key(entry: &EphemeralKeyEntry) -> Result<Vec<u8>, FleetSecretError> {
base64::engine::general_purpose::STANDARD
.decode(&entry.public_key_b64)
.map_err(|_| FleetSecretError::Encoding(entry.key_id.clone()))
}
#[derive(Default)]
pub struct AgentKeyPool {
keys: HashMap<String, EphemeralPrivateKey>,
}
impl std::fmt::Debug for AgentKeyPool {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("AgentKeyPool")
.field("available", &self.keys.len())
.finish()
}
}
impl AgentKeyPool {
pub fn new() -> Self {
Self::default()
}
pub fn mint(&mut self, n: usize) -> Vec<EphemeralKeyEntry> {
let b64 = base64::engine::general_purpose::STANDARD;
let mut out = Vec::with_capacity(n);
for _ in 0..n {
let kp = generate_ephemeral_keypair();
let key_id = uuid::Uuid::new_v4().to_string();
out.push(EphemeralKeyEntry {
key_id: key_id.clone(),
public_key_b64: b64.encode(&kp.public_key_bytes),
});
self.keys.insert(key_id, kp.private);
}
out
}
pub fn len(&self) -> usize {
self.keys.len()
}
pub fn is_empty(&self) -> bool {
self.keys.is_empty()
}
pub fn take(&mut self, key_id: &str) -> Option<EphemeralPrivateKey> {
self.keys.remove(key_id)
}
}
#[derive(Debug, Default, Clone)]
pub struct ServerKeyPool {
unused: VecDeque<EphemeralKeyEntry>,
}
impl ServerKeyPool {
pub fn new() -> Self {
Self::default()
}
pub fn from_entries(entries: Vec<EphemeralKeyEntry>) -> Self {
Self {
unused: entries.into(),
}
}
pub fn replenish(&mut self, entries: impl IntoIterator<Item = EphemeralKeyEntry>) -> usize {
let mut added = 0;
for e in entries {
if !self.unused.iter().any(|x| x.key_id == e.key_id) {
self.unused.push_back(e);
added += 1;
}
}
added
}
pub fn consume(&mut self) -> Option<EphemeralKeyEntry> {
self.unused.pop_front()
}
pub fn len(&self) -> usize {
self.unused.len()
}
pub fn is_empty(&self) -> bool {
self.unused.is_empty()
}
pub fn replenish_deficit(&self, target: usize) -> usize {
target.saturating_sub(self.unused.len())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::crypto::envelope::generate_ephemeral_keypair;
use cloacina_workflow::Context;
fn field_map(pairs: &[(&str, &str)]) -> BTreeMap<String, String> {
pairs
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect()
}
#[tokio::test]
async fn wrap_unwrap_serves_through_context() {
let kp = generate_ephemeral_keypair();
let fields = field_map(&[("host", "db.internal"), ("password", "hunter2")]);
let wrapped = wrap_field_map("db_prod", &fields, "exec-1", &kp.public_key_bytes).unwrap();
assert!(!wrapped.ciphertext_b64.contains("hunter2"));
assert!(!wrapped.enc_b64.contains("hunter2"));
let resolver =
InMemorySecretResolver::from_wrapped(&kp.private, &[wrapped], "exec-1").unwrap();
let ctx = Context::<serde_json::Value>::new().with_secret_resolver(resolver.into_arc());
let resolved = ctx.secret("db_prod").await.unwrap();
assert_eq!(resolved.get("password").unwrap(), "hunter2");
assert_eq!(resolved.get("host").unwrap(), "db.internal");
let json = ctx.to_json().unwrap();
assert!(
!json.contains("hunter2"),
"secret leaked into context: {json}"
);
}
#[test]
fn blob_for_agent_a_not_unwrappable_by_agent_b() {
let a = generate_ephemeral_keypair();
let b = generate_ephemeral_keypair();
let wrapped = wrap_field_map(
"db_prod",
&field_map(&[("password", "A-only")]),
"exec-1",
&a.public_key_bytes,
)
.unwrap();
let err = InMemorySecretResolver::from_wrapped(&b.private, &[wrapped], "exec-1")
.expect_err("agent B must not unwrap A's blob");
assert!(matches!(err, FleetSecretError::Envelope(_)));
}
#[test]
fn blob_bound_to_execution_id() {
let kp = generate_ephemeral_keypair();
let wrapped = wrap_field_map(
"db_prod",
&field_map(&[("password", "bound")]),
"exec-1",
&kp.public_key_bytes,
)
.unwrap();
let err = InMemorySecretResolver::from_wrapped(&kp.private, &[wrapped], "exec-2")
.expect_err("wrong execution id must fail");
assert!(matches!(err, FleetSecretError::Envelope(_)));
}
fn decode_pk(entry: &EphemeralKeyEntry) -> Vec<u8> {
decode_pool_public_key(entry).unwrap()
}
#[test]
fn two_dispatches_use_two_different_one_time_keys() {
let mut agent = AgentKeyPool::new();
let mut server = ServerKeyPool::from_entries(agent.mint(2));
let k1 = server.consume().expect("key for exec-1");
let w1 = wrap_field_map(
"db",
&field_map(&[("password", "value-1")]),
"exec-1",
&decode_pk(&k1),
)
.unwrap();
let k2 = server.consume().expect("key for exec-2");
assert_ne!(k1.key_id, k2.key_id, "each dispatch must spend a fresh key");
let w2 = wrap_field_map(
"db",
&field_map(&[("password", "value-2")]),
"exec-2",
&decode_pk(&k2),
)
.unwrap();
let priv1 = agent.take(&k1.key_id).expect("exec-1 private key");
InMemorySecretResolver::from_wrapped(&priv1, std::slice::from_ref(&w1), "exec-1")
.expect("exec-1 unwraps with its own key");
assert!(
InMemorySecretResolver::from_wrapped(&priv1, std::slice::from_ref(&w2), "exec-2")
.is_err(),
"exec-2 blob must not open with exec-1 key"
);
let priv2 = agent.take(&k2.key_id).expect("exec-2 private key");
assert!(
InMemorySecretResolver::from_wrapped(&priv2, std::slice::from_ref(&w1), "exec-1")
.is_err(),
"exec-1 blob must not open with exec-2 key"
);
InMemorySecretResolver::from_wrapped(&priv2, std::slice::from_ref(&w2), "exec-2")
.expect("exec-2 unwraps with its own key");
assert!(agent.take(&k1.key_id).is_none());
assert!(agent.take(&k2.key_id).is_none());
assert!(agent.is_empty());
}
#[test]
fn a_key_cannot_be_consumed_twice() {
let mut agent = AgentKeyPool::new();
let entries = agent.mint(1);
let key_id = entries[0].key_id.clone();
let mut server = ServerKeyPool::from_entries(entries);
let first = server.consume().expect("first consume");
assert_eq!(first.key_id, key_id);
assert!(
server.consume().is_none(),
"the same key must not be consumable again"
);
}
#[test]
fn pool_exhaustion_then_replenish() {
let mut agent = AgentKeyPool::new();
let mut server = ServerKeyPool::from_entries(agent.mint(1));
assert!(server.consume().is_some());
assert!(server.consume().is_none(), "exhausted pool yields no key");
assert_eq!(
server.replenish_deficit(3),
3,
"server should ask for 3 keys"
);
let added = server.replenish(agent.mint(2));
assert_eq!(added, 2);
assert!(server.consume().is_some());
assert_eq!(server.replenish_deficit(3), 2);
}
#[test]
fn replenish_dedupes_by_key_id() {
let mut agent = AgentKeyPool::new();
let entries = agent.mint(2);
let mut server = ServerKeyPool::new();
assert_eq!(server.replenish(entries.clone()), 2);
assert_eq!(server.replenish(entries), 0, "retry adds nothing");
assert_eq!(server.len(), 2);
}
#[tokio::test]
async fn pool_end_to_end_only_ciphertext_and_key_id_on_wire() {
use crate::fleet::{ArtifactRef, WorkPacket, AGENT_PROTOCOL_VERSION};
let mut agent = AgentKeyPool::new();
let mut server = ServerKeyPool::from_entries(agent.mint(1));
let key = server.consume().expect("a pooled key");
let wrapped = wrap_field_map(
"db_prod",
&field_map(&[("password", "hunter2")]),
"exec-9",
&decode_pk(&key),
)
.unwrap();
let packet = WorkPacket {
protocol_version: AGENT_PROTOCOL_VERSION,
task_execution_id: "exec-9".into(),
workflow_execution_id: "w1".into(),
task_name: "ns::task".into(),
attempt: 1,
context: serde_json::json!({}),
artifact: ArtifactRef {
digest: "d".into(),
fetch_url: "/x".into(),
build_target_triple: "aarch64-apple-darwin".into(),
},
timeout_seconds: 60,
tenant_id: None,
language: None,
wrapped_secrets: vec![wrapped],
secret_key_id: Some(key.key_id.clone()),
};
let json = serde_json::to_string(&packet).unwrap();
assert!(!json.contains("hunter2"), "plaintext leaked onto the wire");
assert!(json.contains(&key.key_id), "packet must carry the key_id");
let key_id = packet.secret_key_id.as_deref().unwrap();
let priv_key = agent
.take(key_id)
.expect("agent holds the pooled private key");
let resolver = InMemorySecretResolver::from_wrapped(
&priv_key,
&packet.wrapped_secrets,
&packet.task_execution_id,
)
.unwrap();
let ctx = Context::<serde_json::Value>::new().with_secret_resolver(resolver.into_arc());
assert_eq!(
ctx.secret("db_prod")
.await
.unwrap()
.get("password")
.unwrap(),
"hunter2"
);
assert!(agent.take(key_id).is_none());
}
#[test]
fn secret_ref_names_reads_the_alias_map() {
let ctx = serde_json::json!({
"some_param": 1,
cloacina_workflow::secret::SECRET_REFS_KEY: {
"db": "db_prod",
"also_db": "db_prod",
"api": "stripe",
}
});
assert_eq!(
secret_ref_names(&ctx),
vec!["db_prod".to_string(), "stripe".to_string()]
);
assert!(secret_ref_names(&serde_json::json!({"x": 1})).is_empty());
}
#[test]
fn unknown_key_id_is_a_clean_miss() {
let mut agent = AgentKeyPool::new();
let _ = agent.mint(1);
assert!(agent.take("never-minted-this").is_none());
}
#[tokio::test]
async fn in_memory_resolver_reports_missing() {
let resolver = InMemorySecretResolver::empty();
assert!(matches!(
resolver.resolve("absent").await.unwrap_err(),
SecretResolverError::NotFound(_)
));
}
#[cfg(feature = "sqlite")]
#[tokio::test]
async fn resolve_and_wrap_through_store_and_grant_gate() {
use crate::dal::unified::DAL;
use crate::database::universal_types::UniversalUuid;
use crate::database::Database;
use crate::security::{SecretAllow, SecretStore, SecretStoreResolver};
use std::collections::HashSet;
let url = format!(
"file:fleet_secret_test_{}?mode=memory&cache=shared",
uuid::Uuid::new_v4()
);
let db = Database::new(&url, "", 5);
db.run_migrations().await.unwrap();
let store = SecretStore::new(DAL::new(db));
let org = UniversalUuid::new_v4();
let kek = vec![7u8; 32];
store
.create_secret(
org,
"db_prod",
&field_map(&[("password", "at-rest-value")]),
&kek,
)
.await
.unwrap();
store
.create_secret(org, "other", &field_map(&[("token", "nope")]), &kek)
.await
.unwrap();
let allow = SecretAllow::List(HashSet::from(["db_prod".to_string()]));
let resolver = SecretStoreResolver::new_gated(store, org, kek, allow);
let kp = generate_ephemeral_keypair();
let wrapped = resolve_and_wrap_secrets(
&resolver,
&["db_prod".to_string()],
"exec-42",
&kp.public_key_bytes,
)
.await
.unwrap();
assert_eq!(wrapped.len(), 1);
assert!(!wrapped[0].ciphertext_b64.contains("at-rest-value"));
let agent_resolver =
InMemorySecretResolver::from_wrapped(&kp.private, &wrapped, "exec-42").unwrap();
let ctx =
Context::<serde_json::Value>::new().with_secret_resolver(agent_resolver.into_arc());
assert_eq!(
ctx.secret("db_prod")
.await
.unwrap()
.get("password")
.unwrap(),
"at-rest-value"
);
let denied = resolve_and_wrap_secrets(
&resolver,
&["other".to_string()],
"exec-42",
&kp.public_key_bytes,
)
.await;
assert!(matches!(
denied,
Err(FleetSecretError::Resolve {
source: SecretResolverError::NotGranted(_),
..
})
));
}
}