feat(secrets): add codex-secrets crate

This commit is contained in:
viyatb-oai
2026-01-28 23:15:33 -08:00
parent 7b34cad1b1
commit 73811db351
5 changed files with 988 additions and 28 deletions

View File

@@ -0,0 +1,24 @@
[package]
name = "codex-secrets"
version.workspace = true
edition.workspace = true
license.workspace = true
[lints]
workspace = true
[dependencies]
age = { workspace = true }
anyhow = { workspace = true }
base64 = { workspace = true }
codex-keyring-store = { workspace = true }
rand = { workspace = true }
schemars = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
sha2 = { workspace = true }
tracing = { workspace = true }
[dev-dependencies]
pretty_assertions = { workspace = true }
tempfile = { workspace = true }

223
codex-rs/secrets/src/lib.rs Normal file
View File

@@ -0,0 +1,223 @@
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 {
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)]
#[serde(rename_all = "lowercase")]
pub enum SecretsBackendKind {
Local,
}
impl Default for SecretsBackendKind {
fn default() -> Self {
Self::Local
}
}
#[derive(Debug, Clone)]
pub struct SecretsManager {
backend: Arc<LocalSecretsBackend>,
}
impl SecretsManager {
pub fn new(codex_home: PathBuf, backend_kind: SecretsBackendKind) -> Self {
let keyring_store: Arc<dyn KeyringStore> = Arc::new(DefaultKeyringStore);
Self::new_with_keyring_store(codex_home, backend_kind, keyring_store)
}
pub fn new_with_keyring_store(
codex_home: PathBuf,
backend_kind: SecretsBackendKind,
keyring_store: Arc<dyn KeyringStore>,
) -> Self {
match backend_kind {
SecretsBackendKind::Local => Self {
backend: Arc::new(LocalSecretsBackend::new(codex_home, keyring_store)),
},
}
}
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());
assert!(env_id.starts_with("cwd-"));
}
#[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(())
}
}

View File

