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 (
- - - + /> + +
@@ -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" + /> +
+ )} +