diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000..9498e50 Binary files /dev/null and b/.DS_Store differ diff --git a/Cargo.lock b/Cargo.lock index 2fa0b49..32f01db 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -146,6 +146,18 @@ dependencies = [ "object", ] +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures", + "password-hash", +] + [[package]] name = "asn1-rs" version = "0.7.1" @@ -323,6 +335,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest", +] + [[package]] name = "block-buffer" version = "0.10.4" @@ -2269,6 +2290,17 @@ dependencies = [ "windows-link", ] +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "pbkdf2" version = "0.12.2" @@ -3484,6 +3516,8 @@ dependencies = [ name = "sunbeam-sdk" version = "1.0.1" dependencies = [ + "aes-gcm", + "argon2", "base64", "bytes", "chrono", diff --git a/sunbeam-sdk/Cargo.toml b/sunbeam-sdk/Cargo.toml index f69667d..a87e855 100644 --- a/sunbeam-sdk/Cargo.toml +++ b/sunbeam-sdk/Cargo.toml @@ -53,6 +53,8 @@ sha2 = "0.10" hmac = "0.12" base64 = "0.22" rand = "0.8" +aes-gcm = "0.10" +argon2 = "0.5" # Certificate generation rcgen = "0.14" diff --git a/sunbeam-sdk/src/auth/mod.rs b/sunbeam-sdk/src/auth/mod.rs index d341a12..fae1e5e 100644 --- a/sunbeam-sdk/src/auth/mod.rs +++ b/sunbeam-sdk/src/auth/mod.rs @@ -674,6 +674,18 @@ pub fn get_gitea_token() -> Result { }) } +/// Get cached OIDC id_token (JWT). +pub fn get_id_token() -> Result { + let tokens = read_cache().map_err(|_| { + SunbeamError::identity("Not logged in. Run `sunbeam auth login` first.") + })?; + tokens.id_token.ok_or_else(|| { + SunbeamError::identity( + "No id_token cached. Run `sunbeam auth sso` to get one.", + ) + }) +} + /// Remove cached auth tokens. pub async fn cmd_auth_logout() -> Result<()> { let path = cache_path(); diff --git a/sunbeam-sdk/src/client.rs b/sunbeam-sdk/src/client.rs index 5bc704a..c25a64a 100644 --- a/sunbeam-sdk/src/client.rs +++ b/sunbeam-sdk/src/client.rs @@ -335,6 +335,11 @@ impl SunbeamClient { crate::auth::get_gitea_token() } + /// Get cached OIDC id_token (JWT with claims including admin flag). + fn id_token(&self) -> Result { + crate::auth::get_id_token() + } + // -- Lazy async accessors (each feature-gated) --------------------------- // // Each accessor resolves the appropriate auth and constructs the client @@ -424,7 +429,7 @@ impl SunbeamClient { pub async fn people(&self) -> Result<&crate::lasuite::PeopleClient> { self.people.get_or_try_init(|| async { let token = self.sso_token().await?; - let url = format!("https://people.{}/api/v1.0", self.domain); + let url = format!("https://people.{}/external_api/v1.0", self.domain); Ok(crate::lasuite::PeopleClient::from_parts(url, AuthMethod::Bearer(token))) }).await } @@ -433,7 +438,7 @@ impl SunbeamClient { pub async fn docs(&self) -> Result<&crate::lasuite::DocsClient> { self.docs.get_or_try_init(|| async { let token = self.sso_token().await?; - let url = format!("https://docs.{}/api/v1.0", self.domain); + let url = format!("https://docs.{}/external_api/v1.0", self.domain); Ok(crate::lasuite::DocsClient::from_parts(url, AuthMethod::Bearer(token))) }).await } @@ -442,7 +447,7 @@ impl SunbeamClient { pub async fn meet(&self) -> Result<&crate::lasuite::MeetClient> { self.meet.get_or_try_init(|| async { let token = self.sso_token().await?; - let url = format!("https://meet.{}/api/v1.0", self.domain); + let url = format!("https://meet.{}/external_api/v1.0", self.domain); Ok(crate::lasuite::MeetClient::from_parts(url, AuthMethod::Bearer(token))) }).await } @@ -451,7 +456,7 @@ impl SunbeamClient { pub async fn drive(&self) -> Result<&crate::lasuite::DriveClient> { self.drive.get_or_try_init(|| async { let token = self.sso_token().await?; - let url = format!("https://drive.{}/api/v1.0", self.domain); + let url = format!("https://drive.{}/external_api/v1.0", self.domain); Ok(crate::lasuite::DriveClient::from_parts(url, AuthMethod::Bearer(token))) }).await } @@ -460,7 +465,7 @@ impl SunbeamClient { pub async fn messages(&self) -> Result<&crate::lasuite::MessagesClient> { self.messages.get_or_try_init(|| async { let token = self.sso_token().await?; - let url = format!("https://mail.{}/api/v1.0", self.domain); + let url = format!("https://mail.{}/external_api/v1.0", self.domain); Ok(crate::lasuite::MessagesClient::from_parts(url, AuthMethod::Bearer(token))) }).await } @@ -469,7 +474,7 @@ impl SunbeamClient { pub async fn calendars(&self) -> Result<&crate::lasuite::CalendarsClient> { self.calendars.get_or_try_init(|| async { let token = self.sso_token().await?; - let url = format!("https://calendar.{}/api/v1.0", self.domain); + let url = format!("https://calendar.{}/external_api/v1.0", self.domain); Ok(crate::lasuite::CalendarsClient::from_parts(url, AuthMethod::Bearer(token))) }).await } @@ -478,16 +483,65 @@ impl SunbeamClient { pub async fn find(&self) -> Result<&crate::lasuite::FindClient> { self.find.get_or_try_init(|| async { let token = self.sso_token().await?; - let url = format!("https://find.{}/api/v1.0", self.domain); + let url = format!("https://find.{}/external_api/v1.0", self.domain); Ok(crate::lasuite::FindClient::from_parts(url, AuthMethod::Bearer(token))) }).await } pub async fn bao(&self) -> Result<&crate::openbao::BaoClient> { self.bao.get_or_try_init(|| async { - let token = self.sso_token().await?; let url = format!("https://vault.{}", self.domain); - Ok(crate::openbao::BaoClient::with_token(&url, &token)) + let id_token = self.id_token()?; + let bearer = self.sso_token().await?; + + // Authenticate to OpenBao via JWT auth method using the OIDC id_token. + // Try admin role first (for users with admin: true), fall back to reader. + let http = reqwest::Client::new(); + let vault_token = { + let mut token = None; + for role in &["cli-admin", "cli-reader"] { + let resp = http + .post(format!("{url}/v1/auth/jwt/login")) + .bearer_auth(&bearer) + .json(&serde_json::json!({ "jwt": id_token, "role": role })) + .send() + .await; + match resp { + Ok(r) => { + let status = r.status(); + if status.is_success() { + if let Ok(body) = r.json::().await { + if let Some(t) = body["auth"]["client_token"].as_str() { + tracing::debug!("vault JWT login ok (role={role})"); + token = Some(t.to_string()); + break; + } + } + } else { + let body = r.text().await.unwrap_or_default(); + tracing::debug!("vault JWT login {status} (role={role}): {body}"); + } + } + Err(e) => { + tracing::debug!("vault JWT login request failed (role={role}): {e}"); + } + } + } + match token { + Some(t) => t, + None => { + tracing::debug!("vault JWT auth failed, falling back to local keystore"); + match crate::vault_keystore::load_keystore(&self.domain) { + Ok(ks) => ks.root_token, + Err(_) => return Err(SunbeamError::secrets( + "Vault auth failed: no valid JWT role and no local keystore. Run `sunbeam auth sso` and retry." + )), + } + } + } + }; + + Ok(crate::openbao::BaoClient::with_proxy_auth(&url, &vault_token, &bearer)) }).await } } diff --git a/sunbeam-sdk/src/kube/mod.rs b/sunbeam-sdk/src/kube/mod.rs index c0cdde6..c9f4405 100644 --- a/sunbeam-sdk/src/kube/mod.rs +++ b/sunbeam-sdk/src/kube/mod.rs @@ -296,6 +296,9 @@ pub async fn create_secret(ns: &str, name: &str, data: HashMap) "metadata": { "name": name, "namespace": ns, + "labels": { + "sunbeam.dev/managed-by": "sunbeam" + }, }, "type": "Opaque", "data": encoded, diff --git a/sunbeam-sdk/src/lasuite/cli.rs b/sunbeam-sdk/src/lasuite/cli.rs index 3c03005..fc2b1cc 100644 --- a/sunbeam-sdk/src/lasuite/cli.rs +++ b/sunbeam-sdk/src/lasuite/cli.rs @@ -550,6 +550,15 @@ pub enum DriveCommand { #[command(subcommand)] action: PermissionAction, }, + /// Upload a local file or directory to a Drive folder. + Upload { + /// Local path to upload (file or directory). + #[arg(short, long)] + path: String, + /// Target Drive folder ID. + #[arg(short = 't', long)] + folder_id: String, + }, } #[derive(Subcommand, Debug)] @@ -687,9 +696,132 @@ pub async fn dispatch_drive( ) } }, + DriveCommand::Upload { path, folder_id } => { + upload_recursive(drive, &path, &folder_id).await + } } } +/// Recursively upload a local file or directory to a Drive folder. +async fn upload_recursive( + drive: &super::DriveClient, + local_path: &str, + parent_id: &str, +) -> Result<()> { + let path = std::path::Path::new(local_path); + if !path.exists() { + return Err(crate::error::SunbeamError::Other(format!( + "Path does not exist: {local_path}" + ))); + } + + if path.is_file() { + upload_single_file(drive, path, parent_id).await + } else if path.is_dir() { + upload_directory(drive, path, parent_id).await + } else { + Err(crate::error::SunbeamError::Other(format!( + "Not a file or directory: {local_path}" + ))) + } +} + +async fn upload_directory( + drive: &super::DriveClient, + dir: &std::path::Path, + parent_id: &str, +) -> Result<()> { + let dir_name = dir + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("unnamed"); + + output::step(&format!("Creating folder: {dir_name}")); + + // Create the folder in Drive + let folder = drive + .create_child( + parent_id, + &serde_json::json!({ + "title": dir_name, + "type": "folder", + }), + ) + .await?; + + let folder_id = folder["id"] + .as_str() + .ok_or_else(|| crate::error::SunbeamError::Other("No folder ID in response".into()))?; + + // Process entries + let mut entries: Vec<_> = std::fs::read_dir(dir) + .map_err(|e| crate::error::SunbeamError::Other(format!("reading dir: {e}")))? + .filter_map(|e| e.ok()) + .collect(); + entries.sort_by_key(|e| e.file_name()); + + for entry in entries { + let entry_path = entry.path(); + if entry_path.is_dir() { + Box::pin(upload_directory(drive, &entry_path, folder_id)).await?; + } else if entry_path.is_file() { + upload_single_file(drive, &entry_path, folder_id).await?; + } + } + + Ok(()) +} + +async fn upload_single_file( + drive: &super::DriveClient, + file_path: &std::path::Path, + parent_id: &str, +) -> Result<()> { + let filename = file_path + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("unnamed"); + + // Skip hidden files + if filename.starts_with('.') { + return Ok(()); + } + + output::ok(&format!("Uploading: {filename}")); + + // Create the file item in Drive + let item = drive + .create_child( + parent_id, + &serde_json::json!({ + "title": filename, + "type": "file", + }), + ) + .await?; + + let item_id = item["id"] + .as_str() + .ok_or_else(|| crate::error::SunbeamError::Other("No item ID in response".into()))?; + + // Get the presigned upload URL (Drive returns it as "policy" on create) + let upload_url = item["policy"] + .as_str() + .ok_or_else(|| crate::error::SunbeamError::Other("No upload policy URL in response — is the item a file?".into()))?; + + // Read the file and upload to S3 + let data = std::fs::read(file_path) + .map_err(|e| crate::error::SunbeamError::Other(format!("reading file: {e}")))?; + drive + .upload_to_s3(upload_url, bytes::Bytes::from(data)) + .await?; + + // Notify Drive the upload is complete + drive.upload_ended(item_id).await?; + + Ok(()) +} + // ═══════════════════════════════════════════════════════════════════════════ // Mail (Messages) // ═══════════════════════════════════════════════════════════════════════════ diff --git a/sunbeam-sdk/src/lasuite/drive.rs b/sunbeam-sdk/src/lasuite/drive.rs index 073fbd4..d02c0a7 100644 --- a/sunbeam-sdk/src/lasuite/drive.rs +++ b/sunbeam-sdk/src/lasuite/drive.rs @@ -39,70 +39,146 @@ impl DriveClient { self } - // -- Files -------------------------------------------------------------- + // -- Items -------------------------------------------------------------- - /// List files with optional pagination. + /// List items with optional pagination and type filter. + pub async fn list_items( + &self, + page: Option, + item_type: Option<&str>, + ) -> Result> { + let mut path = String::from("items/?"); + if let Some(p) = page { + path.push_str(&format!("page={p}&")); + } + if let Some(t) = item_type { + path.push_str(&format!("type={t}&")); + } + self.transport + .json(Method::GET, &path, Option::<&()>::None, "drive list items") + .await + } + + /// List files (items with type=file). pub async fn list_files(&self, page: Option) -> Result> { - let path = match page { - Some(p) => format!("files/?page={p}"), - None => "files/".to_string(), - }; - self.transport - .json(Method::GET, &path, Option::<&()>::None, "drive list files") - .await + self.list_items(page, Some("file")).await } - /// Get a single file by ID. - pub async fn get_file(&self, id: &str) -> Result { - self.transport - .json( - Method::GET, - &format!("files/{id}/"), - Option::<&()>::None, - "drive get file", - ) - .await - } - - /// Upload a new file. - pub async fn upload_file(&self, body: &serde_json::Value) -> Result { - self.transport - .json(Method::POST, "files/", Some(body), "drive upload file") - .await - } - - /// Delete a file. - pub async fn delete_file(&self, id: &str) -> Result<()> { - self.transport - .send( - Method::DELETE, - &format!("files/{id}/"), - Option::<&()>::None, - "drive delete file", - ) - .await - } - - // -- Folders ------------------------------------------------------------ - - /// List folders with optional pagination. + /// List folders (items with type=folder). pub async fn list_folders(&self, page: Option) -> Result> { - let path = match page { - Some(p) => format!("folders/?page={p}"), - None => "folders/".to_string(), - }; + let mut path = String::from("items/?type=folder&"); + if let Some(p) = page { + path.push_str(&format!("page={p}&")); + } self.transport .json(Method::GET, &path, Option::<&()>::None, "drive list folders") .await } - /// Create a new folder. + /// Get a single item by ID. + pub async fn get_file(&self, id: &str) -> Result { + self.transport + .json( + Method::GET, + &format!("items/{id}/"), + Option::<&()>::None, + "drive get item", + ) + .await + } + + /// Create a new item (file or folder) at the root level. + pub async fn upload_file(&self, body: &serde_json::Value) -> Result { + self.transport + .json(Method::POST, "items/", Some(body), "drive create item") + .await + } + + /// Delete an item. + pub async fn delete_file(&self, id: &str) -> Result<()> { + self.transport + .send( + Method::DELETE, + &format!("items/{id}/"), + Option::<&()>::None, + "drive delete item", + ) + .await + } + + /// Create a new folder at the root level. pub async fn create_folder(&self, body: &serde_json::Value) -> Result { self.transport - .json(Method::POST, "folders/", Some(body), "drive create folder") + .json(Method::POST, "items/", Some(body), "drive create folder") .await } + // -- Items (children API) ------------------------------------------------ + + /// Create a child item under a parent folder. + /// Returns the created item including its upload_url for files. + pub async fn create_child( + &self, + parent_id: &str, + body: &serde_json::Value, + ) -> Result { + self.transport + .json( + Method::POST, + &format!("items/{parent_id}/children/"), + Some(body), + "drive create child", + ) + .await + } + + /// List children of an item (folder). + pub async fn list_children( + &self, + parent_id: &str, + page: Option, + ) -> Result> { + let path = match page { + Some(p) => format!("items/{parent_id}/children/?page={p}"), + None => format!("items/{parent_id}/children/"), + }; + self.transport + .json(Method::GET, &path, Option::<&()>::None, "drive list children") + .await + } + + /// Notify Drive that a file upload to S3 is complete. + pub async fn upload_ended(&self, item_id: &str) -> Result { + self.transport + .json( + Method::POST, + &format!("items/{item_id}/upload-ended/"), + Option::<&()>::None, + "drive upload ended", + ) + .await + } + + /// Upload file bytes directly to a presigned S3 URL. + pub async fn upload_to_s3(&self, presigned_url: &str, data: bytes::Bytes) -> Result<()> { + let resp = reqwest::Client::new() + .put(presigned_url) + .header("Content-Type", "application/octet-stream") + .body(data) + .send() + .await + .map_err(|e| crate::error::SunbeamError::network(format!("S3 upload: {e}")))?; + + if !resp.status().is_success() { + let status = resp.status(); + let body = resp.text().await.unwrap_or_default(); + return Err(crate::error::SunbeamError::network(format!( + "S3 upload: HTTP {status}: {body}" + ))); + } + Ok(()) + } + // -- Shares ------------------------------------------------------------- /// Share a file with a user. diff --git a/sunbeam-sdk/src/lib.rs b/sunbeam-sdk/src/lib.rs index e4c7bff..cd800e8 100644 --- a/sunbeam-sdk/src/lib.rs +++ b/sunbeam-sdk/src/lib.rs @@ -19,6 +19,7 @@ pub mod secrets; pub mod services; pub mod update; pub mod users; +pub mod vault_keystore; // Feature-gated service client modules #[cfg(feature = "identity")] diff --git a/sunbeam-sdk/src/openbao/cli.rs b/sunbeam-sdk/src/openbao/cli.rs index bda4fd1..0abf0d1 100644 --- a/sunbeam-sdk/src/openbao/cli.rs +++ b/sunbeam-sdk/src/openbao/cli.rs @@ -66,6 +66,12 @@ pub enum VaultCommand { #[arg(short, long)] data: Option, }, + /// Re-initialize the vault (destructive — wipes all secrets). + Reinit, + /// Show local keystore status. + Keys, + /// Export vault keys as plaintext (for machine migration). + ExportKeys, } #[derive(Subcommand, Debug)] @@ -230,12 +236,89 @@ pub async fn dispatch( client: &SunbeamClient, fmt: OutputFormat, ) -> Result<()> { + // -- Commands that don't need a BaoClient ------------------------------- + match cmd { + VaultCommand::Keys => { + let domain = crate::config::domain(); + let path = crate::vault_keystore::keystore_path(domain); + + if !crate::vault_keystore::keystore_exists(domain) { + output::warn(&format!("No local keystore found at {}", path.display())); + output::warn("Run `sunbeam seed` to create one, or `sunbeam vault reinit` to start fresh."); + return Ok(()); + } + + match crate::vault_keystore::verify_vault_keys(domain) { + Ok(ks) => { + output::ok(&format!("Domain: {}", ks.domain)); + output::ok(&format!("Created: {}", ks.created_at.format("%Y-%m-%d %H:%M:%S UTC"))); + output::ok(&format!("Updated: {}", ks.updated_at.format("%Y-%m-%d %H:%M:%S UTC"))); + output::ok(&format!("Shares: {}/{}", ks.key_threshold, ks.key_shares)); + output::ok(&format!( + "Token: {}...{}", + &ks.root_token[..8.min(ks.root_token.len())], + &ks.root_token[ks.root_token.len().saturating_sub(4)..] + )); + output::ok(&format!("Unseal keys: {}", ks.unseal_keys_b64.len())); + output::ok(&format!("Path: {}", path.display())); + } + Err(e) => { + output::warn(&format!("Keystore at {} is invalid: {e}", path.display())); + } + } + return Ok(()); + } + + VaultCommand::ExportKeys => { + let domain = crate::config::domain(); + output::warn("WARNING: This prints vault root token and unseal keys in PLAINTEXT."); + output::warn("Only use this for machine migration. Do not share or log this output."); + eprint!(" Type 'export' to confirm: "); + let mut answer = String::new(); + std::io::stdin() + .read_line(&mut answer) + .map_err(|e| crate::error::SunbeamError::Other(format!("stdin: {e}")))?; + if answer.trim() != "export" { + output::ok("Aborted."); + return Ok(()); + } + let json = crate::vault_keystore::export_plaintext(domain)?; + println!("{json}"); + return Ok(()); + } + + VaultCommand::Reinit => { + return dispatch_reinit().await; + } + + // All other commands need a BaoClient — fall through. + _ => {} + } + let bao = client.bao().await?; match cmd { // -- Status --------------------------------------------------------- VaultCommand::Status => { let status = bao.seal_status().await?; - output::render(&status, fmt) + output::render(&status, fmt)?; + // Show local keystore status + let domain = crate::config::domain(); + if crate::vault_keystore::keystore_exists(domain) { + match crate::vault_keystore::load_keystore(domain) { + Ok(ks) => { + output::ok(&format!( + "Local keystore: valid (updated {})", + ks.updated_at.format("%Y-%m-%d %H:%M:%S UTC") + )); + } + Err(e) => { + output::warn(&format!("Local keystore: corrupt ({e})")); + } + } + } else { + output::warn("Local keystore: not found"); + } + Ok(()) } // -- Init ----------------------------------------------------------- @@ -335,5 +418,194 @@ pub async fn dispatch( output::render(&resp, fmt) } } + + // Already handled above; unreachable. + VaultCommand::Keys | VaultCommand::ExportKeys | VaultCommand::Reinit => unreachable!(), } } + +// ═══════════════════════════════════════════════════════════════════════════ +// Reinit +// ═══════════════════════════════════════════════════════════════════════════ + +/// Run a kubectl command, returning Ok(()) on success. +async fn kubectl(args: &[&str]) -> Result<()> { + crate::kube::ensure_tunnel().await?; + let ctx = format!("--context={}", crate::kube::context()); + let status = tokio::process::Command::new("kubectl") + .arg(&ctx) + .args(args) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::inherit()) + .stderr(std::process::Stdio::inherit()) + .status() + .await + .map_err(|e| crate::error::SunbeamError::Other(format!("kubectl: {e}")))?; + if !status.success() { + return Err(crate::error::SunbeamError::Other(format!( + "kubectl {} exited with {}", + args.join(" "), + status.code().unwrap_or(-1) + ))); + } + Ok(()) +} + +/// Port-forward guard — cancels the background forwarder on drop. +struct PortForwardGuard { + _abort_handle: tokio::task::AbortHandle, + pub local_port: u16, +} + +impl Drop for PortForwardGuard { + fn drop(&mut self) { + self._abort_handle.abort(); + } +} + +/// Open a kube-rs port-forward to `pod_name` in `namespace` on `remote_port`. +async fn port_forward(namespace: &str, pod_name: &str, remote_port: u16) -> Result { + use k8s_openapi::api::core::v1::Pod; + use kube::api::{Api, ListParams}; + use tokio::net::TcpListener; + + let client = crate::kube::get_client().await?; + let pods: Api = Api::namespaced(client.clone(), namespace); + + let listener = TcpListener::bind("127.0.0.1:0") + .await + .map_err(|e| crate::error::SunbeamError::Other(format!("bind: {e}")))?; + let local_port = listener + .local_addr() + .map_err(|e| crate::error::SunbeamError::Other(format!("local_addr: {e}")))? + .port(); + + let pod_name = pod_name.to_string(); + let ns = namespace.to_string(); + let task = tokio::spawn(async move { + let mut current_pod = pod_name; + loop { + let (mut client_stream, _) = match listener.accept().await { + Ok(s) => s, + Err(_) => break, + }; + + let pf_result = pods.portforward(¤t_pod, &[remote_port]).await; + let mut pf = match pf_result { + Ok(pf) => pf, + Err(e) => { + tracing::warn!("Port-forward failed, re-resolving pod: {e}"); + if let Ok(new_client) = crate::kube::get_client().await { + let new_pods: Api = Api::namespaced(new_client.clone(), &ns); + let lp = ListParams::default(); + if let Ok(pod_list) = new_pods.list(&lp).await { + if let Some(name) = pod_list + .items + .iter() + .find(|p| { + p.metadata + .name + .as_deref() + .map(|n| n.starts_with(current_pod.split('-').next().unwrap_or(""))) + .unwrap_or(false) + }) + .and_then(|p| p.metadata.name.clone()) + { + current_pod = name; + } + } + } + continue; + } + }; + + let mut upstream = match pf.take_stream(remote_port) { + Some(s) => s, + None => continue, + }; + + tokio::spawn(async move { + let _ = tokio::io::copy_bidirectional(&mut client_stream, &mut upstream).await; + }); + } + }); + + let abort_handle = task.abort_handle(); + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + + Ok(PortForwardGuard { + _abort_handle: abort_handle, + local_port, + }) +} + +/// Destructive vault re-initialization workflow. +async fn dispatch_reinit() -> Result<()> { + output::warn("This will DESTROY all vault secrets. You must re-run `sunbeam seed` after."); + eprint!(" Type 'reinit' to confirm: "); + let mut answer = String::new(); + std::io::stdin() + .read_line(&mut answer) + .map_err(|e| crate::error::SunbeamError::Other(format!("stdin: {e}")))?; + if answer.trim() != "reinit" { + output::ok("Aborted."); + return Ok(()); + } + + output::step("Re-initializing vault..."); + + // Delete PVC and pod + output::ok("Deleting vault storage..."); + let _ = kubectl(&["-n", "data", "delete", "pvc", "data-openbao-0", "--ignore-not-found"]).await; + let _ = kubectl(&["-n", "data", "delete", "pod", "openbao-0", "--ignore-not-found"]).await; + + // Wait for pod to come back + output::ok("Waiting for vault pod to restart..."); + tokio::time::sleep(std::time::Duration::from_secs(15)).await; + let _ = kubectl(&[ + "-n", "data", "wait", "--for=condition=Ready", "pod/openbao-0", + "--timeout=120s", + ]) + .await; + + // Port-forward and init + let pf = port_forward("data", "openbao-0", 8200).await?; + let bao_url = format!("http://127.0.0.1:{}", pf.local_port); + let fresh_bao = crate::openbao::BaoClient::new(&bao_url); + + let init = fresh_bao.init(1, 1).await?; + let unseal_key = init.unseal_keys_b64[0].clone(); + let root_token = init.root_token.clone(); + + // Save to local keystore + let domain = crate::config::domain(); + let ks = crate::vault_keystore::VaultKeystore { + version: 1, + domain: domain.to_string(), + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + root_token: root_token.clone(), + unseal_keys_b64: vec![unseal_key.clone()], + key_shares: 1, + key_threshold: 1, + }; + crate::vault_keystore::save_keystore(&ks)?; + output::ok(&format!( + "Keys saved to local keystore at {}", + crate::vault_keystore::keystore_path(domain).display() + )); + + // Save to K8s Secret + let mut data = HashMap::new(); + data.insert("key".to_string(), unseal_key.clone()); + data.insert("root-token".to_string(), root_token.clone()); + crate::kube::create_secret("data", "openbao-keys", data).await?; + output::ok("Keys stored in K8s Secret openbao-keys."); + + // Unseal + fresh_bao.unseal(&unseal_key).await?; + output::ok("Vault unsealed."); + + output::step("Vault re-initialized. Run `sunbeam seed` now to restore all secrets."); + Ok(()) +} diff --git a/sunbeam-sdk/src/openbao/mod.rs b/sunbeam-sdk/src/openbao/mod.rs index 26973b6..5980035 100644 --- a/sunbeam-sdk/src/openbao/mod.rs +++ b/sunbeam-sdk/src/openbao/mod.rs @@ -15,6 +15,8 @@ use std::collections::HashMap; pub struct BaoClient { pub base_url: String, pub token: Option, + /// Optional bearer token for proxy auth_request (separate from vault token). + pub bearer_token: Option, http: reqwest::Client, } @@ -67,17 +69,26 @@ impl BaoClient { Self { base_url: base_url.trim_end_matches('/').to_string(), token: None, + bearer_token: None, http: reqwest::Client::new(), } } - /// Create a client with an authentication token. + /// Create a client with a vault authentication token. pub fn with_token(base_url: &str, token: &str) -> Self { let mut client = Self::new(base_url); client.token = Some(token.to_string()); client } + /// Create a client with both a vault token and a bearer token for proxy auth. + pub fn with_proxy_auth(base_url: &str, vault_token: &str, bearer_token: &str) -> Self { + let mut client = Self::new(base_url); + client.token = Some(vault_token.to_string()); + client.bearer_token = Some(bearer_token.to_string()); + client + } + fn url(&self, path: &str) -> String { format!("{}/v1/{}", self.base_url, path.trim_start_matches('/')) } @@ -87,6 +98,9 @@ impl BaoClient { if let Some(ref token) = self.token { req = req.header("X-Vault-Token", token); } + if let Some(ref bearer) = self.bearer_token { + req = req.header("Authorization", format!("Bearer {bearer}")); + } req } @@ -95,8 +109,7 @@ impl BaoClient { /// Get the seal status of the OpenBao instance. pub async fn seal_status(&self) -> Result { let resp = self - .http - .get(format!("{}/v1/sys/seal-status", self.base_url)) + .request(reqwest::Method::GET, "sys/seal-status") .send() .await .ctx("Failed to connect to OpenBao")?; diff --git a/sunbeam-sdk/src/secrets/seeding.rs b/sunbeam-sdk/src/secrets/seeding.rs index 3d21c9c..d6802fd 100644 --- a/sunbeam-sdk/src/secrets/seeding.rs +++ b/sunbeam-sdk/src/secrets/seeding.rs @@ -101,6 +101,21 @@ pub async fn seed_openbao() -> Result> { data.insert("root-token".to_string(), root_token.clone()); k::create_secret("data", "openbao-keys", data).await?; ok("Initialized -- keys stored in secret/openbao-keys."); + + // Save to local keystore + let domain = crate::config::domain(); + let ks = crate::vault_keystore::VaultKeystore { + version: 1, + domain: domain.to_string(), + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + root_token: root_token.clone(), + unseal_keys_b64: vec![unseal_key.clone()], + key_shares: 1, + key_threshold: 1, + }; + crate::vault_keystore::save_keystore(&ks)?; + ok(&format!("Keys backed up to local keystore at {}", crate::vault_keystore::keystore_path(domain).display())); } Err(e) => { warn(&format!( @@ -114,11 +129,65 @@ pub async fn seed_openbao() -> Result> { } } else { ok("Already initialized."); - if let Ok(key) = k::kube_get_secret_field("data", "openbao-keys", "key").await { - unseal_key = key; - } - if let Ok(token) = k::kube_get_secret_field("data", "openbao-keys", "root-token").await { - root_token = token; + let domain = crate::config::domain(); + + // Try local keystore first (survives K8s Secret overwrites) + if crate::vault_keystore::keystore_exists(domain) { + match crate::vault_keystore::load_keystore(domain) { + Ok(ks) => { + unseal_key = ks.unseal_keys_b64.first().cloned().unwrap_or_default(); + root_token = ks.root_token.clone(); + ok("Loaded keys from local keystore."); + + // Restore K8s Secret if it was wiped + let k8s_token = k::kube_get_secret_field("data", "openbao-keys", "root-token").await.unwrap_or_default(); + if k8s_token.is_empty() && !root_token.is_empty() { + warn("K8s Secret openbao-keys is empty — restoring from local keystore."); + let mut data = HashMap::new(); + data.insert("key".to_string(), unseal_key.clone()); + data.insert("root-token".to_string(), root_token.clone()); + k::create_secret("data", "openbao-keys", data).await?; + ok("Restored openbao-keys from local keystore."); + } + } + Err(e) => { + warn(&format!("Failed to load local keystore: {e}")); + // Fall back to K8s Secret + if let Ok(key) = k::kube_get_secret_field("data", "openbao-keys", "key").await { + unseal_key = key; + } + if let Ok(token) = k::kube_get_secret_field("data", "openbao-keys", "root-token").await { + root_token = token; + } + } + } + } else { + // No local keystore — read from K8s Secret and backfill + if let Ok(key) = k::kube_get_secret_field("data", "openbao-keys", "key").await { + unseal_key = key; + } + if let Ok(token) = k::kube_get_secret_field("data", "openbao-keys", "root-token").await { + root_token = token; + } + + // Backfill local keystore if we got keys from the cluster + if !root_token.is_empty() && !unseal_key.is_empty() { + let ks = crate::vault_keystore::VaultKeystore { + version: 1, + domain: domain.to_string(), + created_at: chrono::Utc::now(), + updated_at: chrono::Utc::now(), + root_token: root_token.clone(), + unseal_keys_b64: vec![unseal_key.clone()], + key_shares: 1, + key_threshold: 1, + }; + if let Err(e) = crate::vault_keystore::save_keystore(&ks) { + warn(&format!("Failed to backfill local keystore: {e}")); + } else { + ok(&format!("Backfilled local keystore at {}", crate::vault_keystore::keystore_path(domain).display())); + } + } } } @@ -468,11 +537,33 @@ pub async fn seed_openbao() -> Result> { for (path, data) in all_paths { if dirty_paths.contains(*path) { - bao.kv_patch("secret", path, data).await?; + // Use kv_put for new paths (patch fails with 404 on nonexistent keys). + // Try patch first (preserves manually-set fields), fall back to put. + if bao.kv_patch("secret", path, data).await.is_err() { + bao.kv_put("secret", path, data).await?; + } } } } + // Seed resource server allowed audiences for La Suite external APIs. + // Combines the static sunbeam-cli client ID with dynamic service client IDs. + ok("Configuring La Suite resource server audiences..."); + { + let mut rs_audiences = HashMap::new(); + // sunbeam-cli is always static (OAuth2Client CRD name) + let mut audiences = vec!["sunbeam-cli".to_string()]; + // Read the messages client ID from the oidc-messages secret if available + if let Ok(client_id) = crate::kube::kube_get_secret_field("lasuite", "oidc-messages", "CLIENT_ID").await { + audiences.push(client_id); + } + rs_audiences.insert( + "OIDC_RS_ALLOWED_AUDIENCES".to_string(), + audiences.join(","), + ); + bao.kv_put("secret", "drive-rs-audiences", &rs_audiences).await?; + } + // Patch gitea admin credentials into secret/sol for Sol's Gitea integration. // Uses kv_patch to preserve manually-set keys (matrix-access-token etc.). { @@ -484,7 +575,9 @@ pub async fn seed_openbao() -> Result> { sol_gitea.insert("gitea-admin-password".to_string(), p.clone()); } if !sol_gitea.is_empty() { - bao.kv_patch("secret", "sol", &sol_gitea).await?; + if bao.kv_patch("secret", "sol", &sol_gitea).await.is_err() { + bao.kv_put("secret", "sol", &sol_gitea).await?; + } } } @@ -537,6 +630,63 @@ pub async fn seed_openbao() -> Result> { ) .await?; + // ── JWT auth for CLI (OIDC via Hydra) ───────────────────────────── + // Enables `sunbeam vault` commands to authenticate with SSO tokens + // instead of the root token. Users with `admin: true` in their + // Kratos metadata_admin get full vault access. + ok("Configuring JWT/OIDC auth for CLI..."); + let _ = bao.auth_enable("jwt", "jwt").await; + + let domain = crate::config::domain(); + bao.write( + "auth/jwt/config", + &serde_json::json!({ + "oidc_discovery_url": format!("https://auth.{domain}/"), + "default_role": "cli-reader" + }), + ) + .await?; + + // Admin role — full access for users with admin: true in JWT + let admin_policy_hcl = concat!( + "path \"*\" { capabilities = [\"create\", \"read\", \"update\", \"delete\", \"list\", \"sudo\"] }\n", + ); + bao.write_policy("cli-admin", admin_policy_hcl).await?; + + bao.write( + "auth/jwt/role/cli-admin", + &serde_json::json!({ + "role_type": "jwt", + "bound_audiences": ["sunbeam-cli"], + "user_claim": "sub", + "bound_claims": { "admin": true }, + "policies": ["cli-admin"], + "ttl": "1h" + }), + ) + .await?; + + // Reader role — read-only access for non-admin SSO users + let cli_reader_hcl = concat!( + "path \"secret/data/*\" { capabilities = [\"read\"] }\n", + "path \"secret/metadata/*\" { capabilities = [\"read\", \"list\"] }\n", + "path \"sys/health\" { capabilities = [\"read\", \"sudo\"] }\n", + "path \"sys/seal-status\" { capabilities = [\"read\"] }\n", + ); + bao.write_policy("cli-reader", cli_reader_hcl).await?; + + bao.write( + "auth/jwt/role/cli-reader", + &serde_json::json!({ + "role_type": "jwt", + "bound_audiences": ["sunbeam-cli"], + "user_claim": "sub", + "policies": ["cli-reader"], + "ttl": "1h" + }), + ) + .await?; + // Build credentials map let mut creds = HashMap::new(); let field_map: &[(&str, &str, &HashMap)] = &[ diff --git a/sunbeam-sdk/src/vault_keystore.rs b/sunbeam-sdk/src/vault_keystore.rs new file mode 100644 index 0000000..5d6d04f --- /dev/null +++ b/sunbeam-sdk/src/vault_keystore.rs @@ -0,0 +1,644 @@ +//! Encrypted local keystore for OpenBao vault keys. +//! +//! Stores root tokens and unseal keys locally, encrypted with AES-256-GCM. +//! Key derivation uses Argon2id with a machine-specific salt. This ensures +//! vault keys survive K8s Secret overwrites and are never lost. + +use crate::error::{Result, SunbeamError}; +use aes_gcm::aead::{Aead, KeyInit, OsRng}; +use aes_gcm::{Aes256Gcm, Nonce}; +use chrono::{DateTime, Utc}; +use rand::RngCore; +use serde::{Deserialize, Serialize}; +use std::path::{Path, PathBuf}; + +/// AES-256-GCM nonce size. +const NONCE_LEN: usize = 12; +/// Argon2 salt size. +const SALT_LEN: usize = 16; +/// Machine salt size (stored in .machine-salt file). +const MACHINE_SALT_LEN: usize = 32; + +/// Vault keys stored in the encrypted keystore. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct VaultKeystore { + pub version: u32, + pub domain: String, + pub created_at: DateTime, + pub updated_at: DateTime, + pub root_token: String, + pub unseal_keys_b64: Vec, + pub key_shares: u32, + pub key_threshold: u32, +} + +/// Result of comparing local keystore with cluster state. +#[derive(Debug, Clone, PartialEq)] +pub enum SyncStatus { + /// Local and cluster keys match. + InSync, + /// Local keystore exists but cluster secret is missing/empty. + ClusterMissing, + /// Cluster secret exists but no local keystore. + LocalMissing, + /// Both exist but differ. + Mismatch, + /// Neither exists. + NoKeys, +} + +// --------------------------------------------------------------------------- +// Path helpers +// --------------------------------------------------------------------------- + +/// Base directory for vault keystore files. +fn base_dir(override_dir: Option<&Path>) -> PathBuf { + if let Some(d) = override_dir { + return d.to_path_buf(); + } + dirs::data_dir() + .unwrap_or_else(|| { + dirs::home_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join(".local/share") + }) + .join("sunbeam") + .join("vault") +} + +/// Path to the encrypted keystore file for a domain. +pub fn keystore_path(domain: &str) -> PathBuf { + keystore_path_in(domain, None) +} + +fn keystore_path_in(domain: &str, override_dir: Option<&Path>) -> PathBuf { + let dir = base_dir(override_dir); + let safe = domain.replace(['/', '\\', ':'], "_"); + let name = if safe.is_empty() { "default" } else { &safe }; + dir.join(format!("{name}.enc")) +} + +/// Whether a local keystore exists for this domain. +pub fn keystore_exists(domain: &str) -> bool { + keystore_path(domain).exists() +} + +fn keystore_exists_in(domain: &str, dir: Option<&Path>) -> bool { + keystore_path_in(domain, dir).exists() +} + +// --------------------------------------------------------------------------- +// Machine salt +// --------------------------------------------------------------------------- + +fn machine_salt_path(override_dir: Option<&Path>) -> PathBuf { + base_dir(override_dir).join(".machine-salt") +} + +fn load_or_create_machine_salt(override_dir: Option<&Path>) -> Result> { + let path = machine_salt_path(override_dir); + if path.exists() { + let data = std::fs::read(&path) + .map_err(|e| SunbeamError::Other(format!("reading machine salt: {e}")))?; + if data.len() == MACHINE_SALT_LEN { + return Ok(data); + } + // Wrong length — regenerate + } + + // Create parent directories + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent) + .map_err(|e| SunbeamError::Other(format!("creating vault dir: {e}")))?; + } + + // Generate new salt + let mut salt = vec![0u8; MACHINE_SALT_LEN]; + OsRng.fill_bytes(&mut salt); + std::fs::write(&path, &salt) + .map_err(|e| SunbeamError::Other(format!("writing machine salt: {e}")))?; + + // Set 0600 permissions + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let perms = std::fs::Permissions::from_mode(0o600); + std::fs::set_permissions(&path, perms) + .map_err(|e| SunbeamError::Other(format!("setting salt permissions: {e}")))?; + } + + Ok(salt) +} + +// --------------------------------------------------------------------------- +// Key derivation +// --------------------------------------------------------------------------- + +fn derive_key(domain: &str, argon2_salt: &[u8], override_dir: Option<&Path>) -> Result<[u8; 32]> { + let machine_salt = load_or_create_machine_salt(override_dir)?; + // Combine machine salt + domain for input + let mut input = machine_salt; + input.extend_from_slice(b"sunbeam-vault-keystore:"); + input.extend_from_slice(domain.as_bytes()); + + let mut key = [0u8; 32]; + argon2::Argon2::default() + .hash_password_into(&input, argon2_salt, &mut key) + .map_err(|e| SunbeamError::Other(format!("argon2 key derivation: {e}")))?; + Ok(key) +} + +// --------------------------------------------------------------------------- +// Encrypt / decrypt +// --------------------------------------------------------------------------- + +fn encrypt(plaintext: &[u8], domain: &str, override_dir: Option<&Path>) -> Result> { + // Generate random nonce and argon2 salt + let mut nonce_bytes = [0u8; NONCE_LEN]; + let mut argon2_salt = [0u8; SALT_LEN]; + OsRng.fill_bytes(&mut nonce_bytes); + OsRng.fill_bytes(&mut argon2_salt); + + let key = derive_key(domain, &argon2_salt, override_dir)?; + let cipher = Aes256Gcm::new_from_slice(&key) + .map_err(|e| SunbeamError::Other(format!("AES init: {e}")))?; + let nonce = Nonce::from_slice(&nonce_bytes); + + let ciphertext = cipher + .encrypt(nonce, plaintext) + .map_err(|e| SunbeamError::Other(format!("AES encrypt: {e}")))?; + + // Output: [nonce (12)][argon2_salt (16)][ciphertext+tag] + let mut output = Vec::with_capacity(NONCE_LEN + SALT_LEN + ciphertext.len()); + output.extend_from_slice(&nonce_bytes); + output.extend_from_slice(&argon2_salt); + output.extend_from_slice(&ciphertext); + Ok(output) +} + +fn decrypt(data: &[u8], domain: &str, override_dir: Option<&Path>) -> Result> { + let header_len = NONCE_LEN + SALT_LEN; + if data.len() < header_len + 16 { + // 16 bytes minimum for AES-GCM tag + return Err(SunbeamError::Other( + "vault keystore file is too short or corrupt".into(), + )); + } + + let nonce_bytes = &data[..NONCE_LEN]; + let argon2_salt = &data[NONCE_LEN..header_len]; + let ciphertext = &data[header_len..]; + + let key = derive_key(domain, argon2_salt, override_dir)?; + let cipher = Aes256Gcm::new_from_slice(&key) + .map_err(|e| SunbeamError::Other(format!("AES init: {e}")))?; + let nonce = Nonce::from_slice(nonce_bytes); + + cipher + .decrypt(nonce, ciphertext) + .map_err(|_| SunbeamError::Other("vault keystore decryption failed — file is corrupt or was encrypted on a different machine".into())) +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/// Save a keystore, encrypted, to the local filesystem. +pub fn save_keystore(ks: &VaultKeystore) -> Result<()> { + save_keystore_in(ks, None) +} + +fn save_keystore_in(ks: &VaultKeystore, override_dir: Option<&Path>) -> Result<()> { + let path = keystore_path_in(&ks.domain, override_dir); + + // Create parent directories + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent) + .map_err(|e| SunbeamError::Other(format!("creating vault dir: {e}")))?; + } + + let plaintext = serde_json::to_vec_pretty(ks)?; + let encrypted = encrypt(&plaintext, &ks.domain, override_dir)?; + + std::fs::write(&path, &encrypted) + .map_err(|e| SunbeamError::Other(format!("writing keystore: {e}")))?; + + // Set 0600 permissions + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let perms = std::fs::Permissions::from_mode(0o600); + std::fs::set_permissions(&path, perms) + .map_err(|e| SunbeamError::Other(format!("setting keystore permissions: {e}")))?; + } + + Ok(()) +} + +/// Load and decrypt a keystore from the local filesystem. +pub fn load_keystore(domain: &str) -> Result { + load_keystore_in(domain, None) +} + +fn load_keystore_in(domain: &str, override_dir: Option<&Path>) -> Result { + let path = keystore_path_in(domain, override_dir); + if !path.exists() { + return Err(SunbeamError::Other(format!( + "no vault keystore found for domain '{domain}' at {}", + path.display() + ))); + } + + let data = std::fs::read(&path) + .map_err(|e| SunbeamError::Other(format!("reading keystore: {e}")))?; + + if data.is_empty() { + return Err(SunbeamError::Other("vault keystore file is empty".into())); + } + + let plaintext = decrypt(&data, domain, override_dir)?; + let ks: VaultKeystore = serde_json::from_slice(&plaintext) + .map_err(|e| SunbeamError::Other(format!("parsing keystore JSON: {e}")))?; + + if ks.version > 1 { + return Err(SunbeamError::Other(format!( + "vault keystore version {} is not supported (max: 1)", + ks.version + ))); + } + + Ok(ks) +} + +/// Load and validate a keystore — fails if any critical fields are empty. +pub fn verify_vault_keys(domain: &str) -> Result { + verify_vault_keys_in(domain, None) +} + +fn verify_vault_keys_in(domain: &str, override_dir: Option<&Path>) -> Result { + let ks = load_keystore_in(domain, override_dir)?; + + if ks.root_token.is_empty() { + return Err(SunbeamError::Other( + "vault keystore has empty root_token".into(), + )); + } + if ks.unseal_keys_b64.is_empty() { + return Err(SunbeamError::Other( + "vault keystore has no unseal keys".into(), + )); + } + if ks.key_shares == 0 { + return Err(SunbeamError::Other( + "vault keystore has key_shares=0".into(), + )); + } + if ks.key_threshold == 0 || ks.key_threshold > ks.key_shares { + return Err(SunbeamError::Other(format!( + "vault keystore has invalid threshold={}/shares={}", + ks.key_threshold, ks.key_shares + ))); + } + + Ok(ks) +} + +/// Export keystore as plaintext JSON (for machine migration). +pub fn export_plaintext(domain: &str) -> Result { + let ks = load_keystore(domain)?; + serde_json::to_string_pretty(&ks) + .map_err(|e| SunbeamError::Other(format!("serializing keystore: {e}"))) +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::TempDir; + + fn test_keystore(domain: &str) -> VaultKeystore { + VaultKeystore { + version: 1, + domain: domain.to_string(), + created_at: Utc::now(), + updated_at: Utc::now(), + root_token: "hvs.test-root-token-abc123".to_string(), + unseal_keys_b64: vec!["dGVzdC11bnNlYWwta2V5".to_string()], + key_shares: 1, + key_threshold: 1, + } + } + + // -- Encryption roundtrip ------------------------------------------------ + + #[test] + fn test_encrypt_decrypt_roundtrip() { + let dir = TempDir::new().unwrap(); + let ks = test_keystore("sunbeam.pt"); + save_keystore_in(&ks, Some(dir.path())).unwrap(); + let loaded = load_keystore_in("sunbeam.pt", Some(dir.path())).unwrap(); + assert_eq!(loaded.root_token, ks.root_token); + assert_eq!(loaded.unseal_keys_b64, ks.unseal_keys_b64); + assert_eq!(loaded.domain, ks.domain); + assert_eq!(loaded.key_shares, ks.key_shares); + assert_eq!(loaded.key_threshold, ks.key_threshold); + } + + #[test] + fn test_encrypt_decrypt_large_token() { + let dir = TempDir::new().unwrap(); + let mut ks = test_keystore("sunbeam.pt"); + ks.root_token = format!("hvs.{}", "a".repeat(200)); + save_keystore_in(&ks, Some(dir.path())).unwrap(); + let loaded = load_keystore_in("sunbeam.pt", Some(dir.path())).unwrap(); + assert_eq!(loaded.root_token, ks.root_token); + } + + #[test] + fn test_different_domains_different_ciphertext() { + let dir = TempDir::new().unwrap(); + let ks_a = test_keystore("a.example.com"); + let ks_b = VaultKeystore { + domain: "b.example.com".into(), + ..test_keystore("b.example.com") + }; + save_keystore_in(&ks_a, Some(dir.path())).unwrap(); + save_keystore_in(&ks_b, Some(dir.path())).unwrap(); + let file_a = std::fs::read(keystore_path_in("a.example.com", Some(dir.path()))).unwrap(); + let file_b = std::fs::read(keystore_path_in("b.example.com", Some(dir.path()))).unwrap(); + // Different ciphertext (random nonce + different key derivation) + assert_ne!(file_a, file_b); + } + + #[test] + fn test_domain_binding() { + let dir = TempDir::new().unwrap(); + let ks = test_keystore("sunbeam.pt"); + save_keystore_in(&ks, Some(dir.path())).unwrap(); + // Try to load with wrong domain — should fail decryption + let path_a = keystore_path_in("sunbeam.pt", Some(dir.path())); + let path_b = keystore_path_in("evil.com", Some(dir.path())); + std::fs::copy(&path_a, &path_b).unwrap(); + let result = load_keystore_in("evil.com", Some(dir.path())); + assert!(result.is_err()); + } + + // -- Machine salt -------------------------------------------------------- + + #[test] + fn test_machine_salt_created_on_first_use() { + let dir = TempDir::new().unwrap(); + let salt_path = machine_salt_path(Some(dir.path())); + assert!(!salt_path.exists()); + let salt = load_or_create_machine_salt(Some(dir.path())).unwrap(); + assert!(salt_path.exists()); + assert_eq!(salt.len(), MACHINE_SALT_LEN); + } + + #[test] + fn test_machine_salt_reused_on_subsequent_calls() { + let dir = TempDir::new().unwrap(); + let salt1 = load_or_create_machine_salt(Some(dir.path())).unwrap(); + let salt2 = load_or_create_machine_salt(Some(dir.path())).unwrap(); + assert_eq!(salt1, salt2); + } + + #[cfg(unix)] + #[test] + fn test_machine_salt_permissions() { + use std::os::unix::fs::PermissionsExt; + let dir = TempDir::new().unwrap(); + load_or_create_machine_salt(Some(dir.path())).unwrap(); + let path = machine_salt_path(Some(dir.path())); + let perms = std::fs::metadata(&path).unwrap().permissions(); + assert_eq!(perms.mode() & 0o777, 0o600); + } + + #[test] + fn test_machine_salt_32_bytes() { + let dir = TempDir::new().unwrap(); + let salt = load_or_create_machine_salt(Some(dir.path())).unwrap(); + assert_eq!(salt.len(), 32); + } + + // -- File integrity ------------------------------------------------------ + + #[test] + fn test_corrupt_nonce() { + let dir = TempDir::new().unwrap(); + let ks = test_keystore("sunbeam.pt"); + save_keystore_in(&ks, Some(dir.path())).unwrap(); + let path = keystore_path_in("sunbeam.pt", Some(dir.path())); + let mut data = std::fs::read(&path).unwrap(); + data[0] ^= 0xFF; // flip bits in nonce + std::fs::write(&path, &data).unwrap(); + assert!(load_keystore_in("sunbeam.pt", Some(dir.path())).is_err()); + } + + #[test] + fn test_corrupt_ciphertext() { + let dir = TempDir::new().unwrap(); + let ks = test_keystore("sunbeam.pt"); + save_keystore_in(&ks, Some(dir.path())).unwrap(); + let path = keystore_path_in("sunbeam.pt", Some(dir.path())); + let mut data = std::fs::read(&path).unwrap(); + let last = data.len() - 1; + data[last] ^= 0xFF; // flip bits in ciphertext + std::fs::write(&path, &data).unwrap(); + assert!(load_keystore_in("sunbeam.pt", Some(dir.path())).is_err()); + } + + #[test] + fn test_truncated_file() { + let dir = TempDir::new().unwrap(); + let ks = test_keystore("sunbeam.pt"); + save_keystore_in(&ks, Some(dir.path())).unwrap(); + let path = keystore_path_in("sunbeam.pt", Some(dir.path())); + std::fs::write(&path, &[0u8; 10]).unwrap(); // too short + let result = load_keystore_in("sunbeam.pt", Some(dir.path())); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("too short")); + } + + #[test] + fn test_empty_file() { + let dir = TempDir::new().unwrap(); + let path = keystore_path_in("sunbeam.pt", Some(dir.path())); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(&path, &[]).unwrap(); + let result = load_keystore_in("sunbeam.pt", Some(dir.path())); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("empty")); + } + + #[test] + fn test_wrong_version() { + let dir = TempDir::new().unwrap(); + let mut ks = test_keystore("sunbeam.pt"); + ks.version = 99; + save_keystore_in(&ks, Some(dir.path())).unwrap(); + let result = load_keystore_in("sunbeam.pt", Some(dir.path())); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("not supported")); + } + + // -- Concurrency / edge cases ------------------------------------------- + + #[test] + fn test_save_overwrites_existing() { + let dir = TempDir::new().unwrap(); + let ks1 = test_keystore("sunbeam.pt"); + save_keystore_in(&ks1, Some(dir.path())).unwrap(); + let mut ks2 = test_keystore("sunbeam.pt"); + ks2.root_token = "hvs.new-token".into(); + save_keystore_in(&ks2, Some(dir.path())).unwrap(); + let loaded = load_keystore_in("sunbeam.pt", Some(dir.path())).unwrap(); + assert_eq!(loaded.root_token, "hvs.new-token"); + } + + #[test] + fn test_load_nonexistent_domain() { + let dir = TempDir::new().unwrap(); + let result = load_keystore_in("nonexistent.example.com", Some(dir.path())); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("no vault keystore")); + } + + #[test] + fn test_keystore_exists_true() { + let dir = TempDir::new().unwrap(); + let ks = test_keystore("sunbeam.pt"); + save_keystore_in(&ks, Some(dir.path())).unwrap(); + assert!(keystore_exists_in("sunbeam.pt", Some(dir.path()))); + } + + #[test] + fn test_keystore_exists_false() { + let dir = TempDir::new().unwrap(); + assert!(!keystore_exists_in("sunbeam.pt", Some(dir.path()))); + } + + #[test] + fn test_save_creates_parent_directories() { + let dir = TempDir::new().unwrap(); + let nested = dir.path().join("deeply").join("nested").join("vault"); + let ks = test_keystore("sunbeam.pt"); + save_keystore_in(&ks, Some(&nested)).unwrap(); + assert!(keystore_path_in("sunbeam.pt", Some(&nested)).exists()); + } + + // -- Field validation --------------------------------------------------- + + #[test] + fn test_verify_rejects_empty_root_token() { + let dir = TempDir::new().unwrap(); + let mut ks = test_keystore("sunbeam.pt"); + ks.root_token = String::new(); + save_keystore_in(&ks, Some(dir.path())).unwrap(); + let result = verify_vault_keys_in("sunbeam.pt", Some(dir.path())); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("empty root_token")); + } + + #[test] + fn test_verify_rejects_empty_unseal_keys() { + let dir = TempDir::new().unwrap(); + let mut ks = test_keystore("sunbeam.pt"); + ks.unseal_keys_b64 = vec![]; + save_keystore_in(&ks, Some(dir.path())).unwrap(); + let result = verify_vault_keys_in("sunbeam.pt", Some(dir.path())); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("no unseal keys")); + } + + #[test] + fn test_verify_rejects_zero_shares() { + let dir = TempDir::new().unwrap(); + let mut ks = test_keystore("sunbeam.pt"); + ks.key_shares = 0; + save_keystore_in(&ks, Some(dir.path())).unwrap(); + let result = verify_vault_keys_in("sunbeam.pt", Some(dir.path())); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("key_shares=0")); + } + + #[test] + fn test_verify_rejects_invalid_threshold() { + let dir = TempDir::new().unwrap(); + let mut ks = test_keystore("sunbeam.pt"); + ks.key_shares = 3; + ks.key_threshold = 5; // threshold > shares + save_keystore_in(&ks, Some(dir.path())).unwrap(); + let result = verify_vault_keys_in("sunbeam.pt", Some(dir.path())); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("invalid threshold")); + } + + // -- Integration-style --------------------------------------------------- + + #[test] + fn test_full_lifecycle() { + let dir = TempDir::new().unwrap(); + // Create + let ks = test_keystore("sunbeam.pt"); + save_keystore_in(&ks, Some(dir.path())).unwrap(); + // Verify + let verified = verify_vault_keys_in("sunbeam.pt", Some(dir.path())).unwrap(); + assert_eq!(verified.root_token, ks.root_token); + // Modify + let mut ks2 = verified; + ks2.root_token = "hvs.rotated-token".into(); + ks2.updated_at = Utc::now(); + save_keystore_in(&ks2, Some(dir.path())).unwrap(); + // Reload + let reloaded = load_keystore_in("sunbeam.pt", Some(dir.path())).unwrap(); + assert_eq!(reloaded.root_token, "hvs.rotated-token"); + } + + #[test] + fn test_export_plaintext_format() { + let dir = TempDir::new().unwrap(); + let ks = test_keystore("sunbeam.pt"); + save_keystore_in(&ks, Some(dir.path())).unwrap(); + // Export by loading and serializing (mirrors the public function logic) + let loaded = load_keystore_in("sunbeam.pt", Some(dir.path())).unwrap(); + let json = serde_json::to_string_pretty(&loaded).unwrap(); + assert!(json.contains("hvs.test-root-token-abc123")); + assert!(json.contains("sunbeam.pt")); + assert!(json.contains("\"version\": 1")); + } + + #[test] + fn test_reinit_flow() { + let dir = TempDir::new().unwrap(); + // Initial save + let ks = test_keystore("sunbeam.pt"); + save_keystore_in(&ks, Some(dir.path())).unwrap(); + // Simulate: cluster keys are lost — local keystore still has them + let recovered = load_keystore_in("sunbeam.pt", Some(dir.path())).unwrap(); + assert_eq!(recovered.root_token, ks.root_token); + assert_eq!(recovered.unseal_keys_b64, ks.unseal_keys_b64); + // Simulate: reinit with new keys + let mut new_ks = test_keystore("sunbeam.pt"); + new_ks.root_token = "hvs.new-after-reinit".into(); + new_ks.unseal_keys_b64 = vec!["bmV3LXVuc2VhbC1rZXk=".into()]; + save_keystore_in(&new_ks, Some(dir.path())).unwrap(); + let final_ks = load_keystore_in("sunbeam.pt", Some(dir.path())).unwrap(); + assert_eq!(final_ks.root_token, "hvs.new-after-reinit"); + } + + #[cfg(unix)] + #[test] + fn test_keystore_file_permissions() { + use std::os::unix::fs::PermissionsExt; + let dir = TempDir::new().unwrap(); + let ks = test_keystore("sunbeam.pt"); + save_keystore_in(&ks, Some(dir.path())).unwrap(); + let path = keystore_path_in("sunbeam.pt", Some(dir.path())); + let perms = std::fs::metadata(&path).unwrap().permissions(); + assert_eq!(perms.mode() & 0o777, 0o600); + } +}