diff --git a/apps/gateway/Cargo.toml b/apps/gateway/Cargo.toml index 588a1bf..d8bfcd7 100644 --- a/apps/gateway/Cargo.toml +++ b/apps/gateway/Cargo.toml @@ -64,8 +64,14 @@ anyhow = "1" # Time (for certificate validity) time = "0.3" +# Chrono (for vault timestamp handling) +chrono = { version = "0.4", features = ["serde"] } + +# ULID/CUID generation +ulid = "1" + # Direct database access -sqlx = { version = "0.8", features = ["runtime-tokio", "tls-rustls", "postgres"] } +sqlx = { version = "0.8", features = ["runtime-tokio", "tls-rustls", "postgres", "chrono"] } [features] cloud = [] diff --git a/apps/gateway/src/connect.rs b/apps/gateway/src/connect.rs index 2789358..fa3edad 100644 --- a/apps/gateway/src/connect.rs +++ b/apps/gateway/src/connect.rs @@ -10,6 +10,7 @@ use std::time::{Duration, Instant}; use crate::crypto::CryptoService; use crate::db; use crate::inject::{ConnectRule, Injection}; +use crate::vault; use dashmap::DashMap; /// How long to cache resolved connect responses before re-checking. @@ -52,6 +53,7 @@ pub(crate) type ConnectCacheKey = (String, String); pub(crate) struct PolicyEngine { pub pool: sqlx::PgPool, pub crypto: Arc, + pub vault_cache: Option, } impl PolicyEngine { @@ -107,8 +109,48 @@ impl PolicyEngine { }); } + // 5. Also check vault records (OnlyKey-protected secrets) + if let Some(ref vc) = self.vault_cache { + let vault_records = vault::db::find_vault_records_by_host( + &self.pool, + &agent.user_id, + hostname, + ) + .await + .unwrap_or_default(); + + for vr in vault_records { + let scope = vault::models::CacheScope::from_str(&vr.cache_scope); + let scope_id = &agent.id; + let idle = std::time::Duration::from_secs(vr.idle_timeout_seconds as u64); + + // Try the unlock cache + if let Some(entry) = vc.get_and_touch(&vr.id, &scope, scope_id, idle) { + // Decrypt with cached record key + if let Ok(decrypted) = vault::crypto::decrypt_vault_secret( + &entry.record_key, + &vr.ciphertext_b64, + &vr.nonce_b64, + &vr.aad_json, + ) { + let path_pattern = vr.path_pattern.unwrap_or_else(|| "*".to_string()); + let injections = + build_injections(&vr.record_type, &decrypted, vr.injection_config.as_ref()); + + rules.push(ConnectRule { + path_pattern, + injections, + }); + } + } + // If not in cache, the record stays locked — agent must request via /v1/vault/records/:id/access + } + } + + let intercept = !rules.is_empty(); + Ok(ConnectResponse { - intercept: true, + intercept, rules, user_id: Some(agent.user_id), }) diff --git a/apps/gateway/src/gateway.rs b/apps/gateway/src/gateway.rs index 3be177f..08d91af 100644 --- a/apps/gateway/src/gateway.rs +++ b/apps/gateway/src/gateway.rs @@ -32,6 +32,7 @@ use crate::auth::AuthUser; use crate::ca::CertificateAuthority; use crate::connect::{self, CachedConnect, ConnectCacheKey, ConnectError, PolicyEngine}; use crate::inject::{self, ConnectRule}; +use crate::vault; // ── GatewayState ─────────────────────────────────────────────────────── @@ -42,6 +43,7 @@ pub(crate) struct GatewayState { pub http_client: reqwest::Client, pub policy_engine: Arc, pub connect_cache: Arc>, + pub vault_state: Arc, } // ── GatewayServer ─────────────────────────────────────────────────────── @@ -52,7 +54,12 @@ pub struct GatewayServer { } impl GatewayServer { - pub fn new(ca: CertificateAuthority, port: u16, policy_engine: Arc) -> Self { + pub fn new( + ca: CertificateAuthority, + port: u16, + policy_engine: Arc, + vault_state: Arc, + ) -> Self { let state = GatewayState { ca: Arc::new(ca), http_client: reqwest::Client::builder() @@ -63,6 +70,7 @@ impl GatewayServer { .expect("build HTTP client"), policy_engine, connect_cache: Arc::new(DashMap::new()), + vault_state, }; Self { state, port } @@ -95,11 +103,23 @@ impl GatewayServer { ]) .allow_credentials(true); + // Build vault sub-router with its own state (Arc). + // Uses nest_service to integrate into the main router which has GatewayState. + let vault_router: Router = Router::new() + .route("/records/{id}/access", axum::routing::post(vault::api::access_record)) + .route("/records/{id}/lock", axum::routing::post(vault::api::lock_record)) + .route("/browser/pending", axum::routing::get(vault::api::get_pending_approvals)) + .route("/browser/approve", axum::routing::post(vault::api::approve_request)) + .route("/agents/{id}/lock", axum::routing::post(vault::api::lock_agent_records)) + .route("/cache/revoke-all", axum::routing::post(vault::api::revoke_all_cache)) + .with_state(Arc::clone(&self.state.vault_state)); + // Build the Axum router for non-CONNECT routes. // The fallback returns 400 Bad Request for anything other than defined routes. let axum_router = Router::new() .route("/healthz", axum::routing::get(healthz)) .route("/me", axum::routing::get(me)) + .nest("/v1/vault", vault_router) .layer(cors_layer) .fallback(fallback) .with_state(self.state.clone()); diff --git a/apps/gateway/src/main.rs b/apps/gateway/src/main.rs index 7f105a7..cf1c805 100644 --- a/apps/gateway/src/main.rs +++ b/apps/gateway/src/main.rs @@ -11,6 +11,7 @@ mod crypto; mod db; mod gateway; mod inject; +mod vault; use std::path::{Path, PathBuf}; use std::sync::Arc; @@ -78,12 +79,41 @@ async fn main() -> Result<()> { // Load encryption key for secret decryption let crypto = Arc::new(crypto::CryptoService::from_env()?); - let policy_engine = Arc::new(PolicyEngine { pool, crypto }); + // Initialize OnlyKey Vault subsystem (before PolicyEngine so we can share the cache) + let vault_origin = std::env::var("VAULT_ORIGIN") + .unwrap_or_else(|_| "apps.crp.to".to_string()); + let vault_unlock_cache = vault::cache::InMemoryUnlockCache::new(); + let vault_state = Arc::new(vault::api::VaultState { + pool: pool.clone(), + unlock_cache: vault_unlock_cache.clone(), + default_origin: vault_origin, + }); + + let policy_engine = Arc::new(PolicyEngine { + pool, + crypto, + vault_cache: Some(vault_unlock_cache), + }); + + // Spawn periodic cache cleanup (every 60s) + { + let vc = vault_state.clone(); + tokio::spawn(async move { + let mut interval = tokio::time::interval(std::time::Duration::from_secs(60)); + loop { + interval.tick().await; + let removed = vc.unlock_cache.cleanup_expired(); + if removed > 0 { + info!(removed = removed, "vault cache: cleaned up expired entries"); + } + } + }); + } info!(port = cli.port, "gateway ready"); // Start the gateway server (blocks forever) - let server = GatewayServer::new(ca, cli.port, policy_engine); + let server = GatewayServer::new(ca, cli.port, policy_engine, vault_state); server.run().await } diff --git a/apps/gateway/src/vault/api.rs b/apps/gateway/src/vault/api.rs new file mode 100644 index 0000000..826d3c1 --- /dev/null +++ b/apps/gateway/src/vault/api.rs @@ -0,0 +1,525 @@ +//! HTTP API endpoints for the OnlyKey Vault. +//! +//! Agent-facing: +//! POST /v1/vault/records/:id/access — Request access to a vault secret +//! POST /v1/vault/records/:id/lock — Manually lock a record +//! +//! Browser-facing: +//! GET /v1/vault/browser/pending — Poll for pending approval requests +//! POST /v1/vault/browser/approve — Submit OnlyKey-derived approval +//! +//! Admin: +//! POST /v1/vault/agents/:id/lock — Lock all records for an agent +//! POST /v1/vault/cache/revoke-all — Revoke all cached keys + +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use axum::extract::{Path, State}; +use axum::http::StatusCode; +use axum::response::IntoResponse; +use axum::Json; +use base64::Engine; +use ring::rand::{SecureRandom, SystemRandom}; +use tracing::{info, warn}; + +use super::cache::{should_require_fresh_unlock, InMemoryUnlockCache, UnlockCacheEntry}; +use super::crypto; +use super::db as vault_db; +use super::models::*; + +// ── Vault state shared across handlers ────────────────────────────────── + +/// Shared state for vault API handlers. +#[derive(Clone)] +pub struct VaultState { + pub pool: sqlx::PgPool, + pub unlock_cache: InMemoryUnlockCache, + pub default_origin: String, +} + +// ── Agent endpoints ───────────────────────────────────────────────────── + +/// POST /v1/vault/records/:id/access +/// +/// Agent requests access to a vault-protected secret. +/// Returns the secret if already unlocked, or creates a pending approval. +pub async fn access_record( + State(state): State>, + Path(record_id): Path, + Json(req): Json, +) -> impl IntoResponse { + // 1. Load the record + let record = match vault_db::find_vault_record(&state.pool, &record_id).await { + Ok(Some(r)) => r, + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(AccessRecordResponse { + status: "denied".to_string(), + request_id: None, + secret: None, + expires_at: None, + }), + ); + } + Err(e) => { + warn!(error = %e, "vault access: db error"); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(AccessRecordResponse { + status: "denied".to_string(), + request_id: None, + secret: None, + expires_at: None, + }), + ); + } + }; + + // 2. Check if agent is allowed + if let Some(ref allowed) = record.allowed_agents { + if let Ok(agents) = serde_json::from_str::>(allowed) { + if !agents.is_empty() && !agents.contains(&req.agent_id) { + return ( + StatusCode::FORBIDDEN, + Json(AccessRecordResponse { + status: "denied".to_string(), + request_id: None, + secret: None, + expires_at: None, + }), + ); + } + } + } + + // 3. Determine scope + let scope = CacheScope::from_str(&record.cache_scope); + let scope_id = match scope { + CacheScope::Session => req.session_id.as_deref().unwrap_or(&req.agent_id), + _ => &req.agent_id, + }; + let idle_timeout = Duration::from_secs(record.idle_timeout_seconds as u64); + + // 4. Check unlock cache + let cache_entry = state + .unlock_cache + .get_and_touch(&record_id, &scope, scope_id, idle_timeout); + + let cache_hit = cache_entry.is_some(); + let needs_fresh = should_require_fresh_unlock( + record.require_onlykey, + record.require_fresh_unlock_for_high_risk, + cache_hit, + req.high_risk, + ); + + if needs_fresh { + // Create pending approval + let rng = SystemRandom::new(); + let mut nonce = [0u8; 32]; + let _ = rng.fill(&mut nonce); + let nonce_b64 = base64::engine::general_purpose::STANDARD.encode(nonce); + + let request_id = generate_id("vreq"); + let expires_at = + chrono::Utc::now().naive_utc() + chrono::Duration::seconds(300); // 5 min + + if let Err(e) = vault_db::create_approval_request( + &state.pool, + &request_id, + &record_id, + &req.agent_id, + req.session_id.as_deref(), + "unlock_record_key", + &state.default_origin, + &nonce_b64, + expires_at, + ) + .await + { + warn!(error = %e, "vault access: failed to create approval"); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(AccessRecordResponse { + status: "denied".to_string(), + request_id: None, + secret: None, + expires_at: None, + }), + ); + } + + // Log audit event + let _ = vault_db::insert_audit_event( + &state.pool, + &generate_id("vaud"), + &record_id, + "unlock_requested", + Some(&req.agent_id), + req.session_id.as_deref(), + None, + None, + None, + None, + ) + .await; + + info!( + record_id = record_id, + agent_id = req.agent_id, + request_id = request_id, + "vault: created pending approval" + ); + + return ( + StatusCode::ACCEPTED, + Json(AccessRecordResponse { + status: "pending_approval".to_string(), + request_id: Some(request_id), + secret: None, + expires_at: Some(expires_at.to_string()), + }), + ); + } + + // 5. Use cached record key to decrypt + if let Some(entry) = cache_entry { + match crypto::decrypt_vault_secret( + &entry.record_key, + &record.ciphertext_b64, + &record.nonce_b64, + &record.aad_json, + ) { + Ok(plaintext) => { + // Log usage + let _ = vault_db::insert_audit_event( + &state.pool, + &generate_id("vaud"), + &record_id, + "key_used", + Some(&req.agent_id), + req.session_id.as_deref(), + Some(&record.cache_scope), + Some(scope_id), + None, + None, + ) + .await; + + return ( + StatusCode::OK, + Json(AccessRecordResponse { + status: "ok".to_string(), + request_id: None, + secret: if record.allow_plaintext_return { + Some(plaintext) + } else { + None + }, + expires_at: None, + }), + ); + } + Err(e) => { + warn!(error = %e, record_id = record_id, "vault: decrypt failed with cached key"); + // Invalidate bad cache entry + state.unlock_cache.revoke_record( + &record_id, + &RevocationReason::PolicyChanged, + ); + } + } + } + + // Fallback: should not reach here normally + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(AccessRecordResponse { + status: "denied".to_string(), + request_id: None, + secret: None, + expires_at: None, + }), + ) +} + +/// POST /v1/vault/records/:id/lock +/// +/// Manually lock a vault record (revoke cached key). +pub async fn lock_record( + State(state): State>, + Path(record_id): Path, +) -> impl IntoResponse { + state + .unlock_cache + .revoke_record(&record_id, &RevocationReason::ManualRevoke); + + let _ = vault_db::insert_audit_event( + &state.pool, + &generate_id("vaud"), + &record_id, + "key_revoked", + None, + None, + None, + None, + Some("manual_revoke"), + None, + ) + .await; + + StatusCode::OK +} + +// ── Browser endpoints ─────────────────────────────────────────────────── + +/// GET /v1/vault/browser/pending?user_id=... +/// +/// Browser polls for pending approval requests. +pub async fn get_pending_approvals( + State(state): State>, + axum::extract::Query(params): axum::extract::Query>, +) -> impl IntoResponse { + let user_id = match params.get("user_id") { + Some(id) => id, + None => { + return ( + StatusCode::BAD_REQUEST, + Json(serde_json::json!({"error": "user_id required"})), + ); + } + }; + + match vault_db::find_pending_approvals(&state.pool, user_id).await { + Ok(items) => { + let views: Vec = items + .into_iter() + .map(|(approval, record)| { + let pubkey_jwk: serde_json::Value = + serde_json::from_str(&record.onecli_pubkey_jwk).unwrap_or_default(); + let additional_data: serde_json::Value = + serde_json::from_str(&record.derivation_context).unwrap_or_default(); + + PendingApprovalView { + request_id: approval.id, + record_id: approval.record_id, + record_name: record.name, + agent_id: approval.agent_id, + operation: approval.operation, + origin: approval.origin, + onecli_record_pubkey_jwk: pubkey_jwk, + additional_data, + created_at: approval.created_at.to_string(), + expires_at: approval.expires_at.to_string(), + nonce_b64: approval.nonce_b64, + } + }) + .collect(); + + (StatusCode::OK, Json(serde_json::json!({ "items": views }))) + } + Err(e) => { + warn!(error = %e, "vault browser: failed to fetch pending"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({"error": "internal error"})), + ) + } + } +} + +/// POST /v1/vault/browser/approve +/// +/// Browser submits OnlyKey-derived shared secret to approve a pending request. +/// Gateway uses it to unwrap record key, caches key in memory, completes the approval. +pub async fn approve_request( + State(state): State>, + Json(payload): Json, +) -> impl IntoResponse { + // 1. Load the approval request + let approval = match vault_db::find_approval_by_id(&state.pool, &payload.request_id).await { + Ok(Some(a)) if a.status == "pending" => a, + Ok(Some(_)) => { + return ( + StatusCode::CONFLICT, + Json(BrowserApproveResponse { + status: "error".to_string(), + message: Some("request already processed".to_string()), + }), + ); + } + Ok(None) => { + return ( + StatusCode::NOT_FOUND, + Json(BrowserApproveResponse { + status: "error".to_string(), + message: Some("request not found".to_string()), + }), + ); + } + Err(e) => { + warn!(error = %e, "vault approve: db error"); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(BrowserApproveResponse { + status: "error".to_string(), + message: Some("internal error".to_string()), + }), + ); + } + }; + + // Check expiry + if approval.expires_at < chrono::Utc::now().naive_utc() { + let _ = + vault_db::update_approval_status(&state.pool, &payload.request_id, "expired", None) + .await; + return ( + StatusCode::GONE, + Json(BrowserApproveResponse { + status: "error".to_string(), + message: Some("request expired".to_string()), + }), + ); + } + + // 2. Load the vault record + let record = match vault_db::find_vault_record(&state.pool, &approval.record_id).await { + Ok(Some(r)) => r, + _ => { + return ( + StatusCode::NOT_FOUND, + Json(BrowserApproveResponse { + status: "error".to_string(), + message: Some("record not found".to_string()), + }), + ); + } + }; + + // 3. Unwrap the record key using the browser-derived shared secret + let record_key = match crypto::unwrap_record_key( + &payload.derived_secret_b64, + &record.wrapped_key_b64, + &record.wrapped_key_nonce_b64, + &record.derivation_context, + ) { + Ok(key) => key, + Err(e) => { + warn!(error = %e, record_id = record.id, "vault approve: unwrap failed"); + let _ = vault_db::update_approval_status( + &state.pool, + &payload.request_id, + "denied", + Some(&payload.browser_session_id), + ) + .await; + return ( + StatusCode::UNAUTHORIZED, + Json(BrowserApproveResponse { + status: "error".to_string(), + message: Some("key unwrap failed — wrong OnlyKey or derivation mismatch".to_string()), + }), + ); + } + }; + + // 4. Cache the record key in memory + let now = Instant::now(); + let ttl = Duration::from_secs(record.unlock_ttl_seconds as u64); + let idle = Duration::from_secs(record.idle_timeout_seconds as u64); + let scope = CacheScope::from_str(&record.cache_scope); + let scope_id = match scope { + CacheScope::Session => approval + .session_id + .as_deref() + .unwrap_or(&approval.agent_id), + _ => &approval.agent_id, + }; + + let cache_entry = UnlockCacheEntry { + record_id: record.id.clone(), + scope_type: scope.clone(), + scope_id: scope_id.to_string(), + record_key, + unlocked_at: now, + absolute_expires_at: now + ttl, + idle_expires_at: now + idle, + last_used_at: now, + policy_version: record.policy_version as u32, + key_version: record.key_version as u32, + unlock_generation: record.unlock_generation as u64, + browser_session_id: Some(payload.browser_session_id.clone()), + }; + + state.unlock_cache.put(cache_entry); + + // 5. Update approval status + let _ = vault_db::update_approval_status( + &state.pool, + &payload.request_id, + "approved", + Some(&payload.browser_session_id), + ) + .await; + + // 6. Audit + let _ = vault_db::insert_audit_event( + &state.pool, + &generate_id("vaud"), + &record.id, + "unlock_approved", + Some(&approval.agent_id), + approval.session_id.as_deref(), + Some(&record.cache_scope), + Some(scope_id), + Some("onlykey_approved"), + None, + ) + .await; + + info!( + record_id = record.id, + agent_id = approval.agent_id, + request_id = payload.request_id, + ttl_seconds = record.unlock_ttl_seconds, + "vault: approval completed, key cached" + ); + + ( + StatusCode::OK, + Json(BrowserApproveResponse { + status: "ok".to_string(), + message: None, + }), + ) +} + +// ── Admin endpoints ───────────────────────────────────────────────────── + +/// POST /v1/vault/agents/:id/lock +pub async fn lock_agent_records( + State(state): State>, + Path(agent_id): Path, +) -> impl IntoResponse { + state + .unlock_cache + .revoke_agent(&agent_id, &RevocationReason::AdminRevoke); + StatusCode::OK +} + +/// POST /v1/vault/cache/revoke-all +pub async fn revoke_all_cache(State(state): State>) -> impl IntoResponse { + state + .unlock_cache + .revoke_all(&RevocationReason::AdminRevoke); + StatusCode::OK +} + +// ── Helpers ───────────────────────────────────────────────────────────── + +fn generate_id(prefix: &str) -> String { + format!("{}_{}", prefix, ulid::Ulid::new().to_string().to_lowercase()) +} diff --git a/apps/gateway/src/vault/cache.rs b/apps/gateway/src/vault/cache.rs new file mode 100644 index 0000000..e9f2f7e --- /dev/null +++ b/apps/gateway/src/vault/cache.rs @@ -0,0 +1,423 @@ +//! In-memory unlock cache for vault record keys. +//! +//! Stores unwrapped record keys in RAM with absolute TTL and idle timeout. +//! Supports scoped unlocks (per agent/session) and revocation events. +//! +//! SECURITY: Record keys exist ONLY in this cache. They are never persisted. +//! On expiry, revocation, or process restart, all cached keys are lost and +//! must be re-derived via OnlyKey. + +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; + +use tracing::info; + +use super::models::{CacheScope, RevocationReason}; + +// ── Cache entry ───────────────────────────────────────────────────────── + +/// A cached unwrapped record key with expiry metadata. +#[derive(Debug, Clone)] +pub struct UnlockCacheEntry { + pub record_id: String, + pub scope_type: CacheScope, + pub scope_id: String, + pub record_key: Vec, // raw 32-byte record key + pub unlocked_at: Instant, + pub absolute_expires_at: Instant, + pub idle_expires_at: Instant, + pub last_used_at: Instant, + pub policy_version: u32, + pub key_version: u32, + pub unlock_generation: u64, + pub browser_session_id: Option, +} + +impl UnlockCacheEntry { + /// Check if this entry is still valid (not expired by TTL or idle). + pub fn is_valid(&self) -> bool { + let now = Instant::now(); + now < self.absolute_expires_at && now < self.idle_expires_at + } + + /// Touch the entry to reset the idle timeout. + pub fn touch(&mut self, idle_timeout: Duration) { + self.last_used_at = Instant::now(); + self.idle_expires_at = self.last_used_at + idle_timeout; + } +} + +// ── Cache key ─────────────────────────────────────────────────────────── + +/// Composite key for the unlock cache: (record_id, scope_type, scope_id). +fn cache_key(record_id: &str, scope: &CacheScope, scope_id: &str) -> String { + let scope_str = match scope { + CacheScope::Global => "global", + CacheScope::Agent => "agent", + CacheScope::Session => "session", + }; + format!("{record_id}:{scope_str}:{scope_id}") +} + +// ── InMemoryUnlockCache ───────────────────────────────────────────────── + +/// Thread-safe in-memory cache for unlocked vault record keys. +#[derive(Clone)] +pub struct InMemoryUnlockCache { + inner: Arc>>, +} + +impl Default for InMemoryUnlockCache { + fn default() -> Self { + Self::new() + } +} + +impl InMemoryUnlockCache { + pub fn new() -> Self { + Self { + inner: Arc::new(Mutex::new(HashMap::new())), + } + } + + /// Get a valid cache entry, returning None if expired or missing. + /// Automatically removes expired entries. + pub fn get( + &self, + record_id: &str, + scope: &CacheScope, + scope_id: &str, + ) -> Option { + let key = cache_key(record_id, scope, scope_id); + let mut inner = self.inner.lock().ok()?; + + if let Some(entry) = inner.get(&key) { + if entry.is_valid() { + return Some(entry.clone()); + } + // Expired — remove it + inner.remove(&key); + } + None + } + + /// Get a valid entry and touch it to reset idle timeout. + pub fn get_and_touch( + &self, + record_id: &str, + scope: &CacheScope, + scope_id: &str, + idle_timeout: Duration, + ) -> Option { + let key = cache_key(record_id, scope, scope_id); + let mut inner = self.inner.lock().ok()?; + + if let Some(entry) = inner.get_mut(&key) { + if entry.is_valid() { + entry.touch(idle_timeout); + return Some(entry.clone()); + } + inner.remove(&key); + } + None + } + + /// Insert or update an unlock cache entry. + pub fn put(&self, entry: UnlockCacheEntry) { + let key = cache_key(&entry.record_id, &entry.scope_type, &entry.scope_id); + if let Ok(mut inner) = self.inner.lock() { + inner.insert(key, entry); + } + } + + /// Revoke (remove) all cache entries for a specific record. + pub fn revoke_record(&self, record_id: &str, reason: &RevocationReason) { + if let Ok(mut inner) = self.inner.lock() { + let before = inner.len(); + inner.retain(|_, entry| entry.record_id != record_id); + let removed = before - inner.len(); + if removed > 0 { + info!( + record_id = record_id, + reason = reason.as_str(), + removed = removed, + "vault cache: revoked record entries" + ); + } + } + } + + /// Revoke a specific scoped entry for a record. + pub fn revoke_record_scope( + &self, + record_id: &str, + scope: &CacheScope, + scope_id: &str, + reason: &RevocationReason, + ) { + let key = cache_key(record_id, scope, scope_id); + if let Ok(mut inner) = self.inner.lock() { + if inner.remove(&key).is_some() { + info!( + record_id = record_id, + scope_id = scope_id, + reason = reason.as_str(), + "vault cache: revoked scoped entry" + ); + } + } + } + + /// Revoke all entries for a specific agent (across all records). + pub fn revoke_agent(&self, agent_id: &str, reason: &RevocationReason) { + if let Ok(mut inner) = self.inner.lock() { + let before = inner.len(); + inner.retain(|_, entry| { + !(entry.scope_type == CacheScope::Agent && entry.scope_id == agent_id) + }); + let removed = before - inner.len(); + if removed > 0 { + info!( + agent_id = agent_id, + reason = reason.as_str(), + removed = removed, + "vault cache: revoked agent entries" + ); + } + } + } + + /// Revoke all entries for a specific browser session. + pub fn revoke_browser_session(&self, browser_session_id: &str, reason: &RevocationReason) { + if let Ok(mut inner) = self.inner.lock() { + let before = inner.len(); + inner.retain(|_, entry| { + entry + .browser_session_id + .as_deref() + .map_or(true, |id| id != browser_session_id) + }); + let removed = before - inner.len(); + if removed > 0 { + info!( + browser_session_id = browser_session_id, + reason = reason.as_str(), + removed = removed, + "vault cache: revoked browser session entries" + ); + } + } + } + + /// Revoke ALL cached entries (e.g., on server restart or admin command). + pub fn revoke_all(&self, reason: &RevocationReason) { + if let Ok(mut inner) = self.inner.lock() { + let count = inner.len(); + inner.clear(); + if count > 0 { + info!( + reason = reason.as_str(), + removed = count, + "vault cache: revoked all entries" + ); + } + } + } + + /// Run a cleanup pass to remove expired entries. Call periodically. + pub fn cleanup_expired(&self) -> usize { + let mut removed = 0; + if let Ok(mut inner) = self.inner.lock() { + let before = inner.len(); + inner.retain(|_, entry| entry.is_valid()); + removed = before - inner.len(); + } + removed + } + + /// Get the number of cached entries (for monitoring). + pub fn len(&self) -> usize { + self.inner.lock().map(|i| i.len()).unwrap_or(0) + } + + pub fn is_empty(&self) -> bool { + self.len() == 0 + } +} + +// ── Helper: should we require fresh unlock? ───────────────────────────── + +/// Determine if a fresh OnlyKey unlock is required based on policy and cache state. +pub fn should_require_fresh_unlock( + require_onlykey: bool, + require_fresh_for_high_risk: bool, + cache_hit: bool, + high_risk: bool, +) -> bool { + if !require_onlykey { + return false; + } + if high_risk && require_fresh_for_high_risk { + return true; + } + !cache_hit +} + +// ── Tests ─────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + + fn make_entry(record_id: &str, scope_id: &str, ttl_secs: u64, idle_secs: u64) -> UnlockCacheEntry { + let now = Instant::now(); + UnlockCacheEntry { + record_id: record_id.to_string(), + scope_type: CacheScope::Agent, + scope_id: scope_id.to_string(), + record_key: vec![0u8; 32], + unlocked_at: now, + absolute_expires_at: now + Duration::from_secs(ttl_secs), + idle_expires_at: now + Duration::from_secs(idle_secs), + last_used_at: now, + policy_version: 1, + key_version: 1, + unlock_generation: 1, + browser_session_id: Some("bsess_1".to_string()), + } + } + + #[test] + fn cache_put_and_get() { + let cache = InMemoryUnlockCache::new(); + let entry = make_entry("rec_1", "agent_1", 3600, 600); + cache.put(entry.clone()); + + let result = cache.get("rec_1", &CacheScope::Agent, "agent_1"); + assert!(result.is_some()); + assert_eq!(result.unwrap().record_id, "rec_1"); + } + + #[test] + fn cache_miss_returns_none() { + let cache = InMemoryUnlockCache::new(); + assert!(cache.get("rec_1", &CacheScope::Agent, "agent_1").is_none()); + } + + #[test] + fn cache_expired_entry_removed() { + let cache = InMemoryUnlockCache::new(); + let now = Instant::now(); + let entry = UnlockCacheEntry { + record_id: "rec_1".to_string(), + scope_type: CacheScope::Agent, + scope_id: "agent_1".to_string(), + record_key: vec![0u8; 32], + unlocked_at: now - Duration::from_secs(100), + absolute_expires_at: now - Duration::from_secs(1), // already expired + idle_expires_at: now + Duration::from_secs(600), + last_used_at: now, + policy_version: 1, + key_version: 1, + unlock_generation: 1, + browser_session_id: None, + }; + cache.put(entry); + assert!(cache.get("rec_1", &CacheScope::Agent, "agent_1").is_none()); + } + + #[test] + fn revoke_record_removes_all_scopes() { + let cache = InMemoryUnlockCache::new(); + cache.put(make_entry("rec_1", "agent_1", 3600, 600)); + cache.put(make_entry("rec_1", "agent_2", 3600, 600)); + cache.put(make_entry("rec_2", "agent_1", 3600, 600)); + + cache.revoke_record("rec_1", &RevocationReason::ManualRevoke); + + assert!(cache.get("rec_1", &CacheScope::Agent, "agent_1").is_none()); + assert!(cache.get("rec_1", &CacheScope::Agent, "agent_2").is_none()); + assert!(cache.get("rec_2", &CacheScope::Agent, "agent_1").is_some()); + } + + #[test] + fn revoke_agent_removes_agent_entries() { + let cache = InMemoryUnlockCache::new(); + cache.put(make_entry("rec_1", "agent_1", 3600, 600)); + cache.put(make_entry("rec_2", "agent_1", 3600, 600)); + cache.put(make_entry("rec_3", "agent_2", 3600, 600)); + + cache.revoke_agent("agent_1", &RevocationReason::AdminRevoke); + + assert!(cache.get("rec_1", &CacheScope::Agent, "agent_1").is_none()); + assert!(cache.get("rec_2", &CacheScope::Agent, "agent_1").is_none()); + assert!(cache.get("rec_3", &CacheScope::Agent, "agent_2").is_some()); + } + + #[test] + fn revoke_all_clears_everything() { + let cache = InMemoryUnlockCache::new(); + cache.put(make_entry("rec_1", "agent_1", 3600, 600)); + cache.put(make_entry("rec_2", "agent_2", 3600, 600)); + + cache.revoke_all(&RevocationReason::ServerRestart); + + assert_eq!(cache.len(), 0); + } + + #[test] + fn revoke_browser_session() { + let cache = InMemoryUnlockCache::new(); + cache.put(make_entry("rec_1", "agent_1", 3600, 600)); // bsess_1 + let mut entry2 = make_entry("rec_2", "agent_1", 3600, 600); + entry2.browser_session_id = Some("bsess_2".to_string()); + cache.put(entry2); + + cache.revoke_browser_session("bsess_1", &RevocationReason::BrowserDisconnect); + + assert!(cache.get("rec_1", &CacheScope::Agent, "agent_1").is_none()); + assert!(cache.get("rec_2", &CacheScope::Agent, "agent_1").is_some()); + } + + #[test] + fn should_require_fresh_unlock_logic() { + // Not required when onlykey not required + assert!(!should_require_fresh_unlock(false, true, false, true)); + + // Required when high risk and policy says so + assert!(should_require_fresh_unlock(true, true, true, true)); + + // Required when no cache hit + assert!(should_require_fresh_unlock(true, false, false, false)); + + // Not required when cache hit and not high risk + assert!(!should_require_fresh_unlock(true, true, true, false)); + } + + #[test] + fn cleanup_expired_removes_stale() { + let cache = InMemoryUnlockCache::new(); + cache.put(make_entry("rec_1", "agent_1", 3600, 600)); // valid + let now = Instant::now(); + let expired = UnlockCacheEntry { + record_id: "rec_2".to_string(), + scope_type: CacheScope::Agent, + scope_id: "agent_1".to_string(), + record_key: vec![0u8; 32], + unlocked_at: now - Duration::from_secs(100), + absolute_expires_at: now - Duration::from_secs(1), + idle_expires_at: now - Duration::from_secs(1), + last_used_at: now - Duration::from_secs(50), + policy_version: 1, + key_version: 1, + unlock_generation: 1, + browser_session_id: None, + }; + cache.put(expired); + + let removed = cache.cleanup_expired(); + assert_eq!(removed, 1); + assert_eq!(cache.len(), 1); + } +} diff --git a/apps/gateway/src/vault/crypto.rs b/apps/gateway/src/vault/crypto.rs new file mode 100644 index 0000000..d6b1008 --- /dev/null +++ b/apps/gateway/src/vault/crypto.rs @@ -0,0 +1,332 @@ +//! Cryptographic operations for the OnlyKey Vault. +//! +//! - HKDF-SHA256 for deriving subkeys from the OnlyKey shared secret +//! - AES-256-GCM for unwrapping record keys and decrypting secrets +//! +//! The browser derives a shared secret via `ok.derive_shared_secret()` using the +//! per-record OneCLI public key. That raw secret is NEVER used directly as a key. +//! Instead we run HKDF to derive purpose-specific subkeys. + +use anyhow::{bail, Context, Result}; +use base64::Engine; +use ring::aead; +use ring::hkdf; + +const KEY_LEN: usize = 32; +const NONCE_LEN: usize = 12; + +// ── HKDF labels ───────────────────────────────────────────────────────── + +/// Label for deriving the key-wrapping key from the OnlyKey shared secret. +const HKDF_LABEL_WRAP_KEY: &[u8] = b"okg-wrap-key-v1"; + +// ── HKDF derivation ───────────────────────────────────────────────────── + +/// Derive a 32-byte wrapping key from the raw OnlyKey shared secret using HKDF-SHA256. +/// +/// `context` should include record_id, purpose, version, etc. to bind the +/// derived key to a specific record. +pub fn derive_wrap_key( + shared_secret: &[u8], + context: &[u8], +) -> Result<[u8; KEY_LEN]> { + let salt = hkdf::Salt::new(hkdf::HKDF_SHA256, context); + let prk = salt.extract(shared_secret); + + let info = &[HKDF_LABEL_WRAP_KEY]; + let okm = prk + .expand(info, HkdfKeyLen) + .map_err(|_| anyhow::anyhow!("HKDF expand failed"))?; + + let mut key = [0u8; KEY_LEN]; + okm.fill(&mut key) + .map_err(|_| anyhow::anyhow!("HKDF fill failed"))?; + + Ok(key) +} + +/// ring HKDF requires a type implementing `KeyType` to specify output length. +struct HkdfKeyLen; + +impl hkdf::KeyType for HkdfKeyLen { + fn len(&self) -> usize { + KEY_LEN + } +} + +// ── AES-256-GCM operations ────────────────────────────────────────────── + +/// Decrypt ciphertext using AES-256-GCM. +/// +/// `key` must be exactly 32 bytes. +/// `nonce` must be exactly 12 bytes. +/// `aad` is the additional authenticated data. +/// Returns the plaintext bytes. +pub fn aes_gcm_decrypt( + key: &[u8], + nonce_bytes: &[u8], + aad: &[u8], + ciphertext_with_tag: &[u8], +) -> Result> { + if key.len() != KEY_LEN { + bail!("AES key must be {KEY_LEN} bytes, got {}", key.len()); + } + if nonce_bytes.len() != NONCE_LEN { + bail!("nonce must be {NONCE_LEN} bytes, got {}", nonce_bytes.len()); + } + + let unbound_key = aead::UnboundKey::new(&aead::AES_256_GCM, key) + .map_err(|_| anyhow::anyhow!("failed to create AES-256-GCM key"))?; + let less_safe_key = aead::LessSafeKey::new(unbound_key); + + let nonce = aead::Nonce::try_assume_unique_for_key(nonce_bytes) + .map_err(|_| anyhow::anyhow!("invalid nonce"))?; + + let mut in_out = ciphertext_with_tag.to_vec(); + let plaintext = less_safe_key + .open_in_place(nonce, aead::Aad::from(aad), &mut in_out) + .map_err(|_| anyhow::anyhow!("AES-GCM decryption failed"))?; + + Ok(plaintext.to_vec()) +} + +/// Encrypt plaintext using AES-256-GCM. +/// +/// Returns (ciphertext_with_tag, nonce). +pub fn aes_gcm_encrypt( + key: &[u8], + aad: &[u8], + plaintext: &[u8], +) -> Result<(Vec, [u8; NONCE_LEN])> { + if key.len() != KEY_LEN { + bail!("AES key must be {KEY_LEN} bytes, got {}", key.len()); + } + + let unbound_key = aead::UnboundKey::new(&aead::AES_256_GCM, key) + .map_err(|_| anyhow::anyhow!("failed to create AES-256-GCM key"))?; + let less_safe_key = aead::LessSafeKey::new(unbound_key); + + let rng = ring::rand::SystemRandom::new(); + let mut nonce_bytes = [0u8; NONCE_LEN]; + ring::rand::SecureRandom::fill(&rng, &mut nonce_bytes) + .map_err(|_| anyhow::anyhow!("failed to generate nonce"))?; + + let nonce = aead::Nonce::try_assume_unique_for_key(&nonce_bytes) + .map_err(|_| anyhow::anyhow!("invalid nonce"))?; + + let mut in_out = plaintext.to_vec(); + less_safe_key + .seal_in_place_append_tag(nonce, aead::Aad::from(aad), &mut in_out) + .map_err(|_| anyhow::anyhow!("AES-GCM encryption failed"))?; + + Ok((in_out, nonce_bytes)) +} + +// ── High-level vault operations ───────────────────────────────────────── + +/// Unwrap a record key using the browser-derived shared secret. +/// +/// 1. HKDF the raw shared secret → wrapping key +/// 2. AES-GCM decrypt the wrapped record key +/// 3. Return the raw record key bytes +/// +/// The caller should cache this record key in memory and immediately +/// zeroize the shared secret and wrapping key. +pub fn unwrap_record_key( + derived_secret_b64: &str, + wrapped_key_b64: &str, + wrapped_key_nonce_b64: &str, + derivation_context_json: &str, +) -> Result> { + let b64 = &base64::engine::general_purpose::STANDARD; + + let derived_secret = b64 + .decode(derived_secret_b64) + .context("invalid derived_secret_b64")?; + + let wrapped_key = b64 + .decode(wrapped_key_b64) + .context("invalid wrapped_key_b64")?; + + let wrapped_nonce = b64 + .decode(wrapped_key_nonce_b64) + .context("invalid wrapped_key_nonce_b64")?; + + // HKDF: derive wrapping key from shared secret + context + let wrap_key = derive_wrap_key(&derived_secret, derivation_context_json.as_bytes())?; + + // Unwrap the record key + let record_key = aes_gcm_decrypt( + &wrap_key, + &wrapped_nonce, + derivation_context_json.as_bytes(), // AAD = derivation context + &wrapped_key, + )?; + + // Best-effort zeroize temporaries (limited in safe Rust) + drop(derived_secret); + drop(wrap_key); + + Ok(record_key) +} + +/// Decrypt a vault secret using the unwrapped record key. +/// +/// The record key should come from the in-memory cache (not re-derived each time +/// unless the cache has expired). +pub fn decrypt_vault_secret( + record_key: &[u8], + ciphertext_b64: &str, + nonce_b64: &str, + aad_json: &str, +) -> Result { + let b64 = &base64::engine::general_purpose::STANDARD; + + let ciphertext = b64.decode(ciphertext_b64).context("invalid ciphertext_b64")?; + let nonce = b64.decode(nonce_b64).context("invalid nonce_b64")?; + + let plaintext_bytes = aes_gcm_decrypt(record_key, &nonce, aad_json.as_bytes(), &ciphertext)?; + + String::from_utf8(plaintext_bytes).context("decrypted value is not valid UTF-8") +} + +/// Wrap a record key for storage, using a derived wrapping key. +/// +/// Used during record creation: +/// 1. Browser derives shared secret via OnlyKey +/// 2. HKDF → wrapping key +/// 3. AES-GCM encrypt the random record key +/// 4. Store wrapped key blob +pub fn wrap_record_key( + derived_secret_b64: &str, + record_key: &[u8], + derivation_context_json: &str, +) -> Result<(String, String)> { + let b64 = &base64::engine::general_purpose::STANDARD; + + let derived_secret = b64 + .decode(derived_secret_b64) + .context("invalid derived_secret_b64")?; + + let wrap_key = derive_wrap_key(&derived_secret, derivation_context_json.as_bytes())?; + + let (wrapped_with_tag, nonce) = aes_gcm_encrypt( + &wrap_key, + derivation_context_json.as_bytes(), + record_key, + )?; + + Ok((b64.encode(wrapped_with_tag), b64.encode(nonce))) +} + +// ── Tests ─────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use ring::rand::{SecureRandom, SystemRandom}; + + fn random_bytes(len: usize) -> Vec { + let rng = SystemRandom::new(); + let mut buf = vec![0u8; len]; + rng.fill(&mut buf).expect("generate random bytes"); + buf + } + + #[test] + fn hkdf_derive_produces_32_bytes() { + let secret = random_bytes(32); + let context = b"test-context"; + let key = derive_wrap_key(&secret, context).expect("derive key"); + assert_eq!(key.len(), 32); + } + + #[test] + fn hkdf_deterministic() { + let secret = random_bytes(32); + let context = b"record_123:wrap:v1"; + let key1 = derive_wrap_key(&secret, context).expect("derive 1"); + let key2 = derive_wrap_key(&secret, context).expect("derive 2"); + assert_eq!(key1, key2); + } + + #[test] + fn hkdf_different_context_different_key() { + let secret = random_bytes(32); + let key1 = derive_wrap_key(&secret, b"context-a").expect("derive a"); + let key2 = derive_wrap_key(&secret, b"context-b").expect("derive b"); + assert_ne!(key1, key2); + } + + #[test] + fn aes_gcm_round_trip() { + let key = random_bytes(32); + let aad = b"test-aad"; + let plaintext = b"hello world secret"; + + let (ciphertext, nonce) = aes_gcm_encrypt(&key, aad, plaintext).expect("encrypt"); + let decrypted = aes_gcm_decrypt(&key, &nonce, aad, &ciphertext).expect("decrypt"); + assert_eq!(decrypted, plaintext); + } + + #[test] + fn aes_gcm_wrong_key_fails() { + let key1 = random_bytes(32); + let key2 = random_bytes(32); + let aad = b"test"; + let (ciphertext, nonce) = aes_gcm_encrypt(&key1, aad, b"secret").expect("encrypt"); + assert!(aes_gcm_decrypt(&key2, &nonce, aad, &ciphertext).is_err()); + } + + #[test] + fn aes_gcm_wrong_aad_fails() { + let key = random_bytes(32); + let (ciphertext, nonce) = aes_gcm_encrypt(&key, b"aad-1", b"secret").expect("encrypt"); + assert!(aes_gcm_decrypt(&key, &nonce, b"aad-2", &ciphertext).is_err()); + } + + #[test] + fn wrap_unwrap_record_key_round_trip() { + let b64 = &base64::engine::general_purpose::STANDARD; + + // Simulate browser-derived shared secret + let shared_secret = random_bytes(32); + let shared_secret_b64 = b64.encode(&shared_secret); + + // Random record key + let record_key = random_bytes(32); + + let context = r#"{"record_id":"rec_123","purpose":"record_key_wrap","version":1}"#; + + // Wrap + let (wrapped_b64, nonce_b64) = + wrap_record_key(&shared_secret_b64, &record_key, context).expect("wrap"); + + // Unwrap + let recovered = unwrap_record_key(&shared_secret_b64, &wrapped_b64, &nonce_b64, context) + .expect("unwrap"); + + assert_eq!(recovered, record_key); + } + + #[test] + fn decrypt_vault_secret_round_trip() { + let b64 = &base64::engine::general_purpose::STANDARD; + let record_key = random_bytes(32); + let aad = r#"{"record_id":"rec_123","type":"api_key","version":1}"#; + let secret = "sk-ant-api03-my-secret-key"; + + // Encrypt + let (ciphertext, nonce) = + aes_gcm_encrypt(&record_key, aad.as_bytes(), secret.as_bytes()).expect("encrypt"); + + let ct_b64 = b64.encode(&ciphertext); + let nonce_b64 = b64.encode(&nonce); + + // Decrypt + let decrypted = decrypt_vault_secret(&record_key, &ct_b64, &nonce_b64, aad) + .expect("decrypt"); + + assert_eq!(decrypted, secret); + } +} diff --git a/apps/gateway/src/vault/db.rs b/apps/gateway/src/vault/db.rs new file mode 100644 index 0000000..e7c6f89 --- /dev/null +++ b/apps/gateway/src/vault/db.rs @@ -0,0 +1,302 @@ +//! Database queries for vault records and approval requests. +//! +//! Read-only queries run by the gateway. Writes happen via the Next.js app +//! (Prisma) or via specific vault API endpoints. + +use anyhow::{Context, Result}; +use sqlx::{FromRow, PgPool}; + +// ── Row types ─────────────────────────────────────────────────────────── + +/// A vault record row from the `VaultRecord` table. +#[derive(Debug, FromRow)] +pub struct VaultRecordRow { + pub id: String, + #[sqlx(rename = "userId")] + pub user_id: String, + pub name: String, + #[sqlx(rename = "recordType")] + pub record_type: String, + + // Encrypted payload + #[sqlx(rename = "ciphertextB64")] + pub ciphertext_b64: String, + #[sqlx(rename = "nonceB64")] + pub nonce_b64: String, + #[sqlx(rename = "aadJson")] + pub aad_json: String, + + // Wrapped record key + #[sqlx(rename = "wrappedKeyB64")] + pub wrapped_key_b64: String, + #[sqlx(rename = "wrappedKeyNonceB64")] + pub wrapped_key_nonce_b64: String, + #[sqlx(rename = "wrapAlg")] + pub wrap_alg: String, + + // OneCLI public key and derivation context + #[sqlx(rename = "onecliPubkeyJwk")] + pub onecli_pubkey_jwk: String, + #[sqlx(rename = "derivationContext")] + pub derivation_context: String, + + // Policy fields + #[sqlx(rename = "requireOnlykey")] + pub require_onlykey: bool, + #[sqlx(rename = "unlockTtlSeconds")] + pub unlock_ttl_seconds: i32, + #[sqlx(rename = "idleTimeoutSeconds")] + pub idle_timeout_seconds: i32, + #[sqlx(rename = "cacheScope")] + pub cache_scope: String, + #[sqlx(rename = "allowManualRevoke")] + pub allow_manual_revoke: bool, + #[sqlx(rename = "relockOnBrowserDisconnect")] + pub relock_on_browser_disconnect: bool, + #[sqlx(rename = "relockOnPolicyChange")] + pub relock_on_policy_change: bool, + #[sqlx(rename = "requireFreshUnlockForHighRisk")] + pub require_fresh_unlock_for_high_risk: bool, + #[sqlx(rename = "allowPlaintextReturn")] + pub allow_plaintext_return: bool, + #[sqlx(rename = "allowedAgents")] + pub allowed_agents: Option, + + // Versioning + #[sqlx(rename = "recordVersion")] + pub record_version: i32, + #[sqlx(rename = "policyVersion")] + pub policy_version: i32, + #[sqlx(rename = "keyVersion")] + pub key_version: i32, + #[sqlx(rename = "unlockGeneration")] + pub unlock_generation: i32, + + // Injection config + #[sqlx(rename = "hostPattern")] + pub host_pattern: String, + #[sqlx(rename = "pathPattern")] + pub path_pattern: Option, + #[sqlx(rename = "injectionConfig")] + pub injection_config: Option, +} + +/// A pending approval request row. +#[derive(Debug, FromRow)] +pub struct VaultApprovalRow { + pub id: String, + #[sqlx(rename = "recordId")] + pub record_id: String, + #[sqlx(rename = "agentId")] + pub agent_id: String, + #[sqlx(rename = "sessionId")] + pub session_id: Option, + pub operation: String, + pub status: String, + pub origin: String, + #[sqlx(rename = "nonceB64")] + pub nonce_b64: String, + #[sqlx(rename = "browserSessionId")] + pub browser_session_id: Option, + #[sqlx(rename = "createdAt")] + pub created_at: chrono::NaiveDateTime, + #[sqlx(rename = "expiresAt")] + pub expires_at: chrono::NaiveDateTime, +} + +// ── Queries ───────────────────────────────────────────────────────────── + +/// Find a vault record by ID. +pub async fn find_vault_record(pool: &PgPool, record_id: &str) -> Result> { + sqlx::query_as::<_, VaultRecordRow>( + r#"SELECT * FROM "VaultRecord" WHERE id = $1 LIMIT 1"#, + ) + .bind(record_id) + .fetch_optional(pool) + .await + .context("querying VaultRecord by id") +} + +/// Find all vault records for a user. +pub async fn find_vault_records_by_user( + pool: &PgPool, + user_id: &str, +) -> Result> { + sqlx::query_as::<_, VaultRecordRow>( + r#"SELECT * FROM "VaultRecord" WHERE "userId" = $1 ORDER BY "createdAt" DESC"#, + ) + .bind(user_id) + .fetch_all(pool) + .await + .context("querying VaultRecords by userId") +} + +/// Find vault records matching a hostname for a user. +pub async fn find_vault_records_by_host( + pool: &PgPool, + user_id: &str, + hostname: &str, +) -> Result> { + // We load all records for the user and filter in Rust (same pattern as connect.rs) + // to support wildcard host patterns like "*.example.com" + let all = find_vault_records_by_user(pool, user_id).await?; + Ok(all + .into_iter() + .filter(|r| host_matches(hostname, &r.host_pattern)) + .collect()) +} + +/// Find pending approval requests (status = "pending", not expired). +pub async fn find_pending_approvals( + pool: &PgPool, + user_id: &str, +) -> Result> { + // Join approval requests with their vault records, filtered by user + let approvals = sqlx::query_as::<_, VaultApprovalRow>( + r#"SELECT a.* FROM "VaultApprovalRequest" a + INNER JOIN "VaultRecord" r ON a."recordId" = r.id + WHERE r."userId" = $1 AND a.status = 'pending' AND a."expiresAt" > NOW() + ORDER BY a."createdAt" DESC"#, + ) + .bind(user_id) + .fetch_all(pool) + .await + .context("querying pending vault approvals")?; + + let mut results = Vec::new(); + for approval in approvals { + if let Some(record) = find_vault_record(pool, &approval.record_id).await? { + results.push((approval, record)); + } + } + + Ok(results) +} + +/// Find a specific pending approval by request ID. +pub async fn find_approval_by_id( + pool: &PgPool, + request_id: &str, +) -> Result> { + sqlx::query_as::<_, VaultApprovalRow>( + r#"SELECT * FROM "VaultApprovalRequest" WHERE id = $1 LIMIT 1"#, + ) + .bind(request_id) + .fetch_optional(pool) + .await + .context("querying VaultApprovalRequest by id") +} + +/// Create a pending approval request. +pub async fn create_approval_request( + pool: &PgPool, + id: &str, + record_id: &str, + agent_id: &str, + session_id: Option<&str>, + operation: &str, + origin: &str, + nonce_b64: &str, + expires_at: chrono::NaiveDateTime, +) -> Result<()> { + sqlx::query( + r#"INSERT INTO "VaultApprovalRequest" + (id, "recordId", "agentId", "sessionId", operation, status, origin, "nonceB64", "expiresAt") + VALUES ($1, $2, $3, $4, $5, 'pending', $6, $7, $8)"#, + ) + .bind(id) + .bind(record_id) + .bind(agent_id) + .bind(session_id) + .bind(operation) + .bind(origin) + .bind(nonce_b64) + .bind(expires_at) + .execute(pool) + .await + .context("inserting VaultApprovalRequest")?; + + Ok(()) +} + +/// Update approval request status. +pub async fn update_approval_status( + pool: &PgPool, + request_id: &str, + status: &str, + browser_session_id: Option<&str>, +) -> Result<()> { + sqlx::query( + r#"UPDATE "VaultApprovalRequest" + SET status = $2, "browserSessionId" = $3 + WHERE id = $1"#, + ) + .bind(request_id) + .bind(status) + .bind(browser_session_id) + .execute(pool) + .await + .context("updating VaultApprovalRequest status")?; + + Ok(()) +} + +/// Insert an audit event. +pub async fn insert_audit_event( + pool: &PgPool, + id: &str, + record_id: &str, + event: &str, + agent_id: Option<&str>, + session_id: Option<&str>, + scope_type: Option<&str>, + scope_id: Option<&str>, + reason: Option<&str>, + metadata: Option<&serde_json::Value>, +) -> Result<()> { + sqlx::query( + r#"INSERT INTO "VaultAuditEvent" + (id, "recordId", event, "agentId", "sessionId", "scopeType", "scopeId", reason, metadata) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)"#, + ) + .bind(id) + .bind(record_id) + .bind(event) + .bind(agent_id) + .bind(session_id) + .bind(scope_type) + .bind(scope_id) + .bind(reason) + .bind(metadata) + .execute(pool) + .await + .context("inserting VaultAuditEvent")?; + + Ok(()) +} + +/// Expire stale pending approval requests. +pub async fn expire_stale_approvals(pool: &PgPool) -> Result { + let result = sqlx::query( + r#"UPDATE "VaultApprovalRequest" + SET status = 'expired' + WHERE status = 'pending' AND "expiresAt" <= NOW()"#, + ) + .execute(pool) + .await + .context("expiring stale approvals")?; + + Ok(result.rows_affected()) +} + +// ── Host matching (shared with connect.rs) ────────────────────────────── + +fn host_matches(request_host: &str, pattern: &str) -> bool { + if request_host == pattern { + return true; + } + if let Some(suffix) = pattern.strip_prefix('*') { + return request_host.ends_with(suffix) && request_host.len() > suffix.len(); + } + false +} diff --git a/apps/gateway/src/vault/mod.rs b/apps/gateway/src/vault/mod.rs new file mode 100644 index 0000000..f4cfe7f --- /dev/null +++ b/apps/gateway/src/vault/mod.rs @@ -0,0 +1,18 @@ +//! OnlyKey Vault: hardware-backed secret protection via FIDO2/WebAuthn bridge. +//! +//! Secrets are encrypted with per-record AES-256-GCM keys. Those record keys are +//! wrapped using a shared secret derived from OnlyKey's `ok.derive_shared_secret()` +//! via the browser FIDO2 bridge. OneCLI stores NO private decryption keys. +//! +//! Flow: +//! 1. Agent requests secret → gateway checks in-memory unlock cache +//! 2. If locked → create pending approval → browser polls and fulfills via OnlyKey +//! 3. Browser derives shared secret, sends to gateway +//! 4. Gateway HKDF → unwrap record key → cache in memory with TTL → decrypt secret +//! 5. On TTL/idle/revocation → zeroize cached key + +pub mod api; +pub mod cache; +pub mod crypto; +pub mod db; +pub mod models; diff --git a/apps/gateway/src/vault/models.rs b/apps/gateway/src/vault/models.rs new file mode 100644 index 0000000..146a851 --- /dev/null +++ b/apps/gateway/src/vault/models.rs @@ -0,0 +1,225 @@ +//! Data models for the OnlyKey Vault system. + +use serde::{Deserialize, Serialize}; + +// ── Record types ──────────────────────────────────────────────────────── + +/// Type of secret stored in a vault record. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "snake_case")] +pub enum RecordType { + ApiKey, + OauthToken, + AgeSecret, + GenericSecret, +} + +impl RecordType { + pub fn as_str(&self) -> &'static str { + match self { + Self::ApiKey => "api_key", + Self::OauthToken => "oauth_token", + Self::AgeSecret => "age_secret", + Self::GenericSecret => "generic_secret", + } + } + + pub fn from_str(s: &str) -> Option { + match s { + "api_key" => Some(Self::ApiKey), + "oauth_token" => Some(Self::OauthToken), + "age_secret" => Some(Self::AgeSecret), + "generic_secret" => Some(Self::GenericSecret), + _ => None, + } + } +} + +// ── Cache scope ───────────────────────────────────────────────────────── + +/// Scope for unlock cache entries. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Hash)] +#[serde(rename_all = "snake_case")] +pub enum CacheScope { + Global, + Agent, + Session, +} + +impl CacheScope { + pub fn from_str(s: &str) -> Self { + match s { + "global" => Self::Global, + "session" => Self::Session, + _ => Self::Agent, // default + } + } +} + +// ── Approval status ───────────────────────────────────────────────────── + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "snake_case")] +pub enum ApprovalStatus { + Pending, + Approved, + Denied, + Expired, +} + +// ── Browser operation ─────────────────────────────────────────────────── + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "snake_case")] +pub enum BrowserOperation { + UnlockRecordKey, + DecryptAge, + SignBlob, +} + +// ── Unlock reason ─────────────────────────────────────────────────────── + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "snake_case")] +pub enum UnlockReason { + OnlykeyApproved, + AdminOverride, +} + +// ── Revocation reason ─────────────────────────────────────────────────── + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "snake_case")] +pub enum RevocationReason { + ManualRevoke, + TtlExpired, + IdleTimeout, + BrowserDisconnect, + PolicyChanged, + KeyRotated, + ServerRestart, + AdminRevoke, +} + +impl RevocationReason { + pub fn as_str(&self) -> &'static str { + match self { + Self::ManualRevoke => "manual_revoke", + Self::TtlExpired => "ttl_expired", + Self::IdleTimeout => "idle_timeout", + Self::BrowserDisconnect => "browser_disconnect", + Self::PolicyChanged => "policy_changed", + Self::KeyRotated => "key_rotated", + Self::ServerRestart => "server_restart", + Self::AdminRevoke => "admin_revoke", + } + } +} + +// ── Record policy (parsed from DB) ────────────────────────────────────── + +/// Policy governing how a vault record can be unlocked and cached. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RecordPolicy { + pub require_onlykey: bool, + pub unlock_ttl_seconds: u64, + pub idle_timeout_seconds: u64, + pub cache_scope: CacheScope, + pub allow_manual_revoke: bool, + pub relock_on_browser_disconnect: bool, + pub relock_on_policy_change: bool, + pub require_fresh_unlock_for_high_risk: bool, + pub allow_plaintext_return: bool, + pub allowed_agents: Vec, +} + +// ── Derivation context ────────────────────────────────────────────────── + +/// Context passed to ok.derive_shared_secret as AdditionalData. +/// Must be stable and deterministic for the same record. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct DerivationContext { + pub record_id: String, + pub purpose: String, + pub version: u32, + #[serde(skip_serializing_if = "Option::is_none")] + pub tenant_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub origin: Option, +} + +// ── API request/response types ────────────────────────────────────────── + +/// Agent's request to access a vault record. +#[derive(Debug, Deserialize)] +pub struct AccessRecordRequest { + pub agent_id: String, + #[serde(default)] + pub session_id: Option, + #[serde(default)] + pub high_risk: bool, + #[serde(default)] + pub purpose: Option, +} + +/// Response to an access request. +#[derive(Debug, Serialize)] +pub struct AccessRecordResponse { + pub status: String, // "ok", "pending_approval", "denied" + #[serde(skip_serializing_if = "Option::is_none")] + pub request_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub secret: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub expires_at: Option, +} + +/// Browser's approval payload after OnlyKey derivation. +#[derive(Debug, Deserialize)] +pub struct BrowserApprovePayload { + pub request_id: String, + pub derived_secret_b64: String, + pub browser_session_id: String, +} + +/// Response to browser approval. +#[derive(Debug, Serialize)] +pub struct BrowserApproveResponse { + pub status: String, // "ok", "error" + #[serde(skip_serializing_if = "Option::is_none")] + pub message: Option, +} + +/// Pending approval as seen by the browser. +#[derive(Debug, Serialize)] +pub struct PendingApprovalView { + pub request_id: String, + pub record_id: String, + pub record_name: String, + pub agent_id: String, + pub operation: String, + pub origin: String, + pub onecli_record_pubkey_jwk: serde_json::Value, + pub additional_data: serde_json::Value, + pub created_at: String, + pub expires_at: String, + pub nonce_b64: String, +} + +/// Audit event (for logging). +#[derive(Debug, Clone, Serialize)] +pub struct AuditEvent { + pub event: String, + pub record_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub agent_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub session_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub scope_type: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub scope_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub reason: Option, + pub timestamp: String, +} diff --git a/apps/web/src/app/(dashboard)/vault/_components/pending-request-card.tsx b/apps/web/src/app/(dashboard)/vault/_components/pending-request-card.tsx new file mode 100644 index 0000000..bf71ca6 --- /dev/null +++ b/apps/web/src/app/(dashboard)/vault/_components/pending-request-card.tsx @@ -0,0 +1,83 @@ +"use client"; + +import { Card, CardContent } from "@onecli/ui/card"; +import { Badge } from "@onecli/ui/badge"; +import { Button } from "@onecli/ui/button"; +import { ShieldCheck, Lock, Loader2, Clock, Bot } from "lucide-react"; +import type { PendingApprovalRequest } from "@/lib/vault/types"; + +interface PendingRequestCardProps { + request: PendingApprovalRequest; + onApprove: (request: PendingApprovalRequest) => void; + onLock: (recordId: string) => void; + approving: boolean; +} + +export const PendingRequestCard = ({ + request, + onApprove, + onLock, + approving, +}: PendingRequestCardProps) => { + const expiresAt = new Date(request.expires_at); + const now = new Date(); + const secondsRemaining = Math.max( + 0, + Math.floor((expiresAt.getTime() - now.getTime()) / 1000), + ); + + const operationLabel: Record = { + unlock_record_key: "Unlock Secret", + decrypt_age: "Decrypt Payload", + sign_blob: "Sign Data", + }; + + return ( + + +
+
+ {request.record_name} + + {operationLabel[request.operation] ?? request.operation} + +
+
+ + + {request.agent_id} + + + + {secondsRemaining > 0 + ? `${secondsRemaining}s remaining` + : "Expired"} + +
+
+
+ + +
+
+
+ ); +}; diff --git a/apps/web/src/app/(dashboard)/vault/_components/vault-content.tsx b/apps/web/src/app/(dashboard)/vault/_components/vault-content.tsx new file mode 100644 index 0000000..783a496 --- /dev/null +++ b/apps/web/src/app/(dashboard)/vault/_components/vault-content.tsx @@ -0,0 +1,232 @@ +"use client"; + +import { useEffect, useState, useCallback } from "react"; +import { Card, CardContent, CardHeader, CardTitle } from "@onecli/ui/card"; +import { Badge } from "@onecli/ui/badge"; +import { Button } from "@onecli/ui/button"; +import { Shield, ShieldCheck, Lock, Unlock, Loader2, Usb } from "lucide-react"; +import { toast } from "sonner"; +import type { PendingApprovalRequest } from "@/lib/vault/types"; +import { + getPendingApprovals, + submitApproval, + lockRecord, + revokeAllCache, +} from "@/lib/vault/api"; +import { + connectOnlyKey, + deriveSecretForRecord, + type OnlyKeyInstance, +} from "@/lib/vault/onlykey"; +import { PendingRequestCard } from "./pending-request-card"; + +export const VaultContent = () => { + const [pendingRequests, setPendingRequests] = useState< + PendingApprovalRequest[] + >([]); + const [onlyKey, setOnlyKey] = useState(null); + const [connecting, setConnecting] = useState(false); + const [polling, setPolling] = useState(false); + const [approvingId, setApprovingId] = useState(null); + + // TODO: Get real user ID from session + const userId = "current-user"; + const browserSessionId = `bsess_${Date.now()}`; + + // Connect to OnlyKey + const handleConnect = useCallback(async () => { + setConnecting(true); + try { + const ok = await connectOnlyKey(); + setOnlyKey(ok); + toast.success("OnlyKey connected"); + } catch (err) { + toast.error( + `Failed to connect OnlyKey: ${err instanceof Error ? err.message : "Unknown error"}`, + ); + } finally { + setConnecting(false); + } + }, []); + + // Poll for pending requests + useEffect(() => { + if (!onlyKey) return; + + setPolling(true); + let active = true; + + const poll = async () => { + while (active) { + try { + const requests = await getPendingApprovals(userId); + if (active) setPendingRequests(requests); + } catch { + // Retry silently + } + await new Promise((r) => setTimeout(r, 3000)); + } + }; + + poll(); + return () => { + active = false; + setPolling(false); + }; + }, [onlyKey, userId]); + + // Approve a pending request using OnlyKey + const handleApprove = useCallback( + async (request: PendingApprovalRequest) => { + if (!onlyKey) { + toast.error("OnlyKey not connected"); + return; + } + + setApprovingId(request.request_id); + try { + // Derive the shared secret via OnlyKey FIDO2 bridge + const { derivedSecretB64 } = await deriveSecretForRecord({ + ok: onlyKey, + onecliRecordPubkeyJwk: request.onecli_record_pubkey_jwk, + additionalData: request.additional_data, + pressRequired: true, + }); + + // Submit to gateway + await submitApproval({ + request_id: request.request_id, + derived_secret_b64: derivedSecretB64, + browser_session_id: browserSessionId, + }); + + toast.success(`Approved: ${request.record_name}`); + + // Remove from pending list + setPendingRequests((prev) => + prev.filter((r) => r.request_id !== request.request_id), + ); + } catch (err) { + toast.error( + `Approval failed: ${err instanceof Error ? err.message : "Unknown error"}`, + ); + } finally { + setApprovingId(null); + } + }, + [onlyKey, browserSessionId], + ); + + // Lock a record manually + const handleLock = useCallback(async (recordId: string) => { + try { + await lockRecord(recordId); + toast.success("Record locked"); + } catch (err) { + toast.error( + `Lock failed: ${err instanceof Error ? err.message : "Unknown error"}`, + ); + } + }, []); + + // Emergency: revoke all + const handleRevokeAll = useCallback(async () => { + try { + await revokeAllCache(); + toast.success("All cached keys revoked"); + } catch (err) { + toast.error( + `Revoke failed: ${err instanceof Error ? err.message : "Unknown error"}`, + ); + } + }, []); + + return ( +
+ {/* OnlyKey Connection Status */} + + + + OnlyKey Connection + + {onlyKey ? ( + + + Connected + + ) : ( + + + Disconnected + + )} + + + {!onlyKey ? ( +
+

+ Connect your OnlyKey to approve vault requests from agents. +

+ +
+ ) : ( +
+

+ {polling + ? "Listening for approval requests..." + : "OnlyKey ready."} +

+ +
+ )} +
+
+ + {/* Pending Approval Requests */} + {onlyKey && ( +
+

Pending Approvals

+ {pendingRequests.length === 0 ? ( + + + +

+ No pending approval requests. Agents will appear here when + they need access to vault-protected secrets. +

+
+
+ ) : ( + pendingRequests.map((request) => ( + + )) + )} +
+ )} +
+ ); +}; diff --git a/apps/web/src/app/(dashboard)/vault/page.tsx b/apps/web/src/app/(dashboard)/vault/page.tsx new file mode 100644 index 0000000..fa785b0 --- /dev/null +++ b/apps/web/src/app/(dashboard)/vault/page.tsx @@ -0,0 +1,22 @@ +import { Suspense } from "react"; +import type { Metadata } from "next"; +import { PageHeader } from "@dashboard/page-header"; +import { VaultContent } from "./_components/vault-content"; + +export const metadata: Metadata = { + title: "Vault", +}; + +export default function VaultPage() { + return ( +
+ + + + +
+ ); +} diff --git a/apps/web/src/lib/actions/vault.ts b/apps/web/src/lib/actions/vault.ts new file mode 100644 index 0000000..e96d83f --- /dev/null +++ b/apps/web/src/lib/actions/vault.ts @@ -0,0 +1,24 @@ +"use server"; + +import { resolveUserId } from "@/lib/actions/resolve-user"; +import { + listVaultRecords, + createVaultRecord as createVaultRecordService, + deleteVaultRecord as deleteVaultRecordService, + type CreateVaultRecordData, +} from "@/lib/services/vault-record-service"; + +export const getVaultRecords = async () => { + const userId = await resolveUserId(); + return listVaultRecords(userId); +}; + +export const createVaultRecord = async (data: CreateVaultRecordData) => { + const userId = await resolveUserId(); + return createVaultRecordService(userId, data); +}; + +export const deleteVaultRecord = async (recordId: string) => { + const userId = await resolveUserId(); + return deleteVaultRecordService(userId, recordId); +}; diff --git a/apps/web/src/lib/nav-items.ts b/apps/web/src/lib/nav-items.ts index 799d321..8c2b19f 100644 --- a/apps/web/src/lib/nav-items.ts +++ b/apps/web/src/lib/nav-items.ts @@ -1,9 +1,10 @@ -import { LayoutDashboard, Bot, KeyRound, Settings } from "lucide-react"; +import { LayoutDashboard, Bot, KeyRound, Shield, Settings } from "lucide-react"; import type { NavItem } from "@/app/(dashboard)/_components/nav-main"; export const navItems: NavItem[] = [ { title: "Overview", url: "/overview", icon: LayoutDashboard }, { title: "Agents", url: "/agents", icon: Bot }, { title: "Secrets", url: "/secrets", icon: KeyRound }, + { title: "Vault", url: "/vault", icon: Shield }, { title: "Settings", url: "/settings", icon: Settings }, ]; diff --git a/apps/web/src/lib/services/vault-record-service.ts b/apps/web/src/lib/services/vault-record-service.ts new file mode 100644 index 0000000..c4bb8fa --- /dev/null +++ b/apps/web/src/lib/services/vault-record-service.ts @@ -0,0 +1,128 @@ +import { db, Prisma } from "@onecli/db"; +import { ServiceError } from "@/lib/services/errors"; +import type { CreateVaultRecordInput } from "@/lib/vault/types"; + +/** + * List all vault records for a user (metadata only, no ciphertext). + */ +export const listVaultRecords = async (userId: string) => { + const records = await db.vaultRecord.findMany({ + where: { userId }, + select: { + id: true, + name: true, + recordType: true, + hostPattern: true, + pathPattern: true, + requireOnlykey: true, + unlockTtlSeconds: true, + idleTimeoutSeconds: true, + cacheScope: true, + recordVersion: true, + policyVersion: true, + keyVersion: true, + createdAt: true, + updatedAt: true, + }, + orderBy: { createdAt: "desc" }, + }); + + return records; +}; + +/** + * Create a new vault record. + * + * NOTE: The actual encryption happens on the browser+gateway side. + * The browser derives the shared secret via OnlyKey, then the gateway + * generates a random record key, encrypts the secret, wraps the record key, + * and stores everything. This server action just creates the DB row + * with the pre-encrypted data from the gateway. + */ +export interface CreateVaultRecordData { + name: string; + recordType: string; + hostPattern: string; + pathPattern?: string | null; + injectionConfig?: { headerName: string; valueFormat?: string } | null; + + // Pre-encrypted by the gateway after OnlyKey derivation + ciphertextB64: string; + nonceB64: string; + aadJson: string; + wrappedKeyB64: string; + wrappedKeyNonceB64: string; + onecliPubkeyJwk: string; + derivationContext: string; + + // Optional policy overrides + unlockTtlSeconds?: number; + idleTimeoutSeconds?: number; + cacheScope?: string; +} + +export const createVaultRecord = async ( + userId: string, + data: CreateVaultRecordData, +) => { + const name = data.name.trim(); + if (!name || name.length > 255) { + throw new ServiceError( + "BAD_REQUEST", + "Name must be between 1 and 255 characters", + ); + } + + if (!data.hostPattern.trim()) { + throw new ServiceError("BAD_REQUEST", "Host pattern is required"); + } + + const record = await db.vaultRecord.create({ + data: { + name, + recordType: data.recordType, + ciphertextB64: data.ciphertextB64, + nonceB64: data.nonceB64, + aadJson: data.aadJson, + wrappedKeyB64: data.wrappedKeyB64, + wrappedKeyNonceB64: data.wrappedKeyNonceB64, + onecliPubkeyJwk: data.onecliPubkeyJwk, + derivationContext: data.derivationContext, + hostPattern: data.hostPattern.trim(), + pathPattern: data.pathPattern?.trim() || null, + injectionConfig: data.injectionConfig + ? (data.injectionConfig as unknown as Prisma.InputJsonValue) + : Prisma.JsonNull, + unlockTtlSeconds: data.unlockTtlSeconds ?? 86400, + idleTimeoutSeconds: data.idleTimeoutSeconds ?? 3600, + cacheScope: data.cacheScope ?? "agent", + userId, + }, + select: { + id: true, + name: true, + recordType: true, + hostPattern: true, + createdAt: true, + }, + }); + + return record; +}; + +/** + * Delete a vault record. + */ +export const deleteVaultRecord = async ( + userId: string, + recordId: string, +) => { + const record = await db.vaultRecord.findFirst({ + where: { id: recordId, userId }, + select: { id: true }, + }); + + if (!record) throw new ServiceError("NOT_FOUND", "Vault record not found"); + + await db.vaultRecord.delete({ where: { id: recordId } }); +}; diff --git a/apps/web/src/lib/vault/api.ts b/apps/web/src/lib/vault/api.ts new file mode 100644 index 0000000..017ba7c --- /dev/null +++ b/apps/web/src/lib/vault/api.ts @@ -0,0 +1,173 @@ +/** + * Client-side API for the OnlyKey Vault browser bridge. + * + * These functions call the gateway's vault endpoints from the browser. + * The browser is the trusted intermediary between OnlyKey (hardware) + * and the gateway (server). + */ + +import type { + BrowserApprovePayload, + BrowserApproveResponse, + PendingApprovalRequest, + PendingRequestsResponse, +} from "./types"; + +// ── Configuration ─────────────────────────────────────────────────────── + +/** + * Get the gateway base URL. In development this is localhost:10255, + * in production it's the Cloudflare Tunnel URL. + */ +const getGatewayBaseUrl = (): string => { + if (typeof window !== "undefined") { + // Use the current origin if running on the same domain as the gateway + return ( + (window as unknown as Record).__GATEWAY_URL__ ?? + process.env.NEXT_PUBLIC_GATEWAY_URL ?? + "https://localhost:10255" + ); + } + return process.env.GATEWAY_URL ?? "https://localhost:10255"; +}; + +// ── Browser → Gateway API calls ───────────────────────────────────────── + +/** + * Poll for pending approval requests that need OnlyKey fulfillment. + */ +export const getPendingApprovals = async ( + userId: string, +): Promise => { + const baseUrl = getGatewayBaseUrl(); + const res = await fetch( + `${baseUrl}/v1/vault/browser/pending?user_id=${encodeURIComponent(userId)}`, + { + credentials: "include", + headers: { Accept: "application/json" }, + }, + ); + + if (!res.ok) { + throw new Error(`Failed to fetch pending approvals: ${res.status}`); + } + + const data: PendingRequestsResponse = await res.json(); + return data.items; +}; + +/** + * Submit an OnlyKey-derived approval to the gateway. + * + * After the browser derives the shared secret via OnlyKey, it sends + * the result here. The gateway uses it to unwrap the record key. + */ +export const submitApproval = async ( + payload: BrowserApprovePayload, +): Promise => { + const baseUrl = getGatewayBaseUrl(); + const res = await fetch(`${baseUrl}/v1/vault/browser/approve`, { + method: "POST", + credentials: "include", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + }, + body: JSON.stringify(payload), + }); + + if (!res.ok) { + const body = await res.json().catch(() => ({})); + throw new Error( + (body as { message?: string }).message ?? + `Approval failed: ${res.status}`, + ); + } + + return res.json(); +}; + +/** + * Manually lock a specific vault record. + */ +export const lockRecord = async (recordId: string): Promise => { + const baseUrl = getGatewayBaseUrl(); + const res = await fetch( + `${baseUrl}/v1/vault/records/${encodeURIComponent(recordId)}/lock`, + { + method: "POST", + credentials: "include", + }, + ); + + if (!res.ok) { + throw new Error(`Failed to lock record: ${res.status}`); + } +}; + +/** + * Lock all vault records for a specific agent. + */ +export const lockAgentRecords = async (agentId: string): Promise => { + const baseUrl = getGatewayBaseUrl(); + const res = await fetch( + `${baseUrl}/v1/vault/agents/${encodeURIComponent(agentId)}/lock`, + { + method: "POST", + credentials: "include", + }, + ); + + if (!res.ok) { + throw new Error(`Failed to lock agent records: ${res.status}`); + } +}; + +/** + * Revoke all cached vault keys (admin emergency action). + */ +export const revokeAllCache = async (): Promise => { + const baseUrl = getGatewayBaseUrl(); + const res = await fetch(`${baseUrl}/v1/vault/cache/revoke-all`, { + method: "POST", + credentials: "include", + }); + + if (!res.ok) { + throw new Error(`Failed to revoke cache: ${res.status}`); + } +}; + +// ── Polling helper ────────────────────────────────────────────────────── + +/** + * Start polling for pending approval requests. + * Returns a cleanup function to stop polling. + */ +export const startPolling = ( + userId: string, + onRequests: (requests: PendingApprovalRequest[]) => void, + intervalMs: number = 3000, +): (() => void) => { + let active = true; + + const poll = async () => { + while (active) { + try { + const requests = await getPendingApprovals(userId); + if (active) { + onRequests(requests); + } + } catch { + // Silently retry on error + } + await new Promise((resolve) => setTimeout(resolve, intervalMs)); + } + }; + + poll(); + + return () => { + active = false; + }; +}; diff --git a/apps/web/src/lib/vault/index.ts b/apps/web/src/lib/vault/index.ts new file mode 100644 index 0000000..e59b9d9 --- /dev/null +++ b/apps/web/src/lib/vault/index.ts @@ -0,0 +1,3 @@ +export * from "./types"; +export * from "./api"; +export * from "./onlykey"; diff --git a/apps/web/src/lib/vault/onlykey.ts b/apps/web/src/lib/vault/onlykey.ts new file mode 100644 index 0000000..d199278 --- /dev/null +++ b/apps/web/src/lib/vault/onlykey.ts @@ -0,0 +1,144 @@ +/** + * OnlyKey integration for the Vault browser bridge. + * + * Uses the real `node-onlykey` library to communicate with OnlyKey + * via the FIDO2/WebAuthn protocol. This runs in the browser at a + * trusted origin (apps.crp.to or custom enrolled origin). + * + * The key operation is `ok.derive_shared_secret()` which deterministically + * derives a shared secret from: + * - The web origin (RPID) + * - The input public key (OneCLI per-record pubkey) + * - AdditionalData (derivation context: record_id, purpose, version) + * + * Given the same inputs and origin, the same secret is reproduced. + * This is what allows OneCLI to store NO private keys. + * + * @see https://github.com/trustcrypto/node-onlykey + */ + +import type { DerivationContext } from "./types"; + +// ── OnlyKey instance type ─────────────────────────────────────────────── + +/** + * Minimal interface for the node-onlykey library's OnlyKey class. + * The actual library exports more methods, but we only need these. + */ +export interface OnlyKeyInstance { + derive_shared_secret: ( + additionalData: string, + inputPubkeyJwk: JsonWebKey, + keyType: string, + pressRequired: boolean, + ) => Promise; // returns base64-encoded shared secret + + // Connection lifecycle + connect: () => Promise; + disconnect: () => Promise; + isConnected: () => boolean; +} + +// ── Derive shared secret for a vault record ───────────────────────────── + +export interface DeriveSecretArgs { + /** The OnlyKey instance (from node-onlykey library) */ + ok: OnlyKeyInstance; + /** Per-record OneCLI public key in JWK format */ + onecliRecordPubkeyJwk: JsonWebKey; + /** Derivation context — serialized as AdditionalData */ + additionalData: DerivationContext; + /** Key type for the derivation (default: "P-256") */ + keyType?: string; + /** Whether physical press on OnlyKey is required (default: true) */ + pressRequired?: boolean; +} + +export interface DeriveSecretResult { + /** Base64-encoded derived shared secret */ + derivedSecretB64: string; +} + +/** + * Derive a shared secret from OnlyKey for a specific vault record. + * + * This calls `ok.derive_shared_secret()` with the record's public key + * and derivation context. The result is deterministic for the same + * inputs and origin. + * + * @throws If OnlyKey is not connected or derivation fails + */ +export const deriveSecretForRecord = async ( + args: DeriveSecretArgs, +): Promise => { + const { + ok, + onecliRecordPubkeyJwk, + additionalData, + keyType = "P-256", + pressRequired = true, + } = args; + + if (!ok.isConnected()) { + throw new Error( + "OnlyKey is not connected. Please connect your OnlyKey and try again.", + ); + } + + // Serialize the derivation context as the AdditionalData parameter. + // This binds the derived secret to the specific record, purpose, and version. + const additionalDataStr = JSON.stringify(additionalData); + + const derivedSecretB64 = await ok.derive_shared_secret( + additionalDataStr, + onecliRecordPubkeyJwk, + keyType, + pressRequired, + ); + + return { derivedSecretB64 }; +}; + +// ── Derive shared secret for record creation ──────────────────────────── + +/** + * During record creation, the browser derives the shared secret so the + * server can wrap the random record key. This is the same derivation + * but used in the "setup" direction rather than "unlock" direction. + */ +export const deriveSecretForRecordCreation = async ( + ok: OnlyKeyInstance, + onecliPubkeyJwk: JsonWebKey, + recordId: string, + version: number = 1, +): Promise => { + const additionalData: DerivationContext = { + record_id: recordId, + purpose: "record_key_wrap", + version, + }; + + return deriveSecretForRecord({ + ok, + onecliRecordPubkeyJwk: onecliPubkeyJwk, + additionalData, + pressRequired: true, + }); +}; + +// ── OnlyKey connection helper ─────────────────────────────────────────── + +/** + * Initialize and connect to OnlyKey via the FIDO2 bridge. + * + * In production, this imports the real `node-onlykey` library. + * The library uses WebAuthn/FIDO2 to tunnel data to the device. + */ +export const connectOnlyKey = async (): Promise => { + // Dynamic import of node-onlykey (loaded from the trusted origin) + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { OnlyKey } = await import("node-onlykey"); + const ok = new OnlyKey() as OnlyKeyInstance; + await ok.connect(); + return ok; +}; diff --git a/apps/web/src/lib/vault/types.ts b/apps/web/src/lib/vault/types.ts new file mode 100644 index 0000000..0e5e2d3 --- /dev/null +++ b/apps/web/src/lib/vault/types.ts @@ -0,0 +1,134 @@ +/** + * Shared types for the OnlyKey Vault system. + * + * These types mirror the Rust models and Prisma schema for the vault + * record protection layer using OnlyKey hardware-derived encryption. + */ + +// ── Enums ─────────────────────────────────────────────────────────────── + +export type RecordType = + | "api_key" + | "oauth_token" + | "age_secret" + | "generic_secret"; + +export type CacheScope = "global" | "agent" | "session"; + +export type ApprovalStatus = "pending" | "approved" | "denied" | "expired"; + +export type BrowserOperation = + | "unlock_record_key" + | "decrypt_age" + | "sign_blob"; + +export type RevocationReason = + | "manual_revoke" + | "ttl_expired" + | "idle_timeout" + | "browser_disconnect" + | "policy_changed" + | "key_rotated" + | "server_restart" + | "admin_revoke"; + +// ── Derivation context ────────────────────────────────────────────────── + +/** Context passed to ok.derive_shared_secret as AdditionalData. */ +export interface DerivationContext { + record_id: string; + purpose: string; + version: number; + tenant_id?: string; + origin?: string; +} + +// ── Record policy ─────────────────────────────────────────────────────── + +export interface RecordPolicy { + requireOnlykey: boolean; + unlockTtlSeconds: number; + idleTimeoutSeconds: number; + cacheScope: CacheScope; + allowManualRevoke: boolean; + relockOnBrowserDisconnect: boolean; + relockOnPolicyChange: boolean; + requireFreshUnlockForHighRisk: boolean; + allowPlaintextReturn: boolean; + allowedAgents: string[]; +} + +// ── Vault record (as seen by the web UI) ──────────────────────────────── + +export interface VaultRecord { + id: string; + name: string; + recordType: RecordType; + hostPattern: string; + pathPattern: string | null; + policy: RecordPolicy; + recordVersion: number; + policyVersion: number; + keyVersion: number; + unlockGeneration: number; + createdAt: string; + updatedAt: string; +} + +// ── Pending approval request ──────────────────────────────────────────── + +/** Pending approval as returned by the gateway's browser endpoint. */ +export interface PendingApprovalRequest { + request_id: string; + record_id: string; + record_name: string; + agent_id: string; + operation: BrowserOperation; + origin: string; + onecli_record_pubkey_jwk: JsonWebKey; + additional_data: DerivationContext; + created_at: string; + expires_at: string; + nonce_b64: string; +} + +// ── Browser approve payload ───────────────────────────────────────────── + +export interface BrowserApprovePayload { + request_id: string; + derived_secret_b64: string; + browser_session_id: string; +} + +// ── API responses ─────────────────────────────────────────────────────── + +export interface AccessRecordResponse { + status: "ok" | "pending_approval" | "denied"; + request_id?: string; + secret?: string; + expires_at?: string; +} + +export interface BrowserApproveResponse { + status: "ok" | "error"; + message?: string; +} + +export interface PendingRequestsResponse { + items: PendingApprovalRequest[]; +} + +// ── Vault record creation input ───────────────────────────────────────── + +export interface CreateVaultRecordInput { + name: string; + recordType: RecordType; + secretValue: string; + hostPattern: string; + pathPattern?: string; + injectionConfig?: { + headerName: string; + valueFormat?: string; + }; + policy?: Partial; +} diff --git a/packages/db/prisma/migrations/20260318200000_add_vault_records/migration.sql b/packages/db/prisma/migrations/20260318200000_add_vault_records/migration.sql new file mode 100644 index 0000000..0a9665e --- /dev/null +++ b/packages/db/prisma/migrations/20260318200000_add_vault_records/migration.sql @@ -0,0 +1,96 @@ +-- CreateTable +CREATE TABLE "VaultRecord" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "name" TEXT NOT NULL, + "recordType" TEXT NOT NULL, + "ciphertextB64" TEXT NOT NULL, + "nonceB64" TEXT NOT NULL, + "aadJson" TEXT NOT NULL, + "wrappedKeyB64" TEXT NOT NULL, + "wrappedKeyNonceB64" TEXT NOT NULL, + "wrapAlg" TEXT NOT NULL DEFAULT 'OK-DERIVED-HKDF-AESGCM-v1', + "onecliPubkeyJwk" TEXT NOT NULL, + "derivationContext" TEXT NOT NULL, + "requireOnlykey" BOOLEAN NOT NULL DEFAULT true, + "unlockTtlSeconds" INTEGER NOT NULL DEFAULT 86400, + "idleTimeoutSeconds" INTEGER NOT NULL DEFAULT 3600, + "cacheScope" TEXT NOT NULL DEFAULT 'agent', + "allowManualRevoke" BOOLEAN NOT NULL DEFAULT true, + "relockOnBrowserDisconnect" BOOLEAN NOT NULL DEFAULT true, + "relockOnPolicyChange" BOOLEAN NOT NULL DEFAULT true, + "requireFreshUnlockForHighRisk" BOOLEAN NOT NULL DEFAULT true, + "allowPlaintextReturn" BOOLEAN NOT NULL DEFAULT true, + "allowedAgents" TEXT, + "recordVersion" INTEGER NOT NULL DEFAULT 1, + "policyVersion" INTEGER NOT NULL DEFAULT 1, + "keyVersion" INTEGER NOT NULL DEFAULT 1, + "unlockGeneration" INTEGER NOT NULL DEFAULT 1, + "hostPattern" TEXT NOT NULL, + "pathPattern" TEXT, + "injectionConfig" JSONB, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "VaultRecord_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "VaultApprovalRequest" ( + "id" TEXT NOT NULL, + "recordId" TEXT NOT NULL, + "agentId" TEXT NOT NULL, + "sessionId" TEXT, + "operation" TEXT NOT NULL, + "status" TEXT NOT NULL DEFAULT 'pending', + "origin" TEXT NOT NULL, + "nonceB64" TEXT NOT NULL, + "browserSessionId" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "expiresAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "VaultApprovalRequest_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "VaultAuditEvent" ( + "id" TEXT NOT NULL, + "recordId" TEXT NOT NULL, + "event" TEXT NOT NULL, + "agentId" TEXT, + "sessionId" TEXT, + "scopeType" TEXT, + "scopeId" TEXT, + "reason" TEXT, + "metadata" JSONB, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "VaultAuditEvent_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "VaultRecord_userId_idx" ON "VaultRecord"("userId"); + +-- CreateIndex +CREATE INDEX "VaultApprovalRequest_recordId_idx" ON "VaultApprovalRequest"("recordId"); + +-- CreateIndex +CREATE INDEX "VaultApprovalRequest_status_idx" ON "VaultApprovalRequest"("status"); + +-- CreateIndex +CREATE INDEX "VaultAuditEvent_recordId_idx" ON "VaultAuditEvent"("recordId"); + +-- CreateIndex +CREATE INDEX "VaultAuditEvent_event_idx" ON "VaultAuditEvent"("event"); + +-- CreateIndex +CREATE INDEX "VaultAuditEvent_createdAt_idx" ON "VaultAuditEvent"("createdAt"); + +-- AddForeignKey +ALTER TABLE "VaultRecord" ADD CONSTRAINT "VaultRecord_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "VaultApprovalRequest" ADD CONSTRAINT "VaultApprovalRequest_recordId_fkey" FOREIGN KEY ("recordId") REFERENCES "VaultRecord"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "VaultAuditEvent" ADD CONSTRAINT "VaultAuditEvent_recordId_fkey" FOREIGN KEY ("recordId") REFERENCES "VaultRecord"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index fffaa44..54cdba5 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -21,6 +21,7 @@ model User { agents Agent[] secrets Secret[] + vaultRecords VaultRecord[] } model Agent { @@ -68,3 +69,106 @@ model AgentSecret { @@id([agentId, secretId]) } + +// ── OnlyKey Vault ────────────────────────────────────────────────────── + +/// A secret record protected by OnlyKey hardware-derived encryption. +/// The record key is wrapped using a shared secret derived via OnlyKey's +/// FIDO2 bridge (ok.derive_shared_secret). OneCLI stores NO private keys. +model VaultRecord { + id String @id @default(cuid()) + userId String + name String + recordType String // "api_key", "oauth_token", "age_secret", "generic_secret" + + // AES-256-GCM encrypted secret payload + ciphertextB64 String // base64-encoded ciphertext + nonceB64 String // base64-encoded 12-byte nonce + aadJson String // JSON string of AAD used during encryption + + // Wrapped record key (encrypted with OnlyKey-derived shared secret via HKDF) + wrappedKeyB64 String // base64-encoded wrapped record key ciphertext + wrappedKeyNonceB64 String // base64-encoded nonce for the key wrap + wrapAlg String @default("OK-DERIVED-HKDF-AESGCM-v1") + + // OneCLI per-record public key (JWK) — input to ok.derive_shared_secret + onecliPubkeyJwk String // JSON string of the JWK + + // Derivation context for deterministic re-derivation + derivationContext String // JSON string: { record_id, purpose, version, tenant_id? } + + // Policy + requireOnlykey Boolean @default(true) + unlockTtlSeconds Int @default(86400) // 24h + idleTimeoutSeconds Int @default(3600) // 1h + cacheScope String @default("agent") // "global", "agent", "session" + allowManualRevoke Boolean @default(true) + relockOnBrowserDisconnect Boolean @default(true) + relockOnPolicyChange Boolean @default(true) + requireFreshUnlockForHighRisk Boolean @default(true) + allowPlaintextReturn Boolean @default(true) + allowedAgents String? // JSON array of agent IDs, null = all + + // Versioning for cache invalidation + recordVersion Int @default(1) + policyVersion Int @default(1) + keyVersion Int @default(1) + unlockGeneration Int @default(1) + + // Host/path binding (reuse existing injection model) + hostPattern String // "api.anthropic.com" or "*.example.com" + pathPattern String? // "/v1/*", null = all paths + injectionConfig Json? // { headerName, valueFormat } + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + user User @relation(fields: [userId], references: [id]) + + approvalRequests VaultApprovalRequest[] + auditEvents VaultAuditEvent[] + + @@index([userId]) +} + +/// Pending approval request — created when an agent requests a vault secret +/// and the record is locked. The browser polls for these and fulfills them +/// via OnlyKey. +model VaultApprovalRequest { + id String @id @default(cuid()) + recordId String + agentId String + sessionId String? + operation String // "unlock_record_key", "decrypt_age", "sign_blob" + status String @default("pending") // "pending", "approved", "denied", "expired" + origin String // trusted origin that must fulfill this + nonceB64 String // one-time nonce to bind the request + browserSessionId String? + + createdAt DateTime @default(now()) + expiresAt DateTime // auto-expire unfulfilled requests + + record VaultRecord @relation(fields: [recordId], references: [id], onDelete: Cascade) + + @@index([recordId]) + @@index([status]) +} + +/// Immutable audit log for all vault operations. +model VaultAuditEvent { + id String @id @default(cuid()) + recordId String + event String // "unlock_requested", "unlock_approved", "key_cached", "key_used", "key_revoked", etc. + agentId String? + sessionId String? + scopeType String? // "global", "agent", "session" + scopeId String? + reason String? // "manual_revoke", "ttl_expired", "idle_timeout", "browser_disconnect", etc. + metadata Json? // additional context + createdAt DateTime @default(now()) + + record VaultRecord @relation(fields: [recordId], references: [id], onDelete: Cascade) + + @@index([recordId]) + @@index([event]) + @@index([createdAt]) +}