From 4e08be12e7b29fc2cfd29dc44b9f710727a13c43 Mon Sep 17 00:00:00 2001 From: u8array Date: Sat, 25 Jul 2026 17:04:36 +0200 Subject: [PATCH] feat(preview): proxy Labelary through Rust, tighten desktop CSP The webview no longer fetches previews (CSP drops http:/https:); the Rust proxy validates host/path and blocks redirects. The API key is a host-bound, rust-only keychain credential the proxy reads itself, so it never enters webview memory and can't be replayed against a foreign host. --- src-tauri/Cargo.lock | 155 +++++++++- src-tauri/Cargo.toml | 3 + src-tauri/THIRD-PARTY-LICENSES-RUST.md | 68 ++++- src-tauri/src/credentials.rs | 23 +- src-tauri/src/lib.rs | 4 + src-tauri/src/preview.rs | 277 ++++++++++++++++++ src-tauri/tauri.conf.json | 4 +- .../PrinterSettings/PreviewSettingsTab.tsx | 16 +- src/lib/credentialStore.ts | 20 ++ src/lib/labelary.test.ts | 8 +- src/lib/labelary.ts | 49 +++- src/main.tsx | 9 +- src/store/labelaryKey.test.ts | 154 ++++------ src/store/labelaryKey.web.test.ts | 83 ++++++ src/store/slices/previewSlice.ts | 7 +- src/store/slices/uiSlice.ts | 50 +++- 16 files changed, 799 insertions(+), 131 deletions(-) create mode 100644 src-tauri/src/preview.rs create mode 100644 src/store/labelaryKey.web.test.ts diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index f0fc5b84..b9b88fe2 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -514,6 +514,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + [[package]] name = "chacha20" version = "0.10.1" @@ -1478,8 +1484,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi", + "wasm-bindgen", ] [[package]] @@ -1501,9 +1509,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 6.0.0", "rand_core", + "wasm-bindgen", ] [[package]] @@ -1819,6 +1829,7 @@ dependencies = [ "tokio", "tokio-rustls", "tower-service", + "webpki-roots", ] [[package]] @@ -2312,6 +2323,12 @@ version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + [[package]] name = "mach2" version = "0.5.0" @@ -3049,6 +3066,62 @@ dependencies = [ "memchr", ] +[[package]] +name = "quinn" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560" +dependencies = [ + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.61.2", +] + [[package]] name = "quote" version = "1.0.46" @@ -3087,6 +3160,15 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core", +] + [[package]] name = "raw-window-handle" version = "0.6.2" @@ -3162,6 +3244,44 @@ version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", +] + [[package]] name = "reqwest" version = "0.13.4" @@ -3299,6 +3419,7 @@ version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046" dependencies = [ + "web-time", "zeroize", ] @@ -3346,6 +3467,12 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + [[package]] name = "same-file" version = "1.0.6" @@ -3581,6 +3708,18 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + [[package]] name = "serde_with" version = "3.21.0" @@ -4202,7 +4341,7 @@ dependencies = [ "percent-encoding", "plist", "raw-window-handle", - "reqwest", + "reqwest 0.13.4", "serde", "serde_json", "serde_repr", @@ -4391,7 +4530,7 @@ dependencies = [ "minisign-verify", "osakit", "percent-encoding", - "reqwest", + "reqwest 0.13.4", "rustls", "semver", "serde", @@ -5215,6 +5354,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "web_atoms" version = "0.2.5" @@ -6098,6 +6247,7 @@ dependencies = [ name = "zplab" version = "0.2.0" dependencies = [ + "base64 0.22.1", "calamine", "chrono", "dunce", @@ -6105,6 +6255,7 @@ dependencies = [ "libc", "nusb", "printers", + "reqwest 0.12.28", "serde", "serde_json", "sqlx", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index bcdf2516..5cd9611d 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -21,6 +21,9 @@ tauri = { version = "2.11.3", features = [] } serde_json = "1" serde = { version = "1", features = ["derive"] } tokio = { version = "1", features = ["net", "time", "io-util", "rt"] } +# Preview proxy only (redirects disabled there); rustls to match sqlx. +reqwest = { version = "0.12", default-features = false, features = ["rustls-tls"] } +base64 = "0.22" # Stream a query's rows (db.rs) instead of fetch_all so a huge result is bounded # by a byte budget and errors instead of OOMing. Already transitive via sqlx; # tokio-stream keeps us in the tokio ecosystem we already depend on directly. diff --git a/src-tauri/THIRD-PARTY-LICENSES-RUST.md b/src-tauri/THIRD-PARTY-LICENSES-RUST.md index 2d6acb14..3c6b84df 100644 --- a/src-tauri/THIRD-PARTY-LICENSES-RUST.md +++ b/src-tauri/THIRD-PARTY-LICENSES-RUST.md @@ -8,9 +8,9 @@ drifts from the locked dependencies. ## Overview -- MIT License (443) +- MIT License (445) - Unicode License v3 (19) -- Apache License 2.0 (6) +- Apache License 2.0 (7) - BSD 3-Clause "New" or "Revised" License (5) - Mozilla Public License 2.0 (5) - ISC License (3) @@ -883,6 +883,7 @@ Apache License Used by: +- [ryu 1.0.23](https://github.com/dtolnay/ryu) - [sync_wrapper 1.0.2](https://github.com/Actyx/sync_wrapper) ```` Apache License @@ -2365,6 +2366,40 @@ DEALINGS IN THE SOFTWARE. Used by: +- [serde_urlencoded 0.7.1](https://github.com/nox/serde_urlencoded) +```` +Copyright (c) 2016 Anthony Ramine + +Permission is hereby granted, free of charge, to any +person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the +Software without restriction, including without +limitation the rights to use, copy, modify, merge, +publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software +is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice +shall be included in all copies or substantial portions +of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF +ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED +TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT +SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY +CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR +IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. + +```` + +### MIT License + +Used by: + - [webkit2gtk-sys 2.0.2](https://github.com/tauri-apps/webkit2gtk-rs) ```` Copyright (c) 2016 Boucher, Antoni @@ -2659,6 +2694,35 @@ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +```` + +### MIT License + +Used by: + +- [reqwest 0.12.28](https://github.com/seanmonstar/reqwest) +```` +Copyright (c) 2016-2025 Sean McArthur + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. + + ```` ### MIT License diff --git a/src-tauri/src/credentials.rs b/src-tauri/src/credentials.rs index 6d9ac7c6..c84bd645 100644 --- a/src-tauri/src/credentials.rs +++ b/src-tauri/src/credentials.rs @@ -42,19 +42,34 @@ pub(crate) fn write_password(name: &str, value: &str) -> Result<(), CredError> { Ok(()) } +/// Rust-internal delete; a missing entry is the desired end state. Blocking. +pub(crate) fn delete_password(name: &str) -> Result<(), CredError> { + match entry(name)?.delete_credential() { + Ok(()) | Err(keyring::Error::NoEntry) => Ok(()), + Err(e) => Err(e.into()), + } +} + /// db-profile passwords flow keychain -> Rust connector only (db.rs /// `password_cred`): the webview may delete them but never read or write them /// over the generic IPC (writes go through the endpoint-binding `db_set_password`). pub(crate) const RUST_ONLY_PREFIX: &str = "db-profile-"; +/// Credential-name prefixes the generic IPC may never read or write: the +/// db-profile passwords and the host-bound Labelary key (`preview.rs`). Each +/// is written only through its endpoint-binding command. +const RUST_ONLY_PREFIXES: [&str; 2] = [RUST_ONLY_PREFIX, "preview-"]; + /// Windows Credential Manager matches target names case-insensitively, so the /// guard must too or `DB-PROFILE-x` slips past yet resolves the same secret. /// Allocation-free ASCII prefix compare. pub(crate) fn is_rust_only(name: &str) -> bool { - name - .as_bytes() - .get(..RUST_ONLY_PREFIX.len()) - .is_some_and(|p| p.eq_ignore_ascii_case(RUST_ONLY_PREFIX.as_bytes())) + RUST_ONLY_PREFIXES.iter().any(|prefix| { + name + .as_bytes() + .get(..prefix.len()) + .is_some_and(|p| p.eq_ignore_ascii_case(prefix.as_bytes())) + }) } #[tauri::command] diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 1c27087b..471ec234 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -3,6 +3,7 @@ mod dataset; mod db; mod excel; mod mcp; +mod preview; mod print; mod scope; mod transport; @@ -37,6 +38,9 @@ pub fn run() { scope::pick_sqlite_file, scope::pick_excel_file, scope::revoke_db_path, + preview::fetch_labelary_preview, + preview::preview_set_labelary_key, + preview::preview_migrate_labelary_key, credentials::credential_get, credentials::credential_set, credentials::credential_delete, diff --git a/src-tauri/src/preview.rs b/src-tauri/src/preview.rs new file mode 100644 index 00000000..acddb1ad --- /dev/null +++ b/src-tauri/src/preview.rs @@ -0,0 +1,277 @@ +//! Labelary preview proxy: the desktop webview never fetches previews itself +//! (CSP drops generic http:/https:), and the API key resolves keychain -> Rust +//! -> header, never crossing IPC or webview memory. + +use std::sync::OnceLock; +use std::time::Duration; + +use base64::Engine; + +use crate::credentials; +use crate::transport::{blocking, check_payload}; + +const TIMEOUT: Duration = Duration::from_secs(10); +/// Generous for label PNGs (a 4x6" 300dpi label is well under 1 MB). +const MAX_BYTES: usize = 10 * 1024 * 1024; +/// The public service; always reachable so the free tier needs no key. +const DEFAULT_HOST: &str = "https://api.labelary.com"; +/// Host-bound key blob (rust-only via the `preview-` prefix), stored as +/// `host\nkey`; the binding is enforced in bound_key_for. LEGACY_KEY_CRED is +/// the pre-binding location drained by the migration. +const KEY_CRED: &str = "preview-labelary-key"; +const LEGACY_KEY_CRED: &str = "labelary-api-key"; + +/// Same normalisation on the set and fetch paths so the host comparison can't +/// drift on a trailing slash or surrounding whitespace. +fn normalize_host(host: &str) -> String { + host.trim().trim_end_matches('/').to_ascii_lowercase() +} + +/// The bound key iff it belongs to `host`; None otherwise (unbound, or bound +/// to a different host the webview must not borrow the key for). +fn bound_key_for(host: &str) -> Result, credentials::CredError> { + let Some(blob) = credentials::read_password(KEY_CRED)? else { + return Ok(None); + }; + let (bound_host, key) = blob.split_once('\n').unwrap_or(("", "")); + Ok((bound_host == host && !key.is_empty()).then(|| key.to_string())) +} + +#[derive(serde::Serialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum PreviewFetchResult { + Png { base64: String }, + Api { status: u16 }, + Timeout, + Network, + TooLarge, +} + +/// Plain HTTP stays inside networks the user controls; everything else must +/// be TLS so the key/ZPL never transit an open network unencrypted. +fn http_host_allowed(host: &str) -> bool { + if host == "localhost" || host.ends_with(".localhost") { + return true; + } + match host.parse::() { + Ok(ip) => match ip { + std::net::IpAddr::V4(v4) => { + v4.is_loopback() || v4.is_private() || v4.is_link_local() + } + std::net::IpAddr::V6(v6) => v6.is_loopback(), + }, + Err(_) => false, + } +} + +/// Host allowlist for the proxy: the public default and LAN/loopback are always +/// fine; any other host needs a key the user bound to it (a deliberate keychain +/// write), so a compromised webview can't use the proxy as an open relay. +/// Residual: it can relay only by overwriting the user's real key, never read it. +fn host_allowed(host: &str, url_host: &str, has_bound_key: bool) -> bool { + has_bound_key || host == DEFAULT_HOST || http_host_allowed(url_host) +} + +/// The webview supplies host + path separately so the validated host can't be +/// smuggled inside a path; the path itself must be the labelary print route. +fn build_url(host: &str, path: &str) -> Result { + let re_ok = path.strip_prefix("/v1/printers/").is_some_and(|rest| { + let mut parts = rest.split('/'); + matches!( + (parts.next(), parts.next(), parts.next(), parts.next(), parts.next(), parts.next()), + (Some(printer), Some("labels"), Some(size), Some(index), Some(""), None) + if printer.ends_with("dpmm") + && printer.trim_end_matches("dpmm").chars().all(|c| c.is_ascii_digit()) + && size.chars().all(|c| c.is_ascii_digit() || c == '.' || c == 'x') + && index.chars().all(|c| c.is_ascii_digit()) + ) + }); + if !re_ok { + return Err(format!("not a labelary print path: {path}")); + } + let url = reqwest::Url::parse(&format!("{host}{path}")).map_err(|e| e.to_string())?; + match url.scheme() { + "https" => {} + "http" => { + let h = url.host_str().unwrap_or_default(); + if !http_host_allowed(h) { + return Err(format!("plain http is only allowed for local hosts, not {h}")); + } + } + s => return Err(format!("unsupported scheme: {s}")), + } + Ok(url) +} + +/// Shared HTTP client: reqwest pools connections internally, so it is built +/// once and reused. Rebuilt on the next call if the one-time TLS init ever +/// fails (no poisoning). +fn http_client() -> Result<&'static reqwest::Client, String> { + static CLIENT: OnceLock = OnceLock::new(); + if let Some(c) = CLIENT.get() { + return Ok(c); + } + let built = reqwest::Client::builder() + .redirect(reqwest::redirect::Policy::none()) + .timeout(TIMEOUT) + .build() + .map_err(|e| e.to_string())?; + Ok(CLIENT.get_or_init(|| built)) +} + +/// POST the ZPL and return the rendered PNG. Redirects are hard errors so the +/// bound key can't be replayed against a host the user didn't configure. +#[tauri::command] +pub async fn fetch_labelary_preview( + host: String, + path: String, + zpl: String, +) -> Result { + check_payload(zpl.len())?; + let host = normalize_host(&host); + let url = build_url(&host, &path)?; + + let api_key = blocking({ + let host = host.clone(); + move || bound_key_for(&host) + }) + .await + .map_err(|e| e.to_string())? + .map_err(|e| e.to_string())?; + + if !host_allowed(&host, url.host_str().unwrap_or_default(), api_key.is_some()) { + return Err(format!("host not allowed without a saved key: {host}")); + } + + let mut req = http_client()? + .post(url) + .header("Content-Type", "application/x-www-form-urlencoded") + .body(zpl); + if let Some(key) = api_key.as_deref().map(str::trim).filter(|k| !k.is_empty()) { + req = req.header("X-API-Key", key); + } + + let res = match req.send().await { + Ok(r) => r, + Err(e) if e.is_timeout() => return Ok(PreviewFetchResult::Timeout), + Err(_) => return Ok(PreviewFetchResult::Network), + }; + + let status = res.status(); + if status.is_redirection() { + return Ok(PreviewFetchResult::Network); + } + if !status.is_success() { + return Ok(PreviewFetchResult::Api { status: status.as_u16() }); + } + if res.content_length().is_some_and(|l| l > MAX_BYTES as u64) { + return Ok(PreviewFetchResult::TooLarge); + } + + let mut bytes: Vec = Vec::new(); + let mut stream = res; + loop { + match stream.chunk().await { + Ok(Some(chunk)) => { + if bytes.len() + chunk.len() > MAX_BYTES { + return Ok(PreviewFetchResult::TooLarge); + } + bytes.extend_from_slice(&chunk); + } + Ok(None) => break, + Err(e) if e.is_timeout() => return Ok(PreviewFetchResult::Timeout), + Err(_) => return Ok(PreviewFetchResult::Network), + } + } + + Ok(PreviewFetchResult::Png { + base64: base64::engine::general_purpose::STANDARD.encode(&bytes), + }) +} + +/// Store the Labelary key bound to `host` (rust-only). An empty key clears it. +#[tauri::command] +pub async fn preview_set_labelary_key(host: String, key: String) -> Result<(), String> { + let host = normalize_host(&host); + let key = key.trim().to_string(); + blocking(move || -> Result<(), credentials::CredError> { + if key.is_empty() { + credentials::delete_password(KEY_CRED) + } else { + credentials::write_password(KEY_CRED, &format!("{host}\n{key}")) + } + }) + .await + .map_err(|e| e.to_string())? + .map_err(|e| e.to_string()) +} + +/// One-time move of a legacy (unbound, IPC-readable) `labelary-api-key` into +/// the host-bound rust-only credential; idempotent once the legacy entry drains. +#[tauri::command] +pub async fn preview_migrate_labelary_key(host: String) -> Result<(), String> { + let host = normalize_host(&host); + blocking(move || -> Result<(), credentials::CredError> { + if let Some(key) = credentials::read_password(LEGACY_KEY_CRED)? { + let key = key.trim(); + if !key.is_empty() { + credentials::write_password(KEY_CRED, &format!("{host}\n{key}"))?; + } + credentials::delete_password(LEGACY_KEY_CRED)?; + } + Ok(()) + }) + .await + .map_err(|e| e.to_string())? + .map_err(|e| e.to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn accepts_the_labelary_print_path() { + assert!(build_url("https://api.labelary.com", "/v1/printers/8dpmm/labels/3.937x1.969/0/").is_ok()); + } + + #[test] + fn rejects_foreign_paths_and_schemes() { + assert!(build_url("https://api.labelary.com", "/v1/printers/8dpmm/labels/1x1/0/../../steal").is_err()); + assert!(build_url("https://api.labelary.com", "/anything").is_err()); + assert!(build_url("ftp://api.labelary.com", "/v1/printers/8dpmm/labels/1x1/0/").is_err()); + } + + #[test] + fn bound_key_splits_host_and_key() { + // Pure split logic (no keychain): the blob is `host\nkey`. + let blob = "https://api.labelary.com\nsecret-123"; + let (h, k) = blob.split_once('\n').unwrap(); + assert_eq!(h, "https://api.labelary.com"); + assert_eq!(k, "secret-123"); + } + + #[test] + fn normalize_host_trims_slash_space_and_case() { + assert_eq!(normalize_host(" https://API.Labelary.com/ "), "https://api.labelary.com"); + } + + #[test] + fn host_allowlist_default_lan_and_bound() { + assert!(host_allowed(DEFAULT_HOST, "api.labelary.com", false)); + assert!(host_allowed("http://192.168.1.5", "192.168.1.5", false)); + assert!(host_allowed("http://localhost:9090", "localhost", false)); + // Custom remote host only with a bound key; bare relay is refused. + assert!(!host_allowed("https://attacker.example", "attacker.example", false)); + assert!(host_allowed("https://custom.example.com", "custom.example.com", true)); + } + + #[test] + fn plain_http_is_local_only() { + assert!(build_url("http://127.0.0.1:8080", "/v1/printers/8dpmm/labels/1x1/0/").is_ok()); + assert!(build_url("http://192.168.1.20", "/v1/printers/8dpmm/labels/1x1/0/").is_ok()); + assert!(build_url("http://localhost:9090", "/v1/printers/8dpmm/labels/1x1/0/").is_ok()); + assert!(build_url("http://api.labelary.com", "/v1/printers/8dpmm/labels/1x1/0/").is_err()); + assert!(build_url("http://8.8.8.8", "/v1/printers/8dpmm/labels/1x1/0/").is_err()); + } +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 74524f6c..1f8c83c4 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -22,8 +22,8 @@ } ], "security": { - "csp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self'; connect-src 'self' ipc: http: https:; object-src 'none'; base-uri 'self'; frame-ancestors 'none'; form-action 'self'", - "devCsp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self'; connect-src 'self' ipc: http: https: ws://localhost:5173; object-src 'none'; base-uri 'self'; frame-ancestors 'none'; form-action 'self'" + "csp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self'; connect-src 'self' ipc: http://ipc.localhost http://127.0.0.1:*; object-src 'none'; base-uri 'self'; frame-ancestors 'none'; form-action 'self'", + "devCsp": "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self'; connect-src 'self' ipc: http://ipc.localhost http://127.0.0.1:* http://localhost:5173 ws://localhost:5173; object-src 'none'; base-uri 'self'; frame-ancestors 'none'; form-action 'self'" } }, "plugins": { diff --git a/src/components/PrinterSettings/PreviewSettingsTab.tsx b/src/components/PrinterSettings/PreviewSettingsTab.tsx index cb1766a7..6cf77192 100644 --- a/src/components/PrinterSettings/PreviewSettingsTab.tsx +++ b/src/components/PrinterSettings/PreviewSettingsTab.tsx @@ -107,19 +107,27 @@ export function PreviewSettingsTab() { // Retry the credential-store load on open (a startup hydrate may have // failed). Persist only via an explicit Save: a keychain write can raise an // OS unlock prompt, so it must be deliberate, not an incidental blur. + const migrateLabelaryKey = useLabelStore((s) => s.migrateLabelaryKey); useEffect(() => { - void hydrateLabelaryApiKey(); - }, [hydrateLabelaryApiKey]); + // Retry of the startup migration + hydrate; a keychain failure must not + // surface as an unhandled rejection or skip the hydrate. + void migrateLabelaryKey().catch(() => undefined).then(hydrateLabelaryApiKey); + }, [hydrateLabelaryApiKey, migrateLabelaryKey]); const [keyDraft, setKeyDraft] = useState(null); - const keyValue = keyDraft ?? storeKey; + // Desktop keeps the key in the keychain only (never hydrated into the store), + // so the field is write-only there: it shows the draft, never a stored value. + const keyValue = keyDraft ?? (isDesktopShell ? '' : storeKey); const [keySaveFailed, setKeySaveFailed] = useState(false); // A keychain write can raise an OS unlock prompt and take seconds; block a // second save (and its duplicate prompt) until this one settles. const [keySaving, setKeySaving] = useState(false); - const keyDirty = keyValue.trim() !== storeKey; + const keyDirty = isDesktopShell ? keyDraft !== null : keyValue.trim() !== storeKey; const saveKey = () => { setKeySaveFailed(false); setKeySaving(true); + // Bind the key to the host the user currently sees, not a still-unblurred + // host draft (setLabelaryHost is a no-op when unchanged). + persistHost(); saveLabelaryApiKey(keyValue) .then(() => setKeyDraft(null)) .catch(() => setKeySaveFailed(true)) diff --git a/src/lib/credentialStore.ts b/src/lib/credentialStore.ts index c7dc3e9a..24ab1d2f 100644 --- a/src/lib/credentialStore.ts +++ b/src/lib/credentialStore.ts @@ -66,6 +66,26 @@ export async function deleteCredential(name: string): Promise { localStorage.removeItem(LS_PREFIX + name); } +export const LABELARY_KEY_CRED = 'labelary-api-key'; + +/** Store the Labelary key bound to `host`. Desktop routes to the rust-only, + * host-bound keychain command so the key never re-enters the webview. The web + * build has no keychain/CSP threat model: it keeps the key in localStorage + * under the legacy name and is NOT host-scoped (host is ignored there). */ +export async function setLabelaryKeyBound(host: string, key: string): Promise { + if (isDesktopShell) { + await invoke('preview_set_labelary_key', { host, key: key.trim() }); + return; + } + await setCredential(LABELARY_KEY_CRED, key); +} + +/** Desktop one-time move of the legacy unbound key into the host-bound + * credential; no-op on web (its key already lives under the legacy name). */ +export async function migrateLabelaryKeyBinding(host: string): Promise { + if (isDesktopShell) await invoke('preview_migrate_labelary_key', { host }); +} + /** API-key semantics: trim, and an empty/whitespace value deletes. */ export async function setCredential(name: string, value: string): Promise { const trimmed = value.trim(); diff --git a/src/lib/labelary.test.ts b/src/lib/labelary.test.ts index 0b4815fe..79ee849e 100644 --- a/src/lib/labelary.test.ts +++ b/src/lib/labelary.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, afterEach, vi } from "vitest"; -import { resolveHost, resolveApiKey, isDefaultHost, fetchPreview } from "./labelary"; +import { resolveHost, resolveApiKey, isDefaultHost, fetchPreview, labelaryPath } from "./labelary"; const LABEL = { dpmm: 8, widthMm: 101.6, heightMm: 50.8 } as const; const DEFAULT_HOST = "https://api.labelary.com"; @@ -53,6 +53,12 @@ describe("resolveApiKey", () => { }); }); +describe("labelaryPath", () => { + it("builds the printer/size print route the proxy validates against", () => { + expect(labelaryPath({ ...LABEL } as never)).toBe("/v1/printers/8dpmm/labels/4.000x2.000/0/"); + }); +}); + describe("fetchPreview headers", () => { function mockFetch() { const fn = vi.fn<(url: string, init?: RequestInit) => Promise>( diff --git a/src/lib/labelary.ts b/src/lib/labelary.ts index acd0aba5..7b921af8 100644 --- a/src/lib/labelary.ts +++ b/src/lib/labelary.ts @@ -1,4 +1,5 @@ import type { LabelConfig } from '@zplab/core/types/LabelConfig'; +import { isDesktopShell } from './platform'; const TIMEOUT_MS = 10_000; const DEFAULT_HOST = 'https://api.labelary.com'; @@ -41,16 +42,56 @@ class LabelaryError extends Error { } } +/** The labelary print route for `label`; the desktop proxy validates it + * against exactly this shape before joining it to the checked host. */ +export function labelaryPath(label: LabelConfig): string { + const { dpmm, widthMm, heightMm } = label; + const widthIn = (widthMm / 25.4).toFixed(3); + const heightIn = (heightMm / 25.4).toFixed(3); + return `/v1/printers/${dpmm}dpmm/labels/${widthIn}x${heightIn}/0/`; +} + +type PreviewProxyResult = + | { kind: 'png'; base64: string } + | { kind: 'api'; status: number } + | { kind: 'timeout' } + | { kind: 'network' } + | { kind: 'too_large' }; + +/** Desktop: the Rust proxy fetches (CSP blocks webview http/https) and + * resolves the API key from the keychain, so it never enters the webview. */ +async function fetchPreviewDesktop(zpl: string, label: LabelConfig, host: string): Promise { + const { invoke } = await import('@tauri-apps/api/core'); + let r: PreviewProxyResult; + try { + r = await invoke('fetch_labelary_preview', { + host, + path: labelaryPath(label), + zpl, + }); + } catch (e) { + throw new LabelaryError('network', String(e)); + } + switch (r.kind) { + case 'png': + return `data:image/png;base64,${r.base64}`; + case 'api': + throw new LabelaryError('api', `Labelary API error: ${r.status}`); + case 'timeout': + throw new LabelaryError('timeout', 'Request timed out.'); + default: + throw new LabelaryError('network', 'Could not reach the Labelary API.'); + } +} + export async function fetchPreview( zpl: string, label: LabelConfig, host: string, apiKey?: string, ): Promise { - const { dpmm, widthMm, heightMm } = label; - const widthIn = (widthMm / 25.4).toFixed(3); - const heightIn = (heightMm / 25.4).toFixed(3); - const url = `${host}/v1/printers/${dpmm}dpmm/labels/${widthIn}x${heightIn}/0/`; + if (isDesktopShell) return fetchPreviewDesktop(zpl, label, resolveHost(host)); + const url = `${host}${labelaryPath(label)}`; const headers: Record = { 'Content-Type': 'application/x-www-form-urlencoded' }; if (apiKey) headers['X-API-Key'] = apiKey; diff --git a/src/main.tsx b/src/main.tsx index c3c5ed7d..d38184a2 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -33,13 +33,16 @@ async function bootstrap() { locale, applyLocale, hydrateLabelaryApiKey, + migrateLabelaryKey, ensureMcpToken, mcpServerEnabled, mcpServerPort, } = useLabelStore.getState(); - // Load the API key from the OS credential store into memory before any - // preview can fire; fire-and-forget so a slow keychain never delays paint. - void hydrateLabelaryApiKey(); + // Migrate a legacy unbound key into the host-bound rust-only credential (so + // an existing key doesn't stay IPC-readable), then load it. Startup, not + // settings-open, so a user who never visits Preview settings is still + // covered. Fire-and-forget; a keychain failure must not delay paint or throw. + void migrateLabelaryKey().catch(() => undefined).then(hydrateLabelaryApiKey); // Stamp the build's sidecar capability so the settings rail and MCP tab can // read it synchronously; fire-and-forget like the key hydration above. void mcpServerStatus() diff --git a/src/store/labelaryKey.test.ts b/src/store/labelaryKey.test.ts index c7bd8dbe..444f5c27 100644 --- a/src/store/labelaryKey.test.ts +++ b/src/store/labelaryKey.test.ts @@ -1,141 +1,109 @@ -import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; +import { describe, it, expect, beforeEach, vi } from "vitest"; -const getCredential = vi.fn<(name: string) => Promise>(); -const setCredential = vi.fn<(name: string, value: string) => Promise>(); -// Mock one level deeper (the Tauri invoke seam) so the real credentialStore -// module, including makeCredentialHydrator, runs in the test. +// Desktop contract: the key is host-bound in the keychain and never enters the +// webview. Mock the Tauri invoke seam so the real credentialStore/uiSlice runs. +const invoked = vi.fn<(cmd: string, args: Record) => Promise>(); vi.mock("@tauri-apps/api/core", () => ({ - invoke: (cmd: string, args: { name: string; value?: string }) => { - if (cmd === "credential_get") return getCredential(args.name); - if (cmd === "credential_set") return setCredential(args.name, args.value ?? ""); - if (cmd === "credential_delete") return setCredential(args.name, ""); - return Promise.reject(new Error(`unmocked command: ${cmd}`)); - }, + invoke: (cmd: string, args: Record) => invoked(cmd, args), })); - -// Pretend we're the desktop shell so a persisted 'printer' provider stays -// 'printer' (the web build degrades it to labelary), exercising the guard that -// a Labelary endpoint change must not tear down a printer render. vi.mock("../lib/platform", () => ({ isDesktopShell: true })); import { useLabelStore } from "./labelStore"; beforeEach(() => { - getCredential.mockReset(); - setCredential.mockReset(); - setCredential.mockResolvedValue(); + invoked.mockReset(); + invoked.mockResolvedValue(undefined); useLabelStore.setState({ labelaryApiKey: "", labelaryApiKeyLoaded: false, + labelaryKeyEpoch: 0, labelaryHost: "", previewMode: { status: "idle" }, }); }); -afterEach(() => vi.unstubAllEnvs()); -describe("labelary key hydration", () => { - it("loads the stored key into the store once", async () => { - getCredential.mockResolvedValue("stored-key"); - await useLabelStore.getState().hydrateLabelaryApiKey(); - expect(useLabelStore.getState().labelaryApiKey).toBe("stored-key"); +describe("desktop preview awaits migration before the first keyed fetch", () => { + it("enterPreviewMode migrates the legacy key before hydrating", async () => { + useLabelStore.setState({ labelaryApiKeyLoaded: false, previewProvider: "labelary", label: { widthMm: 100, heightMm: 50, dpmm: 8 } } as never); + await useLabelStore.getState().enterPreviewMode().catch(() => undefined); + expect(invoked).toHaveBeenCalledWith("preview_migrate_labelary_key", expect.anything()); expect(useLabelStore.getState().labelaryApiKeyLoaded).toBe(true); }); +}); - it("treats an absent credential as an empty key", async () => { - getCredential.mockResolvedValue(null); +describe("desktop labelary key hydration", () => { + it("marks loaded without reading the keychain (key stays out of the webview)", async () => { await useLabelStore.getState().hydrateLabelaryApiKey(); - expect(useLabelStore.getState().labelaryApiKey).toBe(""); expect(useLabelStore.getState().labelaryApiKeyLoaded).toBe(true); + expect(useLabelStore.getState().labelaryApiKey).toBe(""); + expect(invoked).not.toHaveBeenCalledWith("credential_get", expect.anything()); }); +}); - it("stays unloaded on a read failure so a later open retries", async () => { - getCredential.mockRejectedValueOnce(new Error("no daemon")); - await useLabelStore.getState().hydrateLabelaryApiKey(); - expect(useLabelStore.getState().labelaryApiKeyLoaded).toBe(false); - getCredential.mockResolvedValue("recovered"); - await useLabelStore.getState().hydrateLabelaryApiKey(); - expect(useLabelStore.getState().labelaryApiKey).toBe("recovered"); - }); - - it("skips the read once loaded (no redundant IPC)", async () => { - getCredential.mockResolvedValue("k"); - await useLabelStore.getState().hydrateLabelaryApiKey(); - await useLabelStore.getState().hydrateLabelaryApiKey(); - expect(getCredential).toHaveBeenCalledTimes(1); +describe("desktop labelary key save", () => { + it("binds the key to the resolved host via the rust-only command, never the store", async () => { + useLabelStore.setState({ labelaryHost: "https://onprem.example.com/" }); + await useLabelStore.getState().saveLabelaryApiKey(" secret-123 "); + expect(invoked).toHaveBeenCalledWith("preview_set_labelary_key", { + host: "https://onprem.example.com", + key: "secret-123", + }); + // The key must not be mirrored into the webview store on desktop. + expect(useLabelStore.getState().labelaryApiKey).toBe(""); + expect(invoked).not.toHaveBeenCalledWith("credential_set", expect.anything()); }); - it("dedupes concurrent hydrations into a single read", async () => { - let resolveRead!: (v: string | null) => void; - getCredential.mockReturnValue(new Promise((r) => { resolveRead = r; })); - const a = useLabelStore.getState().hydrateLabelaryApiKey(); - const b = useLabelStore.getState().hydrateLabelaryApiKey(); - resolveRead("k"); - await Promise.all([a, b]); - expect(getCredential).toHaveBeenCalledTimes(1); - expect(useLabelStore.getState().labelaryApiKey).toBe("k"); + it("bumps the key epoch so the preview cache invalidates on a key change", async () => { + const before = useLabelStore.getState().labelaryKeyEpoch; + await useLabelStore.getState().saveLabelaryApiKey("k"); + expect(useLabelStore.getState().labelaryKeyEpoch).toBe(before + 1); }); - it("trims a stored value with surrounding whitespace", async () => { - getCredential.mockResolvedValue(" spaced "); - await useLabelStore.getState().hydrateLabelaryApiKey(); - expect(useLabelStore.getState().labelaryApiKey).toBe("spaced"); + it("propagates a keychain failure without bumping the epoch", async () => { + invoked.mockRejectedValueOnce(new Error("locked")); + await expect(useLabelStore.getState().saveLabelaryApiKey("k")).rejects.toThrow("locked"); + expect(useLabelStore.getState().labelaryKeyEpoch).toBe(0); }); -}); -describe("labelary endpoint change tears down a live preview", () => { - it("saving a key exits an active preview", async () => { + it("exits an active labelary preview after a save", async () => { useLabelStore.setState({ previewMode: { status: "active", url: "blob:x" } }); await useLabelStore.getState().saveLabelaryApiKey("k"); expect(useLabelStore.getState().previewMode.status).toBe("idle"); }); - it("changing the host exits an active preview", () => { - useLabelStore.setState({ previewMode: { status: "active", url: "blob:x" }, labelaryHost: "" }); - useLabelStore.getState().setLabelaryHost("https://onprem.example.com"); - expect(useLabelStore.getState().previewMode.status).toBe("idle"); - }); - - it("a no-op host blur leaves the preview alone", () => { - useLabelStore.setState({ previewMode: { status: "active", url: "blob:x" }, labelaryHost: "https://h" }); - useLabelStore.getState().setLabelaryHost("https://h"); - expect(useLabelStore.getState().previewMode.status).toBe("active"); - }); - - it("keeps a printer preview when the labelary endpoint changes", async () => { + it("keeps a printer preview when the labelary key changes", async () => { useLabelStore.setState({ previewProvider: "printer", previewMode: { status: "active", url: "blob:x" }, }); await useLabelStore.getState().saveLabelaryApiKey("k"); - useLabelStore.getState().setLabelaryHost("https://onprem.example.com"); expect(useLabelStore.getState().previewMode.status).toBe("active"); }); -}); -describe("labelary key save", () => { - it("persists to the credential store and mirrors in memory, trimmed", async () => { - await useLabelStore.getState().saveLabelaryApiKey(" abc "); - expect(setCredential).toHaveBeenCalledWith("labelary-api-key", "abc"); - expect(useLabelStore.getState().labelaryApiKey).toBe("abc"); - expect(useLabelStore.getState().labelaryApiKeyLoaded).toBe(true); + it("changing the host exits an active labelary preview", () => { + useLabelStore.setState({ previewProvider: "labelary", previewMode: { status: "active", url: "blob:x" }, labelaryHost: "" }); + useLabelStore.getState().setLabelaryHost("https://onprem.example.com"); + expect(useLabelStore.getState().previewMode.status).toBe("idle"); }); - it("propagates a credential-store failure without touching the cache", async () => { - setCredential.mockRejectedValueOnce(new Error("locked")); - await expect(useLabelStore.getState().saveLabelaryApiKey("abc")).rejects.toThrow("locked"); - expect(useLabelStore.getState().labelaryApiKey).toBe(""); + it("a no-op host blur leaves the preview alone", () => { + useLabelStore.setState({ previewProvider: "labelary", previewMode: { status: "active", url: "blob:x" }, labelaryHost: "https://h" }); + useLabelStore.getState().setLabelaryHost("https://h"); + expect(useLabelStore.getState().previewMode.status).toBe("active"); + }); +}); + +describe("desktop legacy-key migration", () => { + it("moves the legacy key into the host-bound credential via the command", async () => { + useLabelStore.setState({ labelaryHost: "https://api.labelary.com" }); + await useLabelStore.getState().migrateLabelaryKey(); + expect(invoked).toHaveBeenCalledWith("preview_migrate_labelary_key", { + host: "https://api.labelary.com", + }); }); - it("a save during an in-flight hydrate is not clobbered by the late read", async () => { - let resolveRead!: (v: string | null) => void; - getCredential.mockReturnValue(new Promise((r) => { resolveRead = r; })); - // Start hydrate (read pending), then save before it resolves. - const hydrating = useLabelStore.getState().hydrateLabelaryApiKey(); - await useLabelStore.getState().saveLabelaryApiKey("newkey"); - expect(useLabelStore.getState().labelaryApiKey).toBe("newkey"); - // The stale read now resolves; it must not overwrite the saved key. - resolveRead("oldkey"); - await hydrating; - expect(useLabelStore.getState().labelaryApiKey).toBe("newkey"); + it("rejects (surfacing the keychain error) so the caller can guard it", async () => { + invoked.mockRejectedValueOnce(new Error("locked")); + await expect(useLabelStore.getState().migrateLabelaryKey()).rejects.toThrow("locked"); }); }); diff --git a/src/store/labelaryKey.web.test.ts b/src/store/labelaryKey.web.test.ts new file mode 100644 index 00000000..bb392eb7 --- /dev/null +++ b/src/store/labelaryKey.web.test.ts @@ -0,0 +1,83 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; + +// Web contract: no keychain, no CSP; the key lives in localStorage and the +// webview fetch attaches it, so hydration + in-store mirroring still apply. +const getCredential = vi.fn<(name: string) => Promise>(); +const setCredential = vi.fn<(name: string, value: string) => Promise>(); +vi.mock("@tauri-apps/api/core", () => ({ + invoke: (cmd: string, args: { name: string; value?: string }) => { + if (cmd === "credential_get") return getCredential(args.name); + if (cmd === "credential_set") return setCredential(args.name, args.value ?? ""); + if (cmd === "credential_delete") return setCredential(args.name, ""); + return Promise.reject(new Error(`unmocked command: ${cmd}`)); + }, +})); +vi.mock("../lib/platform", () => ({ isDesktopShell: false })); + +import { useLabelStore } from "./labelStore"; + +beforeEach(() => { + getCredential.mockReset(); + setCredential.mockReset(); + setCredential.mockResolvedValue(); + localStorage.clear(); + useLabelStore.setState({ + labelaryApiKey: "", + labelaryApiKeyLoaded: false, + labelaryKeyEpoch: 0, + labelaryHost: "", + previewMode: { status: "idle" }, + }); +}); + +describe("web labelary key hydration", () => { + it("loads the stored key into the store once", async () => { + localStorage.setItem("zpl-cred-labelary-api-key", "stored-key"); + await useLabelStore.getState().hydrateLabelaryApiKey(); + expect(useLabelStore.getState().labelaryApiKey).toBe("stored-key"); + expect(useLabelStore.getState().labelaryApiKeyLoaded).toBe(true); + }); + + it("treats an absent credential as an empty key", async () => { + await useLabelStore.getState().hydrateLabelaryApiKey(); + expect(useLabelStore.getState().labelaryApiKey).toBe(""); + expect(useLabelStore.getState().labelaryApiKeyLoaded).toBe(true); + }); + + it("skips the read once loaded (a later store change is not picked up)", async () => { + localStorage.setItem("zpl-cred-labelary-api-key", "k"); + await useLabelStore.getState().hydrateLabelaryApiKey(); + // A second hydrate must not re-read: change the backing value and confirm + // the store keeps the first-loaded key. + localStorage.setItem("zpl-cred-labelary-api-key", "changed"); + await useLabelStore.getState().hydrateLabelaryApiKey(); + expect(useLabelStore.getState().labelaryApiKey).toBe("k"); + }); + + it("trims a stored value with surrounding whitespace", async () => { + localStorage.setItem("zpl-cred-labelary-api-key", " spaced "); + await useLabelStore.getState().hydrateLabelaryApiKey(); + expect(useLabelStore.getState().labelaryApiKey).toBe("spaced"); + }); +}); + +describe("web labelary key save", () => { + it("persists to localStorage and mirrors in memory, trimmed", async () => { + await useLabelStore.getState().saveLabelaryApiKey(" abc "); + expect(localStorage.getItem("zpl-cred-labelary-api-key")).toBe("abc"); + expect(useLabelStore.getState().labelaryApiKey).toBe("abc"); + expect(useLabelStore.getState().labelaryApiKeyLoaded).toBe(true); + }); + + it("bumps the key epoch so the preview cache invalidates", async () => { + const before = useLabelStore.getState().labelaryKeyEpoch; + await useLabelStore.getState().saveLabelaryApiKey("k"); + expect(useLabelStore.getState().labelaryKeyEpoch).toBe(before + 1); + }); + + it("migrate is a no-op on web", async () => { + await useLabelStore.getState().migrateLabelaryKey(); + // No throw, no invoke; the web key already lives under the legacy name. + expect(useLabelStore.getState().labelaryApiKey).toBe(""); + }); +}); diff --git a/src/store/slices/previewSlice.ts b/src/store/slices/previewSlice.ts index cc195d09..098120ce 100644 --- a/src/store/slices/previewSlice.ts +++ b/src/store/slices/previewSlice.ts @@ -12,6 +12,7 @@ import { buildActiveRow } from '@zplab/core/lib/variableBinding'; import { buildPreviewZpl } from '../../lib/printPreview'; import { currentObjects, selectEffectivePreviewProvider, selectLabelaryEndpoint } from '../labelStore.selectors'; import type { LabelState } from '../labelStore'; +import { isDesktopShell } from '../../lib/platform'; /** A finished render. `printerDims` (printer provider only; Labelary already * fits the label) drives the overlay's crop and mismatch hatching. */ @@ -88,7 +89,11 @@ export const createPreviewSlice: StateCreator // await it once (gated on the loaded flag) so the request isn't sent keyless. // Placed before the snapshot+guard so the captured design can't go stale and // a concurrent enter can't slip past the status guard. + // The first preview after an upgrade must not race the fire-and-forget + // startup migration, or the still-legacy key stays unbound and the fetch + // goes keyless; await the migration once here too. if (selectEffectivePreviewProvider(get()) === 'labelary' && !get().labelaryApiKeyLoaded) { + if (isDesktopShell) await get().migrateLabelaryKey().catch(() => undefined); await get().hydrateLabelaryApiKey(); } const state = get(); @@ -125,7 +130,7 @@ export const createPreviewSlice: StateCreator }; const cacheKey = printerTarget ? printerKey(printerTarget) - : [provider, endpoint.host, endpoint.apiKey ?? '', zpl].join('\0'); + : [provider, endpoint.host, endpoint.apiKey ?? '', String(state.labelaryKeyEpoch), zpl].join('\0'); if (serveCached(cacheKey)) return; set({ previewMode: { status: 'loading' } }); // Stale-request guard: status check catches an exit mid-fetch; the diff --git a/src/store/slices/uiSlice.ts b/src/store/slices/uiSlice.ts index cef03b9d..f91bc6be 100644 --- a/src/store/slices/uiSlice.ts +++ b/src/store/slices/uiSlice.ts @@ -9,13 +9,14 @@ import { } from '../labelStore.internals'; import type { LabelState } from '../labelStore'; import { defaultPaletteRows } from '../../registry/paletteTypes'; -import { makeCredentialHydrator, setCredential } from '../../lib/credentialStore'; +import { makeCredentialHydrator, setCredential, setLabelaryKeyBound, migrateLabelaryKeyBinding, LABELARY_KEY_CRED } from '../../lib/credentialStore'; +import { isDesktopShell } from '../../lib/platform'; +import { resolveHost } from '../../lib/labelary'; import { generateMcpToken, stopMcpServer } from '../../lib/mcpServer'; import { selectEffectivePreviewProvider } from '../labelStore.selectors'; import { newId } from "@zplab/core/lib/ids"; /** Credential-store account name for the Labelary API key. */ -const LABELARY_KEY_CRED = 'labelary-api-key'; /** Credential-store account name for the MCP loopback bearer token. */ const MCP_TOKEN_CRED = 'mcp-server-token'; @@ -128,6 +129,9 @@ export interface UiSlice { * either the hydrate or a save has set the key, a late-resolving hydrate is * a no-op so it can't clobber a freshly saved key. Transient. */ labelaryApiKeyLoaded: boolean; + /** Bumped on every key save so the preview cache invalidates even on desktop, + * where the key lives only in the keychain and never in this store. */ + labelaryKeyEpoch: number; previewProvider: PreviewProvider; canvasSettings: CanvasSettings; /** Curated object-palette rows ({type, variant} instances, duplicates @@ -199,6 +203,8 @@ export interface UiSlice { /** Load the key from the credential store once at startup. Idempotent and * race-safe: skips if a save already populated the key. */ hydrateLabelaryApiKey: () => Promise; + /** Desktop one-time legacy-key migration; see migrateLabelaryKeyBinding. */ + migrateLabelaryKey: () => Promise; acknowledgeLabelaryNotice: () => void; revokeLabelaryNotice: () => void; /** Reset app preferences (theme, canvas, palette, power-user, Labelary @@ -309,6 +315,7 @@ export const createUiSlice: StateCreator = (set, ge labelaryHost: '', labelaryApiKey: '', labelaryApiKeyLoaded: false, + labelaryKeyEpoch: 0, // Keychain-held like the Labelary key; a settings reset keeps it. mcpServerToken: '', mcpServerTokenLoaded: false, @@ -360,21 +367,34 @@ export const createUiSlice: StateCreator = (set, ge }, saveLabelaryApiKey: async (key) => { const trimmed = key.trim(); - // Throws on an unavailable store; caller surfaces it. Mark loaded so a - // still-pending startup hydrate can't overwrite the value we just set. - await setCredential(LABELARY_KEY_CRED, trimmed); - set({ labelaryApiKey: trimmed, labelaryApiKeyLoaded: true }); + const bumpEpoch = () => set((s) => ({ labelaryKeyEpoch: s.labelaryKeyEpoch + 1 })); + // Throws on an unavailable store; caller surfaces it. + if (isDesktopShell) { + // Desktop never mirrors the key into this store; the proxy reads it from + // the keychain (see preview.rs). + await setLabelaryKeyBound(resolveHost(get().labelaryHost), trimmed); + } else { + await setCredential(LABELARY_KEY_CRED, trimmed); + set({ labelaryApiKey: trimmed, labelaryApiKeyLoaded: true }); + } + bumpEpoch(); if (selectEffectivePreviewProvider(get()) === 'labelary') get().exitPreviewMode(); }, - // Losing this key is harmless (the user re-enters it), so there is no - // fallback: an unreadable store stays unloaded and a later open retries. - hydrateLabelaryApiKey: makeCredentialHydrator({ - credName: LABELARY_KEY_CRED, - isLoaded: () => get().labelaryApiKeyLoaded, - onStored: (key) => set({ labelaryApiKey: key.trim(), labelaryApiKeyLoaded: true }), - onEmpty: () => set({ labelaryApiKey: '', labelaryApiKeyLoaded: true }), - onError: () => undefined, - }), + // Desktop keeps the key out of the webview entirely (the proxy reads it from + // the keychain), so hydrate only marks loaded there; the web build reads it. + // Losing the key is harmless (the user re-enters it), so there is no fallback. + hydrateLabelaryApiKey: isDesktopShell + ? async () => set({ labelaryApiKeyLoaded: true }) + : makeCredentialHydrator({ + credName: LABELARY_KEY_CRED, + isLoaded: () => get().labelaryApiKeyLoaded, + onStored: (key) => set({ labelaryApiKey: key.trim(), labelaryApiKeyLoaded: true }), + onEmpty: () => set({ labelaryApiKey: '', labelaryApiKeyLoaded: true }), + onError: () => undefined, + }), + migrateLabelaryKey: async () => { + if (isDesktopShell) await migrateLabelaryKeyBinding(resolveHost(get().labelaryHost)); + }, acknowledgeLabelaryNotice: () => set({ labelaryNoticeAcknowledged: true }), // Revoke consent so the Labelary gate closes again; re-enabling re-shows the // disclosure, keeping consent explicit and reversible. Tear down any live