diff --git a/package.json b/package.json index bae46ab..749ef6f 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "whispassist", "private": true, - "version": "0.0.0", + "version": "0.1.0", "type": "module", "description": "Privacy-first, fully local Windows meeting assistant.", "license": "MIT OR Apache-2.0", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index cce43ab..f94bb37 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -8,6 +8,16 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -53,6 +63,18 @@ version = "1.0.103" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" +[[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", +] + [[package]] name = "async-trait" version = "0.1.89" @@ -199,6 +221,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" @@ -436,6 +467,30 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "chacha20" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" +dependencies = [ + "cfg-if", + "cipher", + "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.45" @@ -450,6 +505,17 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", + "zeroize", +] + [[package]] name = "clang-sys" version = "1.8.1" @@ -621,6 +687,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", + "rand_core 0.6.4", "typenum", ] @@ -1975,6 +2042,15 @@ dependencies = [ "cfb", ] +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + [[package]] name = "ipnet" version = "2.12.0" @@ -2765,6 +2841,12 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + [[package]] name = "option-ext" version = "0.2.0" @@ -2856,6 +2938,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 = "pastey" version = "0.2.3" @@ -3008,6 +3101,17 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "poly1305" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" +dependencies = [ + "cpufeatures", + "opaque-debug", + "universal-hash", +] + [[package]] name = "pom" version = "3.4.0" @@ -5412,6 +5516,16 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + [[package]] name = "untrusted" version = "0.9.0" @@ -5811,11 +5925,14 @@ dependencies = [ [[package]] name = "whispassist" -version = "0.0.0" +version = "0.1.0" dependencies = [ + "argon2", "async-trait", + "chacha20poly1305", "docx-rs", "futures-util", + "getrandom 0.2.17", "hound", "keyring", "ort", @@ -5840,6 +5957,7 @@ dependencies = [ "wasapi", "whisper-rs", "windows 0.58.0", + "zeroize", ] [[package]] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 757149c..f53d0f5 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "whispassist" -version = "0.0.0" +version = "0.1.0" description = "Privacy-first, fully local Windows meeting assistant" authors = ["WhispAssist contributors"] license = "MIT OR Apache-2.0" @@ -35,6 +35,14 @@ sqlx = { version = "0.8", features = ["runtime-tokio", "sqlite", "migrate"] } reqwest = { version = "0.12", default-features = false, features = ["json", "stream", "rustls-tls"] } futures-util = "0.3" sha2 = "0.10" # skip-if-unchanged hashing (sync) + +# At-rest encryption vault (Phase 8, T8.8, FR-SEC-3). Pure-Rust RustCrypto — no +# C build. Argon2id derives the vault key from the password; XChaCha20-Poly1305 +# (24-byte nonce → safe random nonces) is the AEAD for artifacts + key wrapping. +argon2 = "0.5" +chacha20poly1305 = "0.10" +getrandom = "0.2" +zeroize = "1" # wipe key material from memory on lock keyring = { version = "3", optional = true } # OS credential store (sync + AI creds) rmcp = { version = "0.16", optional = true, features = ["server"] } # MCP server (ADR-0011) @@ -73,7 +81,7 @@ windows = { version = "0.58", features = [ wasapi = { version = "0.15", optional = true } # Phase 1 [features] -default = ["audio", "cpu-transcription", "diarization", "pst"] +default = ["audio", "cpu-transcription", "diarization", "pst", "sync"] # Phase 1 audio = ["dep:wasapi", "dep:hound"] cpu-transcription = ["dep:whisper-rs"] # whisper-rs CPU build diff --git a/src-tauri/src/audio/mod.rs b/src-tauri/src/audio/mod.rs index dd3fe09..b9f6b7b 100644 --- a/src-tauri/src/audio/mod.rs +++ b/src-tauri/src/audio/mod.rs @@ -497,13 +497,16 @@ impl Resampler { /// through hound's size-aware iterator; hound is only used to parse the /// format/`fmt ` chunk. pub fn read_wav_mono_16k(path: &Path) -> Result, AudioError> { - let reader = - hound::WavReader::open(path).map_err(|e| AudioError::Capture(format!("open wav: {e}")))?; - let spec = reader.spec(); - let channels = spec.channels.max(1) as usize; - drop(reader); + let raw = std::fs::read(path).map_err(|e| AudioError::Capture(format!("read wav: {e}")))?; + // Decrypt if the recording was sealed at rest (T8.8); plaintext passes through. + let file = + crate::vault::open(&raw).map_err(|e| AudioError::Capture(format!("decrypt wav: {e}")))?; + + let spec = hound::WavReader::new(std::io::Cursor::new(file.as_slice())) + .map_err(|e| AudioError::Capture(format!("open wav: {e}")))? + .spec(); + let channels = spec.channels.max(1) as usize; - let file = std::fs::read(path).map_err(|e| AudioError::Capture(format!("read wav: {e}")))?; let data_marker = file .windows(4) .position(|w| w == b"data") diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 4ae68d0..af496c0 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -18,7 +18,7 @@ use crate::paths::{ diarization_embedding_model_file, diarization_segmentation_model_file, meeting_dir, settings_path, wa_root, whisper_model_file, }; -use crate::storage::{FinalizeMeeting, Meeting, NewMeeting, SummaryFile}; +use crate::storage::{FinalizeMeeting, Meeting, NewMeeting, SummaryFile, SyncTargetRow}; use crate::transcription::{ models as model_catalog, run_streaming_worker, Transcriber, WhisperTranscriber, }; @@ -545,6 +545,30 @@ pub async fn stop_recording( // above, and only when retention is off. if !session.retention { let _ = std::fs::remove_file(&session.wav_path); + } else if crate::vault::is_unlocked() { + // Seal the retained recording at rest when the vault is unlocked (T8.8). + // Runs before the sync enqueue below, so any uploaded copy is ciphertext. + if let Ok(raw) = std::fs::read(&session.wav_path) { + if let Ok(sealed) = crate::vault::seal(&raw) { + let _ = std::fs::write(&session.wav_path, sealed); + } + } + } + + // Sync-on-finalize (T9.5, FR-SYNC-5): enqueue configured artifacts for + // finalize-trigger targets and pump in the background so stop returns + // promptly. Runs after the WAV-retention decision above, so a deleted + // working recording is simply skipped (its file is gone). + if load_settings().sync_enabled { + let store = state.store.clone(); + let app_sync = app.clone(); + let mid = meeting_id.clone(); + tauri::async_runtime::spawn(async move { + if let Err(e) = enqueue_meeting_sync(store.as_ref(), &mid, None, true).await { + tracing::warn!("sync enqueue on finalize failed: {e:?}"); + } + pump_sync(&app_sync, store.as_ref()).await; + }); } crate::update_tray_tooltip(&app, "WhispAssist — idle"); @@ -1387,12 +1411,22 @@ async fn export_meeting_to( std::fs::create_dir_all(dest_path) .map_err(|e| WaError::new("export", e.to_string()))?; let source_dir = meeting_dir(meeting_id); - for name in ["audio.wav", "transcript.json"] { - let src = source_dir.join(name); - if src.exists() { - std::fs::copy(&src, dest_path.join(name)) - .map_err(|e| WaError::new("export", e.to_string()))?; - } + // Audio is stored plaintext — copy as-is. + let audio_src = source_dir.join("audio.wav"); + if audio_src.exists() { + std::fs::copy(&audio_src, dest_path.join("audio.wav")) + .map_err(|e| WaError::new("export", e.to_string()))?; + } + // transcript.json may be vault-sealed; export the decrypted content + // so the bundle is usable (T8.8). + let transcript_src = source_dir.join("transcript.json"); + if transcript_src.exists() { + let bytes = std::fs::read(&transcript_src) + .map_err(|e| WaError::new("export", e.to_string()))?; + let plain = + crate::vault::open(&bytes).map_err(|e| WaError::new("vault", e.to_string()))?; + std::fs::write(dest_path.join("transcript.json"), plain) + .map_err(|e| WaError::new("export", e.to_string()))?; } std::fs::write(dest_path.join("notes.md"), &meeting.notes_markdown) .map_err(|e| WaError::new("export", e.to_string()))?; @@ -1629,7 +1663,10 @@ pub async fn generate_summary( }; let json = serde_json::to_string_pretty(&summary_file) .map_err(|e| WaError::new("llm", e.to_string()))?; - let _ = std::fs::write(meeting_dir(&meeting_id).join("summary.json"), json); + // Seal at rest when the vault is unlocked (T8.8, FR-SEC-3); passthrough otherwise. + if let Ok(sealed) = crate::vault::seal(json.as_bytes()) { + let _ = std::fs::write(meeting_dir(&meeting_id).join("summary.json"), sealed); + } let _ = app.emit( "llm://done", @@ -1750,59 +1787,676 @@ pub async fn attach_meeting_to_event( // ---- Sync / upload (Phase 9, ADR-0010) ---- -#[tauri::command] -pub async fn list_sync_targets() -> WaResult> { - // Never returns secrets (FR-SYNC-6). - Err(not_implemented("list_sync_targets")) +/// Config payload for add/update (snake_case, matching the TS `SyncTargetConfig` +/// and the other serde structs). The `secret` (app password) is write-only — it +/// goes to the OS store, never back to the UI (FR-SYNC-6). +#[derive(Deserialize, Default)] +pub struct SyncTargetConfigInput { + pub id: Option, + pub name: Option, + pub kind: Option, // webdav (default) | onedrive | dropbox | box + pub provider_hint: Option, + pub base_url: Option, + pub remote_base_path: Option, + pub username: Option, + pub secret: Option, + pub enabled: Option, + pub upload_transcript: Option, + pub upload_notes: Option, + pub upload_summary: Option, + pub upload_recording: Option, + pub trigger_on_finalize: Option, + pub allow_plaintext_lan: Option, + pub encrypt_before_upload: Option, } #[tauri::command] -pub async fn add_sync_target(_config: serde_json::Value) -> WaResult { - // `config` includes a `secret` stored to the OS credential store, not the DB. - Err(not_implemented("add_sync_target")) +pub async fn list_sync_targets(state: State<'_, AppState>) -> WaResult> { + // Never returns secrets (FR-SYNC-6) — row_to_info drops credential_ref. + let rows = state + .store + .list_sync_targets() + .await + .map_err(|e| WaError::new("sync", e.to_string()))?; + Ok(rows.iter().map(crate::sync::row_to_info).collect()) } #[tauri::command] -pub async fn update_sync_target(_config: serde_json::Value) -> WaResult { - // `config` carries an `id` and optional fields; an optional `secret` updates the credential store. - Err(not_implemented("update_sync_target")) +pub async fn add_sync_target( + state: State<'_, AppState>, + config: SyncTargetConfigInput, +) -> WaResult { + let id = uuid::Uuid::new_v4().to_string(); + let credential_ref = format!("wa-sync-{id}"); + // Secret goes to the OS credential store keyed by credential_ref, never the DB. + if let Some(secret) = config.secret.as_deref().filter(|s| !s.is_empty()) { + crate::sync::credentials::set(&credential_ref, secret) + .map_err(|e| WaError::new("sync", e.to_string()))?; + } + let row = SyncTargetRow { + id, + name: config.name.unwrap_or_else(|| "WebDAV target".to_string()), + kind: config.kind.unwrap_or_else(|| "webdav".to_string()), + provider_hint: config.provider_hint, + base_url: config.base_url, + remote_base_path: config + .remote_base_path + .unwrap_or_else(|| "/WhispAssist".to_string()), + username: config.username, + credential_ref, + enabled: config.enabled.unwrap_or(false), + upload_transcript: config.upload_transcript.unwrap_or(true), + upload_notes: config.upload_notes.unwrap_or(true), + upload_summary: config.upload_summary.unwrap_or(true), + upload_recording: config.upload_recording.unwrap_or(false), + trigger_on_finalize: config.trigger_on_finalize.unwrap_or(true), + allow_plaintext_lan: config.allow_plaintext_lan.unwrap_or(false), + encrypt_before_upload: config.encrypt_before_upload.unwrap_or(false), + created_at: now_unix(), + }; + state + .store + .add_sync_target(row.clone()) + .await + .map_err(|e| WaError::new("sync", e.to_string()))?; + Ok(crate::sync::row_to_info(&row)) } #[tauri::command] -pub async fn remove_sync_target(_id: String) -> WaResult<()> { - Err(not_implemented("remove_sync_target")) -} +pub async fn update_sync_target( + state: State<'_, AppState>, + config: SyncTargetConfigInput, +) -> WaResult { + let id = config + .id + .clone() + .ok_or_else(|| WaError::new("sync", "update requires a target id"))?; + let mut row = state + .store + .get_sync_target(&id) + .await + .map_err(|e| WaError::new("sync", e.to_string()))?; -/// Begin OAuth 2.0 PKCE linking for a secondary target (loopback redirect). FR-SYNC-9. -#[tauri::command] -pub async fn begin_oauth_link(_kind: String) -> WaResult { - Err(not_implemented("begin_oauth_link")) + // A provided secret rotates the credential; credential_ref is preserved. + if let Some(secret) = config.secret.as_deref().filter(|s| !s.is_empty()) { + crate::sync::credentials::set(&row.credential_ref, secret) + .map_err(|e| WaError::new("sync", e.to_string()))?; + } + if let Some(v) = config.name { + row.name = v; + } + if let Some(v) = config.provider_hint { + row.provider_hint = Some(v); + } + if let Some(v) = config.base_url { + row.base_url = Some(v); + } + if let Some(v) = config.remote_base_path { + row.remote_base_path = v; + } + if let Some(v) = config.username { + row.username = Some(v); + } + if let Some(v) = config.enabled { + row.enabled = v; + } + if let Some(v) = config.upload_transcript { + row.upload_transcript = v; + } + if let Some(v) = config.upload_notes { + row.upload_notes = v; + } + if let Some(v) = config.upload_summary { + row.upload_summary = v; + } + if let Some(v) = config.upload_recording { + row.upload_recording = v; + } + if let Some(v) = config.trigger_on_finalize { + row.trigger_on_finalize = v; + } + if let Some(v) = config.allow_plaintext_lan { + row.allow_plaintext_lan = v; + } + if let Some(v) = config.encrypt_before_upload { + row.encrypt_before_upload = v; + } + state + .store + .update_sync_target(row.clone()) + .await + .map_err(|e| WaError::new("sync", e.to_string()))?; + Ok(crate::sync::row_to_info(&row)) } #[tauri::command] -pub async fn retry_sync_job(_job_id: String) -> WaResult<()> { - Err(not_implemented("retry_sync_job")) +pub async fn remove_sync_target(state: State<'_, AppState>, id: String) -> WaResult<()> { + // Best-effort secret cleanup first, then the row (cascades its jobs). + if let Ok(row) = state.store.get_sync_target(&id).await { + let _ = crate::sync::credentials::delete(&row.credential_ref); + } + state + .store + .remove_sync_target(&id) + .await + .map_err(|e| WaError::new("sync", e.to_string())) +} + +fn oauth_display_name(kind: &str) -> String { + match kind { + "onedrive" => "OneDrive", + "dropbox" => "Dropbox", + "box" => "Box", + other => other, + } + .to_string() +} + +/// Begin OAuth 2.0 Authorization Code + PKCE linking for a secondary target +/// (loopback redirect). Returns the `authUrl` for the frontend to open; the flow +/// finishes in the background and emits `sync://linked` (T9.9, FR-SYNC-9). +#[tauri::command] +pub async fn begin_oauth_link( + app: AppHandle, + state: State<'_, AppState>, + kind: String, +) -> WaResult { + use crate::sync::oauth; + + let provider = oauth::provider_for(&kind) + .ok_or_else(|| WaError::new("sync", format!("unknown OAuth provider '{kind}'")))?; + // Requires an app registration; clear error until a client id is configured. + let client_id = provider + .client_id() + .map_err(|e| WaError::new("sync", e.to_string()))?; + + let pkce = oauth::pkce_pair(); + let csrf_state = uuid::Uuid::new_v4().to_string(); + let loopback = + oauth::LoopbackRedirect::bind().map_err(|e| WaError::new("sync", e.to_string()))?; + let redirect_uri = loopback.redirect_uri.clone(); + let auth_url = oauth::build_auth_url( + &provider, + &client_id, + &redirect_uri, + &pkce.challenge, + &csrf_state, + ) + .map_err(|e| WaError::new("sync", e.to_string()))?; + + // Finish the handshake off-thread: wait for the redirect, exchange the code, + // store tokens, and create the target. The frontend opens `authUrl` and + // listens for `sync://linked`. + let store = state.store.clone(); + let app_bg = app.clone(); + let verifier = pkce.verifier; + tauri::async_runtime::spawn(async move { + let fail = |msg: String| { + let _ = app_bg.emit( + "sync://linked", + serde_json::json!({ "ok": false, "kind": kind, "error": msg }), + ); + }; + + // Blocking single-shot accept of the browser redirect. + let code = + match tauri::async_runtime::spawn_blocking(move || loopback.wait_for_code(&csrf_state)) + .await + { + Ok(Ok(code)) => code, + _ => return fail("authorization was cancelled or failed".into()), + }; + + let tokens = match oauth::exchange_code( + &provider, + &client_id, + &code, + &verifier, + &redirect_uri, + ) + .await + { + Ok(t) => t, + Err(e) => return fail(e.to_string()), + }; + + let id = uuid::Uuid::new_v4().to_string(); + let credential_ref = format!("wa-sync-{id}"); + let Ok(json) = serde_json::to_string(&tokens) else { + return fail("could not serialize tokens".into()); + }; + if let Err(e) = crate::sync::credentials::set(&credential_ref, &json) { + return fail(e.to_string()); + } + let row = SyncTargetRow { + id, + name: oauth_display_name(&kind), + kind: kind.clone(), + provider_hint: None, + base_url: None, + remote_base_path: "/WhispAssist".to_string(), + username: None, + credential_ref, + enabled: true, + upload_transcript: true, + upload_notes: true, + upload_summary: true, + upload_recording: false, + trigger_on_finalize: true, + allow_plaintext_lan: false, + encrypt_before_upload: false, + created_at: now_unix(), + }; + if let Err(e) = store.add_sync_target(row).await { + return fail(e.to_string()); + } + let _ = app_bg.emit( + "sync://linked", + serde_json::json!({ "ok": true, "kind": kind.clone() }), + ); + }); + + Ok(serde_json::json!({ "authUrl": auth_url })) } #[tauri::command] -pub async fn test_sync_target(_config_or_id: serde_json::Value) -> WaResult { - Err(not_implemented("test_sync_target")) +pub async fn retry_sync_job( + app: AppHandle, + state: State<'_, AppState>, + job_id: String, +) -> WaResult<()> { + let mut job = state + .store + .get_sync_job(&job_id) + .await + .map_err(|e| WaError::new("sync", e.to_string()))?; + // Clear the backoff so the pump picks it up immediately. + job.status = "pending".to_string(); + job.next_attempt_at = None; + job.updated_at = now_unix(); + state + .store + .update_sync_job(job) + .await + .map_err(|e| WaError::new("sync", e.to_string()))?; + pump_sync(&app, state.store.as_ref()).await; + Ok(()) +} + +/// Provider-specific setup tips surfaced on a failed/attempted test (T9.4). +fn provider_setup_hints(hint: Option<&str>) -> Vec<&'static str> { + match hint { + Some("seafile") => { + vec!["Enable SeafDAV on the server; it's off by default and may need LOCK disabled."] + } + Some("synology") => { + vec!["Install & enable the WebDAV Server package; prefer the HTTPS port."] + } + Some("nextcloud") | Some("owncloud") => { + vec!["Use an app password (Settings → Security), not your account password."] + } + _ => vec![], + } +} + +/// Reachability + auth test for a target — either an existing one (by `id`) or an +/// unsaved config carrying an inline `secret` (T9.4, FR-SYNC-4). +#[tauri::command] +pub async fn test_sync_target( + state: State<'_, AppState>, + config: SyncTargetConfigInput, +) -> WaResult { + use crate::sync::SyncTarget; + + let hints = provider_setup_hints(config.provider_hint.as_deref()); + let (target, temp_ref): (Box, Option) = + if let Some(id) = config.id.clone() { + // Existing target (any kind) — dispatch to its concrete impl. + let row = state + .store + .get_sync_target(&id) + .await + .map_err(|e| WaError::new("sync", e.to_string()))?; + let target = crate::sync::build_sync_target(&row) + .map_err(|e| WaError::new("sync", e.to_string()))?; + (target, None) + } else { + // Unsaved WebDAV target: stash the secret under a temp credential_ref so + // the same resolve-at-use path works, then clean it up after the test. + let base_url = config + .base_url + .clone() + .ok_or_else(|| WaError::new("sync", "test requires a base_url"))?; + let temp_ref = format!("wa-sync-test-{}", uuid::Uuid::new_v4()); + if let Some(secret) = config.secret.as_deref().filter(|s| !s.is_empty()) { + crate::sync::credentials::set(&temp_ref, secret) + .map_err(|e| WaError::new("sync", e.to_string()))?; + } + let target = crate::sync::WebDavTarget { + base_url, + remote_base_path: config + .remote_base_path + .clone() + .unwrap_or_else(|| "/WhispAssist".to_string()), + username: config.username.clone().unwrap_or_default(), + credential_ref: temp_ref.clone(), + third_party: config.kind.as_deref().unwrap_or("webdav") != "webdav", + allow_plaintext_lan: config.allow_plaintext_lan.unwrap_or(false), + }; + (Box::new(target), Some(temp_ref)) + }; + + let result = target.test().await; + if let Some(temp_ref) = temp_ref { + let _ = crate::sync::credentials::delete(&temp_ref); + } + + match result { + Ok(()) => Ok(serde_json::json!({ "ok": true, "message": "Connected", "hints": hints })), + Err(e) => Ok(serde_json::json!({ "ok": false, "message": e.to_string(), "hints": hints })), + } } #[tauri::command] -pub async fn set_sync_enabled(_enabled: bool) -> WaResult<()> { - Err(not_implemented("set_sync_enabled")) +pub async fn set_sync_enabled(enabled: bool) -> WaResult<()> { + let mut settings = load_settings(); + settings.sync_enabled = enabled; + save_settings(&settings) +} + +// ---- At-rest encryption vault (Phase 8, T8.8, FR-SEC-3) ---- + +#[tauri::command] +pub async fn vault_status() -> WaResult { + Ok(serde_json::json!({ + "enabled": crate::vault::is_enabled(), + "unlocked": crate::vault::is_unlocked(), + })) +} + +#[derive(Deserialize)] +pub struct VaultPasswordArgs { + pub password: String, +} + +/// Turn on the vault: derive a key from the password and start sealing artifacts +/// at rest. Leaves the vault unlocked for the session. +#[tauri::command] +pub async fn enable_vault(args: VaultPasswordArgs) -> WaResult<()> { + crate::vault::enable(&args.password).map_err(|e| WaError::new("vault", e.to_string())) } #[tauri::command] -pub async fn sync_meeting(_meeting_id: MeetingId, _target_id: Option) -> WaResult<()> { - // Manual "Upload now"; streams progress via "sync://job" events. - Err(not_implemented("sync_meeting")) +pub async fn unlock_vault(args: VaultPasswordArgs) -> WaResult<()> { + crate::vault::unlock(&args.password).map_err(|e| WaError::new("vault", e.to_string())) } #[tauri::command] -pub async fn sync_status(_meeting_id: Option) -> WaResult> { - Err(not_implemented("sync_status")) +pub async fn lock_vault() -> WaResult<()> { + crate::vault::lock(); + Ok(()) +} + +#[derive(Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ChangeVaultPasswordArgs { + pub old_password: String, + pub new_password: String, +} + +#[tauri::command] +pub async fn change_vault_password(args: ChangeVaultPasswordArgs) -> WaResult<()> { + crate::vault::change_password(&args.old_password, &args.new_password) + .map_err(|e| WaError::new("vault", e.to_string())) +} + +// ---- Sync durable queue (T9.5) ---- + +/// Give up auto-retrying after this many failures; the job stays `failed` with +/// no `next_attempt_at` until the user hits "retry". +const SYNC_MAX_ATTEMPTS: i64 = 6; + +/// Exponential backoff: 30s, 60s, 120s … capped at 1h. +fn sync_backoff_secs(attempts: i64) -> i64 { + (30_i64.saturating_mul(1_i64 << attempts.min(7))).min(3600) +} + +fn file_sha256(path: &Path) -> Option { + use sha2::{Digest, Sha256}; + let bytes = std::fs::read(path).ok()?; + let mut hasher = Sha256::new(); + hasher.update(&bytes); + Some(format!("{:x}", hasher.finalize())) +} + +/// Which (artifact, filename) pairs this target is configured to upload. +fn artifact_plan(target: &SyncTargetRow) -> Vec<(&'static str, &'static str)> { + let mut plan = Vec::new(); + if target.upload_transcript { + plan.push(("transcript", "transcript.json")); + } + if target.upload_notes { + plan.push(("notes", "notes.md")); + } + if target.upload_summary { + plan.push(("summary", "summary.json")); + } + if target.upload_recording { + plan.push(("recording", "audio.wav")); + } + plan +} + +fn job_to_info(row: &crate::storage::SyncJobRow) -> SyncJobInfo { + SyncJobInfo { + id: row.id.clone(), + target_id: row.target_id.clone(), + meeting_id: row.meeting_id.clone(), + artifact: row.artifact.clone(), + status: row.status.clone(), + attempts: row.attempts as u32, + bytes_sent: row.bytes_sent as u64, + bytes_total: row.bytes_total.map(|b| b as u64), + last_error: row.last_error.clone(), + } +} + +fn emit_sync_job(app: &AppHandle, row: &crate::storage::SyncJobRow) { + // camelCase event shape per docs/04-api-contracts.md (`onSyncJob`). + let _ = app.emit( + "sync://job", + serde_json::json!({ + "jobId": row.id, + "meetingId": row.meeting_id, + "targetId": row.target_id, + "artifact": row.artifact, + "status": row.status, + "bytesSent": row.bytes_sent, + "bytesTotal": row.bytes_total, + "attempts": row.attempts, + "error": row.last_error, + }), + ); +} + +/// Enqueue a meeting's configured artifacts for one or all enabled targets +/// (T9.5). `finalize_only` limits to targets with `trigger_on_finalize`. Files +/// that don't exist are skipped; unchanged files are deduped by SHA-256 in the +/// storage upsert. Returns the number of jobs (re)queued. +pub(crate) async fn enqueue_meeting_sync( + store: &dyn crate::storage::Store, + meeting_id: &MeetingId, + target_id: Option<&str>, + finalize_only: bool, +) -> Result { + let targets = store + .list_sync_targets() + .await + .map_err(|e| WaError::new("sync", e.to_string()))?; + let mut queued = 0u32; + for target in targets.iter().filter(|t| t.enabled) { + if target_id.is_some_and(|id| id != target.id) { + continue; + } + if finalize_only && !target.trigger_on_finalize { + continue; + } + let base = target.remote_base_path.trim_end_matches('/'); + for (artifact, filename) in artifact_plan(target) { + let local = meeting_dir(meeting_id).join(filename); + if !local.exists() { + continue; + } + let bytes_total = std::fs::metadata(&local).ok().map(|m| m.len() as i64); + let job = crate::storage::SyncJobRow { + id: uuid::Uuid::new_v4().to_string(), + target_id: target.id.clone(), + meeting_id: meeting_id.clone(), + artifact: artifact.to_string(), + local_path: local.to_string_lossy().into_owned(), + remote_path: format!("{base}/{meeting_id}/{filename}"), + sha256: file_sha256(&local), + status: "pending".to_string(), + attempts: 0, + last_error: None, + next_attempt_at: None, + bytes_total, + bytes_sent: 0, + updated_at: now_unix(), + }; + if store + .upsert_sync_job(job) + .await + .map_err(|e| WaError::new("sync", e.to_string()))? + { + queued += 1; + } + } + } + Ok(queued) +} + +/// Upload one job's file: ensure the remote dir, PUT, report bytes sent. +async fn upload_job( + target: &dyn crate::sync::SyncTarget, + row: &crate::storage::SyncJobRow, + encrypt: bool, +) -> Result { + use crate::sync::SyncError; + let remote_dir = row + .remote_path + .rsplit_once('/') + .map(|(dir, _)| dir) + .unwrap_or(""); + target.ensure_dir(remote_dir).await?; + let (tx, rx) = std::sync::mpsc::channel(); + if encrypt { + // Client-side encryption before upload (T9.12, FR-SYNC-10): seal to a + // temp file (idempotent if already sealed at rest) so the destination + // only ever holds ciphertext. Requires the vault to be unlocked. + let bytes = tokio::fs::read(&row.local_path) + .await + .map_err(|e| SyncError::Upload(e.to_string()))?; + let sealed = + crate::vault::ensure_sealed(&bytes).map_err(|e| SyncError::Upload(e.to_string()))?; + let tmp = std::env::temp_dir().join(format!("wa-enc-{}.bin", row.id)); + tokio::fs::write(&tmp, &sealed) + .await + .map_err(|e| SyncError::Upload(e.to_string()))?; + let result = target.put(&tmp, &row.remote_path, tx).await; + let _ = tokio::fs::remove_file(&tmp).await; + result?; + } else { + target + .put(Path::new(&row.local_path), &row.remote_path, tx) + .await?; + } + let sent = rx + .try_iter() + .last() + .map(|(s, _)| s) + .unwrap_or(row.bytes_total.unwrap_or(0) as u64); + Ok(sent) +} + +/// Drive all due jobs once: upload each, emit `sync://job` on every transition, +/// apply exponential backoff on failure (T9.5). Called on finalize, on startup, +/// and on manual upload/retry — never on an idle timer (NFR-RES-1). +pub(crate) async fn pump_sync(app: &AppHandle, store: &dyn crate::storage::Store) { + let due = match store.claim_due_sync_jobs(now_unix()).await { + Ok(jobs) => jobs, + Err(e) => { + tracing::warn!("sync pump: claim failed: {e}"); + return; + } + }; + for mut job in due { + let target = match store.get_sync_target(&job.target_id).await { + Ok(t) => t, + Err(_) => continue, // target removed under us — its jobs cascaded away + }; + + job.status = "uploading".to_string(); + job.updated_at = now_unix(); + let _ = store.update_sync_job(job.clone()).await; + emit_sync_job(app, &job); + + let outcome = match crate::sync::build_sync_target(&target) { + Ok(t) => upload_job(t.as_ref(), &job, target.encrypt_before_upload).await, + Err(e) => Err(e), + }; + match outcome { + Ok(sent) => { + job.status = "done".to_string(); + job.bytes_sent = sent as i64; + job.last_error = None; + job.next_attempt_at = None; + } + Err(e) => { + job.attempts += 1; + job.last_error = Some(e.to_string()); + job.status = "failed".to_string(); + job.next_attempt_at = (job.attempts < SYNC_MAX_ATTEMPTS) + .then(|| now_unix() + sync_backoff_secs(job.attempts)); + } + } + job.updated_at = now_unix(); + let _ = store.update_sync_job(job.clone()).await; + emit_sync_job(app, &job); + } +} + +/// Manual "Upload now": (re)enqueue the meeting's artifacts and pump immediately. +/// Streams progress via `sync://job` events. +#[tauri::command] +pub async fn sync_meeting( + app: AppHandle, + state: State<'_, AppState>, + meeting_id: MeetingId, + target_id: Option, +) -> WaResult<()> { + enqueue_meeting_sync( + state.store.as_ref(), + &meeting_id, + target_id.as_deref(), + false, + ) + .await?; + pump_sync(&app, state.store.as_ref()).await; + Ok(()) +} + +#[tauri::command] +pub async fn sync_status( + state: State<'_, AppState>, + meeting_id: Option, +) -> WaResult> { + let rows = state + .store + .list_sync_jobs(meeting_id.as_ref()) + .await + .map_err(|e| WaError::new("sync", e.to_string()))?; + Ok(rows.iter().map(job_to_info).collect()) } // ---- Feature briefs + MCP server (Phase 10b, ADR-0011) ---- @@ -1954,13 +2608,18 @@ fn privacy_self_check_json( } /// Reports current egress + LLM endpoint so the UI can prove local-only handling (FR-SEC-2). -/// Sync targets/hosts are empty until Phase 9 lands `SyncManager`; `list_sync_targets` is still -/// `not_implemented` so its error is swallowed here rather than failing the whole self-check. +/// Enabled sync targets' hosts are folded into the allowlist (T9.8); a DB error is +/// swallowed to an empty list rather than failing the whole self-check. #[tauri::command] -pub async fn privacy_self_check() -> WaResult { +pub async fn privacy_self_check(state: State<'_, AppState>) -> WaResult { let settings = load_settings(); let sync_targets: Vec = if settings.sync_enabled { - list_sync_targets().await.unwrap_or_default() + state + .store + .list_sync_targets() + .await + .map(|rows| rows.iter().map(crate::sync::row_to_info).collect()) + .unwrap_or_default() } else { Vec::new() }; @@ -1983,6 +2642,20 @@ mod tests { } } + #[test] + fn sync_backoff_grows_then_caps_at_one_hour() { + assert_eq!(sync_backoff_secs(0), 30); + assert_eq!(sync_backoff_secs(1), 60); + assert_eq!(sync_backoff_secs(2), 120); + // Monotonic non-decreasing and never above the 1h cap. + let mut prev = 0; + for a in 0..12 { + let b = sync_backoff_secs(a); + assert!(b >= prev && b <= 3600); + prev = b; + } + } + #[test] fn speaker_infos_lists_distinct_speakers_in_first_appearance_order() { let segments = vec![segment("S2"), segment("S1"), segment("S2")]; diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 1e0b420..6d39655 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -20,6 +20,7 @@ pub mod reminders; pub mod storage; pub mod sync; pub mod transcription; +pub mod vault; use std::path::PathBuf; use std::sync::{Arc, Mutex as StdMutex}; @@ -123,6 +124,7 @@ pub fn run() { // FR-STORE-2, FR-CAL-5). Spawned so it never blocks the window from // showing (NFR-PERF-4); nothing here repeats on a timer (NFR-RES-1). let store = store_for_setup; + let startup_app = app.handle().clone(); tauri::async_runtime::spawn(async move { match store.recover_scan().await { Ok(recovered) if !recovered.is_empty() => { @@ -153,6 +155,12 @@ pub fn run() { } Err(e) => tracing::error!("startup reminder reconcile failed: {e}"), } + + // Resume any sync jobs left pending/failed from a previous run + // (T9.5). One-shot on startup — no idle timer (NFR-RES-1). + if commands::load_settings().sync_enabled { + commands::pump_sync(&startup_app, store.as_ref()).await; + } }); Ok(()) }) @@ -218,6 +226,11 @@ pub fn run() { commands::get_settings, commands::update_settings, commands::privacy_self_check, + commands::vault_status, + commands::enable_vault, + commands::unlock_vault, + commands::lock_vault, + commands::change_vault_password, ]) .run(tauri::generate_context!()) .expect("error while running WhispAssist"); diff --git a/src-tauri/src/storage/mod.rs b/src-tauri/src/storage/mod.rs index 6923169..e4307d3 100644 --- a/src-tauri/src/storage/mod.rs +++ b/src-tauri/src/storage/mod.rs @@ -128,6 +128,49 @@ pub struct Retention { pub max_size_gb: Option, } +/// A configured upload destination as stored in the DB (ADR-0010). Holds only a +/// `credential_ref` into the OS credential store — never the secret itself +/// (FR-SYNC-6). Maps 1:1 to the `sync_targets` table. +#[derive(Debug, Clone, sqlx::FromRow)] +pub struct SyncTargetRow { + pub id: String, + pub name: String, + pub kind: String, // webdav|onedrive|dropbox|box + pub provider_hint: Option, + pub base_url: Option, + pub remote_base_path: String, + pub username: Option, + pub credential_ref: String, + pub enabled: bool, + pub upload_transcript: bool, + pub upload_notes: bool, + pub upload_summary: bool, + pub upload_recording: bool, + pub trigger_on_finalize: bool, + pub allow_plaintext_lan: bool, + pub encrypt_before_upload: bool, + pub created_at: i64, +} + +/// One durable upload job (per artifact × per target). Maps to `sync_jobs`. +#[derive(Debug, Clone, sqlx::FromRow)] +pub struct SyncJobRow { + pub id: String, + pub target_id: String, + pub meeting_id: MeetingId, + pub artifact: String, // transcript|notes|summary|recording + pub local_path: String, + pub remote_path: String, + pub sha256: Option, + pub status: String, // pending|uploading|done|failed|skipped + pub attempts: i64, + pub last_error: Option, + pub next_attempt_at: Option, + pub bytes_total: Option, + pub bytes_sent: i64, + pub updated_at: i64, +} + #[async_trait] pub trait Store: Send + Sync { async fn create_meeting(&self, m: NewMeeting) -> Result; @@ -226,6 +269,28 @@ pub trait Store: Send + Sync { /// Enforce retention; returns count removed. Skips in-progress meetings /// (anything whose `status` isn't a terminal `ready`/`error`). async fn enforce_retention(&self, policy: Retention) -> Result; + + // ---- Sync targets (Phase 9, ADR-0010) ---- + async fn add_sync_target(&self, row: SyncTargetRow) -> Result<(), StoreError>; + /// Full-row replace (caller loads, edits, and preserves `credential_ref`). + async fn update_sync_target(&self, row: SyncTargetRow) -> Result<(), StoreError>; + async fn remove_sync_target(&self, id: &str) -> Result<(), StoreError>; + async fn list_sync_targets(&self) -> Result, StoreError>; + async fn get_sync_target(&self, id: &str) -> Result; + + // ---- Sync jobs / durable queue (Phase 9, T9.5) ---- + /// Enqueue (or re-enqueue) a job. Idempotent per (target, meeting, artifact): + /// a job already `done` with the same `sha256` is left untouched (skip-if- + /// unchanged); anything else is reset to `pending`. Returns true if enqueued. + async fn upsert_sync_job(&self, job: SyncJobRow) -> Result; + /// Jobs ready to run now: `pending`/`failed` with no future `next_attempt_at`. + async fn claim_due_sync_jobs(&self, now: i64) -> Result, StoreError>; + async fn update_sync_job(&self, job: SyncJobRow) -> Result<(), StoreError>; + async fn get_sync_job(&self, id: &str) -> Result; + async fn list_sync_jobs( + &self, + meeting_id: Option<&MeetingId>, + ) -> Result, StoreError>; } /// SQLite-backed store. Migrations live in `migrations/` (`sqlx::migrate!`). @@ -254,6 +319,26 @@ impl SqliteStore { Ok(store) } + /// In-memory store for tests: one connection, migrations applied, foreign + /// keys off so sync-queue tests can insert jobs without full meeting rows. + #[cfg(test)] + pub async fn connect_in_memory() -> Result { + let opts = SqliteConnectOptions::new().filename(":memory:"); + let pool = SqlitePoolOptions::new() + .max_connections(1) + .connect_with(opts) + .await?; + sqlx::migrate!("./migrations") + .run(&pool) + .await + .map_err(|e| StoreError::Db(e.to_string()))?; + sqlx::query("PRAGMA foreign_keys = OFF") + .execute(&pool) + .await + .ok(); + Ok(Self { pool }) + } + /// One-time-per-meeting catch-up for meetings finalized before full-text /// search existed (T8.2, FR-SEARCH-1) — `finalize_meeting`/`update_notes` /// keep `meeting_fts` current for everything from here on, but that adds @@ -392,21 +477,19 @@ impl SqliteStore { .fetch_one(&self.pool) .await?; - let transcript_text = - std::fs::read_to_string(paths::meeting_dir(id).join("transcript.json")) - .ok() - .and_then(|s| serde_json::from_str::(&s).ok()) - .map(|t| { - t.segments - .iter() - .map(|s| s.text.as_str()) - .collect::>() - .join(" ") - }) - .unwrap_or_default(); + let transcript_text = read_artifact(&paths::meeting_dir(id).join("transcript.json")) + .and_then(|s| serde_json::from_str::(&s).ok()) + .map(|t| { + t.segments + .iter() + .map(|s| s.text.as_str()) + .collect::>() + .join(" ") + }) + .unwrap_or_default(); let notes_text = - std::fs::read_to_string(paths::meeting_dir(id).join("notes.md")).unwrap_or_default(); + read_artifact(&paths::meeting_dir(id).join("notes.md")).unwrap_or_default(); sqlx::query("DELETE FROM meeting_fts WHERE meeting_id = ?") .bind(id) @@ -579,7 +662,10 @@ impl Store for SqliteStore { }; let json = serde_json::to_string_pretty(&transcript).map_err(|e| StoreError::Db(e.to_string()))?; - std::fs::write(paths::meeting_dir(id).join("transcript.json"), json)?; + write_artifact( + &paths::meeting_dir(id).join("transcript.json"), + json.as_bytes(), + )?; self.reindex_fts(id).await?; let template_id: Option = sqlx::query_scalar("SELECT template_id FROM meetings WHERE id = ?") @@ -693,8 +779,7 @@ impl Store for SqliteStore { .collect(); let folder = paths::meeting_dir(id); - let mut segments = std::fs::read_to_string(folder.join("transcript.json")) - .ok() + let mut segments = read_artifact(&folder.join("transcript.json")) .and_then(|s| serde_json::from_str::(&s).ok()) .map(|t| t.segments) .unwrap_or_default(); @@ -706,9 +791,8 @@ impl Store for SqliteStore { seg.speaker = resolve_canonical(&seg.speaker, &merge_map).to_string(); } } - let notes_markdown = std::fs::read_to_string(folder.join("notes.md")).unwrap_or_default(); - let summary = std::fs::read_to_string(folder.join("summary.json")) - .ok() + let notes_markdown = read_artifact(&folder.join("notes.md")).unwrap_or_default(); + let summary = read_artifact(&folder.join("summary.json")) .and_then(|s| serde_json::from_str::(&s).ok()); let tags = self.tags_for_meeting(id).await?; @@ -752,7 +836,10 @@ impl Store for SqliteStore { } async fn update_notes(&self, id: &MeetingId, markdown: &str) -> Result<(), StoreError> { - std::fs::write(paths::meeting_dir(id).join("notes.md"), markdown)?; + write_artifact( + &paths::meeting_dir(id).join("notes.md"), + markdown.as_bytes(), + )?; sqlx::query("UPDATE meetings SET updated_at = ? WHERE id = ?") .bind(now_unix()) .bind(id) @@ -1189,6 +1276,198 @@ impl Store for SqliteStore { Ok(removed) } + + async fn add_sync_target(&self, row: SyncTargetRow) -> Result<(), StoreError> { + sqlx::query( + "INSERT INTO sync_targets (id, name, kind, provider_hint, base_url, remote_base_path, \ + username, credential_ref, enabled, upload_transcript, upload_notes, upload_summary, \ + upload_recording, trigger_on_finalize, allow_plaintext_lan, encrypt_before_upload, created_at) \ + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)", + ) + .bind(&row.id) + .bind(&row.name) + .bind(&row.kind) + .bind(&row.provider_hint) + .bind(&row.base_url) + .bind(&row.remote_base_path) + .bind(&row.username) + .bind(&row.credential_ref) + .bind(row.enabled) + .bind(row.upload_transcript) + .bind(row.upload_notes) + .bind(row.upload_summary) + .bind(row.upload_recording) + .bind(row.trigger_on_finalize) + .bind(row.allow_plaintext_lan) + .bind(row.encrypt_before_upload) + .bind(row.created_at) + .execute(&self.pool) + .await?; + Ok(()) + } + + async fn update_sync_target(&self, row: SyncTargetRow) -> Result<(), StoreError> { + sqlx::query( + "UPDATE sync_targets SET name=?, kind=?, provider_hint=?, base_url=?, remote_base_path=?, \ + username=?, credential_ref=?, enabled=?, upload_transcript=?, upload_notes=?, \ + upload_summary=?, upload_recording=?, trigger_on_finalize=?, allow_plaintext_lan=?, \ + encrypt_before_upload=? WHERE id=?", + ) + .bind(&row.name) + .bind(&row.kind) + .bind(&row.provider_hint) + .bind(&row.base_url) + .bind(&row.remote_base_path) + .bind(&row.username) + .bind(&row.credential_ref) + .bind(row.enabled) + .bind(row.upload_transcript) + .bind(row.upload_notes) + .bind(row.upload_summary) + .bind(row.upload_recording) + .bind(row.trigger_on_finalize) + .bind(row.allow_plaintext_lan) + .bind(row.encrypt_before_upload) + .bind(&row.id) + .execute(&self.pool) + .await?; + Ok(()) + } + + async fn remove_sync_target(&self, id: &str) -> Result<(), StoreError> { + // ON DELETE CASCADE clears this target's sync_jobs too. + sqlx::query("DELETE FROM sync_targets WHERE id = ?") + .bind(id) + .execute(&self.pool) + .await?; + Ok(()) + } + + async fn list_sync_targets(&self) -> Result, StoreError> { + let rows = sqlx::query_as::<_, SyncTargetRow>( + "SELECT * FROM sync_targets ORDER BY created_at ASC", + ) + .fetch_all(&self.pool) + .await?; + Ok(rows) + } + + async fn get_sync_target(&self, id: &str) -> Result { + let row = sqlx::query_as::<_, SyncTargetRow>("SELECT * FROM sync_targets WHERE id = ?") + .bind(id) + .fetch_optional(&self.pool) + .await? + .ok_or_else(|| StoreError::NotFound(format!("sync target {id}")))?; + Ok(row) + } + + async fn upsert_sync_job(&self, job: SyncJobRow) -> Result { + // ON CONFLICT: re-enqueue as pending, EXCEPT when the row is already + // `done` with the same content hash — that's the skip-if-unchanged case + // (`IS NOT` is SQLite's null-safe comparison). + let res = sqlx::query( + "INSERT INTO sync_jobs (id, target_id, meeting_id, artifact, local_path, remote_path, \ + sha256, status, attempts, last_error, next_attempt_at, bytes_total, bytes_sent, updated_at) \ + VALUES (?,?,?,?,?,?,?, 'pending', 0, NULL, NULL, ?, 0, ?) \ + ON CONFLICT(target_id, meeting_id, artifact) DO UPDATE SET \ + local_path=excluded.local_path, remote_path=excluded.remote_path, sha256=excluded.sha256, \ + status='pending', attempts=0, last_error=NULL, next_attempt_at=NULL, \ + bytes_total=excluded.bytes_total, bytes_sent=0, updated_at=excluded.updated_at \ + WHERE sync_jobs.status != 'done' OR sync_jobs.sha256 IS NOT excluded.sha256", + ) + .bind(&job.id) + .bind(&job.target_id) + .bind(&job.meeting_id) + .bind(&job.artifact) + .bind(&job.local_path) + .bind(&job.remote_path) + .bind(&job.sha256) + .bind(job.bytes_total) + .bind(job.updated_at) + .execute(&self.pool) + .await?; + Ok(res.rows_affected() > 0) + } + + async fn claim_due_sync_jobs(&self, now: i64) -> Result, StoreError> { + let rows = sqlx::query_as::<_, SyncJobRow>( + "SELECT * FROM sync_jobs WHERE status IN ('pending','failed') \ + AND (next_attempt_at IS NULL OR next_attempt_at <= ?) ORDER BY updated_at ASC", + ) + .bind(now) + .fetch_all(&self.pool) + .await?; + Ok(rows) + } + + async fn update_sync_job(&self, job: SyncJobRow) -> Result<(), StoreError> { + sqlx::query( + "UPDATE sync_jobs SET status=?, attempts=?, last_error=?, next_attempt_at=?, \ + bytes_total=?, bytes_sent=?, sha256=?, local_path=?, remote_path=?, updated_at=? WHERE id=?", + ) + .bind(&job.status) + .bind(job.attempts) + .bind(&job.last_error) + .bind(job.next_attempt_at) + .bind(job.bytes_total) + .bind(job.bytes_sent) + .bind(&job.sha256) + .bind(&job.local_path) + .bind(&job.remote_path) + .bind(job.updated_at) + .bind(&job.id) + .execute(&self.pool) + .await?; + Ok(()) + } + + async fn get_sync_job(&self, id: &str) -> Result { + sqlx::query_as::<_, SyncJobRow>("SELECT * FROM sync_jobs WHERE id = ?") + .bind(id) + .fetch_optional(&self.pool) + .await? + .ok_or_else(|| StoreError::NotFound(format!("sync job {id}"))) + } + + async fn list_sync_jobs( + &self, + meeting_id: Option<&MeetingId>, + ) -> Result, StoreError> { + let rows = match meeting_id { + Some(m) => { + sqlx::query_as::<_, SyncJobRow>( + "SELECT * FROM sync_jobs WHERE meeting_id = ? ORDER BY updated_at DESC", + ) + .bind(m) + .fetch_all(&self.pool) + .await? + } + None => { + sqlx::query_as::<_, SyncJobRow>("SELECT * FROM sync_jobs ORDER BY updated_at DESC") + .fetch_all(&self.pool) + .await? + } + }; + Ok(rows) + } +} + +/// Read a derived artifact, transparently decrypting it if the vault sealed it +/// (T8.8, FR-SEC-3). Returns `None` if the file is missing or can't be +/// decrypted (vault locked / wrong key) — callers already treat a missing +/// artifact as empty, so a locked vault degrades to "no content" rather than a +/// crash. +fn read_artifact(path: &std::path::Path) -> Option { + let bytes = std::fs::read(path).ok()?; + let plain = crate::vault::open(&bytes).ok()?; + Some(String::from_utf8_lossy(&plain).into_owned()) +} + +/// Write a derived artifact, sealing it when the vault is unlocked (a no-op +/// passthrough otherwise). Audio is not sealed here — it's handled separately. +fn write_artifact(path: &std::path::Path, contents: &[u8]) -> std::io::Result<()> { + let sealed = crate::vault::seal(contents).map_err(|e| std::io::Error::other(e.to_string()))?; + std::fs::write(path, sealed) } fn dir_size(path: &str) -> u64 { @@ -1205,6 +1484,101 @@ fn dir_size(path: &str) -> u64 { mod tests { use super::*; + fn sample_target(id: &str) -> SyncTargetRow { + SyncTargetRow { + id: id.to_string(), + name: "t".to_string(), + kind: "webdav".to_string(), + provider_hint: None, + base_url: Some("https://host/dav".to_string()), + remote_base_path: "/WhispAssist".to_string(), + username: Some("u".to_string()), + credential_ref: format!("wa-sync-{id}"), + enabled: true, + upload_transcript: true, + upload_notes: true, + upload_summary: true, + upload_recording: false, + trigger_on_finalize: true, + allow_plaintext_lan: false, + encrypt_before_upload: false, + created_at: 0, + } + } + + fn sample_job(id: &str, sha: &str) -> SyncJobRow { + SyncJobRow { + id: id.to_string(), + target_id: "t1".to_string(), + meeting_id: "m1".to_string(), + artifact: "transcript".to_string(), + local_path: "/x/transcript.json".to_string(), + remote_path: "/WhispAssist/m1/transcript.json".to_string(), + sha256: Some(sha.to_string()), + status: "pending".to_string(), + attempts: 0, + last_error: None, + next_attempt_at: None, + bytes_total: Some(10), + bytes_sent: 0, + updated_at: 0, + } + } + + #[tokio::test] + async fn sync_target_crud_round_trips() { + let store = SqliteStore::connect_in_memory().await.unwrap(); + store.add_sync_target(sample_target("t1")).await.unwrap(); + let listed = store.list_sync_targets().await.unwrap(); + assert_eq!(listed.len(), 1); + assert_eq!(listed[0].id, "t1"); + store.remove_sync_target("t1").await.unwrap(); + assert!(store.list_sync_targets().await.unwrap().is_empty()); + } + + #[tokio::test] + async fn upsert_skips_unchanged_done_job_and_reenqueues_changed() { + let store = SqliteStore::connect_in_memory().await.unwrap(); + store.add_sync_target(sample_target("t1")).await.unwrap(); + + // New job → enqueued. + assert!(store.upsert_sync_job(sample_job("j1", "A")).await.unwrap()); + + // Mark it done, then re-upsert the same content (same sha) → skipped. + let mut done = store.list_sync_jobs(None).await.unwrap().remove(0); + done.status = "done".to_string(); + store.update_sync_job(done).await.unwrap(); + assert!( + !store.upsert_sync_job(sample_job("j2", "A")).await.unwrap(), + "an unchanged, already-done artifact must not re-enqueue" + ); + + // Changed content (new sha) → re-enqueued as pending, still one row. + assert!(store.upsert_sync_job(sample_job("j3", "B")).await.unwrap()); + let jobs = store.list_sync_jobs(None).await.unwrap(); + assert_eq!( + jobs.len(), + 1, + "UNIQUE(target,meeting,artifact) keeps one row" + ); + assert_eq!(jobs[0].status, "pending"); + assert_eq!(jobs[0].sha256.as_deref(), Some("B")); + } + + #[tokio::test] + async fn claim_due_skips_jobs_with_a_future_backoff() { + let store = SqliteStore::connect_in_memory().await.unwrap(); + store.add_sync_target(sample_target("t1")).await.unwrap(); + store.upsert_sync_job(sample_job("j1", "A")).await.unwrap(); + // Push it into the future with a failed+backoff state. + let mut job = store.list_sync_jobs(None).await.unwrap().remove(0); + job.status = "failed".to_string(); + job.next_attempt_at = Some(10_000); + store.update_sync_job(job).await.unwrap(); + assert!(store.claim_due_sync_jobs(5_000).await.unwrap().is_empty()); + assert_eq!(store.claim_due_sync_jobs(20_000).await.unwrap().len(), 1); + } + #[test] fn fts_query_from_input_quotes_each_token_as_a_literal_phrase() { assert_eq!( diff --git a/src-tauri/src/sync/mod.rs b/src-tauri/src/sync/mod.rs index ec48e9a..453be6d 100644 --- a/src-tauri/src/sync/mod.rs +++ b/src-tauri/src/sync/mod.rs @@ -9,10 +9,15 @@ //! - TLS required; plaintext http only via explicit per-target LAN opt-in. //! - `SyncManager` derives the egress allowlist from enabled targets. +#[cfg(feature = "sync")] +use crate::models::SyncTargetInfo; use crate::models::{MeetingId, SyncJobInfo, SyncKind}; use async_trait::async_trait; use std::path::Path; +#[cfg(feature = "sync")] +pub mod oauth; + #[derive(Debug, thiserror::Error)] pub enum SyncError { #[error("target unreachable: {0}")] @@ -78,6 +83,46 @@ pub struct WebDavTarget { pub allow_plaintext_lan: bool, } +#[cfg(feature = "sync")] +impl WebDavTarget { + /// Build from a stored target row (secret stays in the OS store, resolved at + /// use via `credential_ref`). + pub fn from_row(row: &crate::storage::SyncTargetRow) -> Self { + Self { + base_url: row.base_url.clone().unwrap_or_default(), + remote_base_path: row.remote_base_path.clone(), + username: row.username.clone().unwrap_or_default(), + credential_ref: row.credential_ref.clone(), + third_party: row.kind != "webdav", + allow_plaintext_lan: row.allow_plaintext_lan, + } + } + + /// Full URL for a path under this target, resolving `//` at the join. + fn url_for(&self, remote_path: &str) -> String { + let base = self.base_url.trim_end_matches('/'); + let path = remote_path.trim_start_matches('/'); + format!("{base}/{path}") + } + + /// Resolves the secret and returns an authenticated request builder, after + /// enforcing TLS (or an explicit LAN plaintext opt-in). + fn request( + &self, + method: reqwest::Method, + url: &str, + ) -> Result { + enforce_transport(&self.base_url, self.allow_plaintext_lan)?; + let mut req = http_client()?.request(method, url); + // Send Basic auth only when a secret is stored — an anonymous target + // (no credential) is valid and must not hard-fail on a missing entry. + if let Ok(secret) = credentials::get(&self.credential_ref) { + req = req.basic_auth(&self.username, Some(secret)); + } + Ok(req) + } +} + #[cfg(feature = "sync")] #[async_trait] impl SyncTarget for WebDavTarget { @@ -87,32 +132,487 @@ impl SyncTarget for WebDavTarget { fn is_third_party(&self) -> bool { self.third_party } + + /// Reachability + auth check: PROPFIND depth 0 on the base path (T9.4). async fn test(&self) -> Result<(), SyncError> { - // T9.4: PROPFIND base path; verify TLS (or explicit LAN opt-in); resolve creds from keyring. - todo!("Phase 9 — WebDAV test connection") + let method = reqwest::Method::from_bytes(b"PROPFIND") + .expect("PROPFIND is a valid HTTP method token"); + let url = self.url_for(&self.remote_base_path); + let resp = self + .request(method, &url)? + .header("Depth", "0") + .send() + .await + .map_err(|e| SyncError::Unreachable(e.to_string()))?; + match resp.status().as_u16() { + 401 | 403 => Err(SyncError::Auth), + // 207 Multi-Status is the success case; 404 means the base dir just + // doesn't exist yet (auth still worked) — creation is ensure_dir's job. + 207 | 200 | 301 | 404 | 405 => Ok(()), + other => Err(SyncError::Unreachable(format!("PROPFIND {other}"))), + } } - async fn ensure_dir(&self, _remote_dir: &str) -> Result<(), SyncError> { - // T9.2: MKCOL the meeting folder path. - todo!("Phase 9 — WebDAV ensure_dir (MKCOL)") + + /// Create each missing path segment with MKCOL (idempotent — an existing + /// collection returns 405, which we treat as success). + async fn ensure_dir(&self, remote_dir: &str) -> Result<(), SyncError> { + let method = + reqwest::Method::from_bytes(b"MKCOL").expect("MKCOL is a valid HTTP method token"); + let mut cumulative = String::new(); + for segment in remote_dir.split('/').filter(|s| !s.is_empty()) { + cumulative.push('/'); + cumulative.push_str(segment); + let url = self.url_for(&cumulative); + let resp = self + .request(method.clone(), &url)? + .send() + .await + .map_err(|e| SyncError::Upload(e.to_string()))?; + match resp.status().as_u16() { + // 201 created, 405 already exists, 301 redirect-to-existing. + 201 | 405 | 301 => {} + 401 | 403 => return Err(SyncError::Auth), + other => return Err(SyncError::Upload(format!("MKCOL {other}"))), + } + } + Ok(()) } - async fn exists(&self, _remote_path: &str, _sha256: &str) -> Result { - todo!("Phase 9 — WebDAV exists / hash compare") + + /// Whether the remote file is already present. WebDAV can't return a SHA-256, + /// so this is a presence check (HEAD); content-level skip is decided by the + /// manager from the recorded job hash. + async fn exists(&self, remote_path: &str, _sha256: &str) -> Result { + let url = self.url_for(remote_path); + let resp = self + .request(reqwest::Method::HEAD, &url)? + .send() + .await + .map_err(|e| SyncError::Unreachable(e.to_string()))?; + Ok(resp.status().is_success()) } + + /// Upload a file via PUT. + /// + /// ponytail: single PUT reading the file into memory — fine for + /// transcript/notes/summary. Chunked/resumable upload for large `.wav` + /// recordings is a later refinement (Nextcloud chunked upload). async fn put( &self, - _local: &Path, - _remote_path: &str, - _prog: ProgressSink, + local: &Path, + remote_path: &str, + prog: ProgressSink, ) -> Result<(), SyncError> { - // T9.2/T9.5: PUT (chunked for large files); stream progress. - todo!("Phase 9 — WebDAV upload") + let bytes = tokio::fs::read(local) + .await + .map_err(|e| SyncError::Upload(e.to_string()))?; + let total = bytes.len() as u64; + let url = self.url_for(remote_path); + let resp = self + .request(reqwest::Method::PUT, &url)? + .body(bytes) + .send() + .await + .map_err(|e| SyncError::Upload(e.to_string()))?; + match resp.status().as_u16() { + code if (200..300).contains(&code) => { + let _ = prog.send((total, total)); + Ok(()) + } + 401 | 403 => Err(SyncError::Auth), + other => Err(SyncError::Upload(format!("PUT {other}"))), + } } } -// Secondary OAuth providers (Phase 9b) — same trait, separate impls. +/// Shared HTTP client (rustls TLS via reqwest's `rustls-tls` feature). #[cfg(feature = "sync")] -pub struct OneDriveTarget; // MS Graph +fn http_client() -> Result { + reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(60)) + .build() + .map_err(|e| SyncError::Upload(e.to_string())) +} + +/// TLS enforcement (T9.7, FR-SYNC-7): https always allowed; plaintext http only +/// for a LAN host when the target explicitly opted in; anything else refused. #[cfg(feature = "sync")] -pub struct DropboxTarget; +fn enforce_transport(base_url: &str, allow_plaintext_lan: bool) -> Result<(), SyncError> { + let url = + reqwest::Url::parse(base_url).map_err(|e| SyncError::Upload(format!("bad url: {e}")))?; + match url.scheme() { + "https" => Ok(()), + "http" if allow_plaintext_lan && url.host_str().is_some_and(is_lan_host) => Ok(()), + "http" => Err(SyncError::InsecureTransport), + other => Err(SyncError::Upload(format!("unsupported scheme {other}"))), + } +} + +/// Heuristic "is this a private/LAN address" for the plaintext opt-in: loopback, +/// RFC-1918 ranges, `.local`, and bare (dotless) hostnames. #[cfg(feature = "sync")] -pub struct BoxTarget; +fn is_lan_host(host: &str) -> bool { + if host == "localhost" || host.ends_with(".local") || !host.contains('.') { + return true; + } + if let Ok(ip) = host.parse::() { + return ip.is_loopback() || ip.is_private() || ip.is_link_local(); + } + host.parse::() + .map(|ip| ip.is_loopback()) + .unwrap_or(false) +} + +/// UI-safe view of a stored target — never carries the secret (FR-SYNC-6). +#[cfg(feature = "sync")] +pub fn row_to_info(row: &crate::storage::SyncTargetRow) -> SyncTargetInfo { + let host = row + .base_url + .as_deref() + .and_then(|u| reqwest::Url::parse(u).ok()) + .and_then(|u| u.host_str().map(str::to_string)); + SyncTargetInfo { + id: row.id.clone(), + name: row.name.clone(), + kind: kind_from_str(&row.kind), + provider_hint: row.provider_hint.clone(), + base_url: row.base_url.clone(), + remote_base_path: row.remote_base_path.clone(), + username: row.username.clone(), + enabled: row.enabled, + third_party: row.kind != "webdav", + host, + } +} + +#[cfg(feature = "sync")] +fn kind_from_str(s: &str) -> SyncKind { + match s { + "onedrive" => SyncKind::OneDrive, + "dropbox" => SyncKind::Dropbox, + "box" => SyncKind::Box, + _ => SyncKind::WebDav, + } +} + +/// Secrets live in the OS credential store (Windows Credential Manager via DPAPI), +/// keyed by the target's `credential_ref` — never in settings.json/DB/logs (FR-SYNC-6). +#[cfg(feature = "sync")] +pub mod credentials { + use super::SyncError; + + const SERVICE: &str = "WhispAssist-sync"; + + fn entry(credential_ref: &str) -> Result { + keyring::Entry::new(SERVICE, credential_ref) + .map_err(|e| SyncError::Credential(e.to_string())) + } + + pub fn set(credential_ref: &str, secret: &str) -> Result<(), SyncError> { + entry(credential_ref)? + .set_password(secret) + .map_err(|e| SyncError::Credential(e.to_string())) + } + + pub fn get(credential_ref: &str) -> Result { + entry(credential_ref)? + .get_password() + .map_err(|e| SyncError::Credential(e.to_string())) + } + + pub fn delete(credential_ref: &str) -> Result<(), SyncError> { + // Missing entry is fine — deletion is best-effort cleanup. + match entry(credential_ref)?.delete_credential() { + Ok(()) | Err(keyring::Error::NoEntry) => Ok(()), + Err(e) => Err(SyncError::Credential(e.to_string())), + } + } +} + +#[cfg(all(test, feature = "sync"))] +mod tests { + use super::*; + + #[test] + fn https_is_always_allowed() { + assert!(enforce_transport("https://cloud.example.com/dav", false).is_ok()); + } + + #[test] + fn plaintext_http_is_refused_by_default() { + assert!(matches!( + enforce_transport("http://cloud.example.com/dav", false), + Err(SyncError::InsecureTransport) + )); + } + + #[test] + fn plaintext_http_allowed_only_for_lan_with_optin() { + // LAN host + opt-in: allowed. + assert!(enforce_transport("http://192.168.1.10/dav", true).is_ok()); + assert!(enforce_transport("http://nas.local/dav", true).is_ok()); + assert!(enforce_transport("http://localhost:8080/dav", true).is_ok()); + // Public host even with opt-in: still refused (opt-in is LAN-only). + assert!(matches!( + enforce_transport("http://cloud.example.com/dav", true), + Err(SyncError::InsecureTransport) + )); + } + + #[test] + fn lan_host_classification() { + assert!(is_lan_host("localhost")); + assert!(is_lan_host("nas.local")); + assert!(is_lan_host("mynas")); // dotless bare hostname + assert!(is_lan_host("10.0.0.5")); + assert!(is_lan_host("172.16.4.4")); + assert!(!is_lan_host("8.8.8.8")); + assert!(!is_lan_host("cloud.example.com")); + } + + #[test] + fn url_join_normalizes_slashes() { + let t = WebDavTarget { + base_url: "https://host/dav/".to_string(), + remote_base_path: "/WhispAssist".to_string(), + username: "u".to_string(), + credential_ref: "r".to_string(), + third_party: false, + allow_plaintext_lan: false, + }; + assert_eq!( + t.url_for("/WhispAssist/m1/notes.md"), + "https://host/dav/WhispAssist/m1/notes.md" + ); + } + + /// Full WebDAV round-trip against a real server, opt-in (needs one running): + /// WA_WEBDAV_URL, WA_WEBDAV_USER, WA_WEBDAV_PASS + /// e.g. `wsgidav --host=127.0.0.1 --port=8899 --root= --auth=anonymous` + /// (or with a user/pass). Run manually — see the increment-1 test notes. + #[tokio::test] + #[ignore = "requires a live WebDAV server; run manually"] + async fn webdav_round_trip() { + let Ok(base_url) = std::env::var("WA_WEBDAV_URL") else { + eprintln!("skip: set WA_WEBDAV_URL"); + return; + }; + let user = std::env::var("WA_WEBDAV_USER").unwrap_or_default(); + let pass = std::env::var("WA_WEBDAV_PASS").unwrap_or_default(); + let cred_ref = "wa-sync-test-roundtrip"; + // Anonymous servers have no password; only store one when provided + // (Windows credential store won't persist an empty secret anyway). + if !pass.is_empty() { + credentials::set(cred_ref, &pass).unwrap(); + } + + let target = WebDavTarget { + base_url, + remote_base_path: "/WhispAssist-test".to_string(), + username: user, + credential_ref: cred_ref.to_string(), + third_party: false, + allow_plaintext_lan: true, + }; + + target.test().await.expect("test connection"); + target + .ensure_dir("/WhispAssist-test/m1") + .await + .expect("mkcol"); + + let tmp = std::env::temp_dir().join("wa_webdav_probe.txt"); + std::fs::write(&tmp, b"hello webdav").unwrap(); + let (tx, rx) = std::sync::mpsc::channel(); + target + .put(&tmp, "/WhispAssist-test/m1/probe.txt", tx) + .await + .expect("put"); + assert!(rx.recv().is_ok(), "progress reported"); + assert!( + target + .exists("/WhispAssist-test/m1/probe.txt", "") + .await + .unwrap(), + "uploaded file should exist remotely" + ); + let _ = credentials::delete(cred_ref); + } +} + +#[cfg(feature = "sync")] +fn now_unix() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0) +} + +/// Resolve a live access token for an OAuth target, refreshing (and re-storing) +/// it if it's expired. Tokens live in the OS credential store as JSON. +#[cfg(feature = "sync")] +async fn resolve_access_token(kind: &str, credential_ref: &str) -> Result { + let provider = oauth::provider_for(kind) + .ok_or_else(|| SyncError::Upload(format!("unknown oauth provider {kind}")))?; + let client_id = provider.client_id()?; + let json = credentials::get(credential_ref)?; + let mut tokens: oauth::TokenSet = + serde_json::from_str(&json).map_err(|e| SyncError::Credential(e.to_string()))?; + if tokens.is_expired(now_unix()) { + let refresh = tokens.refresh_token.clone().ok_or(SyncError::Auth)?; + tokens = oauth::refresh_tokens(&provider, &client_id, &refresh).await?; + let updated = + serde_json::to_string(&tokens).map_err(|e| SyncError::Credential(e.to_string()))?; + credentials::set(credential_ref, &updated)?; + } + Ok(tokens.access_token) +} + +/// Construct the concrete `SyncTarget` for a stored row by its `kind`. This is +/// the one place that maps kind → provider impl (the pump/test dispatch here). +#[cfg(feature = "sync")] +pub fn build_sync_target( + row: &crate::storage::SyncTargetRow, +) -> Result, SyncError> { + match row.kind.as_str() { + "webdav" => Ok(Box::new(WebDavTarget::from_row(row))), + "onedrive" => Ok(Box::new(OneDriveTarget::from_row(row))), + // Linking works (tokens stored); upload lands next, same trait. + "dropbox" | "box" => Err(SyncError::Upload(format!( + "{} upload is not implemented yet (OAuth linking works)", + row.kind + ))), + other => Err(SyncError::Upload(format!( + "unknown sync target kind {other}" + ))), + } +} + +// ---- Secondary OAuth providers (Phase 9b) — same trait, separate impls ---- + +/// OneDrive via Microsoft Graph. The reference OAuth provider impl. +#[cfg(feature = "sync")] +pub struct OneDriveTarget { + pub credential_ref: String, +} + +#[cfg(feature = "sync")] +impl OneDriveTarget { + pub fn from_row(row: &crate::storage::SyncTargetRow) -> Self { + Self { + credential_ref: row.credential_ref.clone(), + } + } + + async fn bearer(&self) -> Result { + resolve_access_token("onedrive", &self.credential_ref).await + } + + /// Graph path addressing: `…/root:/{path}{suffix}` (suffix `""` = the item, + /// `:/content` = its bytes, `:/children` = its child collection). + fn item_url(remote_path: &str, suffix: &str) -> String { + let p = remote_path.trim_start_matches('/'); + format!("https://graph.microsoft.com/v1.0/me/drive/root:/{p}{suffix}") + } +} + +#[cfg(feature = "sync")] +#[async_trait] +impl SyncTarget for OneDriveTarget { + fn kind(&self) -> SyncKind { + SyncKind::OneDrive + } + fn is_third_party(&self) -> bool { + true + } + + async fn test(&self) -> Result<(), SyncError> { + let token = self.bearer().await?; + let resp = http_client()? + .get("https://graph.microsoft.com/v1.0/me/drive") + .bearer_auth(token) + .send() + .await + .map_err(|e| SyncError::Unreachable(e.to_string()))?; + match resp.status().as_u16() { + code if (200..300).contains(&code) => Ok(()), + 401 | 403 => Err(SyncError::Auth), + other => Err(SyncError::Unreachable(format!("graph {other}"))), + } + } + + async fn ensure_dir(&self, remote_dir: &str) -> Result<(), SyncError> { + let token = self.bearer().await?; + let mut parent = String::new(); + for segment in remote_dir.split('/').filter(|s| !s.is_empty()) { + let url = if parent.is_empty() { + "https://graph.microsoft.com/v1.0/me/drive/root/children".to_string() + } else { + format!("https://graph.microsoft.com/v1.0/me/drive/root:/{parent}:/children") + }; + let body = serde_json::json!({ + "name": segment, + "folder": {}, + "@microsoft.graph.conflictBehavior": "replace", + }); + let resp = http_client()? + .post(&url) + .bearer_auth(&token) + .json(&body) + .send() + .await + .map_err(|e| SyncError::Upload(e.to_string()))?; + match resp.status().as_u16() { + 200 | 201 | 409 => {} // created, or already exists + 401 | 403 => return Err(SyncError::Auth), + other => return Err(SyncError::Upload(format!("mkdir {other}"))), + } + parent = if parent.is_empty() { + segment.to_string() + } else { + format!("{parent}/{segment}") + }; + } + Ok(()) + } + + async fn exists(&self, remote_path: &str, _sha256: &str) -> Result { + let token = self.bearer().await?; + let resp = http_client()? + .get(Self::item_url(remote_path, "")) + .bearer_auth(token) + .send() + .await + .map_err(|e| SyncError::Unreachable(e.to_string()))?; + Ok(resp.status().is_success()) + } + + /// ponytail: simple content upload (Graph caps this at 250 MB). A resumable + /// upload session for larger recordings is a follow-up. + async fn put( + &self, + local: &Path, + remote_path: &str, + prog: ProgressSink, + ) -> Result<(), SyncError> { + let token = self.bearer().await?; + let bytes = tokio::fs::read(local) + .await + .map_err(|e| SyncError::Upload(e.to_string()))?; + let total = bytes.len() as u64; + let resp = http_client()? + .put(Self::item_url(remote_path, ":/content")) + .bearer_auth(token) + .body(bytes) + .send() + .await + .map_err(|e| SyncError::Upload(e.to_string()))?; + match resp.status().as_u16() { + code if (200..300).contains(&code) => { + let _ = prog.send((total, total)); + Ok(()) + } + 401 | 403 => Err(SyncError::Auth), + other => Err(SyncError::Upload(format!("PUT {other}"))), + } + } +} diff --git a/src-tauri/src/sync/oauth.rs b/src-tauri/src/sync/oauth.rs new file mode 100644 index 0000000..3fd45f5 --- /dev/null +++ b/src-tauri/src/sync/oauth.rs @@ -0,0 +1,406 @@ +//! OAuth 2.0 Authorization Code + PKCE with a loopback redirect (Phase 9b, +//! ADR-0010, FR-SYNC-9). Desktop-appropriate: no client secret, a random +//! loopback port for the redirect, and tokens kept in the OS credential store. +//! +//! Provider-agnostic core (PKCE, auth-URL, token exchange/refresh, loopback +//! capture) is here and unit-tested. The concrete providers (OneDrive/Dropbox/ +//! Box) are `SyncTarget` impls in `mod.rs`. **Client IDs come from the +//! environment** (`WA_OAUTH__CLIENT_ID`) — an OAuth app registration +//! is required per provider; until one is set, `begin_oauth_link` errors clearly +//! rather than pretending. + +use super::SyncError; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; + +/// A provider's OAuth endpoints + the scopes we request. +pub struct OAuthProvider { + pub kind: &'static str, // onedrive|dropbox|box + pub auth_endpoint: &'static str, + pub token_endpoint: &'static str, + pub scopes: &'static [&'static str], + pub client_id_env: &'static str, +} + +pub fn provider_for(kind: &str) -> Option { + match kind { + "onedrive" => Some(OAuthProvider { + kind: "onedrive", + auth_endpoint: "https://login.microsoftonline.com/common/oauth2/v2.0/authorize", + token_endpoint: "https://login.microsoftonline.com/common/oauth2/v2.0/token", + // offline_access → refresh token; Files.ReadWrite → user's OneDrive. + scopes: &["offline_access", "Files.ReadWrite", "User.Read"], + client_id_env: "WA_OAUTH_ONEDRIVE_CLIENT_ID", + }), + "dropbox" => Some(OAuthProvider { + kind: "dropbox", + auth_endpoint: "https://www.dropbox.com/oauth2/authorize", + token_endpoint: "https://api.dropboxapi.com/oauth2/token", + scopes: &["files.content.write", "files.content.read"], + client_id_env: "WA_OAUTH_DROPBOX_CLIENT_ID", + }), + "box" => Some(OAuthProvider { + kind: "box", + auth_endpoint: "https://account.box.com/api/oauth2/authorize", + token_endpoint: "https://api.box.com/oauth2/token", + scopes: &["root_readwrite"], + client_id_env: "WA_OAUTH_BOX_CLIENT_ID", + }), + _ => None, + } +} + +impl OAuthProvider { + /// The registered client id, from the environment. Empty/unset → the flow + /// can't proceed (an app registration is required). + pub fn client_id(&self) -> Result { + std::env::var(self.client_id_env) + .ok() + .filter(|s| !s.is_empty()) + .ok_or_else(|| { + SyncError::Credential(format!( + "OAuth client id for {} not configured (set {})", + self.kind, self.client_id_env + )) + }) + } +} + +/// Tokens as stored (JSON) in the OS credential store, keyed by the target's +/// `credential_ref`. `expires_at` is a unix timestamp for proactive refresh. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TokenSet { + pub access_token: String, + #[serde(default)] + pub refresh_token: Option, + #[serde(default)] + pub expires_at: i64, +} + +impl TokenSet { + pub fn is_expired(&self, now: i64) -> bool { + // 60s skew so we refresh just before the token actually lapses. + self.expires_at != 0 && now >= self.expires_at - 60 + } +} + +/// PKCE verifier + S256 challenge (RFC 7636). +pub struct Pkce { + pub verifier: String, + pub challenge: String, +} + +pub fn pkce_pair() -> Pkce { + // 32 bytes of randomness → 43-char base64url verifier (uuid v4 is CSPRNG-backed). + let mut bytes = [0u8; 32]; + bytes[..16].copy_from_slice(uuid::Uuid::new_v4().as_bytes()); + bytes[16..].copy_from_slice(uuid::Uuid::new_v4().as_bytes()); + let verifier = base64url_nopad(&bytes); + Pkce { + challenge: challenge_for(&verifier), + verifier, + } +} + +fn challenge_for(verifier: &str) -> String { + let mut hasher = Sha256::new(); + hasher.update(verifier.as_bytes()); + base64url_nopad(&hasher.finalize()) +} + +/// Base64url, no padding (RFC 4648 §5) — PKCE's required encoding. +fn base64url_nopad(input: &[u8]) -> String { + const CHARS: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; + let mut out = String::with_capacity(input.len().div_ceil(3) * 4); + for chunk in input.chunks(3) { + let b0 = chunk[0] as u32; + let b1 = *chunk.get(1).unwrap_or(&0) as u32; + let b2 = *chunk.get(2).unwrap_or(&0) as u32; + let n = (b0 << 16) | (b1 << 8) | b2; + let idx = [(n >> 18) & 63, (n >> 12) & 63, (n >> 6) & 63, n & 63]; + for &i in idx.iter().take(chunk.len() + 1) { + out.push(CHARS[i as usize] as char); + } + } + out +} + +/// The authorization URL to open in the user's browser. +pub fn build_auth_url( + provider: &OAuthProvider, + client_id: &str, + redirect_uri: &str, + challenge: &str, + state: &str, +) -> Result { + let scope = provider.scopes.join(" "); + let url = reqwest::Url::parse_with_params( + provider.auth_endpoint, + &[ + ("response_type", "code"), + ("client_id", client_id), + ("redirect_uri", redirect_uri), + ("scope", &scope), + ("code_challenge", challenge), + ("code_challenge_method", "S256"), + ("state", state), + ], + ) + .map_err(|e| SyncError::Upload(format!("bad auth url: {e}")))?; + Ok(url.to_string()) +} + +#[derive(Deserialize)] +struct TokenResponse { + access_token: String, + refresh_token: Option, + expires_in: Option, +} + +fn token_set_from(resp: TokenResponse, now: i64, prior_refresh: Option) -> TokenSet { + TokenSet { + access_token: resp.access_token, + // Providers omit a new refresh token on refresh — keep the prior one. + refresh_token: resp.refresh_token.or(prior_refresh), + expires_at: resp.expires_in.map(|s| now + s).unwrap_or(0), + } +} + +/// Exchange an authorization code for tokens. +pub async fn exchange_code( + provider: &OAuthProvider, + client_id: &str, + code: &str, + verifier: &str, + redirect_uri: &str, +) -> Result { + let resp = super::http_client()? + .post(provider.token_endpoint) + .form(&[ + ("grant_type", "authorization_code"), + ("client_id", client_id), + ("code", code), + ("code_verifier", verifier), + ("redirect_uri", redirect_uri), + ]) + .send() + .await + .map_err(|e| SyncError::Unreachable(e.to_string()))?; + if !resp.status().is_success() { + return Err(SyncError::Auth); + } + let body: TokenResponse = resp + .json() + .await + .map_err(|e| SyncError::Upload(e.to_string()))?; + Ok(token_set_from(body, now_unix(), None)) +} + +/// Refresh an access token using the stored refresh token. +pub async fn refresh_tokens( + provider: &OAuthProvider, + client_id: &str, + refresh_token: &str, +) -> Result { + let resp = super::http_client()? + .post(provider.token_endpoint) + .form(&[ + ("grant_type", "refresh_token"), + ("client_id", client_id), + ("refresh_token", refresh_token), + ]) + .send() + .await + .map_err(|e| SyncError::Unreachable(e.to_string()))?; + if !resp.status().is_success() { + return Err(SyncError::Auth); + } + let body: TokenResponse = resp + .json() + .await + .map_err(|e| SyncError::Upload(e.to_string()))?; + Ok(token_set_from( + body, + now_unix(), + Some(refresh_token.to_string()), + )) +} + +/// Binds a loopback listener and waits (once) for the OAuth redirect, returning +/// the `code` after checking `state`. The port is chosen by the OS so the +/// redirect URI is `http://127.0.0.1:/callback`. +pub struct LoopbackRedirect { + listener: std::net::TcpListener, + pub redirect_uri: String, +} + +impl LoopbackRedirect { + pub fn bind() -> Result { + let listener = std::net::TcpListener::bind("127.0.0.1:0") + .map_err(|e| SyncError::Upload(format!("loopback bind: {e}")))?; + let port = listener + .local_addr() + .map_err(|e| SyncError::Upload(e.to_string()))? + .port(); + Ok(Self { + listener, + redirect_uri: format!("http://127.0.0.1:{port}/callback"), + }) + } + + /// Accept one request, parse the callback query, and return the auth code. + /// Blocking — call from `spawn_blocking`. + pub fn wait_for_code(self, expected_state: &str) -> Result { + use std::io::{Read, Write}; + let (mut stream, _) = self + .listener + .accept() + .map_err(|e| SyncError::Upload(format!("loopback accept: {e}")))?; + let mut buf = [0u8; 4096]; + let n = stream.read(&mut buf).unwrap_or(0); + let request = String::from_utf8_lossy(&buf[..n]); + let (code, state) = parse_callback(&request); + + let body = + "WhispAssist is now linked. You can close this window."; + let _ = write!( + stream, + "HTTP/1.1 200 OK\r\nContent-Type: text/html\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + body.len(), + body + ); + + if state.as_deref() != Some(expected_state) { + return Err(SyncError::Auth); // CSRF / state mismatch + } + code.ok_or(SyncError::Auth) + } +} + +/// Pull `code` and `state` out of the redirect's request line +/// (`GET /callback?code=…&state=… HTTP/1.1`). +fn parse_callback(request: &str) -> (Option, Option) { + let Some(line) = request.lines().next() else { + return (None, None); + }; + let Some(path) = line.split_whitespace().nth(1) else { + return (None, None); + }; + let query = path.split_once('?').map(|(_, q)| q).unwrap_or(""); + let mut code = None; + let mut state = None; + for pair in query.split('&') { + match pair.split_once('=') { + Some(("code", v)) => code = Some(percent_decode(v)), + Some(("state", v)) => state = Some(percent_decode(v)), + _ => {} + } + } + (code, state) +} + +/// Minimal percent-decode for the two short query values we read. +fn percent_decode(s: &str) -> String { + let bytes = s.replace('+', " "); + let bytes = bytes.as_bytes(); + let mut out = Vec::with_capacity(bytes.len()); + let mut i = 0; + while i < bytes.len() { + if bytes[i] == b'%' && i + 2 < bytes.len() { + if let Ok(byte) = u8::from_str_radix(&s[i + 1..i + 3], 16) { + out.push(byte); + i += 3; + continue; + } + } + out.push(bytes[i]); + i += 1; + } + String::from_utf8_lossy(&out).into_owned() +} + +fn now_unix() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn base64url_matches_known_vectors() { + assert_eq!(base64url_nopad(b""), ""); + assert_eq!(base64url_nopad(b"f"), "Zg"); + assert_eq!(base64url_nopad(b"fo"), "Zm8"); + assert_eq!(base64url_nopad(b"foo"), "Zm9v"); + assert_eq!(base64url_nopad(b"foob"), "Zm9vYg"); + } + + #[test] + fn pkce_challenge_matches_rfc7636_example() { + // RFC 7636 Appendix B vector. + let verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"; + assert_eq!( + challenge_for(verifier), + "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM" + ); + } + + #[test] + fn generated_pkce_is_url_safe_and_deterministic_challenge() { + let p = pkce_pair(); + assert!(p + .verifier + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'-' || b == b'_')); + assert_eq!(challenge_for(&p.verifier), p.challenge); + } + + #[test] + fn auth_url_carries_pkce_and_state() { + let provider = provider_for("onedrive").unwrap(); + let url = build_auth_url( + &provider, + "client123", + "http://127.0.0.1:5000/callback", + "chal", + "st8", + ) + .unwrap(); + assert!(url.contains("code_challenge=chal")); + assert!(url.contains("code_challenge_method=S256")); + assert!(url.contains("client_id=client123")); + assert!(url.contains("state=st8")); + // redirect_uri is percent-encoded by the url builder. + assert!(url.contains("redirect_uri=http%3A%2F%2F127.0.0.1%3A5000%2Fcallback")); + } + + #[test] + fn parse_callback_extracts_code_and_state() { + let req = "GET /callback?code=abc123&state=xyz HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n"; + let (code, state) = parse_callback(req); + assert_eq!(code.as_deref(), Some("abc123")); + assert_eq!(state.as_deref(), Some("xyz")); + } + + #[test] + fn percent_decode_handles_encoded_bytes() { + assert_eq!(percent_decode("a%2Fb"), "a/b"); + assert_eq!(percent_decode("hello+world"), "hello world"); + } + + #[test] + fn expired_uses_skew() { + let t = TokenSet { + access_token: "a".into(), + refresh_token: None, + expires_at: 1000, + }; + assert!(!t.is_expired(900)); + assert!(t.is_expired(950)); // within the 60s skew + assert!(t.is_expired(1000)); + } +} diff --git a/src-tauri/src/vault/mod.rs b/src-tauri/src/vault/mod.rs new file mode 100644 index 0000000..aa1f980 --- /dev/null +++ b/src-tauri/src/vault/mod.rs @@ -0,0 +1,355 @@ +//! At-rest encryption vault (Phase 8, T8.8, FR-SEC-3). Optional, off by default. +//! +//! Design (envelope encryption so the password can change without re-encrypting +//! every file): +//! - A random 32-byte **data key** encrypts artifacts (XChaCha20-Poly1305). +//! - The data key is **wrapped** (encrypted) under a **master key** derived from +//! the user's password via Argon2id + a random salt. +//! - `vault.json` stores only the salt, wrap nonce, and wrapped data key — never +//! the password or the plaintext key. The unlocked data key lives only in +//! process memory (`KEY`) until `lock()` or exit. +//! +//! `seal`/`open` are passthroughs when the vault is locked/disabled, and `open` +//! recognizes plaintext (unencrypted) files by the absence of the magic header — +//! so enabling the vault never breaks reading pre-existing files. + +use crate::paths::wa_root; +use chacha20poly1305::aead::{Aead, KeyInit}; +use chacha20poly1305::{XChaCha20Poly1305, XNonce}; +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; +use std::sync::RwLock; +use zeroize::{Zeroize, Zeroizing}; + +/// Marks a sealed artifact so `open` can distinguish ciphertext from a plaintext +/// (pre-vault) file. "WhispAssist Encrypted Content v1". +const MAGIC: &[u8; 4] = b"WEC1"; +const NONCE_LEN: usize = 24; // XChaCha20-Poly1305 +const KEY_LEN: usize = 32; +const SALT_LEN: usize = 16; + +/// The unlocked data key, held in memory only while the vault is unlocked. +/// ponytail: process-global rather than threaded through every call site — the +/// vault is inherently a single per-process state; upgrade to zeroize-on-drop if +/// key-scrubbing becomes a requirement. +static KEY: RwLock> = RwLock::new(None); + +#[derive(Debug, thiserror::Error)] +pub enum VaultError { + #[error("vault already exists")] + AlreadyExists, + #[error("vault is not set up")] + NotSetup, + #[error("vault is locked")] + Locked, + #[error("incorrect password")] + BadPassword, + #[error("crypto error")] + Crypto, + #[error("io error: {0}")] + Io(String), + #[error("key derivation failed")] + Kdf, +} + +/// On-disk `vault.json`: everything needed to re-derive and unwrap the data key +/// from the password. Contains no secret usable without the password. +#[derive(Serialize, Deserialize)] +struct VaultMeta { + version: u32, + salt: Vec, + wrap_nonce: Vec, + wrapped_key: Vec, +} + +fn vault_file() -> PathBuf { + wa_root().join("vault.json") +} + +pub fn is_enabled() -> bool { + vault_file().exists() +} + +pub fn is_unlocked() -> bool { + KEY.read().map(|k| k.is_some()).unwrap_or(false) +} + +fn random_bytes(buf: &mut [u8]) -> Result<(), VaultError> { + getrandom::getrandom(buf).map_err(|_| VaultError::Crypto) +} + +/// Argon2id: password + salt → 32-byte master key. Wrapped in `Zeroizing` so the +/// derived key is wiped from the stack when the caller drops it. +fn derive_master(password: &str, salt: &[u8]) -> Result, VaultError> { + let mut key = Zeroizing::new([0u8; KEY_LEN]); + argon2::Argon2::default() + .hash_password_into(password.as_bytes(), salt, key.as_mut()) + .map_err(|_| VaultError::Kdf)?; + Ok(key) +} + +fn aead(key: &[u8]) -> Result { + XChaCha20Poly1305::new_from_slice(key).map_err(|_| VaultError::Crypto) +} + +/// Encrypt `plaintext` with `key`, returning `nonce || ciphertext`. +fn encrypt_with(key: &[u8], plaintext: &[u8]) -> Result, VaultError> { + let mut nonce = [0u8; NONCE_LEN]; + random_bytes(&mut nonce)?; + let ct = aead(key)? + .encrypt(XNonce::from_slice(&nonce), plaintext) + .map_err(|_| VaultError::Crypto)?; + let mut out = Vec::with_capacity(NONCE_LEN + ct.len()); + out.extend_from_slice(&nonce); + out.extend_from_slice(&ct); + Ok(out) +} + +/// Inverse of `encrypt_with`; an authentication failure means a wrong key. +fn decrypt_with(key: &[u8], data: &[u8]) -> Result, VaultError> { + if data.len() < NONCE_LEN { + return Err(VaultError::Crypto); + } + let (nonce, ct) = data.split_at(NONCE_LEN); + aead(key)? + .decrypt(XNonce::from_slice(nonce), ct) + .map_err(|_| VaultError::BadPassword) +} + +fn read_meta() -> Result { + let text = std::fs::read_to_string(vault_file()).map_err(|_| VaultError::NotSetup)?; + serde_json::from_str(&text).map_err(|e| VaultError::Io(e.to_string())) +} + +fn write_meta(meta: &VaultMeta) -> Result<(), VaultError> { + std::fs::create_dir_all(wa_root()).map_err(|e| VaultError::Io(e.to_string()))?; + let text = serde_json::to_string_pretty(meta).map_err(|e| VaultError::Io(e.to_string()))?; + std::fs::write(vault_file(), text).map_err(|e| VaultError::Io(e.to_string())) +} + +fn set_key(key: Option<[u8; KEY_LEN]>) { + if let Ok(mut guard) = KEY.write() { + // Wipe the previous key from memory before replacing it (T8.8 hardening). + if let Some(old) = guard.as_mut() { + old.zeroize(); + } + *guard = key; + } +} + +/// Create the vault: generate a data key, wrap it under the password-derived +/// master key, persist metadata, and leave the vault unlocked. +pub fn enable(password: &str) -> Result<(), VaultError> { + if is_enabled() { + return Err(VaultError::AlreadyExists); + } + let mut salt = [0u8; SALT_LEN]; + random_bytes(&mut salt)?; + let mut data_key = [0u8; KEY_LEN]; + random_bytes(&mut data_key)?; + + let master = derive_master(password, &salt)?; + let wrapped = encrypt_with(&*master, &data_key)?; + let (wrap_nonce, wrapped_key) = wrapped.split_at(NONCE_LEN); + + write_meta(&VaultMeta { + version: 1, + salt: salt.to_vec(), + wrap_nonce: wrap_nonce.to_vec(), + wrapped_key: wrapped_key.to_vec(), + })?; + set_key(Some(data_key)); + Ok(()) +} + +/// Unlock the vault with the password, loading the data key into memory. +pub fn unlock(password: &str) -> Result<(), VaultError> { + let meta = read_meta()?; + let master = derive_master(password, &meta.salt)?; + let mut wrapped = meta.wrap_nonce.clone(); + wrapped.extend_from_slice(&meta.wrapped_key); + let data_key = decrypt_with(&*master, &wrapped)?; // AEAD failure ⇒ BadPassword + let key: [u8; KEY_LEN] = data_key.try_into().map_err(|_| VaultError::Crypto)?; + set_key(Some(key)); + Ok(()) +} + +pub fn lock() { + set_key(None); +} + +/// Change the password: verify the old one, re-wrap the *same* data key under a +/// fresh salt+master derived from the new password (no artifact re-encryption). +pub fn change_password(old: &str, new: &str) -> Result<(), VaultError> { + let meta = read_meta()?; + let old_master = derive_master(old, &meta.salt)?; + let mut wrapped = meta.wrap_nonce.clone(); + wrapped.extend_from_slice(&meta.wrapped_key); + let data_key = decrypt_with(&*old_master, &wrapped)?; + + let mut salt = [0u8; SALT_LEN]; + random_bytes(&mut salt)?; + let new_master = derive_master(new, &salt)?; + let rewrapped = encrypt_with(&*new_master, &data_key)?; + let (wrap_nonce, wrapped_key) = rewrapped.split_at(NONCE_LEN); + write_meta(&VaultMeta { + version: 1, + salt: salt.to_vec(), + wrap_nonce: wrap_nonce.to_vec(), + wrapped_key: wrapped_key.to_vec(), + }) +} + +/// Encrypt bytes for storage IF the vault is unlocked, tagging them with the +/// magic header; otherwise return them unchanged (vault off/locked ⇒ plaintext). +pub fn seal(plaintext: &[u8]) -> Result, VaultError> { + let guard = KEY.read().map_err(|_| VaultError::Crypto)?; + match guard.as_ref() { + Some(key) => { + let body = encrypt_with(key, plaintext)?; + let mut out = Vec::with_capacity(MAGIC.len() + body.len()); + out.extend_from_slice(MAGIC); + out.extend_from_slice(&body); + Ok(out) + } + None => Ok(plaintext.to_vec()), + } +} + +/// Inverse of `seal`. Plaintext (no magic) passes through unchanged; sealed data +/// requires the vault to be unlocked. +pub fn open(data: &[u8]) -> Result, VaultError> { + if data.len() < MAGIC.len() || &data[..MAGIC.len()] != MAGIC { + return Ok(data.to_vec()); // plaintext / pre-vault file + } + let guard = KEY.read().map_err(|_| VaultError::Crypto)?; + let key = guard.as_ref().ok_or(VaultError::Locked)?; + decrypt_with(key, &data[MAGIC.len()..]) +} + +/// Ensure bytes are ciphertext for client-side-encrypted upload (T9.12, +/// FR-SYNC-10): already-sealed data (e.g. sealed at rest) passes through; +/// plaintext is sealed here, which requires the vault to be unlocked. +pub fn ensure_sealed(data: &[u8]) -> Result, VaultError> { + if data.len() >= MAGIC.len() && &data[..MAGIC.len()] == MAGIC { + return Ok(data.to_vec()); + } + seal_required(data) +} + +/// Like `seal`, but errors if the vault is locked instead of passing through — +/// used where ciphertext is mandatory (encrypt-before-upload). +fn seal_required(plaintext: &[u8]) -> Result, VaultError> { + let guard = KEY.read().map_err(|_| VaultError::Crypto)?; + let key = guard.as_ref().ok_or(VaultError::Locked)?; + let body = encrypt_with(key, plaintext)?; + let mut out = Vec::with_capacity(MAGIC.len() + body.len()); + out.extend_from_slice(MAGIC); + out.extend_from_slice(&body); + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + + // The KEY state is process-global, so KEY-touching tests must not run + // concurrently — serialize them on a lock (cargo runs tests in parallel). + static TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + + fn with_key(key: Option<[u8; KEY_LEN]>, f: impl FnOnce() -> T) -> T { + let _guard = TEST_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + set_key(key); + let r = f(); + set_key(None); + r + } + + #[test] + fn seal_open_round_trips_when_unlocked() { + let key = [7u8; KEY_LEN]; + with_key(Some(key), || { + let sealed = seal(b"secret notes").unwrap(); + assert_eq!(&sealed[..4], MAGIC); + assert_ne!(&sealed[4..], b"secret notes"); // actually encrypted + assert_eq!(open(&sealed).unwrap(), b"secret notes"); + }); + } + + #[test] + fn seal_is_passthrough_when_locked() { + with_key(None, || { + let out = seal(b"plain").unwrap(); + assert_eq!(out, b"plain"); // no magic, unchanged + }); + } + + #[test] + fn open_passes_through_plaintext_files() { + // A pre-vault plaintext file must still read back even when unlocked. + with_key(Some([1u8; KEY_LEN]), || { + assert_eq!(open(b"{\"json\":true}").unwrap(), b"{\"json\":true}"); + }); + } + + #[test] + fn sealed_data_requires_unlock_to_open() { + let sealed = with_key(Some([9u8; KEY_LEN]), || seal(b"x").unwrap()); + // Now locked: opening sealed content fails rather than returning garbage. + with_key(None, || { + assert!(matches!(open(&sealed), Err(VaultError::Locked))); + }); + } + + #[test] + fn ensure_sealed_seals_plaintext_but_passes_sealed_through() { + let already = with_key(Some([4u8; KEY_LEN]), || seal(b"hi").unwrap()); + with_key(Some([4u8; KEY_LEN]), || { + // Plaintext → sealed (tagged), and round-trips. + let sealed = ensure_sealed(b"plain").unwrap(); + assert_eq!(&sealed[..4], MAGIC); + assert_eq!(open(&sealed).unwrap(), b"plain"); + // Already-sealed data is returned unchanged (no double-seal). + assert_eq!(ensure_sealed(&already).unwrap(), already); + }); + // Locked + plaintext → hard error (can't produce ciphertext). + with_key(None, || { + assert!(matches!(ensure_sealed(b"plain"), Err(VaultError::Locked))); + }); + } + + #[test] + fn wrong_key_fails_authentication() { + let sealed = with_key(Some([1u8; KEY_LEN]), || seal(b"data").unwrap()); + with_key(Some([2u8; KEY_LEN]), || { + assert!(matches!(open(&sealed), Err(VaultError::BadPassword))); + }); + } + + #[test] + fn kdf_is_deterministic_and_password_sensitive() { + let salt = [3u8; SALT_LEN]; + assert_eq!( + derive_master("hunter2", &salt).unwrap(), + derive_master("hunter2", &salt).unwrap() + ); + assert_ne!( + derive_master("hunter2", &salt).unwrap(), + derive_master("hunter3", &salt).unwrap() + ); + } + + #[test] + fn key_wrap_round_trips_and_rejects_wrong_master() { + let master = derive_master("pw", &[5u8; SALT_LEN]).unwrap(); + let data_key = [42u8; KEY_LEN]; + let wrapped = encrypt_with(&*master, &data_key).unwrap(); + assert_eq!(decrypt_with(&*master, &wrapped).unwrap(), data_key); + + let wrong = derive_master("nope", &[5u8; SALT_LEN]).unwrap(); + assert!(matches!( + decrypt_with(&*wrong, &wrapped), + Err(VaultError::BadPassword) + )); + } +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index bf9dbd0..baf212d 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "WhispAssist", - "version": "0.0.0", + "version": "0.1.0", "identifier": "bet.dou.whispassist", "build": { "frontendDist": "../dist", diff --git a/src/App.svelte b/src/App.svelte index c9ee482..84ae6b7 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -19,6 +19,31 @@ let showSettings = $state(false); let showConsent = $state(false); + // Vault unlock gate (T8.8): if the vault is enabled but locked at startup, + // prompt for the password so encrypted meetings are readable. + let vaultLocked = $state(false); + let vaultPw = $state(""); + let vaultErr = $state(null); + async function checkVault() { + try { + const v = await api.vaultStatus(); + vaultLocked = v.enabled && !v.unlocked; + } catch { + vaultLocked = false; + } + } + async function submitUnlock() { + vaultErr = null; + try { + await api.unlockVault(vaultPw); + vaultPw = ""; + vaultLocked = false; + meetings.load(); // refresh now that encrypted content is readable + } catch { + vaultErr = "Incorrect password"; + } + } + // Note templates (T8.1, FR-NOTE-5) — picked before starting a recording, // applied to notes.md's structure once the meeting finalizes. let noteTemplates = $state([]); @@ -46,6 +71,7 @@ recording.init(); settings.load(); meetings.init(); + checkVault(); api .listNoteTemplates() .then((t) => (noteTemplates = t)) @@ -224,6 +250,33 @@ (showSettings = false)} /> {/if} + {#if vaultLocked} + + {/if} +
@@ -512,4 +565,37 @@ overflow: auto; background: var(--bg); } + .vault-overlay { + position: fixed; + inset: 0; + z-index: 50; + display: flex; + align-items: center; + justify-content: center; + background: rgba(10, 12, 16, 0.6); + } + .vault-card { + background: var(--panel, var(--bg)); + border: 1px solid var(--border); + border-radius: 12px; + padding: 1.5rem; + width: min(360px, 90vw); + display: flex; + flex-direction: column; + gap: 0.6rem; + } + .vault-card input { + padding: 0.5rem; + border: 1px solid var(--border-strong); + border-radius: 6px; + } + .vault-actions { + display: flex; + align-items: center; + gap: 0.75rem; + } + .vault-err { + color: var(--danger, #dc2626); + margin: 0; + } diff --git a/src/lib/api.ts b/src/lib/api.ts index 174f258..18baabf 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -233,6 +233,20 @@ export interface SyncTargetConfig { encrypt_before_upload?: boolean; } +// A durable upload job for one artifact × target (sync_status). Snake_case, +// matching the Rust SyncJobInfo; the sync://job *event* is camelCase (onSyncJob). +export interface SyncJobInfo { + id: string; + target_id: string; + meeting_id: MeetingId; + artifact: string; + status: string; // pending|uploading|done|failed|skipped + attempts: number; + bytes_sent: number; + bytes_total: number | null; + last_error: string | null; +} + export interface AppSettings { theme: string; storage_root: string; @@ -377,13 +391,13 @@ export const api = { invoke("update_sync_target", { config }), removeSyncTarget: (id: string) => invoke("remove_sync_target", { id }), testSyncTarget: (configOrId: SyncTargetConfig | { id: string }) => - invoke<{ ok: boolean; message: string }>("test_sync_target", { configOrId }), + invoke<{ ok: boolean; message: string }>("test_sync_target", { config: configOrId }), setSyncEnabled: (enabled: boolean) => invoke("set_sync_enabled", { enabled }), beginOauthLink: (kind: "onedrive" | "dropbox" | "box") => - invoke<{ ok: boolean; account?: string }>("begin_oauth_link", { kind }), + invoke<{ authUrl: string }>("begin_oauth_link", { kind }), syncMeeting: (meetingId: MeetingId, targetId?: string) => invoke("sync_meeting", { meetingId, targetId }), - syncStatus: (meetingId?: MeetingId) => invoke("sync_status", { meetingId }), + syncStatus: (meetingId?: MeetingId) => invoke("sync_status", { meetingId }), retrySyncJob: (jobId: string) => invoke("retry_sync_job", { jobId }), // Feature briefs + MCP server (ADR-0011). MCP server is loopback-only and off by default. @@ -413,6 +427,14 @@ export const api = { updateSettings: (patch: Partial) => invoke("update_settings", { patch }), privacySelfCheck: () => invoke("privacy_self_check"), + + // At-rest encryption vault (Phase 8, T8.8, FR-SEC-3). + vaultStatus: () => invoke<{ enabled: boolean; unlocked: boolean }>("vault_status"), + enableVault: (password: string) => invoke("enable_vault", { args: { password } }), + unlockVault: (password: string) => invoke("unlock_vault", { args: { password } }), + lockVault: () => invoke("lock_vault"), + changeVaultPassword: (oldPassword: string, newPassword: string) => + invoke("change_vault_password", { args: { oldPassword, newPassword } }), }; // ---- Events (Rust → UI) ---- @@ -461,9 +483,14 @@ export const events = { artifact: string; status: string; bytesSent: number; - bytesTotal: number; + bytesTotal: number | null; + attempts?: number; + error?: string | null; }) => void, ): Promise => listen("sync://job", (e) => cb(e.payload as never)), + onSyncLinked: ( + cb: (p: { ok: boolean; kind: string; error?: string }) => void, + ): Promise => listen("sync://linked", (e) => cb(e.payload as never)), onMcpAccess: (cb: (p: McpAccessEntry & { client?: string }) => void): Promise => listen("mcp://access", (e) => cb(e.payload as never)), onAgentProgress: ( diff --git a/src/lib/stores/meetings.svelte.ts b/src/lib/stores/meetings.svelte.ts index 99d3699..a2090b7 100644 --- a/src/lib/stores/meetings.svelte.ts +++ b/src/lib/stores/meetings.svelte.ts @@ -11,8 +11,21 @@ import { type MeetingId, type MeetingListItem, type SearchHit, + type SyncJobInfo, } from "../api"; +/** Normalized (camelCase) upload-job view for the UI — folds the snake_case + * `sync_status` rows and the camelCase `sync://job` events into one shape. */ +export interface SyncJob { + jobId: string; + artifact: string; + status: string; + bytesSent: number; + bytesTotal: number | null; + attempts: number; + error: string | null; +} + class MeetingsStore { list = $state([]); selectedId = $state(null); @@ -37,6 +50,11 @@ class MeetingsStore { summaryStream = $state(""); summaryError = $state(null); + // Upload jobs for the selected meeting (Phase 9, T9.5). Fed by sync_status on + // select and kept live by sync://job events. + syncJobs = $state([]); + uploading = $state(false); + async init() { await this.loadTags(); await this.load(); @@ -53,6 +71,58 @@ class MeetingsStore { if (this.summarizingId === meetingId) this.summarizingId = null; if (this.selectedId === meetingId) await this.select(meetingId); }); + // Live upload progress for the open meeting (T9.5). + await events.onSyncJob((p) => { + if (p.meetingId !== this.selectedId) return; + const job: SyncJob = { + jobId: p.jobId, + artifact: p.artifact, + status: p.status, + bytesSent: p.bytesSent, + bytesTotal: p.bytesTotal, + attempts: p.attempts ?? 0, + error: p.error ?? null, + }; + const i = this.syncJobs.findIndex((j) => j.jobId === job.jobId); + if (i >= 0) this.syncJobs[i] = job; + else this.syncJobs = [...this.syncJobs, job]; + }); + } + + private static toSyncJob(info: SyncJobInfo): SyncJob { + return { + jobId: info.id, + artifact: info.artifact, + status: info.status, + bytesSent: info.bytes_sent, + bytesTotal: info.bytes_total, + attempts: info.attempts, + error: info.last_error, + }; + } + + async loadSyncJobs(id: MeetingId) { + try { + this.syncJobs = (await api.syncStatus(id)).map(MeetingsStore.toSyncJob); + } catch { + this.syncJobs = []; + } + } + + /** Manual "Upload now" — (re)enqueues and pumps; progress streams via events. */ + async uploadNow(id: MeetingId) { + this.uploading = true; + try { + await api.syncMeeting(id); + await this.loadSyncJobs(id); + } finally { + this.uploading = false; + } + } + + async retryJob(jobId: string, meetingId: MeetingId) { + await api.retrySyncJob(jobId); + await this.loadSyncJobs(meetingId); } async generateSummary(id: MeetingId, templateId?: string) { @@ -118,11 +188,13 @@ class MeetingsStore { async select(id: MeetingId) { this.selectedId = id; this.selected = await api.getMeeting(id); + await this.loadSyncJobs(id); } deselect() { this.selectedId = null; this.selected = null; + this.syncJobs = []; } async remove(id: MeetingId) { diff --git a/src/lib/stores/settings.svelte.ts b/src/lib/stores/settings.svelte.ts index 50a2a46..23efffd 100644 --- a/src/lib/stores/settings.svelte.ts +++ b/src/lib/stores/settings.svelte.ts @@ -37,6 +37,8 @@ class SettingsStore { loaded = $state(false); /** Set when the backend isn't wired yet, so the UI can show a "stub" hint. */ backendStub = $state(false); + /** Status line for the OAuth linking flow (Phase 9b). */ + linkMessage = $state(null); // Hardware + model management (Phase 3, T3.6/T3.7). hardware = $state(null); @@ -72,9 +74,40 @@ class SettingsStore { [id]: { received: receivedBytes, total: totalBytes }, }; }); + // OAuth link completion (Phase 9b) — the flow finishes in the background. + await events.onSyncLinked(async ({ ok, kind, error }) => { + if (ok) { + this.linkMessage = `${kind} linked.`; + await this.reloadTargets(); + await this.loadPrivacy(); + } else { + this.linkMessage = `Link failed: ${error ?? "unknown error"}`; + } + }); this.loaded = true; } + async reloadTargets() { + try { + this.targets = await api.listSyncTargets(); + } catch { + /* keep the current list on a transient failure */ + } + } + + /** Start OAuth linking for a secondary provider: open the auth URL; the flow + * completes via the `sync://linked` event handled in load(). */ + async linkOauth(kind: "onedrive" | "dropbox" | "box") { + this.linkMessage = "Opening browser…"; + try { + const { authUrl } = await api.beginOauthLink(kind); + window.open(authUrl, "_blank"); + this.linkMessage = "Finish sign-in in your browser…"; + } catch (e) { + this.linkMessage = `Link failed: ${e}`; + } + } + async loadHardware() { try { this.hardware = await api.hardwareStatus(); diff --git a/src/lib/views/Settings.svelte b/src/lib/views/Settings.svelte index 9017505..3ccf708 100644 --- a/src/lib/views/Settings.svelte +++ b/src/lib/views/Settings.svelte @@ -63,6 +63,58 @@ let showConsent = $state(false); let testResult = $state<{ ok: boolean; message: string } | null>(null); + // ---- At-rest encryption vault (T8.8, FR-SEC-3) ---- + let vault = $state<{ enabled: boolean; unlocked: boolean } | null>(null); + let vaultPw = $state(""); + let vaultPw2 = $state(""); + let vaultMsg = $state(null); + async function loadVault() { + try { + vault = await api.vaultStatus(); + } catch { + vault = null; + } + } + onMount(loadVault); + async function enableVault() { + vaultMsg = null; + try { + await api.enableVault(vaultPw); + vaultPw = ""; + vaultMsg = "Vault enabled and unlocked."; + await loadVault(); + } catch (e) { + vaultMsg = `${e}`; + } + } + async function unlockVault() { + vaultMsg = null; + try { + await api.unlockVault(vaultPw); + vaultPw = ""; + vaultMsg = "Unlocked."; + await loadVault(); + } catch (e) { + vaultMsg = `${e}`; + } + } + async function lockVault() { + await api.lockVault(); + vaultMsg = "Locked."; + await loadVault(); + } + async function changeVaultPassword() { + vaultMsg = null; + try { + await api.changeVaultPassword(vaultPw, vaultPw2); + vaultPw = ""; + vaultPw2 = ""; + vaultMsg = "Password changed."; + } catch (e) { + vaultMsg = `${e}`; + } + } + // ---- NPU package download (T3.4) ---- let npuBusy = $state(false); let npuMsg = $state(null); @@ -143,6 +195,7 @@ upload_recording: false, trigger_on_finalize: true, allow_plaintext_lan: false, + encrypt_before_upload: false, }; } function setKind(k: SyncKind) { @@ -159,8 +212,10 @@ testResult = null; } function linkOAuth() { - // begin_oauth_link → loopback PKCE flow (stubbed). Adds a placeholder target on success. - settings.addTarget({ ...form, name: form.name || form.kind }); + // begin_oauth_link → loopback PKCE flow; the target is created in the + // background on success (sync://linked), which the store handles. + if (form.kind === "webdav") return; + settings.linkOauth(form.kind); } @@ -516,6 +571,15 @@ > Allow plaintext http for a LAN address (not recommended) +
+ {#if settings.linkMessage} +

{settings.linkMessage}

+ {/if} {/if}

@@ -607,6 +674,56 @@ {:else}

Privacy self-check unavailable.

{/if} + + {#if vault} +

Encryption vault

+ {#if !vault.enabled} +

+ Encrypt notes, transcripts, and summaries at rest with a password. (Audio files are + not encrypted yet.) +

+
+ +
+ +

+ Use at least 8 characters. If you forget it, encrypted content can't be recovered. +

+ {:else if !vault.unlocked} +

+ Vault is locked. Unlock to read encrypted meetings. +

+
+ +
+ + {:else} +

+ Vault is unlocked. New notes, transcripts, and summaries are + encrypted at rest. +

+ +
+ Change password +
+ + +
+ +
+ {/if} + {#if vaultMsg}

{vaultMsg}

{/if} + {/if} {/if} diff --git a/src/lib/views/SummaryPanel.svelte b/src/lib/views/SummaryPanel.svelte index 9cc434f..ed87151 100644 --- a/src/lib/views/SummaryPanel.svelte +++ b/src/lib/views/SummaryPanel.svelte @@ -3,9 +3,19 @@ import { onMount } from "svelte"; import { meetings } from "../stores/meetings.svelte"; import { calendar } from "../stores/calendar.svelte"; + import { settings } from "../stores/settings.svelte"; import { api, type ActionItem, type CalendarEventDetail, type LlmStatus } from "../api"; import { renderMarkdown } from "../markdown"; - import { Tags, Sparkles, ListTodo, CalendarDays, Users, Mic2, Bell } from "@lucide/svelte"; + import { + Tags, + Sparkles, + ListTodo, + CalendarDays, + Users, + Mic2, + Bell, + UploadCloud, + } from "@lucide/svelte"; onMount(() => calendar.load()); @@ -132,6 +142,45 @@
+ {#if meetings.selected && settings.settings.sync_enabled} +

+ + {#if meetings.syncJobs.length} +
    + {#each meetings.syncJobs as job (job.jobId)} +
  • + {job.artifact} + {job.status} + {#if job.status === "uploading" && job.bytesTotal} + {Math.round((100 * job.bytesSent) / job.bytesTotal)}% + {/if} + {#if job.status === "failed"} + {#if job.error}⚠{/if} + + {/if} +
  • + {/each} +
+ {:else} +

No uploads yet for this meeting.

+ {/if} + {/if} +

{#if !meetings.selected}

Select a meeting to tag it.

@@ -472,4 +521,40 @@ font-size: 0.8rem; padding: 0; } + .upload-now { + align-self: start; + } + .jobs { + list-style: none; + padding: 0; + margin: 0.4rem 0 0; + display: flex; + flex-direction: column; + gap: 0.25rem; + } + .jobs li { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.8rem; + } + .artifact { + min-width: 5rem; + } + .job-status { + text-transform: capitalize; + color: var(--muted); + } + .job-status.done { + color: var(--success, #16a34a); + } + .job-status.uploading { + color: var(--accent, #2563eb); + } + .job-status.failed { + color: var(--danger, #dc2626); + } + .err { + cursor: help; + }