feat(colibri-vault): scaffold vault credential provision crate #85
4 changed files with 318 additions and 1 deletions
11
Cargo.lock
generated
11
Cargo.lock
generated
|
|
@ -423,6 +423,17 @@ dependencies = [
|
||||||
"uuid",
|
"uuid",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "colibri-vault"
|
||||||
|
version = "0.0.1"
|
||||||
|
dependencies = [
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
"tokio",
|
||||||
|
"tracing",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "colorchoice"
|
name = "colorchoice"
|
||||||
version = "1.0.5"
|
version = "1.0.5"
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
[workspace]
|
[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]
|
[package]
|
||||||
name = "colibri"
|
name = "colibri"
|
||||||
|
|
|
||||||
12
crates/colibri-vault/Cargo.toml
Normal file
12
crates/colibri-vault/Cargo.toml
Normal file
|
|
@ -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"] }
|
||||||
294
crates/colibri-vault/src/lib.rs
Normal file
294
crates/colibri-vault/src/lib.rs
Normal file
|
|
@ -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<Path>,
|
||||||
|
) -> Result<ProvisionResult, VaultError> {
|
||||||
|
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<String, VaultError> {
|
||||||
|
let output = bw(&["list", "collections", "--search", name]).await?;
|
||||||
|
let collections: Vec<SerdeCollection> =
|
||||||
|
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<Vec<SerdeItem>, VaultError> {
|
||||||
|
let output = bw(&["list", "items", "--collectionid", collection_id]).await?;
|
||||||
|
let items: Vec<SerdeItem> = 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<String, VaultError> {
|
||||||
|
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<PathBuf> {
|
||||||
|
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<SerdeLogin>,
|
||||||
|
notes: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize, Debug)]
|
||||||
|
struct SerdeLogin {
|
||||||
|
username: Option<String>,
|
||||||
|
password: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[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()
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue