-
Notifications
You must be signed in to change notification settings - Fork 2
feat: key generation and storage #27
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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}'" |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<Option<Self>> { | ||
| 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<Self> { | ||
| 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()); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<Config> { | ||
| if let Some(existing) = Config::load(paths)? { | ||
| return Ok(existing); | ||
| } | ||
|
|
||
| let backend = prompt_backend()?; | ||
| let cfg = Config { backend }; | ||
| cfg.save(paths)?; | ||
|
Comment on lines
+39
to
+40
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The selected backend is written to Useful? React with 👍 / 👎. |
||
| println!( | ||
| "Saved storage preference: {} ({})", | ||
| backend.label(), | ||
| paths.config_path().display() | ||
| ); | ||
| Ok(cfg) | ||
| } | ||
|
|
||
| fn prompt_backend() -> Result<Backend> { | ||
| 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<bool> { | ||
| 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<SecretSlice<u8>> { | ||
| 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) | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit. i feel like this doesn't belong here, it's not specific to the
enrollcomamnd, maybe move it toconfig.rs