From f694cd1efa6c124de23bbdb247db1d8c894e5365 Mon Sep 17 00:00:00 2001
From: gajendraxdev
Date: Sun, 10 May 2026 02:35:22 +0530
Subject: [PATCH 1/8] feat(vault-core): add backend vault encryption, storage,
and command foundation
---
.github/workflows/ci.yml | 40 ++
docs/VAULT_AND_SYNC_ARCHITECTURE.md | 311 ++++++++++++
src-tauri/.cargo/config.toml | 2 +
src-tauri/.gitignore | 6 +
src-tauri/Cargo.lock | 125 ++++-
src-tauri/Cargo.toml | 12 +-
src-tauri/build.rs | 107 +++++
src-tauri/src/commands.rs | 296 ++++++++++--
src-tauri/src/lib.rs | 30 +-
src-tauri/src/ssh.rs | 120 +++--
src-tauri/src/types.rs | 49 ++
src-tauri/src/vault/commands.rs | 294 ++++++++++++
src-tauri/src/vault/crypto.rs | 381 +++++++++++++++
src-tauri/src/vault/error.rs | 69 +++
src-tauri/src/vault/migration.rs | 328 +++++++++++++
src-tauri/src/vault/mod.rs | 15 +
src-tauri/src/vault/schema.rs | 18 +
src-tauri/src/vault/store.rs | 704 ++++++++++++++++++++++++++++
src-tauri/src/vault/types.rs | 75 +++
19 files changed, 2902 insertions(+), 80 deletions(-)
create mode 100644 .github/workflows/ci.yml
create mode 100644 docs/VAULT_AND_SYNC_ARCHITECTURE.md
create mode 100644 src-tauri/.cargo/config.toml
create mode 100644 src-tauri/src/vault/commands.rs
create mode 100644 src-tauri/src/vault/crypto.rs
create mode 100644 src-tauri/src/vault/error.rs
create mode 100644 src-tauri/src/vault/migration.rs
create mode 100644 src-tauri/src/vault/mod.rs
create mode 100644 src-tauri/src/vault/schema.rs
create mode 100644 src-tauri/src/vault/store.rs
create mode 100644 src-tauri/src/vault/types.rs
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 00000000..a695842a
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,40 @@
+name: CI
+
+on:
+ pull_request:
+ push:
+ branches:
+ - main
+ - master
+ - develop
+ workflow_dispatch:
+
+jobs:
+ vault-crypto-known-answer:
+ name: Vault crypto known-answer (${{ matrix.platform }})
+ strategy:
+ fail-fast: false
+ matrix:
+ platform: [ubuntu-22.04, windows-latest, macos-latest]
+ runs-on: ${{ matrix.platform }}
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+
+ - name: Install dependencies (Ubuntu)
+ if: matrix.platform == 'ubuntu-22.04'
+ run: |
+ sudo apt-get update
+ sudo apt-get install -y libwebkit2gtk-4.1-dev libappindicator3-dev librsvg2-dev patchelf libssl-dev
+
+ - name: Rust setup
+ uses: dtolnay/rust-toolchain@stable
+
+ - name: Run vault crypto known-answer tests
+ working-directory: src-tauri
+ env:
+ # Required only because sync commands compile with env!("GOOGLE_CLIENT_ID").
+ # The vault crypto known-answer tests do not use Google OAuth.
+ GOOGLE_CLIENT_ID: ci-known-answer-test-client-id
+ run: cargo test known_answer
diff --git a/docs/VAULT_AND_SYNC_ARCHITECTURE.md b/docs/VAULT_AND_SYNC_ARCHITECTURE.md
new file mode 100644
index 00000000..29067ee4
--- /dev/null
+++ b/docs/VAULT_AND_SYNC_ARCHITECTURE.md
@@ -0,0 +1,311 @@
+# VAULT_AND_SYNC_ARCHITECTURE.md
+
+## Status
+- **Owner:** Core app team
+- **Document type:** Architecture + implementation guide
+- **Last updated:** 2026-05-08
+- **Scope:** Vault UX, vault core, provider abstraction, sync engine, and future app-data sync
+
+---
+
+## 1) Problem Statement
+Vault currently exists primarily as a settings tab workflow. This reduces discoverability, limits operational clarity for sync, and makes future provider expansion hard to manage.
+
+We need a modular, robust, scalable, and maintainable architecture that:
+1. Makes vault a first-class product feature.
+2. Preserves local-first, secure secret management.
+3. Supports multiple sync providers without coupling vault core to provider implementations.
+4. Scales from secrets sync to broader app-data domain sync over time.
+
+---
+
+## 2) Goals / Non-Goals
+
+### Goals
+- First-class **Vaults** navigation in primary sidebar.
+- Strong **local vault core** with clear security boundaries.
+- Pluggable provider interface for cloud backends.
+- Per-provider sync profiles (manual + optional autosync).
+- Predictable conflict handling and operator-visible sync state.
+- Forward-compatible design for syncing additional domains (hosts, snippets, tunnels, settings, etc.).
+
+### Non-Goals (phase 1)
+- Cross-provider automatic merge of the same logical item.
+- Fully decentralized multi-writer conflict-free collaboration.
+- Team vault server protocol implementation (deferred).
+
+---
+
+## 3) Guiding Principles
+1. **Security-first core:** cryptography and key material remain in trusted backend modules.
+2. **Local-first UX:** users can always work offline with local vault.
+3. **Least coupling:** vault core must not call provider SDKs directly.
+4. **Stable contracts:** provider interface versioning and capability negotiation.
+5. **Small composable modules:** sync orchestration separate from crypto/store/UI.
+6. **Observability by default:** status, timestamps, errors, and conflict states are surfaced.
+7. **Incremental rollout:** no big-bang rewrite; preserve backward compatibility.
+
+---
+
+## 4) High-Level Architecture
+
+```text
+UI (Sidebar Vaults + Vault tabs + Sync status)
+ -> Vault Application Service (commands / policy / orchestration)
+ -> Vault Core (crypto + store + lock state)
+ -> Sync Engine (state machine + conflict resolver + retries)
+ -> Provider Registry (capability-aware adapters)
+ -> Providers (Google Drive, GitHub, AWS, Custom Plugin)
+```
+
+### Mandatory Boundary
+- Vault Core never imports provider-specific code.
+- Provider adapters never access plaintext keys directly unless explicitly required by domain contract.
+
+---
+
+## 5) Product Information Architecture (IA)
+
+### Sidebar
+- Add top-level section: **Vaults**
+ - Local Vault
+ - Remote Profiles (Google, GitHub, AWS, Custom)
+ - Team Vault (future)
+ - `+ Add Provider`
+
+### Tab Behavior
+- Clicking a vault/profile opens a standard app tab.
+- Each tab includes:
+ - vault state (locked/unlocked)
+ - item list/search
+ - provider status
+ - sync controls (upload, download, autosync toggle)
+ - conflict badge if pending conflicts
+
+### Discoverability rules
+- If vault uninitialized, show global CTA: “Set up Vault”.
+- In credential creation flows, default recommendation: “Store in Vault”.
+
+---
+
+## 6) Domain Model
+
+### Core entities
+- `Vault`
+ - id, type (`local`, `team`), state (`uninitialized|locked|unlocked`)
+- `VaultItem`
+ - id, kind, label, encrypted payload, metadata, revision, timestamps
+- `SyncProfile`
+ - id, vault_id, provider_kind, enabled, autosync_policy, last_sync, health
+- `SyncCursor`
+ - profile_id, domain, remote_version, remote_etag, sync_token, last_applied_clock
+- `Conflict`
+ - id, profile_id, domain, item_id, local_meta, remote_meta, status
+
+### Future domain sync abstraction
+- `SyncDomain` enum:
+ - `secrets`
+ - `hosts`
+ - `snippets`
+ - `tunnels`
+ - `settings`
+ - `known_hosts`
+
+---
+
+## 7) Provider Contract (Plugin-Compatible)
+
+Define a versioned backend interface (Rust trait + IPC shape):
+
+```rust
+trait VaultProviderV1 {
+ fn kind(&self) -> ProviderKind;
+ fn capabilities(&self) -> ProviderCapabilities;
+
+ // connection/auth
+ async fn connect(&self, ctx: ProviderContext) -> Result;
+ async fn disconnect(&self, ctx: ProviderContext) -> Result<()>;
+ async fn health_check(&self, ctx: ProviderContext) -> Result;
+
+ // object ops
+ async fn list(&self, req: ListRequest) -> Result>;
+ async fn read(&self, req: ReadRequest) -> Result;
+ async fn write(&self, req: WriteRequest) -> Result;
+ async fn delete(&self, req: DeleteRequest) -> Result<()>;
+
+ // incremental sync support
+ async fn get_cursor(&self, req: CursorRequest) -> Result;
+}
+```
+
+### Capability flags
+- `supports_autosync`
+- `supports_incremental`
+- `supports_etag`
+- `supports_domains`
+- `max_object_size`
+- `encryption_mode` (`provider_encrypted`, `app_encrypted_only`)
+
+---
+
+## 8) Sync Strategy
+
+## 8.1 Local-first semantics
+- Local store is immediately updated.
+- Sync engine asynchronously reconciles with each enabled profile.
+
+## 8.2 Per-profile state machine
+- `idle -> syncing -> success|conflict|retrying|error`
+
+## 8.3 Conflict policy (phase 1)
+- No cross-provider merge.
+- Conflict resolution is **local vs specific provider**.
+- User choices:
+ - Keep Local
+ - Keep Remote
+ - Duplicate as new item (optional safety path)
+
+## 8.4 Retry policy
+- Exponential backoff with jitter.
+- Bounded retry budget per run.
+- Persist retry reason and last failure code.
+
+---
+
+## 9) Security & Compliance Requirements
+1. Vault encryption remains app-managed (Argon2id + AEAD suite currently used).
+2. Provider tokens stored in vault-backed secure storage where possible.
+3. Secrets never logged in plaintext.
+4. Recovery key lifecycle must include rotate + revoke semantics.
+5. Import/export guarded with validation and backup-before-replace.
+6. Plugin providers run under explicit permission boundaries.
+
+---
+
+## 10) Observability Requirements
+- For every sync profile, expose:
+ - connected identity
+ - last sync timestamp
+ - last status
+ - bytes uploaded/downloaded
+ - conflict count
+ - last error code/message (sanitized)
+- Emit structured backend events for UI updates.
+
+---
+
+## 11) Implementation Plan (Incremental)
+
+### Phase 1 — UX promotion + no-risk refactor
+- Add sidebar `Vaults` section and tabs.
+- Keep existing vault core and google sync logic functional.
+- Add global status badges.
+
+### Phase 2 — Provider abstraction
+- Introduce `VaultProviderV1` interface.
+- Wrap existing Google Drive implementation as first provider adapter.
+- Introduce `SyncProfile` persistence.
+
+### Phase 3 — Robust sync behavior
+- Add state machine, retries, conflict objects, conflict badge center.
+- Add autosync policies (manual, periodic, on-change, on-exit).
+
+### Phase 4 — Multi-provider & future domains
+- Add second provider (e.g., GitHub blob store) to validate abstraction.
+- Add domain-scoped sync (start with `secrets`; expand later).
+
+### Phase 5 — Team vault and advanced policy
+- Remote/team vault model and org policies.
+- Audit chain hardening and policy controls.
+
+---
+
+## 12) Data Migration / Backward Compatibility
+- Existing local vault (`vault.redb`) remains canonical.
+- Existing Google token data migrates into `SyncProfile` + provider credentials store.
+- Old APIs remain available behind compatibility adapter during transition.
+- Feature flags gate new sidebar and provider engine rollout.
+
+---
+
+## 13) Testing Strategy
+
+### Unit
+- provider contract conformance tests
+- sync state transitions
+- conflict resolver behavior
+- retry/backoff timing policy
+
+### Integration
+- local vault <-> provider round trip
+- upload/download/restore with fault injection
+- migration compatibility tests
+
+### E2E
+- first-time setup
+- connect provider
+- autosync on/off
+- conflict detection + resolution flow
+
+### Security tests
+- key zeroization checks where applicable
+- token storage isolation
+- no-secret logging checks
+
+---
+
+## 14) Open Decisions
+1. Single-file remote object vs per-item object layout per provider.
+2. Exact conflict metadata schema (`vector_clock` vs lamport + timestamps).
+3. Plugin trust model and signature/allowlist policy.
+4. Team vault protocol shape and authority model.
+
+---
+
+## 15) Acceptance Criteria for “Architecture Ready”
+- Vault appears as global sidebar feature.
+- Provider abstraction exists and Google adapter uses it.
+- Sync profile lifecycle is persisted and visible in UI.
+- Conflict state surfaced with deterministic resolution flow.
+- Existing users can upgrade without data loss.
+
+---
+
+## 16) Immediate Next Engineering Tasks
+1. Create ADR: `docs/adr/ADR-VAULT-001-global-vault-navigation.md`.
+2. Define `SyncProfile` and `ProviderCapabilities` types in backend + frontend contracts.
+3. Implement Google provider adapter over the new interface.
+4. Add sidebar Vaults navigation and tab routing.
+5. Add sync status widget with last run + error summary.
+
+---
+
+## 17) Team Skill Matrix (for Modular + Scalable Delivery)
+
+To keep implementation manageable and robust, split ownership by skill areas.
+
+### 17.1 Required skill lanes
+- **Security/Crypto lane**
+ - Key lifecycle, passphrase/recovery flows, secret-handling guarantees, zeroization checks.
+- **Backend architecture lane (Rust/Tauri)**
+ - Provider contract, sync state machine, profile persistence, migration adapters.
+- **Frontend UX lane (React/TS)**
+ - Sidebar Vaults IA, tab workflows, conflict center, sync health/status surfaces.
+- **Reliability/QA lane**
+ - Fault injection, retry behavior, migration safety, non-regression coverage.
+- **Docs/ADR lane**
+ - Architecture decisions, compatibility notes, rollout and operational playbooks.
+
+### 17.2 Definition of done per lane
+- Security lane: threat model reviewed, no plaintext secret logs, key handling tests passing.
+- Backend lane: provider abstraction merged, Google flow behind adapter, stable IPC contract.
+- Frontend lane: vault discoverability goals met, conflict and sync status visible.
+- QA lane: unit + integration + E2E happy-path and failure-path coverage for sync lifecycle.
+- Docs lane: ADRs, migration notes, and operator troubleshooting guide updated.
+
+### 17.3 Cross-lane quality gates
+1. No feature merges without explicit conflict-state UX.
+2. No provider merge without conformance tests to `VaultProviderV1`.
+3. No migration merge without rollback/backup validation.
+4. No autosync merge without retry/backoff and bounded-failure semantics.
+5. No public release without upgrade path verification from existing local vault users.
diff --git a/src-tauri/.cargo/config.toml b/src-tauri/.cargo/config.toml
new file mode 100644
index 00000000..06616718
--- /dev/null
+++ b/src-tauri/.cargo/config.toml
@@ -0,0 +1,2 @@
+[env]
+# GOOGLE_CLIENT_ID must be provided by local environment or CI/release pipeline.
diff --git a/src-tauri/.gitignore b/src-tauri/.gitignore
index b21bd681..1bcd3051 100644
--- a/src-tauri/.gitignore
+++ b/src-tauri/.gitignore
@@ -5,3 +5,9 @@
# Generated by Tauri
# will have schema files for capabilities auto-completion
/gen/schemas
+
+# Local secrets — never commit
+.env
+.env.local
+.env.*
+.env.*.local
diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock
index 807d2ea1..7f190685 100644
--- a/src-tauri/Cargo.lock
+++ b/src-tauri/Cargo.lock
@@ -125,6 +125,19 @@ dependencies = [
"x11rb",
]
+[[package]]
+name = "argon2"
+version = "0.5.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072"
+dependencies = [
+ "base64ct",
+ "blake2",
+ "cpufeatures",
+ "password-hash",
+ "zeroize",
+]
+
[[package]]
name = "async-broadcast"
version = "0.7.2"
@@ -341,6 +354,15 @@ dependencies = [
"serde_core",
]
+[[package]]
+name = "blake2"
+version = "0.10.6"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
+dependencies = [
+ "digest",
+]
+
[[package]]
name = "block-buffer"
version = "0.10.4"
@@ -596,6 +618,19 @@ dependencies = [
"cpufeatures",
]
+[[package]]
+name = "chacha20poly1305"
+version = "0.10.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35"
+dependencies = [
+ "aead",
+ "chacha20",
+ "cipher",
+ "poly1305",
+ "zeroize",
+]
+
[[package]]
name = "chrono"
version = "0.4.43"
@@ -618,6 +653,7 @@ checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad"
dependencies = [
"crypto-common",
"inout",
+ "zeroize",
]
[[package]]
@@ -2551,6 +2587,20 @@ dependencies = [
"unicode-segmentation",
]
+[[package]]
+name = "keyring"
+version = "3.6.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c"
+dependencies = [
+ "byteorder",
+ "log",
+ "security-framework 2.11.1",
+ "security-framework 3.6.0",
+ "windows-sys 0.60.2",
+ "zeroize",
+]
+
[[package]]
name = "kuchikiki"
version = "0.8.8-speedreader"
@@ -2750,6 +2800,16 @@ version = "0.3.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
+[[package]]
+name = "mime_guess"
+version = "2.0.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e"
+dependencies = [
+ "mime",
+ "unicase",
+]
+
[[package]]
name = "minisign-verify"
version = "0.2.4"
@@ -2820,7 +2880,7 @@ dependencies = [
"openssl-probe",
"openssl-sys",
"schannel",
- "security-framework",
+ "security-framework 3.6.0",
"security-framework-sys",
"tempfile",
]
@@ -3424,6 +3484,17 @@ dependencies = [
"windows-link 0.2.1",
]
+[[package]]
+name = "password-hash"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166"
+dependencies = [
+ "base64ct",
+ "rand_core 0.6.4",
+ "subtle",
+]
+
[[package]]
name = "pathdiff"
version = "0.2.3"
@@ -3997,6 +4068,15 @@ version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539"
+[[package]]
+name = "redb"
+version = "2.6.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "8eca1e9d98d5a7e9002d0013e18d5a9b000aee942eb134883a82f06ebffb6c01"
+dependencies = [
+ "libc",
+]
+
[[package]]
name = "redox_syscall"
version = "0.5.18"
@@ -4108,6 +4188,7 @@ dependencies = [
"js-sys",
"log",
"mime",
+ "mime_guess",
"native-tls",
"percent-encoding",
"pin-project-lite",
@@ -4420,7 +4501,7 @@ dependencies = [
"openssl-probe",
"rustls-pki-types",
"schannel",
- "security-framework",
+ "security-framework 3.6.0",
]
[[package]]
@@ -4447,7 +4528,7 @@ dependencies = [
"rustls-native-certs",
"rustls-platform-verifier-android",
"rustls-webpki",
- "security-framework",
+ "security-framework 3.6.0",
"security-framework-sys",
"webpki-root-certs",
"windows-sys 0.61.2",
@@ -4591,6 +4672,29 @@ dependencies = [
"zeroize",
]
+[[package]]
+name = "secrecy"
+version = "0.10.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e891af845473308773346dc847b2c23ee78fe442e0472ac50e22a18a93d3ae5a"
+dependencies = [
+ "serde",
+ "zeroize",
+]
+
+[[package]]
+name = "security-framework"
+version = "2.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02"
+dependencies = [
+ "bitflags 2.10.0",
+ "core-foundation 0.9.4",
+ "core-foundation-sys",
+ "libc",
+ "security-framework-sys",
+]
+
[[package]]
name = "security-framework"
version = "3.6.0"
@@ -6122,6 +6226,12 @@ dependencies = [
"unic-common",
]
+[[package]]
+name = "unicase"
+version = "2.9.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
+
[[package]]
name = "unicode-ident"
version = "1.0.22"
@@ -7634,18 +7744,26 @@ name = "zync"
version = "2.15.1"
dependencies = [
"anyhow",
+ "argon2",
"async-trait",
"base64 0.21.7",
+ "chacha20poly1305",
"dirs 5.0.1",
+ "hkdf",
+ "keyring",
"log",
"portable-pty",
+ "rand_core 0.6.4",
+ "redb",
"regex",
"reqwest 0.12.28",
"russh",
"russh-keys",
"russh-sftp",
+ "secrecy",
"serde",
"serde_json",
+ "sha2",
"tauri",
"tauri-build",
"tauri-plugin-clipboard-manager",
@@ -7658,5 +7776,6 @@ dependencies = [
"uuid",
"whoami",
"winreg 0.56.0",
+ "zeroize",
"zip 2.4.2",
]
diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml
index 7dbbc180..f86b38ef 100644
--- a/src-tauri/Cargo.toml
+++ b/src-tauri/Cargo.toml
@@ -39,9 +39,19 @@ base64 = "0.21"
url = "2.5"
regex = "1.12.3"
tauri-plugin-clipboard-manager = "2.3.2"
-reqwest = { version = "0.12", features = ["json", "stream"] }
+reqwest = { version = "0.12", features = ["json", "stream", "multipart"] }
zip = "2.2"
log = "0.4"
+# Vault crypto (Phase 0)
+argon2 = { version = "0.5", features = ["zeroize"] }
+chacha20poly1305 = "0.10"
+hkdf = "0.12"
+sha2 = "0.10"
+zeroize = { version = "1.8", features = ["derive"] }
+secrecy = { version = "0.10", features = ["serde"] }
+rand_core = { version = "0.6", features = ["getrandom"] }
+redb = "2"
+keyring = { version = "3", features = ["apple-native", "windows-native"] }
[target.'cfg(target_os = "windows")'.dependencies]
winreg = "0.56"
diff --git a/src-tauri/build.rs b/src-tauri/build.rs
index d860e1e6..64aa9025 100644
--- a/src-tauri/build.rs
+++ b/src-tauri/build.rs
@@ -1,3 +1,110 @@
fn main() {
+ println!("cargo:rerun-if-changed=.env");
+ let mut file_google_client_id: Option = None;
+ if let Ok(contents) = std::fs::read_to_string(".env") {
+ for line in contents.lines() {
+ let mut line = line.trim();
+ if line.is_empty() || line.starts_with('#') {
+ continue;
+ }
+ if let Some(stripped) = line.strip_prefix("export ") {
+ line = stripped.trim_start();
+ }
+ if let Some((key, value)) = line.split_once('=') {
+ let key = key.trim();
+ if is_sensitive_env_key(key) {
+ continue;
+ }
+ let cleaned_value = clean_env_value(value.trim());
+ if key.eq_ignore_ascii_case("GOOGLE_CLIENT_ID") {
+ file_google_client_id = Some(cleaned_value.clone());
+ }
+ emit_rustc_env(key, &cleaned_value);
+ }
+ }
+ }
+ if std::env::var("PROFILE").ok().as_deref() == Some("release") {
+ let env_google_client_id = std::env::var("GOOGLE_CLIENT_ID").ok();
+ let has_valid_client_id = file_google_client_id
+ .as_deref()
+ .or(env_google_client_id.as_deref())
+ .map(is_valid_google_client_id)
+ .unwrap_or(false);
+ if !has_valid_client_id {
+ panic!("GOOGLE_CLIENT_ID is missing or placeholder. Set a real client ID for release builds.");
+ }
+ }
tauri_build::build()
}
+
+fn is_sensitive_env_key(key: &str) -> bool {
+ let upper = key.to_ascii_uppercase();
+ upper == "SECRET"
+ || upper.ends_with("_SECRET")
+ || upper == "PASSWORD"
+ || upper.ends_with("_PASSWORD")
+ || upper == "TOKEN"
+ || upper.ends_with("_TOKEN")
+ || upper == "API_KEY"
+ || upper.ends_with("_API_KEY")
+ || upper == "CREDENTIAL"
+ || upper == "CREDENTIALS"
+ || upper.ends_with("_CREDENTIAL")
+ || upper.ends_with("_CREDENTIALS")
+ || upper.contains("PRIVATE_KEY")
+ || upper.ends_with("_PRIVATE")
+ || upper.contains("AUTH_TOKEN")
+ || upper.contains("AUTH_KEY")
+}
+
+fn emit_rustc_env(key: &str, value: &str) {
+ let single_line = value
+ .replace('\r', "\\r")
+ .replace('\n', "\\n");
+ println!("cargo:rustc-env={}={}", key, single_line);
+}
+
+fn is_valid_google_client_id(value: &str) -> bool {
+ let trimmed = value.trim();
+ !trimmed.is_empty() && trimmed != "PLACEHOLDER_CLIENT_ID"
+}
+
+fn clean_env_value(value: &str) -> String {
+ let unquoted = if value.len() >= 2
+ && ((value.starts_with('"') && value.ends_with('"'))
+ || (value.starts_with('\'') && value.ends_with('\'')))
+ {
+ &value[1..value.len() - 1]
+ } else {
+ value
+ };
+
+ decode_escapes(unquoted)
+}
+
+fn decode_escapes(input: &str) -> String {
+ let mut out = String::with_capacity(input.len());
+ let mut chars = input.chars();
+
+ while let Some(ch) = chars.next() {
+ if ch != '\\' {
+ out.push(ch);
+ continue;
+ }
+ match chars.next() {
+ Some('n') => out.push('\n'),
+ Some('r') => out.push('\r'),
+ Some('t') => out.push('\t'),
+ Some('"') => out.push('"'),
+ Some('\'') => out.push('\''),
+ Some('\\') => out.push('\\'),
+ Some(other) => {
+ out.push('\\');
+ out.push(other);
+ }
+ None => out.push('\\'),
+ }
+ }
+
+ out
+}
diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs
index a3f28be2..b2cce5b8 100644
--- a/src-tauri/src/commands.rs
+++ b/src-tauri/src/commands.rs
@@ -354,7 +354,8 @@ pub fn get_data_dir(app: &AppHandle) -> std::path::PathBuf {
let merged_settings =
read_effective_settings(app).unwrap_or_else(|_| Value::Object(serde_json::Map::new()));
- let resolved = if let Some(data_path) = merged_settings.get("dataPath").and_then(|v| v.as_str()) {
+ let resolved = if let Some(data_path) = merged_settings.get("dataPath").and_then(|v| v.as_str())
+ {
if !data_path.is_empty() {
let custom_dir = std::path::PathBuf::from(data_path);
if !custom_dir.exists() {
@@ -457,6 +458,7 @@ pub struct ConnectionHandle {
pub sftp_session: Option>,
pub detected_os: Option,
pub detected_shell: Option,
+ pub uses_vault_auth: bool,
}
/// Internal helper: establishes a full SSH connection (session + SFTP + OS detection)
@@ -592,24 +594,92 @@ async fn reconnect_connection(
sftp_session,
detected_os,
detected_shell,
+ uses_vault_auth: config_uses_vault_auth(config),
+ })
+}
+
+/// Recursively resolves every `VaultRef` auth method in `config` (and jump hosts)
+/// to a concrete `Password` or `PrivateKeyData` using the vault service.
+/// Must be called before any SSH connect/test operation.
+/// Secret for ssh-private-key items may be plain PEM or JSON {"key":"...","passphrase":"..."}.
+fn parse_key_secret(secret: &str) -> (String, Option) {
+ if let Ok(val) = serde_json::from_str::(secret) {
+ if let Some(key) = val["key"].as_str() {
+ let passphrase = val["passphrase"].as_str().map(|s| s.to_string());
+ return (key.to_string(), passphrase);
+ }
+ }
+ (secret.to_string(), None)
+}
+
+fn config_uses_vault_auth(config: &ConnectionConfig) -> bool {
+ matches!(config.auth_method, crate::types::AuthMethod::VaultRef { .. })
+ || config
+ .jump_host
+ .as_ref()
+ .map(|jump| config_uses_vault_auth(jump.as_ref()))
+ .unwrap_or(false)
+}
+
+fn resolve_vault_refs<'a>(
+ config: &'a mut ConnectionConfig,
+ vault: &'a tokio::sync::Mutex,
+) -> std::pin::Pin> + Send + 'a>> {
+ Box::pin(async move {
+ if let crate::types::AuthMethod::VaultRef { item_id } = &config.auth_method {
+ let item_id = item_id.clone();
+ let svc = vault.lock().await;
+ let record = svc.item_get(&item_id).map_err(|e| e.to_string())?;
+ drop(svc);
+ config.auth_method = match record.kind.as_str() {
+ "ssh-password" => crate::types::AuthMethod::Password {
+ password: record.secret.clone(),
+ },
+ "ssh-private-key" => {
+ let (key_data, passphrase) = parse_key_secret(&record.secret);
+ crate::types::AuthMethod::PrivateKeyData {
+ key_data,
+ passphrase,
+ }
+ }
+ k => {
+ return Err(format!(
+ "Vault item kind '{k}' is not supported for SSH auth"
+ ))
+ }
+ };
+ }
+ if let Some(jump) = config.jump_host.as_mut() {
+ resolve_vault_refs(jump.as_mut(), vault).await?;
+ }
+ Ok(())
})
}
#[tauri::command]
pub async fn ssh_connect(
- config: ConnectionConfig,
+ mut config: ConnectionConfig,
state: State<'_, AppState>,
+ vault: State<'_, tokio::sync::Mutex>,
) -> Result {
+ let original_config = config.clone();
+ let uses_vault_auth = config_uses_vault_auth(&original_config);
+ resolve_vault_refs(&mut config, &vault).await?;
match reconnect_connection(&config, &state.ssh_manager, &state.tunnel_manager).await {
- Ok(handle) => {
+ Ok(mut handle) => {
let detected_os = handle.detected_os.clone();
+ // Do not keep decrypted vault secrets in the long-lived handle config.
+ // The handle keeps the original VaultRef config so future reconnects
+ // require the vault to be explicitly unlocked again.
+ handle.config = original_config.clone();
+ handle.uses_vault_auth = uses_vault_auth;
let mut connections = state.connections.lock().await;
- connections.insert(config.id.clone(), handle);
+ connections.insert(original_config.id.clone(), handle);
Ok(ConnectionResponse {
success: true,
message: "Connected".to_string(),
- term_id: Some(config.id.clone()),
+ term_id: Some(original_config.id.clone()),
detected_os,
})
}
@@ -622,9 +692,11 @@ pub async fn ssh_connect(
#[tauri::command]
pub async fn ssh_test_connection(
- config: ConnectionConfig,
+ mut config: ConnectionConfig,
state: State<'_, AppState>,
+ vault: State<'_, tokio::sync::Mutex>,
) -> Result {
+ resolve_vault_refs(&mut config, &vault).await?;
match state
.ssh_manager
.connect(config.clone(), Arc::new((*state.tunnel_manager).clone()))
@@ -904,6 +976,99 @@ pub async fn ssh_disconnect(id: String, state: State<'_, AppState>) -> Result<()
Ok(())
}
+#[tauri::command]
+pub async fn ssh_disconnect_vault_backed(
+ app: AppHandle,
+ state: State<'_, AppState>,
+) -> Result, String> {
+ let ids = {
+ let connections = state.connections.lock().await;
+ connections
+ .iter()
+ .filter_map(|(id, handle)| handle.uses_vault_auth.then(|| id.clone()))
+ .collect::>()
+ };
+
+ for id in &ids {
+ state
+ .pty_manager
+ .close_by_connection(id)
+ .await
+ .map_err(|e| e.to_string())?;
+ }
+
+ stop_tunnels_for_connections(&app, &state, &ids).await?;
+
+ let mut connections = state.connections.lock().await;
+ for id in &ids {
+ connections.remove(id);
+ }
+
+ Ok(ids)
+}
+
+async fn stop_tunnels_for_connections(
+ app: &AppHandle,
+ state: &AppState,
+ connection_ids: &[String],
+) -> Result<(), String> {
+ if connection_ids.is_empty() {
+ return Ok(());
+ }
+
+ let data_dir = get_data_dir(app);
+ let file_path = data_dir.join("tunnels.json");
+ if !file_path.exists() {
+ return Ok(());
+ }
+
+ let data = std::fs::read_to_string(file_path).map_err(|e| e.to_string())?;
+ let saved_data: SavedTunnelsData = serde_json::from_str(&data).map_err(|e| e.to_string())?;
+ let connection_id_set: HashSet<&str> = connection_ids.iter().map(String::as_str).collect();
+ let tunnels = saved_data
+ .tunnels
+ .into_iter()
+ .filter(|t| connection_id_set.contains(t.connection_id.as_str()))
+ .collect::>();
+
+ for tunnel in tunnels {
+ let internal_id = tunnel_internal_id(&tunnel);
+ let session = {
+ let connections = state.connections.lock().await;
+ connections
+ .get(&tunnel.connection_id)
+ .and_then(|c| c.session.clone())
+ };
+ let result = state
+ .tunnel_manager
+ .stop_tunnel(session, internal_id, tunnel.bind_address.clone())
+ .await;
+
+ let (status, error) = match result {
+ Ok(()) => ("stopped".to_string(), None),
+ Err(error) => ("error".to_string(), Some(error.to_string())),
+ };
+ let _ = app.emit(
+ "tunnel:status-change",
+ TunnelStatusChange {
+ id: tunnel.id,
+ status,
+ error,
+ },
+ );
+ }
+
+ Ok(())
+}
+
+fn tunnel_internal_id(tunnel: &SavedTunnel) -> String {
+ if tunnel.tunnel_type == "local" {
+ format!("local:{}:{}", tunnel.local_port, tunnel.remote_port)
+ } else {
+ format!("remote:{}:{}", tunnel.remote_port, tunnel.local_port)
+ }
+}
+
#[tauri::command]
pub async fn terminal_write(
term_id: String,
@@ -1281,6 +1446,7 @@ fn parse_csv_connections(content: &str) -> Result, String>
} else {
Some(pinned_features)
},
+ auth_ref: None,
});
}
@@ -1553,6 +1719,12 @@ async fn get_live_ssh_session(
.map(|c| c.config.clone())
.ok_or_else(|| format!("Connection config for {} not found", connection_id))?
};
+ if config_uses_vault_auth(&config) {
+ return Err(
+ "Vault-backed connection needs an unlocked vault. Unlock Vault, then reconnect."
+ .to_string(),
+ );
+ }
let mut new_handle =
reconnect_connection(&config, &state.ssh_manager, &state.tunnel_manager).await?;
let new_session = new_handle
@@ -1631,6 +1803,13 @@ async fn get_sftp_or_reconnect(
id
);
+ if config_uses_vault_auth(&config) {
+ return Err(
+ "DISCONNECTED: Vault-backed connection needs an unlocked vault. Unlock Vault, then reconnect."
+ .to_string(),
+ );
+ }
+
let timeout_duration = std::time::Duration::from_secs(12);
let new_handle = match tokio::time::timeout(
timeout_duration,
@@ -2567,7 +2746,10 @@ pub async fn fs_copy(
println!("[FS] Server-side copy failed (non-zero exit), checking SFTP fallback...");
}
Ok(Err(e)) => {
- println!("[FS] Server-side copy failed (error), checking SFTP fallback: {}", e);
+ println!(
+ "[FS] Server-side copy failed (error), checking SFTP fallback: {}",
+ e
+ );
}
Err(_) => {
println!("[FS] Server-side copy optimization timed out, checking SFTP fallback...");
@@ -3677,9 +3859,7 @@ pub async fn settings_write_raw(
} else {
None
};
- let current_data_path = current_raw
- .as_deref()
- .and_then(data_path_from_raw_json);
+ let current_data_path = current_raw.as_deref().and_then(data_path_from_raw_json);
let actual = settings_mtime_ms(&settings_path);
if actual != expected_modified_ms {
@@ -3721,9 +3901,7 @@ pub async fn settings_restore_last_known_good(
} else {
None
};
- let current_data_path = current_raw
- .as_deref()
- .and_then(data_path_from_raw_json);
+ let current_data_path = current_raw.as_deref().and_then(data_path_from_raw_json);
let backup_path = get_last_known_good_settings_path(&app)?;
if !backup_path.exists() {
return Err("No last-known-good settings backup found.".to_string());
@@ -4642,7 +4820,9 @@ pub enum ShellIconData {
}
fn bundled(name: &'static str) -> Option {
- Some(ShellIconData::Bundled { name: name.to_string() })
+ Some(ShellIconData::Bundled {
+ name: name.to_string(),
+ })
}
fn wsl_bundled_icon(_distro: &str) -> Option {
@@ -4775,11 +4955,18 @@ async fn query_remote_windows_shells(
#[cfg(not(target_os = "windows"))]
fn linux_icon(path: &str) -> Option {
- let name = if path.contains("bash") { "bash.png" }
- else if path.contains("zsh") { "zsh.svg" }
- else if path.contains("fish") { "fish.png" }
- else { "terminal.png" };
- Some(ShellIconData::Bundled { name: name.to_string() })
+ let name = if path.contains("bash") {
+ "bash.png"
+ } else if path.contains("zsh") {
+ "zsh.svg"
+ } else if path.contains("fish") {
+ "fish.png"
+ } else {
+ "terminal.png"
+ };
+ Some(ShellIconData::Bundled {
+ name: name.to_string(),
+ })
}
#[derive(serde::Serialize, Clone)]
@@ -4790,14 +4977,20 @@ pub struct DetectedShell {
}
#[tauri::command]
-pub async fn shell_get_windows_shells(state: tauri::State<'_, AppState>) -> Result, String> {
+pub async fn shell_get_windows_shells(
+ state: tauri::State<'_, AppState>,
+) -> Result, String> {
#[cfg(target_os = "windows")]
{
use tokio::process::Command;
let mut shells = Vec::new();
// Windows PowerShell — always present on Win10+
- shells.push(DetectedShell { id: "powershell".into(), label: "Windows PowerShell".into(), icon: bundled("powershell.svg") });
+ shells.push(DetectedShell {
+ id: "powershell".into(),
+ label: "Windows PowerShell".into(),
+ icon: bundled("powershell.svg"),
+ });
// PowerShell 7 (pwsh) — optional install
let pwsh_paths = [
@@ -4805,19 +4998,34 @@ pub async fn shell_get_windows_shells(state: tauri::State<'_, AppState>) -> Resu
"C:\\Program Files\\PowerShell\\pwsh.exe",
];
if pwsh_paths.iter().any(|p| std::path::Path::new(p).exists()) {
- shells.push(DetectedShell { id: "pwsh".into(), label: "PowerShell".into(), icon: bundled("pwsh.svg") });
+ shells.push(DetectedShell {
+ id: "pwsh".into(),
+ label: "PowerShell".into(),
+ icon: bundled("pwsh.svg"),
+ });
}
// Command Prompt — always present
- shells.push(DetectedShell { id: "cmd".into(), label: "Command Prompt".into(), icon: bundled("cmd.png") });
+ shells.push(DetectedShell {
+ id: "cmd".into(),
+ label: "Command Prompt".into(),
+ icon: bundled("cmd.png"),
+ });
// Git Bash — check common install paths
let git_bash_paths = [
"C:\\Program Files\\Git\\bin\\bash.exe",
"C:\\Program Files (x86)\\Git\\bin\\bash.exe",
];
- if git_bash_paths.iter().any(|p| std::path::Path::new(p).exists()) {
- shells.push(DetectedShell { id: "gitbash".into(), label: "Git Bash".into(), icon: bundled("gitbash.svg") });
+ if git_bash_paths
+ .iter()
+ .any(|p| std::path::Path::new(p).exists())
+ {
+ shells.push(DetectedShell {
+ id: "gitbash".into(),
+ label: "Git Bash".into(),
+ icon: bundled("gitbash.svg"),
+ });
}
// WSL distros — reuse the same UTF-16 decode as shell_get_wsl_distros
@@ -4831,8 +5039,11 @@ pub async fn shell_get_windows_shells(state: tauri::State<'_, AppState>) -> Resu
i += 2;
}
let mut decoded = String::from_utf16_lossy(&words);
- if decoded.starts_with('\u{feff}') { decoded.remove(0); }
- let distros: Vec = decoded.lines()
+ if decoded.starts_with('\u{feff}') {
+ decoded.remove(0);
+ }
+ let distros: Vec = decoded
+ .lines()
.map(|l| l.trim().to_string())
.filter(|l| !l.is_empty() && !l.to_lowercase().starts_with("docker-"))
.collect();
@@ -4856,15 +5067,23 @@ pub async fn shell_get_windows_shells(state: tauri::State<'_, AppState>) -> Resu
.and_then(|v| v.clone())
.map(|tagged| {
if let Some(data) = tagged.strip_prefix("png:") {
- ShellIconData::Base64Png { data: data.to_string() }
+ ShellIconData::Base64Png {
+ data: data.to_string(),
+ }
} else if let Some(data) = tagged.strip_prefix("ico:") {
- ShellIconData::Base64Icon { data: data.to_string() }
+ ShellIconData::Base64Icon {
+ data: data.to_string(),
+ }
} else {
ShellIconData::Base64Png { data: tagged }
}
})
.or_else(|| wsl_bundled_icon(&distro));
- shells.push(DetectedShell { id: format!("wsl:{}", distro), label: distro, icon });
+ shells.push(DetectedShell {
+ id: format!("wsl:{}", distro),
+ label: distro,
+ icon,
+ });
}
}
}
@@ -5006,14 +5225,21 @@ pub async fn shell_get_connection_shells(
};
if let Err(err) = query_result {
- eprintln!("[Shells] Remote query FAILED for '{}': {}", connection_id, err);
+ eprintln!(
+ "[Shells] Remote query FAILED for '{}': {}",
+ connection_id, err
+ );
// Return Err — not Ok([]) — so the frontend keeps any cached shells
// visible and exposes an explicit reload affordance instead of caching
// a sticky empty result during connection-startup races.
return Err(err);
}
if !stderr.trim().is_empty() {
- eprintln!("[Shells] Remote stderr for '{}': {}", connection_id, stderr.trim());
+ eprintln!(
+ "[Shells] Remote stderr for '{}': {}",
+ connection_id,
+ stderr.trim()
+ );
}
let mut seen = HashSet::new();
@@ -5194,10 +5420,8 @@ pub async fn plugin_window_create(
std::fs::create_dir_all(&cache_dir)
.map_err(|e| format!("Failed to create plugin cache dir: {}", e))?;
}
- let file_path = cache_dir.join(format!(
- "zync-plugin-window-{}.html",
- uuid::Uuid::new_v4()
- ));
+ let file_path =
+ cache_dir.join(format!("zync-plugin-window-{}.html", uuid::Uuid::new_v4()));
std::fs::write(&file_path, h)
.map_err(|e| format!("Failed to write temporary plugin HTML file: {}", e))?;
temp_html_path = Some(file_path.clone());
diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs
index 4286575e..69ee9223 100644
--- a/src-tauri/src/lib.rs
+++ b/src-tauri/src/lib.rs
@@ -1,18 +1,20 @@
mod ai;
mod commands;
-mod shell_icons;
mod fs;
mod ghost;
pub mod plugins;
mod pty;
mod session;
+mod shell_icons;
mod snippets;
mod ssh;
mod ssh_config;
mod ssh_parser;
+mod sync;
pub mod tunnel;
mod types;
mod utils;
+mod vault;
use commands::AppState;
use tauri::{Emitter, Manager};
@@ -36,8 +38,11 @@ pub fn run() {
let app_handle = app.handle().clone();
let data_dir = commands::get_data_dir(&app_handle);
- let app_state = AppState::new(data_dir);
+ let app_state = AppState::new(data_dir.clone());
app.manage(app_state);
+ app.manage(tokio::sync::Mutex::new(vault::store::VaultService::new(
+ data_dir,
+ )));
commands::cleanup_stale_plugin_window_temp_files(&app_handle);
Ok(())
})
@@ -98,6 +103,7 @@ pub fn run() {
commands::ssh_extract_pem,
commands::ssh_migrate_all_keys,
commands::ssh_disconnect,
+ commands::ssh_disconnect_vault_backed,
commands::terminal_write,
commands::terminal_navigate,
commands::terminal_resize,
@@ -193,6 +199,26 @@ pub fn run() {
ghost::commands::ghost_candidates,
session::session_load,
session::session_save,
+ vault::commands::vault_status,
+ vault::commands::vault_initialize,
+ vault::commands::vault_unlock,
+ vault::commands::vault_lock,
+ vault::commands::vault_item_create,
+ vault::commands::vault_item_list,
+ vault::commands::vault_item_get,
+ vault::commands::vault_item_delete,
+ vault::commands::vault_migration_preview,
+ vault::commands::vault_migrate_existing_secrets,
+ vault::commands::vault_generate_recovery_key,
+ vault::commands::vault_has_recovery_key,
+ vault::commands::vault_unlock_with_recovery_key,
+ vault::commands::vault_export,
+ vault::commands::vault_import,
+ sync::commands::sync_status,
+ sync::commands::sync_connect,
+ sync::commands::sync_disconnect,
+ sync::commands::sync_upload,
+ sync::commands::sync_download,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
diff --git a/src-tauri/src/ssh.rs b/src-tauri/src/ssh.rs
index f63c17b1..cea795c7 100644
--- a/src-tauri/src/ssh.rs
+++ b/src-tauri/src/ssh.rs
@@ -190,16 +190,17 @@ fn handle_agent_request(
11 => {
// SSH_AGENTC_REQUEST_IDENTITIES
// Response: SSH_AGENT_IDENTITIES_ANSWER (12) + u32 count + (string blob + string comment) * count
- let keys = keys_mutex.lock().unwrap();
-
+ let keys = match keys_mutex.lock() {
+ Ok(guard) => guard,
+ Err(poisoned) => poisoned.into_inner(),
+ };
+
fn is_ed25519_blob(blob: &[u8]) -> bool {
if blob.len() < 15 {
return false;
}
// Read first 4 bytes as big-endian u32 length
- let length = u32::from_be_bytes([
- blob[0], blob[1], blob[2], blob[3]
- ]);
+ let length = u32::from_be_bytes([blob[0], blob[1], blob[2], blob[3]]);
// "ssh-ed25519" has length 11
if length != 11 || blob.len() < 15 {
return false;
@@ -207,11 +208,11 @@ fn handle_agent_request(
// Check if next bytes match "ssh-ed25519"
&blob[4..15] == b"ssh-ed25519"
}
-
+
// Single-pass optimization: reserve space for count, then iterate once
let mut buf = vec![12];
buf.extend_from_slice(&0u32.to_be_bytes()); // Placeholder for count
-
+
let mut count = 0u32;
for k in keys.iter() {
let blob = k.public_key_bytes();
@@ -234,7 +235,10 @@ fn handle_agent_request(
read_string(&mut cursor),
read_u32(&mut cursor),
) {
- let keys = keys_mutex.lock().unwrap();
+ let keys = match keys_mutex.lock() {
+ Ok(guard) => guard,
+ Err(poisoned) => poisoned.into_inner(),
+ };
for k in keys.iter() {
let blob = k.public_key_bytes();
if blob == req_blob {
@@ -364,42 +368,53 @@ impl SshManager {
session: &mut client::Handle,
config: &ConnectionConfig,
) -> Result<()> {
- let (pwd, pk, passphrase) = match &config.auth_method {
- AuthMethod::Password { password } => (Some(password.clone()), None, None),
+ let auth_res = match &config.auth_method {
+ AuthMethod::Password { password } => {
+ session
+ .authenticate_password(&config.username, password.clone())
+ .await?
+ }
AuthMethod::PrivateKey {
key_path,
passphrase,
- } => (None, Some(key_path.clone()), passphrase.clone()),
- };
-
- let auth_res = if let Some(pk_path) = pk {
- let mut expanded_path = pk_path.clone();
- if expanded_path.starts_with("~") {
- if let Some(home) = dirs::home_dir() {
- expanded_path = expanded_path.replacen("~", &home.to_string_lossy(), 1);
+ } => {
+ let mut expanded = key_path.clone();
+ if expanded.starts_with("~") {
+ if let Some(home) = dirs::home_dir() {
+ expanded = expanded.replacen("~", &home.to_string_lossy(), 1);
+ }
}
+ let key_data = tokio::fs::read_to_string(&expanded)
+ .await
+ .map_err(|e| anyhow!("Failed to read private key file: {}", e))?;
+ Self::auth_with_key_data(
+ session,
+ &config.username,
+ &key_data,
+ passphrase.as_deref(),
+ &self.agent_keys,
+ )
+ .await?
}
- let key_data = std::fs::read_to_string(&expanded_path)
- .map_err(|e| anyhow!("Failed to read private key file: {}", e))?;
-
- // Decode key with optional passphrase
- let privkey = russh_keys::decode_secret_key(&key_data, passphrase.as_deref())
- .map_err(|e| anyhow!("Failed to decode private key: {}", e))?;
- let privkey = Arc::new(privkey);
-
- // Note: In russh 0.46, KeyPair implements Authenticate
- let auth_success = session.authenticate_publickey(&config.username, privkey.clone()).await?;
-
- if auth_success {
- // Add the underlying key to Global Virtual Agent only on SUCCESS
- let mut keys = self.agent_keys.lock().unwrap();
- keys.push((*privkey).clone());
+ AuthMethod::PrivateKeyData {
+ key_data,
+ passphrase,
+ } => {
+ Self::auth_with_key_data(
+ session,
+ &config.username,
+ key_data,
+ passphrase.as_deref(),
+ &self.agent_keys,
+ )
+ .await?
+ }
+ AuthMethod::VaultRef { item_id } => {
+ return Err(anyhow!(
+ "VaultRef({}) was not resolved before authentication — call resolve_vault_refs first",
+ item_id
+ ));
}
- auth_success
- } else if let Some(pwd) = pwd {
- session.authenticate_password(&config.username, pwd).await?
- } else {
- false
};
if !auth_res {
@@ -407,4 +422,33 @@ impl SshManager {
}
Ok(())
}
+
+ async fn auth_with_key_data(
+ session: &mut client::Handle,
+ username: &str,
+ key_data: &str,
+ passphrase: Option<&str>,
+ agent_keys: &std::sync::Mutex>,
+ ) -> Result {
+ let privkey = russh_keys::decode_secret_key(key_data, passphrase)
+ .map_err(|e| anyhow!("Failed to decode private key: {}", e))?;
+ let privkey = Arc::new(privkey);
+ let auth_success = session
+ .authenticate_publickey(username, privkey.clone())
+ .await?;
+ if auth_success {
+ let mut keys = match agent_keys.lock() {
+ Ok(keys) => keys,
+ Err(poisoned) => poisoned.into_inner(),
+ };
+ let public_key = privkey.public_key_bytes();
+ let already_loaded = keys
+ .iter()
+ .any(|key| key.public_key_bytes() == public_key);
+ if !already_loaded {
+ keys.push((*privkey).clone());
+ }
+ }
+ Ok(auth_success)
+ }
}
diff --git a/src-tauri/src/types.rs b/src-tauri/src/types.rs
index 7b83e0ef..58e9ba88 100644
--- a/src-tauri/src/types.rs
+++ b/src-tauri/src/types.rs
@@ -21,6 +21,18 @@ pub enum AuthMethod {
key_path: String,
passphrase: Option,
},
+ /// Sent by the frontend when the connection uses a vault credential.
+ /// The backend resolves this to Password or PrivateKeyData before authenticating.
+ VaultRef {
+ item_id: String,
+ },
+ /// Internal only — constructed by the backend after vault resolution.
+ /// Never accepted from IPC input; VaultRef is the on-wire form.
+ #[serde(skip_deserializing, skip_serializing)]
+ PrivateKeyData {
+ key_data: String,
+ passphrase: Option,
+ },
}
#[derive(Debug, Serialize)]
@@ -31,6 +43,41 @@ pub struct ConnectionResponse {
pub detected_os: Option,
}
+/// A reference to a vault item used as SSH credentials.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct CredentialRef {
+ pub vault_id: String,
+ pub item_id: String,
+ pub item_kind: CredentialItemKind,
+ pub purpose: CredentialPurpose,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(rename_all = "kebab-case")]
+pub enum CredentialItemKind {
+ SshPassword,
+ SshPrivateKey,
+ SshAgentKey,
+}
+
+impl CredentialItemKind {
+ pub fn as_str(&self) -> &'static str {
+ match self {
+ Self::SshPassword => "ssh-password",
+ Self::SshPrivateKey => "ssh-private-key",
+ Self::SshAgentKey => "ssh-agent-key",
+ }
+ }
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(rename_all = "kebab-case")]
+#[non_exhaustive]
+pub enum CredentialPurpose {
+ SshAuth,
+}
+
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")] // Match TS interface
pub struct SavedConnection {
@@ -50,6 +97,8 @@ pub struct SavedConnection {
pub created_at: Option,
pub is_favorite: Option,
pub pinned_features: Option>,
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub auth_ref: Option,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
diff --git a/src-tauri/src/vault/commands.rs b/src-tauri/src/vault/commands.rs
new file mode 100644
index 00000000..2118fa41
--- /dev/null
+++ b/src-tauri/src/vault/commands.rs
@@ -0,0 +1,294 @@
+use secrecy::{ExposeSecret, SecretString};
+use serde::{Deserialize, Serialize};
+use sha2::{Digest, Sha256};
+use tauri::State;
+use tokio::sync::Mutex;
+use zeroize::Zeroize;
+use base64::Engine;
+
+use crate::vault::error::VaultError;
+use crate::vault::migration::{MigrationPreview, MigrationResult};
+use crate::vault::store::VaultService;
+use crate::vault::types::{PlaintextRecord, VaultItemMeta, VaultStatus};
+
+// ── Error wrapper (serializable for IPC) ─────────────────────────────────────
+
+#[derive(Debug, Serialize)]
+pub struct VaultCommandError {
+ pub code: String,
+ pub message: String,
+}
+
+impl From for VaultCommandError {
+ fn from(e: VaultError) -> Self {
+ let (code, message) = match &e {
+ VaultError::NotInitialized => ("not_initialized", e.to_string()),
+ VaultError::AlreadyInitialized => ("already_initialized", e.to_string()),
+ VaultError::Locked => ("locked", e.to_string()),
+ VaultError::WrongPassphrase => ("wrong_passphrase", e.to_string()),
+ VaultError::RecordNotFound(_) => ("not_found", e.to_string()),
+ _ => ("error", e.to_string()),
+ };
+ Self {
+ code: code.to_string(),
+ message,
+ }
+ }
+}
+
+type VaultResult = Result;
+
+// ── Commands ──────────────────────────────────────────────────────────────────
+
+#[tauri::command]
+pub async fn vault_status(vault: State<'_, Mutex>) -> VaultResult {
+ vault.lock().await.status().map_err(Into::into)
+}
+
+#[derive(Deserialize)]
+pub struct InitializeArgs {
+ pub passphrase: SecretString,
+}
+
+#[tauri::command]
+pub async fn vault_initialize(
+ vault: State<'_, Mutex>,
+ args: InitializeArgs,
+) -> VaultResult {
+ vault
+ .lock()
+ .await
+ .initialize(args.passphrase.expose_secret())
+ .map_err(Into::into)
+}
+
+#[derive(Deserialize)]
+pub struct UnlockArgs {
+ pub passphrase: SecretString,
+}
+
+#[tauri::command]
+pub async fn vault_unlock(
+ vault: State<'_, Mutex>,
+ args: UnlockArgs,
+) -> VaultResult {
+ vault
+ .lock()
+ .await
+ .unlock(args.passphrase.expose_secret())
+ .map_err(Into::into)
+}
+
+#[tauri::command]
+pub async fn vault_lock(vault: State<'_, Mutex>) -> VaultResult<()> {
+ vault.lock().await.lock();
+ Ok(())
+}
+
+#[derive(Deserialize)]
+pub struct ItemCreateArgs {
+ pub label: String,
+ pub kind: String,
+ pub secret: SecretString,
+ pub notes: Option,
+}
+
+impl Drop for ItemCreateArgs {
+ fn drop(&mut self) {
+ if let Some(notes) = &mut self.notes {
+ notes.zeroize();
+ }
+ }
+}
+
+#[tauri::command]
+pub async fn vault_item_create(
+ vault: State<'_, Mutex>,
+ args: ItemCreateArgs,
+) -> VaultResult {
+ let record = vault
+ .lock()
+ .await
+ .item_create(
+ &args.label,
+ &args.kind,
+ args.secret.expose_secret(),
+ args.notes.as_deref(),
+ )
+ .map_err(VaultCommandError::from)?;
+ Ok(item_meta_from_plaintext(record))
+}
+
+#[tauri::command]
+pub async fn vault_item_list(
+ vault: State<'_, Mutex>,
+) -> VaultResult> {
+ let items = vault.lock().await.item_list().map_err(VaultCommandError::from)?;
+ Ok(items.into_iter().map(item_meta_from_plaintext).collect())
+}
+
+#[derive(Deserialize)]
+pub struct ItemGetArgs {
+ pub item_id: String,
+}
+
+#[tauri::command]
+pub async fn vault_item_get(
+ vault: State<'_, Mutex>,
+ args: ItemGetArgs,
+) -> VaultResult {
+ vault
+ .lock()
+ .await
+ .item_get(&args.item_id)
+ .map_err(Into::into)
+}
+
+fn item_meta_from_plaintext(record: PlaintextRecord) -> VaultItemMeta {
+ VaultItemMeta {
+ id: record.id.clone(),
+ kind: record.kind.clone(),
+ label: record.label.clone(),
+ secret_fingerprint: secret_fingerprint(&record.secret),
+ revision: record.revision,
+ created_at: record.created_at,
+ updated_at: record.updated_at,
+ }
+}
+
+fn secret_fingerprint(secret: &str) -> String {
+ let digest = Sha256::digest(secret.as_bytes());
+ base64::engine::general_purpose::STANDARD.encode(digest)
+}
+
+#[derive(Deserialize)]
+pub struct ItemDeleteArgs {
+ pub item_id: String,
+}
+
+#[tauri::command]
+pub async fn vault_item_delete(
+ vault: State<'_, Mutex>,
+ args: ItemDeleteArgs,
+) -> VaultResult<()> {
+ vault
+ .lock()
+ .await
+ .item_delete(&args.item_id)
+ .map_err(Into::into)
+}
+
+// ── Recovery key commands ─────────────────────────────────────────────────────
+
+#[tauri::command]
+pub async fn vault_generate_recovery_key(
+ vault: State<'_, Mutex>,
+) -> VaultResult {
+ vault
+ .lock()
+ .await
+ .generate_recovery_key()
+ .map_err(Into::into)
+}
+
+#[tauri::command]
+pub async fn vault_has_recovery_key(vault: State<'_, Mutex>) -> VaultResult {
+ vault.lock().await.has_recovery_key().map_err(Into::into)
+}
+
+#[derive(Deserialize)]
+pub struct UnlockWithRecoveryKeyArgs {
+ pub recovery_key: SecretString,
+}
+
+#[tauri::command]
+pub async fn vault_unlock_with_recovery_key(
+ vault: State<'_, Mutex>,
+ args: UnlockWithRecoveryKeyArgs,
+) -> VaultResult {
+ vault
+ .lock()
+ .await
+ .unlock_with_recovery_key(args.recovery_key.expose_secret())
+ .map_err(Into::into)
+}
+
+// ── Export / Import commands ──────────────────────────────────────────────────
+
+#[derive(Deserialize)]
+pub struct ExportArgs {
+ pub dest_path: String,
+}
+
+#[tauri::command]
+pub async fn vault_export(
+ vault: State<'_, Mutex>,
+ args: ExportArgs,
+) -> VaultResult<()> {
+ let dest_path = validate_export_path(&args.dest_path)?;
+ vault
+ .lock()
+ .await
+ .export_vault(&dest_path)
+ .map_err(Into::into)
+}
+
+#[derive(Deserialize)]
+pub struct ImportArgs {
+ pub src_path: String,
+}
+
+#[tauri::command]
+pub async fn vault_import(
+ vault: State<'_, Mutex>,
+ args: ImportArgs,
+) -> VaultResult {
+ let src_path = validate_import_path(&args.src_path)?;
+ vault
+ .lock()
+ .await
+ .import_vault(&src_path)
+ .map_err(Into::into)
+}
+
+// ── Migration commands ────────────────────────────────────────────────────────
+
+#[tauri::command]
+pub async fn vault_migration_preview(app: tauri::AppHandle) -> VaultResult {
+ let data_dir = crate::commands::get_data_dir(&app);
+ crate::vault::migration::preview(&data_dir).map_err(Into::into)
+}
+
+#[tauri::command]
+pub async fn vault_migrate_existing_secrets(
+ app: tauri::AppHandle,
+ vault: State<'_, Mutex>,
+) -> VaultResult {
+ let data_dir = crate::commands::get_data_dir(&app);
+ let guard = vault.lock().await;
+ crate::vault::migration::migrate(&data_dir, &guard).map_err(Into::into)
+}
+
+fn validate_export_path(path: &str) -> VaultResult {
+ let path = std::path::PathBuf::from(path);
+ let parent = path.parent().ok_or_else(|| VaultCommandError {
+ code: "invalid_path".into(),
+ message: "Export path must have a parent directory".into(),
+ })?;
+ let canonical_parent = std::fs::canonicalize(parent).map_err(|e| VaultCommandError {
+ code: "invalid_path".into(),
+ message: format!("Export parent does not exist or is not accessible: {e}"),
+ })?;
+ let file_name = path.file_name().ok_or_else(|| VaultCommandError {
+ code: "invalid_path".into(),
+ message: "Export path must include a file name".into(),
+ })?;
+ Ok(canonical_parent.join(file_name))
+}
+
+fn validate_import_path(path: &str) -> VaultResult {
+ std::fs::canonicalize(path).map_err(|e| VaultCommandError {
+ code: "invalid_path".into(),
+ message: format!("Import file does not exist or is not accessible: {e}"),
+ })
+}
diff --git a/src-tauri/src/vault/crypto.rs b/src-tauri/src/vault/crypto.rs
new file mode 100644
index 00000000..5d46238c
--- /dev/null
+++ b/src-tauri/src/vault/crypto.rs
@@ -0,0 +1,381 @@
+use argon2::{Algorithm, Argon2, Params, Version};
+use chacha20poly1305::{
+ aead::{Aead, KeyInit, Payload},
+ Key, XChaCha20Poly1305, XNonce,
+};
+use hkdf::Hkdf;
+use rand_core::{OsRng, RngCore};
+use sha2::Sha256;
+use zeroize::{Zeroize, ZeroizeOnDrop};
+
+// ── Error ─────────────────────────────────────────────────────────────────────
+
+#[derive(Debug)]
+pub enum VaultCryptoError {
+ Argon2(argon2::Error),
+ Aead,
+ HkdfExpand,
+ InvalidSaltLength,
+}
+
+impl std::fmt::Display for VaultCryptoError {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ match self {
+ Self::Argon2(e) => write!(f, "KDF error: {e}"),
+ Self::Aead => write!(f, "AEAD error: authentication failed or bad ciphertext"),
+ Self::HkdfExpand => write!(f, "HKDF expand failed"),
+ Self::InvalidSaltLength => write!(f, "salt must be at least 8 bytes"),
+ }
+ }
+}
+
+impl std::error::Error for VaultCryptoError {}
+
+impl From for VaultCryptoError {
+ fn from(e: argon2::Error) -> Self {
+ Self::Argon2(e)
+ }
+}
+
+// ── Key material wrapper ───────────────────────────────────────────────────────
+
+/// A 256-bit key that is zeroed on drop.
+#[derive(Clone, Zeroize, ZeroizeOnDrop)]
+pub struct SecretKey([u8; 32]);
+
+impl SecretKey {
+ pub fn from_bytes(bytes: [u8; 32]) -> Self {
+ Self(bytes)
+ }
+
+ pub fn as_bytes(&self) -> &[u8; 32] {
+ &self.0
+ }
+
+ fn as_mut_bytes(&mut self) -> &mut [u8; 32] {
+ &mut self.0
+ }
+}
+
+// ── KDF parameters ─────────────────────────────────────────────────────────────
+
+#[derive(Clone, Debug)]
+pub struct KdfParams {
+ /// Memory cost in kibibytes.
+ pub m_cost: u32,
+ /// Number of iterations.
+ pub t_cost: u32,
+ /// Parallelism (lanes).
+ pub p_cost: u32,
+}
+
+impl KdfParams {
+ /// Production default: 64 MiB, 3 iterations, 1 lane.
+ /// Meets OWASP Argon2id recommended minimums with headroom.
+ pub fn default_production() -> Self {
+ Self {
+ m_cost: 65536,
+ t_cost: 3,
+ p_cost: 1,
+ }
+ }
+
+ /// Fast params for unit tests only. Never use in production.
+ #[cfg(test)]
+ pub fn test_fast() -> Self {
+ Self {
+ m_cost: 4096,
+ t_cost: 1,
+ p_cost: 1,
+ }
+ }
+}
+
+// ── Encrypted envelope ──────────────────────────────────────────────────────────
+
+/// The on-disk/in-redb representation of one encrypted record or key slot.
+#[derive(Clone, Debug)]
+pub struct EncryptedEnvelope {
+ /// 192-bit XChaCha20-Poly1305 nonce.
+ pub nonce: [u8; 24],
+ /// Ciphertext + 16-byte Poly1305 authentication tag.
+ pub ciphertext: Vec,
+}
+
+// ── Primitive operations ────────────────────────────────────────────────────────
+
+/// Derive a 256-bit Key-Encryption Key from a master passphrase using Argon2id.
+///
+/// `salt` must be at least 8 bytes; use 32 bytes in production (see `generate_salt`).
+pub fn derive_kek(
+ passphrase: &[u8],
+ salt: &[u8],
+ params: &KdfParams,
+) -> Result {
+ if salt.len() < 8 {
+ return Err(VaultCryptoError::InvalidSaltLength);
+ }
+ let p = Params::new(params.m_cost, params.t_cost, params.p_cost, Some(32))?;
+ let argon2 = Argon2::new(Algorithm::Argon2id, Version::V0x13, p);
+ let mut key = SecretKey([0u8; 32]);
+ argon2.hash_password_into(passphrase, salt, key.as_mut_bytes())?;
+ Ok(key)
+}
+
+/// Derive a per-record encryption key from the VEK using HKDF-SHA256.
+///
+/// `info` should encode the record's identity, e.g.
+/// `b"zync:vault:record:v1::"`.
+pub fn derive_record_key(vek: &SecretKey, info: &[u8]) -> Result {
+ let hk = Hkdf::::new(None, vek.as_bytes());
+ let mut key = SecretKey([0u8; 32]);
+ hk.expand(info, key.as_mut_bytes())
+ .map_err(|_| VaultCryptoError::HkdfExpand)?;
+ Ok(key)
+}
+
+/// Encrypt `plaintext` with XChaCha20-Poly1305 using a random nonce.
+///
+/// `aad` is authenticated but not encrypted (e.g. vault_id + record_id + revision).
+pub fn encrypt_record(
+ key: &SecretKey,
+ plaintext: &[u8],
+ aad: &[u8],
+) -> Result {
+ let mut nonce_bytes = [0u8; 24];
+ OsRng.fill_bytes(&mut nonce_bytes);
+ encrypt_with_nonce(key, &nonce_bytes, plaintext, aad)
+}
+
+/// Decrypt an `EncryptedEnvelope` produced by `encrypt_record`.
+///
+/// Returns `Err(VaultCryptoError::Aead)` on wrong key, tampered ciphertext, or tampered AAD.
+pub fn decrypt_record(
+ key: &SecretKey,
+ envelope: &EncryptedEnvelope,
+ aad: &[u8],
+) -> Result, VaultCryptoError> {
+ let cipher = XChaCha20Poly1305::new(Key::from_slice(key.as_bytes()));
+ let nonce = XNonce::from_slice(&envelope.nonce);
+ let payload = Payload {
+ msg: &envelope.ciphertext,
+ aad,
+ };
+ cipher
+ .decrypt(nonce, payload)
+ .map_err(|_| VaultCryptoError::Aead)
+}
+
+/// Generate a cryptographically random 32-byte vault salt.
+pub fn generate_salt() -> [u8; 32] {
+ let mut salt = [0u8; 32];
+ OsRng.fill_bytes(&mut salt);
+ salt
+}
+
+/// Generate a cryptographically random 256-bit Vault Encryption Key.
+pub fn generate_vek() -> SecretKey {
+ let mut key = SecretKey([0u8; 32]);
+ OsRng.fill_bytes(key.as_mut_bytes());
+ key
+}
+
+// ── Internal helper (exposed for deterministic tests) ─────────────────────────
+
+fn encrypt_with_nonce(
+ key: &SecretKey,
+ nonce_bytes: &[u8; 24],
+ plaintext: &[u8],
+ aad: &[u8],
+) -> Result {
+ let cipher = XChaCha20Poly1305::new(Key::from_slice(key.as_bytes()));
+ let nonce = XNonce::from_slice(nonce_bytes);
+ let payload = Payload {
+ msg: plaintext,
+ aad,
+ };
+ let ciphertext = cipher
+ .encrypt(nonce, payload)
+ .map_err(|_| VaultCryptoError::Aead)?;
+ Ok(EncryptedEnvelope {
+ nonce: *nonce_bytes,
+ ciphertext,
+ })
+}
+
+// ── Tests ─────────────────────────────────────────────────────────────────────
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ const TEST_PASSPHRASE: &[u8] = b"zync-test-passphrase-v1";
+ const TEST_PLAINTEXT: &[u8] = b"ssh-password: hunter2";
+ const TEST_AAD: &[u8] = b"vault-id:test-vault|record-id:rec-001|revision:1";
+
+ fn test_params() -> KdfParams {
+ KdfParams::test_fast()
+ }
+
+ fn test_salt() -> [u8; 32] {
+ [0x42u8; 32]
+ }
+
+ fn test_nonce() -> [u8; 24] {
+ [0xABu8; 24]
+ }
+
+ fn hex_encode(bytes: &[u8]) -> String {
+ bytes.iter().map(|byte| format!("{byte:02x}")).collect()
+ }
+
+ // ── KDF ──────────────────────────────────────────────────────────────────
+
+ #[test]
+ fn kdf_is_deterministic() {
+ let salt = test_salt();
+ let kek1 = derive_kek(TEST_PASSPHRASE, &salt, &test_params()).unwrap();
+ let kek2 = derive_kek(TEST_PASSPHRASE, &salt, &test_params()).unwrap();
+ assert_eq!(*kek1.as_bytes(), *kek2.as_bytes());
+ }
+
+ #[test]
+ fn kdf_different_passphrase_produces_different_key() {
+ let salt = test_salt();
+ let kek1 = derive_kek(b"passphrase-a", &salt, &test_params()).unwrap();
+ let kek2 = derive_kek(b"passphrase-b", &salt, &test_params()).unwrap();
+ assert_ne!(*kek1.as_bytes(), *kek2.as_bytes());
+ }
+
+ #[test]
+ fn kdf_different_salt_produces_different_key() {
+ let kek1 = derive_kek(TEST_PASSPHRASE, &[0x11u8; 32], &test_params()).unwrap();
+ let kek2 = derive_kek(TEST_PASSPHRASE, &[0x22u8; 32], &test_params()).unwrap();
+ assert_ne!(*kek1.as_bytes(), *kek2.as_bytes());
+ }
+
+ #[test]
+ fn kdf_rejects_short_salt() {
+ let err = derive_kek(TEST_PASSPHRASE, &[0u8; 4], &test_params());
+ assert!(matches!(err, Err(VaultCryptoError::InvalidSaltLength)));
+ }
+
+ // ── HKDF record key ───────────────────────────────────────────────────────
+
+ #[test]
+ fn record_key_derivation_is_deterministic() {
+ let vek = SecretKey::from_bytes([0x55u8; 32]);
+ let info = b"zync:vault:record:v1:rec-001:1";
+ let k1 = derive_record_key(&vek, info).unwrap();
+ let k2 = derive_record_key(&vek, info).unwrap();
+ assert_eq!(*k1.as_bytes(), *k2.as_bytes());
+ }
+
+ #[test]
+ fn record_key_differs_per_record_id() {
+ let vek = SecretKey::from_bytes([0x55u8; 32]);
+ let k1 = derive_record_key(&vek, b"zync:vault:record:v1:rec-001:1").unwrap();
+ let k2 = derive_record_key(&vek, b"zync:vault:record:v1:rec-002:1").unwrap();
+ assert_ne!(*k1.as_bytes(), *k2.as_bytes());
+ }
+
+ #[test]
+ fn record_key_differs_per_revision() {
+ let vek = SecretKey::from_bytes([0x55u8; 32]);
+ let k1 = derive_record_key(&vek, b"zync:vault:record:v1:rec-001:1").unwrap();
+ let k2 = derive_record_key(&vek, b"zync:vault:record:v1:rec-001:2").unwrap();
+ assert_ne!(*k1.as_bytes(), *k2.as_bytes());
+ }
+
+ // ── AEAD round-trip ───────────────────────────────────────────────────────
+
+ #[test]
+ fn encrypt_decrypt_round_trip() {
+ let key = derive_kek(TEST_PASSPHRASE, &test_salt(), &test_params()).unwrap();
+ let envelope = encrypt_record(&key, TEST_PLAINTEXT, TEST_AAD).unwrap();
+ let plaintext = decrypt_record(&key, &envelope, TEST_AAD).unwrap();
+ assert_eq!(plaintext, TEST_PLAINTEXT);
+ }
+
+ #[test]
+ fn wrong_key_fails_decryption() {
+ let key = derive_kek(TEST_PASSPHRASE, &test_salt(), &test_params()).unwrap();
+ let wrong_key = derive_kek(b"wrong-passphrase", &test_salt(), &test_params()).unwrap();
+ let envelope = encrypt_record(&key, TEST_PLAINTEXT, TEST_AAD).unwrap();
+ let result = decrypt_record(&wrong_key, &envelope, TEST_AAD);
+ assert!(matches!(result, Err(VaultCryptoError::Aead)));
+ }
+
+ #[test]
+ fn tampered_ciphertext_fails_decryption() {
+ let key = derive_kek(TEST_PASSPHRASE, &test_salt(), &test_params()).unwrap();
+ let mut envelope = encrypt_record(&key, TEST_PLAINTEXT, TEST_AAD).unwrap();
+ // Flip a bit in the ciphertext body (not the auth tag at the end).
+ envelope.ciphertext[0] ^= 0xFF;
+ let result = decrypt_record(&key, &envelope, TEST_AAD);
+ assert!(matches!(result, Err(VaultCryptoError::Aead)));
+ }
+
+ #[test]
+ fn tampered_aad_fails_decryption() {
+ let key = derive_kek(TEST_PASSPHRASE, &test_salt(), &test_params()).unwrap();
+ let envelope = encrypt_record(&key, TEST_PLAINTEXT, TEST_AAD).unwrap();
+ let wrong_aad = b"vault-id:attacker|record-id:rec-001|revision:1";
+ let result = decrypt_record(&key, &envelope, wrong_aad);
+ assert!(matches!(result, Err(VaultCryptoError::Aead)));
+ }
+
+ #[test]
+ fn tampered_nonce_fails_decryption() {
+ let key = derive_kek(TEST_PASSPHRASE, &test_salt(), &test_params()).unwrap();
+ let mut envelope = encrypt_record(&key, TEST_PLAINTEXT, TEST_AAD).unwrap();
+ envelope.nonce[0] ^= 0xFF;
+ let result = decrypt_record(&key, &envelope, TEST_AAD);
+ assert!(matches!(result, Err(VaultCryptoError::Aead)));
+ }
+
+ // ── Known-answer vector ───────────────────────────────────────────────────
+ // Fixed inputs must produce identical output on every platform (Windows/macOS/Linux).
+ // If this test fails after pinning, the crypto output changed — investigate before shipping.
+
+ #[test]
+ fn known_answer_kdf_is_reproducible() {
+ let kek = derive_kek(TEST_PASSPHRASE, &test_salt(), &test_params()).unwrap();
+ // Verify non-zero output and stable length.
+ assert_eq!(kek.as_bytes().len(), 32);
+ assert_ne!(*kek.as_bytes(), [0u8; 32]);
+ assert_eq!(
+ hex_encode(kek.as_bytes()),
+ "b052565b8931ce7601892df5f7be4ff2a8ef6cdc6886f38dc08c8f34de3281e7",
+ "KDF known-answer vector changed"
+ );
+ }
+
+ #[test]
+ fn known_answer_aead_is_reproducible() {
+ let key = SecretKey::from_bytes([0x77u8; 32]);
+ let nonce = test_nonce();
+ let envelope = encrypt_with_nonce(&key, &nonce, TEST_PLAINTEXT, TEST_AAD).unwrap();
+
+ // Verify the envelope has correct structure.
+ assert_eq!(envelope.nonce, nonce);
+ // Ciphertext = plaintext + 16-byte Poly1305 tag.
+ assert_eq!(envelope.ciphertext.len(), TEST_PLAINTEXT.len() + 16);
+ assert_eq!(
+ hex_encode(&envelope.ciphertext),
+ "f0fdb9b785c52b0f84b577f7a58bc57b7e82ae7ce49d3b5b7314b7e604b87cd3976944f774",
+ "AEAD known-answer vector changed"
+ );
+ }
+
+ // ── Zeroize compiles ──────────────────────────────────────────────────────
+
+ #[test]
+ fn secret_key_zeroizes_on_drop() {
+ // This test verifies that SecretKey implements Zeroize correctly.
+ // We can't observe memory after drop, but we can verify the trait is present.
+ let mut key = SecretKey::from_bytes([0xFFu8; 32]);
+ key.zeroize();
+ assert_eq!(*key.as_bytes(), [0u8; 32]);
+ }
+}
diff --git a/src-tauri/src/vault/error.rs b/src-tauri/src/vault/error.rs
new file mode 100644
index 00000000..8ab70cc6
--- /dev/null
+++ b/src-tauri/src/vault/error.rs
@@ -0,0 +1,69 @@
+use crate::vault::crypto::VaultCryptoError;
+
+#[derive(Debug)]
+pub enum VaultError {
+ NotInitialized,
+ AlreadyInitialized,
+ Locked,
+ WrongPassphrase,
+ RecordNotFound(String),
+ InvalidData(String),
+ Crypto(VaultCryptoError),
+ Storage(anyhow::Error),
+ Serde(serde_json::Error),
+}
+
+impl std::fmt::Display for VaultError {
+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+ match self {
+ Self::NotInitialized => write!(f, "Vault is not initialized"),
+ Self::AlreadyInitialized => write!(f, "Vault is already initialized"),
+ Self::Locked => write!(f, "Vault is locked"),
+ Self::WrongPassphrase => write!(f, "Incorrect passphrase"),
+ Self::RecordNotFound(id) => write!(f, "Record not found: {id}"),
+ Self::InvalidData(msg) => write!(f, "Invalid vault data: {msg}"),
+ Self::Crypto(e) => write!(f, "Crypto error: {e}"),
+ Self::Storage(e) => write!(f, "Storage error: {e}"),
+ Self::Serde(e) => write!(f, "Serialization error: {e}"),
+ }
+ }
+}
+
+impl std::error::Error for VaultError {
+ fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
+ match self {
+ Self::Crypto(e) => Some(e),
+ Self::Storage(e) => Some(e.as_ref()),
+ Self::Serde(e) => Some(e),
+ _ => None,
+ }
+ }
+}
+
+impl From for VaultError {
+ fn from(e: VaultCryptoError) -> Self {
+ Self::Crypto(e)
+ }
+}
+
+impl From for VaultError {
+ fn from(e: serde_json::Error) -> Self {
+ Self::Serde(e)
+ }
+}
+
+macro_rules! impl_from_storage {
+ ($t:ty) => {
+ impl From<$t> for VaultError {
+ fn from(e: $t) -> Self {
+ Self::Storage(anyhow::Error::new(e))
+ }
+ }
+ };
+}
+
+impl_from_storage!(redb::DatabaseError);
+impl_from_storage!(redb::TransactionError);
+impl_from_storage!(redb::TableError);
+impl_from_storage!(redb::CommitError);
+impl_from_storage!(redb::StorageError);
diff --git a/src-tauri/src/vault/migration.rs b/src-tauri/src/vault/migration.rs
new file mode 100644
index 00000000..07560746
--- /dev/null
+++ b/src-tauri/src/vault/migration.rs
@@ -0,0 +1,328 @@
+use std::collections::HashMap;
+use std::path::Path;
+
+use base64::Engine;
+use serde::{Deserialize, Serialize};
+use sha2::{Digest, Sha256};
+
+use crate::types::{CredentialItemKind, CredentialPurpose, CredentialRef, SavedData};
+use crate::vault::error::VaultError;
+use crate::vault::store::VaultService;
+
+// ── Preview ───────────────────────────────────────────────────────────────────
+
+/// One connection that can be migrated.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct MigrationCandidate {
+ pub connection_id: String,
+ pub connection_name: String,
+ pub host: String,
+ pub migration_kind: MigrationKind,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(rename_all = "kebab-case")]
+pub enum MigrationKind {
+ SshPassword,
+ SshPrivateKey,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct MigrationPreview {
+ pub candidates: Vec,
+ pub already_migrated: u32,
+ /// Key files referenced in connections but not found on disk — cannot migrate.
+ pub skipped_no_file: u32,
+}
+
+/// Read connections.json and return what would be migrated. Does not require vault unlock.
+pub fn preview(data_dir: &Path) -> Result {
+ let saved = load_connections(data_dir)?;
+
+ let mut candidates = Vec::new();
+ let mut already_migrated = 0u32;
+ let mut skipped_no_file = 0u32;
+
+ for conn in &saved.connections {
+ if conn.auth_ref.is_some() {
+ already_migrated += 1;
+ continue;
+ }
+ if let Some(key_path) = &conn.private_key_path {
+ if key_path.is_empty() {
+ continue;
+ }
+ if !std::path::Path::new(key_path).exists() {
+ skipped_no_file += 1;
+ continue;
+ }
+ candidates.push(MigrationCandidate {
+ connection_id: conn.id.clone(),
+ connection_name: conn.name.clone(),
+ host: conn.host.clone(),
+ migration_kind: MigrationKind::SshPrivateKey,
+ });
+ continue;
+ }
+ if conn.password.is_some() {
+ candidates.push(MigrationCandidate {
+ connection_id: conn.id.clone(),
+ connection_name: conn.name.clone(),
+ host: conn.host.clone(),
+ migration_kind: MigrationKind::SshPassword,
+ });
+ }
+ }
+
+ Ok(MigrationPreview {
+ candidates,
+ already_migrated,
+ skipped_no_file,
+ })
+}
+
+// ── Migrate ───────────────────────────────────────────────────────────────────
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct MigrationResult {
+ pub migrated: u32,
+ pub skipped: u32,
+ pub already_done: u32,
+ pub backup_path: Option,
+}
+
+/// Migrate all migratable connections to vault items and rewrite connections.json.
+///
+/// Handles password auth (ssh-password) and key file auth (ssh-private-key).
+/// Key files are read and stored in vault; original files are left untouched.
+/// A backup is written to `connections.json.pre-vault-migration` before any change.
+pub fn migrate(data_dir: &Path, vault: &VaultService) -> Result {
+ let connections_path = data_dir.join("connections.json");
+ let backup_path = data_dir.join("connections.json.pre-vault-migration");
+
+ let mut saved = load_connections(data_dir)?;
+
+ let mut skipped = 0u32;
+ let mut already_done = 0u32;
+ let vault_id = vault.vault_id().ok_or(VaultError::Locked)?;
+ let mut prepared = Vec::new();
+
+ for (index, conn) in saved.connections.iter().enumerate() {
+ if conn.auth_ref.is_some() {
+ already_done += 1;
+ continue;
+ }
+
+ // ── Key-based auth ────────────────────────────────────────────────────
+ if let Some(ref key_path) = conn.private_key_path {
+ if key_path.is_empty() {
+ skipped += 1;
+ continue;
+ }
+ let key_content = match std::fs::read_to_string(key_path) {
+ Ok(c) => c,
+ Err(_) => {
+ skipped += 1;
+ continue;
+ }
+ };
+ let label = format!("{} key ({}@{})", conn.name, conn.username, conn.host);
+ prepared.push(PreparedMigration {
+ index,
+ label,
+ kind: CredentialItemKind::SshPrivateKey,
+ secret: serde_json::json!({
+ "key": key_content,
+ "passphrase": conn.password.as_ref(),
+ })
+ .to_string(),
+ });
+ continue;
+ }
+
+ // ── Password auth ─────────────────────────────────────────────────────
+ let Some(password) = conn.password.clone() else {
+ continue;
+ };
+ let label = format!("{} ({}@{})", conn.name, conn.username, conn.host);
+ prepared.push(PreparedMigration {
+ index,
+ label,
+ kind: CredentialItemKind::SshPassword,
+ secret: password,
+ });
+ }
+
+ if prepared.is_empty() {
+ return Ok(MigrationResult {
+ migrated: 0,
+ skipped,
+ already_done,
+ backup_path: None,
+ });
+ }
+
+ let original_json = std::fs::read_to_string(&connections_path).map_err(|e| {
+ VaultError::InvalidData(format!("backup read failed ({connections_path:?}): {e}"))
+ })?;
+ std::fs::write(&backup_path, &original_json).map_err(|e| {
+ VaultError::InvalidData(format!("backup write failed ({backup_path:?}): {e}"))
+ })?;
+
+ let existing_records = vault.item_list()?;
+ let mut existing_by_fingerprint: HashMap<(String, String, String), (String, u64)> = HashMap::new();
+ for record in existing_records {
+ let key = (
+ record.kind.clone(),
+ record.label.clone(),
+ secret_fingerprint(&record.secret),
+ );
+ existing_by_fingerprint
+ .entry(key)
+ .and_modify(|current| {
+ // Prefer the newest duplicate when earlier failed/stale migrations left
+ // multiple records with the same generated migration label and secret.
+ if record.created_at >= current.1 {
+ *current = (record.id.clone(), record.created_at);
+ }
+ })
+ .or_insert((record.id.clone(), record.created_at));
+ }
+
+ let mut linked = Vec::new();
+ let mut created_for_cleanup = Vec::new();
+ for migration in &prepared {
+ let kind = migration.kind.as_str();
+ let lookup_key = (
+ kind.to_string(),
+ migration.label.clone(),
+ secret_fingerprint(&migration.secret),
+ );
+ if let Some((existing_id, _)) = existing_by_fingerprint.get(&lookup_key) {
+ linked.push((migration.index, existing_id.clone(), migration.kind.clone()));
+ continue;
+ }
+
+ match vault.item_create(&migration.label, kind, &migration.secret, None) {
+ Ok(record) => {
+ let linked_record = (migration.index, record.id.clone(), migration.kind.clone());
+ existing_by_fingerprint.insert(lookup_key, (record.id.clone(), record.created_at));
+ created_for_cleanup.push(linked_record.clone());
+ linked.push(linked_record);
+ }
+ Err(e) => {
+ cleanup_created_items(vault, &created_for_cleanup);
+ return Err(VaultError::InvalidData(format!("vault item create: {e}")));
+ }
+ }
+ }
+
+ for (index, record_id, kind) in &linked {
+ let conn = &mut saved.connections[*index];
+ conn.auth_ref = Some(CredentialRef {
+ vault_id: vault_id.clone(),
+ item_id: record_id.clone(),
+ item_kind: kind.clone(),
+ purpose: CredentialPurpose::SshAuth,
+ });
+ match kind {
+ CredentialItemKind::SshPrivateKey => {
+ conn.private_key_path = None;
+ conn.password = None;
+ }
+ CredentialItemKind::SshPassword => {
+ conn.password = None;
+ }
+ CredentialItemKind::SshAgentKey => {}
+ }
+ }
+
+ let migrated = linked.len() as u32;
+ let updated_json = serde_json::to_string_pretty(&saved).map_err(VaultError::Serde)?;
+ if let Err(e) = atomic_write(&connections_path, &updated_json) {
+ cleanup_created_items(vault, &created_for_cleanup);
+ return Err(e);
+ }
+
+ Ok(MigrationResult {
+ migrated,
+ skipped,
+ already_done,
+ backup_path: Some(backup_path.to_string_lossy().into_owned()),
+ })
+}
+
+fn secret_fingerprint(secret: &str) -> String {
+ let digest = Sha256::digest(secret.as_bytes());
+ base64::engine::general_purpose::STANDARD.encode(digest)
+}
+
+// ── Helpers ───────────────────────────────────────────────────────────────────
+
+struct PreparedMigration {
+ index: usize,
+ label: String,
+ kind: CredentialItemKind,
+ secret: String,
+}
+
+fn cleanup_created_items(vault: &VaultService, created: &[(usize, String, CredentialItemKind)]) {
+ for (_, item_id, _) in created {
+ let _ = vault.item_delete(item_id);
+ }
+}
+
+fn load_connections(data_dir: &Path) -> Result {
+ let path = data_dir.join("connections.json");
+ if !path.exists() {
+ return Ok(SavedData {
+ connections: vec![],
+ folders: vec![],
+ });
+ }
+ let raw = std::fs::read_to_string(&path)
+ .map_err(|e| VaultError::InvalidData(format!("read connections.json: {e}")))?;
+ serde_json::from_str(&raw).map_err(VaultError::Serde)
+}
+
+fn atomic_write(path: &Path, content: &str) -> Result<(), VaultError> {
+ use std::io::Write;
+ let unique_suffix = uuid::Uuid::new_v4();
+ let tmp = path.with_extension(format!("json.tmp.{unique_suffix}"));
+ let mut f = std::fs::OpenOptions::new()
+ .write(true)
+ .create(true)
+ .truncate(true)
+ .open(&tmp)
+ .map_err(|e| VaultError::InvalidData(format!("tmp write open: {e}")))?;
+ f.write_all(content.as_bytes())
+ .map_err(|e| VaultError::InvalidData(format!("tmp write: {e}")))?;
+ f.sync_all()
+ .map_err(|e| VaultError::InvalidData(format!("tmp sync: {e}")))?;
+ drop(f);
+
+ if path.exists() {
+ let backup = path.with_extension(format!("json.replace-bak.{unique_suffix}"));
+ std::fs::rename(path, &backup)
+ .map_err(|e| VaultError::InvalidData(format!("atomic backup rename: {e}")))?;
+ match std::fs::rename(&tmp, path) {
+ Ok(()) => {
+ let _ = std::fs::remove_file(&backup);
+ }
+ Err(rename_error) => {
+ let _ = std::fs::rename(&backup, path);
+ let _ = std::fs::remove_file(&tmp);
+ return Err(VaultError::InvalidData(format!(
+ "atomic replace rename: {rename_error}"
+ )));
+ }
+ }
+ } else {
+ std::fs::rename(&tmp, path)
+ .map_err(|e| VaultError::InvalidData(format!("atomic rename: {e}")))?;
+ }
+ Ok(())
+}
diff --git a/src-tauri/src/vault/mod.rs b/src-tauri/src/vault/mod.rs
new file mode 100644
index 00000000..85c18a78
--- /dev/null
+++ b/src-tauri/src/vault/mod.rs
@@ -0,0 +1,15 @@
+//! Vault subsystem for encrypted local credential storage.
+//!
+//! `commands` exposes Tauri IPC, `crypto` owns KDF/AEAD helpers, `schema`
+//! defines redb tables and key-slot identifiers, `store` coordinates encrypted
+//! redb persistence, `migration` moves legacy credentials into vault records,
+//! while `types` and `error` define the public data/error contracts. Secrets
+//! should stay in backend memory only and be zeroized where practical.
+
+pub mod commands;
+pub(crate) mod crypto;
+pub mod error;
+pub(crate) mod migration;
+pub(crate) mod schema;
+pub(crate) mod store;
+pub mod types;
diff --git a/src-tauri/src/vault/schema.rs b/src-tauri/src/vault/schema.rs
new file mode 100644
index 00000000..11f55004
--- /dev/null
+++ b/src-tauri/src/vault/schema.rs
@@ -0,0 +1,18 @@
+use redb::TableDefinition;
+
+pub const SLOT_PASSPHRASE: &str = "passphrase";
+#[allow(dead_code)]
+pub const SLOT_DEVICE: &str = "device";
+pub const SLOT_RECOVERY: &str = "recovery";
+
+/// vault_id, schema_version, crypto_suite, salt, kdf params, timestamps.
+/// Key: string field name, Value: raw bytes (vault_id) or JSON bytes (meta).
+pub const VAULT_META: TableDefinition<&str, &[u8]> = TableDefinition::new("vault_meta");
+
+/// Per-slot wrapped VEK material.
+/// Key: slot id (`SLOT_PASSPHRASE`, `SLOT_DEVICE`, `SLOT_RECOVERY`), Value: JSON StoredEnvelope bytes.
+pub const KEY_SLOTS: TableDefinition<&str, &[u8]> = TableDefinition::new("key_slots");
+
+/// Encrypted vault records.
+/// Key: record UUID, Value: JSON StoredEnvelope bytes.
+pub const RECORDS: TableDefinition<&str, &[u8]> = TableDefinition::new("records");
diff --git a/src-tauri/src/vault/store.rs b/src-tauri/src/vault/store.rs
new file mode 100644
index 00000000..bf33852f
--- /dev/null
+++ b/src-tauri/src/vault/store.rs
@@ -0,0 +1,704 @@
+use std::path::{Path, PathBuf};
+
+use base64::{engine::general_purpose::STANDARD, Engine};
+use rand_core::{OsRng, RngCore};
+use redb::{Database, ReadTransaction, ReadableTable};
+use uuid::Uuid;
+use zeroize::Zeroize;
+
+use crate::vault::crypto::{
+ decrypt_record, derive_kek, derive_record_key, encrypt_record, generate_salt, generate_vek,
+ EncryptedEnvelope, KdfParams, SecretKey,
+};
+use crate::vault::error::VaultError;
+use crate::vault::schema::{KEY_SLOTS, RECORDS, SLOT_PASSPHRASE, SLOT_RECOVERY, VAULT_META};
+use crate::vault::types::{PlaintextRecord, StoredEnvelope, VaultMeta, VaultStatus};
+
+const CRYPTO_SUITE: &str = "xchacha20poly1305-argon2id-v1";
+const AAD_VERSION: u32 = 1;
+const SCHEMA_VERSION: u32 = 1;
+
+// ── Service ───────────────────────────────────────────────────────────────────
+
+pub struct VaultService {
+ db: Option,
+ vek: Option,
+ /// Cached after initialize/unlock; cleared on lock.
+ meta: Option,
+ data_dir: PathBuf,
+}
+
+impl VaultService {
+ pub fn new(data_dir: PathBuf) -> Self {
+ Self {
+ db: None,
+ vek: None,
+ meta: None,
+ data_dir,
+ }
+ }
+
+ fn vault_path(&self) -> PathBuf {
+ self.data_dir.join("vault.redb")
+ }
+
+ /// Opens an existing vault.redb without unlocking. No-op if already open.
+ fn try_open(&mut self) -> Result<(), VaultError> {
+ if self.db.is_some() {
+ return Ok(());
+ }
+ let path = self.vault_path();
+ if path.exists() {
+ self.db = Some(Database::open(&path)?);
+ }
+ Ok(())
+ }
+
+ fn now_secs() -> u64 {
+ std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .map(|d| d.as_secs())
+ .unwrap_or(0)
+ }
+
+ // ── Status ────────────────────────────────────────────────────────────────
+
+ pub fn status(&mut self) -> Result {
+ self.try_open()?;
+
+ let Some(db) = &self.db else {
+ return Ok(VaultStatus::Uninitialized);
+ };
+
+ let read_txn = db.begin_read()?;
+ let meta_table = match read_txn.open_table(VAULT_META) {
+ Ok(t) => t,
+ Err(redb::TableError::TableDoesNotExist(_)) => return Ok(VaultStatus::Uninitialized),
+ Err(e) => return Err(VaultError::from(e)),
+ };
+
+ let vault_id = meta_table
+ .get("vault_id")?
+ .map(|v| String::from_utf8_lossy(v.value()).into_owned())
+ .unwrap_or_default();
+
+ if vault_id.is_empty() {
+ return Ok(VaultStatus::Uninitialized);
+ }
+
+ if self.vek.is_none() {
+ return Ok(VaultStatus::Locked { vault_id });
+ }
+
+ let item_count = live_record_count(&read_txn)?;
+
+ Ok(VaultStatus::Unlocked {
+ vault_id,
+ item_count,
+ })
+ }
+
+ // ── Initialize ────────────────────────────────────────────────────────────
+
+ pub fn initialize(&mut self, passphrase: &str) -> Result {
+ let path = self.vault_path();
+ if path.exists() {
+ return Err(VaultError::AlreadyInitialized);
+ }
+
+ let kdf_params = KdfParams::default_production();
+ let salt = generate_salt();
+ let kek = derive_kek(passphrase.as_bytes(), &salt, &kdf_params)?;
+ let vek = generate_vek();
+ let vault_id = Uuid::new_v4().to_string();
+ let now = Self::now_secs();
+
+ // Wrap VEK with KEK → passphrase key slot
+ let slot_aad = slot_aad_string(&vault_id, SLOT_PASSPHRASE);
+ let slot_envelope = encrypt_record(&kek, vek.as_bytes(), slot_aad.as_bytes())?;
+ let stored_slot = StoredEnvelope {
+ id: SLOT_PASSPHRASE.into(),
+ kind: "key-slot".into(),
+ revision: 1,
+ deleted: false,
+ crypto_suite: CRYPTO_SUITE.into(),
+ aad_version: AAD_VERSION,
+ nonce: STANDARD.encode(slot_envelope.nonce),
+ ciphertext: STANDARD.encode(&slot_envelope.ciphertext),
+ };
+
+ let meta = VaultMeta {
+ vault_id: vault_id.clone(),
+ schema_version: SCHEMA_VERSION,
+ crypto_suite: CRYPTO_SUITE.into(),
+ salt: STANDARD.encode(salt),
+ kdf_m_cost: kdf_params.m_cost,
+ kdf_t_cost: kdf_params.t_cost,
+ kdf_p_cost: kdf_params.p_cost,
+ live_records: Some(0),
+ created_at: now,
+ updated_at: now,
+ };
+
+ let db = Database::create(&path)?;
+ let write_txn = db.begin_write()?;
+ {
+ let mut vm = write_txn.open_table(VAULT_META)?;
+ vm.insert("vault_id", vault_id.as_bytes())?;
+ vm.insert("meta", serde_json::to_vec(&meta)?.as_slice())?;
+
+ let mut ks = write_txn.open_table(KEY_SLOTS)?;
+ ks.insert(
+ SLOT_PASSPHRASE,
+ serde_json::to_vec(&stored_slot)?.as_slice(),
+ )?;
+
+ // Pre-create records table so reads never hit TableDoesNotExist.
+ write_txn.open_table(RECORDS)?;
+ }
+ write_txn.commit()?;
+
+ self.db = Some(db);
+ self.vek = Some(vek);
+ self.meta = Some(meta);
+
+ Ok(VaultStatus::Unlocked {
+ vault_id,
+ item_count: 0,
+ })
+ }
+
+ // ── Unlock ────────────────────────────────────────────────────────────────
+
+ pub fn unlock(&mut self, passphrase: &str) -> Result {
+ self.try_open()?;
+ let db = self.db.as_ref().ok_or(VaultError::NotInitialized)?;
+
+ let read_txn = db.begin_read()?;
+ let vm = read_txn.open_table(VAULT_META).map_err(VaultError::from)?;
+
+ let meta_bytes = vm.get("meta")?.ok_or(VaultError::NotInitialized)?;
+ let meta: VaultMeta = serde_json::from_slice(meta_bytes.value())?;
+
+ // Derive KEK from passphrase
+ let salt = STANDARD
+ .decode(&meta.salt)
+ .map_err(|e| VaultError::InvalidData(e.to_string()))?;
+ let kdf_params = KdfParams {
+ m_cost: meta.kdf_m_cost,
+ t_cost: meta.kdf_t_cost,
+ p_cost: meta.kdf_p_cost,
+ };
+ let kek = derive_kek(passphrase.as_bytes(), &salt, &kdf_params)?;
+
+ // Read and decrypt passphrase key slot
+ let ks = read_txn.open_table(KEY_SLOTS).map_err(VaultError::from)?;
+ let slot_bytes = ks.get(SLOT_PASSPHRASE)?.ok_or(VaultError::NotInitialized)?;
+ let stored_slot: StoredEnvelope = serde_json::from_slice(slot_bytes.value())?;
+
+ let slot_envelope = parse_envelope(&stored_slot)?;
+ let slot_aad = slot_aad_string(&meta.vault_id, SLOT_PASSPHRASE);
+ let vek_bytes = decrypt_record(&kek, &slot_envelope, slot_aad.as_bytes())
+ .map_err(|_| VaultError::WrongPassphrase)?;
+
+ let vek_arr: [u8; 32] = vek_bytes
+ .try_into()
+ .map_err(|_| VaultError::InvalidData("VEK wrong length".into()))?;
+
+ let vault_id = meta.vault_id.clone();
+ self.vek = Some(SecretKey::from_bytes(vek_arr));
+ self.meta = Some(meta);
+
+ // Count existing records for the returned status
+ let item_count = live_record_count(&read_txn)?;
+
+ Ok(VaultStatus::Unlocked {
+ vault_id,
+ item_count,
+ })
+ }
+
+ // ── Lock ──────────────────────────────────────────────────────────────────
+
+ pub fn lock(&mut self) {
+ // SecretKey implements ZeroizeOnDrop — drops immediately here.
+ self.vek = None;
+ self.meta = None;
+ }
+
+ /// Returns the vault ID if the vault is unlocked (meta is cached).
+ pub fn vault_id(&self) -> Option {
+ self.meta.as_ref().map(|m| m.vault_id.clone())
+ }
+
+ // ── Item CRUD ─────────────────────────────────────────────────────────────
+
+ pub fn item_create(
+ &self,
+ label: &str,
+ kind: &str,
+ secret: &str,
+ notes: Option<&str>,
+ ) -> Result {
+ let vek = self.vek.as_ref().ok_or(VaultError::Locked)?;
+ let db = self.db.as_ref().ok_or(VaultError::NotInitialized)?;
+ let meta = self.meta.as_ref().ok_or(VaultError::Locked)?;
+
+ let id = Uuid::new_v4().to_string();
+ let now = Self::now_secs();
+ let revision = 1u64;
+
+ let record = PlaintextRecord {
+ id: id.clone(),
+ kind: kind.to_string(),
+ label: label.to_string(),
+ secret: secret.to_string(),
+ notes: notes.map(str::to_string),
+ revision,
+ created_at: now,
+ updated_at: now,
+ };
+
+ let plaintext = serde_json::to_vec(&record)?;
+ let record_key = derive_record_key(vek, record_info_bytes(&id, revision).as_bytes())?;
+ let aad = record_aad_string(&meta.vault_id, &id, revision);
+ let envelope = encrypt_record(&record_key, &plaintext, aad.as_bytes())?;
+
+ let stored = StoredEnvelope {
+ id: id.clone(),
+ kind: kind.to_string(),
+ revision,
+ deleted: false,
+ crypto_suite: CRYPTO_SUITE.into(),
+ aad_version: AAD_VERSION,
+ nonce: STANDARD.encode(envelope.nonce),
+ ciphertext: STANDARD.encode(&envelope.ciphertext),
+ };
+
+ let write_txn = db.begin_write()?;
+ {
+ let mut records = write_txn.open_table(RECORDS)?;
+ records.insert(id.as_str(), serde_json::to_vec(&stored)?.as_slice())?;
+
+ let mut meta_table = write_txn.open_table(VAULT_META)?;
+ let meta_bytes = meta_table.get("meta")?.ok_or(VaultError::NotInitialized)?;
+ let mut db_meta: VaultMeta = serde_json::from_slice(meta_bytes.value())?;
+ db_meta.live_records = Some(db_meta.live_records.unwrap_or(0).saturating_add(1));
+ db_meta.updated_at = Self::now_secs();
+ drop(meta_bytes);
+ meta_table.insert("meta", serde_json::to_vec(&db_meta)?.as_slice())?;
+ }
+ write_txn.commit()?;
+
+ Ok(record)
+ }
+
+ pub fn item_get(&self, item_id: &str) -> Result {
+ let vek = self.vek.as_ref().ok_or(VaultError::Locked)?;
+ let db = self.db.as_ref().ok_or(VaultError::NotInitialized)?;
+ let meta = self.meta.as_ref().ok_or(VaultError::Locked)?;
+
+ let read_txn = db.begin_read()?;
+ let records = read_txn.open_table(RECORDS).map_err(VaultError::from)?;
+ let stored_bytes = records
+ .get(item_id)?
+ .ok_or_else(|| VaultError::RecordNotFound(item_id.to_string()))?;
+ let stored: StoredEnvelope = serde_json::from_slice(stored_bytes.value())?;
+
+ if stored.deleted {
+ return Err(VaultError::RecordNotFound(item_id.to_string()));
+ }
+
+ decrypt_stored(vek, &meta.vault_id, &stored)
+ }
+
+ pub fn item_list(&self) -> Result, VaultError> {
+ let vek = self.vek.as_ref().ok_or(VaultError::Locked)?;
+ let db = self.db.as_ref().ok_or(VaultError::NotInitialized)?;
+ let meta = self.meta.as_ref().ok_or(VaultError::Locked)?;
+
+ let read_txn = db.begin_read()?;
+ let records = match read_txn.open_table(RECORDS) {
+ Ok(t) => t,
+ Err(redb::TableError::TableDoesNotExist(_)) => return Ok(vec![]),
+ Err(e) => return Err(VaultError::from(e)),
+ };
+
+ let mut results = Vec::new();
+ for entry in records.iter()? {
+ let (_, v): (redb::AccessGuard<&str>, redb::AccessGuard<&[u8]>) = entry?;
+ let stored: StoredEnvelope = serde_json::from_slice(v.value())?;
+ if stored.deleted {
+ continue;
+ }
+ results.push(decrypt_stored(vek, &meta.vault_id, &stored)?);
+ }
+ Ok(results)
+ }
+
+ pub fn item_delete(&self, item_id: &str) -> Result<(), VaultError> {
+ let db = self.db.as_ref().ok_or(VaultError::NotInitialized)?;
+ if self.vek.is_none() {
+ return Err(VaultError::Locked);
+ }
+
+ let write_txn = db.begin_write()?;
+ {
+ let mut records = write_txn.open_table(RECORDS)?;
+ let stored_bytes = records
+ .get(item_id)?
+ .ok_or_else(|| VaultError::RecordNotFound(item_id.to_string()))?;
+ let mut stored: StoredEnvelope = serde_json::from_slice(stored_bytes.value())?;
+ drop(stored_bytes);
+ if stored.deleted {
+ return Err(VaultError::RecordNotFound(item_id.to_string()));
+ }
+ stored.deleted = true;
+ stored.revision += 1;
+ records.insert(item_id, serde_json::to_vec(&stored)?.as_slice())?;
+
+ let mut meta_table = write_txn.open_table(VAULT_META)?;
+ let meta_bytes = meta_table.get("meta")?.ok_or(VaultError::NotInitialized)?;
+ let mut db_meta: VaultMeta = serde_json::from_slice(meta_bytes.value())?;
+ db_meta.live_records = Some(db_meta.live_records.unwrap_or(0).saturating_sub(1));
+ db_meta.updated_at = Self::now_secs();
+ drop(meta_bytes);
+ meta_table.insert("meta", serde_json::to_vec(&db_meta)?.as_slice())?;
+ }
+ write_txn.commit()?;
+ Ok(())
+ }
+
+ // ── Recovery key ─────────────────────────────────────────────────────────
+
+ /// Generates a new 32-byte random recovery key, stores it as a second key slot,
+ /// and returns it as uppercase hex groups (e.g. "AABB-CCDD-…").
+ pub fn generate_recovery_key(&mut self) -> Result {
+ let vek = self.vek.as_ref().ok_or(VaultError::Locked)?;
+ let db = self.db.as_ref().ok_or(VaultError::NotInitialized)?;
+ let meta = self.meta.as_ref().ok_or(VaultError::Locked)?;
+
+ let mut raw = [0u8; 32];
+ OsRng.fill_bytes(&mut raw);
+ let recovery_key = encode_recovery_key(&raw);
+ let kek = SecretKey::from_bytes(raw);
+ raw.zeroize();
+
+ let slot_aad = slot_aad_string(&meta.vault_id, SLOT_RECOVERY);
+ let slot_envelope = encrypt_record(&kek, vek.as_bytes(), slot_aad.as_bytes())?;
+
+ let stored_slot = StoredEnvelope {
+ id: SLOT_RECOVERY.into(),
+ kind: "key-slot".into(),
+ revision: 1,
+ deleted: false,
+ crypto_suite: CRYPTO_SUITE.into(),
+ aad_version: AAD_VERSION,
+ nonce: STANDARD.encode(slot_envelope.nonce),
+ ciphertext: STANDARD.encode(&slot_envelope.ciphertext),
+ };
+
+ let write_txn = db.begin_write()?;
+ {
+ let mut ks = write_txn.open_table(KEY_SLOTS)?;
+ ks.insert(SLOT_RECOVERY, serde_json::to_vec(&stored_slot)?.as_slice())?;
+ }
+ write_txn.commit()?;
+
+ Ok(recovery_key)
+ }
+
+ pub fn has_recovery_key(&self) -> Result {
+ let db = self.db.as_ref().ok_or(VaultError::NotInitialized)?;
+ let read_txn = db.begin_read()?;
+ let ks = match read_txn.open_table(KEY_SLOTS) {
+ Ok(t) => t,
+ Err(redb::TableError::TableDoesNotExist(_)) => return Ok(false),
+ Err(e) => return Err(VaultError::from(e)),
+ };
+ Ok(ks.get(SLOT_RECOVERY)?.is_some())
+ }
+
+ /// Unlocks the vault using a recovery key string instead of a passphrase.
+ pub fn unlock_with_recovery_key(&mut self, key: &str) -> Result {
+ self.try_open()?;
+ let db = self.db.as_ref().ok_or(VaultError::NotInitialized)?;
+
+ let read_txn = db.begin_read()?;
+ let vm = read_txn.open_table(VAULT_META).map_err(VaultError::from)?;
+ let meta_bytes = vm.get("meta")?.ok_or(VaultError::NotInitialized)?;
+ let meta: VaultMeta = serde_json::from_slice(meta_bytes.value())?;
+
+ let mut raw = parse_recovery_key(key).ok_or(VaultError::WrongPassphrase)?;
+ let kek = SecretKey::from_bytes(raw);
+ raw.zeroize();
+
+ let ks = read_txn.open_table(KEY_SLOTS).map_err(VaultError::from)?;
+ let slot_bytes = ks
+ .get(SLOT_RECOVERY)?
+ .ok_or_else(|| VaultError::InvalidData("No recovery key slot found".into()))?;
+ let stored_slot: StoredEnvelope = serde_json::from_slice(slot_bytes.value())?;
+
+ let slot_envelope = parse_envelope(&stored_slot)?;
+ let slot_aad = slot_aad_string(&meta.vault_id, SLOT_RECOVERY);
+ let vek_bytes = decrypt_record(&kek, &slot_envelope, slot_aad.as_bytes())
+ .map_err(|_| VaultError::WrongPassphrase)?;
+
+ let mut vek_arr: [u8; 32] = vek_bytes
+ .try_into()
+ .map_err(|_| VaultError::InvalidData("VEK wrong length".into()))?;
+
+ let vault_id = meta.vault_id.clone();
+ self.vek = Some(SecretKey::from_bytes(vek_arr));
+ vek_arr.zeroize();
+ self.meta = Some(meta);
+
+ let item_count = live_record_count(&read_txn)?;
+
+ Ok(VaultStatus::Unlocked {
+ vault_id,
+ item_count,
+ })
+ }
+
+ // ── Export / Import ───────────────────────────────────────────────────────
+
+ /// Copies vault.redb to `dest_path` as an encrypted backup.
+ pub fn export_vault(&mut self, dest_path: &Path) -> Result<(), VaultError> {
+ let src = self.vault_path();
+ if !src.exists() {
+ return Err(VaultError::NotInitialized);
+ }
+ // Temporarily drop the DB handle to release the redb exclusive file lock.
+ let had_db = self.db.take().is_some();
+ let result = std::fs::copy(&src, dest_path)
+ .map_err(|e| VaultError::InvalidData(format!("export failed: {e}")));
+ if had_db {
+ let reopen_result = self.try_open();
+ result?;
+ reopen_result?;
+ return Ok(());
+ }
+ result?;
+ Ok(())
+ }
+
+ /// Replaces the current vault.redb with the file at `src_path`.
+ /// Validates the file is a valid redb database before overwriting.
+ /// Backs up the existing vault first.
+ pub fn import_vault(&mut self, src_path: &Path) -> Result {
+ // Validate before touching the current vault.
+ validate_vault_database(src_path)?;
+
+ // Close and clear current state.
+ self.db = None;
+ self.vek = None;
+ self.meta = None;
+
+ let dest = self.vault_path();
+ let tmp = dest.with_extension("redb.tmp-pre-import");
+ let backup = dest.with_extension("redb.pre-import");
+ let _ = std::fs::remove_file(&tmp);
+
+ if dest.exists() {
+ std::fs::copy(&dest, &backup)
+ .map_err(|e| VaultError::InvalidData(format!("pre-import backup failed: {e}")))?;
+ sync_file(&backup)?;
+ }
+
+ if let Err(e) = copy_file_synced(src_path, &tmp)
+ .and_then(|_| {
+ if dest.exists() {
+ std::fs::remove_file(&dest).map_err(|e| {
+ VaultError::InvalidData(format!("import remove old vault failed: {e}"))
+ })?;
+ }
+ std::fs::rename(&tmp, &dest).map_err(|e| {
+ VaultError::InvalidData(format!("import replace failed: {e}"))
+ })?;
+ sync_parent_dir(&dest)
+ })
+ {
+ let _ = std::fs::remove_file(&tmp);
+ if backup.exists() {
+ let _ = std::fs::copy(&backup, &dest);
+ let _ = sync_file(&dest);
+ }
+ return Err(e);
+ }
+
+ if let Err(e) = self.try_open().and_then(|_| self.status()) {
+ if backup.exists() {
+ self.db = None;
+ let _ = std::fs::copy(&backup, &dest);
+ let _ = sync_file(&dest);
+ self.try_open()?;
+ }
+ return Err(VaultError::InvalidData(format!("imported vault failed to open: {e}")));
+ }
+
+ self.status()
+ }
+}
+
+// ── Helpers ───────────────────────────────────────────────────────────────────
+
+fn record_info_bytes(record_id: &str, revision: u64) -> String {
+ format!("zync:vault:record:v1:{record_id}:{revision}")
+}
+
+fn record_aad_string(vault_id: &str, record_id: &str, revision: u64) -> String {
+ format!("vault:{vault_id}|record:{record_id}|revision:{revision}|v:{AAD_VERSION}")
+}
+
+fn slot_aad_string(vault_id: &str, slot_id: &str) -> String {
+ format!("vault:{vault_id}|slot:{slot_id}|v:{AAD_VERSION}")
+}
+
+fn parse_envelope(stored: &StoredEnvelope) -> Result {
+ let nonce_bytes = STANDARD
+ .decode(&stored.nonce)
+ .map_err(|e| VaultError::InvalidData(e.to_string()))?;
+ let nonce: [u8; 24] = nonce_bytes
+ .try_into()
+ .map_err(|_| VaultError::InvalidData("nonce wrong length".into()))?;
+ let ciphertext = STANDARD
+ .decode(&stored.ciphertext)
+ .map_err(|e| VaultError::InvalidData(e.to_string()))?;
+ Ok(EncryptedEnvelope { nonce, ciphertext })
+}
+
+fn live_record_count(read_txn: &ReadTransaction) -> Result {
+ if let Ok(meta_table) = read_txn.open_table(VAULT_META) {
+ if let Some(meta_bytes) = meta_table.get("meta")? {
+ let meta: VaultMeta = serde_json::from_slice(meta_bytes.value())?;
+ if let Some(count) = meta.live_records {
+ return Ok(count);
+ }
+ }
+ }
+
+ let records = match read_txn.open_table(RECORDS) {
+ Ok(t) => t,
+ Err(redb::TableError::TableDoesNotExist(_)) => return Ok(0),
+ Err(e) => return Err(VaultError::from(e)),
+ };
+
+ let mut count = 0u64;
+ for entry in records.iter()? {
+ let (_, value): (redb::AccessGuard<&str>, redb::AccessGuard<&[u8]>) = entry?;
+ let stored: StoredEnvelope = serde_json::from_slice(value.value())?;
+ if !stored.deleted {
+ count += 1;
+ }
+ }
+ Ok(count)
+}
+
+fn encode_recovery_key(bytes: &[u8; 32]) -> String {
+ let hex: Vec = bytes
+ .iter()
+ .flat_map(|b| {
+ let hi = char::from_digit((b >> 4) as u32, 16)
+ .unwrap()
+ .to_ascii_uppercase();
+ let lo = char::from_digit((b & 0xf) as u32, 16)
+ .unwrap()
+ .to_ascii_uppercase();
+ [hi, lo]
+ })
+ .collect();
+ hex.chunks(4)
+ .map(|c| c.iter().collect::())
+ .collect::>()
+ .join("-")
+}
+
+fn parse_recovery_key(s: &str) -> Option<[u8; 32]> {
+ let clean: String = s
+ .chars()
+ .filter(|c| c.is_ascii_hexdigit())
+ .map(|c| c.to_ascii_uppercase())
+ .collect();
+ if clean.len() != 64 {
+ return None;
+ }
+ let b = clean.as_bytes();
+ let mut out = [0u8; 32];
+ for (i, byte) in out.iter_mut().enumerate() {
+ let hi = hex_nibble(b[i * 2])?;
+ let lo = hex_nibble(b[i * 2 + 1])?;
+ *byte = (hi << 4) | lo;
+ }
+ Some(out)
+}
+
+fn hex_nibble(c: u8) -> Option {
+ match c {
+ b'0'..=b'9' => Some(c - b'0'),
+ b'A'..=b'F' => Some(c - b'A' + 10),
+ _ => None,
+ }
+}
+
+fn decrypt_stored(
+ vek: &SecretKey,
+ vault_id: &str,
+ stored: &StoredEnvelope,
+) -> Result {
+ let record_key = derive_record_key(
+ vek,
+ record_info_bytes(&stored.id, stored.revision).as_bytes(),
+ )?;
+ let envelope = parse_envelope(stored)?;
+ let aad = record_aad_string(vault_id, &stored.id, stored.revision);
+ let plaintext = decrypt_record(&record_key, &envelope, aad.as_bytes())?;
+ Ok(serde_json::from_slice(&plaintext)?)
+}
+
+fn validate_vault_database(path: &Path) -> Result<(), VaultError> {
+ let db = Database::open(path)
+ .map_err(|_| VaultError::InvalidData("Import file is not a valid vault.".into()))?;
+ let read_txn = db
+ .begin_read()
+ .map_err(|e| VaultError::InvalidData(format!("Import file cannot be read: {e}")))?;
+ read_txn
+ .open_table(VAULT_META)
+ .map_err(|_| VaultError::InvalidData("Import file is missing vault metadata.".into()))?;
+ read_txn
+ .open_table(KEY_SLOTS)
+ .map_err(|_| VaultError::InvalidData("Import file is missing key slots.".into()))?;
+ read_txn
+ .open_table(RECORDS)
+ .map_err(|_| VaultError::InvalidData("Import file is missing records table.".into()))?;
+ Ok(())
+}
+
+fn copy_file_synced(src: &Path, dest: &Path) -> Result<(), VaultError> {
+ std::fs::copy(src, dest)
+ .map_err(|e| VaultError::InvalidData(format!("import copy failed: {e}")))?;
+ sync_file(dest)
+}
+
+fn sync_file(path: &Path) -> Result<(), VaultError> {
+ std::fs::OpenOptions::new()
+ .read(true)
+ .open(path)
+ .and_then(|file| file.sync_all())
+ .map_err(|e| VaultError::InvalidData(format!("sync failed for {path:?}: {e}")))
+}
+
+#[cfg(not(target_os = "windows"))]
+fn sync_parent_dir(path: &Path) -> Result<(), VaultError> {
+ if let Some(parent) = path.parent() {
+ std::fs::File::open(parent)
+ .and_then(|file| file.sync_all())
+ .map_err(|e| VaultError::InvalidData(format!("parent directory sync failed: {e}")))?;
+ }
+ Ok(())
+}
+
+#[cfg(target_os = "windows")]
+fn sync_parent_dir(_path: &Path) -> Result<(), VaultError> {
+ Ok(())
+}
diff --git a/src-tauri/src/vault/types.rs b/src-tauri/src/vault/types.rs
new file mode 100644
index 00000000..09828d81
--- /dev/null
+++ b/src-tauri/src/vault/types.rs
@@ -0,0 +1,75 @@
+use serde::{Deserialize, Serialize};
+use zeroize::{Zeroize, ZeroizeOnDrop};
+
+/// Stored in redb vault_meta table under key "meta".
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct VaultMeta {
+ pub vault_id: String,
+ pub schema_version: u32,
+ pub crypto_suite: String,
+ /// Base64-encoded 32-byte Argon2id salt.
+ pub salt: String,
+ pub kdf_m_cost: u32,
+ pub kdf_t_cost: u32,
+ pub kdf_p_cost: u32,
+ #[serde(default)]
+ pub live_records: Option,
+ pub created_at: u64,
+ pub updated_at: u64,
+}
+
+/// Returned by vault IPC status/initialize/unlock commands.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(tag = "status", rename_all = "camelCase")]
+pub enum VaultStatus {
+ Uninitialized,
+ Locked { vault_id: String },
+ Unlocked { vault_id: String, item_count: u64 },
+}
+
+/// Plaintext record payload — only exists in memory after decryption.
+#[derive(Clone, Serialize, Deserialize, Zeroize, ZeroizeOnDrop)]
+#[serde(rename_all = "camelCase")]
+pub struct PlaintextRecord {
+ pub id: String,
+ /// e.g. "ssh-password", "ssh-private-key", "api-key", "secure-note"
+ pub kind: String,
+ pub label: String,
+ pub secret: String,
+ pub notes: Option,
+ pub revision: u64,
+ pub created_at: u64,
+ pub updated_at: u64,
+}
+
+/// Metadata-only vault item DTO for renderer list views.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct VaultItemMeta {
+ pub id: String,
+ pub kind: String,
+ pub label: String,
+ /// Stable hash of the decrypted secret for equality-only UI workflows.
+ /// The plaintext secret is never serialized by the list API.
+ pub secret_fingerprint: String,
+ pub revision: u64,
+ pub created_at: u64,
+ pub updated_at: u64,
+}
+
+/// Stored in redb records and key_slots tables as JSON bytes.
+/// Contains encrypted payload; `id` and `kind` are minimal plaintext index.
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(rename_all = "camelCase")]
+pub struct StoredEnvelope {
+ pub id: String,
+ pub kind: String,
+ pub revision: u64,
+ pub deleted: bool,
+ pub crypto_suite: String,
+ pub aad_version: u32,
+ /// Base64-encoded 24-byte XChaCha20 nonce.
+ pub nonce: String,
+ /// Base64-encoded ciphertext + 16-byte Poly1305 tag.
+ pub ciphertext: String,
+}
From e03a8e5d462700b7baf2b460cf20eac0c2e147d5 Mon Sep 17 00:00:00 2001
From: gajendraxdev
Date: Sun, 10 May 2026 02:35:22 +0530
Subject: [PATCH 2/8] feat(vault-ui): add global vault workspace and
profile-based navigation
---
package.json | 2 +
src/App.tsx | 3 +
src/components/layout/CommandPalette.tsx | 22 +-
src/components/layout/MainLayout.tsx | 17 +
src/components/layout/Sidebar.tsx | 101 +--
src/components/layout/TabBar.tsx | 30 +-
.../layout/sidebar/SidebarActionButton.tsx | 49 ++
.../layout/sidebar/VaultNavSection.tsx | 119 +++
.../layout/sidebar/vaultNavConfig.ts | 21 +
.../layout/sidebar/vaultNavState.ts | 15 +
src/components/modals/AddConnectionModal.tsx | 509 ++++++++----
src/components/modals/useAutoVault.ts | 168 ++++
src/components/modals/useConnectionForm.ts | 124 +++
src/components/settings/tabs/VaultTab.tsx | 761 ++++++++++++++++++
src/components/settings/tabs/vaultFocus.ts | 5 +
src/components/ui/Input.tsx | 32 +-
src/components/ui/Modal.tsx | 46 +-
src/components/vault/RecoveryKeyModal.tsx | 130 +++
src/components/vault/VaultUnlockModal.tsx | 213 +++++
src/components/vault/VaultWorkspacePanel.tsx | 40 +
.../connections/application/tabService.ts | 33 +
.../connections/domain/connectionConfig.ts | 19 +-
.../connections/domain/formTransforms.ts | 25 +-
src/features/connections/domain/merge.ts | 37 +-
src/features/connections/domain/types.ts | 17 +-
src/features/connections/domain/validation.ts | 2 +-
.../infrastructure/connectionIpc.ts | 10 +-
src/store/connectionSlice.ts | 59 +-
src/store/sessionPersistence.ts | 5 +-
src/store/settingsSlice.ts | 43 +
src/vault/ipc.ts | 97 +++
src/vault/profileTypes.ts | 9 +
src/vault/useVaultStore.ts | 127 +++
tests/connectionDomain.test.mjs | 177 ++++
tests/connectionTabService.test.mjs | 37 +-
tests/sessionPersistence.test.mjs | 27 +
tests/vaultFocus.test.mjs | 26 +
tests/vaultNavState.test.mjs | 50 ++
tsconfig.agent-tests.json | 5 +-
39 files changed, 2918 insertions(+), 294 deletions(-)
create mode 100644 src/components/layout/sidebar/SidebarActionButton.tsx
create mode 100644 src/components/layout/sidebar/VaultNavSection.tsx
create mode 100644 src/components/layout/sidebar/vaultNavConfig.ts
create mode 100644 src/components/layout/sidebar/vaultNavState.ts
create mode 100644 src/components/modals/useAutoVault.ts
create mode 100644 src/components/modals/useConnectionForm.ts
create mode 100644 src/components/settings/tabs/VaultTab.tsx
create mode 100644 src/components/settings/tabs/vaultFocus.ts
create mode 100644 src/components/vault/RecoveryKeyModal.tsx
create mode 100644 src/components/vault/VaultUnlockModal.tsx
create mode 100644 src/components/vault/VaultWorkspacePanel.tsx
create mode 100644 src/vault/ipc.ts
create mode 100644 src/vault/profileTypes.ts
create mode 100644 src/vault/useVaultStore.ts
create mode 100644 tests/vaultFocus.test.mjs
create mode 100644 tests/vaultNavState.test.mjs
diff --git a/package.json b/package.json
index 262f80af..892ecf6a 100644
--- a/package.json
+++ b/package.json
@@ -28,6 +28,8 @@
"test:connection-tab-service": "npm run compile:agent-tests && node tests/connectionTabService.test.mjs",
"test:connection-lifecycle-service": "npm run compile:agent-tests && node tests/connectionLifecycleService.test.mjs",
"test:tunnel-autostart-service": "npm run compile:agent-tests && node tests/tunnelAutoStartService.test.mjs",
+ "test:vault-nav-state": "npm run compile:agent-tests && node tests/vaultNavState.test.mjs",
+ "test:vault-focus": "npm run compile:agent-tests && node tests/vaultFocus.test.mjs",
"test:ghost-helpers": "npm run compile:agent-tests && node tests/ghostSuggestionsHelpers.test.mjs",
"test:session-persistence": "npm run compile:agent-tests && node tests/sessionPersistence.test.mjs",
"test:update-notification": "node tests/updateNotificationAutoUpdateFlow.test.mjs",
diff --git a/src/App.tsx b/src/App.tsx
index cd12eec4..3b833114 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -3,6 +3,7 @@ import { UpdateNotification } from './components/UpdateNotification';
import { ToastContainer } from './components/ui/Toast';
import { useEffect } from 'react';
import { useAppStore } from './store/useAppStore';
+import { useVaultStore } from './vault/useVaultStore';
import { WelcomeScreen } from './components/dashboard/WelcomeScreen';
import { useTransferEvents } from './hooks/useTransferEvents';
import { ErrorBoundary } from './components/ErrorBoundary';
@@ -15,6 +16,7 @@ function AppContent() {
const loadSettings = useAppStore((state) => state.loadSettings);
const loadSession = useAppStore((state) => state.loadSession);
const fetchSystemInfo = useAppStore((state) => state.fetchSystemInfo);
+ const refreshVault = useVaultStore((state) => state.refresh);
useTransferEvents();
@@ -33,6 +35,7 @@ function AppContent() {
} catch (e) {
console.warn('[App] fetchSystemInfo failed:', e);
}
+ refreshVault().catch(e => console.warn('[App] refreshVault failed:', e));
};
init().catch(e => console.warn('[App] Initialisation error:', e));
// eslint-disable-next-line react-hooks/exhaustive-deps -- store actions are stable
diff --git a/src/components/layout/CommandPalette.tsx b/src/components/layout/CommandPalette.tsx
index 05f46ad1..f5310166 100644
--- a/src/components/layout/CommandPalette.tsx
+++ b/src/components/layout/CommandPalette.tsx
@@ -9,7 +9,9 @@ import {
Plus,
Code,
Network,
- FolderPlus
+ FolderPlus,
+ Cloud,
+ Shield
} from "lucide-react";
import { useAppStore, Connection } from "../../store/useAppStore";
import { useShallow } from 'zustand/react/shallow';
@@ -348,6 +350,24 @@ export function CommandPalette() {
Port Forwarding
+ runCommand(() => useAppStore.getState().openVaultTab('local'))}
+ className="relative flex cursor-pointer select-none items-center rounded-lg px-2 py-1.5 text-sm outline-none data-[selected=true]:bg-app-accent/20 data-[selected=true]:text-app-accent text-app-text transition-colors group mb-0.5"
+ >
+
+ Local Vault
+
+
+ runCommand(() => useAppStore.getState().openVaultTab('google'))}
+ className="relative flex cursor-pointer select-none items-center rounded-lg px-2 py-1.5 text-sm outline-none data-[selected=true]:bg-app-accent/20 data-[selected=true]:text-app-accent text-app-text transition-colors group mb-0.5"
+ >
+
+ Google Vault Sync
+
+
runCommand(() => useAppStore.getState().openSnippetsTab())}
diff --git a/src/components/layout/MainLayout.tsx b/src/components/layout/MainLayout.tsx
index 18622827..24cf9adf 100644
--- a/src/components/layout/MainLayout.tsx
+++ b/src/components/layout/MainLayout.tsx
@@ -53,6 +53,9 @@ const PluginPanel = lazy(() => import('../plugins/PluginPanel').then(module => (
const SettingsJsonEditorPanel = lazy(() =>
import('../settings/SettingsJsonEditorPanel').then(module => ({ default: module.SettingsJsonEditorPanel }))
);
+const VaultWorkspacePanel = lazy(() =>
+ import('../vault/VaultWorkspacePanel').then(module => ({ default: module.default }))
+);
// Loading Component
const TabLoading = () => (
@@ -283,6 +286,20 @@ const TabContent = memo(function TabContent({ tab, isActive }: {
);
}
+ if (tab.type === 'vault') {
+ return (
+
+ }>
+
+
+
+ );
+ }
+
if (!tab.connectionId) {
return null;
}
diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx
index a7b75805..56c21154 100644
--- a/src/components/layout/Sidebar.tsx
+++ b/src/components/layout/Sidebar.tsx
@@ -10,6 +10,8 @@ import { SidebarSection } from './sidebar/SidebarSection';
import { ConnectionItem } from './sidebar/ConnectionItem';
import { FolderItem } from './sidebar/FolderItem';
import { FolderFormModal } from './sidebar/FolderFormModal';
+import { SidebarActionButton } from './sidebar/SidebarActionButton';
+import { VaultNavSection } from './sidebar/VaultNavSection';
import { AddConnectionModal } from '../modals/AddConnectionModal';
import { AddTunnelModal } from '../modals/AddTunnelModal';
import { normalizeFolderPath } from '../../features/connections/domain';
@@ -24,6 +26,13 @@ const SettingsModal = lazy(() => import('../settings/SettingsModal').then(mod =>
const ConnectionDetailsModal = lazy(() => import('../modals/ConnectionDetailsModal').then(mod => ({ default: mod.ConnectionDetailsModal })));
const ExportConnectionsModal = lazy(() => import('../modals/ExportConnectionsModal').then(mod => ({ default: mod.ExportConnectionsModal })));
+const FEATURE_ITEMS: Array<{ id: FeatureId; label: string }> = [
+ { id: 'files', label: 'File Manager' },
+ { id: 'port-forwarding', label: 'Port Forwarding' },
+ { id: 'snippets', label: 'Snippets' },
+ { id: 'dashboard', label: 'Dashboard' },
+];
+
export function Sidebar({ className }: { className?: string }) {
const [viewingDetailsId, setViewingDetailsId] = useState(null);
@@ -153,7 +162,6 @@ export function Sidebar({ className }: { className?: string }) {
return hostCount > 99 ? '99+' : String(hostCount);
}, [connections]);
-
const openEditConnection = useCallback((conn: Connection) => {
openConnectionModal(conn.id);
}, [openConnectionModal]);
@@ -184,9 +192,9 @@ export function Sidebar({ className }: { className?: string }) {
const expandedFolders = useMemo(() => new Set(settings.expandedFolders), [settings.expandedFolders]);
- const toggleFolder = (folderPath: string) => {
+ const toggleFolder = useCallback((folderPath: string) => {
toggleExpandedFolder(folderPath);
- };
+ }, [toggleExpandedFolder]);
const handleAllHostsDrop = useCallback((e: React.DragEvent) => {
e.preventDefault();
@@ -212,10 +220,10 @@ export function Sidebar({ className }: { className?: string }) {
}
}, []);
- const handleRenameFolder = (path: string) => {
+ const handleRenameFolder = useCallback((path: string) => {
setFolderToRename(path);
setIsRenameFolderModalOpen(true);
- };
+ }, []);
const openConnectionContextMenu = useCallback((conn: Connection, x: number, y: number) => {
setConnectionContextMenu({ x, y, connectionId: conn.id });
@@ -286,13 +294,6 @@ export function Sidebar({ className }: { className?: string }) {
const connectionContextMenuItems = useMemo(() => {
if (!contextMenuConnection) return [];
- const featureItems: Array<{ id: FeatureId; label: string }> = [
- { id: 'files', label: 'File Manager' },
- { id: 'port-forwarding', label: 'Port Forwarding' },
- { id: 'snippets', label: 'Snippets' },
- { id: 'dashboard', label: 'Dashboard' },
- ];
-
return [
{
label: contextMenuConnection.status === 'connected' ? 'Disconnect' : 'Connect',
@@ -317,7 +318,7 @@ export function Sidebar({ className }: { className?: string }) {
action: () => setViewingDetailsId(contextMenuConnection.id)
},
{ separator: true },
- ...featureItems.map(({ id, label }) => {
+ ...FEATURE_ITEMS.map(({ id, label }) => {
const Icon = FEATURE_META[id].icon;
return {
label,
@@ -415,7 +416,7 @@ export function Sidebar({ className }: { className?: string }) {
onOpenContextMenu: openConnectionContextMenu,
}), [openEditConnection, openConnectionContextMenu]);
- const allHostsContent = (
+ const allHostsContent = useMemo(() => (
))}
- );
+ ), [
+ compactMode,
+ connectionItemProps,
+ expandedFolders,
+ handleAllHostsDragOver,
+ handleAllHostsDrop,
+ handleRenameFolder,
+ renameFolder,
+ toggleFolder,
+ treeRoot.children,
+ treeRoot.connections,
+ updateConnectionFolder,
+ ]);
+
+ const initialConnectionId = activeConnectionId && activeConnectionId !== 'local' && activeConnectionId !== 'port-forwarding'
+ ? activeConnectionId
+ : undefined;
return (
- }
+ label="New Terminal"
onClick={() => openTab('local')}
- >
-
- New Terminal
-
-
-
+
+ }
+ label="Port Forwarding"
onClick={() => openPortForwardingTab()}
- >
-
- Port Forwarding
-
+ />
+
+
@@ -559,16 +568,18 @@ export function Sidebar({ className }: { className?: string }) {
>
) : (
- {
- event.preventDefault();
- setAllHostsContextMenu({ x: event.clientX, y: event.clientY });
- }}
- >
- {allHostsContent}
-
+ <>
+ {
+ event.preventDefault();
+ setAllHostsContextMenu({ x: event.clientX, y: event.clientY });
+ }}
+ >
+ {allHostsContent}
+
+ >
)}
@@ -599,7 +610,7 @@ export function Sidebar({ className }: { className?: string }) {
setIsAddTunnelModalOpen(false)}
- initialConnectionId={activeConnectionId && activeConnectionId !== 'local' && activeConnectionId !== 'port-forwarding' ? activeConnectionId : undefined}
+ initialConnectionId={initialConnectionId}
/>
)}
diff --git a/src/components/layout/TabBar.tsx b/src/components/layout/TabBar.tsx
index 41791bb4..cdb672f4 100644
--- a/src/components/layout/TabBar.tsx
+++ b/src/components/layout/TabBar.tsx
@@ -1,4 +1,4 @@
-import { X, Settings as SettingsIcon, PanelLeft, Network, Gift, Plus, Laptop, FolderPlus, Sparkles, Home } from 'lucide-react';
+import { X, Settings as SettingsIcon, PanelLeft, Network, Gift, Plus, Laptop, FolderPlus, Sparkles, Home, Shield } from 'lucide-react';
import { OSIcon } from '../icons/OSIcon';
import { useAppStore, Tab, Connection } from '../../store/useAppStore'; // Updated Import
import { cn } from '../../lib/utils';
@@ -27,6 +27,17 @@ import {
} from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
+function getIconForTab(tab: Tab, connections: Connection[], size: 12 | 13 = 12) {
+ if (tab.type === 'port-forwarding') return ;
+ if (tab.type === 'settings') return ;
+ if (tab.type === 'release-notes') return ;
+ if (tab.type === 'vault') return ;
+
+ const conn = connections.find((c: Connection) => c.id === tab.connectionId);
+ const iconClassName = size === 13 ? "w-[13px] h-[13px]" : "w-[12px] h-[12px]";
+ return ;
+}
+
// Extract SortableTab component
function SortableTab({
tab,
@@ -73,14 +84,7 @@ function SortableTab({
title={tab.title}
>
{/* Icon based on type */}
- {(() => {
- if (tab.type === 'port-forwarding') return ;
- if (tab.type === 'settings') return ;
- if (tab.type === 'release-notes') return ;
-
- const conn = connections.find((c: Connection) => c.id === tab.connectionId);
- return ;
- })()}
+ {getIconForTab(tab, connections, 12)}
{tab.title}
@@ -398,13 +402,7 @@ export function TabBar() {
if (!tab) return null;
return (
- {(() => {
- if (tab.type === 'port-forwarding') return
;
- if (tab.type === 'settings') return
;
- if (tab.type === 'release-notes') return
;
- const conn = connections.find((c: Connection) => c.id === tab.connectionId);
- return
;
- })()}
+ {getIconForTab(tab, connections, 13)}
{tab.title}
diff --git a/src/components/layout/sidebar/SidebarActionButton.tsx b/src/components/layout/sidebar/SidebarActionButton.tsx
new file mode 100644
index 00000000..6c718b73
--- /dev/null
+++ b/src/components/layout/sidebar/SidebarActionButton.tsx
@@ -0,0 +1,49 @@
+import type { ReactNode } from 'react';
+import { cn } from '../../../lib/utils';
+
+export interface SidebarActionButtonProps {
+ icon: ReactNode;
+ label: string;
+ onClick: () => void;
+ active?: boolean;
+ nested?: boolean;
+ trailing?: ReactNode;
+}
+
+export function SidebarActionButton({
+ icon,
+ label,
+ onClick,
+ active = false,
+ nested = false,
+ trailing,
+}: SidebarActionButtonProps) {
+ return (
+
+ );
+}
diff --git a/src/components/layout/sidebar/VaultNavSection.tsx b/src/components/layout/sidebar/VaultNavSection.tsx
new file mode 100644
index 00000000..1678671d
--- /dev/null
+++ b/src/components/layout/sidebar/VaultNavSection.tsx
@@ -0,0 +1,119 @@
+import { useCallback, useEffect, useMemo, useState } from 'react';
+import { ChevronDown, Shield } from 'lucide-react';
+import { useAppStore } from '../../../store/useAppStore';
+import { cn } from '../../../lib/utils';
+import { DEFAULT_VAULT_PROFILE_ID } from '../../../vault/profileTypes';
+import { useVaultStore } from '../../../vault/useVaultStore';
+import { syncIpc, SYNC_STATUS_CHANGED_EVENT, type SyncProviderStatus } from '../../../vault/syncIpc';
+import { SidebarActionButton } from './SidebarActionButton';
+import { VAULT_NAV_ITEMS } from './vaultNavConfig';
+import { nextSidebarSectionsForVaultToggle, resolveVaultExpanded } from './vaultNavState';
+
+function StatusDot({ className, title }: { className: string; title: string }) {
+ return (
+
+ );
+}
+
+export function VaultNavSection() {
+ const settings = useAppStore(state => state.settings);
+ const updateSidebarSectionsSettings = useAppStore(state => state.updateSidebarSectionsSettings);
+ const openVaultTab = useAppStore(state => state.openVaultTab);
+ const activeVaultProfileId = useAppStore(state => {
+ const activeTab = state.tabs.find(tab => tab.id === state.activeTabId);
+ if (activeTab?.type === 'vault') return activeTab.vaultProfileId ?? DEFAULT_VAULT_PROFILE_ID;
+
+ return state.tabs.find(tab => tab.type === 'vault')?.vaultProfileId ?? DEFAULT_VAULT_PROFILE_ID;
+ });
+ const vaultStatus = useVaultStore(state => state.status);
+ const [googleSync, setGoogleSync] = useState
(null);
+
+ const expanded = resolveVaultExpanded(settings);
+
+ const refreshGoogleSync = useCallback(() => {
+ syncIpc.status('google')
+ .then(setGoogleSync)
+ .catch(error => console.warn('[VaultNavSection] Failed to load Google sync status:', error));
+ }, []);
+
+ useEffect(() => {
+ refreshGoogleSync();
+ const interval = window.setInterval(refreshGoogleSync, 10_000);
+ const handleSyncChanged = (event: Event) => {
+ const detail = (event as CustomEvent<{ provider?: string }>).detail;
+ if (!detail?.provider || detail.provider === 'google') {
+ refreshGoogleSync();
+ }
+ };
+ window.addEventListener(SYNC_STATUS_CHANGED_EVENT, handleSyncChanged);
+ return () => {
+ window.clearInterval(interval);
+ window.removeEventListener(SYNC_STATUS_CHANGED_EVENT, handleSyncChanged);
+ };
+ }, [refreshGoogleSync]);
+
+ const toggleExpanded = useCallback(() => {
+ const sidebarSections = nextSidebarSectionsForVaultToggle(settings, expanded);
+ void updateSidebarSectionsSettings(sidebarSections).catch(error => {
+ console.warn('[VaultNavSection] Failed to persist expanded state:', error);
+ });
+ }, [expanded, settings, updateSidebarSectionsSettings]);
+
+ const statusByProfile = useMemo(() => ({
+ local: vaultStatus?.status === 'unlocked'
+ ? { className: 'bg-emerald-400/80', title: 'Local vault unlocked' }
+ : vaultStatus?.status === 'locked'
+ ? { className: 'bg-amber-400/80', title: 'Local vault locked' }
+ : { className: 'bg-app-muted/40', title: 'Local vault not set up' },
+ google: googleSync?.connected
+ ? { className: 'bg-blue-400/80', title: 'Google sync connected' }
+ : { className: 'bg-app-muted/40', title: 'Google sync not connected' },
+ }), [googleSync?.connected, vaultStatus?.status]);
+
+ return (
+ <>
+ }
+ label="Vault"
+ onClick={toggleExpanded}
+ trailing={(
+
+ )}
+ />
+
+ {expanded && (
+
+ {VAULT_NAV_ITEMS.map((item) => {
+ const Icon = item.icon;
+ const status = statusByProfile[item.id];
+ if (!status && !import.meta.env.PROD) {
+ console.warn('[VaultNavSection] Missing statusByProfile entry for profile id:', item.id, statusByProfile);
+ }
+ const resolvedStatus = status ?? { className: 'bg-app-muted/40', title: 'Unknown vault profile' };
+
+ return (
+ }
+ label={item.label}
+ onClick={() => openVaultTab(item.id)}
+ trailing={}
+ />
+ );
+ })}
+
+ )}
+ >
+ );
+}
diff --git a/src/components/layout/sidebar/vaultNavConfig.ts b/src/components/layout/sidebar/vaultNavConfig.ts
new file mode 100644
index 00000000..4da98142
--- /dev/null
+++ b/src/components/layout/sidebar/vaultNavConfig.ts
@@ -0,0 +1,21 @@
+import { Cloud, HardDrive, type LucideIcon } from 'lucide-react';
+import type { VaultProfileId } from '../../../vault/profileTypes';
+
+export interface VaultNavItemConfig {
+ id: VaultProfileId;
+ label: string;
+ icon: LucideIcon;
+}
+
+export const VAULT_NAV_ITEMS: ReadonlyArray = [
+ {
+ id: 'local',
+ label: 'Local Vault',
+ icon: HardDrive,
+ },
+ {
+ id: 'google',
+ label: 'Google Sync',
+ icon: Cloud,
+ },
+];
diff --git a/src/components/layout/sidebar/vaultNavState.ts b/src/components/layout/sidebar/vaultNavState.ts
new file mode 100644
index 00000000..f5d7b8b0
--- /dev/null
+++ b/src/components/layout/sidebar/vaultNavState.ts
@@ -0,0 +1,15 @@
+import type { AppSettings } from '../../../store/settingsSlice';
+
+export function resolveVaultExpanded(settings: AppSettings): boolean {
+ return settings.sidebarSections?.vaultExpanded ?? true;
+}
+
+export function nextSidebarSectionsForVaultToggle(
+ settings: AppSettings,
+ expanded: boolean,
+): AppSettings['sidebarSections'] {
+ return {
+ ...settings.sidebarSections,
+ vaultExpanded: !expanded,
+ };
+}
diff --git a/src/components/modals/AddConnectionModal.tsx b/src/components/modals/AddConnectionModal.tsx
index 17115807..41d276eb 100644
--- a/src/components/modals/AddConnectionModal.tsx
+++ b/src/components/modals/AddConnectionModal.tsx
@@ -1,4 +1,4 @@
-import { lazy, Suspense, useEffect, useMemo, useState } from 'react';
+import { lazy, Suspense, useEffect, useRef, useState } from 'react';
import { Modal } from '../ui/Modal';
import { Input } from '../ui/Input';
import { Button } from '../ui/Button';
@@ -7,14 +7,16 @@ import { OSIcon } from '../icons/OSIcon';
import { useAppStore, Connection } from '../../store/useAppStore';
import { open } from '@tauri-apps/plugin-dialog';
import { cn } from '../../lib/utils';
-import { ShieldCheck, CheckCircle2, AlertCircle, Loader2, FileText, Laptop, Files, ChevronDown, ChevronRight } from 'lucide-react';
+import { ShieldCheck, CheckCircle2, AlertCircle, Loader2, FileText, Laptop, Files, ChevronDown, ChevronRight, Shield, KeyRound } from 'lucide-react';
import { testConnectionIpc, type ConnectionConfigPayload } from '../../features/connections/infrastructure/connectionIpc';
-import { buildConnectionSavePayload, buildConnectionTestPayload, getCredentialHealthChecks, validateConnectionDraft } from '../../features/connections/domain';
-import { findDuplicateConnectionByEndpoint } from '../../features/connections/application/connectionService';
+import { buildConnectionSavePayload, buildConnectionTestPayload } from '../../features/connections/domain';
import {
importConnectionsFromFileIpc,
type ConnectionExchangeImportFormat,
} from '../../features/connections/infrastructure/connectionTransfer';
+import { useConnectionForm } from './useConnectionForm';
+import { useAutoVault } from './useAutoVault';
+
const ImportSshModal = lazy(async () => {
const module = await import('./ImportSshModal');
return { default: module.ImportSshModal };
@@ -42,92 +44,66 @@ const THEMES = [
];
export function AddConnectionModal({ isOpen, onClose, editingConnectionId }: AddConnectionModalProps) {
- const connections = useAppStore(state => state.connections);
- const folders = useAppStore(state => state.folders);
- const addConnection = useAppStore(state => state.addConnection);
- const editConnection = useAppStore(state => state.editConnection);
const importConnections = useAppStore(state => state.importConnections);
const showToast = useAppStore(state => state.showToast);
const openTab = useAppStore(state => state.openTab);
+ const {
+ connections, folders, addConnection, editConnection,
+ formData, setFormData,
+ authMethod, setAuthMethod,
+ keyInputMode, setKeyInputMode,
+ setTouched,
+ submitAttempted: _submitAttempted, setSubmitAttempted,
+ allowDuplicateEndpoint, setAllowDuplicateEndpoint,
+ activeEditingConnectionId,
+ validation,
+ visibleHostError, visibleUsernameError, visiblePortError, visibleKeyPathError,
+ duplicateConnection, credentialHealthChecks, jumpCycleWarning,
+ saveForm,
+ } = useConnectionForm(isOpen, editingConnectionId);
+
+ const {
+ vaultStatus, vaultItems, refreshItems,
+ pastedKeyText,
+ setPastedKeyText,
+ pastedPassphrase, setPastedPassphrase,
+ pastedKeyError, setPastedKeyError,
+ vaultLabel, setVaultLabel,
+ keyVaultLabel, setKeyVaultLabel,
+ defaultVaultLabel, effectiveVaultLabel: _effectiveVaultLabel, vaultLabelConflict,
+ defaultKeyVaultLabel, effectiveKeyVaultLabel: _effectiveKeyVaultLabel, keyVaultLabelConflict,
+ autoVaultPassword, autoVaultKeyFile, buildPastedKeyConnection,
+ } = useAutoVault({
+ isOpen,
+ formData,
+ authMethod,
+ keyInputMode,
+ activeEditingConnectionId,
+ validationOk: validation.ok,
+ showToast,
+ });
+
const [testStatus, setTestStatus] = useState<'idle' | 'testing' | 'success' | 'error'>('idle');
const [testMessage, setTestMessage] = useState('');
const [isAdvancedOpen, setIsAdvancedOpen] = useState(false);
const [showAllIcons, setShowAllIcons] = useState(false);
const [entryMode, setEntryMode] = useState<'chooser' | 'manual'>('manual');
const [isImportModalOpen, setIsImportModalOpen] = useState(false);
- const [submitAttempted, setSubmitAttempted] = useState(false);
- const [touched, setTouched] = useState({ host: false, username: false, port: false, keyPath: false });
-
- const [formData, setFormData] = useState>({
- name: '', host: '', username: '', port: 22, password: '', privateKeyPath: '', jumpServerId: undefined, icon: 'Server', folder: '', theme: '', tags: []
- });
- const [authMethod, setAuthMethod] = useState<'password' | 'key'>('password');
- const [allowDuplicateEndpoint, setAllowDuplicateEndpoint] = useState(false);
- const activeEditingConnectionId = useMemo(
- () => (editingConnectionId && connections.some((connection) => connection.id === editingConnectionId))
- ? editingConnectionId
- : null,
- [connections, editingConnectionId]
- );
+ const [isSaving, setIsSaving] = useState(false);
+ const lastImportPlaintextCountRef = useRef(0);
useEffect(() => {
if (!isOpen) return;
-
setTestStatus('idle');
setTestMessage('');
- setAllowDuplicateEndpoint(false);
setIsAdvancedOpen(!!activeEditingConnectionId);
setShowAllIcons(false);
setEntryMode(activeEditingConnectionId ? 'manual' : 'chooser');
- setSubmitAttempted(false);
- setTouched({ host: false, username: false, port: false, keyPath: false });
-
- if (activeEditingConnectionId) {
- const conn = useAppStore.getState().connections.find(c => c.id === activeEditingConnectionId);
- if (conn) {
- setFormData({
- ...conn,
- password: conn.password || '',
- privateKeyPath: conn.privateKeyPath || '',
- jumpServerId: conn.jumpServerId,
- icon: conn.icon || 'Server',
- tags: conn.tags || []
- });
- setAuthMethod(conn.privateKeyPath ? 'key' : 'password');
- return;
- }
-
- setFormData({ name: '', host: '', username: '', port: 22, password: '', privateKeyPath: '', jumpServerId: undefined, icon: 'Server', folder: '', theme: '', tags: [] });
- setAuthMethod('password');
- return;
- }
-
- setFormData({ name: '', host: '', username: '', port: 22, password: '', privateKeyPath: '', jumpServerId: undefined, icon: 'Server', folder: '', theme: '', tags: [] });
- setAuthMethod('password');
+ setIsSaving(false);
}, [activeEditingConnectionId, isOpen]);
- const validation = useMemo(
- () => validateConnectionDraft(formData, authMethod),
- [formData, authMethod]
- );
- const hostError = validation.fieldErrors.host || '';
- const usernameError = validation.fieldErrors.username || '';
- const keyPathError = validation.fieldErrors.privateKeyPath || '';
- const portError = validation.fieldErrors.port || '';
- const visibleHostError = (submitAttempted || touched.host) ? hostError : '';
- const visibleUsernameError = (submitAttempted || touched.username) ? usernameError : '';
- const visiblePortError = (submitAttempted || touched.port) ? portError : '';
- const visibleKeyPathError = (submitAttempted || touched.keyPath) ? keyPathError : '';
- const duplicateConnection = useMemo(
- () => findDuplicateConnectionByEndpoint(connections, formData, activeEditingConnectionId),
- [activeEditingConnectionId, connections, formData]
- );
- const credentialHealthChecks = useMemo(
- () => getCredentialHealthChecks(formData, authMethod),
- [formData, authMethod]
- );
- const canSave = !duplicateConnection || allowDuplicateEndpoint;
+ const canSave = (!duplicateConnection || allowDuplicateEndpoint) && !vaultLabelConflict && !keyVaultLabelConflict;
const selectedIcon = formData.icon || 'Server';
const compactIcons = ICONS.slice(0, 12);
const visibleIcons = showAllIcons
@@ -136,35 +112,69 @@ export function AddConnectionModal({ isOpen, onClose, editingConnectionId }: Add
? compactIcons
: [...compactIcons, selectedIcon];
- const saveForm = (): Connection | null => {
- if (!canSave || !validation.ok) return null;
-
- const connectionData = buildConnectionSavePayload({
- formData,
- authMethod,
- editingConnectionId: activeEditingConnectionId,
- connections,
- }) as Connection;
-
- if (activeEditingConnectionId) {
- editConnection(connectionData);
- } else {
- addConnection(connectionData);
+ const performSave = async (): Promise => {
+ if (isSaving) return null;
+ setIsSaving(true);
+ setSubmitAttempted(true);
+ try {
+ if (authMethod === 'key' && keyInputMode === 'paste') {
+ const connectionData = await buildPastedKeyConnection();
+ if (!connectionData) return null;
+ activeEditingConnectionId ? editConnection(connectionData) : addConnection(connectionData);
+ return connectionData;
+ }
+ if (authMethod === 'key' && keyInputMode === 'file' && vaultStatus?.status === 'unlocked' && formData.privateKeyPath) {
+ try {
+ const vaultedData = await autoVaultKeyFile();
+ if (vaultedData) {
+ const connectionData = buildConnectionSavePayload({
+ formData: vaultedData,
+ authMethod: 'vault',
+ editingConnectionId: activeEditingConnectionId,
+ connections: useAppStore.getState().connections,
+ });
+ activeEditingConnectionId ? editConnection(connectionData) : addConnection(connectionData);
+ await refreshItems();
+ return connectionData;
+ }
+ } catch (e: unknown) {
+ showToast('error', `Failed to encrypt key: ${e instanceof Error ? e.message : String(e)}`);
+ return null;
+ }
+ }
+ if (authMethod === 'password' && vaultStatus?.status === 'unlocked') {
+ try {
+ const vaultedData = await autoVaultPassword();
+ if (vaultedData) {
+ const connectionData = buildConnectionSavePayload({
+ formData: vaultedData,
+ authMethod: 'vault',
+ editingConnectionId: activeEditingConnectionId,
+ connections: useAppStore.getState().connections,
+ });
+ activeEditingConnectionId ? editConnection(connectionData) : addConnection(connectionData);
+ await refreshItems();
+ return connectionData;
+ }
+ } catch (e: unknown) {
+ showToast('error', `Failed to encrypt credential: ${e instanceof Error ? e.message : String(e)}`);
+ return null;
+ }
+ }
+ return saveForm(canSave);
+ } finally {
+ setIsSaving(false);
}
-
- return connectionData;
};
- const handleSave = () => {
- setSubmitAttempted(true);
- const saved = saveForm();
+ const handleSave = async () => {
+ const saved = await performSave();
if (!saved) return;
onClose();
};
- const handleSaveAndConnect = () => {
- setSubmitAttempted(true);
- const saved = saveForm();
+ const handleSaveAndConnect = async () => {
+ const saved = await performSave();
if (!saved) return;
openTab(saved.id, 'terminal');
onClose();
@@ -177,19 +187,11 @@ export function AddConnectionModal({ isOpen, onClose, editingConnectionId }: Add
setTestMessage(validation.errors[0] || 'Please fill required fields.');
return;
}
-
setTestStatus('testing');
setTestMessage('');
-
try {
- const config = buildConnectionTestPayload({
- formData,
- authMethod,
- connections,
- });
-
+ const config = buildConnectionTestPayload({ formData, authMethod, connections });
await testConnectionIpc(config as ConnectionConfigPayload);
-
setTestStatus('success');
setTestMessage('Connection successful!');
} catch (error: unknown) {
@@ -201,30 +203,12 @@ export function AddConnectionModal({ isOpen, onClose, editingConnectionId }: Add
const handleBrowseKey = async () => {
try {
- const selected = await open({
- multiple: false,
- directory: false,
- });
-
+ const selected = await open({ multiple: false, directory: false });
if (!selected) return;
const path = Array.isArray(selected) ? selected[0] : selected;
if (!path) return;
-
- if (!window?.ipcRenderer?.invoke) {
- setTouched((prev) => ({ ...prev, keyPath: true }));
- setFormData((prev) => ({ ...prev, privateKeyPath: path }));
- showToast('success', 'Private key path saved.');
- return;
- }
-
- try {
- const extractedPath = await window.ipcRenderer.invoke('ssh:extract-pem', path);
- setTouched((prev) => ({ ...prev, keyPath: true }));
- setFormData((prev) => ({ ...prev, privateKeyPath: extractedPath }));
- } catch {
- setTouched((prev) => ({ ...prev, keyPath: true }));
- setFormData((prev) => ({ ...prev, privateKeyPath: path }));
- }
+ setTouched((prev) => ({ ...prev, keyPath: true }));
+ setFormData((prev) => ({ ...prev, privateKeyPath: path }));
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error);
setTouched((prev) => ({ ...prev, keyPath: true }));
@@ -243,26 +227,25 @@ export function AddConnectionModal({ isOpen, onClose, editingConnectionId }: Add
const handleImportConnectionsFile = async () => {
try {
- const selected = await open({
- multiple: false,
- directory: false,
- });
-
+ const selected = await open({ multiple: false, directory: false });
if (!selected) return;
const path = Array.isArray(selected) ? selected[0] : selected;
if (!path || typeof path !== 'string') return;
-
const imported = await importConnectionsFromFileIpc({
path,
format: inferImportFormatFromPath(path),
});
-
const mappedConnections: Connection[] = (imported.connections || []).map((connection) => ({
...connection,
status: 'disconnected',
}));
importConnections(mappedConnections, imported.folders || []);
- showToast('success', `Imported ${mappedConnections.length} connection(s) from file.`);
+ const plaintextCount = mappedConnections.filter(c => !c.authRef && (c.password || c.privateKeyPath)).length;
+ if (plaintextCount > 0 && vaultStatus?.status !== 'uninitialized') {
+ showToast('success', `Imported ${mappedConnections.length} connection(s) — ${plaintextCount} have plaintext credentials. Open Vault tab to migrate.`);
+ } else {
+ showToast('success', `Imported ${mappedConnections.length} connection(s) from file.`);
+ }
onClose();
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error);
@@ -438,10 +421,15 @@ export function AddConnectionModal({ isOpen, onClose, editingConnectionId }: Add
label="Port"
type="number"
placeholder="22"
- value={formData.port}
+ value={formData.port ?? ''}
onChange={e => {
setTouched((prev) => ({ ...prev, port: true }));
- setFormData({ ...formData, port: Number(e.target.value) });
+ if (e.target.value === '') {
+ setFormData({ ...formData, port: undefined });
+ return;
+ }
+ const p = parseInt(e.target.value, 10);
+ if (!isNaN(p)) setFormData({ ...formData, port: p });
}}
error={visiblePortError}
/>
@@ -467,46 +455,226 @@ export function AddConnectionModal({ isOpen, onClose, editingConnectionId }: Add
>
Private Key
+ {vaultStatus?.status === 'unlocked' && (
+
+ )}
{authMethod === 'password' ? (
- setFormData({ ...formData, password: e.target.value })} />
+
+
setFormData({ ...formData, password: e.target.value })} />
+ {vaultStatus?.status === 'unlocked' && formData.password && (
+
+
+
+ setVaultLabel(e.target.value)}
+ placeholder={defaultVaultLabel}
+ className="flex-1 rounded-md border border-app-border/60 bg-app-bg px-2 py-1 text-[11px] text-app-text placeholder:text-app-muted/40 focus:outline-none focus:ring-1 focus:ring-app-accent/50"
+ />
+
+ {vaultLabelConflict ? (
+
+ A vault item with this label already exists — rename to avoid a duplicate.
+
+ ) : (
+
+ Password will be encrypted in vault on save.
+
+ )}
+
+ )}
+ {vaultStatus?.status === 'locked' && formData.password && (
+
+ Vault is locked — password will be saved as plaintext.
+
+ )}
+
+ ) : authMethod === 'key' ? (
+
+
+
+
+
+ {keyInputMode === 'file' ? (
+
+
+
+
+
+ {vaultStatus?.status === 'unlocked' && formData.privateKeyPath ? (
+
+
+
+ setKeyVaultLabel(e.target.value)}
+ placeholder={defaultKeyVaultLabel}
+ className="flex-1 rounded-md border border-app-border/60 bg-app-bg px-2 py-1 text-[11px] text-app-text placeholder:text-app-muted/40 focus:outline-none focus:ring-1 focus:ring-app-accent/50"
+ />
+
+ {keyVaultLabelConflict ? (
+
+ A vault item with this label already exists — rename to avoid a duplicate.
+
+ ) : (
+
+ Key will be read and encrypted in vault on save.
+
+ )}
+
+ ) : (
+
Key file path is stored and read at connect time.
+ )}
+ {vaultStatus?.status === 'locked' && formData.privateKeyPath && (
+
+ Vault is locked — key path will be saved as plaintext. Use "Paste to Vault" to encrypt.
+
+ )}
+
+ ) : (
+
+ {vaultStatus?.status === 'unlocked' && (
+
+
+ setKeyVaultLabel(e.target.value)}
+ placeholder={defaultKeyVaultLabel}
+ className="flex-1 rounded-md border border-app-border/60 bg-app-bg px-2 py-1 text-[11px] text-app-text placeholder:text-app-muted/40 focus:outline-none focus:ring-1 focus:ring-app-accent/50"
+ />
+
+ )}
+
+ )}
+
) : (
-
-
-
Vault Credential
+ {vaultItems.length === 0 ? (
+
+ No items in vault. Migrate existing connections to populate.
+
+ ) : (
+
-
Selected key will be securely used for this connection.
+ )}
+ {formData.authRef && (
+
+ Using vault item · {formData.authRef.itemId.slice(0, 8)}
+
+ )}
)}
-
+
Route this connection through another SSH server.
+ {jumpCycleWarning && (
+
+ Jump chain creates a loop — this connection will not be reachable.
+
+ )}
)}
@@ -614,10 +787,10 @@ export function AddConnectionModal({ isOpen, onClose, editingConnectionId }: Add
) : }
-
+
{activeEditingConnectionId ? 'Save Changes' : 'Create Connection'}
-
+
{activeEditingConnectionId ? 'Save & Open' : 'Save & Connect'}
@@ -633,6 +806,9 @@ export function AddConnectionModal({ isOpen, onClose, editingConnectionId }: Add
onClose={() => setIsImportModalOpen(false)}
onImport={(configs) => {
importConnections(configs);
+ lastImportPlaintextCountRef.current = configs.filter(
+ c => !c.connection.authRef && (c.connection.password || c.connection.privateKeyPath)
+ ).length;
}}
onImportReport={(report) => {
const renamedSuffix = report.renamed.length > 0
@@ -641,17 +817,22 @@ export function AddConnectionModal({ isOpen, onClose, editingConnectionId }: Add
const conflictSuffix = report.conflicted > 0
? `, ${report.conflicted} conflicts`
: '';
+ const count = lastImportPlaintextCountRef.current;
+ const migrationSuffix = count > 0 && vaultStatus?.status !== 'uninitialized'
+ ? ` — ${count} have plaintext credentials. Open Vault tab to migrate.`
+ : '.';
showToast(
'success',
- `Imported ${report.selected}: ${report.created} new, ${report.updated} updated, ${report.skipped} skipped${conflictSuffix}${renamedSuffix}.`
+ `Imported ${report.selected}: ${report.created} new, ${report.updated} updated, ${report.skipped} skipped${conflictSuffix}${renamedSuffix}${migrationSuffix}`
);
+ lastImportPlaintextCountRef.current = 0;
setIsImportModalOpen(false);
onClose();
}}
/>
)}
-
+
);
}
diff --git a/src/components/modals/useAutoVault.ts b/src/components/modals/useAutoVault.ts
new file mode 100644
index 00000000..589df3eb
--- /dev/null
+++ b/src/components/modals/useAutoVault.ts
@@ -0,0 +1,168 @@
+import { useEffect, useState } from 'react';
+import { invoke } from '@tauri-apps/api/core';
+import { useAppStore, Connection } from '../../store/useAppStore';
+import { useVaultStore } from '../../vault/useVaultStore';
+import { vaultIpc } from '../../vault/ipc';
+import { buildConnectionSavePayload } from '../../features/connections/domain';
+import { ToastType } from '../../store/toastSlice';
+
+interface UseAutoVaultOptions {
+ isOpen: boolean;
+ formData: Partial;
+ authMethod: 'password' | 'key' | 'vault';
+ keyInputMode: 'file' | 'paste';
+ activeEditingConnectionId: string | null;
+ validationOk: boolean;
+ showToast: (type: ToastType, message: string) => void;
+}
+
+export function useAutoVault({
+ isOpen,
+ formData,
+ authMethod,
+ keyInputMode,
+ activeEditingConnectionId,
+ validationOk,
+ showToast,
+}: UseAutoVaultOptions) {
+ const { status: vaultStatus, items: vaultItems, refreshItems } = useVaultStore();
+
+ const [pastedKeyText, setPastedKeyText] = useState('');
+ const [pastedPassphrase, setPastedPassphrase] = useState('');
+ const [pastedKeyError, setPastedKeyError] = useState('');
+ const [vaultLabel, setVaultLabel] = useState('');
+ const [keyVaultLabel, setKeyVaultLabel] = useState('');
+
+ useEffect(() => {
+ if (!isOpen) return;
+ setPastedKeyText('');
+ setPastedPassphrase('');
+ setPastedKeyError('');
+ setVaultLabel('');
+ setKeyVaultLabel('');
+ }, [isOpen]);
+
+ const defaultVaultLabel = `${formData.name || formData.host || 'credential'} (${formData.username || 'user'}@${formData.host || 'host'})`;
+ const effectiveVaultLabel = vaultLabel.trim() || defaultVaultLabel;
+ const vaultLabelConflict = vaultStatus?.status === 'unlocked' && authMethod === 'password' && !!formData.password
+ && vaultItems.some(i => i.label === effectiveVaultLabel);
+
+ const defaultKeyVaultLabel = `${formData.name || formData.host || 'credential'} key (${formData.username || 'user'}@${formData.host || 'host'})`;
+ const effectiveKeyVaultLabel = keyVaultLabel.trim() || defaultKeyVaultLabel;
+ const hasKeyInput = authMethod === 'key' && (
+ keyInputMode === 'file'
+ ? !!formData.privateKeyPath?.trim()
+ : !!pastedKeyText.trim()
+ );
+ const keyVaultLabelConflict = vaultStatus?.status === 'unlocked' && hasKeyInput
+ && vaultItems.some(i => i.label === effectiveKeyVaultLabel);
+
+ const deleteOldAuthItem = () => {
+ if (!activeEditingConnectionId) return;
+ const { connections } = useAppStore.getState();
+ const existing = connections.find(c => c.id === activeEditingConnectionId);
+ if (!existing?.authRef?.itemId) return;
+ vaultIpc.itemDelete(existing.authRef.itemId).catch(() => {
+ showToast('error', 'Old vault credential could not be deleted — remove it manually in Vault tab.');
+ });
+ };
+
+ const savePastedKey = async (): Promise | null> => {
+ const keyText = pastedKeyText.trim();
+ if (!keyText) {
+ showToast('error', 'Please paste a private key.');
+ setPastedKeyError('Please paste a private key.');
+ return null;
+ }
+ if (!isValidPrivateKeyFormat(keyText)) {
+ const message = 'Pasted key must include valid BEGIN/END private key markers.';
+ setPastedKeyError(message);
+ showToast('error', message);
+ return null;
+ }
+ const unlockedVault = vaultStatus?.status === 'unlocked' ? vaultStatus : null;
+ if (!unlockedVault) {
+ showToast('error', 'Vault must be unlocked to store a pasted key.');
+ return null;
+ }
+ setPastedKeyError('');
+ const secret = pastedPassphrase.trim()
+ ? JSON.stringify({ key: keyText, passphrase: pastedPassphrase })
+ : keyText;
+ const item = await vaultIpc.itemCreate(effectiveKeyVaultLabel, 'ssh-private-key', secret);
+ deleteOldAuthItem();
+ setPastedKeyText('');
+ setPastedPassphrase('');
+ return { ...formData, authRef: { vaultId: unlockedVault.vaultId, itemId: item.id, itemKind: 'ssh-private-key', purpose: 'ssh-auth' } };
+ };
+
+ const autoVaultPassword = async (): Promise | null> => {
+ if (vaultStatus?.status !== 'unlocked' || authMethod !== 'password') return null;
+ const password = (formData.password || '').trim();
+ if (!password) return null;
+ const item = await vaultIpc.itemCreate(effectiveVaultLabel, 'ssh-password', password);
+ deleteOldAuthItem();
+ return {
+ ...formData,
+ password: '',
+ authRef: { vaultId: vaultStatus.vaultId, itemId: item.id, itemKind: 'ssh-password', purpose: 'ssh-auth' },
+ };
+ };
+
+ const autoVaultKeyFile = async (): Promise | null> => {
+ if (vaultStatus?.status !== 'unlocked' || authMethod !== 'key' || keyInputMode !== 'file') return null;
+ const keyPath = (formData.privateKeyPath || '').trim();
+ if (!keyPath) return null;
+ let keyContent: string;
+ try {
+ keyContent = await invoke('plugin_fs_read', { path: keyPath });
+ } catch (e) {
+ throw new Error(`Could not read key file: ${e instanceof Error ? e.message : String(e)}`);
+ }
+ if (!isValidPrivateKeyFormat(keyContent)) {
+ throw new Error('Selected file does not appear to be a valid private key.');
+ }
+ const item = await vaultIpc.itemCreate(effectiveKeyVaultLabel, 'ssh-private-key', keyContent);
+ deleteOldAuthItem();
+ return {
+ ...formData,
+ privateKeyPath: '',
+ authRef: { vaultId: vaultStatus.vaultId, itemId: item.id, itemKind: 'ssh-private-key', purpose: 'ssh-auth' },
+ };
+ };
+
+ const buildPastedKeyConnection = async (): Promise => {
+ if (!validationOk) return null;
+ try {
+ const updatedData = await savePastedKey();
+ if (!updatedData) return null;
+ // Re-read connections fresh after the await to avoid stale closure.
+ const { connections } = useAppStore.getState();
+ return buildConnectionSavePayload({
+ formData: updatedData,
+ authMethod: 'vault',
+ editingConnectionId: activeEditingConnectionId,
+ connections,
+ });
+ } catch (e: unknown) {
+ showToast('error', `Failed to store key: ${e instanceof Error ? e.message : String(e)}`);
+ return null;
+ }
+ };
+
+ return {
+ vaultStatus, vaultItems, refreshItems,
+ pastedKeyText, setPastedKeyText,
+ pastedPassphrase, setPastedPassphrase,
+ pastedKeyError, setPastedKeyError,
+ vaultLabel, setVaultLabel,
+ keyVaultLabel, setKeyVaultLabel,
+ defaultVaultLabel, effectiveVaultLabel, vaultLabelConflict,
+ defaultKeyVaultLabel, effectiveKeyVaultLabel, keyVaultLabelConflict,
+ savePastedKey, autoVaultPassword, autoVaultKeyFile, buildPastedKeyConnection,
+ };
+}
+ const PRIVATE_KEY_BEGIN_PATTERN = /-----BEGIN [A-Z ]*PRIVATE KEY-----/;
+ const PRIVATE_KEY_END_PATTERN = /-----END [A-Z ]*PRIVATE KEY-----/;
+ const isValidPrivateKeyFormat = (keyContent: string): boolean =>
+ PRIVATE_KEY_BEGIN_PATTERN.test(keyContent) && PRIVATE_KEY_END_PATTERN.test(keyContent);
diff --git a/src/components/modals/useConnectionForm.ts b/src/components/modals/useConnectionForm.ts
new file mode 100644
index 00000000..03d58a9b
--- /dev/null
+++ b/src/components/modals/useConnectionForm.ts
@@ -0,0 +1,124 @@
+import { useEffect, useMemo, useState } from 'react';
+import { useAppStore, Connection } from '../../store/useAppStore';
+import {
+ validateConnectionDraft,
+ getCredentialHealthChecks,
+ buildConnectionSavePayload,
+} from '../../features/connections/domain';
+import { findDuplicateConnectionByEndpoint } from '../../features/connections/application/connectionService';
+
+const EMPTY_FORM: Partial = {
+ name: '', host: '', username: '', port: 22, password: '',
+ privateKeyPath: '', jumpServerId: undefined, icon: 'Server',
+ folder: '', theme: '', tags: [],
+};
+
+export function useConnectionForm(isOpen: boolean, editingConnectionId: string | null) {
+ const connections = useAppStore(state => state.connections);
+ const folders = useAppStore(state => state.folders);
+ const addConnection = useAppStore(state => state.addConnection);
+ const editConnection = useAppStore(state => state.editConnection);
+
+ const [formData, setFormData] = useState>(EMPTY_FORM);
+ const [authMethod, setAuthMethod] = useState<'password' | 'key' | 'vault'>('password');
+ const [keyInputMode, setKeyInputMode] = useState<'file' | 'paste'>('file');
+ const [touched, setTouched] = useState({ host: false, username: false, port: false, keyPath: false });
+ const [submitAttempted, setSubmitAttempted] = useState(false);
+ const [allowDuplicateEndpoint, setAllowDuplicateEndpoint] = useState(false);
+
+ const activeEditingConnectionId = useMemo(
+ () => (editingConnectionId && connections.some(c => c.id === editingConnectionId))
+ ? editingConnectionId
+ : null,
+ [connections, editingConnectionId]
+ );
+
+ useEffect(() => {
+ if (!isOpen) return;
+ setAllowDuplicateEndpoint(false);
+ setSubmitAttempted(false);
+ setTouched({ host: false, username: false, port: false, keyPath: false });
+ setKeyInputMode('file');
+
+ if (activeEditingConnectionId) {
+ const conn = useAppStore.getState().connections.find(c => c.id === activeEditingConnectionId);
+ if (conn) {
+ setFormData({
+ ...conn,
+ password: conn.password || '',
+ privateKeyPath: conn.privateKeyPath || '',
+ jumpServerId: conn.jumpServerId,
+ icon: conn.icon || 'Server',
+ tags: conn.tags || [],
+ });
+ setAuthMethod(conn.authRef ? 'vault' : conn.privateKeyPath ? 'key' : 'password');
+ return;
+ }
+ }
+ setFormData(EMPTY_FORM);
+ setAuthMethod('password');
+ }, [activeEditingConnectionId, isOpen]);
+
+ // Paste mode stores key in vault — treat as vault for field validation.
+ const effectiveAuthMode = authMethod === 'key' && keyInputMode === 'paste' ? 'vault' : authMethod;
+ const validation = useMemo(
+ () => validateConnectionDraft(formData, effectiveAuthMode),
+ [formData, effectiveAuthMode]
+ );
+ const hostError = validation.fieldErrors.host || '';
+ const usernameError = validation.fieldErrors.username || '';
+ const keyPathError = validation.fieldErrors.privateKeyPath || '';
+ const portError = validation.fieldErrors.port || '';
+ const visibleHostError = (submitAttempted || touched.host) ? hostError : '';
+ const visibleUsernameError = (submitAttempted || touched.username) ? usernameError : '';
+ const visiblePortError = (submitAttempted || touched.port) ? portError : '';
+ const visibleKeyPathError = (submitAttempted || touched.keyPath) ? keyPathError : '';
+
+ const duplicateConnection = useMemo(
+ () => findDuplicateConnectionByEndpoint(connections, formData, activeEditingConnectionId),
+ [activeEditingConnectionId, connections, formData]
+ );
+ const credentialHealthChecks = useMemo(
+ () => getCredentialHealthChecks(formData, effectiveAuthMode),
+ [formData, effectiveAuthMode]
+ );
+ const jumpCycleWarning = useMemo(() => {
+ if (!formData.jumpServerId || !activeEditingConnectionId) return false;
+ const visited = new Set();
+ let current: string | undefined = formData.jumpServerId;
+ while (current) {
+ if (current === activeEditingConnectionId) return true;
+ if (visited.has(current)) break;
+ visited.add(current);
+ current = connections.find(c => c.id === current)?.jumpServerId;
+ }
+ return false;
+ }, [formData.jumpServerId, activeEditingConnectionId, connections]);
+
+ const saveForm = (canSave: boolean): Connection | null => {
+ if (!canSave || !validation.ok) return null;
+ const connectionData = buildConnectionSavePayload({
+ formData,
+ authMethod,
+ editingConnectionId: activeEditingConnectionId,
+ connections,
+ });
+ activeEditingConnectionId ? editConnection(connectionData) : addConnection(connectionData);
+ return connectionData;
+ };
+
+ return {
+ connections, folders, addConnection, editConnection,
+ formData, setFormData,
+ authMethod, setAuthMethod,
+ keyInputMode, setKeyInputMode,
+ touched, setTouched,
+ submitAttempted, setSubmitAttempted,
+ allowDuplicateEndpoint, setAllowDuplicateEndpoint,
+ activeEditingConnectionId,
+ validation,
+ visibleHostError, visibleUsernameError, visiblePortError, visibleKeyPathError,
+ duplicateConnection, credentialHealthChecks, jumpCycleWarning,
+ saveForm,
+ };
+}
diff --git a/src/components/settings/tabs/VaultTab.tsx b/src/components/settings/tabs/VaultTab.tsx
new file mode 100644
index 00000000..e882b50e
--- /dev/null
+++ b/src/components/settings/tabs/VaultTab.tsx
@@ -0,0 +1,761 @@
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import { Shield, Lock, Unlock, Trash2, RefreshCw, ArrowRight, KeyRound, Download, Upload, Cloud, LogOut, Search } from 'lucide-react';
+import { open, save } from '@tauri-apps/plugin-dialog';
+import { useVaultStore } from '../../../vault/useVaultStore';
+import { vaultIpc, type MigrationPreview } from '../../../vault/ipc';
+import { notifySyncStatusChanged, syncIpc, type SyncProviderStatus } from '../../../vault/syncIpc';
+import { VaultUnlockModal } from '../../vault/VaultUnlockModal';
+import { RecoveryKeyModal } from '../../vault/RecoveryKeyModal';
+import { Button } from '../../ui/Button';
+import { useAppStore } from '../../../store/useAppStore';
+import { cn } from '../../../lib/utils';
+import { DEFAULT_VAULT_PROFILE_ID, type VaultProfileId } from '../../../vault/profileTypes';
+import { resolveVaultFocusProfile } from './vaultFocus';
+import { disconnectVaultBackedIpc } from '../../../features/connections/infrastructure/connectionIpc';
+import type { Connection } from '../../../features/connections/domain/types';
+
+interface VaultTabProps {
+ focusedProfileId?: VaultProfileId;
+}
+
+function connectionUsesVaultCredential(
+ connections: Connection[],
+ connectionId: string,
+ visited = new Set(),
+): boolean {
+ if (visited.has(connectionId)) return false;
+ visited.add(connectionId);
+
+ const connection = connections.find(item => item.id === connectionId);
+ if (!connection) return false;
+ if (connection.authRef) return true;
+ return connection.jumpServerId
+ ? connectionUsesVaultCredential(connections, connection.jumpServerId, visited)
+ : false;
+}
+
+export function VaultTab({
+ focusedProfileId = DEFAULT_VAULT_PROFILE_ID,
+}: VaultTabProps) {
+ const { status, items, refresh, lock, deleteItem, refreshItems } = useVaultStore();
+ const showToast = useAppStore((state) => state.showToast);
+ const showConfirmDialog = useAppStore((state) => state.showConfirmDialog);
+ const connections = useAppStore((state) => state.connections);
+ const tabs = useAppStore((state) => state.tabs);
+ const disconnectConnection = useAppStore((state) => state.disconnect);
+ const loadConnections = useAppStore((state) => state.loadConnections);
+
+ const [isUnlockModalOpen, setIsUnlockModalOpen] = useState(false);
+ const [migrationPreview, setMigrationPreview] = useState(null);
+ const [isMigrating, setIsMigrating] = useState(false);
+ const [isDeduplicating, setIsDeduplicating] = useState(false);
+ const [recoveryKey, setRecoveryKey] = useState('');
+ const [isRecoveryModalOpen, setIsRecoveryModalOpen] = useState(false);
+ const [hasRecoveryKey, setHasRecoveryKey] = useState(false);
+ const [googleSync, setGoogleSync] = useState(null);
+ const [isSyncing, setIsSyncing] = useState(false);
+ const [itemSearch, setItemSearch] = useState('');
+ const localSectionRef = useRef(null);
+ const googleSectionRef = useRef(null);
+
+ const isMigrableCandidate = useCallback(
+ (candidate: { migrationKind: string }) =>
+ candidate.migrationKind === 'ssh-password' || candidate.migrationKind === 'ssh-private-key',
+ []
+ );
+
+ const loadMigrationPreview = useCallback(async () => {
+ try {
+ const preview = await vaultIpc.migrationPreview();
+ setMigrationPreview(preview);
+ } catch (error) {
+ console.warn('[Vault] Failed to load migration preview:', error);
+ }
+ }, []);
+
+ useEffect(() => {
+ void refresh().catch(error => {
+ console.warn('[Vault] Failed to refresh vault status:', error);
+ });
+ syncIpc.status('google').then(setGoogleSync).catch(error => {
+ console.warn('[Vault] Failed to load Google sync status:', error);
+ });
+ }, [refresh]);
+
+ useEffect(() => {
+ const targetProfile = resolveVaultFocusProfile(focusedProfileId);
+ const target = targetProfile === 'google' ? googleSectionRef.current : localSectionRef.current;
+ if (!target) return;
+
+ requestAnimationFrame(() => {
+ target.scrollIntoView({ block: 'start', behavior: 'smooth' });
+ });
+ }, [focusedProfileId]);
+
+ useEffect(() => {
+ if (status?.status === 'unlocked') {
+ void refreshItems().catch(error => {
+ console.warn('[Vault] Failed to refresh vault items:', error);
+ });
+ loadMigrationPreview();
+ vaultIpc.hasRecoveryKey().then(setHasRecoveryKey).catch((error) => {
+ console.warn('[Vault] Failed to load recovery-key status:', error);
+ });
+ }
+ }, [loadMigrationPreview, refreshItems, status?.status]);
+
+ const handleLock = async () => {
+ try {
+ const backendVaultBackedIds = await disconnectVaultBackedIpc().catch((error) => {
+ console.warn('[Vault] Backend vault-backed disconnect failed:', error);
+ return [] as string[];
+ });
+ const activeTabConnectionIds = tabs
+ .map(tab => tab.connectionId)
+ .filter((id): id is string => Boolean(id) && id !== 'local');
+ const vaultBackedConnectionIds = new Set(
+ [
+ ...backendVaultBackedIds,
+ ...connections
+ .filter(connection =>
+ connection.id !== 'local'
+ && (connection.status === 'connected' || connection.status === 'connecting')
+ && connectionUsesVaultCredential(connections, connection.id)
+ )
+ .map(connection => connection.id),
+ ...activeTabConnectionIds.filter(id => connectionUsesVaultCredential(connections, id)),
+ ]
+ );
+ const vaultBackedIds = [...vaultBackedConnectionIds];
+ const disconnectResults = await Promise.allSettled(vaultBackedIds.map((id) => disconnectConnection(id)));
+ disconnectResults.forEach((result, index) => {
+ if (result.status === 'rejected') {
+ console.error('[Vault] Failed to disconnect vault-backed connection:', vaultBackedIds[index], result.reason);
+ }
+ });
+ await lock();
+ showToast('info', 'Vault locked.');
+ if (vaultBackedConnectionIds.size > 0) {
+ showToast(
+ 'info',
+ `Disconnected ${vaultBackedConnectionIds.size} vault-backed connection${vaultBackedConnectionIds.size > 1 ? 's' : ''} for security.`
+ );
+ }
+ } catch (e: unknown) {
+ const msg = e && typeof e === 'object' && 'message' in e
+ ? String((e as { message: unknown }).message)
+ : String(e);
+ showToast('error', `Failed to lock vault: ${msg}`);
+ }
+ };
+
+ const handleMigrate = async () => {
+ const migrableCount = migrationPreview?.candidates.filter(isMigrableCandidate).length ?? 0;
+
+ const confirmed = await showConfirmDialog({
+ title: 'Secure Credentials in Vault',
+ message: `Secure ${migrableCount} connection credential(s) in the encrypted vault. A backup will be saved first.`,
+ confirmText: 'Secure Keys',
+ });
+ if (!confirmed) return;
+
+ setIsMigrating(true);
+ try {
+ const result = await vaultIpc.migrateExistingSecrets();
+ showToast('success', `Secured ${result.migrated} credential(s).${result.backupPath ? ' Backup saved.' : ''}`);
+ await loadConnections();
+ await refresh();
+ await loadMigrationPreview();
+ } catch (e: unknown) {
+ const msg = e && typeof e === 'object' && 'message' in e
+ ? String((e as { message: unknown }).message)
+ : String(e);
+ showToast('error', `Migration failed: ${msg}`);
+ } finally {
+ setIsMigrating(false);
+ }
+ };
+
+ const handleDeleteItem = async (itemId: string, label: string) => {
+ const confirmed = await showConfirmDialog({
+ title: 'Delete Vault Item',
+ message: `Delete "${label}"? Connections referencing this item will fail to connect.`,
+ confirmText: 'Delete',
+ variant: 'danger',
+ });
+ if (!confirmed) return;
+
+ try {
+ await deleteItem(itemId);
+ showToast('success', `Deleted "${label}".`);
+ } catch (e: unknown) {
+ const msg = e && typeof e === 'object' && 'message' in e
+ ? String((e as { message: unknown }).message)
+ : String(e);
+ showToast('error', `Failed to delete: ${msg}`);
+ }
+ };
+
+ const handleGenerateRecoveryKey = async () => {
+ if (hasRecoveryKey) {
+ const confirmed = await showConfirmDialog({
+ title: 'Replace Recovery Key',
+ message: 'This will replace your existing recovery key. The old key will no longer work.',
+ confirmText: 'Replace',
+ variant: 'danger',
+ });
+ if (!confirmed) return;
+ }
+ try {
+ const key = await vaultIpc.generateRecoveryKey();
+ setRecoveryKey(key);
+ setHasRecoveryKey(true);
+ setIsRecoveryModalOpen(true);
+ } catch (e: unknown) {
+ const msg = e && typeof e === 'object' && 'message' in e
+ ? String((e as { message: unknown }).message) : String(e);
+ showToast('error', `Failed to generate recovery key: ${msg}`);
+ }
+ };
+
+ const handleExport = async () => {
+ try {
+ const destPath = await save({
+ defaultPath: 'zync-vault-backup.redb',
+ filters: [{ name: 'Vault Backup', extensions: ['redb'] }],
+ });
+ if (!destPath) return;
+ await vaultIpc.exportVault(destPath);
+ showToast('success', 'Vault exported successfully.');
+ } catch (e: unknown) {
+ const msg = e && typeof e === 'object' && 'message' in e
+ ? String((e as { message: unknown }).message) : String(e);
+ showToast('error', `Export failed: ${msg}`);
+ }
+ };
+
+ const handleImport = async () => {
+ const confirmed = await showConfirmDialog({
+ title: 'Import Vault',
+ message: 'This replaces your current vault with the imported file. A backup is saved first. You will need to unlock the imported vault with its passphrase.',
+ confirmText: 'Import',
+ variant: 'danger',
+ });
+ if (!confirmed) return;
+
+ try {
+ const srcPath = await open({
+ multiple: false,
+ directory: false,
+ filters: [{ name: 'Vault Backup', extensions: ['redb'] }],
+ });
+ if (!srcPath) return;
+ const path = Array.isArray(srcPath) ? srcPath[0] : srcPath;
+ await vaultIpc.importVault(path);
+ await refresh();
+ showToast('success', 'Vault imported. Please unlock it with the vault passphrase.');
+ } catch (e: unknown) {
+ const msg = e && typeof e === 'object' && 'message' in e
+ ? String((e as { message: unknown }).message) : String(e);
+ showToast('error', `Import failed: ${msg}`);
+ }
+ };
+
+ const handleGoogleConnect = async () => {
+ setIsSyncing(true);
+ try {
+ await syncIpc.connect('google');
+ const status = await syncIpc.status('google');
+ setGoogleSync(status);
+ showToast('success', `Connected to Google Drive${status.email ? ` as ${status.email}` : ''}.`);
+ } catch (e: unknown) {
+ const msg = e && typeof e === 'object' && 'message' in e
+ ? String((e as { message: unknown }).message) : String(e);
+ showToast('error', `Google Drive connection failed: ${msg}`);
+ } finally {
+ setIsSyncing(false);
+ }
+ };
+
+ const handleGoogleDisconnect = async () => {
+ const confirmed = await showConfirmDialog({
+ title: 'Disconnect Google Drive',
+ message: 'Remove the stored Google Drive tokens. The vault backup will remain in Drive.',
+ confirmText: 'Disconnect',
+ variant: 'danger',
+ });
+ if (!confirmed) return;
+ try {
+ await syncIpc.disconnect('google');
+ setGoogleSync({ connected: false });
+ showToast('info', 'Disconnected from Google Drive.');
+ } catch (e: unknown) {
+ const msg = e && typeof e === 'object' && 'message' in e
+ ? String((e as { message: unknown }).message)
+ : String(e);
+ if (msg.startsWith('Disconnected locally,')) {
+ setGoogleSync({ connected: false });
+ notifySyncStatusChanged('google', { connected: false });
+ showToast('info', 'Disconnected from Google Drive locally.');
+ return;
+ }
+ showToast('error', `Failed to disconnect from Google Drive: ${msg}`);
+ }
+ };
+
+ const handleSyncUpload = async () => {
+ setIsSyncing(true);
+ try {
+ const ts = await syncIpc.upload('google');
+ setGoogleSync((prev) => prev ? { ...prev, lastSync: ts } : prev);
+ showToast('success', 'Vault uploaded to Google Drive.');
+ } catch (e: unknown) {
+ const msg = e && typeof e === 'object' && 'message' in e
+ ? String((e as { message: unknown }).message) : String(e);
+ showToast('error', `Upload failed: ${msg}`);
+ } finally {
+ setIsSyncing(false);
+ }
+ };
+
+ const handleSyncDownload = async () => {
+ const confirmed = await showConfirmDialog({
+ title: 'Download Vault from Drive',
+ message: 'This replaces your local vault with the Drive backup. A local backup is saved first. You will need to unlock the vault afterwards.',
+ confirmText: 'Download',
+ variant: 'danger',
+ });
+ if (!confirmed) return;
+ setIsSyncing(true);
+ try {
+ await syncIpc.download('google');
+ await refresh();
+ showToast('success', 'Vault downloaded from Google Drive. Please unlock it.');
+ } catch (e: unknown) {
+ const msg = e && typeof e === 'object' && 'message' in e
+ ? String((e as { message: unknown }).message) : String(e);
+ showToast('error', `Download failed: ${msg}`);
+ } finally {
+ setIsSyncing(false);
+ }
+ };
+
+ const migrableCandidates = useMemo(
+ () => migrationPreview?.candidates.filter(isMigrableCandidate) ?? [],
+ [isMigrableCandidate, migrationPreview?.candidates]
+ );
+
+ const duplicateCount = useMemo(() => {
+ const seen = new Set();
+ let count = 0;
+ for (const item of items) {
+ const fingerprint = `${item.kind}:${item.secretFingerprint}`;
+ if (seen.has(fingerprint)) count++;
+ else seen.add(fingerprint);
+ }
+ return count;
+ }, [items]);
+
+ const filteredItems = useMemo(() => {
+ const search = itemSearch.trim().toLowerCase();
+ return search ? items.filter(item => item.label.toLowerCase().includes(search)) : items;
+ }, [items, itemSearch]);
+
+ const handleDeduplicateItems = async () => {
+ const confirmed = await showConfirmDialog({
+ title: 'Remove Duplicate Items',
+ message: `Found ${duplicateCount} duplicate vault item(s). Items referenced by a connection are kept; unreferenced duplicates are deleted. This cannot be undone.`,
+ confirmText: 'Remove Duplicates',
+ variant: 'danger',
+ });
+ if (!confirmed) return;
+
+ const referencedIds = new Set(connections.map(c => c.authRef?.itemId).filter(Boolean));
+ const toDelete: string[] = [];
+ const groups = new Map();
+
+ for (const item of items) {
+ const fingerprint = `${item.kind}:${item.secretFingerprint}`;
+ const group = groups.get(fingerprint);
+ if (group) {
+ group.push(item);
+ } else {
+ groups.set(fingerprint, [item]);
+ }
+ }
+
+ for (const group of groups.values()) {
+ if (group.length <= 1) continue;
+ const sorted = [...group].sort((a, b) => {
+ const aReferenced = referencedIds.has(a.id) ? 1 : 0;
+ const bReferenced = referencedIds.has(b.id) ? 1 : 0;
+ if (aReferenced !== bReferenced) return bReferenced - aReferenced;
+ return b.createdAt - a.createdAt;
+ });
+ const [, ...duplicates] = sorted;
+ toDelete.push(...duplicates.map(item => item.id));
+ }
+
+ setIsDeduplicating(true);
+ try {
+ const deleteResults = await Promise.allSettled(toDelete.map(id => vaultIpc.itemDelete(id)));
+ const failedDeletes = deleteResults.filter(result => result.status === 'rejected');
+ if (failedDeletes.length > 0) {
+ throw new Error(`${failedDeletes.length} duplicate item(s) could not be deleted.`);
+ }
+ await refreshItems();
+ showToast('success', `Removed ${toDelete.length} duplicate item(s).`);
+ } catch (e: unknown) {
+ const msg = e && typeof e === 'object' && 'message' in e ? String((e as { message: unknown }).message) : String(e);
+ showToast('error', `Deduplication failed: ${msg}`);
+ } finally {
+ setIsDeduplicating(false);
+ }
+ };
+
+ const isUnlocked = status?.status === 'unlocked';
+ const hasVaultConfigured = status?.status === 'locked' || status?.status === 'unlocked';
+ const unlockedStatus = isUnlocked ? status : null;
+ const googleStatusLabel = googleSync?.connected ? 'Connected' : 'Not connected';
+ const googleStatusTone = googleSync?.connected
+ ? 'bg-emerald-500/15 text-emerald-300 border-emerald-500/30'
+ : 'bg-[var(--color-app-surface)] text-[var(--color-app-muted)] border-[var(--color-app-border)]/60';
+
+ return (
+
+ {/* Status card */}
+
+
+
+
+
+
+
+
+ {isUnlocked ? 'Vault Unlocked'
+ : status?.status === 'locked' ? 'Vault Locked'
+ : 'Vault Not Set Up'}
+
+
+ {isUnlocked
+ ? `${unlockedStatus?.itemCount ?? 0} item(s) · XChaCha20-Poly1305 encrypted`
+ : status?.status === 'locked'
+ ? 'Unlock to access and manage credentials'
+ : 'Create a vault to store SSH credentials securely'}
+
+
+
+
+ {isUnlocked ? (
+
+
+ Lock
+
+ ) : (
+
setIsUnlockModalOpen(true)} className="gap-1.5 shrink-0">
+ {status?.status === 'locked' ? : }
+ {status?.status === 'locked' ? 'Unlock' : 'Set Up Vault'}
+
+ )}
+
+
+
+ {/* Migration banner */}
+ {isUnlocked && migrableCandidates.length > 0 && (
+
+
+
+
Unsecured credentials detected
+
+ {migrableCandidates.length} connection{migrableCandidates.length > 1 ? 's have' : ' has'} credentials
+ stored in plaintext. Secure them with vault encryption at rest.
+
+ {(migrationPreview?.alreadyMigrated ?? 0) > 0 || (migrationPreview?.skippedNoFile ?? 0) > 0 ? (
+
+ {migrationPreview?.alreadyMigrated ?? 0} already use vault auth
+ {(migrationPreview?.skippedNoFile ?? 0) > 0
+ ? ` · ${migrationPreview?.skippedNoFile ?? 0} skipped (key file missing)`
+ : ''}
+
+ ) : null}
+
+
+ {isMigrating
+ ?
+ : }
+ Secure Keys
+
+
+
+ )}
+
+ {/* Security actions */}
+ {isUnlocked && (
+
+
+ Security
+
+
+
+
+
Recovery Key
+
+ {hasRecoveryKey ? 'A recovery key is set' : 'No recovery key — create one as a passphrase fallback'}
+
+
+
+
+ {hasRecoveryKey ? 'Regenerate' : 'Generate'}
+
+
+
+
+
Export Vault
+
Save an encrypted backup of the vault file
+
+
+
+ Export
+
+
+
+
+
Import Vault
+
Replace the vault from a backup file
+
+
+
+ Import
+
+
+
+
+ )}
+
+ {/* Cloud Sync */}
+
+
+
+ Cloud Sync
+
+
+
+
+
+
+
+
+
Google Drive
+
+
+ {googleStatusLabel}
+
+ {googleSync?.email && (
+
+ {googleSync.email}
+
+ )}
+
+ {!googleSync?.connected && (
+
+ Syncs to your Drive appdata folder (encrypted).
+
+ )}
+
+
+ {googleSync?.connected ? (
+
+
+ Disconnect
+
+ ) : (
+
+ {isSyncing ? : }
+ Connect
+
+ )}
+
+
+ {googleSync?.connected && (
+
+
+
+ {isSyncing ? : }
+ Backup to Drive
+
+
+
+ Restore from Drive
+
+ {googleSync.lastSync && (
+
+ Last sync: {new Date(googleSync.lastSync * 1000).toLocaleString()}
+
+ )}
+
+ {!hasVaultConfigured && (
+
+ Create or unlock a vault first, then use Backup/Restore.
+
+ )}
+
+ )}
+
+
+ The vault is always encrypted before upload. Zync never uploads plaintext data.
+
+
+

+
+ Powered by Zync Vault encryption
+
+
+ {!googleSync?.connected && (
+
+ Tip: on the Google sign-in screen, make sure to check the Drive checkbox; Google requires explicit consent for storage access.
+
+ )}
+
+
+
+ {/* Items list */}
+ {isUnlocked && (
+
+
+
+ Stored Items
+
+ {itemSearch
+ ? `${filteredItems.length} of ${items.length}`
+ : items.length}
+
+
+ {duplicateCount > 0 && (
+
+ {isDeduplicating ? : null}
+ {duplicateCount} duplicate{duplicateCount > 1 ? 's' : ''} — clean up
+
+ )}
+
+ {items.length === 0 ? (
+
+
No items in vault
+
+ Items are added when you migrate connection credentials.
+
+
+ ) : (
+ <>
+
+
+ setItemSearch(e.target.value)}
+ className="w-full rounded-lg border border-app-border/60 bg-app-surface/25 pl-8 pr-3 py-2 text-xs text-app-text placeholder:text-app-muted/50 focus:outline-none focus:ring-1 focus:ring-app-accent/50"
+ />
+
+
+ {filteredItems.length === 0 ? (
+
+
No items match "{itemSearch}"
+
+ ) : (
+ filteredItems
+ .map((item) => (
+
+
+
{item.label}
+
+ {item.kind} · {item.id.slice(0, 8)}
+
+
+
handleDeleteItem(item.id, item.label)}
+ className="opacity-0 group-hover:opacity-100 focus:opacity-100 focus-visible:opacity-100 focus:outline-none focus:ring-2 focus:ring-red-400/40 p-1.5 rounded-md text-[var(--color-app-muted)] hover:text-red-400 hover:bg-red-400/10 transition-all"
+ title="Delete item"
+ aria-label={`Delete ${item.label}`}
+ >
+
+
+
+ ))
+ )}
+
+ >
+ )}
+
+ )}
+
+
{
+ setRecoveryKey('');
+ setIsRecoveryModalOpen(false);
+ }}
+ />
+
+ {
+ setIsUnlockModalOpen(false);
+ void refresh().catch(error => {
+ console.warn('[Vault] Failed to refresh vault after unlock modal close:', error);
+ });
+ }}
+ />
+
+ );
+}
diff --git a/src/components/settings/tabs/vaultFocus.ts b/src/components/settings/tabs/vaultFocus.ts
new file mode 100644
index 00000000..761cfb35
--- /dev/null
+++ b/src/components/settings/tabs/vaultFocus.ts
@@ -0,0 +1,5 @@
+import type { VaultProfileId } from '../../../vault/profileTypes';
+
+export function resolveVaultFocusProfile(profileId: VaultProfileId | undefined): VaultProfileId {
+ return profileId === 'google' ? 'google' : 'local';
+}
diff --git a/src/components/ui/Input.tsx b/src/components/ui/Input.tsx
index 8ed24187..0222e9ee 100644
--- a/src/components/ui/Input.tsx
+++ b/src/components/ui/Input.tsx
@@ -1,27 +1,37 @@
-import { forwardRef, type InputHTMLAttributes } from 'react';
+import { forwardRef, type InputHTMLAttributes, type ReactNode } from 'react';
import { cn } from '../../lib/utils';
interface InputProps extends InputHTMLAttributes {
label?: string;
error?: string;
+ rightElement?: ReactNode;
}
-export const Input = forwardRef(({ className, label, error, ...props }, ref) => {
+export const Input = forwardRef(({ className, label, error, rightElement, ...props }, ref) => {
const isNumber = props.type === 'number';
return (
{label &&
}
-
+
+ {rightElement && (
+
+ {rightElement}
+
)}
- {...props}
- />
+
{error && {error}}
);
diff --git a/src/components/ui/Modal.tsx b/src/components/ui/Modal.tsx
index b357ea81..15e370b8 100644
--- a/src/components/ui/Modal.tsx
+++ b/src/components/ui/Modal.tsx
@@ -16,6 +16,9 @@ interface ModalProps {
headerClassName?: string;
contentClassName?: string;
titleClassName?: string;
+ closeOnEsc?: boolean;
+ closeOnOverlayClick?: boolean;
+ showCloseButton?: boolean;
}
/**
@@ -31,6 +34,9 @@ interface ModalProps {
* @param headerClassName - Optional classes applied to the modal header container.
* @param contentClassName - Optional classes applied to the modal body/content container.
* @param titleClassName - Optional classes applied to the modal title text.
+ * @param closeOnEsc - Whether pressing Escape closes the modal (default true).
+ * @param closeOnOverlayClick - Whether clicking the overlay closes the modal (default true).
+ * @param showCloseButton - Whether to render the close button in the header (default true).
* @returns The modal element mounted into the ZPortal target when `isOpen` is true, otherwise null.
*/
export function Modal({
@@ -44,15 +50,31 @@ export function Modal({
headerClassName,
contentClassName,
titleClassName,
+ closeOnEsc = true,
+ closeOnOverlayClick = true,
+ showCloseButton = true,
}: ModalProps) {
+ const hasCloseMechanism = closeOnEsc || closeOnOverlayClick || showCloseButton;
+ const effectiveShowCloseButton = hasCloseMechanism ? showCloseButton : true;
+
+ useEffect(() => {
+ if (import.meta.env.DEV && !hasCloseMechanism) {
+ console.warn(
+ '[Modal] closeOnEsc, closeOnOverlayClick, and showCloseButton are all false; forcing close button for accessibility.'
+ );
+ }
+ }, [hasCloseMechanism]);
+
useEffect(() => {
const handleEsc = (e: KeyboardEvent) => {
if (document.querySelector('[data-zync-select-open="true"]')) return;
if (e.key === 'Escape') onClose();
};
- if (isOpen) window.addEventListener('keydown', handleEsc, { capture: true });
+ if (isOpen && closeOnEsc) {
+ window.addEventListener('keydown', handleEsc, { capture: true });
+ }
return () => window.removeEventListener('keydown', handleEsc, { capture: true });
- }, [isOpen, onClose]);
+ }, [closeOnEsc, isOpen, onClose]);
return (
@@ -64,7 +86,7 @@ export function Modal({
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.15 }}
- onClick={onClose}
+ onClick={closeOnOverlayClick ? onClose : undefined}
className="absolute inset-0 bg-black/70 backdrop-blur-md"
/>
{subtitle}
)}
-
-
-
+ {effectiveShowCloseButton && (
+
+
+
+ )}
{children}
diff --git a/src/components/vault/RecoveryKeyModal.tsx b/src/components/vault/RecoveryKeyModal.tsx
new file mode 100644
index 00000000..f15d03a8
--- /dev/null
+++ b/src/components/vault/RecoveryKeyModal.tsx
@@ -0,0 +1,130 @@
+import { useEffect, useRef, useState } from 'react';
+import { Modal } from '../ui/Modal';
+import { Button } from '../ui/Button';
+import { KeyRound, Copy, Check, Download, AlertTriangle } from 'lucide-react';
+
+interface Props {
+ isOpen: boolean;
+ recoveryKey: string;
+ onClose: () => void;
+}
+
+export function RecoveryKeyModal({ isOpen, recoveryKey, onClose }: Props) {
+ const [copied, setCopied] = useState(false);
+ const copyTimeoutRef = useRef(null);
+
+ useEffect(() => {
+ if (copyTimeoutRef.current !== null) {
+ window.clearTimeout(copyTimeoutRef.current);
+ copyTimeoutRef.current = null;
+ }
+ setCopied(false);
+ }, [isOpen, recoveryKey]);
+
+ useEffect(() => {
+ return () => {
+ if (copyTimeoutRef.current !== null) {
+ window.clearTimeout(copyTimeoutRef.current);
+ copyTimeoutRef.current = null;
+ }
+ };
+ }, []);
+
+ const handleCopy = async () => {
+ try {
+ await navigator.clipboard.writeText(recoveryKey);
+ setCopied(true);
+ if (copyTimeoutRef.current !== null) {
+ window.clearTimeout(copyTimeoutRef.current);
+ }
+ copyTimeoutRef.current = window.setTimeout(() => setCopied(false), 2000);
+ } catch (error) {
+ console.warn('[Vault] Failed to copy recovery key:', error);
+ setCopied(false);
+ }
+ };
+
+ const handleDownload = () => {
+ const content = [
+ 'Zync Vault Recovery Key',
+ '=======================',
+ '',
+ 'Store this file somewhere safe and offline.',
+ 'This key can unlock your vault if you forget your passphrase.',
+ '',
+ recoveryKey,
+ '',
+ `Generated: ${new Date().toISOString()}`,
+ ].join('\n');
+
+ const blob = new Blob([content], { type: 'text/plain' });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ a.download = 'zync-vault-recovery-key.txt';
+ a.click();
+ URL.revokeObjectURL(url);
+ };
+
+ // Split key into visual groups of 4 chars for display
+ const groups = recoveryKey.split('-');
+
+ return (
+
+
+ {/* Warning banner */}
+
+
+
+ This key is shown only once. Write it down or save the file before closing this dialog.
+
+
+
+ {/* Key display */}
+
+
+
+
+ Recovery Key
+
+
+
+ {groups.map((group, i) => (
+
+ {group}
+ {i < groups.length - 1 && (
+ -
+ )}
+
+ ))}
+
+
+
+ {/* Actions */}
+
+
+ {copied ? : }
+ {copied ? 'Copied!' : 'Copy Key'}
+
+
+
+ Save File
+
+
+
+
+ I've saved my recovery key
+
+
+
+ );
+}
diff --git a/src/components/vault/VaultUnlockModal.tsx b/src/components/vault/VaultUnlockModal.tsx
new file mode 100644
index 00000000..0ea71c09
--- /dev/null
+++ b/src/components/vault/VaultUnlockModal.tsx
@@ -0,0 +1,213 @@
+import { useState } from 'react';
+import { Modal } from '../ui/Modal';
+import { Input } from '../ui/Input';
+import { Button } from '../ui/Button';
+import { Shield, Lock, Eye, EyeOff } from 'lucide-react';
+import { useVaultStore } from '../../vault/useVaultStore';
+
+interface Props {
+ isOpen: boolean;
+ onClose: () => void;
+}
+
+export function VaultUnlockModal({ isOpen, onClose }: Props) {
+ const { status, initialize, unlock, unlockWithRecoveryKey, isLoading, clearError } = useVaultStore();
+ const isUninitialized = !status || status.status === 'uninitialized';
+ const canUseRecoveryKey = !isUninitialized;
+
+ const [passphrase, setPassphrase] = useState('');
+ const [confirm, setConfirm] = useState('');
+ const [recoveryKey, setRecoveryKey] = useState('');
+ const [showPass, setShowPass] = useState(false);
+ const [localError, setLocalError] = useState('');
+ const [unlockMode, setUnlockMode] = useState<'passphrase' | 'recovery'>('passphrase');
+
+ const extractError = (error: unknown): { code?: string; message: string } => {
+ if (error && typeof error === 'object') {
+ const code = 'code' in error ? String((error as { code: unknown }).code) : undefined;
+ const message = 'message' in error
+ ? String((error as { message: unknown }).message)
+ : String(error);
+ return { code, message };
+ }
+ return { message: String(error) };
+ };
+
+ const handleClose = () => {
+ setPassphrase('');
+ setConfirm('');
+ setRecoveryKey('');
+ setUnlockMode('passphrase');
+ setLocalError('');
+ clearError();
+ onClose();
+ };
+
+ const handleSubmit = async (e: React.FormEvent) => {
+ e.preventDefault();
+ setLocalError('');
+ clearError();
+
+ if (isUninitialized) {
+ if (passphrase.length < 8) {
+ setLocalError('Passphrase must be at least 8 characters.');
+ return;
+ }
+ if (passphrase !== confirm) {
+ setLocalError('Passphrases do not match.');
+ return;
+ }
+ try {
+ await initialize(passphrase);
+ handleClose();
+ } catch (e: unknown) {
+ const { message } = extractError(e);
+ setLocalError(message || 'Failed to create vault.');
+ }
+ } else if (unlockMode === 'recovery') {
+ if (!recoveryKey.trim()) {
+ setLocalError('Recovery key is required.');
+ return;
+ }
+ try {
+ await unlockWithRecoveryKey(recoveryKey.trim());
+ handleClose();
+ } catch (e: unknown) {
+ const { code, message } = extractError(e);
+ const raw = `${code ?? ''} ${message}`.trim();
+ setLocalError(raw || 'Failed to unlock with recovery key.');
+ }
+ } else {
+ try {
+ await unlock(passphrase);
+ handleClose();
+ } catch (e: unknown) {
+ const { code, message } = extractError(e);
+ const raw = `${code ?? ''} ${message}`.trim();
+ setLocalError(
+ raw.includes('wrong_passphrase') ? 'Incorrect passphrase.' : raw
+ );
+ }
+ }
+ };
+
+ const title = isUninitialized ? 'Create Vault' : 'Unlock Vault';
+ const subtitle = isUninitialized
+ ? 'Set a strong passphrase to protect your credentials.'
+ : unlockMode === 'recovery'
+ ? 'Enter your recovery key to unlock the vault.'
+ : 'Enter your vault passphrase to access credentials.';
+
+ return (
+
+
+
+ );
+}
diff --git a/src/components/vault/VaultWorkspacePanel.tsx b/src/components/vault/VaultWorkspacePanel.tsx
new file mode 100644
index 00000000..bfd77f7d
--- /dev/null
+++ b/src/components/vault/VaultWorkspacePanel.tsx
@@ -0,0 +1,40 @@
+import { VaultTab } from '../settings/tabs/VaultTab';
+import { DEFAULT_VAULT_PROFILE_ID, type VaultProfileId } from '../../vault/profileTypes';
+import { cn } from '../../lib/utils';
+
+interface VaultWorkspacePanelProps {
+ profileId?: VaultProfileId;
+}
+
+const VAULT_PROFILES: ReadonlyArray<{ id: VaultProfileId; label: string }> = [
+ { id: 'local', label: 'Local Vault' },
+ { id: 'google', label: 'Google Drive Sync' },
+];
+
+export default function VaultWorkspacePanel({
+ profileId = DEFAULT_VAULT_PROFILE_ID,
+}: VaultWorkspacePanelProps) {
+ return (
+
+
+
Vault Profiles (current scope)
+
+ {VAULT_PROFILES.map(item => (
+
+ {item.label}
+
+ ))}
+
+
+
+
+ );
+}
diff --git a/src/features/connections/application/tabService.ts b/src/features/connections/application/tabService.ts
index 2d0a9818..6e86927a 100644
--- a/src/features/connections/application/tabService.ts
+++ b/src/features/connections/application/tabService.ts
@@ -1,4 +1,5 @@
import type { Connection, Tab } from '../domain/types.js';
+import type { VaultProfileId } from '../../../vault/profileTypes.js';
export interface TabState {
tabs: Tab[];
@@ -116,3 +117,35 @@ export const ensureGlobalSnippetsTab = (
activeConnectionId: GLOBAL_SNIPPETS_CONNECTION_ID,
};
};
+
+export const ensureVaultTabState = (
+ tabs: Tab[],
+ profileId: VaultProfileId,
+): { tabs: Tab[]; activeTabId: string; activeConnectionId: null } => {
+ const existing = tabs.find((tab) => tab.type === 'vault');
+ if (existing) {
+ return {
+ tabs: tabs.flatMap((tab) => {
+ if (tab.id === existing.id) return [{ ...tab, vaultProfileId: profileId }];
+ if (tab.type === 'vault') return [];
+ return [tab];
+ }),
+ activeTabId: existing.id,
+ activeConnectionId: null,
+ };
+ }
+
+ const newTab: Tab = {
+ id: crypto.randomUUID(),
+ type: 'vault',
+ title: 'Vault',
+ view: 'terminal',
+ vaultProfileId: profileId,
+ };
+
+ return {
+ tabs: [...tabs, newTab],
+ activeTabId: newTab.id,
+ activeConnectionId: null,
+ };
+};
diff --git a/src/features/connections/domain/connectionConfig.ts b/src/features/connections/domain/connectionConfig.ts
index 223ddd7c..19ecb434 100644
--- a/src/features/connections/domain/connectionConfig.ts
+++ b/src/features/connections/domain/connectionConfig.ts
@@ -11,7 +11,16 @@ export interface ConnectAuthMethodPrivateKey {
passphrase: string | null;
}
-export type ConnectAuthMethod = ConnectAuthMethodPassword | ConnectAuthMethodPrivateKey;
+/** Sent when the connection uses a vault credential. Backend resolves item_id → secret. */
+export interface ConnectAuthMethodVaultRef {
+ type: 'VaultRef';
+ item_id: string;
+}
+
+export type ConnectAuthMethod =
+ | ConnectAuthMethodPassword
+ | ConnectAuthMethodPrivateKey
+ | ConnectAuthMethodVaultRef;
export interface ConnectConfig {
id: string;
@@ -36,9 +45,11 @@ export const buildConnectConfig = (
const connection = connections.find((item) => item.id === connectionId);
if (!connection) return null;
- const auth_method: ConnectAuthMethod = connection.privateKeyPath
- ? { type: 'PrivateKey', key_path: connection.privateKeyPath, passphrase: connection.password || null }
- : { type: 'Password', password: connection.password || '' };
+ const auth_method: ConnectAuthMethod = connection.authRef
+ ? { type: 'VaultRef', item_id: connection.authRef.itemId }
+ : connection.privateKeyPath
+ ? { type: 'PrivateKey', key_path: connection.privateKeyPath, passphrase: connection.password || null }
+ : { type: 'Password', password: connection.password || '' };
const config: ConnectConfig = {
id: connection.id,
diff --git a/src/features/connections/domain/formTransforms.ts b/src/features/connections/domain/formTransforms.ts
index 48cb19b6..e9881781 100644
--- a/src/features/connections/domain/formTransforms.ts
+++ b/src/features/connections/domain/formTransforms.ts
@@ -1,7 +1,7 @@
import type { Connection } from './types.js';
import { normalizeFolderPath, normalizeTags, normalizeText, parsePort } from './normalization.js';
-export type ConnectionAuthMode = 'password' | 'key';
+export type ConnectionAuthMode = 'password' | 'key' | 'vault';
export type ConnectionFormDraft = Partial;
@@ -11,7 +11,10 @@ interface ToBackendConfig {
host: string;
port: number;
username: string;
- auth_method: { type: 'Password'; password: string } | { type: 'PrivateKey'; key_path: string; passphrase: null };
+ auth_method:
+ | { type: 'Password'; password: string }
+ | { type: 'PrivateKey'; key_path: string; passphrase: null }
+ | { type: 'VaultRef'; item_id: string };
jump_host: ToBackendConfig | null;
}
@@ -38,13 +41,22 @@ const resolveAuthMethod = (
if (!normalizedPassword) throw new Error('Password is required for password auth.');
return { type: 'Password', password: normalizedPassword };
}
+ if (authMode === 'vault') {
+ const authRef = (candidate as Connection).authRef;
+ const itemId = authRef?.itemId;
+ if (!itemId) throw new Error('No vault credential selected.');
+ return { type: 'VaultRef', item_id: itemId };
+ }
const normalizedKeyPath = normalizeText(keyPath);
if (!normalizedKeyPath) throw new Error('Private key path is required for key auth.');
return { type: 'PrivateKey', key_path: normalizedKeyPath, passphrase: null };
}
- // Use privateKeyPath as the discriminator — matches buildConnectConfig and correctly
- // handles connections loaded from Rust where password comes back as null (not undefined).
+ // Use authRef as highest-priority discriminator for existing connections.
+ const itemId = (candidate as Connection).authRef?.itemId;
+ if (itemId) {
+ return { type: 'VaultRef', item_id: itemId };
+ }
if (candidate.privateKeyPath) {
const normalizedKeyPath = normalizeText(candidate.privateKeyPath);
if (!normalizedKeyPath) throw new Error('Private key path is required for key auth.');
@@ -96,6 +108,7 @@ const buildJumpChain = (
if (!jumpConnection) return null;
return {
+ // Auth mode is ignored when `candidate` is an existing Connection (isForm=false).
...toBackendConfig(jumpConnection, {} as ConnectionFormDraft, 'password'),
jump_host: buildJumpChain(connections, jumpConnection.jumpServerId, new Set(visited)),
};
@@ -117,6 +130,9 @@ export const buildConnectionSavePayload = ({
const name = normalizeText(formData.name) || host;
const portResult = parsePort(formData.port);
if (portResult.error) throw new Error(portResult.error);
+ if (authMethod === 'vault' && !formData.authRef?.itemId) {
+ throw new Error('No vault credential selected.');
+ }
return {
id: editingConnectionId || crypto.randomUUID(),
@@ -126,6 +142,7 @@ export const buildConnectionSavePayload = ({
port: portResult.normalizedPort,
password: authMethod === 'password' ? formData.password : undefined,
privateKeyPath: authMethod === 'key' ? formData.privateKeyPath : undefined,
+ authRef: authMethod === 'vault' ? formData.authRef : undefined,
status: editingConnectionId ? (connections.find((c) => c.id === editingConnectionId)?.status || 'disconnected') : 'disconnected',
jumpServerId: formData.jumpServerId,
icon: formData.icon,
diff --git a/src/features/connections/domain/merge.ts b/src/features/connections/domain/merge.ts
index ac116c30..1c9a9f8f 100644
--- a/src/features/connections/domain/merge.ts
+++ b/src/features/connections/domain/merge.ts
@@ -14,6 +14,27 @@ const generateUniqueId = (usedIds: Set): string => {
return next;
};
+export const preserveVaultCredentialOnUpdate = (
+ existing: Connection,
+ incoming: Connection,
+): Connection => {
+ if (incoming.authRef) {
+ return {
+ ...incoming,
+ password: undefined,
+ privateKeyPath: undefined,
+ };
+ }
+ if (!existing.authRef) return incoming;
+
+ return {
+ ...incoming,
+ authRef: existing.authRef,
+ password: undefined,
+ privateKeyPath: undefined,
+ };
+};
+
// Current strategy parity: name is the import identity key in existing flow.
export const mergeImportedConnectionsByName = (
existing: Connection[],
@@ -37,21 +58,15 @@ export const mergeImportedConnectionsByName = (
for (const incoming of imported) {
const matches = existingMap.get(incoming.name);
+ // Duplicate names are matched in order: each imported entry consumes the
+ // next existing match, then preserveVaultCredentialOnUpdate keeps vault
+ // secrets while matchedIds/mergedImported track the update target.
const match = matches && matches.length > 0 ? matches.shift() : undefined;
if (match) {
updated += 1;
matchedIds.add(match.id);
- const preservedMetadata: Partial = {};
- if (match.isFavorite !== undefined) preservedMetadata.isFavorite = match.isFavorite;
- if (match.pinnedFeatures !== undefined) preservedMetadata.pinnedFeatures = match.pinnedFeatures;
- if (match.icon !== undefined) preservedMetadata.icon = match.icon;
- if (match.lastConnected !== undefined) preservedMetadata.lastConnected = match.lastConnected;
- if (match.homePath !== undefined) preservedMetadata.homePath = match.homePath;
- if (match.createdAt !== undefined) preservedMetadata.createdAt = match.createdAt;
- if (match.folder !== undefined) preservedMetadata.folder = match.folder;
- if (match.theme !== undefined) preservedMetadata.theme = match.theme;
- if (match.tags !== undefined) preservedMetadata.tags = match.tags;
- mergedImported.push({ ...incoming, ...preservedMetadata, id: match.id, status: match.status });
+ const secureIncoming = preserveVaultCredentialOnUpdate(match, incoming);
+ mergedImported.push({ ...match, ...secureIncoming, id: match.id, status: match.status });
} else {
created += 1;
const normalizedId = (incoming.id || '').trim();
diff --git a/src/features/connections/domain/types.ts b/src/features/connections/domain/types.ts
index ebf5115a..2eb7f27c 100644
--- a/src/features/connections/domain/types.ts
+++ b/src/features/connections/domain/types.ts
@@ -1,7 +1,19 @@
+import type { VaultProfileId } from '../../../vault/profileTypes';
+
export type CoreTabView = 'dashboard' | 'files' | 'port-forwarding' | 'snippets' | 'terminal';
export type PluginTabView = `plugin:${string}`;
export type TabView = CoreTabView | PluginTabView;
+export type CredentialItemKind = 'ssh-password' | 'ssh-private-key' | 'ssh-agent-key';
+export type CredentialPurpose = 'ssh-auth';
+
+export interface CredentialRef {
+ vaultId: string;
+ itemId: string;
+ itemKind: CredentialItemKind;
+ purpose: CredentialPurpose;
+}
+
export interface Connection {
id: string;
name: string;
@@ -10,6 +22,8 @@ export interface Connection {
port: number;
password?: string;
privateKeyPath?: string;
+ /** Vault credential reference — when set, password/privateKeyPath are ignored for SSH auth. */
+ authRef?: CredentialRef;
status: 'disconnected' | 'connecting' | 'connected' | 'error';
jumpServerId?: string;
lastConnected?: number;
@@ -30,8 +44,9 @@ export interface Folder {
export interface Tab {
id: string;
- type: 'connection' | 'settings' | 'port-forwarding' | 'release-notes';
+ type: 'connection' | 'settings' | 'port-forwarding' | 'release-notes' | 'vault';
title: string;
connectionId?: string;
+ vaultProfileId?: VaultProfileId;
view: TabView;
}
diff --git a/src/features/connections/domain/validation.ts b/src/features/connections/domain/validation.ts
index 155b9073..e75a99a2 100644
--- a/src/features/connections/domain/validation.ts
+++ b/src/features/connections/domain/validation.ts
@@ -10,7 +10,7 @@ export interface ConnectionDraft {
folder?: string;
}
-export type AuthMode = 'password' | 'key';
+export type AuthMode = 'password' | 'key' | 'vault';
export type ConnectionDraftField = 'host' | 'username' | 'port' | 'privateKeyPath';
diff --git a/src/features/connections/infrastructure/connectionIpc.ts b/src/features/connections/infrastructure/connectionIpc.ts
index e01c3244..fa512cc5 100644
--- a/src/features/connections/infrastructure/connectionIpc.ts
+++ b/src/features/connections/infrastructure/connectionIpc.ts
@@ -9,7 +9,12 @@ export interface AuthMethodPrivateKey {
passphrase: string | null;
}
-export type AuthMethodPayload = AuthMethodPassword | AuthMethodPrivateKey;
+export interface AuthMethodVaultRef {
+ type: 'VaultRef';
+ item_id: string;
+}
+
+export type AuthMethodPayload = AuthMethodPassword | AuthMethodPrivateKey | AuthMethodVaultRef;
export interface ConnectionConfigPayload {
id: string;
@@ -71,5 +76,8 @@ export const connectIpc = async (config: ConnectionConfigPayload): Promise =>
window.ipcRenderer.invoke('ssh:disconnect', connectionId);
+export const disconnectVaultBackedIpc = async (): Promise =>
+ window.ipcRenderer.invoke('ssh_disconnect_vault_backed');
+
export const getRemoteCwdIpc = async (connectionId: string): Promise =>
window.ipcRenderer.invoke('fs:cwd', connectionId);
diff --git a/src/store/connectionSlice.ts b/src/store/connectionSlice.ts
index 61d02172..4bf254fe 100644
--- a/src/store/connectionSlice.ts
+++ b/src/store/connectionSlice.ts
@@ -13,6 +13,7 @@ import {
activateExistingConnectionTab,
createConnectionTabState,
createLocalTerminalTabState,
+ ensureVaultTabState,
ensureGlobalSnippetsTab,
ensureSingleTabByType,
findConnectionTab,
@@ -30,11 +31,12 @@ import {
pinFeatureOnConnectionIfNeeded,
startAutoStartTunnels,
} from '../features/connections/application/tunnelAutoStartService';
-import { buildConnectConfig, normalizeFolderPath, type ImportPlanItem } from '../features/connections/domain';
+import { buildConnectConfig, normalizeFolderPath, preserveVaultCredentialOnUpdate, type ImportPlanItem } from '../features/connections/domain';
import { connectIpc, disconnectIpc, getRemoteCwdIpc } from '../features/connections/infrastructure/connectionIpc';
-import { loadConnectionsIpc, saveConnectionsIpc } from '../features/connections/infrastructure/connectionPersistence';
+import { loadConnectionsIpc, saveConnectionsIpc, type LoadConnectionsIpcResult } from '../features/connections/infrastructure/connectionPersistence';
import { clearRemoteShellCache } from '../lib/shells/cache';
import type { TabSnapshot } from './sessionPersistence';
+import { DEFAULT_VAULT_PROFILE_ID, isVaultProfileId, type VaultProfileId } from '../vault/profileTypes';
export type { Connection, Folder, Tab } from '../features/connections/domain/types.js';
export interface ConnectionSlice {
@@ -70,6 +72,7 @@ export interface ConnectionSlice {
openSnippetsTab: () => void;
openReleaseNotesTab: () => void;
openSettingsJsonTab: () => void;
+ openVaultTab: (profileId?: VaultProfileId) => void;
closeTab: (tabId: string) => void;
activateTab: (tabId: string) => void;
/** Deactivate all tabs and show the welcome screen without closing anything. */
@@ -108,6 +111,9 @@ export interface ConnectionSlice {
}
const VALID_RESTORABLE_VIEWS = new Set(['terminal', 'files', 'port-forwarding', 'snippets', 'dashboard']);
+const RESTORABLE_TAB_TYPES = new Set(['connection', 'port-forwarding', 'release-notes', 'snippets', 'settings', 'vault']);
+
+type PersistedConnection = Omit & Partial>;
export const createConnectionSlice: StateCreator = (set, get) => ({
connections: [],
@@ -135,24 +141,25 @@ export const createConnectionSlice: StateCreator typeof f === 'string' ? { name: f } : f);
+ const payload = loaded as LoadConnectionsIpcResult;
+ const conns: PersistedConnection[] = Array.isArray(payload) ? payload : payload.connections;
+ const foldersSource: Array = Array.isArray(payload) ? [] : (payload.folders || []);
+ const folders: Folder[] = foldersSource.map((folder) => typeof folder === 'string' ? { name: folder } : folder);
- // Deduplicate connections by ID to prevent React key collisions
- const uniqueConns = Array.from(new Map(conns.map((c: any) => [c.id, c])).values());
+ // Deduplicate connections by ID to prevent React key collisions.
+ // Map keeps the last occurrence for duplicate IDs by design.
+ const uniqueConns = Array.from(new Map(conns.map((connection) => [connection.id, connection])).values());
if (uniqueConns.length !== conns.length) {
console.warn(`[RENDERER] Found ${conns.length - uniqueConns.length} duplicate connection IDs. Deduplicated.`);
}
- console.log('Setting connections state:', uniqueConns);
+ console.info(`[RENDERER] Applying ${uniqueConns.length} connection(s) and ${folders.length} folder(s).`);
set({
- connections: uniqueConns.map((c: any) => ({ ...c, status: 'disconnected' })),
+ connections: uniqueConns.map((connection): Connection => ({ ...connection, status: 'disconnected' })),
folders
});
} else {
@@ -284,8 +291,9 @@ export const createConnectionSlice: StateCreator 0) {
- existing.tags = folder.tags;
+ folderMap.set(normalized, { ...existing, tags: folder.tags });
}
return;
}
@@ -409,9 +417,7 @@ export const createConnectionSlice: StateCreator {
+ set(state => {
+ return {
+ ...ensureVaultTabState(state.tabs, profileId),
+ showWelcomeScreen: false,
+ };
+ });
+ get().saveSession();
+ },
+
openSnippetsTab: () => {
set(state => {
return { ...ensureGlobalSnippetsTab(state.tabs), showWelcomeScreen: false };
@@ -655,9 +671,8 @@ export const createConnectionSlice: StateCreator {
set(state => {
- const RESTORABLE_TYPES = new Set(['connection', 'port-forwarding', 'release-notes', 'snippets', 'settings']);
const tabs: Tab[] = snapshots
- .filter(s => RESTORABLE_TYPES.has(s.tabType))
+ .filter(s => RESTORABLE_TAB_TYPES.has(s.tabType))
.filter(s => {
// Drop connection tabs whose connection was deleted.
// 'local' is always valid — it is not in the connections array.
@@ -676,6 +691,9 @@ export const createConnectionSlice: StateCreator f !== feature)
: [...current, feature];
- // Use specific store method to sync pined features to local terminal settings
- (get() as any as AppStore).updateLocalTermSettings({ pinnedFeatures: updated });
+ // Use specific store method to sync pinned features to local terminal settings.
+ void get().updateLocalTermSettings({ pinnedFeatures: updated }).catch(error => {
+ console.error('Failed to update local terminal pinned features:', error);
+ get().showToast('error', `Failed to save pinned features: ${error instanceof Error ? error.message : String(error)}`, 5000);
+ });
return;
}
diff --git a/src/store/sessionPersistence.ts b/src/store/sessionPersistence.ts
index e6c7622d..85ef3fe7 100644
--- a/src/store/sessionPersistence.ts
+++ b/src/store/sessionPersistence.ts
@@ -1,4 +1,5 @@
import type { Tab } from '../features/connections/domain/types.js';
+import type { VaultProfileId } from '../vault/profileTypes.js';
export interface TerminalTabSnapshot {
id: string;
@@ -14,6 +15,7 @@ export interface TabSnapshot {
tabType: string;
title: string;
connectionId?: string;
+ vaultProfileId?: VaultProfileId;
view: string;
}
@@ -58,7 +60,7 @@ export function buildSessionData(state: SessionStoreSnapshot): SessionData {
cwd: t.lastKnownCwd,
initialPath: t.initialPath,
isSynced: t.isSynced,
- shellOverride: t.shellOverride,
+ ...(t.shellOverride !== undefined && { shellOverride: t.shellOverride }),
})),
]),
) as Record;
@@ -76,6 +78,7 @@ export function buildSessionData(state: SessionStoreSnapshot): SessionData {
tabType: t.type,
title: t.title,
connectionId: t.connectionId,
+ ...(t.vaultProfileId !== undefined && { vaultProfileId: t.vaultProfileId }),
view: t.view,
})),
terminals,
diff --git a/src/store/settingsSlice.ts b/src/store/settingsSlice.ts
index c1c63b16..7f9df35c 100644
--- a/src/store/settingsSlice.ts
+++ b/src/store/settingsSlice.ts
@@ -17,6 +17,9 @@ export interface AppSettings {
compactMode: boolean;
sidebarWidth: number;
sidebarCollapsed: boolean;
+ sidebarSections: {
+ vaultExpanded: boolean;
+ };
terminal: {
fontSize: number;
fontFamily: string;
@@ -111,6 +114,9 @@ export const defaultSettings: AppSettings = {
compactMode: true,
sidebarWidth: 288,
sidebarCollapsed: false,
+ sidebarSections: {
+ vaultExpanded: true,
+ },
expandedFolders: [],
ai: {
provider: 'ollama',
@@ -225,6 +231,7 @@ export interface SettingsSlice {
updateSettings: (settings: Partial) => Promise;
updateAiSettings: (updates: Partial) => Promise;
updateEditorSettings: (updates: Partial) => Promise;
+ updateSidebarSectionsSettings: (updates: Partial) => Promise;
updateTerminalSettings: (updates: Partial) => Promise;
updateLocalTermSettings: (updates: Partial) => Promise;
updateFileManagerSettings: (updates: Partial) => Promise;
@@ -257,6 +264,10 @@ export const createSettingsSlice: StateCreator
fontFamily: normalizeTerminalFontFamily(loaded?.terminal?.fontFamily) ?? defaultSettings.terminal.fontFamily,
},
fileManager: { ...defaultSettings.fileManager, ...(loaded?.fileManager || {}) },
+ sidebarSections: {
+ ...defaultSettings.sidebarSections,
+ ...(loaded?.sidebarSections || {}),
+ },
localTerm: { ...defaultSettings.localTerm, ...(loaded?.localTerm || {}) },
ghostSuggestions: {
...defaultSettings.ghostSuggestions,
@@ -345,6 +356,38 @@ export const createSettingsSlice: StateCreator
}
},
+ updateSidebarSectionsSettings: async (updates) => {
+ const previous = get().settings;
+ const updated = {
+ ...previous,
+ sidebarSections: { ...previous.sidebarSections, ...updates }
+ };
+ set({ settings: updated });
+ const changedKeys = Object.keys(updates) as Array;
+ const optimisticSidebarSections = updated.sidebarSections;
+ try {
+ await persistSettings({ sidebarSections: updates });
+ } catch (error) {
+ console.error('Failed to save sidebar section settings:', error);
+ const current = get().settings;
+ const rollbackPatch = Object.fromEntries(
+ changedKeys
+ .filter((key) => current.sidebarSections[key] === optimisticSidebarSections[key])
+ .map((key) => [key, previous.sidebarSections[key]])
+ ) as Partial;
+ if (Object.keys(rollbackPatch).length === 0) {
+ throw error;
+ }
+ set({
+ settings: {
+ ...current,
+ sidebarSections: { ...current.sidebarSections, ...rollbackPatch }
+ }
+ });
+ throw error;
+ }
+ },
+
updateTerminalSettings: async (updates) => {
const previous = get().settings;
const updated = {
diff --git a/src/vault/ipc.ts b/src/vault/ipc.ts
new file mode 100644
index 00000000..1044e8b5
--- /dev/null
+++ b/src/vault/ipc.ts
@@ -0,0 +1,97 @@
+import { invoke } from '@tauri-apps/api/core';
+
+export type VaultStatus =
+ | { status: 'uninitialized' }
+ | { status: 'locked'; vaultId: string }
+ | { status: 'unlocked'; vaultId: string; itemCount: number };
+
+export interface VaultItem {
+ id: string;
+ kind: string;
+ label: string;
+ secretFingerprint: string;
+ revision: number;
+ createdAt: number;
+ updatedAt: number;
+}
+
+export interface VaultItemSecret {
+ id: string;
+ kind: string;
+ label: string;
+ secret: string;
+ notes?: string;
+ revision: number;
+ createdAt: number;
+ updatedAt: number;
+}
+
+export interface MigrationCandidate {
+ connectionId: string;
+ connectionName: string;
+ host: string;
+ migrationKind: string;
+}
+
+export interface MigrationPreview {
+ candidates: MigrationCandidate[];
+ alreadyMigrated: number;
+ skippedNoFile: number;
+}
+
+export interface MigrationResult {
+ migrated: number;
+ skipped: number;
+ alreadyDone: number;
+ backupPath?: string;
+}
+
+export const vaultIpc = {
+ status: (): Promise =>
+ invoke('vault_status'),
+
+ initialize: (passphrase: string): Promise =>
+ invoke('vault_initialize', { args: { passphrase } }),
+
+ unlock: (passphrase: string): Promise =>
+ invoke('vault_unlock', { args: { passphrase } }),
+
+ lock: (): Promise =>
+ invoke('vault_lock'),
+
+ itemList: (): Promise =>
+ invoke('vault_item_list'),
+
+ itemGet: (itemId: string): Promise =>
+ invoke('vault_item_get', { args: { item_id: itemId } }),
+
+ itemCreate: (label: string, kind: string, secret: string, notes?: string): Promise => {
+ const args: { label: string; kind: string; secret: string; notes?: string } = { label, kind, secret };
+ if (notes !== undefined) args.notes = notes;
+ return invoke('vault_item_create', { args });
+ },
+
+ itemDelete: (itemId: string): Promise =>
+ invoke('vault_item_delete', { args: { item_id: itemId } }),
+
+ migrationPreview: (): Promise =>
+ invoke('vault_migration_preview'),
+
+ migrateExistingSecrets: (): Promise =>
+ invoke('vault_migrate_existing_secrets'),
+
+ generateRecoveryKey: (): Promise =>
+ invoke('vault_generate_recovery_key'),
+
+ hasRecoveryKey: (): Promise =>
+ invoke('vault_has_recovery_key'),
+
+ unlockWithRecoveryKey: (recoveryKey: string): Promise =>
+ invoke('vault_unlock_with_recovery_key', { args: { recovery_key: recoveryKey } }),
+
+ exportVault: (destPath: string): Promise =>
+ invoke('vault_export', { args: { dest_path: destPath } }),
+
+ importVault: (srcPath: string): Promise =>
+ invoke('vault_import', { args: { src_path: srcPath } }),
+};
diff --git a/src/vault/profileTypes.ts b/src/vault/profileTypes.ts
new file mode 100644
index 00000000..5f88de5c
--- /dev/null
+++ b/src/vault/profileTypes.ts
@@ -0,0 +1,9 @@
+export const VAULT_PROFILE_IDS = ['local', 'google'] as const;
+
+export type VaultProfileId = typeof VAULT_PROFILE_IDS[number];
+
+export const DEFAULT_VAULT_PROFILE_ID: VaultProfileId = 'local';
+
+export function isVaultProfileId(value: unknown): value is VaultProfileId {
+ return typeof value === 'string' && VAULT_PROFILE_IDS.includes(value as VaultProfileId);
+}
diff --git a/src/vault/useVaultStore.ts b/src/vault/useVaultStore.ts
new file mode 100644
index 00000000..425772c7
--- /dev/null
+++ b/src/vault/useVaultStore.ts
@@ -0,0 +1,127 @@
+import { create } from 'zustand';
+import { vaultIpc, type VaultStatus, type VaultItem } from './ipc';
+
+interface VaultStore {
+ status: VaultStatus | null;
+ items: VaultItem[];
+ isLoading: boolean;
+ error: string | null;
+
+ refresh: () => Promise;
+ refreshItems: () => Promise;
+ initialize: (passphrase: string) => Promise;
+ unlock: (passphrase: string) => Promise;
+ unlockWithRecoveryKey: (recoveryKey: string) => Promise;
+ lock: () => Promise;
+ deleteItem: (itemId: string) => Promise;
+ clearError: () => void;
+}
+
+export const useVaultStore = create((set, get) => ({
+ status: null,
+ items: [],
+ isLoading: false,
+ error: null,
+
+ clearError: () => set({ error: null }),
+
+ refresh: async () => {
+ set({ isLoading: true, error: null });
+ try {
+ const status = await vaultIpc.status();
+ set({ status });
+ if (status.status === 'unlocked') {
+ await get().refreshItems();
+ } else {
+ set({ items: [] });
+ }
+ } catch (e) {
+ set({ status: null, items: [], error: extractErrorMessage(e) });
+ throw e;
+ } finally {
+ set({ isLoading: false });
+ }
+ },
+
+ refreshItems: async () => {
+ try {
+ const items = await vaultIpc.itemList();
+ set({ items });
+ } catch (e: unknown) {
+ const msg = extractErrorMessage(e);
+ console.warn('[Vault] refreshItems failed:', e);
+ set({ items: [], error: msg });
+ throw e;
+ }
+ },
+
+ initialize: async (passphrase: string) => {
+ set({ isLoading: true, error: null });
+ try {
+ const status = await vaultIpc.initialize(passphrase);
+ set({ status, isLoading: false });
+ } catch (e: unknown) {
+ const msg = extractErrorMessage(e);
+ set({ isLoading: false, error: msg });
+ throw e;
+ }
+ },
+
+ unlock: async (passphrase: string) => {
+ set({ isLoading: true, error: null });
+ try {
+ const status = await vaultIpc.unlock(passphrase);
+ set({ status, isLoading: false });
+ await get().refreshItems();
+ } catch (e: unknown) {
+ const msg = extractErrorMessage(e);
+ set({ isLoading: false, error: msg });
+ throw e;
+ }
+ },
+
+ unlockWithRecoveryKey: async (recoveryKey: string) => {
+ set({ isLoading: true, error: null });
+ try {
+ const status = await vaultIpc.unlockWithRecoveryKey(recoveryKey);
+ set({ status, isLoading: false });
+ await get().refreshItems();
+ } catch (e: unknown) {
+ const msg = extractErrorMessage(e);
+ set({ isLoading: false, error: msg });
+ throw e;
+ }
+ },
+
+ lock: async () => {
+ try {
+ await vaultIpc.lock();
+ set({ items: [] });
+ await get().refresh();
+ } catch (e) {
+ console.error('Failed to lock vault', e);
+ set({ error: extractErrorMessage(e) });
+ throw e;
+ }
+ },
+
+ deleteItem: async (itemId: string) => {
+ try {
+ await vaultIpc.itemDelete(itemId);
+ await get().refresh();
+ } catch (e: unknown) {
+ const msg = extractErrorMessage(e);
+ set({ error: msg });
+ throw e;
+ }
+ },
+}));
+
+function extractErrorMessage(e: unknown): string {
+ if (e && typeof e === 'object') {
+ const obj = e as Record;
+ if (typeof obj.message === 'string') return obj.message;
+ if (typeof obj.code === 'string') return obj.code;
+ }
+ return String(e);
+}
diff --git a/tests/connectionDomain.test.mjs b/tests/connectionDomain.test.mjs
index b17d474f..38f16f4d 100644
--- a/tests/connectionDomain.test.mjs
+++ b/tests/connectionDomain.test.mjs
@@ -10,6 +10,7 @@ import {
} from '../.tmp-agent-tests/src/features/connections/domain/folderTreeOps.js';
import {
mergeImportedConnectionsByName,
+ preserveVaultCredentialOnUpdate,
} from '../.tmp-agent-tests/src/features/connections/domain/merge.js';
import {
applyImportPlan,
@@ -158,6 +159,182 @@ runTest('import merge preserves existing folder/theme/tags metadata on matched u
assert.deepEqual(web?.tags, ['core']);
});
+runTest('import merge preserves existing vault auth when overriding with plaintext import', () => {
+ const authRef = {
+ vaultId: 'vault-1',
+ itemId: 'item-1',
+ itemKind: 'ssh-private-key',
+ purpose: 'ssh-auth',
+ };
+ const existing = [{
+ id: 'a',
+ name: 'web',
+ host: 'prod',
+ username: 'root',
+ port: 22,
+ status: 'connected',
+ authRef,
+ }];
+ const incoming = [{
+ id: 'x',
+ name: 'web',
+ host: 'prod',
+ username: 'root',
+ port: 22,
+ status: 'disconnected',
+ privateKeyPath: 'C:/Users/me/.ssh/id_rsa',
+ password: 'key-passphrase',
+ }];
+
+ const result = mergeImportedConnectionsByName(existing, incoming);
+ const web = result.merged.find((c) => c.id === 'a');
+ assert.deepEqual(web?.authRef, authRef);
+ assert.equal(web?.privateKeyPath, undefined);
+ assert.equal(web?.password, undefined);
+
+ const preserved = preserveVaultCredentialOnUpdate(existing[0], incoming[0]);
+ assert.deepEqual(preserved.authRef, authRef);
+ assert.equal(preserved.privateKeyPath, undefined);
+ assert.equal(preserved.password, undefined);
+});
+
+runTest('import merge allows incoming metadata to override or clear matched metadata', () => {
+ const existing = [{
+ id: 'a',
+ name: 'web',
+ host: 'prod',
+ username: 'root',
+ port: 22,
+ status: 'connected',
+ folder: 'prod',
+ theme: 'blue',
+ tags: ['core'],
+ icon: 'Server',
+ }];
+ const incoming = [{
+ id: 'x',
+ name: 'web',
+ host: 'prod',
+ username: 'root',
+ port: 22,
+ status: 'disconnected',
+ folder: '',
+ theme: null,
+ tags: [],
+ icon: 'Ubuntu',
+ }];
+
+ const result = mergeImportedConnectionsByName(existing, incoming);
+ const web = result.merged.find((c) => c.id === 'a');
+ assert.equal(web?.folder, '');
+ assert.equal(web?.theme, null);
+ assert.deepEqual(web?.tags, []);
+ assert.equal(web?.icon, 'Ubuntu');
+});
+
+runTest('preserveVaultCredentialOnUpdate lets incoming vault auth replace plaintext credentials', () => {
+ const authRef = {
+ vaultId: 'vault-2',
+ itemId: 'item-2',
+ itemKind: 'ssh-password',
+ purpose: 'ssh-auth',
+ };
+ const existing = {
+ id: 'a',
+ name: 'web',
+ host: 'prod',
+ username: 'root',
+ port: 22,
+ status: 'connected',
+ password: 'old-plaintext',
+ };
+ const incoming = {
+ ...existing,
+ id: 'x',
+ status: 'disconnected',
+ password: undefined,
+ privateKeyPath: 'incoming-key-path',
+ authRef,
+ };
+
+ const preserved = preserveVaultCredentialOnUpdate(existing, incoming);
+ assert.deepEqual(preserved.authRef, authRef);
+ assert.equal(preserved.password, undefined);
+ assert.equal(preserved.privateKeyPath, undefined);
+});
+
+runTest('preserveVaultCredentialOnUpdate lets incoming vault auth win when both sides are vaulted', () => {
+ const existingAuthRef = {
+ vaultId: 'vault-1',
+ itemId: 'item-1',
+ itemKind: 'ssh-private-key',
+ purpose: 'ssh-auth',
+ };
+ const incomingAuthRef = {
+ vaultId: 'vault-2',
+ itemId: 'item-2',
+ itemKind: 'ssh-password',
+ purpose: 'ssh-auth',
+ };
+ const existing = {
+ id: 'a',
+ name: 'web',
+ host: 'prod',
+ username: 'root',
+ port: 22,
+ status: 'connected',
+ authRef: existingAuthRef,
+ };
+ const incoming = {
+ ...existing,
+ id: 'x',
+ status: 'disconnected',
+ password: 'stale-password',
+ privateKeyPath: 'stale-key',
+ authRef: incomingAuthRef,
+ };
+
+ const preserved = preserveVaultCredentialOnUpdate(existing, incoming);
+ assert.deepEqual(preserved.authRef, incomingAuthRef);
+ assert.equal(preserved.password, undefined);
+ assert.equal(preserved.privateKeyPath, undefined);
+});
+
+runTest('preserveVaultCredentialOnUpdate strips plaintext when existing vault auth is retained', () => {
+ const authRef = {
+ vaultId: 'vault-1',
+ itemId: 'item-1',
+ itemKind: 'ssh-private-key',
+ purpose: 'ssh-auth',
+ };
+ const existing = {
+ id: 'a',
+ name: 'web',
+ host: 'prod',
+ username: 'root',
+ port: 22,
+ status: 'connected',
+ authRef,
+ password: 'stale-password',
+ privateKeyPath: 'stale-key',
+ };
+ const incoming = {
+ id: 'x',
+ name: 'web',
+ host: 'prod',
+ username: 'root',
+ port: 22,
+ status: 'disconnected',
+ password: 'imported-password',
+ privateKeyPath: 'imported-key',
+ };
+
+ const preserved = preserveVaultCredentialOnUpdate(existing, incoming);
+ assert.deepEqual(preserved.authRef, authRef);
+ assert.equal(preserved.password, undefined);
+ assert.equal(preserved.privateKeyPath, undefined);
+});
+
runTest('import plan builds recommendations and applies new/update/skip decisions', () => {
const existing = [
{ id: 'a', name: 'web', host: 'prod', username: 'root', port: 22, status: 'connected' },
diff --git a/tests/connectionTabService.test.mjs b/tests/connectionTabService.test.mjs
index 6bdd1c0a..4b790b4d 100644
--- a/tests/connectionTabService.test.mjs
+++ b/tests/connectionTabService.test.mjs
@@ -5,6 +5,7 @@ import {
createLocalTerminalTabState,
ensureGlobalSnippetsTab,
ensureSingleTabByType,
+ ensureVaultTabState,
findConnectionTab,
} from '../.tmp-agent-tests/src/features/connections/application/tabService.js';
@@ -71,12 +72,42 @@ runTest('ensureSingleTabByType returns existing tab activation', () => {
assert.equal(state.activeConnectionId, null);
});
-runTest('ensureGlobalSnippetsTab creates local snippets tab if absent', () => {
+runTest('ensureGlobalSnippetsTab creates global snippets tab if absent', () => {
const state = ensureGlobalSnippetsTab([]);
assert.equal(state.tabs?.length, 1);
- assert.equal(state.tabs?.[0].connectionId, 'local');
+ assert.equal(state.tabs?.[0].connectionId, 'global');
assert.equal(state.tabs?.[0].view, 'snippets');
- assert.equal(state.activeConnectionId, 'local');
+ assert.equal(state.activeConnectionId, 'global');
+});
+
+runTest('ensureVaultTabState creates vault tab with selected profile', () => {
+ const state = ensureVaultTabState([], 'google');
+ assert.equal(state.tabs.length, 1);
+ assert.equal(state.tabs[0].type, 'vault');
+ assert.equal(state.tabs[0].vaultProfileId, 'google');
+ assert.equal(state.activeConnectionId, null);
+});
+
+runTest('ensureVaultTabState updates existing vault tab profile', () => {
+ const tabs = [{ id: 'vault-1', type: 'vault', title: 'Vault', view: 'terminal', vaultProfileId: 'local' }];
+ const state = ensureVaultTabState(tabs, 'google');
+ assert.equal(state.tabs.length, 1);
+ assert.equal(state.tabs[0].id, 'vault-1');
+ assert.equal(state.tabs[0].vaultProfileId, 'google');
+ assert.equal(state.activeTabId, 'vault-1');
+});
+
+runTest('ensureVaultTabState does not duplicate vault tab when other tabs exist', () => {
+ const tabs = [
+ { id: 'conn-1', type: 'connection', title: 'Prod', connectionId: 'c1', view: 'terminal' },
+ { id: 'vault-1', type: 'vault', title: 'Vault', view: 'terminal', vaultProfileId: 'local' },
+ { id: 'vault-legacy', type: 'vault', title: 'Legacy Vault', view: 'terminal', vaultProfileId: 'local' },
+ ];
+ const state = ensureVaultTabState(tabs, 'google');
+ assert.equal(state.tabs.length, 2);
+ assert.equal(state.tabs.filter((tab) => tab.type === 'vault').length, 1);
+ assert.equal(state.tabs.find((tab) => tab.type === 'vault')?.vaultProfileId, 'google');
+ assert.equal(state.activeTabId, 'vault-1');
});
console.log('Connection tab service tests passed.');
diff --git a/tests/sessionPersistence.test.mjs b/tests/sessionPersistence.test.mjs
index 5ea48edc..34b84cf0 100644
--- a/tests/sessionPersistence.test.mjs
+++ b/tests/sessionPersistence.test.mjs
@@ -15,6 +15,10 @@ function makeConnectionTab(id = 'tab-1') {
return { id, type: 'connection', title: 'Prod', connectionId: 'conn-1', view: 'terminal' };
}
+function makeVaultTab(id = 'vault-tab', profile = 'google') {
+ return { id, type: 'vault', title: 'Vault', vaultProfileId: profile, view: 'terminal' };
+}
+
function makeSettingsTab(id = 'settings-tab') {
return { id, type: 'settings', title: 'Settings', view: 'terminal' };
}
@@ -102,4 +106,27 @@ runTest('buildSessionData filters active terminal IDs to kept terminals only', (
});
});
+runTest('buildSessionData preserves vault tab profile metadata', () => {
+ const defaultProfileData = buildSessionData({
+ activeTabId: 'vault-tab',
+ activeConnectionId: null,
+ tabs: [makeVaultTab()],
+ terminals: {},
+ activeTerminalIds: {},
+ });
+
+ assert.equal(defaultProfileData.tabs.length, 1);
+ assert.equal(defaultProfileData.tabs[0].tabType, 'vault');
+ assert.equal(defaultProfileData.tabs[0].vaultProfileId, 'google');
+
+ const customProfileData = buildSessionData({
+ activeTabId: 'vault-tab',
+ activeConnectionId: null,
+ tabs: [makeVaultTab('vault-tab', 'custom-profile')],
+ terminals: {},
+ activeTerminalIds: {},
+ });
+ assert.equal(customProfileData.tabs[0].vaultProfileId, 'custom-profile');
+});
+
console.log('Session persistence tests passed.');
diff --git a/tests/vaultFocus.test.mjs b/tests/vaultFocus.test.mjs
new file mode 100644
index 00000000..c80a58bf
--- /dev/null
+++ b/tests/vaultFocus.test.mjs
@@ -0,0 +1,26 @@
+import assert from 'node:assert/strict';
+import { resolveVaultFocusProfile } from '../.tmp-agent-tests/src/components/settings/tabs/vaultFocus.js';
+
+function runTest(name, fn) {
+ try {
+ fn();
+ console.log(`PASS ${name}`);
+ } catch (error) {
+ console.error(`FAIL ${name}`);
+ throw error;
+ }
+}
+
+runTest('resolveVaultFocusProfile keeps google profile', () => {
+ assert.equal(resolveVaultFocusProfile('google'), 'google');
+});
+
+runTest('resolveVaultFocusProfile defaults undefined to local', () => {
+ assert.equal(resolveVaultFocusProfile(undefined), 'local');
+});
+
+runTest('resolveVaultFocusProfile keeps local profile', () => {
+ assert.equal(resolveVaultFocusProfile('local'), 'local');
+});
+
+console.log('Vault focus tests passed.');
diff --git a/tests/vaultNavState.test.mjs b/tests/vaultNavState.test.mjs
new file mode 100644
index 00000000..35610fa9
--- /dev/null
+++ b/tests/vaultNavState.test.mjs
@@ -0,0 +1,50 @@
+import assert from 'node:assert/strict';
+import {
+ nextSidebarSectionsForVaultToggle,
+ resolveVaultExpanded,
+} from '../.tmp-agent-tests/src/components/layout/sidebar/vaultNavState.js';
+
+function runTest(name, fn) {
+ try {
+ fn();
+ console.log(`PASS ${name}`);
+ } catch (error) {
+ console.error(`FAIL ${name}`);
+ throw error;
+ }
+}
+
+function makeSettings(overrides = {}) {
+ return {
+ sidebarSections: {
+ vaultExpanded: true,
+ },
+ ...overrides,
+ };
+}
+
+runTest('resolveVaultExpanded defaults to true when missing', () => {
+ const settings = makeSettings({ sidebarSections: undefined });
+ assert.equal(resolveVaultExpanded(settings), true);
+});
+
+runTest('resolveVaultExpanded returns explicit value', () => {
+ assert.equal(resolveVaultExpanded(makeSettings({ sidebarSections: { vaultExpanded: false } })), false);
+ assert.equal(resolveVaultExpanded(makeSettings({ sidebarSections: { vaultExpanded: true } })), true);
+});
+
+runTest('nextSidebarSectionsForVaultToggle flips current value', () => {
+ const collapsed = nextSidebarSectionsForVaultToggle(
+ makeSettings({ sidebarSections: { vaultExpanded: true } }),
+ true,
+ );
+ assert.deepEqual(collapsed, { vaultExpanded: false });
+
+ const expanded = nextSidebarSectionsForVaultToggle(
+ makeSettings({ sidebarSections: { vaultExpanded: false } }),
+ false,
+ );
+ assert.deepEqual(expanded, { vaultExpanded: true });
+});
+
+console.log('Vault nav state tests passed.');
diff --git a/tsconfig.agent-tests.json b/tsconfig.agent-tests.json
index c29f2fd5..1199abdc 100644
--- a/tsconfig.agent-tests.json
+++ b/tsconfig.agent-tests.json
@@ -39,6 +39,8 @@
"src/components/editor/codemirror/status.ts",
"src/components/editor/codemirror/theme.ts",
"src/components/editor/providers.ts",
+ "src/components/settings/tabs/vaultFocus.ts",
+ "src/components/layout/sidebar/vaultNavState.ts",
"src/components/ui/Button.tsx",
"src/components/ui/KeyboardKey.tsx",
"src/features/connections/application/connectionLifecycleService.ts",
@@ -58,6 +60,7 @@
"src/lib/ghostSuggestions/popupState.ts",
"src/lib/ghostSuggestions/runtime.ts",
"src/lib/ghostSuggestions/tabState.ts",
- "src/store/sessionPersistence.ts"
+ "src/store/sessionPersistence.ts",
+ "src/vault/profileTypes.ts"
]
}
From c6e1c884c465d85b3d9c0bb6f3ea70d909f3d777 Mon Sep 17 00:00:00 2001
From: gajendraxdev
Date: Sun, 10 May 2026 02:35:22 +0530
Subject: [PATCH 3/8] feat(vault-sync): add Google Drive vault sync integration
and status-driven UX
---
.github/workflows/release.yml | 3 +-
CHANGELOG.md | 13 +
src-tauri/src/sync/commands.rs | 789 +++++++++++++++++++++++++++++++++
src-tauri/src/sync/mod.rs | 1 +
src/vault/syncIpc.ts | 117 +++++
5 files changed, 922 insertions(+), 1 deletion(-)
create mode 100644 src-tauri/src/sync/commands.rs
create mode 100644 src-tauri/src/sync/mod.rs
create mode 100644 src/vault/syncIpc.ts
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 71c9ce4d..3b98bace 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -53,6 +53,7 @@ jobs:
uses: tauri-apps/tauri-action@v0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }}
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
with:
@@ -123,4 +124,4 @@ jobs:
git config user.email "actions@github.com"
git add .
git commit -m "APT repo update: ${{ github.ref_name }}"
- git push
\ No newline at end of file
+ git push
diff --git a/CHANGELOG.md b/CHANGELOG.md
index e1b4b7cf..c1f59f32 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -4,6 +4,19 @@ All notable changes to Zync are documented in this file. The format is based on
## [Unreleased]
+### Added
+- **Global Vault Workspace**: Introduced Vault as a first-class sidebar/workspace surface with profile-based navigation for Local Vault and Google Vault Sync. Added dedicated vault workspace UI, unlock/recovery flows, and vault-focused panels. ([6e8dd42], [d9d3663])
+- **Vault Core Backend Foundation**: Added backend vault modules for crypto, schema, storage, lifecycle commands, and migration scaffolding to support encrypted credential storage and recovery workflows. ([6e8dd42])
+- **Google Drive Vault Sync Module**: Added sync command module and IPC surface for connect/disconnect/backup/restore flows dedicated to vault data sync. ([8cdb20d])
+
+### Changed
+- **Settings Information Architecture**: Removed Vault from Settings navigation; vault management now lives in the dedicated Vault workspace flow. Updated related UX copy from “Settings → Vault” to “Vault tab/workspace” guidance. ([d9d3663])
+- **Sync Status UX Resilience**: Sync IPC status events now use canonical post-operation status refresh and last-known-state fallback behavior to avoid false disconnected states when status refresh fails. ([8cdb20d])
+
+### Fixed
+- **Command Palette Vault Icon Semantics**: Corrected icon mapping so Local Vault and Google Vault Sync entries use appropriate visual semantics. ([d9d3663])
+- **Pasted Key Vaulting Consistency**: Converted pasted private-key flow to controlled state handling and centralized private-key marker validation logic to keep save/validation behavior consistent. ([d9d3663])
+
## [2.15.1] - 2026-04-27
### Fixed
diff --git a/src-tauri/src/sync/commands.rs b/src-tauri/src/sync/commands.rs
new file mode 100644
index 00000000..3e2ea346
--- /dev/null
+++ b/src-tauri/src/sync/commands.rs
@@ -0,0 +1,789 @@
+use crate::vault::store::VaultService;
+use rand_core::{OsRng, RngCore};
+use serde::{Deserialize, Serialize};
+use sha2::{Digest, Sha256};
+use std::time::Duration;
+use tauri::State;
+use tokio::sync::Mutex;
+
+// ── Constants ─────────────────────────────────────────────────────────────────
+
+/// Loaded from .env at build time (src-tauri/.env → GOOGLE_CLIENT_ID=...).
+const GOOGLE_CLIENT_ID: &str = env!("GOOGLE_CLIENT_ID");
+const GOOGLE_CLIENT_SECRET: Option<&str> = option_env!("GOOGLE_CLIENT_SECRET");
+
+const GOOGLE_AUTH_URL: &str = "https://accounts.google.com/o/oauth2/v2/auth";
+const GOOGLE_TOKEN_URL: &str = "https://oauth2.googleapis.com/token";
+const GOOGLE_TOKENINFO_URL: &str = "https://oauth2.googleapis.com/tokeninfo";
+const GOOGLE_REVOKE_URL: &str = "https://oauth2.googleapis.com/revoke";
+/// drive.appdata = hidden app folder, not visible in user's Drive.
+const GOOGLE_DRIVE_SCOPE: &str = "https://www.googleapis.com/auth/drive.appdata";
+const GOOGLE_SCOPE: &str = "https://www.googleapis.com/auth/drive.appdata email";
+const GDRIVE_API: &str = "https://www.googleapis.com/drive/v3";
+const GDRIVE_UPLOAD_API: &str = "https://www.googleapis.com/upload/drive/v3";
+const APPDATA_SPACE: &str = "appDataFolder";
+const VAULT_FILENAME: &str = "vault.redb";
+
+const GOOGLE_TOKENS_KEY: &str = "google-tokens";
+const SYNC_TOKEN_KEYRING_SERVICE: &str = "Zync Sync Refresh Tokens";
+
+// ── Types ─────────────────────────────────────────────────────────────────────
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+struct StoredTokens {
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ access_token: Option,
+ #[serde(default, skip_serializing)]
+ refresh_token: Option,
+ #[serde(default)]
+ has_refresh_token: bool,
+ #[serde(default)]
+ expires_at: u64,
+ last_sync: Option,
+}
+
+#[derive(Debug, Serialize)]
+#[serde(rename_all = "camelCase")]
+pub struct SyncProviderStatus {
+ pub connected: bool,
+ pub email: Option,
+ pub last_sync: Option,
+}
+
+#[derive(Deserialize)]
+struct DriveFile {
+ id: String,
+}
+
+#[derive(Deserialize)]
+struct DriveFileList {
+ files: Vec,
+}
+
+// ── PKCE helpers ──────────────────────────────────────────────────────────────
+
+fn gen_random_base64url() -> String {
+ let mut bytes = [0u8; 32];
+ OsRng.fill_bytes(&mut bytes);
+ b64url_no_pad(&bytes)
+}
+
+fn code_challenge_s256(verifier: &str) -> String {
+ let hash = Sha256::digest(verifier.as_bytes());
+ b64url_no_pad(&hash)
+}
+
+fn b64url_no_pad(bytes: &[u8]) -> String {
+ use base64::Engine;
+ base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes)
+}
+
+// ── Local redirect server ─────────────────────────────────────────────────────
+
+fn http_client() -> Result {
+ reqwest::Client::builder()
+ .timeout(Duration::from_secs(30))
+ .build()
+ .map_err(|e| e.to_string())
+}
+
+fn http_upload_client() -> Result {
+ reqwest::Client::builder()
+ .timeout(Duration::from_secs(180))
+ .build()
+ .map_err(|e| e.to_string())
+}
+
+fn bind_redirect_listener() -> Option<(u16, std::net::TcpListener)> {
+ for port in 7357u16..7400 {
+ if let Ok(listener) = std::net::TcpListener::bind(("127.0.0.1", port)) {
+ return Some((port, listener));
+ }
+ }
+ None
+}
+
+async fn wait_for_auth_code(listener: std::net::TcpListener) -> Result<(String, String), String> {
+ use tokio::io::{AsyncReadExt, AsyncWriteExt};
+ use tokio::net::TcpListener;
+
+ listener
+ .set_nonblocking(true)
+ .map_err(|e| format!("failed to configure redirect listener: {e}"))?;
+ let listener = TcpListener::from_std(listener)
+ .map_err(|e| format!("failed to convert redirect listener: {e}"))?;
+
+ let (mut stream, _) =
+ tokio::time::timeout(std::time::Duration::from_secs(300), listener.accept())
+ .await
+ .map_err(|_| "OAuth timed out — no browser redirect received within 5 minutes")?
+ .map_err(|e| e.to_string())?;
+
+ let mut buf = vec![0u8; 4096];
+ let n = stream.read(&mut buf).await.map_err(|e| e.to_string())?;
+ let request = String::from_utf8_lossy(&buf[..n]);
+
+ let (code, state) = parse_code_and_state(&request)?;
+
+ let html = b"HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\n\r\n\
+\
+\
+\
+\
+\
+Zync \xe2\x80\x94 Connected\
+\
+\
+\
+\
+
\
+
Authorization Received
\
+
Return to Zync to finish connecting Google Drive sync. If Drive access was not granted, Zync will reject this connection for safety.
\
+
Zync Vault Sync
\
+
You can close this tab now.
\
+
\
+\
+";
+ let _ = stream.write_all(html).await;
+
+ Ok((code, state))
+}
+
+fn parse_code_and_state(request: &str) -> Result<(String, String), String> {
+ let line = request.lines().next().unwrap_or("");
+ let query = line
+ .split('?')
+ .nth(1)
+ .unwrap_or("")
+ .split_whitespace()
+ .next()
+ .unwrap_or("");
+
+ let mut code = None;
+ let mut state = None;
+ for (key, val) in url::form_urlencoded::parse(query.as_bytes()) {
+ match key.as_ref() {
+ "code" => code = Some(val.into_owned()),
+ "state" => state = Some(val.into_owned()),
+ _ => {}
+ }
+ }
+
+ match (code, state) {
+ (Some(c), Some(s)) => Ok((c, s)),
+ _ => Err("OAuth redirect missing code or state parameter".into()),
+ }
+}
+
+// ── Token storage (file-based) ────────────────────────────────────────────────
+
+fn tokens_path(data_dir: &std::path::Path, key: &str) -> std::path::PathBuf {
+ data_dir.join(format!("sync-{key}.json"))
+}
+
+fn refresh_token_entry(key: &str) -> Result {
+ keyring::Entry::new(SYNC_TOKEN_KEYRING_SERVICE, key).map_err(|e| e.to_string())
+}
+
+fn save_refresh_token(key: &str, refresh_token: &str) -> Result<(), String> {
+ refresh_token_entry(key)?
+ .set_password(refresh_token)
+ .map_err(|e| e.to_string())
+}
+
+fn load_refresh_token(key: &str) -> Option {
+ refresh_token_entry(key).ok()?.get_password().ok()
+}
+
+fn delete_refresh_token(key: &str) {
+ if let Ok(entry) = refresh_token_entry(key) {
+ match entry.delete_credential() {
+ Ok(()) | Err(keyring::Error::NoEntry) => {}
+ Err(error) => eprintln!("[sync] Failed to delete refresh token from keyring: {error}"),
+ }
+ }
+}
+
+fn tokens_for_disk(tokens: &StoredTokens) -> StoredTokens {
+ let mut safe = tokens.clone();
+ safe.access_token = None; // Never persist short-lived bearer tokens.
+ safe.refresh_token = None; // Never persist long-lived OAuth refresh tokens in plaintext.
+ if tokens.refresh_token.is_some() {
+ safe.has_refresh_token = true;
+ }
+ safe
+}
+
+async fn save_tokens(data_dir: &std::path::Path, key: &str, tokens: &StoredTokens) -> Result<(), String> {
+ if let Some(refresh_token) = tokens.refresh_token.as_deref() {
+ save_refresh_token(key, refresh_token)?;
+ } else if !tokens.has_refresh_token {
+ delete_refresh_token(key);
+ }
+ let safe = tokens_for_disk(tokens);
+ let json = serde_json::to_string(&safe).map_err(|e| e.to_string())?;
+ tokio::fs::write(tokens_path(data_dir, key), json)
+ .await
+ .map_err(|e| e.to_string())
+}
+
+fn load_tokens(data_dir: &std::path::Path, key: &str) -> Option {
+ let json = std::fs::read_to_string(tokens_path(data_dir, key)).ok()?;
+ let mut tokens: StoredTokens = serde_json::from_str(&json).ok()?;
+ if let Some(refresh_token) = tokens.refresh_token.clone() {
+ if save_refresh_token(key, &refresh_token).is_ok() {
+ tokens.has_refresh_token = true;
+ if let Ok(json) = serde_json::to_string(&tokens_for_disk(&tokens)) {
+ let _ = std::fs::write(tokens_path(data_dir, key), json);
+ }
+ }
+ } else if tokens.has_refresh_token {
+ tokens.refresh_token = load_refresh_token(key);
+ tokens.has_refresh_token = tokens.refresh_token.is_some();
+ }
+ Some(tokens)
+}
+
+fn delete_tokens(data_dir: &std::path::Path, key: &str) {
+ delete_refresh_token(key);
+ let _ = std::fs::remove_file(tokens_path(data_dir, key));
+}
+
+fn now_secs() -> u64 {
+ std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .unwrap_or_default()
+ .as_secs()
+}
+
+// ── Google Drive API ──────────────────────────────────────────────────────────
+
+async fn refresh_google_access_token(tokens: &mut StoredTokens) -> Result<(), String> {
+ let refresh_token = tokens
+ .refresh_token
+ .clone()
+ .ok_or("No refresh token stored — please reconnect.")?;
+
+ let mut form_fields = vec![
+ ("client_id", GOOGLE_CLIENT_ID.to_string()),
+ ("refresh_token", refresh_token.clone()),
+ ("grant_type", "refresh_token".to_string()),
+ ];
+ if let Some(secret) = GOOGLE_CLIENT_SECRET.filter(|s| !s.trim().is_empty()) {
+ form_fields.push(("client_secret", secret.to_string()));
+ }
+
+ let resp: serde_json::Value = http_client()?
+ .post(GOOGLE_TOKEN_URL)
+ .form(&form_fields)
+ .send()
+ .await
+ .map_err(|e| e.to_string())?
+ .json()
+ .await
+ .map_err(|e| e.to_string())?;
+
+ if let Some(err) = resp["error"].as_str() {
+ return Err(format!(
+ "Token refresh failed: {err} — {}",
+ resp["error_description"].as_str().unwrap_or("")
+ ));
+ }
+
+ tokens.access_token = Some(
+ resp["access_token"]
+ .as_str()
+ .ok_or("No access_token in refresh response")?
+ .to_string(),
+ );
+ tokens.expires_at = now_secs() + resp["expires_in"].as_u64().unwrap_or(3600) - 60;
+ Ok(())
+}
+
+async fn get_valid_google_token(data_dir: &std::path::Path) -> Result {
+ let mut tokens = load_tokens(data_dir, GOOGLE_TOKENS_KEY)
+ .ok_or("Not connected to Google Drive. Please connect first.")?;
+
+ if tokens.access_token.is_none() || now_secs() >= tokens.expires_at {
+ refresh_google_access_token(&mut tokens).await?;
+ save_tokens(data_dir, GOOGLE_TOKENS_KEY, &tokens).await?;
+ }
+
+ tokens
+ .access_token
+ .clone()
+ .ok_or("No access token available after refresh.".into())
+}
+
+fn token_has_scope(scope_value: Option<&str>, required_scope: &str) -> bool {
+ scope_value
+ .unwrap_or_default()
+ .split_whitespace()
+ .any(|scope| scope == required_scope)
+}
+
+async fn reject_google_connect(
+ access_token: &str,
+ reason: impl Into,
+) -> Result {
+ let reason = reason.into();
+ match revoke_google_token(access_token).await {
+ Ok(()) => Err(reason),
+ Err(revoke_error) => Err(format!(
+ "{reason} Zync tried to revoke the partial Google authorization but Google returned: {revoke_error}"
+ )),
+ }
+}
+
+async fn verify_google_drive_scope(
+ access_token: &str,
+ token_response: &serde_json::Value,
+) -> Result<(), String> {
+ if token_has_scope(token_response["scope"].as_str(), GOOGLE_DRIVE_SCOPE) {
+ return Ok(());
+ }
+
+ let token_info: serde_json::Value = http_client()?
+ .get(GOOGLE_TOKENINFO_URL)
+ .query(&[("access_token", access_token)])
+ .send()
+ .await
+ .map_err(|e| e.to_string())?
+ .error_for_status()
+ .map_err(|e| e.to_string())?
+ .json()
+ .await
+ .map_err(|e| e.to_string())?;
+
+ if token_has_scope(token_info["scope"].as_str(), GOOGLE_DRIVE_SCOPE) {
+ Ok(())
+ } else {
+ Err("Google Drive permission was not granted. Reconnect and allow Drive appdata access so Zync can back up the encrypted vault.".into())
+ }
+}
+
+async fn find_vault_file(token: &str) -> Result