diff --git a/Cargo.lock b/Cargo.lock index f815407..51c1a41 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1992,6 +1992,18 @@ version = "0.4.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cc14f565cf027a105f7a44ccf9e5b424348421a1d8952a8fc9d499d313107789" +[[package]] +name = "console" +version = "0.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d64e8af5551369d19cf50138de61f1c42074ab970f74e99be916646777f8fc87" +dependencies = [ + "encode_unicode", + "libc", + "unicode-width 0.2.2", + "windows-sys 0.61.2", +] + [[package]] name = "const-hex" version = "1.19.0" @@ -2052,6 +2064,16 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation" version = "0.10.1" @@ -2314,6 +2336,28 @@ version = "2.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" +[[package]] +name = "dbus" +version = "0.9.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b942602992bb7acfd1f51c49811c58a610ef9181b6e66f3e519d79b540a3bf73" +dependencies = [ + "libc", + "libdbus-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "dbus-secret-service" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "708b509edf7889e53d7efb0ffadd994cc6c2345ccb62f55cfd6b0682165e4fa6" +dependencies = [ + "dbus", + "openssl", + "zeroize", +] + [[package]] name = "der" version = "0.7.10" @@ -2391,6 +2435,16 @@ dependencies = [ "unicode-xid", ] +[[package]] +name = "dialoguer" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25f104b501bf2364e78d0d3974cbc774f738f5865306ed128e1e0d7499c0ad96" +dependencies = [ + "console", + "shell-words", +] + [[package]] name = "digest" version = "0.9.0" @@ -2423,6 +2477,15 @@ dependencies = [ "crypto-common 0.2.1", ] +[[package]] +name = "directories" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16f5094c54661b38d03bd7e50df373292118db60b585c08a411c6d840017fe7d" +dependencies = [ + "dirs-sys", +] + [[package]] name = "dirs-next" version = "2.0.0" @@ -2433,6 +2496,18 @@ dependencies = [ "dirs-sys-next", ] +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users 0.5.2", + "windows-sys 0.61.2", +] + [[package]] name = "dirs-sys-next" version = "0.1.2" @@ -2440,7 +2515,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ebda144c4fe02d1f7ea1a7d9641b6fc6b580adcfa024ae48797ecdeb6825b4d" dependencies = [ "libc", - "redox_users", + "redox_users 0.4.6", "winapi", ] @@ -2707,6 +2782,21 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + [[package]] name = "form_urlencoded" version = "1.2.2" @@ -3747,6 +3837,23 @@ dependencies = [ "sha3-asm", ] +[[package]] +name = "keyring" +version = "3.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c" +dependencies = [ + "byteorder", + "dbus-secret-service", + "linux-keyutils", + "log", + "openssl", + "security-framework 2.11.1", + "security-framework 3.7.0", + "windows-sys 0.60.2", + "zeroize", +] + [[package]] name = "konst" version = "0.2.20" @@ -3786,6 +3893,16 @@ version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +[[package]] +name = "libdbus-sys" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "328c4789d42200f1eeec05bd86c9c13c7f091d2ba9a6ea35acdf51f31bc0f043" +dependencies = [ + "cc", + "pkg-config", +] + [[package]] name = "libm" version = "0.2.16" @@ -3804,6 +3921,16 @@ dependencies = [ "redox_syscall 0.7.5", ] +[[package]] +name = "linux-keyutils" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83270a18e9f90d0707c41e9f35efada77b64c0e6f3f1810e71c8368a864d5590" +dependencies = [ + "bitflags", + "libc", +] + [[package]] name = "linux-raw-sys" version = "0.12.1" @@ -4077,12 +4204,65 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" +[[package]] +name = "openssl" +version = "0.10.79" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf0b434746ee2832f4f0baf10137e1cabb18cbe6912c69e2e33263c45250f542" +dependencies = [ + "bitflags", + "cfg-if", + "foreign-types", + "libc", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "openssl-probe" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" +[[package]] +name = "openssl-src" +version = "300.6.0+3.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8e8cbfd3a4a8c8f089147fd7aaa33cf8c7450c4d09f8f80698a0cf093abeff4" +dependencies = [ + "cc", +] + +[[package]] +name = "openssl-sys" +version = "0.9.115" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "158fe5b292746440aa6e7a7e690e55aeb72d41505e2804c23c6973ad0e9c9781" +dependencies = [ + "cc", + "libc", + "openssl-src", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + [[package]] name = "ordered-float" version = "5.3.0" @@ -5426,6 +5606,17 @@ dependencies = [ "thiserror 1.0.69", ] +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 2.0.18", +] + [[package]] name = "ref-cast" version = "1.0.25" @@ -5767,7 +5958,7 @@ dependencies = [ "openssl-probe", "rustls-pki-types", "schannel", - "security-framework", + "security-framework 3.7.0", ] [[package]] @@ -5786,7 +5977,7 @@ version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19787cda76408ec5404443dc8b31795c87cd8fec49762dc75fa727740d34acc1" dependencies = [ - "core-foundation", + "core-foundation 0.10.1", "core-foundation-sys", "jni 0.21.1", "log", @@ -5795,7 +5986,7 @@ dependencies = [ "rustls-native-certs", "rustls-platform-verifier-android", "rustls-webpki", - "security-framework", + "security-framework 3.7.0", "security-framework-sys", "webpki-root-certs 0.26.11", "windows-sys 0.59.0", @@ -5807,7 +5998,7 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" dependencies = [ - "core-foundation", + "core-foundation 0.10.1", "core-foundation-sys", "jni 0.22.4", "log", @@ -5816,7 +6007,7 @@ dependencies = [ "rustls-native-certs", "rustls-platform-verifier-android", "rustls-webpki", - "security-framework", + "security-framework 3.7.0", "security-framework-sys", "webpki-root-certs 1.0.7", "windows-sys 0.61.2", @@ -6030,6 +6221,19 @@ dependencies = [ "zeroize", ] +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags", + "core-foundation 0.9.4", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + [[package]] name = "security-framework" version = "3.7.0" @@ -6037,7 +6241,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ "bitflags", - "core-foundation", + "core-foundation 0.10.1", "core-foundation-sys", "libc", "security-framework-sys", @@ -6288,6 +6492,12 @@ dependencies = [ "lazy_static", ] +[[package]] +name = "shell-words" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" + [[package]] name = "shlex" version = "1.3.0" @@ -7550,6 +7760,12 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "vec-collections" version = "0.4.3" @@ -8297,7 +8513,17 @@ dependencies = [ name = "world-id-agentkit" version = "0.0.1" dependencies = [ + "anyhow", "clap", + "dialoguer", + "directories", + "getrandom 0.3.4", + "hex", + "keyring", + "secrecy", + "serde", + "serde_json", + "tempfile", "walletkit-core", ] diff --git a/Cargo.toml b/Cargo.toml index e309440..d77f2e7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,3 +12,16 @@ authors = [ "Paolo D'Amico ", "Andy Wang <41224501+andy-t-wang@users.noreply.github.com>" ] + +[workspace.lints.rust] +missing_docs = "deny" +dead_code = "deny" + +[workspace.lints.clippy] +pedantic = { level = "warn", priority = -1 } +unwrap_used = "deny" +expect_used = "warn" +panic = "deny" +dbg_macro = "deny" +todo = "deny" +mem_forget = "deny" diff --git a/client/Cargo.toml b/client/Cargo.toml index be263fd..844f4cc 100644 --- a/client/Cargo.toml +++ b/client/Cargo.toml @@ -12,14 +12,28 @@ name = "agentkit" path = "src/main.rs" [dependencies] +anyhow = "1.0" clap = { version = "4.5", features = ["derive"] } +dialoguer = { version = "0.12", default-features = false } +directories = "6" +getrandom = "0.3" +hex = "0.4" +secrecy = "0.10" +serde = { version = "1", features = ["derive"] } +serde_json = "1" walletkit-core = "0.16" -[lints.clippy] -pedantic = { level = "warn", priority = -1 } -unwrap_used = "deny" -expect_used = "warn" -panic = "deny" -dbg_macro = "deny" -todo = "deny" -mem_forget = "deny" +[dev-dependencies] +tempfile = "3" + +[target.'cfg(target_os = "macos")'.dependencies] +keyring = { version = "3.6.3", default-features = false, features = ["apple-native"] } + +[target.'cfg(target_os = "linux")'.dependencies] +keyring = { version = "3.6.3", default-features = false, features = ["sync-secret-service", "linux-native", "vendored"] } + +[target.'cfg(target_os = "windows")'.dependencies] +keyring = { version = "3.6.3", default-features = false, features = ["windows-native"] } + +[lints] +workspace = true diff --git a/client/scripts/codesign.sh b/client/scripts/codesign.sh new file mode 100755 index 0000000..e6f4759 --- /dev/null +++ b/client/scripts/codesign.sh @@ -0,0 +1,27 @@ +#!/usr/bin/env bash +# Ad-hoc code-sign the agentkit binary on macOS. +# +# Why: macOS Keychain trust ("Always Allow") is bound to a binary's code-signing +# identity. Without an explicit signature, every cargo rebuild produces a binary +# the Keychain treats as new and re-prompts for the user's password. Ad-hoc +# signing (-) gives the binary an identity; for trust that survives rebuilds, +# create a self-signed code-signing certificate in Keychain Access and pass its +# name via AGENTKIT_SIGN_IDENTITY. +# +# Run after `cargo build`. Pass `release` as the first argument to sign the +# release build instead of debug. + +set -euo pipefail + +profile="${1:-debug}" +identity="${AGENTKIT_SIGN_IDENTITY:--}" +script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +binary="${script_dir}/../../target/${profile}/agentkit" + +if [[ ! -f "${binary}" ]]; then + echo "error: ${binary} not found; run 'cargo build' (or 'cargo build --release' for the release profile) first" >&2 + exit 1 +fi + +codesign --force --sign "${identity}" "${binary}" +echo "signed ${binary} with identity '${identity}'" diff --git a/client/src/config.rs b/client/src/config.rs new file mode 100644 index 0000000..9b4da14 --- /dev/null +++ b/client/src/config.rs @@ -0,0 +1,125 @@ +use std::fs; +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result, anyhow}; +use directories::ProjectDirs; +use serde::{Deserialize, Serialize}; + +use crate::storage::Backend; + +const CONFIG_FILE: &str = "config.json"; + +/// Persistent user preferences for the `AgentKit` client, serialized as JSON in the application +/// data directory. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Config { + pub backend: Backend, +} + +impl Config { + /// Reads the config from disk, returning `Ok(None)` when no config has been saved yet. + pub fn load(paths: &Paths) -> Result> { + let path = paths.config_path(); + match fs::read_to_string(&path) { + Ok(contents) => { + let cfg: Self = serde_json::from_str(&contents) + .with_context(|| format!("failed to parse config at {}", path.display()))?; + Ok(Some(cfg)) + } + Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(err) => Err(err).with_context(|| format!("failed to read config at {}", path.display())), + } + } + + /// Writes the config to disk, creating the application data directory if it does not exist. + pub fn save(&self, paths: &Paths) -> Result<()> { + ensure_dir(&paths.data_dir)?; + let path = paths.config_path(); + let contents = serde_json::to_string_pretty(self).context("failed to serialize config")?; + fs::write(&path, contents).with_context(|| format!("failed to write config to {}", path.display()))?; + Ok(()) + } +} + +/// Resolved filesystem locations the `AgentKit` client uses for configuration and storage. +pub struct Paths { + pub data_dir: PathBuf, +} + +impl Paths { + /// Resolves the platform-appropriate application data directory (e.g. + /// `~/Library/Application Support/org.world.agentkit` on macOS). + pub fn discover() -> Result { + let project = ProjectDirs::from("org", "world", "agentkit") + .ok_or_else(|| anyhow!("could not determine application data directory for this platform"))?; + Ok(Self { + data_dir: project.data_dir().to_path_buf(), + }) + } + + /// Returns the absolute path to the JSON config file inside the data directory. + pub fn config_path(&self) -> PathBuf { + self.data_dir.join(CONFIG_FILE) + } +} + +fn ensure_dir(dir: &Path) -> Result<()> { + fs::create_dir_all(dir).with_context(|| format!("failed to create directory {}", dir.display())) +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used)] +mod tests { + use tempfile::TempDir; + + use super::*; + + fn paths_in(dir: &TempDir) -> Paths { + Paths { + data_dir: dir.path().join("data"), + } + } + + #[test] + fn load_returns_none_when_missing() { + let dir = TempDir::new().unwrap(); + assert!(Config::load(&paths_in(&dir)).unwrap().is_none()); + } + + #[test] + fn save_then_load_round_trips() { + let dir = TempDir::new().unwrap(); + let paths = paths_in(&dir); + + Config { backend: Backend::File }.save(&paths).unwrap(); + let loaded = Config::load(&paths).unwrap().unwrap(); + + assert_eq!(loaded.backend, Backend::File); + assert!(paths.config_path().exists()); + } + + #[test] + fn save_creates_data_dir() { + let dir = TempDir::new().unwrap(); + let paths = paths_in(&dir); + assert!(!paths.data_dir.exists()); + + Config { + backend: Backend::Keyring, + } + .save(&paths) + .unwrap(); + + assert!(paths.data_dir.is_dir()); + } + + #[test] + fn load_errors_on_malformed_json() { + let dir = TempDir::new().unwrap(); + let paths = paths_in(&dir); + fs::create_dir_all(&paths.data_dir).unwrap(); + fs::write(paths.config_path(), "{not valid json").unwrap(); + + assert!(Config::load(&paths).is_err()); + } +} diff --git a/client/src/enroll.rs b/client/src/enroll.rs new file mode 100644 index 0000000..75bf1e4 --- /dev/null +++ b/client/src/enroll.rs @@ -0,0 +1,82 @@ +use anyhow::{Context, Result}; +use dialoguer::{Confirm, Select, theme::ColorfulTheme}; +use secrecy::{ExposeSecretMut, SecretSlice}; + +use crate::config::{Config, Paths}; +use crate::storage::{self, Backend, SecretStore}; + +const SEED_LEN: usize = 32; + +pub fn run() -> Result<()> { + let paths = Paths::discover()?; + let config = resolve_config(&paths)?; + let store = storage::open(config.backend, &paths.data_dir)?; + + if store.read()?.is_some() && !confirm_overwrite(&*store)? { + println!("Aborted. Existing identity preserved."); + return Ok(()); + } + + let location = store.location(); + store + .write(generate_seed()?) + .with_context(|| format!("failed to store identity in {location}"))?; + + println!(); + println!("AgentKit identity created."); + println!(" Storage: {}", store.location()); + + // TODO: Initiate registration with World ID + Ok(()) +} + +fn resolve_config(paths: &Paths) -> Result { + if let Some(existing) = Config::load(paths)? { + return Ok(existing); + } + + let backend = prompt_backend()?; + let cfg = Config { backend }; + cfg.save(paths)?; + println!( + "Saved storage preference: {} ({})", + backend.label(), + paths.config_path().display() + ); + Ok(cfg) +} + +fn prompt_backend() -> Result { + let options = [ + "System keychain (recommended) — uses your OS secure storage", + "File — stores the key on disk in the AgentKit data directory (less secure)", + ]; + let selection = Select::with_theme(&ColorfulTheme::default()) + .with_prompt("Where should AgentKit store its identity key?") + .items(options) + .default(0) + .interact() + .context("interactive prompt failed; run in a terminal or pre-configure storage")?; + + let backend = match selection { + 0 => Backend::Keyring, + _ => Backend::File, + }; + + Ok(backend) +} + +fn confirm_overwrite(store: &dyn SecretStore) -> Result { + println!("An AgentKit identity already exists in {}.", store.location()); + Confirm::with_theme(&ColorfulTheme::default()) + .with_prompt("Overwrite the existing identity? This cannot be undone.") + .default(false) + .interact() + .context("interactive prompt failed") +} + +fn generate_seed() -> Result> { + let mut secret = SecretSlice::from(vec![0u8; SEED_LEN]); + getrandom::fill(secret.expose_secret_mut()).context("failed to read randomness from the OS")?; + Ok(secret) +} diff --git a/client/src/main.rs b/client/src/main.rs index dd9ef0a..8812f01 100644 --- a/client/src/main.rs +++ b/client/src/main.rs @@ -1,7 +1,14 @@ +//! `AgentKit` Client CLI. Allows agents to interact online with a delegated Proof of Human from +//! a user's World ID. + use std::process::ExitCode; use clap::{Parser, Subcommand}; +mod config; +mod enroll; +mod storage; + #[derive(Debug, Parser)] #[command( name = "agentkit", @@ -22,9 +29,15 @@ enum Command { fn main() -> ExitCode { let cli = Cli::parse(); - let name = match cli.command { - Command::Enroll => "enroll", + let result = match cli.command { + Command::Enroll => enroll::run(), }; - eprintln!("agentkit: `{name}` is not yet implemented (WIP-512 scaffold)."); - ExitCode::from(1) + + match result { + Ok(()) => ExitCode::SUCCESS, + Err(err) => { + eprintln!("agentkit: {err:#}"); + ExitCode::from(1) + } + } } diff --git a/client/src/storage.rs b/client/src/storage.rs new file mode 100644 index 0000000..bafbd6d --- /dev/null +++ b/client/src/storage.rs @@ -0,0 +1,259 @@ +use std::fs; +use std::io::Write; +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result, anyhow}; +use secrecy::zeroize::Zeroizing; +use secrecy::{ExposeSecret, SecretSlice}; +use serde::{Deserialize, Serialize}; + +const KEYRING_SERVICE: &str = "org.world.agentkit"; +const KEYRING_ACCOUNT: &str = "default"; +const FILE_NAME: &str = "key"; + +/// Selects which secure storage backend the client uses to persist its identity key. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Backend { + /// Delegates to the platform's native credential store (Keychain on macOS, Secret + /// Service on Linux, Credential Manager on Windows) and is the recommended choice. + Keyring, + /// Writes the key to a permission-restricted file in the application data directory + /// as a fallback for environments without a usable keyring. + File, +} + +impl Backend { + /// Returns a short human-readable name for this backend, suitable for CLI output. + pub fn label(self) -> &'static str { + match self { + Self::Keyring => "system keychain", + Self::File => "file", + } + } +} + +/// Backend-agnostic interface for persisting a single secret tied to one `AgentKit` instance. +pub trait SecretStore { + /// Writes a secret to the store. Takes the secret by value so the + /// in-memory buffer is wiped as soon as the call returns. + fn write(&self, secret: SecretSlice) -> Result<()>; + fn read(&self) -> Result>>; + fn location(&self) -> String; +} + +/// Constructs the [`SecretStore`] implementation for `backend`, rooted at `data_dir` for +/// filesystem-based backends. +pub fn open(backend: Backend, data_dir: &Path) -> Result> { + match backend { + Backend::Keyring => Ok(Box::new(KeyringStore::new()?)), + Backend::File => Ok(Box::new(FileStore::new(data_dir.join(FILE_NAME)))), + } +} + +/// Stores the secret in the platform's native credential store via the `keyring` crate. +pub struct KeyringStore { + entry: keyring::Entry, +} + +impl KeyringStore { + /// Opens (or creates on first write) the keyring entry in the OS-store (e.g. Apple Keychain). + pub fn new() -> Result { + let entry = + keyring::Entry::new(KEYRING_SERVICE, KEYRING_ACCOUNT).context("failed to construct keyring entry")?; + Ok(Self { entry }) + } +} + +impl SecretStore for KeyringStore { + fn write(&self, secret: SecretSlice) -> Result<()> { + self.entry + .set_secret(secret.expose_secret()) + .context("failed to write secret to system keychain") + } + + fn read(&self) -> Result>> { + match self.entry.get_secret() { + Ok(bytes) => Ok(Some(SecretSlice::from(bytes))), + Err(keyring::Error::NoEntry) => Ok(None), + Err(err) => Err(anyhow!(err)).context("failed to read secret from system keychain"), + } + } + + fn location(&self) -> String { + format!("system keychain ({KEYRING_SERVICE}/{KEYRING_ACCOUNT})") + } +} + +/// File-based fallback that stores the secret hex-encoded at a fixed path. +/// +/// On Unix, the secret file is created with mode `0600` and its parent directory with `0700`. +pub struct FileStore { + path: PathBuf, +} + +impl FileStore { + /// Creates a `FileStore` that reads and writes the secret at `path`. + pub fn new(path: PathBuf) -> Self { + Self { path } + } +} + +impl SecretStore for FileStore { + fn write(&self, secret: SecretSlice) -> Result<()> { + if let Some(parent) = self.path.parent() { + fs::create_dir_all(parent).with_context(|| format!("failed to create directory {}", parent.display()))?; + set_dir_permissions(parent)?; + } + + let mut opts = fs::OpenOptions::new(); + opts.create(true).truncate(true).write(true); + set_file_create_mode(&mut opts); + + let mut file = opts + .open(&self.path) + .with_context(|| format!("failed to open {} for writing", self.path.display()))?; + let encoded = Zeroizing::new(hex::encode(secret.expose_secret())); + file.write_all(encoded.as_bytes()) + .with_context(|| format!("failed to write secret to {}", self.path.display()))?; + file.sync_all().ok(); + + set_file_permissions(&self.path)?; + Ok(()) + } + + fn read(&self) -> Result>> { + match fs::read_to_string(&self.path) { + Ok(contents) => { + let contents = Zeroizing::new(contents); + let bytes = hex::decode(contents.trim()) + .with_context(|| format!("secret file at {} is malformed", self.path.display()))?; + Ok(Some(SecretSlice::from(bytes))) + } + Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(err) => Err(err).with_context(|| format!("failed to read {}", self.path.display())), + } + } + + fn location(&self) -> String { + format!("file ({})", self.path.display()) + } +} + +#[cfg(unix)] +fn set_file_create_mode(opts: &mut fs::OpenOptions) { + use std::os::unix::fs::OpenOptionsExt; + opts.mode(0o600); +} + +#[cfg(not(unix))] +fn set_file_create_mode(_opts: &mut fs::OpenOptions) {} + +#[cfg(unix)] +fn set_file_permissions(path: &Path) -> Result<()> { + use std::os::unix::fs::PermissionsExt; + let perms = fs::Permissions::from_mode(0o600); + fs::set_permissions(path, perms).with_context(|| format!("failed to set permissions on {}", path.display())) +} + +#[cfg(not(unix))] +fn set_file_permissions(_path: &Path) -> Result<()> { + Ok(()) +} + +#[cfg(unix)] +fn set_dir_permissions(path: &Path) -> Result<()> { + use std::os::unix::fs::PermissionsExt; + let perms = fs::Permissions::from_mode(0o700); + fs::set_permissions(path, perms).with_context(|| format!("failed to set permissions on {}", path.display())) +} + +#[cfg(not(unix))] +fn set_dir_permissions(_path: &Path) -> Result<()> { + Ok(()) +} + +#[cfg(test)] +#[allow(clippy::unwrap_used, clippy::expect_used)] +mod tests { + use tempfile::TempDir; + + use super::*; + + fn file_store(dir: &TempDir) -> FileStore { + FileStore::new(dir.path().join("nested").join(FILE_NAME)) + } + + fn make_secret(byte: u8) -> SecretSlice { + SecretSlice::from(vec![byte; 32]) + } + + #[test] + fn file_store_round_trips_bytes() { + let dir = TempDir::new().unwrap(); + let store = file_store(&dir); + + store.write(make_secret(0xAB)).unwrap(); + let read = store.read().unwrap().unwrap(); + + assert_eq!(read.expose_secret(), &[0xAB; 32]); + } + + #[test] + fn file_store_read_returns_none_when_missing() { + let dir = TempDir::new().unwrap(); + let store = file_store(&dir); + + assert!(store.read().unwrap().is_none()); + } + + #[test] + fn file_store_overwrites_existing_secret() { + let dir = TempDir::new().unwrap(); + let store = file_store(&dir); + + store.write(make_secret(1)).unwrap(); + store.write(make_secret(2)).unwrap(); + + assert_eq!(store.read().unwrap().unwrap().expose_secret(), &[2; 32]); + } + + #[test] + fn file_store_rejects_malformed_contents() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join(FILE_NAME); + fs::write(&path, "not hex!").unwrap(); + + assert!(FileStore::new(path).read().is_err()); + } + + #[cfg(unix)] + #[test] + fn file_store_sets_unix_permissions() { + use std::os::unix::fs::PermissionsExt; + + let dir = TempDir::new().unwrap(); + let store = file_store(&dir); + store.write(make_secret(0)).unwrap(); + + let file_mode = fs::metadata(dir.path().join("nested").join(FILE_NAME)) + .unwrap() + .permissions() + .mode() & 0o777; + let dir_mode = fs::metadata(dir.path().join("nested")).unwrap().permissions().mode() & 0o777; + + assert_eq!(file_mode, 0o600); + assert_eq!(dir_mode, 0o700); + } + + #[test] + fn backend_serializes_as_snake_case() { + assert_eq!(serde_json::to_string(&Backend::Keyring).unwrap(), "\"keyring\""); + assert_eq!(serde_json::to_string(&Backend::File).unwrap(), "\"file\""); + assert_eq!( + serde_json::from_str::("\"keyring\"").unwrap(), + Backend::Keyring + ); + assert_eq!(serde_json::from_str::("\"file\"").unwrap(), Backend::File); + } +}