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:
34
Cargo.lock
generated
34
Cargo.lock
generated
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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.
|
||||
pub async fn cmd_auth_logout() -> Result<()> {
|
||||
let path = cache_path();
|
||||
|
||||
@@ -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<String> {
|
||||
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::<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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -296,6 +296,9 @@ pub async fn create_secret(ns: &str, name: &str, data: HashMap<String, String>)
|
||||
"metadata": {
|
||||
"name": name,
|
||||
"namespace": ns,
|
||||
"labels": {
|
||||
"sunbeam.dev/managed-by": "sunbeam"
|
||||
},
|
||||
},
|
||||
"type": "Opaque",
|
||||
"data": encoded,
|
||||
|
||||
@@ -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,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(())
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
@@ -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<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>> {
|
||||
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<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.
|
||||
/// List folders (items with type=folder).
|
||||
pub async fn list_folders(&self, page: Option<u32>) -> Result<DRFPage<DriveFolder>> {
|
||||
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<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> {
|
||||
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<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 -------------------------------------------------------------
|
||||
|
||||
/// Share a file with a user.
|
||||
|
||||
@@ -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")]
|
||||
|
||||
@@ -66,6 +66,12 @@ pub enum VaultCommand {
|
||||
#[arg(short, long)]
|
||||
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)]
|
||||
@@ -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<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(¤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<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(())
|
||||
}
|
||||
|
||||
@@ -15,6 +15,8 @@ use std::collections::HashMap;
|
||||
pub struct BaoClient {
|
||||
pub base_url: String,
|
||||
pub token: Option<String>,
|
||||
/// Optional bearer token for proxy auth_request (separate from vault token).
|
||||
pub bearer_token: Option<String>,
|
||||
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<SealStatusResponse> {
|
||||
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")?;
|
||||
|
||||
@@ -101,6 +101,21 @@ pub async fn seed_openbao() -> Result<Option<SeedResult>> {
|
||||
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,6 +129,30 @@ pub async fn seed_openbao() -> Result<Option<SeedResult>> {
|
||||
}
|
||||
} else {
|
||||
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 {
|
||||
unseal_key = key;
|
||||
}
|
||||
@@ -121,6 +160,36 @@ pub async fn seed_openbao() -> Result<Option<SeedResult>> {
|
||||
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
|
||||
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 {
|
||||
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<Option<SeedResult>> {
|
||||
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<Option<SeedResult>> {
|
||||
)
|
||||
.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<String, String>)] = &[
|
||||
|
||||
644
sunbeam-sdk/src/vault_keystore.rs
Normal file
644
sunbeam-sdk/src/vault_keystore.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user