mirror of
https://github.com/openai/codex.git
synced 2026-04-24 22:54:54 +00:00
249 lines
8.2 KiB
Rust
249 lines
8.2 KiB
Rust
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::SecretsBackend;
|
|
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 => {
|
|
// Generate a high-entropy key and persist it in the OS keyring.
|
|
// This keeps secrets out of plaintext config while remaining
|
|
// fully local/offline for the MVP.
|
|
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)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
impl SecretsBackend for LocalSecretsBackend {
|
|
fn set(&self, scope: &SecretScope, name: &SecretName, value: &str) -> Result<()> {
|
|
LocalSecretsBackend::set(self, scope, name, value)
|
|
}
|
|
|
|
fn get(&self, scope: &SecretScope, name: &SecretName) -> Result<Option<String>> {
|
|
LocalSecretsBackend::get(self, scope, name)
|
|
}
|
|
|
|
fn delete(&self, scope: &SecretScope, name: &SecretName) -> Result<bool> {
|
|
LocalSecretsBackend::delete(self, scope, name)
|
|
}
|
|
|
|
fn list(&self, scope_filter: Option<&SecretScope>) -> Result<Vec<SecretListEntry>> {
|
|
LocalSecretsBackend::list(self, scope_filter)
|
|
}
|
|
}
|
|
|
|
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")?;
|
|
// Base64 keeps the keyring payload ASCII-safe without reducing entropy.
|
|
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,
|
|
}
|
|
}
|