Files
secrets/apps/desktop/src-tauri/src/local_vault.rs
agent 0374899dab
Some checks failed
Secrets v3 CI / 检查 (push) Has been cancelled
feat(v3): migrate workspace to API, Tauri desktop, and v3 crates; remove legacy MCP stack
- 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)
2026-04-14 17:37:12 +08:00

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,
&current_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, &current, 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);
}
}