diff --git a/docs/NATIVE-STORAGE-BRIDGE.md b/docs/NATIVE-STORAGE-BRIDGE.md new file mode 100644 index 0000000..3306706 --- /dev/null +++ b/docs/NATIVE-STORAGE-BRIDGE.md @@ -0,0 +1,129 @@ +# NATIVE-STORAGE-BRIDGE.md: one share-store, two backends + +**Status: DESIGN + WIRED RUST COMMANDS (logic-tested), JS SWAP NOT YET APPLIED.** The Rust side +(`src-tauri/src/share_store.rs` + the four `#[tauri::command]`s in `src-tauri/src/lib.rs`) is +written and its keychain logic passes a real headless test run against `keyring`'s in-memory mock. +The JS `share-store.ts` below is specified but deliberately NOT added to `ui/`, because a live +`.ts` that imports `@tauri-apps/api` would break `npm --prefix ui run build` and CI until the Tauri +dependency is installed. This doc is the exact contract so the swap is mechanical when the Tauri +work lands. It does not change the web path, which stays the tested default (ADR-0005). + +## 1. The problem it solves + +`ui/src/storage.ts` persists a `/net` vault's per-device FROST share in encrypted IndexedDB +(PBKDF2 -> AES-GCM). IndexedDB is origin-scoped and evictable: storage pressure, "clear site +data", private windows, and iOS Safari's ~7-day inactivity eviction can all drop it. A treasurer +losing a share to a cleared cache is a real failure mode. The OS keychain is durable, OS-account +protected, and survives browser resets. The native shell should let the SAME UI persist its share +in the keychain instead, with no screen rewrite. + +## 2. The abstraction + +A narrow `StorageBackend` interface both backends satisfy. `ui/src/storage.ts` already has the +right shape (`saveVault` / `loadVault` / `listVaults` / `deleteVault`), so the web backend is that +module verbatim; the native backend forwards ciphertext to the keychain via `invoke`. + +```ts +// SKETCH for ui/src/share-store.ts -- NOT added to the repo yet (see status note above). +import { invoke } from '@tauri-apps/api/core' +import { + saveVault, loadVault, listVaults, deleteVault, + type VaultData, type VaultLoaded, type VaultPublic, +} from './storage' + +export interface StorageBackend { + save(id: string, data: VaultData, passphrase: string): Promise + load(id: string, passphrase: string): Promise + list(): Promise + remove(id: string): Promise +} + +/** Tauri injects this global into the webview; the same bundle detects it at runtime. */ +export function isTauri(): boolean { + return typeof (globalThis as { __TAURI_INTERNALS__?: unknown }).__TAURI_INTERNALS__ !== 'undefined' +} + +/** Web backend: the existing, tested module unchanged (storage.ts + storage.test.ts). */ +export const webBackend: StorageBackend = { + save: saveVault, load: loadVault, list: listVaults, remove: deleteVault, +} + +/** Runtime selection. The web path is the default and stays fully tested. */ +export const shareStore: StorageBackend = isTauri() ? nativeBackend : webBackend +``` + +## 3. The native backend and its `invoke` shapes + +The UI stays the encryptor in BOTH backends. The native backend derives the key and seals the +share with the SAME WebCrypto PBKDF2 -> AES-GCM as `storage.ts` (WebCrypto is available in every +Tauri webview), then hands only CIPHERTEXT (as base64) to the keychain. The keychain is durable +at-rest storage, not the encryptor. Public metadata (`groupKey`, `address`, `roster`, `createdAt`) +carries no secret and stays in IndexedDB so vaults can be listed for unlock exactly as today. + +The Rust commands are in `src-tauri/src/lib.rs`; their argument names below are exactly what +`invoke` must send (Tauri maps a Rust `snake_case` parameter to the same key in the JS args +object). + +| JS call | Rust command | Args | Returns | +|---|---|---|---| +| `invoke('secure_store', { id, shareB64 })` | `secure_store(id: String, share_b64: String)` | `id`, `share_b64` (base64 ciphertext) | `void` (rejects on invalid base64 / backend error) | +| `invoke('secure_load', { id })` | `secure_load(id: String) -> String` | `id` | base64 ciphertext, or rejects `"no share stored on this device for id ..."` | +| `invoke('secure_delete', { id })` | `secure_delete(id: String)` | `id` | `void` (idempotent: deleting an absent id succeeds) | +| `invoke('secure_list')` | `secure_list() -> Vec` | none | array of vault ids that have a share on this device | + +> Tauri's JS `invoke` camelCases nothing automatically for custom keys: a Rust parameter named +> `share_b64` is sent as `share_b64`. To use `shareB64` in JS, either rename the Rust parameter or +> pass the key as `share_b64`. The table above assumes the Rust names; the sketch below shows the +> exact keys. + +```ts +const nativeBackend: StorageBackend = { + async save(id, data, passphrase) { + // Same sealing path as storage.ts: derive key from passphrase, AES-GCM encrypt sealedShare. + const cipher = await sealShare(data.sealedShare, passphrase) // Uint8Array of ciphertext + await invoke('secure_store', { id, share_b64: toBase64(cipher) }) + await putPublicMeta(id, data) // public metadata stays in IndexedDB + }, + async load(id, passphrase) { + const shareB64 = await invoke('secure_load', { id }) + const sealedShare = await openShare(fromBase64(shareB64), passphrase) // AES-GCM decrypt + return { ...(await getPublicMeta(id)), sealedShare } + }, + list: listVaults, // public metadata still lives in IndexedDB + async remove(id) { + await invoke('secure_delete', { id }) + await deleteVault(id) // drop the public metadata too + }, +} +``` + +`sealShare` / `openShare` / `toBase64` / `fromBase64` are the existing helpers factored out of +`storage.ts` (the AES-GCM + PBKDF2 body is already there; only the persistence target changes). + +## 4. The one-line consumer change + +`ui/src/screens/NetVault.tsx` imports the four storage functions directly today. The swap is to +import `shareStore` and call `shareStore.save/load/list/remove`. Mechanical, non-breaking, and +deferred until the Tauri deps exist so `npm run build` and CI stay green. + +## 5. Why base64 (not raw bytes) at the boundary + +`invoke` marshals JSON; strings cross cleanly, byte arrays would arrive as `number[]` and cost a +conversion on both ends. The Rust command decodes the base64 to raw bytes and stores those in the +keychain (via `keyring`'s `set_secret`), so the keychain holds real bytes, not doubly-encoded +text. `share_store.rs` carries the tiny dependency-free base64 codec and a round-trip test. + +## 6. `secure_list` and the id index (honest note) + +`keyring` has no portable "list all credentials for a service" API. `secure_list` is therefore +served from an INDEX entry the Rust store maintains under a reserved keychain account, holding the +newline-joined set of vault ids. Vault ids are PUBLIC identifiers (never secret material), so +keeping that index in the clear does not weaken the threat model. `store` adds an id to the index, +`delete` removes it, and the reserved index account can never be used as a vault id (guarded). + +## 7. Graceful degradation + +On a host with no keychain backend (headless Linux with no Secret Service daemon), the native +command errors clearly rather than losing data silently. The shell must fall back to `webBackend` +(IndexedDB) in that case: `isTauri()` gates the choice, and a failed `secure_*` call is a +recoverable, human-readable error (§6.11), never a silent share loss (§6.8). diff --git a/docs/TAURI-PLAN.md b/docs/TAURI-PLAN.md new file mode 100644 index 0000000..f43a995 --- /dev/null +++ b/docs/TAURI-PLAN.md @@ -0,0 +1,247 @@ +# TAURI-PLAN.md: a native shell for Konclave, honestly scoped + +**Status: PLAN + UNVALIDATED SCAFFOLD.** Nothing in `src-tauri/` has been compiled or run in +this repo's environment. The dev machine's WSLg/GTK webview does not paint a window +([ADR-0004](adr/0004-local-http-bridge.md)), and no macOS / iOS / Android build host was +available. This document is groundwork: it says what a native Konclave would be, what it +reuses, what it adds, and exactly what still needs real hardware to prove. Where a claim +cannot be checked without a build, it is labelled as such rather than asserted. + +This plan is consistent with the two prior delivery decisions and does not reopen them: + +- [ADR-0004](adr/0004-local-http-bridge.md): Tauri packaging is a roadmap item; the loopback + HTTP bridge is the working transport today. Tauri changes the delivery form, not the trust + model. +- [ADR-0005](adr/0005-web-first-delivery.md): web-first is primary; native shells are optional + wrappers. A Tauri shell is exactly such a wrapper. + +## 1. The key insight: Tauri reuses what the browser already proved + +Konclave's client is already a browser app. The `/net` flow runs a real cross-device DKG and a +FROST signing ceremony entirely in-browser via `konclave-wasm`, over the blind relay, with the +share held in encrypted IndexedDB (`ui/src/storage.ts`). ADR-0005 records this is proven +device-to-device on the hosted relay. + +A Tauri app is a native OS webview pointed at a web bundle. So a Tauri Konclave loads the same +`ui/dist` and runs the same code. The scaffold wires this with one line of config: +`tauri.conf.json` -> `build.frontendDist: "../ui/dist"`. + +### What is ALREADY validated by the browser path (carries over unchanged) + +These need no re-validation under Tauri, because Tauri runs the identical JS/WASM: + +- The React UI and all screens (`ui/`). +- `konclave-wasm` crypto: DKG (`DkgSession`), the confidential channel (`sealTo`/`open`), and + FROST signing (`Coordinator`, `participantRound1/2`). +- The relay transport (`ui/src/net.ts`) and the helper signing-request protocol + (`ui/src/net-sign.ts`, Architecture B). +- The custody invariant: the share stays on the device, only public / encrypted material + crosses the wire. + +### What Tauri ADDS (and therefore what is genuinely new and unproven here) + +- **Durable, OS-backed share persistence** in the OS keychain instead of a browser origin's + IndexedDB (Section 3). This is the one new native surface in the scaffold. +- **Native packaging / distribution** as an installable app per OS (Section 4). +- **Offline app shell** by default: a native binary ships its own UI assets, so it opens with + no network (the relay/helper are still needed to actually run a ceremony, exactly as on web). +- **Optionally, bundling the local `orchestrator`** so the native app can also be the helper + and the loopback bridge on the same machine (Section 5). Not in the scaffold. + +Everything in this second list is PLAN or SCAFFOLD, not validated. + +```mermaid +flowchart TD + subgraph shared["Shared, browser-proven (unchanged under Tauri)"] + ui["Vite/React UI, ui/dist"] + wasm["konclave-wasm: DKG, seal, FROST"] + net["relay + helper protocol"] + end + browser["Browser tab or PWA"] --> ui + tauri["Tauri native webview"] --> ui + ui --> wasm + ui --> net + browser -. share at rest .-> idb["Encrypted IndexedDB"] + tauri -. share at rest .-> kc["OS keychain, native command"] +``` + +## 2. Why bother, given web-first (ADR-0005) + +Web-first stays primary. A native shell earns its place only for what a browser tab cannot give: + +- **Durable custody.** A browser can evict IndexedDB (storage pressure, "clear site data", + private windows). A treasurer losing a share to a cleared cache is a real failure mode. The OS + keychain is backed up and survives browser state. +- **A real installed app.** An icon, no address bar, offline launch, OS integration. For a + non-technical treasurer this is the difference between "a website" and "my vault app". +- **A path to bundling the orchestrator/helper** locally, so a single desktop install is a + self-contained node (bridge + helper + UI), matching the original local-first vision. + +## 3. The share-persistence swap (Rust wired + logic-tested; JS swap specified) + +The goal: the SAME UI code persists its share through either the browser IndexedDB path +(`ui/src/storage.ts`) or a Tauri keychain command, chosen at runtime, with no screen rewrite. + +### 3.1 What is now implemented (the native side) + +- **`src-tauri/src/share_store.rs`** -- a `ShareStore` trait and an OS-keychain-backed + `KeychainShareStore`, plus a `StoreError` with explicit variants (`Backend` / `NotFound` / + `InvalidId`). It has NO `tauri` dependency, so its logic is covered by a REAL headless test run + (12 tests, green) against `keyring`'s in-memory mock -- the same technique + `orchestrator::secrets::KeychainStore` uses. Because `keyring` has no portable enumeration API, + `list` is served from a public-id index entry the store maintains (see `NATIVE-STORAGE-BRIDGE.md` + §6). The `keyring` pin (`3`, no backend feature) mirrors the orchestrator exactly. +- **`src-tauri/src/lib.rs`** -- four thin `#[tauri::command]`s over that store: + `secure_store` / `secure_load` / `secure_delete` / `secure_list`. These marshal base64 at the JS + boundary and never see plaintext key material. This wiring is SCAFFOLD (the `tauri` crate needs a + system webview absent here, so the crate is not compiled), but the store it delegates to is + proven. + +### 3.2 The JS side (specified, not yet added) + +A narrow `StorageBackend` interface both backends satisfy: `ui/src/storage.ts` verbatim for web, +and an `invoke`-based native backend. Runtime selection via `isTauri()`. The exact interface, the +`invoke` argument shapes (matching the Rust command signatures above), the sealing path, and the +one-line `NetVault.tsx` consumer change are specified in +[`NATIVE-STORAGE-BRIDGE.md`](NATIVE-STORAGE-BRIDGE.md). + +It is deliberately NOT added to `ui/` yet: a live `.ts` importing `@tauri-apps/api` would break +`npm --prefix ui run build` and CI until the Tauri dependency is installed. It lands with the Tauri +work; the change is mechanical and non-breaking, and the web path stays the tested default. + +### 3.2 Where encryption happens (deliberate) + +The UI stays the encryptor in BOTH backends (PBKDF2 -> AES-GCM, `ui/src/storage.ts`). The +keychain only holds the resulting ciphertext. Rationale: + +- One sealing path, one audited threat model, whether on web or native. +- The native command (`src-tauri/src/lib.rs`) never touches plaintext key material, so the + Rust/webview boundary carries only opaque bytes (mirrors the relay/helper discipline). +- The keychain adds a second at-rest factor (OS account protection) ON TOP of the passphrase, + rather than replacing it. + +An alternative (keychain-only, no app passphrase, relying on OS unlock) is viable and simpler +for the user, but it drops the passphrase factor and diverges from the web path. Kept as a +documented option, not the default. + +## 4. Per-platform build matrix + +Legend: **scaffolded** = config/code present for it here; **plan-only** = documented, no files +specific to it; **needs-hardware** = cannot even be attempted in this environment. + +| Target | Toolchain needed | Bundle | Status here | What blocks validation | +|---|---|---|---|---| +| Windows desktop | Rust + MSVC build tools, WebView2 (bundled on Win 11), Tauri CLI 2 | `.msi`, `.exe` | scaffolded, plan-only | No Windows build host in this environment. | +| macOS desktop | Rust, Xcode CLT, Tauri CLI 2; Apple Developer ID to sign/notarize | `.app`, `.dmg` | scaffolded, plan-only | Needs a Mac; signing needs an Apple ID. | +| Linux desktop | Rust, `libwebkit2gtk-4.1-dev`, Tauri CLI 2 | `.deb`, `.rpm`, AppImage | scaffolded, needs-hardware to VALIDATE | Compiles in principle, but the WSLg webview does not render here (ADR-0004), so it cannot be seen to work on this box. | +| iOS | macOS + Xcode, Apple Developer account, Rust ios targets | `.ipa` via `tauri ios` | plan-only | Impossible off a Mac; signing/provisioning required. | +| Android | Android SDK + NDK, `ANDROID_HOME`/`NDK_HOME`, Rust android targets, Tauri CLI 2 | `.apk`/`.aab` via `tauri android` | plan-only | No Android SDK/NDK installed here. | + +The crate is already shaped for mobile: `src-tauri/Cargo.toml` sets +`crate-type = ["staticlib", "cdylib", "rlib"]` and `src/lib.rs` uses +`#[cfg_attr(mobile, tauri::mobile_entry_point)]`, so `tauri android init` / `tauri ios init` +have what they need. That shape is asserted from the Tauri 2 mobile docs, not from a run here. + +Exact commands per target are in [`../src-tauri/README.md`](../src-tauri/README.md). + +## 5. Optionally bundling the orchestrator (beyond the scaffold) + +ADR-0004's loopback bridge (`konclave serve`) and Architecture B's helper are Rust in the +`orchestrator` crate. A desktop Tauri build could spawn that as a sidecar (Tauri's +`externalBin`) or link it, so one install is UI + helper + bridge on the same machine, fully +local-first. This is NOT in the scaffold (it needs the orchestrator to build for each target, +including its SQLCipher/native deps, which is its own validation effort). It is the natural next +milestone after a desktop build renders. + +Mobile is different: on iOS/Android the phone is a signing device only. It holds its share and +signs in-webview; build/prove/broadcast stay off-device via a remote helper (Architecture B), +exactly as on web. No orchestrator on the phone. + +```mermaid +flowchart LR + phone["Phone: Tauri webview + share in keychain"] -->|"public and encrypted only"| relay["Blind relay, never sees a share"] + desktop["Desktop: Tauri + bundled orchestrator/helper"] -->|"public and encrypted only"| relay + desktop -->|"build, prove, broadcast PCZT"| chain["Zcash network"] +``` + +## 6. Platform choice: Tauri 2.0 vs React Native vs Flutter + +The native shell must reuse Konclave's two biggest assets -- the existing React UI (`ui/`) and the +Rust crypto core (`konclave-wasm` for in-browser FROST/DKG, `orchestrator` for the helper/bridge) +-- while giving OS-keychain custody and staying blind and local-first (no server holds a share). +Measured against exactly those needs: + +| Need | Tauri 2.0 | React Native | Flutter | +|---|---|---|---| +| Reuse the React UI (`ui/`) | **As-is.** The webview loads `ui/dist` unchanged; every screen and the whole `/net` flow run verbatim. | Rewrite. RN is not the DOM; React components using web APIs/CSS do not port. | Rewrite in Dart/Flutter widgets. | +| Reuse the Rust crypto (`konclave-wasm`, `orchestrator`) | **Directly.** WASM runs in the webview as today; native Rust can be linked/sidecar'd (desktop) or invoked via commands. Rust is Tauri's native language. | Re-bridge. Rust reachable only via a custom native module (JSI/turbo-module) or re-run the WASM in a JS engine; extra FFI surface. | Re-bridge via Dart FFI (`dart:ffi`) or platform channels; the WASM path is awkward. | +| OS keychain (desktop + mobile) | Native Rust command over `keyring` (this scaffold) or a plugin; the same crate the orchestrator already uses. | `react-native-keychain` (mature) -- but a different code path per platform, not the Rust one. | `flutter_secure_storage` -- again a separate, non-Rust path. | +| Blind, local-first, no-server | Natural: a native binary with a bundled UI + optional local orchestrator/helper; nothing phones home. | Achievable, but the ecosystem assumes a JS backend; more to strip out. | Achievable; same caveat. | +| Single codebase desktop + mobile | Yes (desktop mature; **mobile is younger**, 2.0-era, smaller track record). | Mobile-first and very mature; desktop support is secondary/less polished. | Both mature; strong mobile, decent desktop. | +| Bundle size / footprint | Small (system webview, no bundled Chromium). | Medium. | Medium/large (bundled engine). | + +**Recommendation: Tauri 2.0.** It is the only option that reuses BOTH the existing React UI and the +Rust crypto core with essentially zero rewrite -- for a solo project that is the difference between +a shippable shell and a second full front end. The honest tradeoff is that Tauri's **mobile** story +(iOS/Android under 2.0) is younger and less battle-tested than React Native's or Flutter's, and the +Linux desktop webview does not render on this dev machine (ADR-0004), so validation waits on real +hardware. React Native or Flutter would buy a more mature mobile runtime at the cost of rewriting +the UI in a non-DOM framework AND re-bridging the Rust crypto over FFI -- re-implementing, and +re-auditing, the exact parts that are already proven. That cost is not justified for this app. + +## 7. Security notes + +- **The share never leaves the device.** In both backends the plaintext share stays local. Over + the wire, only public or encrypted material moves (unchanged from the browser path, §6.3/§6.4 + of CLAUDE.md). The native keychain command receives only ciphertext. +- **Keychain vs IndexedDB tradeoffs.** + - IndexedDB is origin-scoped and app-clearable; convenient, but evictable and tied to browser + state. It is protected only by the app passphrase. + - The OS keychain (Windows Credential Manager / macOS + iOS Keychain / Linux Secret Service) + is durable, OS-account protected, and survives browser resets. Cost: it is a platform + surface with per-OS behavior (Linux Secret Service needs a running keyring daemon; headless + Linux has no backend), so the app must degrade gracefully to the IndexedDB path when no + keychain is present. The design keeps the passphrase factor either way. +- **The relay and helper stay blind.** Tauri changes nothing about coordination: the relay + forwards opaque bytes, and the helper (Architecture B) builds/proves/broadcasts without ever + holding a share. A quorum is still required to move funds; a native shell does not weaken that. +- **CSP must be tightened before release.** The scaffold ships `csp: null` so the shell loads at + all. A real build must set a strict CSP: allow `wasm-unsafe-eval` for `konclave-wasm`, and + restrict `connect-src` to the configured relay and helper origins only. Tracked as a build-time + task, not a scaffold default. +- **No new trust in the packager.** Distribution (code signing, notarization, store review) adds + supply-chain responsibilities but does not change the on-device custody guarantee. + +## 8. Honest status ladder + +**Code-complete + logic-tested here (headless, no Tauri build needed):** +- `src-tauri/src/share_store.rs`: the `ShareStore` trait, `KeychainShareStore`, the public-id + index behind `list`, the id guard, error mapping, and the base64 boundary codec. **12 unit tests + pass** against `keyring`'s in-memory mock (run in an isolated crate, since the enclosing + `src-tauri` crate cannot compile here). Clippy `-D warnings` clean, rustfmt conformant. + +**Code-complete but UNBUILT here (needs a desktop/mobile host to compile and run):** +- `src-tauri/src/lib.rs`: the four `#[tauri::command]`s (`secure_store/load/delete/list`) and the + Tauri app builder. Correct-in-principle wiring, but the `tauri` crate needs a system webview + (webkit2gtk/glib) absent on this machine, so it is not compiled. No green signal on the wiring. +- `tauri.conf.json`, `Cargo.toml`, `build.rs`, `capabilities/default.json`, `main.rs`: schema-valid + Tauri 2.0 scaffold, mobile-shaped crate types, not compiled here. + +**Design-only (specified, no code committed):** +- `ui/src/share-store.ts` + the `NetVault.tsx` swap: fully specified in `NATIVE-STORAGE-BRIDGE.md` + with exact `invoke` shapes, deliberately not added so `npm run build`/CI stay green until the + Tauri deps are installed. +- Bundling the `orchestrator`/helper as a desktop sidecar (§5). + +**Needs real hardware to VALIDATE (cannot be attempted here):** +- Compile the shell on each desktop host; render a real window (blocked by WSLg/GTK, ADR-0004). +- Prove the keychain round-trip on each OS (the mock is per-`Entry`; true cross-call persistence + only exercises on a real backend -- Windows Credential Manager / macOS + iOS Keychain / Linux + Secret Service). +- Mobile: `tauri ios/android init` + build + sign on real Mac / Android SDK toolchains. +- Tighten CSP (the scaffold ships `csp: null`); code-sign / notarize per store. + +Until the desktop/mobile builds and the keychain round-trip are proven on hardware, Konclave's +honest delivery statement stays: **web-first, proven in the browser; native shell scaffolded and +planned, with the keychain share-store logic implemented and unit-tested but not yet built into a +running app.** diff --git a/src-tauri/.gitignore b/src-tauri/.gitignore new file mode 100644 index 0000000..b4cd035 --- /dev/null +++ b/src-tauri/.gitignore @@ -0,0 +1,6 @@ +# Rust build output +/target + +# Tauri-generated code (schemas, mobile projects) is produced by `tauri android/ios init` +# and the build script; regenerate it per host rather than committing it. +/gen diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml new file mode 100644 index 0000000..3e5978d --- /dev/null +++ b/src-tauri/Cargo.toml @@ -0,0 +1,50 @@ +# Konclave Tauri shell (Layer 3 delivery wrapper). +# +# UNVALIDATED SCAFFOLD. This crate has NOT been compiled or run in this environment +# (the dev machine's WSLg/GTK webview does not render, see docs/adr/0004-local-http-bridge.md). +# It is laid down as honest groundwork per docs/TAURI-PLAN.md. Versions are chosen to be +# coherent with Tauri 2.x and the rest of the repo, not verified by a successful build here. + +[package] +name = "konclave-tauri" +version = "0.1.0" +edition = "2021" +description = "Konclave native shell (Tauri 2.0): wraps the Vite/React UI in an OS webview and adds OS-keychain share persistence" +license = "MIT OR Apache-2.0" +# Keep the shell out of any future cargo workspace by default; it builds standalone. +publish = false + +# staticlib + cdylib are required by Tauri 2's mobile targets (iOS/Android link the app +# as a library); rlib keeps the crate usable from a normal `cargo build` on desktop. +[lib] +name = "konclave_tauri_lib" +crate-type = ["staticlib", "cdylib", "rlib"] + +[build-dependencies] +tauri-build = { version = "2", features = [] } + +[dependencies] +# The webview shell. `features` are intentionally empty here; a real build enables what a +# given target needs (for example a tray or a custom protocol) once validated on hardware. +tauri = { version = "2", features = [] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +# OS keychain for durable, on-device share persistence, replacing the browser's IndexedDB +# when running inside the native shell. Same crate the orchestrator already uses for the +# sealing key (audit C2): Windows Credential Manager / macOS + iOS Keychain / Linux Secret +# Service. Pinned with NO backend feature so this scaffold resolves anywhere; a real build +# turns on the platform-native backend per target (see the commented block below). +keyring = "3" + +# Per-target keychain backends (uncomment on the matching build host, once validated): +# [target.'cfg(target_os = "windows")'.dependencies] +# keyring = { version = "3", features = ["windows-native"] } +# [target.'cfg(any(target_os = "macos", target_os = "ios"))'.dependencies] +# keyring = { version = "3", features = ["apple-native"] } +# [target.'cfg(target_os = "linux")'.dependencies] +# keyring = { version = "3", features = ["sync-secret-service"] } + +[features] +# `custom-protocol` is the Tauri convention for a production (non-dev-server) build. +default = ["custom-protocol"] +custom-protocol = ["tauri/custom-protocol"] diff --git a/src-tauri/README.md b/src-tauri/README.md index 9a04443..fe45e9d 100644 --- a/src-tauri/README.md +++ b/src-tauri/README.md @@ -1,19 +1,88 @@ -# src-tauri/ +# src-tauri/: Konclave native shell (Tauri 2.0) -**Placeholder: Tauri packaging is on the roadmap, not built yet.** +**Status: UNVALIDATED SCAFFOLD. Not built, not run, not validated in this repo's environment.** -The plan was a Tauri desktop shell hosting the Rust backend. During integration the -WSLg/GTK window would not render on the dev machine, so we pivoted to a **loopback HTTP -bridge**: `konclave serve` binds `127.0.0.1` and serves the `ui` bundle plus a JSON API -wired to the tested core. Same local-first guarantee (keys never leave the device), only -the transport differs. See [docs/adr/0004-local-http-bridge.md](../docs/adr/0004-local-http-bridge.md). +This directory is honest groundwork for packaging Konclave as a native app. It has never +compiled or launched here: the dev machine's WSLg/GTK webview does not paint a window +(see [../docs/adr/0004-local-http-bridge.md](../docs/adr/0004-local-http-bridge.md)), and no +macOS / iOS / Android build host was available. Treat every command below as "what a +developer on the right hardware would run", not as something proven. -Where the code actually lives today: +The full rationale, the browser-vs-Tauri boundary, the share-persistence design, and the +per-platform matrix live in [../docs/TAURI-PLAN.md](../docs/TAURI-PLAN.md). Read that first. -- **Backend / orchestrator (Layer 2):** `orchestrator/`, holding the proposal state machine, - validation, wallet, ceremony, store, sealing, and the loopback bridge. -- **FROST↔PCZT bridge:** `konclave-signer/`. -- **Frontend (Layer 3, the UI):** `ui/`. +## What this shell is -Packaging the app as a single Tauri desktop binary is a post-submission roadmap item; it -changes only the delivery, not the architecture. +A thin Tauri 2.0 wrapper that loads the **already-built** Vite/React UI (`../ui/dist`) in a +native OS webview. The entire browser-proven app carries over unchanged: the `/net` DKG + +FROST flow, the relay/helper protocol, and the `konclave-wasm` crypto all run inside the +webview exactly as they do in a browser tab. The shell adds only one native capability today: +four `invoke` commands (`secure_store` / `secure_load` / `secure_delete` / `secure_list` in +`src/lib.rs`, over the `share_store` module) that let the UI persist its already-encrypted +per-device share in the **OS keychain** instead of browser IndexedDB. Plaintext key material +never crosses that boundary. The exact JS contract is in +[`../docs/NATIVE-STORAGE-BRIDGE.md`](../docs/NATIVE-STORAGE-BRIDGE.md). + +## Files + +| File | Purpose | +|---|---| +| `Cargo.toml` | Crate + Tauri 2.x deps; `lib` crate-type set for mobile targets. | +| `build.rs` | Standard `tauri_build::build()`. | +| `src/share_store.rs` | `ShareStore` trait + OS-keychain `KeychainShareStore` (no `tauri` dep). 12 unit tests pass headlessly against `keyring`'s mock. | +| `src/lib.rs` | App builder + the four `secure_*` commands delegating to `share_store`. Scaffold wiring (not compiled here). | +| `src/main.rs` | Desktop entry point that calls `run()`. | +| `tauri.conf.json` | `frontendDist` -> `../ui/dist`; window; CSP is `null` in the scaffold (must be tightened). | +| `capabilities/default.json` | Core default permissions only. | + +## Prerequisites (all hosts) + +- Rust (repo uses 1.95) and the Tauri CLI: `cargo install tauri-cli --version "^2"` (or `npm i -g @tauri-apps/cli@2`). +- Build the UI at least once so `../ui/dist` exists: `npm --prefix ../ui ci && npm --prefix ../ui run build`. +- Generate app icons once (bundling requires them): `npx tauri icon path/to/icon-1024.png`. + +## Per-target commands and honest status + +Run from `src-tauri/` (or repo root with `--config src-tauri/tauri.conf.json`). + +### Desktop + +```sh +# Dev (hot-reload against the Vite dev server): +cargo tauri dev + +# Release bundle for the current OS: +cargo tauri build +``` + +- **Windows** (`.msi` / `.exe` via WiX/NSIS), PLAN-ONLY. Needs a Windows host with MSVC + build tools + WebView2. Not run here. +- **macOS** (`.app` / `.dmg`), NEEDS HARDWARE. Needs a Mac with Xcode command-line tools; + signing/notarization need an Apple Developer ID. Not run here. +- **Linux** (`.deb` / `.rpm` / AppImage), SCAFFOLDED but BLOCKED on THIS machine. Needs + WebKitGTK (`libwebkit2gtk-4.1-dev`); the WSLg webview does not render here (ADR-0004), so + even a successful compile could not be validated visually on this box. + +### Mobile + +```sh +# Android (needs the crate's lib target, already set): +cargo tauri android init +cargo tauri android dev # or: cargo tauri android build + +# iOS: +cargo tauri ios init +cargo tauri ios dev # or: cargo tauri ios build +``` + +- **Android**, NEEDS HARDWARE/SDK. Needs the Android SDK + NDK, `ANDROID_HOME`/`NDK_HOME`, + and Rust android targets (`aarch64-linux-android`, ...). Not installed here. +- **iOS**, NEEDS HARDWARE. Needs macOS + Xcode + an Apple Developer account for signing, and + Rust ios targets (`aarch64-apple-ios`, ...). Impossible to validate off a Mac. + +## The one thing that IS checkable offline + +`src/lib.rs` has a single pure unit test (`base64_round_trips`) guarding the byte-wrapping at +the keychain boundary. It exercises no Tauri and no keychain (neither is available here). Even +that test has not been compiled in this environment, because pulling the Tauri dependency tree +was out of scope for laying down the scaffold. It is written to pass; it is not proven to. diff --git a/src-tauri/build.rs b/src-tauri/build.rs new file mode 100644 index 0000000..b326d5b --- /dev/null +++ b/src-tauri/build.rs @@ -0,0 +1,5 @@ +// UNVALIDATED SCAFFOLD (see src/lib.rs). Standard Tauri 2.0 build script: it wires the +// generated context, capabilities, and platform metadata at compile time. +fn main() { + tauri_build::build(); +} diff --git a/src-tauri/capabilities/default.json b/src-tauri/capabilities/default.json new file mode 100644 index 0000000..1e4619b --- /dev/null +++ b/src-tauri/capabilities/default.json @@ -0,0 +1,7 @@ +{ + "$schema": "../gen/schemas/desktop-schema.json", + "identifier": "default", + "description": "SCAFFOLD capability set: core defaults only. The four share-store commands (secure_store / secure_load / secure_delete / secure_list) are custom app commands wired via invoke_handler, so they need no plugin permission here. Widen this only when a plugin (for example a stronghold or a real keychain plugin) is added, and only for the windows/webviews that need it.", + "windows": ["main"], + "permissions": ["core:default"] +} diff --git a/src-tauri/icons/128x128.png b/src-tauri/icons/128x128.png new file mode 100644 index 0000000..78b7591 Binary files /dev/null and b/src-tauri/icons/128x128.png differ diff --git a/src-tauri/icons/128x128@2x.png b/src-tauri/icons/128x128@2x.png new file mode 100644 index 0000000..0024f57 Binary files /dev/null and b/src-tauri/icons/128x128@2x.png differ diff --git a/src-tauri/icons/32x32.png b/src-tauri/icons/32x32.png new file mode 100644 index 0000000..b4e3595 Binary files /dev/null and b/src-tauri/icons/32x32.png differ diff --git a/src-tauri/icons/icon.icns b/src-tauri/icons/icon.icns new file mode 100644 index 0000000..1aee9d8 Binary files /dev/null and b/src-tauri/icons/icon.icns differ diff --git a/src-tauri/icons/icon.ico b/src-tauri/icons/icon.ico new file mode 100644 index 0000000..8918433 Binary files /dev/null and b/src-tauri/icons/icon.ico differ diff --git a/src-tauri/icons/icon.png b/src-tauri/icons/icon.png new file mode 100644 index 0000000..d065349 Binary files /dev/null and b/src-tauri/icons/icon.png differ diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs new file mode 100644 index 0000000..d376ac8 --- /dev/null +++ b/src-tauri/src/lib.rs @@ -0,0 +1,73 @@ +// UNVALIDATED SCAFFOLD (the command wiring), LOGIC-TESTED CORE (share_store.rs). +// +// This file has NOT been compiled or run anywhere in this project: the `tauri` crate needs a +// system webview (webkit2gtk/glib), which is absent on this machine (docs/adr/0004-local-http-bridge.md), +// and no macOS/iOS/Android build host was available. Do not read a green CI signal into the Tauri +// wiring: there is none yet. What IS proven is the keychain logic it delegates to -- `share_store` +// has no `tauri` dependency and its tests run headlessly against `keyring`'s in-memory mock (the +// same technique `orchestrator::secrets::KeychainStore` uses). See docs/TAURI-PLAN.md. +// +// What it does when it DOES build on proper hardware: +// - opens a native webview whose content is the already-built Vite/React UI +// (tauri.conf.json -> build.frontendDist -> ../ui/dist), so the entire browser-proven app +// (the /net DKG + FROST flow, the relay/helper protocol, the konclave-wasm crypto) runs +// unchanged inside the OS webview; +// - exposes four `invoke` commands (secure_store / secure_load / secure_delete / secure_list) +// that let the SAME UI persist its per-device FROST share in the OS keychain instead of +// browser IndexedDB (ui/src/storage.ts). Only ENCRYPTED share bytes (already sealed by the +// UI) and public vault ids cross this boundary; plaintext key material never does. +// +// The share still never leaves the device (principle §6.3); the keychain is simply a more durable, +// OS-backed at-rest store than a browser origin's IndexedDB. The JS side of this contract is +// specified in docs/NATIVE-STORAGE-BRIDGE.md (exact invoke shapes). + +mod share_store; + +use share_store::{b64, unb64, KeychainShareStore, ShareStore, StoreError}; + +/// Map a storage failure to a UI-ready string. The UI turns these into human messages (§6.11). +fn msg(e: StoreError) -> String { + e.to_string() +} + +/// Persist the (already-encrypted) share bytes for `id` in the OS keychain. +/// `share_b64` is base64 of ciphertext produced by the UI; this command does not encrypt or +/// decrypt, and never sees plaintext key material (boundary validation of the base64, §6.8). +#[tauri::command] +fn secure_store(id: String, share_b64: String) -> Result<(), String> { + let bytes = unb64(&share_b64)?; + KeychainShareStore::default().store(&id, &bytes).map_err(msg) +} + +/// Return the stored share bytes (base64) for `id`, or an explicit "not found" error. +#[tauri::command] +fn secure_load(id: String) -> Result { + let bytes = KeychainShareStore::default().load(&id).map_err(msg)?; + Ok(b64(&bytes)) +} + +/// Remove the stored share for `id` from this device's keychain (idempotent). +#[tauri::command] +fn secure_delete(id: String) -> Result<(), String> { + KeychainShareStore::default().delete(&id).map_err(msg) +} + +/// List the vault ids that have a share stored on this device (public ids only). +#[tauri::command] +fn secure_list() -> Result, String> { + KeychainShareStore::default().list().map_err(msg) +} + +// Mobile (iOS/Android) enters through this function; desktop uses the same path via main.rs. +#[cfg_attr(mobile, tauri::mobile_entry_point)] +pub fn run() { + tauri::Builder::default() + .invoke_handler(tauri::generate_handler![ + secure_store, + secure_load, + secure_delete, + secure_list, + ]) + .run(tauri::generate_context!()) + .expect("error while running the Konclave Tauri application"); +} diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs new file mode 100644 index 0000000..a1174b2 --- /dev/null +++ b/src-tauri/src/main.rs @@ -0,0 +1,10 @@ +// UNVALIDATED SCAFFOLD. Not compiled or run in this environment (WSLg/GTK does not render a +// webview here, see docs/adr/0004-local-http-bridge.md). Groundwork only; see docs/TAURI-PLAN.md. +// +// Desktop entry point. On Windows release builds this attribute suppresses the console window; +// it is a no-op elsewhere. All the real setup lives in lib.rs so iOS/Android can share it. +#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] + +fn main() { + konclave_tauri_lib::run(); +} diff --git a/src-tauri/src/share_store.rs b/src-tauri/src/share_store.rs new file mode 100644 index 0000000..dd521ef --- /dev/null +++ b/src-tauri/src/share_store.rs @@ -0,0 +1,414 @@ +// On-device share persistence backed by the OS keychain. +// +// UNVALIDATED ON HARDWARE, BUT LOGIC-TESTED. This module has NO `tauri` dependency: it needs +// only `keyring`, so its unit tests run headlessly against `keyring`'s in-memory mock (exactly +// as `orchestrator::secrets::KeychainStore` is tested). The surrounding crate that wraps these +// in `#[tauri::command]`s (lib.rs) still cannot be COMPILED here, because `tauri` needs a system +// webview (webkit2gtk/glib), absent on this machine (ADR-0004). So: the keychain logic below is +// proven by a real green test run in isolation; the Tauri command wiring is scaffold only. +// +// THREAT MODEL (mirrors ui/src/storage.ts). The bytes stored here are ALREADY ciphertext, sealed +// by the UI under a passphrase (PBKDF2 -> AES-GCM). This layer is durable at-rest storage, not +// the encryptor: the plaintext share never crosses this boundary and never leaves the device. +// The keychain adds a second, OS-account at-rest factor ON TOP of the passphrase; it does not +// replace it. Compared with the browser's IndexedDB (origin-scoped, app-clearable, evictable), +// the keychain (Windows Credential Manager / macOS + iOS Keychain / Linux Secret Service) is +// durable and survives browser resets -- the whole reason to go native (docs/TAURI-PLAN.md §2). + +use std::collections::BTreeSet; + +/// Default keychain service namespace for Konclave's per-device sealed shares. Every entry this +/// module owns on the OS account lives under this service; the account is the caller's vault id. +pub const DEFAULT_SERVICE: &str = "app.konclave.share"; + +/// Reserved account name under which the list index lives (see [`KeychainShareStore::list`]). +/// A vault id may not collide with it. The `\u{0}`-fenced name cannot be a real vault id. +const INDEX_ACCOUNT: &str = "\u{0}konclave.index\u{0}"; + +/// Explicit, human-mappable failures at this boundary (§6.8/§6.11). Never a silent loss. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum StoreError { + /// The underlying keychain backend failed (no Secret Service, locked keychain, IO, ...). + Backend(String), + /// No share is stored on this device for the given id. + NotFound(String), + /// The id is empty or collides with the reserved index account. + InvalidId(String), +} + +impl std::fmt::Display for StoreError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + StoreError::Backend(e) => write!(f, "keychain unavailable: {e}"), + StoreError::NotFound(id) => write!(f, "no share stored on this device for id {id}"), + StoreError::InvalidId(id) => write!(f, "invalid vault id {id:?}"), + } + } +} +impl std::error::Error for StoreError {} + +/// A durable at-rest store for opaque, already-encrypted share bytes, keyed by vault id. +/// +/// The domain depends only on this trait; the real implementation is OS-keychain-backed and the +/// tests drive an in-memory one. This mirrors `orchestrator::secrets::KeyStore`. +pub trait ShareStore { + /// Persist `ciphertext` for `id`, overwriting any previous value. + fn store(&self, id: &str, ciphertext: &[u8]) -> Result<(), StoreError>; + /// Return the stored ciphertext for `id`, or [`StoreError::NotFound`]. + fn load(&self, id: &str) -> Result, StoreError>; + /// Remove the stored share for `id`. Idempotent: deleting an absent id is `Ok`. + fn delete(&self, id: &str) -> Result<(), StoreError>; + /// List the ids that currently have a stored share on this device. + fn list(&self) -> Result, StoreError>; +} + +/// OS-keychain-backed [`ShareStore`]. +/// +/// `keyring` exposes no enumeration API (there is no portable "list all credentials for a +/// service" across Windows/macOS/Linux/iOS), so [`list`](Self::list) is served from an INDEX +/// entry this store maintains under a reserved account. The index holds only vault ids, which +/// are public identifiers (the group verifying key's namespace) -- never secret material -- so +/// keeping them in the clear does not weaken the threat model. +/// +/// The native backend is selected by the app enabling `keyring`'s platform feature per target; +/// with no backend (headless CI) construction still succeeds and calls error clearly, and the +/// unit tests drive it through `keyring`'s in-memory mock. +pub struct KeychainShareStore { + service: String, +} + +impl Default for KeychainShareStore { + fn default() -> Self { + Self { + service: DEFAULT_SERVICE.to_string(), + } + } +} + +impl KeychainShareStore { + /// Construct a store under a specific keychain service namespace (tests isolate with this). + #[allow(dead_code)] // used by tests + the future keychain wiring; not on the default build path yet + pub fn new(service: impl Into) -> Self { + Self { + service: service.into(), + } + } + + fn entry(&self, account: &str) -> Result { + keyring::Entry::new(&self.service, account).map_err(|e| StoreError::Backend(e.to_string())) + } + + fn check_id(id: &str) -> Result<(), StoreError> { + if id.is_empty() || id == INDEX_ACCOUNT { + return Err(StoreError::InvalidId(id.to_string())); + } + Ok(()) + } + + /// Read the id index (a `\n`-joined set of public ids), tolerating a missing index as empty. + fn read_index(&self) -> Result, StoreError> { + let entry = self.entry(INDEX_ACCOUNT)?; + match entry.get_secret() { + Ok(bytes) => Ok(String::from_utf8_lossy(&bytes) + .lines() + .filter(|l| !l.is_empty()) + .map(str::to_string) + .collect()), + Err(keyring::Error::NoEntry) => Ok(BTreeSet::new()), + Err(e) => Err(StoreError::Backend(e.to_string())), + } + } + + fn write_index(&self, ids: &BTreeSet) -> Result<(), StoreError> { + let entry = self.entry(INDEX_ACCOUNT)?; + let joined = ids.iter().cloned().collect::>().join("\n"); + entry + .set_secret(joined.as_bytes()) + .map_err(|e| StoreError::Backend(e.to_string())) + } +} + +impl ShareStore for KeychainShareStore { + fn store(&self, id: &str, ciphertext: &[u8]) -> Result<(), StoreError> { + Self::check_id(id)?; + self.entry(id)? + .set_secret(ciphertext) + .map_err(|e| StoreError::Backend(e.to_string()))?; + let mut ids = self.read_index()?; + if ids.insert(id.to_string()) { + self.write_index(&ids)?; + } + Ok(()) + } + + fn load(&self, id: &str) -> Result, StoreError> { + Self::check_id(id)?; + match self.entry(id)?.get_secret() { + Ok(bytes) => Ok(bytes), + Err(keyring::Error::NoEntry) => Err(StoreError::NotFound(id.to_string())), + Err(e) => Err(StoreError::Backend(e.to_string())), + } + } + + fn delete(&self, id: &str) -> Result<(), StoreError> { + Self::check_id(id)?; + match self.entry(id)?.delete_credential() { + Ok(()) | Err(keyring::Error::NoEntry) => {} + Err(e) => return Err(StoreError::Backend(e.to_string())), + } + let mut ids = self.read_index()?; + if ids.remove(id) { + self.write_index(&ids)?; + } + Ok(()) + } + + fn list(&self) -> Result, StoreError> { + Ok(self.read_index()?.into_iter().collect()) + } +} + +// ---- base64 at the JS boundary ---- +// +// Tauri `invoke` marshals JS strings cleanly, so the command layer (lib.rs) hands share bytes +// across as standard padded base64 and this store keeps raw bytes in the keychain. These tiny, +// dependency-free helpers live here so they are covered by the same headless test run. + +const B64: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + +/// Encode bytes as standard padded base64. +pub fn b64(bytes: &[u8]) -> String { + let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4); + for chunk in bytes.chunks(3) { + let b = [ + chunk[0], + *chunk.get(1).unwrap_or(&0), + *chunk.get(2).unwrap_or(&0), + ]; + let n = ((b[0] as u32) << 16) | ((b[1] as u32) << 8) | (b[2] as u32); + out.push(B64[((n >> 18) & 63) as usize] as char); + out.push(B64[((n >> 12) & 63) as usize] as char); + out.push(if chunk.len() > 1 { + B64[((n >> 6) & 63) as usize] as char + } else { + '=' + }); + out.push(if chunk.len() > 2 { + B64[(n & 63) as usize] as char + } else { + '=' + }); + } + out +} + +/// Decode standard base64 (padding and whitespace tolerated), erroring on any stray symbol. +pub fn unb64(s: &str) -> Result, String> { + fn val(c: u8) -> Option { + match c { + b'A'..=b'Z' => Some((c - b'A') as u32), + b'a'..=b'z' => Some((c - b'a' + 26) as u32), + b'0'..=b'9' => Some((c - b'0' + 52) as u32), + b'+' => Some(62), + b'/' => Some(63), + _ => None, + } + } + let clean: Vec = s + .bytes() + .filter(|&c| c != b'=' && !c.is_ascii_whitespace()) + .collect(); + let mut out = Vec::with_capacity(clean.len() / 4 * 3); + for chunk in clean.chunks(4) { + let mut n = 0u32; + let mut bits = 0; + for &c in chunk { + n = (n << 6) | val(c).ok_or("invalid base64 in stored share")?; + bits += 6; + } + n <<= 24 - bits; + for i in 0..(bits / 8) { + out.push(((n >> (16 - i * 8)) & 0xff) as u8); + } + } + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::cell::RefCell; + use std::collections::BTreeMap; + + // ---- trait SEMANTICS, proven against an in-memory double ---- + // + // `keyring`'s in-memory mock keeps state INSIDE each `Entry` object, not in a shared backend + // (that is why `orchestrator::secrets` never round-trips a value across two `Entry::new` + // calls under the mock). So a true store-here / load-there round trip is only exercisable on + // a real OS backend, not headlessly. We therefore prove the store SEMANTICS against a + // `MemoryShareStore` (mirroring `orchestrator::secrets`'s `MemoryKeyStore`), and prove the + // `KeychainShareStore` error MAPPING + id guard against the mock below. + + #[derive(Default)] + struct MemoryShareStore { + map: RefCell>>, + } + impl ShareStore for MemoryShareStore { + fn store(&self, id: &str, ciphertext: &[u8]) -> Result<(), StoreError> { + KeychainShareStore::check_id(id)?; + self.map + .borrow_mut() + .insert(id.to_string(), ciphertext.to_vec()); + Ok(()) + } + fn load(&self, id: &str) -> Result, StoreError> { + KeychainShareStore::check_id(id)?; + self.map + .borrow() + .get(id) + .cloned() + .ok_or_else(|| StoreError::NotFound(id.to_string())) + } + fn delete(&self, id: &str) -> Result<(), StoreError> { + KeychainShareStore::check_id(id)?; + self.map.borrow_mut().remove(id); + Ok(()) + } + fn list(&self) -> Result, StoreError> { + Ok(self.map.borrow().keys().cloned().collect()) + } + } + + #[test] + fn store_then_load_returns_exact_bytes() { + let s = MemoryShareStore::default(); + let cipher = &[0u8, 255, 1, 254, 127, 42]; + s.store("vault-a", cipher).unwrap(); + assert_eq!(s.load("vault-a").unwrap(), cipher); + } + + #[test] + fn load_missing_is_an_explicit_not_found() { + let s = MemoryShareStore::default(); + assert_eq!(s.load("nope"), Err(StoreError::NotFound("nope".into()))); + } + + #[test] + fn store_overwrites_in_place() { + let s = MemoryShareStore::default(); + s.store("v", b"first").unwrap(); + s.store("v", b"second").unwrap(); + assert_eq!(s.load("v").unwrap(), b"second"); + assert_eq!(s.list().unwrap(), vec!["v".to_string()]); + } + + #[test] + fn delete_removes_and_is_idempotent() { + let s = MemoryShareStore::default(); + s.store("v", b"x").unwrap(); + s.delete("v").unwrap(); + assert_eq!(s.load("v"), Err(StoreError::NotFound("v".into()))); + // Deleting an absent id is not an error (§: explicit, non-surprising boundary). + s.delete("v").unwrap(); + assert!(s.list().unwrap().is_empty()); + } + + #[test] + fn list_reflects_the_stored_set() { + let s = MemoryShareStore::default(); + s.store("alice", b"a").unwrap(); + s.store("bob", b"b").unwrap(); + s.store("carol", b"c").unwrap(); + s.delete("bob").unwrap(); + let mut got = s.list().unwrap(); + got.sort(); + assert_eq!(got, vec!["alice".to_string(), "carol".to_string()]); + } + + #[test] + fn empty_and_reserved_ids_are_rejected() { + let s = MemoryShareStore::default(); + assert!(matches!(s.store("", b"x"), Err(StoreError::InvalidId(_)))); + assert!(matches!( + s.store(INDEX_ACCOUNT, b"x"), + Err(StoreError::InvalidId(_)) + )); + assert!(matches!(s.load(""), Err(StoreError::InvalidId(_)))); + } + + // ---- KeychainShareStore error MAPPING + guard, against the keyring mock ---- + + /// Route `keyring` through its in-memory mock (set once per process), exactly as + /// `orchestrator::secrets` does, so the keychain path is reachable with no OS Secret Service. + fn use_mock_keychain() { + use std::sync::Once; + static ONCE: Once = Once::new(); + ONCE.call_once(|| { + keyring::set_default_credential_builder(keyring::mock::default_credential_builder()); + }); + } + + #[test] + fn keychain_load_absent_maps_no_entry_to_not_found() { + use_mock_keychain(); + // A fresh keychain entry reports NoEntry; the store must surface it as a clean NotFound, + // never a panic or an opaque backend string. + let s = KeychainShareStore::new("test.konclave.share.absent"); + assert_eq!(s.load("ghost"), Err(StoreError::NotFound("ghost".into()))); + } + + #[test] + fn keychain_list_is_empty_without_an_index() { + use_mock_keychain(); + // No index entry yet => an empty list, not a backend error (read_index tolerates NoEntry). + let s = KeychainShareStore::new("test.konclave.share.emptylist"); + assert!(s.list().unwrap().is_empty()); + } + + #[test] + fn keychain_rejects_invalid_ids_before_touching_the_backend() { + use_mock_keychain(); + let s = KeychainShareStore::new("test.konclave.share.guard"); + assert!(matches!(s.store("", b"x"), Err(StoreError::InvalidId(_)))); + assert!(matches!( + s.store(INDEX_ACCOUNT, b"x"), + Err(StoreError::InvalidId(_)) + )); + assert!(matches!(s.delete(""), Err(StoreError::InvalidId(_)))); + } + + #[test] + fn keychain_backend_contract_secret_roundtrips_within_one_entry() { + use_mock_keychain(); + // Documents the backend contract KeychainShareStore relies on: a single Entry stores and + // returns exactly the bytes set, and signals NoEntry when absent. (Cross-`Entry::new` + // persistence needs a REAL OS backend; the mock is per-Entry, hence the MemoryShareStore + // tests above for full round-trip semantics.) + let entry = keyring::Entry::new("test.konclave.share.contract", "acct").unwrap(); + assert!(matches!(entry.get_secret(), Err(keyring::Error::NoEntry))); + let cipher = &[9u8, 8, 7, 6, 0, 255]; + entry.set_secret(cipher).unwrap(); + assert_eq!(entry.get_secret().unwrap().as_slice(), cipher); + } + + // ---- base64 boundary helpers ---- + + #[test] + fn base64_round_trips() { + for case in [ + b"".as_slice(), + b"a", + b"ab", + b"abc", + b"abcd", + &[0u8, 255, 1, 254, 127], + ] { + assert_eq!(unb64(&b64(case)).unwrap(), case, "round-trip mismatch"); + } + } + + #[test] + fn base64_rejects_stray_symbols() { + assert!(unb64("not*base64").is_err()); + } +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json new file mode 100644 index 0000000..7b1b949 --- /dev/null +++ b/src-tauri/tauri.conf.json @@ -0,0 +1,38 @@ +{ + "$schema": "https://schema.tauri.app/config/2", + "productName": "Konclave", + "version": "0.1.0", + "identifier": "app.konclave.desktop", + "build": { + "frontendDist": "../ui/dist", + "devUrl": "http://localhost:5173", + "beforeDevCommand": "npm --prefix ui run dev", + "beforeBuildCommand": "npm --prefix ui run build" + }, + "app": { + "windows": [ + { + "title": "Konclave", + "width": 1100, + "height": 820, + "minWidth": 900, + "minHeight": 600, + "resizable": true + } + ], + "security": { + "csp": null + } + }, + "bundle": { + "active": true, + "targets": "all", + "icon": [ + "icons/32x32.png", + "icons/128x128.png", + "icons/128x128@2x.png", + "icons/icon.icns", + "icons/icon.ico" + ] + } +} diff --git a/ui/src/App.tsx b/ui/src/App.tsx index b58712d..42a8494 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -1,4 +1,5 @@ -import { Routes, Route } from 'react-router-dom' +import { Routes, Route, Navigate } from 'react-router-dom' +import { isDesktop } from './platform' import { useReveal } from './reveal' import { DemoBanner } from './components' import Layout from './Layout' @@ -34,7 +35,9 @@ export default function App() { {/* Onboarding — standalone, no rail */} - } /> + {/* The desktop app opens straight on the product (the vaults), not the marketing landing; + the web keeps the landing at `/`. */} + : } /> } /> } /> } /> diff --git a/ui/src/platform.ts b/ui/src/platform.ts new file mode 100644 index 0000000..173ac40 --- /dev/null +++ b/ui/src/platform.ts @@ -0,0 +1,6 @@ +// Detect the Tauri desktop shell. Tauri v2 always injects `__TAURI_INTERNALS__` into the webview +// (and `__TAURI__` when `withGlobalTauri` is on); on the plain web both are absent. Used to open +// the desktop app straight on the product (the vaults), not the marketing landing. +export const isDesktop = + typeof window !== 'undefined' && + ('__TAURI_INTERNALS__' in window || '__TAURI__' in window)