mirror of
https://github.com/openai/codex.git
synced 2026-05-02 04:11:39 +03:00
feat(secrets): add codex-secrets crate (#10142)
## 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`
This commit is contained in:
243
codex-rs/secrets/src/lib.rs
Normal file
243
codex-rs/secrets/src/lib.rs
Normal file
@@ -0,0 +1,243 @@
|
||||
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(())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user