@@ -0,0 +1,225 @@
use std::collections::BTreeMap;
use std::fs;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::atomic::Ordering;
use std::sync::atomic::compiler_fence;
use age::decrypt;
use age::encrypt;
use age::scrypt::Identity as ScryptIdentity;
use age::scrypt::Recipient as ScryptRecipient;
use age::secrecy::ExposeSecret;
use age::secrecy::SecretString;
use anyhow::Context;
use anyhow::Result;
use base64::Engine as _;
use base64::engine::general_purpose::STANDARD as BASE64_STANDARD;
use codex_keyring_store::KeyringStore;
use rand::TryRngCore;
use rand::rngs::OsRng;
use serde::Deserialize;
use serde::Serialize;
use tracing::warn;
use super::SecretListEntry;
use super::SecretName;
use super::SecretScope;
use super::compute_keyring_account;
use super::keyring_service;
const SECRETS_VERSION: u8 = 1;
const LOCAL_SECRETS_FILENAME: &str = "local.age";
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
struct SecretsFile {
version: u8,
secrets: BTreeMap<String, String>,
}
impl SecretsFile {
fn new_empty() -> Self {
Self {
version: SECRETS_VERSION,
secrets: BTreeMap::new(),
}
}
}
#[derive(Debug, Clone)]
pub struct LocalSecretsBackend {
codex_home: PathBuf,
keyring_store: Arc<dyn KeyringStore>,
}
impl LocalSecretsBackend {
pub fn new(codex_home: PathBuf, keyring_store: Arc<dyn KeyringStore>) -> Self {
Self {
codex_home,
keyring_store,
}
}
pub fn set(&self, scope: &SecretScope, name: &SecretName, value: &str) -> Result<()> {
anyhow::ensure!(!value.is_empty(), "secret value must not be empty");
let canonical_key = scope.canonical_key(name);
let mut file = self.load_file()?;
file.secrets.insert(canonical_key, value.to_string());
self.save_file(&file)
}
pub fn get(&self, scope: &SecretScope, name: &SecretName) -> Result<Option<String>> {
let canonical_key = scope.canonical_key(name);
let file = self.load_file()?;
Ok(file.secrets.get(&canonical_key).cloned())
}
pub fn delete(&self, scope: &SecretScope, name: &SecretName) -> Result<bool> {
let canonical_key = scope.canonical_key(name);
let mut file = self.load_file()?;
let removed = file.secrets.remove(&canonical_key).is_some();
if removed {
self.save_file(&file)?;
}
Ok(removed)
}
pub fn list(&self, scope_filter: Option<&SecretScope>) -> Result<Vec<SecretListEntry>> {
let file = self.load_file()?;
let mut entries = Vec::new();
for canonical_key in file.secrets.keys() {
let Some(entry) = parse_canonical_key(canonical_key) else {
warn!("skipping invalid canonical secret key: {canonical_key}");
continue;
};
if let Some(scope) = scope_filter
&& entry.scope != *scope
{
continue;
}
entries.push(entry);
}
Ok(entries)
}
fn secrets_dir(&self) -> PathBuf {
self.codex_home.join("secrets")
}
fn secrets_path(&self) -> PathBuf {
self.secrets_dir().join(LOCAL_SECRETS_FILENAME)
}
fn load_file(&self) -> Result<SecretsFile> {
let path = self.secrets_path();
if !path.exists() {
return Ok(SecretsFile::new_empty());
}
let ciphertext = fs::read(&path)
.with_context(|| format!("failed to read secrets file at {}", path.display()))?;
let passphrase = self.load_or_create_passphrase()?;
let plaintext = decrypt_with_passphrase(&ciphertext, &passphrase)?;
let mut parsed: SecretsFile = serde_json::from_slice(&plaintext).with_context(|| {
format!(
"failed to deserialize decrypted secrets file at {}",
path.display()
)
})?;
if parsed.version == 0 {
parsed.version = SECRETS_VERSION;
}
Ok(parsed)
}
fn save_file(&self, file: &SecretsFile) -> Result<()> {
let dir = self.secrets_dir();
fs::create_dir_all(&dir)
.with_context(|| format!("failed to create secrets dir {}", dir.display()))?;
let passphrase = self.load_or_create_passphrase()?;
let plaintext = serde_json::to_vec(file).context("failed to serialize secrets file")?;
let ciphertext = encrypt_with_passphrase(&plaintext, &passphrase)?;
let path = self.secrets_path();
fs::write(&path, ciphertext)
.with_context(|| format!("failed to write secrets file at {}", path.display()))?;
Ok(())
}
fn load_or_create_passphrase(&self) -> Result<SecretString> {
let account = compute_keyring_account(&self.codex_home);
match self
.keyring_store
.load(keyring_service(), &account)
.map_err(|err| anyhow::anyhow!(err.message()))?
{
Some(existing) => Ok(SecretString::from(existing)),
None => {
let generated = generate_passphrase()?;
self.keyring_store
.save(keyring_service(), &account, generated.expose_secret())
.map_err(|err| anyhow::anyhow!(err.message()))
.context("failed to persist secrets key in keyring")?;
Ok(generated)
}
}
}
}
fn generate_passphrase() -> Result<SecretString> {
let mut bytes = [0_u8; 32];
let mut rng = OsRng;
rng.try_fill_bytes(&mut bytes)
.context("failed to generate random secrets key")?;
let encoded = BASE64_STANDARD.encode(bytes);
wipe_bytes(&mut bytes);
Ok(SecretString::from(encoded))
}
fn wipe_bytes(bytes: &mut [u8]) {
for byte in bytes {
// Volatile writes make it much harder for the compiler to elide the wipe.
// SAFETY: `byte` is a valid mutable reference into `bytes`.
unsafe { std::ptr::write_volatile(byte, 0) };
}
compiler_fence(Ordering::SeqCst);
}
fn encrypt_with_passphrase(plaintext: &[u8], passphrase: &SecretString) -> Result<Vec<u8>> {
let recipient = ScryptRecipient::new(passphrase.clone());
encrypt(&recipient, plaintext).context("failed to encrypt secrets file")
}
fn decrypt_with_passphrase(ciphertext: &[u8], passphrase: &SecretString) -> Result<Vec<u8>> {
let identity = ScryptIdentity::new(passphrase.clone());
decrypt(&identity, ciphertext).context("failed to decrypt secrets file")
}
fn parse_canonical_key(canonical_key: &str) -> Option<SecretListEntry> {
let mut parts = canonical_key.split('/');
let scope_kind = parts.next()?;
match scope_kind {
"global" => {
let name = parts.next()?;
if parts.next().is_some() {
return None;
}
let name = SecretName::new(name).ok()?;
Some(SecretListEntry {
scope: SecretScope::Global,
name,
})
}
"env" => {
let environment_id = parts.next()?;
let name = parts.next()?;
if parts.next().is_some() {
return None;
}
let name = SecretName::new(name).ok()?;
let scope = SecretScope::environment(environment_id.to_string()).ok()?;
Some(SecretListEntry { scope, name })
}
_ => None,
}
}