feat: encrypted vault keystore, JWT auth, Drive upload

Vault keystore (vault_keystore.rs):
- AES-256-GCM encrypted local storage for root tokens + unseal keys
- Argon2id KDF with machine-specific salt, 0600 permissions
- save/load/verify/export API with 26 unit tests
- Integrated into seed flow: save after init, load as fallback,
  backfill from cluster, restore K8s Secret if wiped

Vault CLI:
- vault reinit: wipe and re-initialize vault with confirmation
- vault keys: show local keystore status
- vault export-keys: plaintext export for machine migration
- vault status: now shows keystore status + uses JWT auth
- Fixed seal_status() bypassing request() (missing auth headers)

Vault OIDC auth:
- JWT auth method enabled on OpenBao via seed script
- cli-admin role: full access for users with admin:true JWT claim
- cli-reader role: read-only for non-admin SSO users
- BaoClient.with_proxy_auth(): sends both Bearer (proxy) and
  X-Vault-Token (vault) headers
- SunbeamClient.bao() authenticates via JWT login, falls back
  to local keystore root token

Drive:
- SDK client uses /items/ endpoint (was /files/ and /folders/)
- Added create_child, upload_ended, upload_to_s3 methods
- Added recursive drive upload command (--path, --folder-id)
- Switched all La Suite clients to /external_api/v1.0/

Infrastructure:
- Removed openbao-keys-placeholder.yaml from kustomization
- Added sunbeam.dev/managed-by label to programmatic secrets
- kv_patch→kv_put fallback for fresh vault initialization
- Hydra/Kratos secrets combined (new,old) for key rotation
This commit is contained in:
2026-03-24 12:09:01 +00:00
parent 13e3f5d42e
commit ca0748b109
13 changed files with 1462 additions and 69 deletions

BIN
.DS_Store vendored Normal file

Binary file not shown.

34
Cargo.lock generated
View File

