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",
|
"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",
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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(())
|
||||||
}
|
}
|
||||||
|
|
||||||
// ═══════════════════════════════════════════════════════════════════════════
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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")]
|
||||||
|
|||||||
@@ -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(¤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 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")?;
|
||||||
|
|||||||
@@ -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>)] = &[
|
||||||
|
|||||||
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