## Summary This introduces the first working foundation for Codex managed secrets: a small Rust crate that can securely store and retrieve secrets locally. Concretely, it adds a `codex-secrets` crate that: - encrypts a local secrets file using `age` - generates a high-entropy encryption key - stores that key in the OS keyring ## What this enables - A secure local persistence model for secrets - A clean, isolated place for future provider backends - A clear boundary: Codex can become a credential broker without putting plaintext secrets in config files ## Implementation details - New crate: `codex-rs/secrets/` - Encryption: `age` with scrypt recipient/identity - Key generation: `OsRng` (32 random bytes) - Key storage: OS keyring via `codex-keyring-store` ## Testing - `cd codex-rs && just fmt` - `cd codex-rs && cargo test -p codex-secrets`
243 lines
7.1 KiB
Rust
243 lines
7.1 KiB
Rust
use std::fmt;
|
|
use std::path::Path;
|
|
use std::path::PathBuf;
|
|
use std::sync::Arc;
|
|
|
|
use anyhow::Result;
|
|
use codex_keyring_store::DefaultKeyringStore;
|
|
use codex_keyring_store::KeyringStore;
|
|
use schemars::JsonSchema;
|
|
use serde::Deserialize;
|
|
use serde::Serialize;
|
|
use sha2::Digest;
|
|
use sha2::Sha256;
|
|
|
|
mod local;
|
|
|
|
pub use local::LocalSecretsBackend;
|
|
|
|
const KEYRING_SERVICE: &str = "codex";
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
|
pub struct SecretName(String);
|
|
|
|
impl SecretName {
|
|
pub fn new(raw: &str) -> Result<Self> {
|
|
let trimmed = raw.trim();
|
|
anyhow::ensure!(!trimmed.is_empty(), "secret name must not be empty");
|
|
anyhow::ensure!(
|
|
trimmed
|
|
.chars()
|
|
.all(|ch| ch.is_ascii_uppercase() || ch.is_ascii_digit() || ch == '_'),
|
|
"secret name must contain only A-Z, 0-9, or _"
|
|
);
|
|
Ok(Self(trimmed.to_string()))
|
|
}
|
|
|
|
pub fn as_str(&self) -> &str {
|
|
self.0.as_str()
|
|
}
|
|
}
|
|
|
|
impl fmt::Display for SecretName {
|
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
|
write!(f, "{}", self.0)
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
|
pub enum SecretScope {
|
|
Global,
|
|
Environment(String),
|
|
}
|
|
|
|
impl SecretScope {
|
|
pub fn environment(environment_id: impl Into<String>) -> Result<Self> {
|
|
let env_id = environment_id.into();
|
|
let trimmed = env_id.trim();
|
|
anyhow::ensure!(!trimmed.is_empty(), "environment id must not be empty");
|
|
Ok(Self::Environment(trimmed.to_string()))
|
|
}
|
|
|
|
pub fn canonical_key(&self, name: &SecretName) -> String {
|
|
// Stable, env-safe identifier used as the on-disk map key.
|
|
match self {
|
|
Self::Global => format!("global/{}", name.as_str()),
|
|
Self::Environment(environment_id) => {
|
|
format!("env/{environment_id}/{}", name.as_str())
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct SecretListEntry {
|
|
pub scope: SecretScope,
|
|
pub name: SecretName,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Default)]
|
|
#[serde(rename_all = "lowercase")]
|
|
pub enum SecretsBackendKind {
|
|
#[default]
|
|
Local,
|
|
}
|
|
|
|
pub trait SecretsBackend: Send + Sync {
|
|
fn set(&self, scope: &SecretScope, name: &SecretName, value: &str) -> Result<()>;
|
|
fn get(&self, scope: &SecretScope, name: &SecretName) -> Result<Option<String>>;
|
|
fn delete(&self, scope: &SecretScope, name: &SecretName) -> Result<bool>;
|
|
fn list(&self, scope_filter: Option<&SecretScope>) -> Result<Vec<SecretListEntry>>;
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
pub struct SecretsManager {
|
|
backend: Arc<dyn SecretsBackend>,
|
|
}
|
|
|
|
impl SecretsManager {
|
|
pub fn new(codex_home: PathBuf, backend_kind: SecretsBackendKind) -> Self {
|
|
let backend: Arc<dyn SecretsBackend> = match backend_kind {
|
|
SecretsBackendKind::Local => {
|
|
let keyring_store: Arc<dyn KeyringStore> = Arc::new(DefaultKeyringStore);
|
|
Arc::new(LocalSecretsBackend::new(codex_home, keyring_store))
|
|
}
|
|
};
|
|
Self { backend }
|
|
}
|
|
|
|
pub fn new_with_keyring_store(
|
|
codex_home: PathBuf,
|
|
backend_kind: SecretsBackendKind,
|
|
keyring_store: Arc<dyn KeyringStore>,
|
|
) -> Self {
|
|
let backend: Arc<dyn SecretsBackend> = match backend_kind {
|
|
SecretsBackendKind::Local => {
|
|
Arc::new(LocalSecretsBackend::new(codex_home, keyring_store))
|
|
}
|
|
};
|
|
Self { backend }
|
|
}
|
|
|
|
pub fn set(&self, scope: &SecretScope, name: &SecretName, value: &str) -> Result<()> {
|
|
self.backend.set(scope, name, value)
|
|
}
|
|
|
|
pub fn get(&self, scope: &SecretScope, name: &SecretName) -> Result<Option<String>> {
|
|
self.backend.get(scope, name)
|
|
}
|
|
|
|
pub fn delete(&self, scope: &SecretScope, name: &SecretName) -> Result<bool> {
|
|
self.backend.delete(scope, name)
|
|
}
|
|
|
|
pub fn list(&self, scope_filter: Option<&SecretScope>) -> Result<Vec<SecretListEntry>> {
|
|
self.backend.list(scope_filter)
|
|
}
|
|
}
|
|
|
|
pub fn environment_id_from_cwd(cwd: &Path) -> String {
|
|
if let Some(repo_root) = get_git_repo_root(cwd)
|
|
&& let Some(name) = repo_root.file_name()
|
|
{
|
|
let name = name.to_string_lossy().trim().to_string();
|
|
if !name.is_empty() {
|
|
return name;
|
|
}
|
|
}
|
|
|
|
let canonical = cwd
|
|
.canonicalize()
|
|
.unwrap_or_else(|_| cwd.to_path_buf())
|
|
.to_string_lossy()
|
|
.into_owned();
|
|
let mut hasher = Sha256::new();
|
|
hasher.update(canonical.as_bytes());
|
|
let digest = hasher.finalize();
|
|
let hex = format!("{digest:x}");
|
|
let short = hex.get(..12).unwrap_or(hex.as_str());
|
|
format!("cwd-{short}")
|
|
}
|
|
|
|
fn get_git_repo_root(base_dir: &Path) -> Option<PathBuf> {
|
|
let mut dir = base_dir.to_path_buf();
|
|
|
|
loop {
|
|
if dir.join(".git").exists() {
|
|
return Some(dir);
|
|
}
|
|
|
|
if !dir.pop() {
|
|
break;
|
|
}
|
|
}
|
|
|
|
None
|
|
}
|
|
|
|
pub(crate) fn compute_keyring_account(codex_home: &Path) -> String {
|
|
let canonical = codex_home
|
|
.canonicalize()
|
|
.unwrap_or_else(|_| codex_home.to_path_buf())
|
|
.to_string_lossy()
|
|
.into_owned();
|
|
let mut hasher = Sha256::new();
|
|
hasher.update(canonical.as_bytes());
|
|
let digest = hasher.finalize();
|
|
let hex = format!("{digest:x}");
|
|
let short = hex.get(..16).unwrap_or(hex.as_str());
|
|
format!("secrets|{short}")
|
|
}
|
|
|
|
pub(crate) fn keyring_service() -> &'static str {
|
|
KEYRING_SERVICE
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use codex_keyring_store::tests::MockKeyringStore;
|
|
use pretty_assertions::assert_eq;
|
|
|
|
#[test]
|
|
fn environment_id_fallback_has_cwd_prefix() {
|
|
let dir = tempfile::tempdir().expect("tempdir");
|
|
let env_id = environment_id_from_cwd(dir.path());
|
|
let canonical = dir
|
|
.path()
|
|
.canonicalize()
|
|
.expect("tempdir canonical path should exist")
|
|
.to_string_lossy()
|
|
.into_owned();
|
|
let mut hasher = Sha256::new();
|
|
hasher.update(canonical.as_bytes());
|
|
let digest = hasher.finalize();
|
|
let hex = format!("{digest:x}");
|
|
let short = hex.get(..12).expect("digest has at least 12 chars");
|
|
assert_eq!(env_id, format!("cwd-{short}"));
|
|
}
|
|
|
|
#[test]
|
|
fn manager_round_trips_local_backend() -> Result<()> {
|
|
let codex_home = tempfile::tempdir().expect("tempdir");
|
|
let keyring = Arc::new(MockKeyringStore::default());
|
|
let manager = SecretsManager::new_with_keyring_store(
|
|
codex_home.path().to_path_buf(),
|
|
SecretsBackendKind::Local,
|
|
keyring,
|
|
);
|
|
let scope = SecretScope::Global;
|
|
let name = SecretName::new("GITHUB_TOKEN")?;
|
|
|
|
manager.set(&scope, &name, "token-1")?;
|
|
assert_eq!(manager.get(&scope, &name)?, Some("token-1".to_string()));
|
|
|
|
let listed = manager.list(None)?;
|
|
assert_eq!(listed.len(), 1);
|
|
assert_eq!(listed[0].name, name);
|
|
|
|
assert!(manager.delete(&scope, &name)?);
|
|
assert_eq!(manager.get(&scope, &name)?, None);
|
|
Ok(())
|
|
}
|
|
}
|