Some checks failed
Secrets v3 CI / 检查 (push) Has been cancelled
- Add apps/api, desktop Tauri shell, domain/application/crypto/device-auth/infrastructure-db - Replace desktop-daemon vault integration; drop secrets-core and secrets-mcp* - Ignore apps/desktop/dist and generated Tauri icons; document icon/dist steps in AGENTS.md - Apply rustfmt; fix clippy (collapsible_if, HTTP method as str)
1428 lines
45 KiB
Rust
1428 lines
45 KiB
Rust
use std::{
|
|
path::PathBuf,
|
|
sync::{Arc, RwLock},
|
|
};
|
|
|
|
use anyhow::{Context, Result, anyhow};
|
|
use chrono::Utc;
|
|
use secrets_crypto::{KEY_CHECK_PLAINTEXT, decrypt, encrypt};
|
|
use secrets_domain::{
|
|
ApiKeyPayload, CipherType, CipherView, CustomField, ItemPayload, KdfConfig, LoginPayload,
|
|
SecureNotePayload, SshKeyPayload, VaultObjectChange, VaultObjectEnvelope, VaultObjectKind,
|
|
};
|
|
use serde::{Deserialize, Serialize};
|
|
use serde_json::Value;
|
|
use sha2::{Digest, Sha256};
|
|
use sqlx::{
|
|
Row, SqlitePool,
|
|
sqlite::{SqliteConnectOptions, SqlitePoolOptions},
|
|
};
|
|
use std::str::FromStr;
|
|
use uuid::Uuid;
|
|
|
|
#[derive(Clone)]
|
|
pub struct LocalVault {
|
|
pub pool: SqlitePool,
|
|
pub unlocked_key: Arc<RwLock<Option<[u8; 32]>>>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct LocalVaultBootstrap {
|
|
pub unlocked: bool,
|
|
pub has_master_password: bool,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct LocalVaultEntrySummary {
|
|
pub id: String,
|
|
pub name: String,
|
|
pub cipher_type: String,
|
|
pub folder: String,
|
|
pub deleted: bool,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct LocalSecretField {
|
|
pub id: String,
|
|
pub name: String,
|
|
pub secret_type: String,
|
|
pub masked_value: String,
|
|
pub version: i64,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct LocalDetailField {
|
|
pub label: String,
|
|
pub value: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct LocalEntryDetail {
|
|
pub id: String,
|
|
pub name: String,
|
|
pub folder: String,
|
|
pub cipher_type: String,
|
|
pub metadata: Vec<LocalDetailField>,
|
|
pub secrets: Vec<LocalSecretField>,
|
|
pub deleted: bool,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct LocalSecretValue {
|
|
pub id: String,
|
|
pub entry_id: String,
|
|
pub name: String,
|
|
pub secret_type: String,
|
|
pub value: String,
|
|
pub version: i64,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct LocalHistoryItem {
|
|
pub history_id: i64,
|
|
pub secret_id: String,
|
|
pub name: String,
|
|
pub secret_type: String,
|
|
pub masked_value: String,
|
|
pub value: String,
|
|
pub version: i64,
|
|
pub action: String,
|
|
pub created_at: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct LocalEntryDraft {
|
|
pub folder: String,
|
|
pub name: String,
|
|
pub cipher_type: String,
|
|
pub metadata: Vec<LocalDetailField>,
|
|
pub secrets: Vec<LocalSecretDraft>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct LocalSecretDraft {
|
|
pub name: String,
|
|
pub secret_type: Option<String>,
|
|
pub value: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct LocalSecretUpdateDraft {
|
|
pub id: String,
|
|
pub name: Option<String>,
|
|
pub secret_type: Option<String>,
|
|
pub value: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct LocalEntryQuery {
|
|
pub folder: Option<String>,
|
|
pub cipher_type: Option<String>,
|
|
pub query: Option<String>,
|
|
pub deleted_only: bool,
|
|
}
|
|
|
|
pub async fn open_or_create_local_vault() -> Result<LocalVault> {
|
|
let path = local_vault_path()?;
|
|
open_or_create_local_vault_at(&path).await
|
|
}
|
|
|
|
async fn open_or_create_local_vault_at(path: &std::path::Path) -> Result<LocalVault> {
|
|
if let Some(parent) = path.parent() {
|
|
std::fs::create_dir_all(parent)
|
|
.with_context(|| format!("failed to create {}", parent.display()))?;
|
|
}
|
|
let options = SqliteConnectOptions::from_str(&format!("sqlite://{}", path.display()))
|
|
.context("failed to build sqlite connect options")?
|
|
.create_if_missing(true);
|
|
let pool = SqlitePoolOptions::new()
|
|
.max_connections(1)
|
|
.connect_with(options)
|
|
.await
|
|
.with_context(|| format!("failed to open local vault {}", path.display()))?;
|
|
|
|
migrate_local_vault(&pool).await?;
|
|
|
|
Ok(LocalVault {
|
|
pool,
|
|
unlocked_key: Arc::new(RwLock::new(None)),
|
|
})
|
|
}
|
|
|
|
pub fn local_vault_path() -> Result<PathBuf> {
|
|
let home = std::env::var("HOME").context("HOME is not set")?;
|
|
Ok(PathBuf::from(home)
|
|
.join(".secrets-v3")
|
|
.join("desktop")
|
|
.join("vault.sqlite3"))
|
|
}
|
|
|
|
async fn migrate_local_vault(pool: &SqlitePool) -> Result<()> {
|
|
sqlx::query(
|
|
r#"
|
|
CREATE TABLE IF NOT EXISTS vault_meta (
|
|
key TEXT PRIMARY KEY,
|
|
value BLOB NOT NULL
|
|
);
|
|
CREATE TABLE IF NOT EXISTS vault_objects (
|
|
object_id TEXT PRIMARY KEY,
|
|
object_kind TEXT NOT NULL,
|
|
revision INTEGER NOT NULL,
|
|
cipher_version INTEGER NOT NULL,
|
|
ciphertext BLOB NOT NULL,
|
|
content_hash TEXT NOT NULL,
|
|
deleted_at TEXT,
|
|
updated_at TEXT NOT NULL,
|
|
last_synced_at TEXT
|
|
);
|
|
CREATE TABLE IF NOT EXISTS vault_object_history (
|
|
object_id TEXT NOT NULL,
|
|
revision INTEGER NOT NULL,
|
|
ciphertext BLOB NOT NULL,
|
|
source TEXT NOT NULL,
|
|
created_at TEXT NOT NULL,
|
|
PRIMARY KEY (object_id, revision)
|
|
);
|
|
CREATE TABLE IF NOT EXISTS pending_changes (
|
|
change_id TEXT PRIMARY KEY,
|
|
object_id TEXT NOT NULL,
|
|
object_kind TEXT NOT NULL,
|
|
operation TEXT NOT NULL,
|
|
base_revision INTEGER,
|
|
ciphertext BLOB,
|
|
content_hash TEXT,
|
|
queued_at TEXT NOT NULL,
|
|
retry_count INTEGER NOT NULL DEFAULT 0,
|
|
last_error TEXT
|
|
);
|
|
CREATE TABLE IF NOT EXISTS sync_state (
|
|
scope TEXT PRIMARY KEY,
|
|
last_server_revision INTEGER NOT NULL DEFAULT 0,
|
|
last_full_sync_at TEXT,
|
|
last_success_at TEXT,
|
|
last_error_at TEXT,
|
|
last_error TEXT
|
|
);
|
|
CREATE TABLE IF NOT EXISTS search_index_docs (
|
|
object_id TEXT PRIMARY KEY,
|
|
doc_ciphertext BLOB NOT NULL,
|
|
doc_version INTEGER NOT NULL,
|
|
updated_at TEXT NOT NULL
|
|
);
|
|
"#,
|
|
)
|
|
.execute(pool)
|
|
.await
|
|
.context("failed to migrate local vault")?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn bootstrap(vault: &LocalVault) -> Result<LocalVaultBootstrap> {
|
|
let has_master_password = sqlx::query_scalar::<_, i64>(
|
|
"SELECT COUNT(*) FROM vault_meta WHERE key IN ('key_salt', 'key_check', 'vault_key')",
|
|
)
|
|
.fetch_one(&vault.pool)
|
|
.await
|
|
.context("failed to read local vault bootstrap")?
|
|
>= 3;
|
|
let unlocked = vault
|
|
.unlocked_key
|
|
.read()
|
|
.map_err(|_| anyhow!("failed to read local unlock state"))?
|
|
.is_some();
|
|
Ok(LocalVaultBootstrap {
|
|
unlocked,
|
|
has_master_password,
|
|
})
|
|
}
|
|
|
|
pub async fn setup_master_password(
|
|
vault: &LocalVault,
|
|
password: &str,
|
|
config: &KdfConfig,
|
|
) -> Result<()> {
|
|
let salt = Uuid::new_v4().as_bytes().to_vec();
|
|
let master_key = derive_master_key(password, &salt, config)?;
|
|
let vault_key = derive_random_vault_key();
|
|
let key_check = encrypt(&master_key, KEY_CHECK_PLAINTEXT)?;
|
|
let protected_vault_key = encrypt(&master_key, &vault_key)?;
|
|
|
|
upsert_meta(&vault.pool, "key_salt", salt).await?;
|
|
upsert_meta(
|
|
&vault.pool,
|
|
"key_params",
|
|
serde_json::to_vec(config).context("failed to encode key params")?,
|
|
)
|
|
.await?;
|
|
upsert_meta(&vault.pool, "key_check", key_check).await?;
|
|
upsert_meta(&vault.pool, "vault_key", protected_vault_key).await?;
|
|
set_unlocked_key(
|
|
vault,
|
|
Some(
|
|
vault_key
|
|
.try_into()
|
|
.map_err(|_| anyhow!("invalid vault key"))?,
|
|
),
|
|
);
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn unlock(vault: &LocalVault, password: &str) -> Result<()> {
|
|
let salt = get_meta(&vault.pool, "key_salt")
|
|
.await?
|
|
.context("vault is not initialized")?;
|
|
let config_bytes = get_meta(&vault.pool, "key_params")
|
|
.await?
|
|
.context("missing key params")?;
|
|
let config: KdfConfig = serde_json::from_slice(&config_bytes).context("invalid key params")?;
|
|
let master_key = derive_master_key(password, &salt, &config)?;
|
|
let key_check = get_meta(&vault.pool, "key_check")
|
|
.await?
|
|
.context("missing key check")?;
|
|
let plaintext =
|
|
decrypt(&master_key, &key_check).map_err(|_| anyhow!("invalid master password"))?;
|
|
if plaintext.as_slice() != KEY_CHECK_PLAINTEXT {
|
|
return Err(anyhow!("invalid master password"));
|
|
}
|
|
let wrapped_vault_key = get_meta(&vault.pool, "vault_key")
|
|
.await?
|
|
.context("missing vault key")?;
|
|
let vault_key = decrypt(&master_key, &wrapped_vault_key)?;
|
|
let key: [u8; 32] = vault_key
|
|
.try_into()
|
|
.map_err(|_| anyhow!("invalid local vault key length"))?;
|
|
set_unlocked_key(vault, Some(key));
|
|
Ok(())
|
|
}
|
|
|
|
pub fn lock(vault: &LocalVault) {
|
|
set_unlocked_key(vault, None);
|
|
}
|
|
|
|
pub async fn list_entries(
|
|
vault: &LocalVault,
|
|
query: &LocalEntryQuery,
|
|
) -> Result<Vec<LocalVaultEntrySummary>> {
|
|
let views = load_all_views(vault).await?;
|
|
let mut entries: Vec<_> = views
|
|
.into_iter()
|
|
.filter(|view| {
|
|
let deleted = view.deleted_at.is_some();
|
|
if query.deleted_only != deleted {
|
|
return false;
|
|
}
|
|
if let Some(folder) = query.folder.as_ref()
|
|
&& &view.folder != folder
|
|
{
|
|
return false;
|
|
}
|
|
if let Some(cipher_type) = query.cipher_type.as_ref()
|
|
&& view.cipher_type.as_str() != cipher_type
|
|
{
|
|
return false;
|
|
}
|
|
if let Some(keyword) = query.query.as_ref() {
|
|
let keyword = keyword.to_lowercase();
|
|
let haystack = format!(
|
|
"{} {} {}",
|
|
view.name,
|
|
view.folder,
|
|
view.notes.clone().unwrap_or_default()
|
|
)
|
|
.to_lowercase();
|
|
if !haystack.contains(&keyword) {
|
|
return false;
|
|
}
|
|
}
|
|
true
|
|
})
|
|
.map(|view| LocalVaultEntrySummary {
|
|
id: view.id.to_string(),
|
|
name: view.name,
|
|
cipher_type: view.cipher_type.as_str().to_string(),
|
|
folder: view.folder,
|
|
deleted: view.deleted_at.is_some(),
|
|
})
|
|
.collect();
|
|
|
|
entries.sort_by(|a, b| b.name.cmp(&a.name));
|
|
Ok(entries)
|
|
}
|
|
|
|
pub async fn entry_detail(vault: &LocalVault, entry_id: &str) -> Result<LocalEntryDetail> {
|
|
let view = load_view(vault, entry_id).await?;
|
|
let (metadata, secrets) = detail_parts(&view);
|
|
Ok(LocalEntryDetail {
|
|
id: view.id.to_string(),
|
|
name: view.name,
|
|
folder: view.folder,
|
|
cipher_type: view.cipher_type.as_str().to_string(),
|
|
metadata,
|
|
secrets,
|
|
deleted: view.deleted_at.is_some(),
|
|
})
|
|
}
|
|
|
|
pub async fn reveal_secret_value(
|
|
vault: &LocalVault,
|
|
entry_id: &str,
|
|
secret_name: &str,
|
|
) -> Result<LocalSecretValue> {
|
|
let view = load_view(vault, entry_id).await?;
|
|
let value =
|
|
secret_value_from_view(&view, secret_name).ok_or_else(|| anyhow!("secret not found"))?;
|
|
Ok(LocalSecretValue {
|
|
id: format!("{}:{secret_name}", view.id),
|
|
entry_id: view.id.to_string(),
|
|
name: secret_name.to_string(),
|
|
secret_type: infer_secret_type(secret_name).to_string(),
|
|
value,
|
|
version: current_revision(vault, &view.id).await?,
|
|
})
|
|
}
|
|
|
|
pub async fn secret_history(
|
|
vault: &LocalVault,
|
|
entry_id: &str,
|
|
secret_name: &str,
|
|
) -> Result<Vec<LocalHistoryItem>> {
|
|
let rows = sqlx::query(
|
|
r#"
|
|
SELECT revision, ciphertext, source, created_at
|
|
FROM vault_object_history
|
|
WHERE object_id = $1
|
|
ORDER BY revision DESC
|
|
"#,
|
|
)
|
|
.bind(entry_id)
|
|
.fetch_all(&vault.pool)
|
|
.await
|
|
.context("failed to load local history")?;
|
|
|
|
let mut history = Vec::new();
|
|
for row in rows {
|
|
let revision: i64 = row.try_get("revision")?;
|
|
let ciphertext: Vec<u8> = row.try_get("ciphertext")?;
|
|
let source: String = row.try_get("source")?;
|
|
let created_at: String = row.try_get("created_at")?;
|
|
let view = decrypt_view(vault, &ciphertext)?;
|
|
if let Some(value) = secret_value_from_view(&view, secret_name) {
|
|
history.push(LocalHistoryItem {
|
|
history_id: revision,
|
|
secret_id: format!("{}:{secret_name}", view.id),
|
|
name: secret_name.to_string(),
|
|
secret_type: infer_secret_type(secret_name).to_string(),
|
|
masked_value: mask_secret(&value),
|
|
value,
|
|
version: revision,
|
|
action: source,
|
|
created_at,
|
|
});
|
|
}
|
|
}
|
|
Ok(history)
|
|
}
|
|
|
|
pub async fn create_entry(vault: &LocalVault, draft: LocalEntryDraft) -> Result<LocalEntryDetail> {
|
|
let id = Uuid::new_v4();
|
|
let view = draft_to_view(id, draft);
|
|
write_view(vault, &view, Some("create")).await?;
|
|
entry_detail(vault, &id.to_string()).await
|
|
}
|
|
|
|
pub async fn update_entry(
|
|
vault: &LocalVault,
|
|
detail: LocalEntryDetail,
|
|
) -> Result<LocalEntryDetail> {
|
|
let existing = load_view(vault, &detail.id).await?;
|
|
let updated = LocalEntryDraft {
|
|
folder: detail.folder.clone(),
|
|
name: detail.name.clone(),
|
|
cipher_type: detail.cipher_type.clone(),
|
|
metadata: detail.metadata.clone(),
|
|
secrets: existing
|
|
.payload_secret_names()
|
|
.into_iter()
|
|
.filter_map(|name| {
|
|
let secret_type = infer_secret_type(&name).to_string();
|
|
secret_value_from_view(&existing, &name).map(|value| LocalSecretDraft {
|
|
name,
|
|
secret_type: Some(secret_type),
|
|
value,
|
|
})
|
|
})
|
|
.collect(),
|
|
};
|
|
let mut view = draft_to_view(existing.id, updated);
|
|
view.deleted_at = existing.deleted_at;
|
|
write_view(vault, &view, Some("update")).await?;
|
|
entry_detail(vault, &view.id.to_string()).await
|
|
}
|
|
|
|
pub async fn delete_entry(vault: &LocalVault, entry_id: &str) -> Result<()> {
|
|
let mut view = load_view(vault, entry_id).await?;
|
|
view.deleted_at = Some(Utc::now());
|
|
write_view(vault, &view, Some("delete")).await?;
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn restore_entry(vault: &LocalVault, entry_id: &str) -> Result<()> {
|
|
let mut view = load_view(vault, entry_id).await?;
|
|
view.deleted_at = None;
|
|
write_view(vault, &view, Some("restore")).await?;
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn create_secret(
|
|
vault: &LocalVault,
|
|
entry_id: &str,
|
|
secret: LocalSecretDraft,
|
|
) -> Result<LocalEntryDetail> {
|
|
let mut view = load_view(vault, entry_id).await?;
|
|
set_secret_on_view(
|
|
&mut view,
|
|
&secret.name,
|
|
secret.value,
|
|
secret.secret_type.as_deref(),
|
|
);
|
|
write_view(vault, &view, Some("create_secret")).await?;
|
|
entry_detail(vault, entry_id).await
|
|
}
|
|
|
|
pub async fn update_secret(
|
|
vault: &LocalVault,
|
|
update: LocalSecretUpdateDraft,
|
|
) -> Result<LocalEntryDetail> {
|
|
let (entry_id, secret_name) = split_secret_ref(&update.id)?;
|
|
let mut view = load_view(vault, &entry_id.to_string()).await?;
|
|
let current_name = update.name.clone().unwrap_or(secret_name.clone());
|
|
let current_value = match update.value {
|
|
Some(value) => value,
|
|
None => secret_value_from_view(&view, &secret_name).unwrap_or_default(),
|
|
};
|
|
set_secret_on_view(
|
|
&mut view,
|
|
¤t_name,
|
|
current_value,
|
|
update.secret_type.as_deref(),
|
|
);
|
|
write_view(vault, &view, Some("update_secret")).await?;
|
|
entry_detail(vault, &entry_id.to_string()).await
|
|
}
|
|
|
|
pub async fn delete_secret(vault: &LocalVault, secret_id: &str) -> Result<()> {
|
|
let (entry_id, secret_name) = split_secret_ref(secret_id)?;
|
|
let mut view = load_view(vault, &entry_id.to_string()).await?;
|
|
remove_secret_from_view(&mut view, &secret_name);
|
|
write_view(vault, &view, Some("delete_secret")).await?;
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn rollback_secret(
|
|
vault: &LocalVault,
|
|
secret_id: &str,
|
|
history_id: Option<i64>,
|
|
) -> Result<LocalEntryDetail> {
|
|
let (entry_id, secret_name) = split_secret_ref(secret_id)?;
|
|
let revision = history_id.context("history_id is required for local rollback")?;
|
|
let row = sqlx::query(
|
|
r#"
|
|
SELECT ciphertext
|
|
FROM vault_object_history
|
|
WHERE object_id = $1 AND revision = $2
|
|
"#,
|
|
)
|
|
.bind(entry_id)
|
|
.bind(revision)
|
|
.fetch_one(&vault.pool)
|
|
.await
|
|
.context("failed to load local rollback revision")?;
|
|
let ciphertext: Vec<u8> = row.try_get("ciphertext")?;
|
|
let snapshot = decrypt_view(vault, &ciphertext)?;
|
|
let value = secret_value_from_view(&snapshot, &secret_name)
|
|
.context("secret not found in rollback snapshot")?;
|
|
let mut current = load_view(vault, &entry_id.to_string()).await?;
|
|
set_secret_on_view(&mut current, &secret_name, value, None);
|
|
write_view(vault, ¤t, Some("rollback_secret")).await?;
|
|
entry_detail(vault, &entry_id.to_string()).await
|
|
}
|
|
|
|
pub async fn sync_pull(vault: &LocalVault, api_base: &str, token: &str) -> Result<()> {
|
|
let cursor = local_server_revision(vault).await?;
|
|
let client = reqwest::Client::new();
|
|
let response = client
|
|
.post(format!("{api_base}/sync/pull"))
|
|
.bearer_auth(token)
|
|
.json(&serde_json::json!({
|
|
"cursor": cursor,
|
|
"limit": 200,
|
|
"includeDeleted": true
|
|
}))
|
|
.send()
|
|
.await
|
|
.context("failed to pull sync objects")?
|
|
.error_for_status()
|
|
.context("sync pull returned error")?;
|
|
let payload: secrets_domain::SyncPullResponse = response
|
|
.json()
|
|
.await
|
|
.context("failed to decode sync pull response")?;
|
|
|
|
for object in payload.objects {
|
|
apply_remote_object(vault, &object).await?;
|
|
}
|
|
set_local_server_revision(vault, payload.server_revision).await?;
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn sync_push(vault: &LocalVault, api_base: &str, token: &str) -> Result<()> {
|
|
let pending_rows = sqlx::query(
|
|
r#"
|
|
SELECT change_id, object_id, object_kind, operation, base_revision, ciphertext, content_hash
|
|
FROM pending_changes
|
|
ORDER BY queued_at ASC
|
|
"#,
|
|
)
|
|
.fetch_all(&vault.pool)
|
|
.await
|
|
.context("failed to load pending changes")?;
|
|
|
|
if pending_rows.is_empty() {
|
|
return Ok(());
|
|
}
|
|
|
|
let changes = pending_rows
|
|
.iter()
|
|
.map(|row| -> Result<VaultObjectChange> {
|
|
Ok(VaultObjectChange {
|
|
change_id: Uuid::parse_str(&row.try_get::<String, _>("change_id")?)?,
|
|
object_id: Uuid::parse_str(&row.try_get::<String, _>("object_id")?)?,
|
|
object_kind: VaultObjectKind::Cipher,
|
|
operation: row.try_get("operation")?,
|
|
base_revision: row.try_get("base_revision")?,
|
|
cipher_version: Some(1),
|
|
ciphertext: row.try_get("ciphertext")?,
|
|
content_hash: row.try_get("content_hash")?,
|
|
})
|
|
})
|
|
.collect::<Result<Vec<_>>>()?;
|
|
|
|
let client = reqwest::Client::new();
|
|
let response = client
|
|
.post(format!("{api_base}/sync/push"))
|
|
.bearer_auth(token)
|
|
.json(&serde_json::json!({ "changes": changes }))
|
|
.send()
|
|
.await
|
|
.context("failed to push local changes")?
|
|
.error_for_status()
|
|
.context("sync push returned error")?;
|
|
let payload: secrets_domain::SyncPushResponse = response
|
|
.json()
|
|
.await
|
|
.context("failed to decode sync push response")?;
|
|
|
|
for accepted in payload.accepted {
|
|
sqlx::query("DELETE FROM pending_changes WHERE change_id = $1")
|
|
.bind(accepted.change_id.to_string())
|
|
.execute(&vault.pool)
|
|
.await
|
|
.context("failed to clear accepted pending change")?;
|
|
}
|
|
set_local_server_revision(vault, payload.server_revision).await?;
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn queue_sync_from_view(
|
|
vault: &LocalVault,
|
|
view: &CipherView,
|
|
operation: &str,
|
|
) -> Result<()> {
|
|
let ciphertext = encrypt_view(vault, view)?;
|
|
let content_hash = hash_ciphertext(&ciphertext);
|
|
let base_revision = current_revision(vault, &view.id).await.ok();
|
|
sqlx::query(
|
|
r#"
|
|
INSERT INTO pending_changes (
|
|
change_id, object_id, object_kind, operation, base_revision, ciphertext, content_hash, queued_at
|
|
)
|
|
VALUES ($1, $2, 'cipher', $3, $4, $5, $6, $7)
|
|
"#,
|
|
)
|
|
.bind(Uuid::new_v4().to_string())
|
|
.bind(view.id.to_string())
|
|
.bind(operation)
|
|
.bind(base_revision)
|
|
.bind(ciphertext)
|
|
.bind(content_hash)
|
|
.bind(Utc::now().to_rfc3339())
|
|
.execute(&vault.pool)
|
|
.await
|
|
.context("failed to queue pending change")?;
|
|
Ok(())
|
|
}
|
|
|
|
fn set_unlocked_key(vault: &LocalVault, key: Option<[u8; 32]>) {
|
|
if let Ok(mut guard) = vault.unlocked_key.write() {
|
|
*guard = key;
|
|
}
|
|
}
|
|
|
|
fn unlocked_key(vault: &LocalVault) -> Result<[u8; 32]> {
|
|
vault
|
|
.unlocked_key
|
|
.read()
|
|
.map_err(|_| anyhow!("failed to read unlock state"))?
|
|
.ok_or_else(|| anyhow!("vault is locked"))
|
|
}
|
|
|
|
fn derive_master_key(password: &str, salt: &[u8], config: &KdfConfig) -> Result<[u8; 32]> {
|
|
let argon2 = config.build_argon2()?;
|
|
let mut out = [0_u8; 32];
|
|
argon2
|
|
.hash_password_into(password.as_bytes(), salt, &mut out)
|
|
.map_err(|err| anyhow!("failed to derive master key: {err}"))?;
|
|
Ok(out)
|
|
}
|
|
|
|
fn derive_random_vault_key() -> Vec<u8> {
|
|
Uuid::new_v4()
|
|
.as_bytes()
|
|
.iter()
|
|
.copied()
|
|
.chain(Uuid::new_v4().as_bytes().iter().copied())
|
|
.collect()
|
|
}
|
|
|
|
async fn upsert_meta(pool: &SqlitePool, key: &str, value: Vec<u8>) -> Result<()> {
|
|
sqlx::query(
|
|
r#"
|
|
INSERT INTO vault_meta (key, value)
|
|
VALUES ($1, $2)
|
|
ON CONFLICT(key) DO UPDATE SET value = excluded.value
|
|
"#,
|
|
)
|
|
.bind(key)
|
|
.bind(value)
|
|
.execute(pool)
|
|
.await
|
|
.with_context(|| format!("failed to upsert meta {key}"))?;
|
|
Ok(())
|
|
}
|
|
|
|
async fn get_meta(pool: &SqlitePool, key: &str) -> Result<Option<Vec<u8>>> {
|
|
let row = sqlx::query("SELECT value FROM vault_meta WHERE key = $1")
|
|
.bind(key)
|
|
.fetch_optional(pool)
|
|
.await
|
|
.with_context(|| format!("failed to load meta {key}"))?;
|
|
Ok(row.map(|row| row.get::<Vec<u8>, _>("value")))
|
|
}
|
|
|
|
async fn local_server_revision(vault: &LocalVault) -> Result<i64> {
|
|
let row = sqlx::query("SELECT last_server_revision FROM sync_state WHERE scope = 'default'")
|
|
.fetch_optional(&vault.pool)
|
|
.await
|
|
.context("failed to read sync state")?;
|
|
Ok(row
|
|
.map(|row| row.get::<i64, _>("last_server_revision"))
|
|
.unwrap_or(0))
|
|
}
|
|
|
|
async fn set_local_server_revision(vault: &LocalVault, revision: i64) -> Result<()> {
|
|
sqlx::query(
|
|
r#"
|
|
INSERT INTO sync_state (scope, last_server_revision, last_success_at)
|
|
VALUES ('default', $1, $2)
|
|
ON CONFLICT(scope) DO UPDATE SET
|
|
last_server_revision = excluded.last_server_revision,
|
|
last_success_at = excluded.last_success_at
|
|
"#,
|
|
)
|
|
.bind(revision)
|
|
.bind(Utc::now().to_rfc3339())
|
|
.execute(&vault.pool)
|
|
.await
|
|
.context("failed to update local sync state")?;
|
|
Ok(())
|
|
}
|
|
|
|
async fn apply_remote_object(vault: &LocalVault, object: &VaultObjectEnvelope) -> Result<()> {
|
|
sqlx::query(
|
|
r#"
|
|
INSERT INTO vault_objects (
|
|
object_id, object_kind, revision, cipher_version, ciphertext, content_hash, deleted_at, updated_at, last_synced_at
|
|
)
|
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
|
ON CONFLICT(object_id) DO UPDATE SET
|
|
object_kind = excluded.object_kind,
|
|
revision = excluded.revision,
|
|
cipher_version = excluded.cipher_version,
|
|
ciphertext = excluded.ciphertext,
|
|
content_hash = excluded.content_hash,
|
|
deleted_at = excluded.deleted_at,
|
|
updated_at = excluded.updated_at,
|
|
last_synced_at = excluded.last_synced_at
|
|
"#,
|
|
)
|
|
.bind(object.object_id.to_string())
|
|
.bind(object.object_kind.as_str())
|
|
.bind(object.revision)
|
|
.bind(object.cipher_version)
|
|
.bind(object.ciphertext.clone())
|
|
.bind(&object.content_hash)
|
|
.bind(object.deleted_at.map(|value| value.to_rfc3339()))
|
|
.bind(object.updated_at.to_rfc3339())
|
|
.bind(Utc::now().to_rfc3339())
|
|
.execute(&vault.pool)
|
|
.await
|
|
.context("failed to apply remote object")?;
|
|
|
|
sqlx::query(
|
|
r#"
|
|
INSERT OR IGNORE INTO vault_object_history (object_id, revision, ciphertext, source, created_at)
|
|
VALUES ($1, $2, $3, 'sync_pull', $4)
|
|
"#,
|
|
)
|
|
.bind(object.object_id.to_string())
|
|
.bind(object.revision)
|
|
.bind(object.ciphertext.clone())
|
|
.bind(Utc::now().to_rfc3339())
|
|
.execute(&vault.pool)
|
|
.await
|
|
.context("failed to append local object history")?;
|
|
Ok(())
|
|
}
|
|
|
|
async fn load_all_views(vault: &LocalVault) -> Result<Vec<CipherView>> {
|
|
let rows = sqlx::query("SELECT ciphertext FROM vault_objects ORDER BY updated_at DESC")
|
|
.fetch_all(&vault.pool)
|
|
.await
|
|
.context("failed to load local vault objects")?;
|
|
rows.into_iter()
|
|
.map(|row| row.get::<Vec<u8>, _>("ciphertext"))
|
|
.map(|ciphertext| decrypt_view(vault, &ciphertext))
|
|
.collect()
|
|
}
|
|
|
|
async fn load_view(vault: &LocalVault, entry_id: &str) -> Result<CipherView> {
|
|
let row = sqlx::query("SELECT ciphertext FROM vault_objects WHERE object_id = $1")
|
|
.bind(entry_id)
|
|
.fetch_one(&vault.pool)
|
|
.await
|
|
.with_context(|| format!("failed to load local entry {entry_id}"))?;
|
|
let ciphertext: Vec<u8> = row.get("ciphertext");
|
|
decrypt_view(vault, &ciphertext)
|
|
}
|
|
|
|
fn encrypt_view(vault: &LocalVault, view: &CipherView) -> Result<Vec<u8>> {
|
|
let key = unlocked_key(vault)?;
|
|
let plaintext = serde_json::to_vec(view).context("failed to encode cipher view")?;
|
|
encrypt(&key, &plaintext)
|
|
}
|
|
|
|
fn decrypt_view(vault: &LocalVault, ciphertext: &[u8]) -> Result<CipherView> {
|
|
let key = unlocked_key(vault)?;
|
|
let plaintext = decrypt(&key, ciphertext).context("failed to decrypt local vault object")?;
|
|
serde_json::from_slice(&plaintext).context("failed to decode cipher view")
|
|
}
|
|
|
|
async fn write_view(vault: &LocalVault, view: &CipherView, source: Option<&str>) -> Result<()> {
|
|
let ciphertext = encrypt_view(vault, view)?;
|
|
let content_hash = hash_ciphertext(&ciphertext);
|
|
let next_revision = current_revision(vault, &view.id).await.unwrap_or(0) + 1;
|
|
let updated_at = Utc::now().to_rfc3339();
|
|
sqlx::query(
|
|
r#"
|
|
INSERT INTO vault_objects (
|
|
object_id, object_kind, revision, cipher_version, ciphertext, content_hash, deleted_at, updated_at, last_synced_at
|
|
)
|
|
VALUES ($1, 'cipher', $2, 1, $3, $4, $5, $6, NULL)
|
|
ON CONFLICT(object_id) DO UPDATE SET
|
|
revision = excluded.revision,
|
|
cipher_version = excluded.cipher_version,
|
|
ciphertext = excluded.ciphertext,
|
|
content_hash = excluded.content_hash,
|
|
deleted_at = excluded.deleted_at,
|
|
updated_at = excluded.updated_at
|
|
"#,
|
|
)
|
|
.bind(view.id.to_string())
|
|
.bind(next_revision)
|
|
.bind(ciphertext.clone())
|
|
.bind(content_hash.clone())
|
|
.bind(view.deleted_at.map(|value| value.to_rfc3339()))
|
|
.bind(updated_at.clone())
|
|
.execute(&vault.pool)
|
|
.await
|
|
.context("failed to write local vault object")?;
|
|
|
|
sqlx::query(
|
|
r#"
|
|
INSERT INTO vault_object_history (object_id, revision, ciphertext, source, created_at)
|
|
VALUES ($1, $2, $3, $4, $5)
|
|
"#,
|
|
)
|
|
.bind(view.id.to_string())
|
|
.bind(next_revision)
|
|
.bind(ciphertext)
|
|
.bind(source.unwrap_or("local"))
|
|
.bind(updated_at)
|
|
.execute(&vault.pool)
|
|
.await
|
|
.context("failed to append local object history")?;
|
|
|
|
queue_sync_from_view(
|
|
vault,
|
|
view,
|
|
if view.deleted_at.is_some() {
|
|
"delete"
|
|
} else {
|
|
"upsert"
|
|
},
|
|
)
|
|
.await?;
|
|
Ok(())
|
|
}
|
|
|
|
async fn current_revision(vault: &LocalVault, object_id: &Uuid) -> Result<i64> {
|
|
let row = sqlx::query("SELECT revision FROM vault_objects WHERE object_id = $1")
|
|
.bind(object_id.to_string())
|
|
.fetch_optional(&vault.pool)
|
|
.await
|
|
.context("failed to load current revision")?;
|
|
Ok(row.map(|row| row.get::<i64, _>("revision")).unwrap_or(0))
|
|
}
|
|
|
|
fn hash_ciphertext(ciphertext: &[u8]) -> String {
|
|
let digest = Sha256::digest(ciphertext);
|
|
format!("sha256:{}", hex::encode(digest))
|
|
}
|
|
|
|
fn draft_to_view(id: Uuid, draft: LocalEntryDraft) -> CipherView {
|
|
let mut custom_fields = Vec::new();
|
|
let mut notes = None;
|
|
for field in &draft.metadata {
|
|
if field.label.contains("说明") || field.label.eq_ignore_ascii_case("notes") {
|
|
if !field.value.trim().is_empty() {
|
|
notes = Some(field.value.clone());
|
|
}
|
|
} else if !field.label.trim().is_empty() {
|
|
custom_fields.push(CustomField {
|
|
name: field.label.clone(),
|
|
value: Value::String(field.value.clone()),
|
|
sensitive: false,
|
|
});
|
|
}
|
|
}
|
|
|
|
let cipher_type = infer_cipher_type(&draft.cipher_type, &draft.metadata, &draft.secrets);
|
|
let payload = payload_from_draft(cipher_type, &draft.metadata, &draft.secrets);
|
|
|
|
CipherView {
|
|
id,
|
|
cipher_type,
|
|
name: draft.name,
|
|
folder: draft.folder,
|
|
notes,
|
|
custom_fields,
|
|
deleted_at: None,
|
|
revision_date: Utc::now(),
|
|
payload,
|
|
}
|
|
}
|
|
|
|
fn infer_cipher_type(
|
|
entry_type: &str,
|
|
metadata: &[LocalDetailField],
|
|
secrets: &[LocalSecretDraft],
|
|
) -> CipherType {
|
|
let has_password = secrets
|
|
.iter()
|
|
.any(|secret| secret.name.eq_ignore_ascii_case("password"));
|
|
let has_token = secrets.iter().any(|secret| {
|
|
matches!(
|
|
secret.name.to_ascii_lowercase().as_str(),
|
|
"token" | "api_key" | "access_token" | "access_key"
|
|
)
|
|
});
|
|
let has_ssh = secrets
|
|
.iter()
|
|
.any(|secret| secret.name.eq_ignore_ascii_case("ssh_key"));
|
|
let has_username = metadata.iter().any(|field| {
|
|
matches!(
|
|
field.label.to_ascii_lowercase().as_str(),
|
|
"username" | "user" | "ssh_user"
|
|
)
|
|
});
|
|
let has_url = metadata.iter().any(|field| {
|
|
matches!(
|
|
field.label.to_ascii_lowercase().as_str(),
|
|
"url" | "base_url" | "endpoint" | "host" | "hostname"
|
|
)
|
|
});
|
|
|
|
if has_ssh {
|
|
CipherType::SshKey
|
|
} else if has_token && has_url {
|
|
CipherType::ApiKey
|
|
} else if entry_type == "account" || (has_username && has_password) {
|
|
CipherType::Login
|
|
} else {
|
|
CipherType::SecureNote
|
|
}
|
|
}
|
|
|
|
fn payload_from_draft(
|
|
cipher_type: CipherType,
|
|
metadata: &[LocalDetailField],
|
|
secrets: &[LocalSecretDraft],
|
|
) -> ItemPayload {
|
|
match cipher_type {
|
|
CipherType::Login => ItemPayload::Login(LoginPayload {
|
|
username: metadata_lookup(metadata, &["username", "user"]),
|
|
uris: metadata_lookup(metadata, &["url", "base_url", "endpoint"])
|
|
.into_iter()
|
|
.collect(),
|
|
password: secret_lookup(secrets, &["password"]),
|
|
totp: secret_lookup(secrets, &["totp"]),
|
|
}),
|
|
CipherType::ApiKey => ItemPayload::ApiKey(ApiKeyPayload {
|
|
client_id: metadata_lookup(metadata, &["username", "user"]),
|
|
secret: secret_lookup(secrets, &["token", "api_key", "access_token", "access_key"]),
|
|
base_url: metadata_lookup(metadata, &["base_url", "url", "endpoint"]),
|
|
host: metadata_lookup(metadata, &["host", "hostname"]),
|
|
}),
|
|
CipherType::SshKey => ItemPayload::SshKey(SshKeyPayload {
|
|
username: metadata_lookup(metadata, &["ssh_user", "user", "username"]),
|
|
host: metadata_lookup(metadata, &["host", "hostname", "public_ip", "private_ip"]),
|
|
port: metadata_lookup(metadata, &["ssh_port", "port"])
|
|
.and_then(|value| value.parse().ok()),
|
|
private_key: secret_lookup(secrets, &["ssh_key"]),
|
|
passphrase: secret_lookup(secrets, &["password", "passphrase"]),
|
|
}),
|
|
_ => ItemPayload::SecureNote(SecureNotePayload {
|
|
text: metadata_lookup(metadata, &["notes", "说明"]),
|
|
}),
|
|
}
|
|
}
|
|
|
|
fn metadata_lookup(metadata: &[LocalDetailField], keys: &[&str]) -> Option<String> {
|
|
metadata.iter().find_map(|field| {
|
|
if keys.iter().any(|key| field.label.eq_ignore_ascii_case(key)) {
|
|
Some(field.value.clone())
|
|
} else {
|
|
None
|
|
}
|
|
})
|
|
}
|
|
|
|
fn secret_lookup(secrets: &[LocalSecretDraft], keys: &[&str]) -> Option<String> {
|
|
secrets.iter().find_map(|secret| {
|
|
if keys.iter().any(|key| secret.name.eq_ignore_ascii_case(key)) {
|
|
Some(secret.value.clone())
|
|
} else {
|
|
None
|
|
}
|
|
})
|
|
}
|
|
|
|
fn detail_parts(view: &CipherView) -> (Vec<LocalDetailField>, Vec<LocalSecretField>) {
|
|
let mut metadata = Vec::new();
|
|
if let Some(notes) = view.notes.as_ref() {
|
|
metadata.push(LocalDetailField {
|
|
label: "notes".to_string(),
|
|
value: notes.clone(),
|
|
});
|
|
}
|
|
for field in &view.custom_fields {
|
|
metadata.push(LocalDetailField {
|
|
label: field.name.clone(),
|
|
value: stringify_value(&field.value),
|
|
});
|
|
}
|
|
|
|
let secrets = payload_secret_fields(view);
|
|
(metadata, secrets)
|
|
}
|
|
|
|
fn payload_secret_fields(view: &CipherView) -> Vec<LocalSecretField> {
|
|
let names = view.payload_secret_names();
|
|
names
|
|
.into_iter()
|
|
.filter_map(|name| {
|
|
secret_value_from_view(view, &name).map(|value| LocalSecretField {
|
|
id: format!("{}:{name}", view.id),
|
|
name: name.clone(),
|
|
secret_type: infer_secret_type(&name).to_string(),
|
|
masked_value: mask_secret(&value),
|
|
version: 1,
|
|
})
|
|
})
|
|
.collect()
|
|
}
|
|
|
|
trait CipherViewSecrets {
|
|
fn payload_secret_names(&self) -> Vec<String>;
|
|
}
|
|
|
|
impl CipherViewSecrets for CipherView {
|
|
fn payload_secret_names(&self) -> Vec<String> {
|
|
match &self.payload {
|
|
ItemPayload::Login(payload) => {
|
|
let mut out = Vec::new();
|
|
if payload.password.is_some() {
|
|
out.push("password".to_string());
|
|
}
|
|
if payload.totp.is_some() {
|
|
out.push("totp".to_string());
|
|
}
|
|
out
|
|
}
|
|
ItemPayload::ApiKey(payload) => {
|
|
if payload.secret.is_some() {
|
|
vec!["api_key".to_string()]
|
|
} else {
|
|
Vec::new()
|
|
}
|
|
}
|
|
ItemPayload::SshKey(payload) => {
|
|
let mut out = Vec::new();
|
|
if payload.private_key.is_some() {
|
|
out.push("ssh_key".to_string());
|
|
}
|
|
if payload.passphrase.is_some() {
|
|
out.push("password".to_string());
|
|
}
|
|
out
|
|
}
|
|
ItemPayload::SecureNote(_) => Vec::new(),
|
|
}
|
|
}
|
|
}
|
|
|
|
fn secret_value_from_view(view: &CipherView, secret_name: &str) -> Option<String> {
|
|
match &view.payload {
|
|
ItemPayload::Login(payload) => match secret_name {
|
|
"password" => payload.password.clone(),
|
|
"totp" => payload.totp.clone(),
|
|
_ => None,
|
|
},
|
|
ItemPayload::ApiKey(payload) => {
|
|
if matches!(
|
|
secret_name,
|
|
"api_key" | "token" | "access_key" | "access_token"
|
|
) {
|
|
payload.secret.clone()
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
ItemPayload::SshKey(payload) => match secret_name {
|
|
"ssh_key" => payload.private_key.clone(),
|
|
"password" | "passphrase" => payload.passphrase.clone(),
|
|
_ => None,
|
|
},
|
|
ItemPayload::SecureNote(_) => None,
|
|
}
|
|
}
|
|
|
|
fn set_secret_on_view(
|
|
view: &mut CipherView,
|
|
secret_name: &str,
|
|
value: String,
|
|
secret_type: Option<&str>,
|
|
) {
|
|
match &mut view.payload {
|
|
ItemPayload::Login(payload) => match secret_name {
|
|
"password" => payload.password = Some(value),
|
|
"totp" => payload.totp = Some(value),
|
|
_ => upsert_sensitive_field(&mut view.custom_fields, secret_name, value),
|
|
},
|
|
ItemPayload::ApiKey(payload) => {
|
|
if matches!(
|
|
secret_name,
|
|
"api_key" | "token" | "access_key" | "access_token"
|
|
) {
|
|
payload.secret = Some(value);
|
|
} else {
|
|
upsert_sensitive_field(&mut view.custom_fields, secret_name, value);
|
|
}
|
|
}
|
|
ItemPayload::SshKey(payload) => match secret_name {
|
|
"ssh_key" => payload.private_key = Some(value),
|
|
"password" | "passphrase" => payload.passphrase = Some(value),
|
|
_ => upsert_sensitive_field(&mut view.custom_fields, secret_name, value),
|
|
},
|
|
ItemPayload::SecureNote(_) => {
|
|
let _ = secret_type;
|
|
upsert_sensitive_field(&mut view.custom_fields, secret_name, value);
|
|
}
|
|
}
|
|
view.revision_date = Utc::now();
|
|
}
|
|
|
|
fn remove_secret_from_view(view: &mut CipherView, secret_name: &str) {
|
|
match &mut view.payload {
|
|
ItemPayload::Login(payload) => match secret_name {
|
|
"password" => payload.password = None,
|
|
"totp" => payload.totp = None,
|
|
_ => remove_custom_field(&mut view.custom_fields, secret_name),
|
|
},
|
|
ItemPayload::ApiKey(payload) => {
|
|
if matches!(
|
|
secret_name,
|
|
"api_key" | "token" | "access_key" | "access_token"
|
|
) {
|
|
payload.secret = None;
|
|
} else {
|
|
remove_custom_field(&mut view.custom_fields, secret_name);
|
|
}
|
|
}
|
|
ItemPayload::SshKey(payload) => match secret_name {
|
|
"ssh_key" => payload.private_key = None,
|
|
"password" | "passphrase" => payload.passphrase = None,
|
|
_ => remove_custom_field(&mut view.custom_fields, secret_name),
|
|
},
|
|
ItemPayload::SecureNote(_) => remove_custom_field(&mut view.custom_fields, secret_name),
|
|
}
|
|
view.revision_date = Utc::now();
|
|
}
|
|
|
|
fn upsert_sensitive_field(fields: &mut Vec<CustomField>, name: &str, value: String) {
|
|
if let Some(field) = fields.iter_mut().find(|field| field.name == name) {
|
|
field.value = Value::String(value);
|
|
field.sensitive = true;
|
|
return;
|
|
}
|
|
fields.push(CustomField {
|
|
name: name.to_string(),
|
|
value: Value::String(value),
|
|
sensitive: true,
|
|
});
|
|
}
|
|
|
|
fn remove_custom_field(fields: &mut Vec<CustomField>, name: &str) {
|
|
fields.retain(|field| field.name != name);
|
|
}
|
|
|
|
fn stringify_value(value: &Value) -> String {
|
|
match value {
|
|
Value::Null => String::new(),
|
|
Value::String(text) => text.clone(),
|
|
Value::Bool(value) => value.to_string(),
|
|
Value::Number(value) => value.to_string(),
|
|
other => other.to_string(),
|
|
}
|
|
}
|
|
|
|
fn infer_secret_type(secret_name: &str) -> &'static str {
|
|
match secret_name {
|
|
"password" | "passphrase" => "password",
|
|
"ssh_key" => "key",
|
|
_ => "text",
|
|
}
|
|
}
|
|
|
|
fn mask_secret(value: &str) -> String {
|
|
if value.is_empty() {
|
|
return "未设置".to_string();
|
|
}
|
|
if value.len() <= 4 {
|
|
return "••••".to_string();
|
|
}
|
|
format!("{}••••••", &value[..2])
|
|
}
|
|
|
|
fn split_secret_ref(secret_id: &str) -> Result<(Uuid, String)> {
|
|
let (entry_id, name) = secret_id
|
|
.split_once(':')
|
|
.context("invalid local secret id")?;
|
|
Ok((Uuid::parse_str(entry_id)?, name.to_string()))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use sqlx::sqlite::SqlitePoolOptions;
|
|
use std::time::{SystemTime, UNIX_EPOCH};
|
|
|
|
async fn test_vault() -> LocalVault {
|
|
let pool = SqlitePoolOptions::new()
|
|
.max_connections(1)
|
|
.connect("sqlite::memory:")
|
|
.await
|
|
.expect("open in-memory sqlite");
|
|
migrate_local_vault(&pool)
|
|
.await
|
|
.expect("migrate local vault");
|
|
LocalVault {
|
|
pool,
|
|
unlocked_key: Arc::new(RwLock::new(None)),
|
|
}
|
|
}
|
|
|
|
fn temp_vault_path(name: &str) -> PathBuf {
|
|
let stamp = SystemTime::now()
|
|
.duration_since(UNIX_EPOCH)
|
|
.expect("system time")
|
|
.as_nanos();
|
|
std::env::temp_dir().join(format!("secrets-{name}-{stamp}.sqlite3"))
|
|
}
|
|
|
|
fn field(label: &str, value: &str) -> LocalDetailField {
|
|
LocalDetailField {
|
|
label: label.to_string(),
|
|
value: value.to_string(),
|
|
}
|
|
}
|
|
|
|
fn secret(name: &str, value: &str) -> LocalSecretDraft {
|
|
LocalSecretDraft {
|
|
name: name.to_string(),
|
|
secret_type: None,
|
|
value: value.to_string(),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn infer_cipher_type_prefers_ssh_key() {
|
|
let metadata = vec![
|
|
field("ssh_user", "deploy"),
|
|
field("host", "git.example.com"),
|
|
];
|
|
let secrets = vec![secret("ssh_key", "pem"), secret("password", "passphrase")];
|
|
|
|
let ty = infer_cipher_type("service", &metadata, &secrets);
|
|
|
|
assert_eq!(ty, CipherType::SshKey);
|
|
}
|
|
|
|
#[test]
|
|
fn infer_cipher_type_detects_api_key_shape() {
|
|
let metadata = vec![field("base_url", "https://api.example.com")];
|
|
let secrets = vec![secret("api_key", "ak-123")];
|
|
|
|
let ty = infer_cipher_type("service", &metadata, &secrets);
|
|
|
|
assert_eq!(ty, CipherType::ApiKey);
|
|
}
|
|
|
|
#[test]
|
|
fn infer_cipher_type_detects_login_shape() {
|
|
let metadata = vec![
|
|
field("username", "alice"),
|
|
field("url", "https://mail.example.com"),
|
|
];
|
|
let secrets = vec![secret("password", "pw-123")];
|
|
|
|
let ty = infer_cipher_type("account", &metadata, &secrets);
|
|
|
|
assert_eq!(ty, CipherType::Login);
|
|
}
|
|
|
|
#[test]
|
|
fn infer_cipher_type_defaults_to_secure_note() {
|
|
let metadata = vec![field("notes", "free form note")];
|
|
let secrets = vec![secret("misc", "value")];
|
|
|
|
let ty = infer_cipher_type("misc", &metadata, &secrets);
|
|
|
|
assert_eq!(ty, CipherType::SecureNote);
|
|
}
|
|
|
|
#[test]
|
|
fn payload_from_draft_builds_api_key_fields() {
|
|
let metadata = vec![
|
|
field("base_url", "https://api.example.com"),
|
|
field("host", "api.example.com"),
|
|
field("username", "client-1"),
|
|
];
|
|
let secrets = vec![secret("access_token", "tok-123")];
|
|
|
|
let payload = payload_from_draft(CipherType::ApiKey, &metadata, &secrets);
|
|
|
|
match payload {
|
|
ItemPayload::ApiKey(api_key) => {
|
|
assert_eq!(api_key.client_id.as_deref(), Some("client-1"));
|
|
assert_eq!(api_key.secret.as_deref(), Some("tok-123"));
|
|
assert_eq!(api_key.base_url.as_deref(), Some("https://api.example.com"));
|
|
assert_eq!(api_key.host.as_deref(), Some("api.example.com"));
|
|
}
|
|
other => panic!("expected api key payload, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn setup_master_password_marks_vault_initialized_and_unlocked() {
|
|
let vault = test_vault().await;
|
|
|
|
let before = bootstrap(&vault).await.expect("bootstrap before setup");
|
|
assert!(!before.has_master_password);
|
|
assert!(!before.unlocked);
|
|
|
|
setup_master_password(
|
|
&vault,
|
|
"correct horse battery staple",
|
|
&KdfConfig::default(),
|
|
)
|
|
.await
|
|
.expect("setup master password");
|
|
|
|
let after = bootstrap(&vault).await.expect("bootstrap after setup");
|
|
assert!(after.has_master_password);
|
|
assert!(after.unlocked);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn lock_and_unlock_cycle_requires_correct_password() {
|
|
let vault = test_vault().await;
|
|
let password = "correct horse battery staple";
|
|
|
|
setup_master_password(&vault, password, &KdfConfig::default())
|
|
.await
|
|
.expect("setup master password");
|
|
|
|
lock(&vault);
|
|
let locked = bootstrap(&vault).await.expect("bootstrap after lock");
|
|
assert!(locked.has_master_password);
|
|
assert!(!locked.unlocked);
|
|
|
|
let wrong = unlock(&vault, "wrong-password").await;
|
|
assert!(wrong.is_err());
|
|
|
|
let still_locked = bootstrap(&vault)
|
|
.await
|
|
.expect("bootstrap after wrong unlock");
|
|
assert!(!still_locked.unlocked);
|
|
|
|
unlock(&vault, password)
|
|
.await
|
|
.expect("unlock with correct password");
|
|
let unlocked = bootstrap(&vault)
|
|
.await
|
|
.expect("bootstrap after correct unlock");
|
|
assert!(unlocked.unlocked);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn open_or_create_local_vault_creates_missing_file() {
|
|
let path = temp_vault_path("local-vault-create");
|
|
if path.exists() {
|
|
std::fs::remove_file(&path).expect("remove stale temp vault");
|
|
}
|
|
|
|
let vault = open_or_create_local_vault_at(&path)
|
|
.await
|
|
.expect("create local vault at missing path");
|
|
|
|
assert!(path.exists());
|
|
let state = bootstrap(&vault).await.expect("bootstrap temp vault");
|
|
assert!(!state.has_master_password);
|
|
assert!(!state.unlocked);
|
|
|
|
let _ = std::fs::remove_file(&path);
|
|
}
|
|
}
|