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>>, } #[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, pub secrets: Vec, 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, pub secrets: Vec, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct LocalSecretDraft { pub name: String, pub secret_type: Option, pub value: String, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct LocalSecretUpdateDraft { pub id: String, pub name: Option, pub secret_type: Option, pub value: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct LocalEntryQuery { pub folder: Option, pub cipher_type: Option, pub query: Option, pub deleted_only: bool, } pub async fn open_or_create_local_vault() -> Result { 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 { 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 { 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 { 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> { 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 { 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 { 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> { 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 = 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 { 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 { 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 { 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 { 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, ) -> Result { 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 = 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 { Ok(VaultObjectChange { change_id: Uuid::parse_str(&row.try_get::("change_id")?)?, object_id: Uuid::parse_str(&row.try_get::("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::>>()?; 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 { 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) -> 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>> { 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::, _>("value"))) } async fn local_server_revision(vault: &LocalVault) -> Result { 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::("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> { 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::, _>("ciphertext")) .map(|ciphertext| decrypt_view(vault, &ciphertext)) .collect() } async fn load_view(vault: &LocalVault, entry_id: &str) -> Result { 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 = row.get("ciphertext"); decrypt_view(vault, &ciphertext) } fn encrypt_view(vault: &LocalVault, view: &CipherView) -> Result> { 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 { 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 { 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::("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 { 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 { 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, Vec) { 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 { 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; } impl CipherViewSecrets for CipherView { fn payload_secret_names(&self) -> Vec { 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 { 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, 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, 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); } }