mirror of
https://github.com/openai/codex.git
synced 2026-05-03 19:06:58 +00:00
feat(secrets): add codex-secrets crate
This commit is contained in:
24
codex-rs/secrets/Cargo.toml
Normal file
24
codex-rs/secrets/Cargo.toml
Normal 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
223
codex-rs/secrets/src/lib.rs
Normal 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(())
|
||||
}
|
||||
}
|
||||
225
codex-rs/secrets/src/local.rs
Normal file
225
codex-rs/secrets/src/local.rs
Normal 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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user