@@ -146,6 +146,18 @@ dependencies = [
"object", "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]] [[package]]
name = "asn1-rs" name = "asn1-rs"
version = "0.7.1" version = "0.7.1"
@@ -323,6 +335,15 @@ dependencies = [
"serde_core", "serde_core",
] ]
[[package]]
name = "blake2"
version = "0.10.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
dependencies = [
"digest",
]
[[package]] [[package]]
name = "block-buffer" name = "block-buffer"
version = "0.10.4" version = "0.10.4"
@@ -2269,6 +2290,17 @@ dependencies = [
"windows-link", "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]] [[package]]
name = "pbkdf2" name = "pbkdf2"
version = "0.12.2" version = "0.12.2"
@@ -3484,6 +3516,8 @@ dependencies = [
name = "sunbeam-sdk" name = "sunbeam-sdk"
version = "1.0.1" version = "1.0.1"
dependencies = [ dependencies = [
"aes-gcm",
"argon2",
"base64", "base64",
"bytes", "bytes",
"chrono", "chrono",

View File

@@ -53,6 +53,8 @@ sha2 = "0.10"
hmac = "0.12" hmac = "0.12"
base64 = "0.22" base64 = "0.22"
rand = "0.8" rand = "0.8"
aes-gcm = "0.10"
argon2 = "0.5"
# Certificate generation # Certificate generation
rcgen = "0.14" rcgen = "0.14"

View File

@@ -674,6 +674,18 @@ pub fn get_gitea_token() -> Result<String> {
}) })
} }
/// Get cached OIDC id_token (JWT).
pub fn get_id_token() -> Result<String> {
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. /// Remove cached auth tokens.
pub async fn cmd_auth_logout() -> Result<()> { pub async fn cmd_auth_logout() -> Result<()> {
let path = cache_path(); let path = cache_path();

View File

@@ -335,6 +335,11 @@ impl SunbeamClient {
crate::auth::get_gitea_token() crate::auth::get_gitea_token()
} }
/// Get cached OIDC id_token (JWT with claims including admin flag).
fn id_token(&self) -> Result<String> {
crate::auth::get_id_token()
}
// -- Lazy async accessors (each feature-gated) --------------------------- // -- Lazy async accessors (each feature-gated) ---------------------------
// //
// Each accessor resolves the appropriate auth and constructs the client // 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> { pub async fn people(&self) -> Result<&crate::lasuite::PeopleClient> {
self.people.get_or_try_init(|| async { self.people.get_or_try_init(|| async {
let token = self.sso_token().await?; 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))) Ok(crate::lasuite::PeopleClient::from_parts(url, AuthMethod::Bearer(token)))
}).await }).await
} }
@@ -433,7 +438,7 @@ impl SunbeamClient {
pub async fn docs(&self) -> Result<&crate::lasuite::DocsClient> { pub async fn docs(&self) -> Result<&crate::lasuite::DocsClient> {
self.docs.get_or_try_init(|| async { self.docs.get_or_try_init(|| async {
let token = self.sso_token().await?; 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))) Ok(crate::lasuite::DocsClient::from_parts(url, AuthMethod::Bearer(token)))
}).await }).await
} }
@@ -442,7 +447,7 @@ impl SunbeamClient {
pub async fn meet(&self) -> Result<&crate::lasuite::MeetClient> { pub async fn meet(&self) -> Result<&crate::lasuite::MeetClient> {
self.meet.get_or_try_init(|| async { self.meet.get_or_try_init(|| async {
let token = self.sso_token().await?; 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))) Ok(crate::lasuite::MeetClient::from_parts(url, AuthMethod::Bearer(token)))
}).await }).await
} }
@@ -451,7 +456,7 @@ impl SunbeamClient {
pub async fn drive(&self) -> Result<&crate::lasuite::DriveClient> { pub async fn drive(&self) -> Result<&crate::lasuite::DriveClient> {
self.drive.get_or_try_init(|| async { self.drive.get_or_try_init(|| async {
let token = self.sso_token().await?; 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))) Ok(crate::lasuite::DriveClient::from_parts(url, AuthMethod::Bearer(token)))
}).await }).await
} }
@@ -460,7 +465,7 @@ impl SunbeamClient {
pub async fn messages(&self) -> Result<&crate::lasuite::MessagesClient> { pub async fn messages(&self) -> Result<&crate::lasuite::MessagesClient> {
self.messages.get_or_try_init(|| async { self.messages.get_or_try_init(|| async {
let token = self.sso_token().await?; 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))) Ok(crate::lasuite::MessagesClient::from_parts(url, AuthMethod::Bearer(token)))
}).await }).await
} }
@@ -469,7 +474,7 @@ impl SunbeamClient {
pub async fn calendars(&self) -> Result<&crate::lasuite::CalendarsClient> { pub async fn calendars(&self) -> Result<&crate::lasuite::CalendarsClient> {
self.calendars.get_or_try_init(|| async { self.calendars.get_or_try_init(|| async {
let token = self.sso_token().await?; 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))) Ok(crate::lasuite::CalendarsClient::from_parts(url, AuthMethod::Bearer(token)))
}).await }).await
} }
@@ -478,16 +483,65 @@ impl SunbeamClient {
pub async fn find(&self) -> Result<&crate::lasuite::FindClient> { pub async fn find(&self) -> Result<&crate::lasuite::FindClient> {
self.find.get_or_try_init(|| async { self.find.get_or_try_init(|| async {
let token = self.sso_token().await?; 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))) Ok(crate::lasuite::FindClient::from_parts(url, AuthMethod::Bearer(token)))
}).await }).await
} }
pub async fn bao(&self) -> Result<&crate::openbao::BaoClient> { pub async fn bao(&self) -> Result<&crate::openbao::BaoClient> {
self.bao.get_or_try_init(|| async { self.bao.get_or_try_init(|| async {
let token = self.sso_token().await?;
let url = format!("https://vault.{}", self.domain); 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::<serde_json::Value>().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 }).await
} }
} }

View File

@@ -296,6 +296,9 @@ pub async fn create_secret(ns: &str, name: &str, data: HashMap<String, String>)
"metadata": { "metadata": {
"name": name, "name": name,
"namespace": ns, "namespace": ns,
"labels": {
"sunbeam.dev/managed-by": "sunbeam"
},
}, },
"type": "Opaque", "type": "Opaque",
"data": encoded, "data": encoded,

View File

@@ -550,6 +550,15 @@ pub enum DriveCommand {
#[command(subcommand)] #[command(subcommand)]
action: PermissionAction, 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)] #[derive(Subcommand, Debug)]
@@ -687,7 +696,130 @@ 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(())
} }
// ═══════════════════════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════════════════════

