Remove age dependency from secrets storage

This commit is contained in:
jif-oai
2026-06-08 10:05:09 +01:00
parent 4e803a017c
commit 9a7c8ee8b9
5 changed files with 193 additions and 448 deletions
-32
View File
File diff suppressed because one or more lines are too long
+20 -376
View File
@@ -196,49 +196,6 @@ dependencies = [
"cpufeatures 0.2.17",
]
[[package]]
name = "age"
version = "0.11.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf640be7658959746f1f0f2faab798f6098a9436a8e18e148d18bc9875e13c4b"
dependencies = [
"age-core",
"base64 0.21.7",
"bech32",
"chacha20poly1305",
"cookie-factory",
"hmac 0.12.1",
"i18n-embed",
"i18n-embed-fl",
"lazy_static",
"nom 7.1.3",
"pin-project",
"rand 0.8.5",
"rust-embed",
"scrypt",
"sha2 0.10.9",
"subtle",
"x25519-dalek",
"zeroize",
]
[[package]]
name = "age-core"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e2bf6a89c984ca9d850913ece2da39e1d200563b0a94b002b253beee4c5acf99"
dependencies = [
"base64 0.21.7",
"chacha20poly1305",
"cookie-factory",
"hkdf 0.12.4",
"io_tee",
"nom 7.1.3",
"rand 0.8.5",
"secrecy",
"sha2 0.10.9",
]
[[package]]
name = "ahash"
version = "0.8.12"
@@ -1250,21 +1207,6 @@ version = "1.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
[[package]]
name = "basic-toml"
version = "0.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba62675e8242a4c4e806d12f11d136e626e6c8361d6b829310732241652a178a"
dependencies = [
"serde",
]
[[package]]
name = "bech32"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d86b93f97252c47b41663388e6d155714a9d0c398b99f1005cbc5f978b29f445"
[[package]]
name = "beef"
version = "0.5.2"
@@ -1295,7 +1237,7 @@ dependencies = [
"proc-macro2",
"quote",
"regex",
"rustc-hash 2.1.1",
"rustc-hash",
"shlex",
"syn 2.0.117",
]
@@ -2025,7 +1967,7 @@ dependencies = [
"tokio",
"tokio-tungstenite",
"tokio-util",
"toml 0.9.11+spec-1.1.0",
"toml",
"toml_edit 0.24.0+spec-1.1.0",
"tracing",
"tracing-opentelemetry",
@@ -2057,7 +1999,7 @@ dependencies = [
"tempfile",
"tokio",
"tokio-tungstenite",
"toml 0.9.11+spec-1.1.0",
"toml",
"tracing",
"url",
]
@@ -2355,7 +2297,7 @@ dependencies = [
"sys-locale",
"tempfile",
"tokio",
"toml 0.9.11+spec-1.1.0",
"toml",
"tracing",
"tracing-appender",
"tracing-subscriber",
@@ -2532,7 +2474,7 @@ dependencies = [
"thiserror 2.0.18",
"tokio",
"tokio-stream",
"toml 0.9.11+spec-1.1.0",
"toml",
"toml_edit 0.24.0+spec-1.1.0",
"tonic",
"tonic-prost",
@@ -2674,7 +2616,7 @@ dependencies = [
"tokio",
"tokio-tungstenite",
"tokio-util",
"toml 0.9.11+spec-1.1.0",
"toml",
"toml_edit 0.24.0+spec-1.1.0",
"tracing",
"tracing-opentelemetry",
@@ -2741,7 +2683,7 @@ dependencies = [
"tempfile",
"thiserror 2.0.18",
"tokio",
"toml 0.9.11+spec-1.1.0",
"toml",
"tracing",
"url",
"wiremock",
@@ -2775,7 +2717,7 @@ dependencies = [
"shlex",
"tempfile",
"tokio",
"toml 0.9.11+spec-1.1.0",
"toml",
"tracing",
"zip 2.4.2",
]
@@ -2862,7 +2804,7 @@ dependencies = [
"tokio",
"tokio-tungstenite",
"tokio-util",
"toml 0.9.11+spec-1.1.0",
"toml",
"tracing",
"uuid",
"wiremock",
@@ -2933,7 +2875,7 @@ dependencies = [
"serde_json",
"serde_yaml",
"tempfile",
"toml 0.9.11+spec-1.1.0",
"toml",
]
[[package]]
@@ -2959,7 +2901,7 @@ dependencies = [
"pretty_assertions",
"schemars 0.8.22",
"serde",
"toml 0.9.11+spec-1.1.0",
"toml",
"tracing",
]
@@ -3394,7 +3336,7 @@ dependencies = [
"schemars 0.8.22",
"serde",
"tempfile",
"toml 0.9.11+spec-1.1.0",
"toml",
]
[[package]]
@@ -3720,9 +3662,9 @@ dependencies = [
name = "codex-secrets"
version = "0.0.0"
dependencies = [
"age",
"anyhow",
"base64 0.22.1",
"chacha20poly1305",
"codex-git-utils",
"codex-keyring-store",
"keyring",
@@ -3735,6 +3677,7 @@ dependencies = [
"sha2 0.10.9",
"tempfile",
"tracing",
"zeroize",
]
[[package]]
@@ -4001,7 +3944,7 @@ dependencies = [
"tokio",
"tokio-stream",
"tokio-util",
"toml 0.9.11+spec-1.1.0",
"toml",
"tracing",
"tracing-appender",
"tracing-subscriber",
@@ -4079,7 +4022,7 @@ dependencies = [
"codex-shell-command",
"pretty_assertions",
"serde",
"toml 0.9.11+spec-1.1.0",
"toml",
]
[[package]]
@@ -4119,7 +4062,7 @@ version = "0.0.0"
dependencies = [
"pretty_assertions",
"serde_json",
"toml 0.9.11+spec-1.1.0",
"toml",
]
[[package]]
@@ -4466,15 +4409,6 @@ dependencies = [
"version_check",
]
[[package]]
name = "cookie-factory"
version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9885fa71e26b8ab7855e2ec7cae6e9b380edff76cd052e07c683a0319d51b3a2"
dependencies = [
"futures",
]
[[package]]
name = "cookie_store"
version = "0.22.1"
@@ -5890,15 +5824,6 @@ dependencies = [
"libredox",
]
[[package]]
name = "find-crate"
version = "0.6.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "59a98bbaacea1c0eb6a0876280051b892eb73594fd90cf3b20e9c817029c57d2"
dependencies = [
"toml 0.5.11",
]
[[package]]
name = "find-msvc-tools"
version = "0.1.9"
@@ -5960,50 +5885,6 @@ dependencies = [
"num-traits",
]
[[package]]
name = "fluent"
version = "0.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb74634707bebd0ce645a981148e8fb8c7bccd4c33c652aeffd28bf2f96d555a"
dependencies = [
"fluent-bundle",
"unic-langid",
]
[[package]]
name = "fluent-bundle"
version = "0.15.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7fe0a21ee80050c678013f82edf4b705fe2f26f1f9877593d13198612503f493"
dependencies = [
"fluent-langneg",
"fluent-syntax",
"intl-memoizer",
"intl_pluralrules",
"rustc-hash 1.1.0",
"self_cell 0.10.3",
"smallvec",
"unic-langid",
]
[[package]]
name = "fluent-langneg"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7eebbe59450baee8282d71676f3bfed5689aeab00b27545e83e5f14b1195e8b0"
dependencies = [
"unic-langid",
]
[[package]]
name = "fluent-syntax"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a530c4694a6a8d528794ee9bbd8ba0122e779629ac908d15ad5a7ae7763a33d"
dependencies = [
"thiserror 1.0.69",
]
[[package]]
name = "fluent-uri"
version = "0.1.4"
@@ -7735,72 +7616,6 @@ dependencies = [
"windows-registry",
]
[[package]]
name = "i18n-config"
version = "0.4.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3e06b90c8a0d252e203c94344b21e35a30f3a3a85dc7db5af8f8df9f3e0c63ef"
dependencies = [
"basic-toml",
"log",
"serde",
"serde_derive",
"thiserror 1.0.69",
"unic-langid",
]
[[package]]
name = "i18n-embed"
version = "0.15.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "669ffc2c93f97e6ddf06ddbe999fcd6782e3342978bb85f7d3c087c7978404c4"
dependencies = [
"arc-swap",
"fluent",
"fluent-langneg",
"fluent-syntax",
"i18n-embed-impl",
"intl-memoizer",
"log",
"parking_lot",
"rust-embed",
"thiserror 1.0.69",
"unic-langid",
"walkdir",
]
[[package]]
name = "i18n-embed-fl"
version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04b2969d0b3fc6143776c535184c19722032b43e6a642d710fa3f88faec53c2d"
dependencies = [
"find-crate",
"fluent",
"fluent-syntax",
"i18n-config",
"i18n-embed",
"proc-macro-error2",
"proc-macro2",
"quote",
"strsim 0.11.1",
"syn 2.0.117",
"unic-langid",
]
[[package]]
name = "i18n-embed-impl"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0f2cc0e0523d1fe6fc2c6f66e5038624ea8091b3e7748b5e8e0c84b1698db6c2"
dependencies = [
"find-crate",
"i18n-config",
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "iana-time-zone"
version = "0.1.65"
@@ -8210,25 +8025,6 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "intl-memoizer"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "310da2e345f5eb861e7a07ee182262e94975051db9e4223e909ba90f392f163f"
dependencies = [
"type-map",
"unic-langid",
]
[[package]]
name = "intl_pluralrules"
version = "7.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "078ea7b7c29a2b4df841a7f6ac8775ff6074020c6776d48491ce2268e068f972"
dependencies = [
"unic-langid",
]
[[package]]
name = "inventory"
version = "0.3.24"
@@ -8248,12 +8044,6 @@ dependencies = [
"winapi",
]
[[package]]
name = "io_tee"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4b3f7cef34251886990511df1c61443aa928499d598a9473929ab5a90a527304"
[[package]]
name = "ipconfig"
version = "0.3.2"
@@ -10371,28 +10161,6 @@ dependencies = [
"toml_edit 0.23.10+spec-1.0.0",
]
[[package]]
name = "proc-macro-error-attr2"
version = "2.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5"
dependencies = [
"proc-macro2",
"quote",
]
[[package]]
name = "proc-macro-error2"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802"
dependencies = [
"proc-macro-error-attr2",
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "proc-macro2"
version = "1.0.106"
@@ -10635,7 +10403,7 @@ dependencies = [
"pin-project-lite",
"quinn-proto",
"quinn-udp",
"rustc-hash 2.1.1",
"rustc-hash",
"rustls",
"socket2 0.6.3",
"thiserror 2.0.18",
@@ -10656,7 +10424,7 @@ dependencies = [
"lru-slab",
"rand 0.9.3",
"ring",
"rustc-hash 2.1.1",
"rustc-hash",
"rustls",
"rustls-pki-types",
"slab",
@@ -11464,40 +11232,6 @@ name = "runfiles"
version = "0.1.0"
source = "git+https://github.com/dzbarsky/rules_rust?rev=b56cbaa8465e74127f1ea216f813cd377295ad81#b56cbaa8465e74127f1ea216f813cd377295ad81"
[[package]]
name = "rust-embed"
version = "8.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04113cb9355a377d83f06ef1f0a45b8ab8cd7d8b1288160717d66df5c7988d27"
dependencies = [
"rust-embed-impl",
"rust-embed-utils",
"walkdir",
]
[[package]]
name = "rust-embed-impl"
version = "8.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "da0902e4c7c8e997159ab384e6d0fc91c221375f6894346ae107f47dd0f3ccaa"
dependencies = [
"proc-macro2",
"quote",
"rust-embed-utils",
"syn 2.0.117",
"walkdir",
]
[[package]]
name = "rust-embed-utils"
version = "8.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5bcdef0be6fe7f6fa333b1073c949729274b05f123a0ad7efcb8efd878e5c3b1"
dependencies = [
"sha2 0.10.9",
"walkdir",
]
[[package]]
name = "rust-stemmers"
version = "1.2.0"
@@ -11514,12 +11248,6 @@ version = "0.1.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d"
[[package]]
name = "rustc-hash"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2"
[[package]]
name = "rustc-hash"
version = "2.1.1"
@@ -11839,17 +11567,6 @@ version = "1.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d68f2ec51b097e4c1a75b681a8bec621909b5e91f15bb7b840c4f2f7b01148b2"
[[package]]
name = "scrypt"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0516a385866c09368f0b5bcd1caff3366aace790fcd46e2bb032697bb172fd1f"
dependencies = [
"pbkdf2 0.12.2",
"salsa20",
"sha2 0.10.9",
]
[[package]]
name = "sdd"
version = "3.0.10"
@@ -11879,15 +11596,6 @@ dependencies = [
"libc",
]
[[package]]
name = "secrecy"
version = "0.10.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e891af845473308773346dc847b2c23ee78fe442e0472ac50e22a18a93d3ae5a"
dependencies = [
"zeroize",
]
[[package]]
name = "secret-service"
version = "4.0.0"
@@ -11943,21 +11651,6 @@ dependencies = [
"libc",
]
[[package]]
name = "self_cell"
version = "0.10.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e14e4d63b804dc0c7ec4a1e52bcb63f02c7ac94476755aa579edac21e01f915d"
dependencies = [
"self_cell 1.2.2",
]
[[package]]
name = "self_cell"
version = "1.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b12e76d157a900eb52e81bc6e9f3069344290341720e9178cde2407113ac8d89"
[[package]]
name = "semver"
version = "1.0.27"
@@ -13110,7 +12803,7 @@ dependencies = [
"cfg-expr",
"heck 0.5.0",
"pkg-config",
"toml 0.9.11+spec-1.1.0",
"toml",
"version-compare",
]
@@ -13550,15 +13243,6 @@ dependencies = [
"tokio",
]
[[package]]
name = "toml"
version = "0.5.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f4f7f0dd8d50a853a531c426359045b1998f04219d88799810762cd4ad314234"
dependencies = [
"serde",
]
[[package]]
name = "toml"
version = "0.9.11+spec-1.1.0"
@@ -13988,15 +13672,6 @@ dependencies = [
"syntect",
]
[[package]]
name = "type-map"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cb30dbbd9036155e74adad6812e9898d03ec374946234fbcebd5dfc7b9187b90"
dependencies = [
"rustc-hash 2.1.1",
]
[[package]]
name = "typeid"
version = "1.0.3"
@@ -14035,25 +13710,6 @@ version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94"
[[package]]
name = "unic-langid"
version = "0.9.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a28ba52c9b05311f4f6e62d5d9d46f094bd6e84cb8df7b3ef952748d752a7d05"
dependencies = [
"unic-langid-impl",
]
[[package]]
name = "unic-langid-impl"
version = "0.9.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dce1bf08044d4b7a94028c93786f8566047edc11110595914de93362559bc658"
dependencies = [
"serde",
"tinystr",
]
[[package]]
name = "unicase"
version = "2.9.0"
@@ -15439,18 +15095,6 @@ version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd"
[[package]]
name = "x25519-dalek"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277"
dependencies = [
"curve25519-dalek",
"rand_core 0.6.4",
"serde",
"zeroize",
]
[[package]]
name = "x509-parser"
version = "0.18.1"
+1 -1
View File
@@ -247,7 +247,6 @@ core_test_support = { path = "core/tests/common" }
mcp_test_support = { path = "mcp-server/tests/common" }
# External
age = "0.11.1"
allocative = "0.3.3"
ansi-to-tui = "7.0.0"
anyhow = "1"
@@ -268,6 +267,7 @@ base64 = "0.22.1"
bm25 = "2.3.2"
bytes = "1.10.1"
chardetng = "0.1.17"
chacha20poly1305 = "0.10.1"
chrono = "0.4.43"
clap = "4"
clap_complete = "4"
+2 -1
View File
@@ -8,9 +8,9 @@ license.workspace = true
workspace = true
[dependencies]
age = { workspace = true }
anyhow = { workspace = true }
base64 = { workspace = true }
chacha20poly1305 = { workspace = true }
codex-git-utils = { workspace = true }
codex-keyring-store = { workspace = true }
rand = { workspace = true }
@@ -20,6 +20,7 @@ serde = { workspace = true }
serde_json = { workspace = true }
sha2 = { workspace = true }
tracing = { workspace = true }
zeroize = { workspace = true }
[dev-dependencies]
keyring = { workspace = true }
+170 -38
View File
@@ -4,27 +4,25 @@ use std::io::Write;
use std::path::Path;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::atomic::Ordering;
use std::sync::atomic::compiler_fence;
use std::time::SystemTime;
use std::time::UNIX_EPOCH;
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 chacha20poly1305::XChaCha20Poly1305;
use chacha20poly1305::XNonce;
use chacha20poly1305::aead::Aead;
use chacha20poly1305::aead::KeyInit;
use chacha20poly1305::aead::Payload;
use codex_keyring_store::KeyringStore;
use rand::TryRngCore;
use rand::rngs::OsRng;
use serde::Deserialize;
use serde::Serialize;
use tracing::warn;
use zeroize::Zeroizing;
use super::SecretListEntry;
use super::SecretName;
@@ -34,7 +32,12 @@ use super::compute_keyring_account;
use super::keyring_service;
const SECRETS_VERSION: u8 = 1;
const LOCAL_SECRETS_FILENAME: &str = "local.age";
const LOCAL_SECRETS_FILENAME: &str = "local.secrets";
const LEGACY_LOCAL_SECRETS_FILENAME: &str = "local.age";
const LOCAL_SECRETS_MAGIC: &[u8] = b"codex-local-secrets-v2\n";
const LOCAL_SECRETS_AAD: &[u8] = b"codex-local-secrets-v2";
const LOCAL_SECRETS_KEY_BYTES: usize = 32;
const LOCAL_SECRETS_NONCE_BYTES: usize = 24;
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
struct SecretsFile {
@@ -115,16 +118,26 @@ impl LocalSecretsBackend {
self.secrets_dir().join(LOCAL_SECRETS_FILENAME)
}
fn legacy_secrets_path(&self) -> PathBuf {
self.secrets_dir().join(LEGACY_LOCAL_SECRETS_FILENAME)
}
fn load_file(&self) -> Result<SecretsFile> {
let path = self.secrets_path();
if !path.exists() {
let legacy_path = self.legacy_secrets_path();
anyhow::ensure!(
!legacy_path.exists(),
"found legacy age-encrypted secrets file at {}; this version cannot read it",
legacy_path.display()
);
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 key = self.load_or_create_key()?;
let plaintext = decrypt_with_key(&ciphertext, &key)?;
let mut parsed: SecretsFile = serde_json::from_slice(&plaintext).with_context(|| {
format!(
"failed to deserialize decrypted secrets file at {}",
@@ -148,15 +161,16 @@ impl LocalSecretsBackend {
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 key = self.load_or_create_key()?;
let plaintext =
Zeroizing::new(serde_json::to_vec(file).context("failed to serialize secrets file")?);
let ciphertext = encrypt_with_key(&plaintext, &key)?;
let path = self.secrets_path();
write_file_atomically(&path, &ciphertext)?;
Ok(())
}
fn load_or_create_passphrase(&self) -> Result<SecretString> {
fn load_or_create_key(&self) -> Result<Zeroizing<[u8; LOCAL_SECRETS_KEY_BYTES]>> {
let account = compute_keyring_account(&self.codex_home);
let loaded = self
.keyring_store
@@ -164,14 +178,17 @@ impl LocalSecretsBackend {
.map_err(|err| anyhow::anyhow!(err.message()))
.with_context(|| format!("failed to load secrets key from keyring for {account}"))?;
match loaded {
Some(existing) => Ok(SecretString::from(existing)),
Some(existing) => decode_key(&existing).with_context(|| {
format!("failed to decode secrets key from keyring for {account}")
}),
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()?;
let generated = generate_key()?;
let encoded = Zeroizing::new(BASE64_STANDARD.encode(generated.as_slice()));
self.keyring_store
.save(keyring_service(), &account, generated.expose_secret())
.save(keyring_service(), &account, encoded.as_str())
.map_err(|err| anyhow::anyhow!(err.message()))
.context("failed to persist secrets key in keyring")?;
Ok(generated)
@@ -270,34 +287,84 @@ fn write_file_atomically(path: &Path, contents: &[u8]) -> Result<()> {
}
}
fn generate_passphrase() -> Result<SecretString> {
let mut bytes = [0_u8; 32];
fn generate_key() -> Result<Zeroizing<[u8; LOCAL_SECRETS_KEY_BYTES]>> {
let mut bytes = Zeroizing::new([0_u8; LOCAL_SECRETS_KEY_BYTES]);
let mut rng = OsRng;
rng.try_fill_bytes(&mut bytes)
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))
Ok(bytes)
}
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 decode_key(encoded: &str) -> Result<Zeroizing<[u8; LOCAL_SECRETS_KEY_BYTES]>> {
let decoded = Zeroizing::new(
BASE64_STANDARD
.decode(encoded)
.context("secrets key is not valid base64")?,
);
anyhow::ensure!(
decoded.len() == LOCAL_SECRETS_KEY_BYTES,
"secrets key must be {LOCAL_SECRETS_KEY_BYTES} bytes"
);
let mut key = Zeroizing::new([0_u8; LOCAL_SECRETS_KEY_BYTES]);
key.copy_from_slice(decoded.as_slice());
Ok(key)
}
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 encrypt_with_key(plaintext: &[u8], key: &[u8; LOCAL_SECRETS_KEY_BYTES]) -> Result<Vec<u8>> {
let mut nonce_bytes = [0_u8; LOCAL_SECRETS_NONCE_BYTES];
let mut rng = OsRng;
rng.try_fill_bytes(&mut nonce_bytes)
.context("failed to generate secrets file nonce")?;
let nonce = XNonce::from_slice(&nonce_bytes);
let cipher = XChaCha20Poly1305::new_from_slice(key)
.map_err(|_| anyhow::anyhow!("invalid secrets key length"))?;
let encrypted = cipher
.encrypt(
nonce,
Payload {
msg: plaintext,
aad: LOCAL_SECRETS_AAD,
},
)
.map_err(|_| anyhow::anyhow!("failed to encrypt secrets file"))?;
let mut envelope =
Vec::with_capacity(LOCAL_SECRETS_MAGIC.len() + nonce_bytes.len() + encrypted.len());
envelope.extend_from_slice(LOCAL_SECRETS_MAGIC);
envelope.extend_from_slice(&nonce_bytes);
envelope.extend_from_slice(&encrypted);
Ok(envelope)
}
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 decrypt_with_key(
envelope: &[u8],
key: &[u8; LOCAL_SECRETS_KEY_BYTES],
) -> Result<Zeroizing<Vec<u8>>> {
anyhow::ensure!(
envelope.starts_with(LOCAL_SECRETS_MAGIC),
"unsupported local secrets file format"
);
let encrypted_offset = LOCAL_SECRETS_MAGIC.len() + LOCAL_SECRETS_NONCE_BYTES;
anyhow::ensure!(
envelope.len() >= encrypted_offset,
"local secrets file is truncated"
);
let nonce = XNonce::from_slice(
&envelope[LOCAL_SECRETS_MAGIC.len()..LOCAL_SECRETS_MAGIC.len() + LOCAL_SECRETS_NONCE_BYTES],
);
let encrypted = &envelope[encrypted_offset..];
let cipher = XChaCha20Poly1305::new_from_slice(key)
.map_err(|_| anyhow::anyhow!("invalid secrets key length"))?;
let plaintext = cipher
.decrypt(
nonce,
Payload {
msg: encrypted,
aad: LOCAL_SECRETS_AAD,
},
)
.map_err(|_| anyhow::anyhow!("failed to decrypt secrets file"))?;
Ok(Zeroizing::new(plaintext))
}
fn parse_canonical_key(canonical_key: &str) -> Option<SecretListEntry> {
@@ -358,6 +425,71 @@ mod tests {
Ok(())
}
#[test]
fn save_file_writes_versioned_encrypted_envelope() -> Result<()> {
let codex_home = tempfile::tempdir().expect("tempdir");
let keyring = Arc::new(MockKeyringStore::default());
let backend = LocalSecretsBackend::new(codex_home.path().to_path_buf(), keyring);
let file = SecretsFile {
version: SECRETS_VERSION,
secrets: BTreeMap::from([(
"global/GITHUB_TOKEN".to_string(),
"secret-value".to_string(),
)]),
};
backend.save_file(&file)?;
let ciphertext = fs::read(backend.secrets_path())?;
assert!(ciphertext.starts_with(LOCAL_SECRETS_MAGIC));
assert!(
!String::from_utf8_lossy(&ciphertext).contains("secret-value"),
"secrets file must not store plaintext secret values"
);
assert_eq!(backend.load_file()?, file);
Ok(())
}
#[test]
fn load_file_rejects_unknown_envelope_format() -> Result<()> {
let codex_home = tempfile::tempdir().expect("tempdir");
let keyring = Arc::new(MockKeyringStore::default());
let backend = LocalSecretsBackend::new(codex_home.path().to_path_buf(), keyring);
fs::create_dir_all(backend.secrets_dir())?;
fs::write(backend.secrets_path(), b"age-encryption.org/v1")?;
let error = backend
.load_file()
.expect_err("unknown envelope format should be rejected");
assert!(
error
.to_string()
.contains("unsupported local secrets file format"),
"unexpected error: {error:#}"
);
Ok(())
}
#[test]
fn load_file_rejects_legacy_age_file() -> Result<()> {
let codex_home = tempfile::tempdir().expect("tempdir");
let keyring = Arc::new(MockKeyringStore::default());
let backend = LocalSecretsBackend::new(codex_home.path().to_path_buf(), keyring);
fs::create_dir_all(backend.secrets_dir())?;
fs::write(backend.legacy_secrets_path(), b"age-encryption.org/v1")?;
let error = backend
.load_file()
.expect_err("legacy age format should be rejected");
assert!(
error
.to_string()
.contains("found legacy age-encrypted secrets file"),
"unexpected error: {error:#}"
);
Ok(())
}
#[test]
fn set_fails_when_keyring_is_unavailable() -> Result<()> {
let codex_home = tempfile::tempdir().expect("tempdir");