diff --git a/Cargo.lock b/Cargo.lock index 75c186f..1ada89b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -423,6 +423,17 @@ dependencies = [ "uuid", ] +[[package]] +name = "colibri-vault" +version = "0.0.1" +dependencies = [ + "serde", + "serde_json", + "thiserror 2.0.18", + "tokio", + "tracing", +] + [[package]] name = "colorchoice" version = "1.0.5" diff --git a/Cargo.toml b/Cargo.toml index c2e6e10..a606d4f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = ["crates/colibri-contracts", "crates/colibri-deepseek", "crates/colibri-runtime", "crates/colibri-glasspane", "crates/colibri-daemon", "crates/colibri-client", "crates/colibri-glasspane-tui", "crates/colibri-store", "crates/colibri-skills", "crates/colibri-mcp", "crates/clawdie"] +members = ["crates/colibri-contracts", "crates/colibri-deepseek", "crates/colibri-runtime", "crates/colibri-glasspane", "crates/colibri-daemon", "crates/colibri-client", "crates/colibri-glasspane-tui", "crates/colibri-store", "crates/colibri-skills", "crates/colibri-mcp", "crates/colibri-vault", "crates/clawdie"] [package] name = "colibri" diff --git a/crates/colibri-vault/Cargo.toml b/crates/colibri-vault/Cargo.toml new file mode 100644 index 0000000..7555cf8 --- /dev/null +++ b/crates/colibri-vault/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "colibri-vault" +version = "0.0.1" +edition = "2021" +description = "Vaultwarden credential provision for Colibri — fetch a tenant collection → jail .env" + +[dependencies] +serde = { version = "1", features = ["derive"] } +serde_json = "1" +thiserror = "2" +tracing = "0.1" +tokio = { version = "1", features = ["process", "rt"] } diff --git a/crates/colibri-vault/src/lib.rs b/crates/colibri-vault/src/lib.rs new file mode 100644 index 0000000..17c6a96 --- /dev/null +++ b/crates/colibri-vault/src/lib.rs @@ -0,0 +1,294 @@ +//! colibri-vault — Vaultwarden credential provision for Colibri. +//! +//! Fetches a named collection from Vaultwarden via the `bw` CLI and materializes +//! a `.env` file into a target directory (typically a freshly-created Bastille jail +//! root). Fail-closed, idempotent, wraps the existing Bitwarden CLI instead of +//! reimplementing the protocol. +//! +//! # Usage +//! +//! ```ignore +//! use colibri_vault::provision; +//! +//! provision("hermes-osa-tenant", "/jails/tenant-1/home/clawdie").await?; +//! ``` +//! +//! # Requirements +//! +//! - `bw` CLI installed and logged in (`BW_SESSION` or `BW_CLIENTID`+`BW_CLIENTSECRET`) +//! - `BW_SERVER` pointing at the Vaultwarden instance +//! - The authenticated account has read access to the target collection + +use std::path::{Path, PathBuf}; +use std::process::Stdio; +use thiserror::Error; +use tokio::process::Command; + +#[derive(Error, Debug)] +pub enum VaultError { + #[error("bw CLI not found — install @bitwarden/cli")] + BwNotFound, + + #[error("bw command failed: {0}")] + BwFailed(String), + + #[error("collection not found: {0}")] + CollectionNotFound(String), + + #[error("no items in collection: {0}")] + CollectionEmpty(String), + + #[error("cannot write to target: {0}")] + TargetNotWritable(PathBuf), + + #[error("I/O error: {0}")] + Io(#[from] std::io::Error), + + #[error("JSON deserialization failed: {0}")] + Json(#[from] serde_json::Error), +} + +/// Provision a tenant's secrets into a target directory. +/// +/// 1. Looks up the collection by name +/// 2. Fetches all items in that collection +/// 3. Writes a 0600 `.env` file with KEY=VALUE pairs into `target_dir` +/// +/// No-op if the collection has no items (returns Ok with a warning). +/// Fails if `bw` is not installed or the collection doesn't exist. +pub async fn provision( + collection_name: &str, + target_dir: impl AsRef, +) -> Result { + let target_dir = target_dir.as_ref(); + + // Verify bw is available + which("bw").ok_or(VaultError::BwNotFound)?; + + // Verify target is writable + std::fs::create_dir_all(target_dir)?; + let test = target_dir.join(".vault-write-test"); + std::fs::write(&test, b"")?; + std::fs::remove_file(&test)?; + + // 1. Find the collection by name + let collection_id = resolve_collection(collection_name).await?; + + // 2. Fetch items + let items = list_items(&collection_id).await?; + if items.is_empty() { + return Ok(ProvisionResult { + collection: collection_name.to_string(), + items_written: 0, + bytes_written: 0, + path: target_dir.join(".env"), + }); + } + + // 3. Write .env + let env_path = target_dir.join(".env"); + let mut env_content = String::new(); + env_content.push_str("# Generated by colibri-vault — do not edit by hand\n"); + env_content.push_str(&format!("# Collection: {collection_name}\n")); + + for item in &items { + if let Some(login) = &item.login { + // item.name = KEY, login.password = VALUE (Vaultwarden login item convention) + let raw_key = item.name.trim(); + if let Some(password) = &login.password { + let key = validate_key(raw_key); + if key.is_empty() { + tracing::warn!(item = item.name, "skipping item with invalid env key"); + continue; + } + let val = password.trim(); + env_content.push_str(&format!("{key}={val}\n")); + } + } + // Also handle secure notes (KEY=VALUE pairs separated by newlines) + if item.kind == 2 { + // Secure note + if let Some(notes) = &item.notes { + for line in notes.lines() { + let line = line.trim(); + if line.contains('=') && !line.starts_with('#') { + env_content.push_str(&format!("{line}\n")); + } + } + } + } + } + + let bytes = env_content.len(); + std::fs::write(&env_path, &env_content)?; + + // Set 0600 + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(&env_path, std::fs::Permissions::from_mode(0o600))?; + } + + Ok(ProvisionResult { + collection: collection_name.to_string(), + items_written: items.len(), + bytes_written: bytes, + path: env_path, + }) +} + +#[derive(Debug, Clone)] +pub struct ProvisionResult { + pub collection: String, + pub items_written: usize, + pub bytes_written: usize, + pub path: PathBuf, +} + +// ── Internal helpers ────────────────────────────────── + +async fn resolve_collection(name: &str) -> Result { + let output = bw(&["list", "collections", "--search", name]).await?; + let collections: Vec = + serde_json::from_str(&output).map_err(|e| { + VaultError::BwFailed(format!( + "failed to parse collections: {e} — output: {output}" + )) + })?; + + collections + .iter() + .find(|c| c.name == name || c.id == name) + .map(|c| c.id.clone()) + .ok_or_else(|| VaultError::CollectionNotFound(name.to_string())) +} + +async fn list_items(collection_id: &str) -> Result, VaultError> { + let output = bw(&["list", "items", "--collectionid", collection_id]).await?; + let items: Vec = serde_json::from_str(&output).map_err(|e| { + VaultError::BwFailed(format!( + "failed to parse items: {e} — output: {output}" + )) + })?; + Ok(items) +} + +async fn bw(args: &[&str]) -> Result { + let output = Command::new("bw") + .args(args) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output() + .await + .map_err(|e| VaultError::BwFailed(format!("bw invocation failed: {e}")))?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + return Err(VaultError::BwFailed(stderr.trim().to_string())); + } + + Ok(String::from_utf8_lossy(&output.stdout).trim().to_string()) +} + +fn which(cmd: &str) -> Option { + std::env::var_os("PATH").and_then(|path| { + std::env::split_paths(&path).find_map(|dir| { + let candidate = dir.join(cmd); + if candidate.is_file() { + Some(candidate) + } else { + None + } + }) + }) +} + +// ── Serde types (minimum surface for bw CLI JSON) ───── + +#[derive(serde::Deserialize, Debug)] +struct SerdeCollection { + id: String, + name: String, +} + +#[derive(serde::Deserialize, Debug)] +struct SerdeItem { + #[allow(dead_code)] + id: String, + #[allow(dead_code)] + name: String, + #[serde(rename = "type")] + kind: u8, + login: Option, + notes: Option, +} + +#[derive(serde::Deserialize, Debug)] +struct SerdeLogin { + username: Option, + password: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn provision_result_debug() { + let r = ProvisionResult { + collection: "test".into(), + items_written: 3, + bytes_written: 256, + path: PathBuf::from("/tmp/.env"), + }; + assert_eq!(r.collection, "test"); + assert_eq!(r.items_written, 3); + } + + #[test] + fn vault_error_display() { + let e = VaultError::CollectionNotFound("missing".into()); + assert!(e.to_string().contains("missing")); + } + + #[test] + fn key_from_item_name_not_username() { + // The contract: item.name = KEY, login.password = VALUE + // Not username=KEY (bug that would produce wrong .env output) + let item = SerdeItem { + id: "test-id".into(), + name: "OPENROUTER_API_KEY".into(), + kind: 1, + login: Some(SerdeLogin { + username: Some("irrelevant@email.com".into()), + password: Some("sk-or-v1-abc123".into()), + }), + notes: None, + }; + assert_eq!(item.name, "OPENROUTER_API_KEY"); + assert_eq!(item.login.as_ref().unwrap().password.as_deref(), Some("sk-or-v1-abc123")); + } + + #[test] + fn validate_key_rejects_dangerous_chars() { + assert_eq!(validate_key("MY_KEY"), "MY_KEY"); + assert_eq!(validate_key("my-key"), "MY_KEY"); + assert_eq!(validate_key("OpenRouter API Key"), "OPENROUTER_API_KEY"); + assert_eq!(validate_key("my.key.name"), "MY_KEY_NAME"); + // Reject injection attempts + assert!(validate_key("BAD;rm -rf /").is_empty()); + assert!(validate_key("KEY\nMALICIOUS=1").is_empty()); + assert!(validate_key("").is_empty()); + } +} + +/// Validate and normalize a key for .env output. +/// Reject keys with non-[A-Z0-9_] characters (matching the clawdie-vault-fetch helper). +fn validate_key(raw: &str) -> String { + let key = raw.to_uppercase().replace([' ', '-', '.'], "_"); + if key.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') && !key.is_empty() { + key + } else { + String::new() + } +}