View File

@@ -39,70 +39,146 @@ impl DriveClient {
self self
} }
// -- Files -------------------------------------------------------------- // -- Items --------------------------------------------------------------
/// List files with optional pagination. /// List items with optional pagination and type filter.
pub async fn list_items(
&self,
page: Option<u32>,
item_type: Option<&str>,
) -> Result<DRFPage<DriveFile>> {
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<u32>) -> Result<DRFPage<DriveFile>> { pub async fn list_files(&self, page: Option<u32>) -> Result<DRFPage<DriveFile>> {
let path = match page { self.list_items(page, Some("file")).await
Some(p) => format!("files/?page={p}"),
None => "files/".to_string(),
};
self.transport
.json(Method::GET, &path, Option::<&()>::None, "drive list files")
.await
} }
/// Get a single file by ID. /// List folders (items with type=folder).
pub async fn get_file(&self, id: &str) -> Result<DriveFile> {
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<DriveFile> {
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.
pub async fn list_folders(&self, page: Option<u32>) -> Result<DRFPage<DriveFolder>> { pub async fn list_folders(&self, page: Option<u32>) -> Result<DRFPage<DriveFolder>> {
let path = match page { let mut path = String::from("items/?type=folder&");
Some(p) => format!("folders/?page={p}"), if let Some(p) = page {
None => "folders/".to_string(), path.push_str(&format!("page={p}&"));
}; }
self.transport self.transport
.json(Method::GET, &path, Option::<&()>::None, "drive list folders") .json(Method::GET, &path, Option::<&()>::None, "drive list folders")
.await .await
} }
/// Create a new folder. /// Get a single item by ID.
pub async fn get_file(&self, id: &str) -> Result<DriveFile> {
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<DriveFile> {
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<DriveFolder> { pub async fn create_folder(&self, body: &serde_json::Value) -> Result<DriveFolder> {
self.transport self.transport
.json(Method::POST, "folders/", Some(body), "drive create folder") .json(Method::POST, "items/", Some(body), "drive create folder")
.await .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<serde_json::Value> {
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<u32>,
) -> Result<DRFPage<serde_json::Value>> {
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<serde_json::Value> {
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 ------------------------------------------------------------- // -- Shares -------------------------------------------------------------
/// Share a file with a user. /// Share a file with a user.

View File

@@ -19,6 +19,7 @@ pub mod secrets;
pub mod services; pub mod services;
pub mod update; pub mod update;
pub mod users; pub mod users;
pub mod vault_keystore;
// Feature-gated service client modules // Feature-gated service client modules
#[cfg(feature = "identity")] #[cfg(feature = "identity")]

View File

@@ -66,6 +66,12 @@ pub enum VaultCommand {
#[arg(short, long)] #[arg(short, long)]
data: Option<String>, data: Option<String>,
}, },
/// 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)] #[derive(Subcommand, Debug)]
@@ -230,12 +236,89 @@ pub async fn dispatch(
client: &SunbeamClient, client: &SunbeamClient,
fmt: OutputFormat, fmt: OutputFormat,
) -> Result<()> { ) -> 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?; let bao = client.bao().await?;
match cmd { match cmd {
// -- Status --------------------------------------------------------- // -- Status ---------------------------------------------------------
VaultCommand::Status => { VaultCommand::Status => {
let status = bao.seal_status().await?; 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 ----------------------------------------------------------- // -- Init -----------------------------------------------------------
@@ -335,5 +418,194 @@ pub async fn dispatch(
output::render(&resp, fmt) 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<PortForwardGuard> {
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<Pod> = 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(&current_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<Pod> = 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(())
}

View File

@@ -15,6 +15,8 @@ use std::collections::HashMap;
pub struct BaoClient { pub struct BaoClient {
pub base_url: String, pub base_url: String,
pub token: Option<String>, pub token: Option<String>,
/// Optional bearer token for proxy auth_request (separate from vault token).
pub bearer_token: Option<String>,
http: reqwest::Client, http: reqwest::Client,
} }
@@ -67,17 +69,26 @@ impl BaoClient {
Self { Self {
base_url: base_url.trim_end_matches('/').to_string(), base_url: base_url.trim_end_matches('/').to_string(),
token: None, token: None,
bearer_token: None,
http: reqwest::Client::new(), 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 { pub fn with_token(base_url: &str, token: &str) -> Self {
let mut client = Self::new(base_url); let mut client = Self::new(base_url);
client.token = Some(token.to_string()); client.token = Some(token.to_string());
client 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 { fn url(&self, path: &str) -> String {
format!("{}/v1/{}", self.base_url, path.trim_start_matches('/')) format!("{}/v1/{}", self.base_url, path.trim_start_matches('/'))
} }
@@ -87,6 +98,9 @@ impl BaoClient {
if let Some(ref token) = self.token { if let Some(ref token) = self.token {
req = req.header("X-Vault-Token", token); req = req.header("X-Vault-Token", token);
} }
if let Some(ref bearer) = self.bearer_token {
req = req.header("Authorization", format!("Bearer {bearer}"));
}
req req
} }
@@ -95,8 +109,7 @@ impl BaoClient {
/// Get the seal status of the OpenBao instance. /// Get the seal status of the OpenBao instance.
pub async fn seal_status(&self) -> Result<SealStatusResponse> { pub async fn seal_status(&self) -> Result<SealStatusResponse> {
let resp = self let resp = self
.http .request(reqwest::Method::GET, "sys/seal-status")
.get(format!("{}/v1/sys/seal-status", self.base_url))
.send() .send()
.await .await
.ctx("Failed to connect to OpenBao")?; .ctx("Failed to connect to OpenBao")?;

View File

@@ -101,6 +101,21 @@ pub async fn seed_openbao() -> Result<Option<SeedResult>> {
data.insert("root-token".to_string(), root_token.clone()); data.insert("root-token".to_string(), root_token.clone());
k::create_secret("data", "openbao-keys", data).await?; k::create_secret("data", "openbao-keys", data).await?;
ok("Initialized -- keys stored in secret/openbao-keys."); 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) => { Err(e) => {
warn(&format!( warn(&format!(
@@ -114,6 +129,30 @@ pub async fn seed_openbao() -> Result<Option<SeedResult>> {
} }
} else { } else {
ok("Already initialized."); ok("Already initialized.");
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 { if let Ok(key) = k::kube_get_secret_field("data", "openbao-keys", "key").await {
unseal_key = key; unseal_key = key;
} }
@@ -121,6 +160,36 @@ pub async fn seed_openbao() -> Result<Option<SeedResult>> {
root_token = token; 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()));
}
}
}
}
// Unseal if needed // Unseal if needed
let status = bao.seal_status().await.unwrap_or_else(|_| { let status = bao.seal_status().await.unwrap_or_else(|_| {
@@ -468,10 +537,32 @@ pub async fn seed_openbao() -> Result<Option<SeedResult>> {
for (path, data) in all_paths { for (path, data) in all_paths {
if dirty_paths.contains(*path) { 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. // Patch gitea admin credentials into secret/sol for Sol's Gitea integration.
// Uses kv_patch to preserve manually-set keys (matrix-access-token etc.). // Uses kv_patch to preserve manually-set keys (matrix-access-token etc.).
@@ -484,7 +575,9 @@ pub async fn seed_openbao() -> Result<Option<SeedResult>> {
sol_gitea.insert("gitea-admin-password".to_string(), p.clone()); sol_gitea.insert("gitea-admin-password".to_string(), p.clone());
} }
if !sol_gitea.is_empty() { 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<Option<SeedResult>> {
) )
.await?; .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 // Build credentials map
let mut creds = HashMap::new(); let mut creds = HashMap::new();
let field_map: &[(&str, &str, &HashMap<String, String>)] = &[ let field_map: &[(&str, &str, &HashMap<String, String>)] = &[

View File

@@ -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<Utc>,
pub updated_at: DateTime<Utc>,
pub root_token: String,
pub unseal_keys_b64: Vec<String>,
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<Vec<u8>> {
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<Vec<u8>> {
// 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<Vec<u8>> {
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<VaultKeystore> {
load_keystore_in(domain, None)
}
fn load_keystore_in(domain: &str, override_dir: Option<&Path>) -> Result<VaultKeystore> {
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<VaultKeystore> {
verify_vault_keys_in(domain, None)
}
fn verify_vault_keys_in(domain: &str, override_dir: Option<&Path>) -> Result<VaultKeystore> {
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<String> {
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);
}
}