From 712d564e9d466c61c1772a9a4c82c9a58bb38282 Mon Sep 17 00:00:00 2001 From: Huang Xin Date: Fri, 8 May 2026 19:22:49 +0800 Subject: [PATCH] feat(sync): encrypted OPDS credentials + Tauri keychain (PR 4c + 4d) (#4090) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(sync): encrypt opds_catalog credentials end-to-end (TS path) Wires encrypted-credential sync for opds_catalog via the CryptoSession shipped in PR 4a (#4084) plus a new publish/pull crypto middleware. TS-only — native still uses ephemeral storage (re-enter passphrase per launch); PR 4d wires the OS keychain. - ReplicaAdapter gains optional `encryptedFields: readonly string[]`. Adapters stay sync; the middleware handles the crypto round trip. - replicaCryptoMiddleware.ts: encryptPackedFields drops the named fields from the push when the session is locked (no plaintext leak); decryptRowFields drops them on pull failure (local plaintext preserved by the store merge). - replicaPublish / replicaPullAndApply invoke the middleware. - OPDS adapter declares encryptedFields = [username, password] and now pack/unpack them as plaintext. - passphraseGate.ts: ensurePassphraseUnlocked coalesces concurrent calls, prompts via the registered prompter with kind=setup|unlock, throws NO_PASSPHRASE on cancel. - PassphrasePromptModal mounted at the Providers root; registers itself as the gate prompter. - CryptoSession.forget() wipes server-side envelopes + salts. - Migration 010 + replica_keys_forget RPC; DELETE /api/sync/replica-keys + client wrapper. - SyncPassphraseSection on the user page: status / Set / Unlock / Lock / Forgot. - CatalogManager pre-save: ensurePassphraseUnlocked when credentials are present; user cancel saves locally without sync. Plan updated: PR 4 split documented as 4a/4b/4c/4d. Co-Authored-By: Claude Opus 4.7 (1M context) * feat(sync): persist sync passphrase via OS keychain (Tauri) Replaces the EphemeralPassphraseStore stub on native with real OS-keychain storage so users don't re-enter their sync passphrase every launch. Web stays on the in-memory ephemeral store by design. Native bridge plugin gains 4 commands wired across all platforms: - Rust desktop (`keyring` crate): macOS Keychain on apple-native, Windows Credential Manager on windows-native, Linux libsecret/ Secret Service on sync-secret-service. Per-target features so each platform compiles only the backend it needs. - iOS Swift: Security framework Keychain (kSecClassGenericPassword, SecItemAdd / Copy / Delete). - Android Kotlin: androidx.security EncryptedSharedPreferences (AndroidKeystore-derived AES-GCM master key, AES256_SIV / AES256_GCM key/value encryption). TS layer: - TauriPassphraseStore wraps the bridge calls. set is fail-loud (surfaces keychain rejection); get is fail-soft (returns null on any error so the gate prompts). - createPassphraseStore returns ephemeral synchronously; upgradeToKeychainIfAvailable swaps the singleton to TauriPassphraseStore on Tauri after probing the bridge. CryptoSession resolves the store via createPassphraseStore() each touch so the swap is transparent. - CryptoSession.tryRestoreFromStore: silent unlock at boot. Stale- entry recovery clears the store when the account has no salt server-side. unlock/setup persist; forget also clears the store. - Providers boot effect: upgrade keychain → silent restore. Co-Authored-By: Claude Opus 4.7 (1M context) * fix(sync): make encrypted-credential pull actually decrypt + UX polish PR 4c shipped the encrypt path but the pull side silently dropped ciphers when locked, the modal was busy with double-rings, and web re-prompted on every page refresh. This rolls up the post-test fixes + UX polish: Pull-side decrypt: - decryptRowFields takes an `onLocked` callback the orchestrator wires to the passphrase gate; encountering a cipher field with a locked session now triggers the lazy-prompt path instead of dropping the field. - replicaPullAndApply re-applies the unpacked row for metadata-only kinds even when a local copy exists, so the now-decrypted creds reach the store (the binary-kind skip-if-local optimization doesn't apply). - Cipher fingerprint comparison: capture the row's `cipher.c` for each encrypted field, compare against the local record's lastSeenCipher. Same → skip prompt + decrypt entirely. Different (rotation / value change on another device) → prompt to re-decrypt. Fingerprint persists via OPDSCatalog.lastSeenCipher. Web persistence: - SessionStoragePassphraseStore: passphrase survives page refresh within the same tab, dies on tab close. Replaces EphemeralPassphraseStore as the default on web. Avoids localStorage / IndexedDB to keep the tab-scoped trust boundary. UI: - Renamed PassphrasePromptModal → PassphrasePrompt; modernized: filled input style with single subtle focus border, btn-primary + btn-ghost replaced with leaner custom buttons. eink-bordered + btn-primary classes give the dialog correct e-paper rendering. - globals.css: suppress redundant outline/box-shadow on focused text inputs / textareas (the element's own border is the focus indicator). - AGENTS.md: documents the e-ink convention (`eink-bordered`, `btn-primary` for inverted CTAs, etc.) so future widgets ship with e-paper support. Co-Authored-By: Claude Opus 4.7 (1M context) --------- Co-authored-by: Claude Opus 4.7 (1M context) --- Cargo.lock | 117 +++++++-- .../.claude/plans/vivid-orbiting-thimble.md | 35 ++- apps/readest-app/AGENTS.md | 11 + .../tauri-plugin-native-bridge/Cargo.toml | 14 + .../android/build.gradle.kts | 5 + .../src/main/java/NativeBridgePlugin.kt | 90 +++++++ .../ios/Sources/NativeBridgePlugin.swift | 82 ++++++ .../commands/clear_sync_passphrase.toml | 13 + .../commands/get_sync_passphrase.toml | 13 + .../commands/is_sync_keychain_available.toml | 13 + .../commands/set_sync_passphrase.toml | 13 + .../permissions/autogenerated/reference.md | 108 ++++++++ .../permissions/default.toml | 4 + .../permissions/schemas/schema.json | 52 +++- .../src/commands.rs | 29 +++ .../tauri-plugin-native-bridge/src/desktop.rs | 81 ++++++ .../tauri-plugin-native-bridge/src/lib.rs | 4 + .../tauri-plugin-native-bridge/src/mobile.rs | 35 +++ .../tauri-plugin-native-bridge/src/models.rs | 36 +++ .../__tests__/hooks/useReplicaPull.test.tsx | 36 +++ .../__tests__/libs/crypto/passphrase.test.ts | 121 ++++++++- .../src/__tests__/libs/crypto/session.test.ts | 16 ++ .../sync/adapters/opdsCatalog.test.ts | 14 +- .../services/sync/passphraseGate.test.ts | 121 +++++++++ .../sync/replicaCryptoMiddleware.test.ts | 245 ++++++++++++++++++ .../app/opds/components/CatalogManager.tsx | 23 ++ .../user/components/SyncPassphraseSection.tsx | 111 ++++++++ apps/readest-app/src/app/user/page.tsx | 2 + .../src/components/PassphrasePrompt.tsx | 153 +++++++++++ apps/readest-app/src/components/Providers.tsx | 16 ++ .../color/BackgroundTextureSelector.tsx | 19 +- apps/readest-app/src/hooks/useReplicaPull.ts | 4 +- .../readest-app/src/libs/crypto/passphrase.ts | 109 +++++++- apps/readest-app/src/libs/crypto/session.ts | 97 ++++++- apps/readest-app/src/libs/replicaSchemas.ts | 5 + .../readest-app/src/libs/replicaSyncClient.ts | 22 ++ .../src/pages/api/sync/replica-keys.ts | 22 +- .../src/services/sync/adapters/opdsCatalog.ts | 26 ++ .../src/services/sync/passphraseGate.ts | 98 +++++++ .../services/sync/replicaCryptoMiddleware.ts | 189 ++++++++++++++ .../src/services/sync/replicaPublish.ts | 4 + .../src/services/sync/replicaPullAndApply.ts | 63 +++++ .../src/services/sync/replicaRegistry.ts | 12 + apps/readest-app/src/store/customOPDSStore.ts | 19 +- apps/readest-app/src/styles/globals.css | 23 ++ apps/readest-app/src/types/opds.ts | 10 + apps/readest-app/src/utils/bridge.ts | 45 ++++ .../db/migrations/010_replica_keys_forget.sql | 53 ++++ 48 files changed, 2367 insertions(+), 66 deletions(-) create mode 100644 apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/permissions/autogenerated/commands/clear_sync_passphrase.toml create mode 100644 apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/permissions/autogenerated/commands/get_sync_passphrase.toml create mode 100644 apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/permissions/autogenerated/commands/is_sync_keychain_available.toml create mode 100644 apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/permissions/autogenerated/commands/set_sync_passphrase.toml create mode 100644 apps/readest-app/src/__tests__/services/sync/passphraseGate.test.ts create mode 100644 apps/readest-app/src/__tests__/services/sync/replicaCryptoMiddleware.test.ts create mode 100644 apps/readest-app/src/app/user/components/SyncPassphraseSection.tsx create mode 100644 apps/readest-app/src/components/PassphrasePrompt.tsx create mode 100644 apps/readest-app/src/services/sync/passphraseGate.ts create mode 100644 apps/readest-app/src/services/sync/replicaCryptoMiddleware.ts create mode 100644 docker/volumes/db/migrations/010_replica_keys_forget.sql diff --git a/Cargo.lock b/Cargo.lock index fed5201e..82a10fd8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -216,7 +216,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -227,7 +227,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -678,7 +678,7 @@ version = "3.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "519bd3116aeeb42d5372c29d982d16d0170d3d4a5ed85fc7dd91642ffff3c67c" dependencies = [ - "darling 0.20.11", + "darling 0.23.0", "ident_case", "prettyplease", "proc-macro2", @@ -1497,6 +1497,27 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c286de4e81ea2590afc24d754e0f83810c566f50a1388fa75ebd57928c0d9745" +[[package]] +name = "dbus" +version = "0.9.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b942602992bb7acfd1f51c49811c58a610ef9181b6e66f3e519d79b540a3bf73" +dependencies = [ + "libc", + "libdbus-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "dbus-secret-service" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "708b509edf7889e53d7efb0ffadd994cc6c2345ccb62f55cfd6b0682165e4fa6" +dependencies = [ + "dbus", + "zeroize", +] + [[package]] name = "default-net" version = "0.22.0" @@ -1628,7 +1649,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -1675,7 +1696,7 @@ version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab8ecd87370524b461f8557c119c405552c396ed91fc0a8eec68679eab26f94a" dependencies = [ - "libloading 0.7.4", + "libloading 0.8.9", ] [[package]] @@ -1917,7 +1938,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -2395,8 +2416,8 @@ dependencies = [ "libc", "log", "rustversion", - "windows-link 0.1.3", - "windows-result 0.3.4", + "windows-link 0.2.1", + "windows-result 0.4.1", ] [[package]] @@ -2535,7 +2556,7 @@ dependencies = [ "gobject-sys 0.21.5", "libc", "system-deps 7.0.7", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -2967,7 +2988,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.57.0", + "windows-core 0.61.2", ] [[package]] @@ -3404,6 +3425,21 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "keyring" +version = "3.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c" +dependencies = [ + "byteorder", + "dbus-secret-service", + "log", + "security-framework 2.11.1", + "security-framework 3.7.0", + "windows-sys 0.60.2", + "zeroize", +] + [[package]] name = "kqueue" version = "1.1.1" @@ -3490,6 +3526,15 @@ version = "0.2.183" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" +[[package]] +name = "libdbus-sys" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "328c4789d42200f1eeec05bd86c9c13c7f091d2ba9a6ea35acdf51f31bc0f043" +dependencies = [ + "pkg-config", +] + [[package]] name = "libloading" version = "0.7.4" @@ -3886,7 +3931,7 @@ dependencies = [ "openssl-probe", "openssl-sys", "schannel", - "security-framework", + "security-framework 3.7.0", "security-framework-sys", "tempfile", ] @@ -4100,7 +4145,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -4615,7 +4660,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.45.0", + "windows-sys 0.61.2", ] [[package]] @@ -5911,7 +5956,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.4.15", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -5924,7 +5969,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -5950,7 +5995,7 @@ dependencies = [ "openssl-probe", "rustls-pki-types", "schannel", - "security-framework", + "security-framework 3.7.0", ] [[package]] @@ -5978,10 +6023,10 @@ dependencies = [ "rustls-native-certs", "rustls-platform-verifier-android", "rustls-webpki", - "security-framework", + "security-framework 3.7.0", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -6127,6 +6172,19 @@ version = "4.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b" +[[package]] +name = "security-framework" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" +dependencies = [ + "bitflags 2.11.0", + "core-foundation 0.9.4", + "core-foundation-sys 0.8.7", + "libc", + "security-framework-sys", +] + [[package]] name = "security-framework" version = "3.7.0" @@ -6579,7 +6637,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -7439,6 +7497,7 @@ name = "tauri-plugin-native-bridge" version = "0.1.0" dependencies = [ "font-enumeration", + "keyring", "schemars 0.8.22", "serde", "tauri", @@ -7872,7 +7931,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix 1.1.4", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -8620,7 +8679,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset", "tempfile", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -9202,7 +9261,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -10187,6 +10246,20 @@ name = "zeroize" version = "1.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] [[package]] name = "zerotrie" diff --git a/apps/readest-app/.claude/plans/vivid-orbiting-thimble.md b/apps/readest-app/.claude/plans/vivid-orbiting-thimble.md index 56f5ca83..94ddb2ee 100644 --- a/apps/readest-app/.claude/plans/vivid-orbiting-thimble.md +++ b/apps/readest-app/.claude/plans/vivid-orbiting-thimble.md @@ -559,11 +559,12 @@ example. Add `'font'` to the allowlist + schema + adapter. Similar shape to fonts. -### PR 4 — `opds_catalog` (split into 4a + 4b + 4c) +### PR 4 — `opds_catalog` (split into 4a + 4b + 4c + 4d) -Originally one PR; split during PR 4 build because the crypto -introduction is a step-up in complexity that benefits from separate -review surfaces. +Originally one PR; split during build because the crypto introduction +is a step-up in complexity that benefits from separate review surfaces, +and the Tauri keychain backend is cross-language work (Rust + Swift + +Kotlin) that's easier to review on its own. **PR 4a — encrypted-field session wiring (no consumer kind).** Ships the per-account PBKDF2 salt endpoint (`/api/sync/replica-keys` + the @@ -580,14 +581,28 @@ not pushed, not pull-overwritten. Public catalogs sync end-to-end immediately; credentialed catalogs need re-entry on each device until 4c lands. -**PR 4c — `opds_catalog` encrypted credentials + passphrase UX.** +**PR 4c — `opds_catalog` encrypted credentials + passphrase UX (TS-only).** Adds `username` and `password` as encrypted-field envelopes via the -crypto session shipped in 4a. Ships `SyncPassphrasePanel` UI -(set / change / forgot), the lazy `getOrPromptPassphrase` modal that -fires on first encrypted-field push or pull, the Tauri keychain backend -that replaces the `EphemeralPassphraseStore` stub, and the +crypto session shipped in 4a. Ships the lazy `getOrPromptPassphrase` +helper, a passphrase prompt modal that fires on first encrypted-field +push or pull, a Sync section in Settings with "Set sync passphrase" / +"Change passphrase" / "Forgot passphrase" actions, the forgot-passphrase server endpoint (wipes encrypted envelopes + rotates -salt). +salt), and the adapter contract change to support async pack/unpack +for encryption. Web users get the per-session ephemeral passphrase +storage; **native users also use ephemeral storage in this PR** and +re-enter their passphrase each app launch — a known UX wart that PR 4d +fixes by wiring the Tauri keychain. + +**PR 4d — Tauri keychain backend (Rust + iOS + Android).** Replaces +`EphemeralPassphraseStore` on native with persistent OS-keychain +storage via the existing `tauri-plugin-native-bridge` plugin. Desktop +uses the `keyring` Rust crate (macOS Keychain, Windows Credential +Manager, Linux libsecret); iOS uses the Security framework via Swift; +Android uses the AndroidKeystore via Kotlin. No TS surface change — +`TauriPassphraseStore` becomes the platform default; web continues to +use `EphemeralPassphraseStore`. Eliminates the per-launch re-prompt on +native. ### PR 5 — `settings` bundled kind (collapses original PR 5 + PR 6+) diff --git a/apps/readest-app/AGENTS.md b/apps/readest-app/AGENTS.md index 39c41514..a600c8ac 100644 --- a/apps/readest-app/AGENTS.md +++ b/apps/readest-app/AGENTS.md @@ -77,6 +77,17 @@ See [docs/i18n.md](docs/i18n.md) for the key-as-content translation approach, `s See [docs/safe-area-insets.md](docs/safe-area-insets.md) for rules on handling top/bottom insets for UI elements near screen edges. +### E-ink mode + +Every new UI widget must look right under `[data-eink='true']`. E-ink screens have no shadows, no gradients, slow refresh, and need crisp 1px borders for delineation. The conventions live in `src/styles/globals.css` — reuse the existing classes instead of inventing new ones: + +- **Surfaces / inputs** — add `eink-bordered`. In eink mode it swaps to `bg-base-100` + 1px `base-content` border. Use it on inputs, custom button backgrounds, ghost-styled cancel buttons, and any container that needs a visible boundary. +- **Primary action buttons** — add `btn-primary` (alongside whatever Tailwind classes you use for color themes). The `[data-eink] .btn-primary` rule inverts to `base-content` bg + `base-100` text so the primary CTA stays distinct from secondary actions. +- **`.modal-box`** picks up no-shadow + 1px border automatically; dialogs that use it don't need additions. +- **Don't rely on color/shadow alone for hierarchy.** Two same-tone buttons differ only by hover on color themes, and hover doesn't exist on e-ink touchscreens. Pair a borderless ghost (cancel) with a solid CTA (submit) so eink can invert one without flattening the difference. + +When in doubt, toggle E-ink in Settings → Misc and check. The rules in `globals.css` cover most cases automatically, but composite components (custom buttons, layered cards) often need `eink-bordered` on the right element to stay legible. + Available gstack skills: - `/plan-ceo-review` — CEO/founder-mode plan review diff --git a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/Cargo.toml b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/Cargo.toml index 6512a73d..46bea58a 100644 --- a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/Cargo.toml +++ b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/Cargo.toml @@ -20,3 +20,17 @@ schemars = "0.8" [target.'cfg(any(target_os = "macos", windows, target_os = "linux"))'.dependencies] font-enumeration = "0.9.0" + +# OS-keychain backed secret storage for the sync passphrase. Each +# desktop target enables exactly the native backend it can compile. +# iOS / Android take a different path entirely: Swift's Security +# framework + Android's EncryptedSharedPreferences, dispatched via +# mobile.rs. +[target.'cfg(target_os = "macos")'.dependencies] +keyring = { version = "3", default-features = false, features = ["apple-native"] } + +[target.'cfg(target_os = "windows")'.dependencies] +keyring = { version = "3", default-features = false, features = ["windows-native"] } + +[target.'cfg(target_os = "linux")'.dependencies] +keyring = { version = "3", default-features = false, features = ["sync-secret-service"] } diff --git a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/android/build.gradle.kts b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/android/build.gradle.kts index 63a5ec15..17a24bdb 100644 --- a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/android/build.gradle.kts +++ b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/android/build.gradle.kts @@ -49,6 +49,11 @@ dependencies { implementation("androidx.appcompat:appcompat:1.6.1") implementation("androidx.browser:browser:1.8.0") implementation("com.google.android.material:material:1.7.0") + // EncryptedSharedPreferences (sync passphrase keychain backing). + // Stays on the 1.1.0-alpha line because the stable 1.0.x release + // doesn't support modern API targets cleanly; alpha is widely used + // in production and the API is stable. + implementation("androidx.security:security-crypto:1.1.0-alpha06") testImplementation("junit:junit:4.13.2") androidTestImplementation("androidx.test.ext:junit:1.1.5") androidTestImplementation("androidx.test.espresso:espresso-core:3.5.1") diff --git a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/android/src/main/java/NativeBridgePlugin.kt b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/android/src/main/java/NativeBridgePlugin.kt index ce1b4cb8..9ddf13a7 100644 --- a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/android/src/main/java/NativeBridgePlugin.kt +++ b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/android/src/main/java/NativeBridgePlugin.kt @@ -792,4 +792,94 @@ class NativeBridgePlugin(private val activity: Activity): Plugin(activity) { trigger(eventName, payload) } } + + // ── Sync passphrase keychain ────────────────────────────────────── + // Backed by EncryptedSharedPreferences, which derives an AES-GCM + // master key from AndroidKeystore and stores the value-of-keys map + // in a private SharedPreferences file. The TS-side CryptoSession + // reads/writes via these commands so the user's sync passphrase + // persists across app launches. + + private val syncPrefsName = "readest_sync_passphrase_v1" + private val syncPrefsKey = "passphrase" + + private fun openSyncPrefs(): android.content.SharedPreferences { + val masterKey = androidx.security.crypto.MasterKey.Builder(activity) + .setKeyScheme(androidx.security.crypto.MasterKey.KeyScheme.AES256_GCM) + .build() + return androidx.security.crypto.EncryptedSharedPreferences.create( + activity, + syncPrefsName, + masterKey, + androidx.security.crypto.EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV, + androidx.security.crypto.EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM, + ) + } + + @Command + fun set_sync_passphrase(invoke: Invoke) { + val args = invoke.parseArgs(SyncPassphraseSetArgs::class.java) + val ret = JSObject() + try { + val prefs = openSyncPrefs() + prefs.edit().putString(syncPrefsKey, args.passphrase).apply() + ret.put("success", true) + } catch (e: Exception) { + Log.e("NativeBridgePlugin", "set_sync_passphrase failed", e) + ret.put("success", false) + ret.put("error", e.message ?: "unknown") + } + invoke.resolve(ret) + } + + @Command + fun get_sync_passphrase(invoke: Invoke) { + val ret = JSObject() + try { + val prefs = openSyncPrefs() + val value = prefs.getString(syncPrefsKey, null) + if (value != null) ret.put("passphrase", value) + } catch (e: Exception) { + Log.e("NativeBridgePlugin", "get_sync_passphrase failed", e) + ret.put("error", e.message ?: "unknown") + } + invoke.resolve(ret) + } + + @Command + fun clear_sync_passphrase(invoke: Invoke) { + val ret = JSObject() + try { + val prefs = openSyncPrefs() + prefs.edit().remove(syncPrefsKey).apply() + ret.put("success", true) + } catch (e: Exception) { + Log.e("NativeBridgePlugin", "clear_sync_passphrase failed", e) + ret.put("success", false) + ret.put("error", e.message ?: "unknown") + } + invoke.resolve(ret) + } + + @Command + fun is_sync_keychain_available(invoke: Invoke) { + val ret = JSObject() + try { + // Probe by opening the prefs file. Failure surfaces as + // available=false with the underlying error string so the + // TS layer can fall back to the ephemeral store. + openSyncPrefs() + ret.put("available", true) + } catch (e: Exception) { + Log.e("NativeBridgePlugin", "is_sync_keychain_available failed", e) + ret.put("available", false) + ret.put("error", e.message ?: "unknown") + } + invoke.resolve(ret) + } +} + +@app.tauri.annotation.InvokeArg +class SyncPassphraseSetArgs { + lateinit var passphrase: String } diff --git a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/ios/Sources/NativeBridgePlugin.swift b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/ios/Sources/NativeBridgePlugin.swift index ea3e47e5..e4e16a54 100644 --- a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/ios/Sources/NativeBridgePlugin.swift +++ b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/ios/Sources/NativeBridgePlugin.swift @@ -921,6 +921,88 @@ class NativeBridgePlugin: Plugin { } } } + + // ── Sync passphrase keychain ────────────────────────────────────────── + // Backed by the iOS Security framework Keychain. The TS-side + // CryptoSession reads/writes via these commands so the user's sync + // passphrase persists across app launches. + + private static let syncKeychainService = "com.bilingify.readest.sync-passphrase" + private static let syncKeychainAccount = "default" + + private func syncKeychainBaseQuery() -> [String: Any] { + return [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: NativeBridgePlugin.syncKeychainService, + kSecAttrAccount as String: NativeBridgePlugin.syncKeychainAccount + ] + } + + @objc public func set_sync_passphrase(_ invoke: Invoke) { + do { + let args = try invoke.parseArgs(SyncPassphraseSetArgs.self) + guard let data = args.passphrase.data(using: .utf8) else { + invoke.resolve(["success": false, "error": "encoding"]) + return + } + var query = syncKeychainBaseQuery() + query[kSecValueData as String] = data + // Replace any existing entry. Delete-then-add keeps the + // accessibility class consistent across SDK versions. + SecItemDelete(query as CFDictionary) + let status = SecItemAdd(query as CFDictionary, nil) + if status == errSecSuccess { + invoke.resolve(["success": true]) + } else { + invoke.resolve(["success": false, "error": "OSStatus \(status)"]) + } + } catch { + invoke.resolve(["success": false, "error": "\(error)"]) + } + } + + @objc public func get_sync_passphrase(_ invoke: Invoke) { + var query = syncKeychainBaseQuery() + query[kSecReturnData as String] = true + query[kSecMatchLimit as String] = kSecMatchLimitOne + var item: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &item) + if status == errSecSuccess, let data = item as? Data, let s = String(data: data, encoding: .utf8) { + invoke.resolve(["passphrase": s]) + } else if status == errSecItemNotFound { + // No entry: empty response. The TS layer treats this as "prompt". + invoke.resolve([:]) + } else { + invoke.resolve(["error": "OSStatus \(status)"]) + } + } + + @objc public func clear_sync_passphrase(_ invoke: Invoke) { + let status = SecItemDelete(syncKeychainBaseQuery() as CFDictionary) + if status == errSecSuccess || status == errSecItemNotFound { + invoke.resolve(["success": true]) + } else { + invoke.resolve(["success": false, "error": "OSStatus \(status)"]) + } + } + + @objc public func is_sync_keychain_available(_ invoke: Invoke) { + // The Keychain is always available on iOS; report true and let the + // TS layer trust it. We probe SecItemCopyMatching anyway so a + // future sandbox restriction surfaces an explicit error. + var query = syncKeychainBaseQuery() + query[kSecMatchLimit as String] = kSecMatchLimitOne + let status = SecItemCopyMatching(query as CFDictionary, nil) + if status == errSecSuccess || status == errSecItemNotFound { + invoke.resolve(["available": true]) + } else { + invoke.resolve(["available": false, "error": "OSStatus \(status)"]) + } + } +} + +class SyncPassphraseSetArgs: Decodable { + let passphrase: String } @_cdecl("init_plugin_native_bridge") diff --git a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/permissions/autogenerated/commands/clear_sync_passphrase.toml b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/permissions/autogenerated/commands/clear_sync_passphrase.toml new file mode 100644 index 00000000..b2806ec4 --- /dev/null +++ b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/permissions/autogenerated/commands/clear_sync_passphrase.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-clear-sync-passphrase" +description = "Enables the clear_sync_passphrase command without any pre-configured scope." +commands.allow = ["clear_sync_passphrase"] + +[[permission]] +identifier = "deny-clear-sync-passphrase" +description = "Denies the clear_sync_passphrase command without any pre-configured scope." +commands.deny = ["clear_sync_passphrase"] diff --git a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/permissions/autogenerated/commands/get_sync_passphrase.toml b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/permissions/autogenerated/commands/get_sync_passphrase.toml new file mode 100644 index 00000000..eab94ce9 --- /dev/null +++ b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/permissions/autogenerated/commands/get_sync_passphrase.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-get-sync-passphrase" +description = "Enables the get_sync_passphrase command without any pre-configured scope." +commands.allow = ["get_sync_passphrase"] + +[[permission]] +identifier = "deny-get-sync-passphrase" +description = "Denies the get_sync_passphrase command without any pre-configured scope." +commands.deny = ["get_sync_passphrase"] diff --git a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/permissions/autogenerated/commands/is_sync_keychain_available.toml b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/permissions/autogenerated/commands/is_sync_keychain_available.toml new file mode 100644 index 00000000..b20f07f6 --- /dev/null +++ b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/permissions/autogenerated/commands/is_sync_keychain_available.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-is-sync-keychain-available" +description = "Enables the is_sync_keychain_available command without any pre-configured scope." +commands.allow = ["is_sync_keychain_available"] + +[[permission]] +identifier = "deny-is-sync-keychain-available" +description = "Denies the is_sync_keychain_available command without any pre-configured scope." +commands.deny = ["is_sync_keychain_available"] diff --git a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/permissions/autogenerated/commands/set_sync_passphrase.toml b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/permissions/autogenerated/commands/set_sync_passphrase.toml new file mode 100644 index 00000000..69cca95d --- /dev/null +++ b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/permissions/autogenerated/commands/set_sync_passphrase.toml @@ -0,0 +1,13 @@ +# Automatically generated - DO NOT EDIT! + +"$schema" = "../../schemas/schema.json" + +[[permission]] +identifier = "allow-set-sync-passphrase" +description = "Enables the set_sync_passphrase command without any pre-configured scope." +commands.allow = ["set_sync_passphrase"] + +[[permission]] +identifier = "deny-set-sync-passphrase" +description = "Denies the set_sync_passphrase command without any pre-configured scope." +commands.deny = ["set_sync_passphrase"] diff --git a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/permissions/autogenerated/reference.md b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/permissions/autogenerated/reference.md index 0b67e542..765407f8 100644 --- a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/permissions/autogenerated/reference.md +++ b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/permissions/autogenerated/reference.md @@ -34,6 +34,10 @@ Default permissions for the plugin - `allow-request-permissions` - `allow-checkPermissions` - `allow-requestPermissions` +- `allow-set-sync-passphrase` +- `allow-get-sync-passphrase` +- `allow-clear-sync-passphrase` +- `allow-is-sync-keychain-available` ## Permission Table @@ -177,6 +181,32 @@ Denies the check_permissions command without any pre-configured scope. +`native-bridge:allow-clear-sync-passphrase` + + + + +Enables the clear_sync_passphrase command without any pre-configured scope. + + + + + + + +`native-bridge:deny-clear-sync-passphrase` + + + + +Denies the clear_sync_passphrase command without any pre-configured scope. + + + + + + + `native-bridge:allow-copy-uri-to-path` @@ -333,6 +363,32 @@ Denies the get_storefront_region_code command without any pre-configured scope. +`native-bridge:allow-get-sync-passphrase` + + + + +Enables the get_sync_passphrase command without any pre-configured scope. + + + + + + + +`native-bridge:deny-get-sync-passphrase` + + + + +Denies the get_sync_passphrase command without any pre-configured scope. + + + + + + + `native-bridge:allow-get-sys-fonts-list` @@ -567,6 +623,32 @@ Denies the intercept_keys command without any pre-configured scope. +`native-bridge:allow-is-sync-keychain-available` + + + + +Enables the is_sync_keychain_available command without any pre-configured scope. + + + + + + + +`native-bridge:deny-is-sync-keychain-available` + + + + +Denies the is_sync_keychain_available command without any pre-configured scope. + + + + + + + `native-bridge:allow-lock-screen-orientation` @@ -827,6 +909,32 @@ Denies the set_screen_brightness command without any pre-configured scope. +`native-bridge:allow-set-sync-passphrase` + + + + +Enables the set_sync_passphrase command without any pre-configured scope. + + + + + + + +`native-bridge:deny-set-sync-passphrase` + + + + +Denies the set_sync_passphrase command without any pre-configured scope. + + + + + + + `native-bridge:allow-set-system-ui-visibility` diff --git a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/permissions/default.toml b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/permissions/default.toml index e9fc5e8a..1773e1c3 100644 --- a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/permissions/default.toml +++ b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/permissions/default.toml @@ -31,4 +31,8 @@ permissions = [ "allow-request-permissions", "allow-checkPermissions", "allow-requestPermissions", + "allow-set-sync-passphrase", + "allow-get-sync-passphrase", + "allow-clear-sync-passphrase", + "allow-is-sync-keychain-available", ] diff --git a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/permissions/schemas/schema.json b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/permissions/schemas/schema.json index cb0cc2e3..3e6a82db 100644 --- a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/permissions/schemas/schema.json +++ b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/permissions/schemas/schema.json @@ -354,6 +354,18 @@ "const": "deny-check-permissions", "markdownDescription": "Denies the check_permissions command without any pre-configured scope." }, + { + "description": "Enables the clear_sync_passphrase command without any pre-configured scope.", + "type": "string", + "const": "allow-clear-sync-passphrase", + "markdownDescription": "Enables the clear_sync_passphrase command without any pre-configured scope." + }, + { + "description": "Denies the clear_sync_passphrase command without any pre-configured scope.", + "type": "string", + "const": "deny-clear-sync-passphrase", + "markdownDescription": "Denies the clear_sync_passphrase command without any pre-configured scope." + }, { "description": "Enables the copy_uri_to_path command without any pre-configured scope.", "type": "string", @@ -426,6 +438,18 @@ "const": "deny-get-storefront-region-code", "markdownDescription": "Denies the get_storefront_region_code command without any pre-configured scope." }, + { + "description": "Enables the get_sync_passphrase command without any pre-configured scope.", + "type": "string", + "const": "allow-get-sync-passphrase", + "markdownDescription": "Enables the get_sync_passphrase command without any pre-configured scope." + }, + { + "description": "Denies the get_sync_passphrase command without any pre-configured scope.", + "type": "string", + "const": "deny-get-sync-passphrase", + "markdownDescription": "Denies the get_sync_passphrase command without any pre-configured scope." + }, { "description": "Enables the get_sys_fonts_list command without any pre-configured scope.", "type": "string", @@ -534,6 +558,18 @@ "const": "deny-intercept-keys", "markdownDescription": "Denies the intercept_keys command without any pre-configured scope." }, + { + "description": "Enables the is_sync_keychain_available command without any pre-configured scope.", + "type": "string", + "const": "allow-is-sync-keychain-available", + "markdownDescription": "Enables the is_sync_keychain_available command without any pre-configured scope." + }, + { + "description": "Denies the is_sync_keychain_available command without any pre-configured scope.", + "type": "string", + "const": "deny-is-sync-keychain-available", + "markdownDescription": "Denies the is_sync_keychain_available command without any pre-configured scope." + }, { "description": "Enables the lock_screen_orientation command without any pre-configured scope.", "type": "string", @@ -654,6 +690,18 @@ "const": "deny-set-screen-brightness", "markdownDescription": "Denies the set_screen_brightness command without any pre-configured scope." }, + { + "description": "Enables the set_sync_passphrase command without any pre-configured scope.", + "type": "string", + "const": "allow-set-sync-passphrase", + "markdownDescription": "Enables the set_sync_passphrase command without any pre-configured scope." + }, + { + "description": "Denies the set_sync_passphrase command without any pre-configured scope.", + "type": "string", + "const": "deny-set-sync-passphrase", + "markdownDescription": "Denies the set_sync_passphrase command without any pre-configured scope." + }, { "description": "Enables the set_system_ui_visibility command without any pre-configured scope.", "type": "string", @@ -679,10 +727,10 @@ "markdownDescription": "Denies the use_background_audio command without any pre-configured scope." }, { - "description": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-auth-with-safari`\n- `allow-auth-with-custom-tab`\n- `allow-copy-uri-to-path`\n- `allow-use-background-audio`\n- `allow-install-package`\n- `allow-set-system-ui-visibility`\n- `allow-get-status-bar-height`\n- `allow-get-sys-fonts-list`\n- `allow-intercept-keys`\n- `allow-lock-screen-orientation`\n- `allow-iap-is-available`\n- `allow-iap-initialize`\n- `allow-iap-fetch-products`\n- `allow-iap-purchase-product`\n- `allow-iap-restore-purchases`\n- `allow-get-system-color-scheme`\n- `allow-get-safe-area-insets`\n- `allow-get-screen-brightness`\n- `allow-set-screen-brightness`\n- `allow-get-external-sdcard-path`\n- `allow-open-external-url`\n- `allow-select-directory`\n- `allow-get-storefront-region-code`\n- `allow-request-manage-storage-permission`\n- `allow-register-listener`\n- `allow-remove-listener`\n- `allow-check-permissions`\n- `allow-request-permissions`\n- `allow-checkPermissions`\n- `allow-requestPermissions`", + "description": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-auth-with-safari`\n- `allow-auth-with-custom-tab`\n- `allow-copy-uri-to-path`\n- `allow-use-background-audio`\n- `allow-install-package`\n- `allow-set-system-ui-visibility`\n- `allow-get-status-bar-height`\n- `allow-get-sys-fonts-list`\n- `allow-intercept-keys`\n- `allow-lock-screen-orientation`\n- `allow-iap-is-available`\n- `allow-iap-initialize`\n- `allow-iap-fetch-products`\n- `allow-iap-purchase-product`\n- `allow-iap-restore-purchases`\n- `allow-get-system-color-scheme`\n- `allow-get-safe-area-insets`\n- `allow-get-screen-brightness`\n- `allow-set-screen-brightness`\n- `allow-get-external-sdcard-path`\n- `allow-open-external-url`\n- `allow-select-directory`\n- `allow-get-storefront-region-code`\n- `allow-request-manage-storage-permission`\n- `allow-register-listener`\n- `allow-remove-listener`\n- `allow-check-permissions`\n- `allow-request-permissions`\n- `allow-checkPermissions`\n- `allow-requestPermissions`\n- `allow-set-sync-passphrase`\n- `allow-get-sync-passphrase`\n- `allow-clear-sync-passphrase`\n- `allow-is-sync-keychain-available`", "type": "string", "const": "default", - "markdownDescription": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-auth-with-safari`\n- `allow-auth-with-custom-tab`\n- `allow-copy-uri-to-path`\n- `allow-use-background-audio`\n- `allow-install-package`\n- `allow-set-system-ui-visibility`\n- `allow-get-status-bar-height`\n- `allow-get-sys-fonts-list`\n- `allow-intercept-keys`\n- `allow-lock-screen-orientation`\n- `allow-iap-is-available`\n- `allow-iap-initialize`\n- `allow-iap-fetch-products`\n- `allow-iap-purchase-product`\n- `allow-iap-restore-purchases`\n- `allow-get-system-color-scheme`\n- `allow-get-safe-area-insets`\n- `allow-get-screen-brightness`\n- `allow-set-screen-brightness`\n- `allow-get-external-sdcard-path`\n- `allow-open-external-url`\n- `allow-select-directory`\n- `allow-get-storefront-region-code`\n- `allow-request-manage-storage-permission`\n- `allow-register-listener`\n- `allow-remove-listener`\n- `allow-check-permissions`\n- `allow-request-permissions`\n- `allow-checkPermissions`\n- `allow-requestPermissions`" + "markdownDescription": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-auth-with-safari`\n- `allow-auth-with-custom-tab`\n- `allow-copy-uri-to-path`\n- `allow-use-background-audio`\n- `allow-install-package`\n- `allow-set-system-ui-visibility`\n- `allow-get-status-bar-height`\n- `allow-get-sys-fonts-list`\n- `allow-intercept-keys`\n- `allow-lock-screen-orientation`\n- `allow-iap-is-available`\n- `allow-iap-initialize`\n- `allow-iap-fetch-products`\n- `allow-iap-purchase-product`\n- `allow-iap-restore-purchases`\n- `allow-get-system-color-scheme`\n- `allow-get-safe-area-insets`\n- `allow-get-screen-brightness`\n- `allow-set-screen-brightness`\n- `allow-get-external-sdcard-path`\n- `allow-open-external-url`\n- `allow-select-directory`\n- `allow-get-storefront-region-code`\n- `allow-request-manage-storage-permission`\n- `allow-register-listener`\n- `allow-remove-listener`\n- `allow-check-permissions`\n- `allow-request-permissions`\n- `allow-checkPermissions`\n- `allow-requestPermissions`\n- `allow-set-sync-passphrase`\n- `allow-get-sync-passphrase`\n- `allow-clear-sync-passphrase`\n- `allow-is-sync-keychain-available`" } ] } diff --git a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/src/commands.rs b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/src/commands.rs index a8b9c040..89b0021f 100644 --- a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/src/commands.rs +++ b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/src/commands.rs @@ -199,3 +199,32 @@ pub(crate) async fn request_manage_storage_permission( ) -> Result { app.native_bridge().request_manage_storage_permission() } + +#[command] +pub(crate) async fn set_sync_passphrase( + app: AppHandle, + payload: SetSyncPassphraseRequest, +) -> Result { + app.native_bridge().set_sync_passphrase(payload) +} + +#[command] +pub(crate) async fn get_sync_passphrase( + app: AppHandle, +) -> Result { + app.native_bridge().get_sync_passphrase() +} + +#[command] +pub(crate) async fn clear_sync_passphrase( + app: AppHandle, +) -> Result { + app.native_bridge().clear_sync_passphrase() +} + +#[command] +pub(crate) async fn is_sync_keychain_available( + app: AppHandle, +) -> Result { + app.native_bridge().is_sync_keychain_available() +} diff --git a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/src/desktop.rs b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/src/desktop.rs index a312dd42..3a6627b9 100644 --- a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/src/desktop.rs +++ b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/src/desktop.rs @@ -146,4 +146,85 @@ impl NativeBridge { ) -> crate::Result { Err(crate::Error::UnsupportedPlatformError) } + + // ── Sync passphrase keychain ──────────────────────────────────────── + // + // Uses the `keyring` crate, which transparently maps to: + // * macOS → Security framework Keychain + // * Windows → Credential Manager + // * Linux → Secret Service (libsecret-compatible) + // + // `service` and `user` form the keychain item identity. Service is + // the bundle id; user is a stable string ("default") so multiple + // Readest installs on the same machine could coexist with distinct + // user values if ever needed. + + pub fn set_sync_passphrase( + &self, + payload: SetSyncPassphraseRequest, + ) -> crate::Result { + match keyring_entry().and_then(|e| e.set_password(&payload.passphrase)) { + Ok(()) => Ok(SyncPassphraseResponse { + success: true, + error: None, + }), + Err(err) => Ok(SyncPassphraseResponse { + success: false, + error: Some(err.to_string()), + }), + } + } + + pub fn get_sync_passphrase(&self) -> crate::Result { + match keyring_entry().and_then(|e| e.get_password()) { + Ok(passphrase) => Ok(GetSyncPassphraseResponse { + passphrase: Some(passphrase), + error: None, + }), + Err(keyring::Error::NoEntry) => Ok(GetSyncPassphraseResponse { + passphrase: None, + error: None, + }), + Err(err) => Ok(GetSyncPassphraseResponse { + passphrase: None, + error: Some(err.to_string()), + }), + } + } + + pub fn clear_sync_passphrase(&self) -> crate::Result { + match keyring_entry().and_then(|e| e.delete_credential()) { + Ok(()) | Err(keyring::Error::NoEntry) => Ok(SyncPassphraseResponse { + success: true, + error: None, + }), + Err(err) => Ok(SyncPassphraseResponse { + success: false, + error: Some(err.to_string()), + }), + } + } + + pub fn is_sync_keychain_available(&self) -> crate::Result { + // Best-effort probe: open an entry handle. Surface the error + // string instead of throwing so the TS layer can fall back + // to the ephemeral store gracefully. + match keyring_entry() { + Ok(_) => Ok(SyncKeychainAvailableResponse { + available: true, + error: None, + }), + Err(err) => Ok(SyncKeychainAvailableResponse { + available: false, + error: Some(err.to_string()), + }), + } + } +} + +const KEYRING_SERVICE: &str = "com.bilingify.readest.sync-passphrase"; +const KEYRING_USER: &str = "default"; + +fn keyring_entry() -> std::result::Result { + keyring::Entry::new(KEYRING_SERVICE, KEYRING_USER) } diff --git a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/src/lib.rs b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/src/lib.rs index c4d74818..7ac0df0f 100644 --- a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/src/lib.rs +++ b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/src/lib.rs @@ -79,6 +79,10 @@ pub fn init() -> TauriPlugin { commands::select_directory, commands::get_storefront_region_code, commands::request_manage_storage_permission, + commands::set_sync_passphrase, + commands::get_sync_passphrase, + commands::clear_sync_passphrase, + commands::is_sync_keychain_available, ]) .setup(|app, api| { #[cfg(mobile)] diff --git a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/src/mobile.rs b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/src/mobile.rs index 515e7885..4d5860dd 100644 --- a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/src/mobile.rs +++ b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/src/mobile.rs @@ -241,3 +241,38 @@ impl NativeBridge { .map_err(Into::into) } } + +impl NativeBridge { + pub fn set_sync_passphrase( + &self, + payload: SetSyncPassphraseRequest, + ) -> crate::Result { + self.0 + .run_mobile_plugin("set_sync_passphrase", payload) + .map_err(Into::into) + } +} + +impl NativeBridge { + pub fn get_sync_passphrase(&self) -> crate::Result { + self.0 + .run_mobile_plugin("get_sync_passphrase", ()) + .map_err(Into::into) + } +} + +impl NativeBridge { + pub fn clear_sync_passphrase(&self) -> crate::Result { + self.0 + .run_mobile_plugin("clear_sync_passphrase", ()) + .map_err(Into::into) + } +} + +impl NativeBridge { + pub fn is_sync_keychain_available(&self) -> crate::Result { + self.0 + .run_mobile_plugin("is_sync_keychain_available", ()) + .map_err(Into::into) + } +} diff --git a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/src/models.rs b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/src/models.rs index 3736a199..e8140d63 100644 --- a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/src/models.rs +++ b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/src/models.rs @@ -237,3 +237,39 @@ pub struct GetStorefrontRegionCodeResponse { pub region_code: Option, pub error: Option, } + +// ── Sync passphrase keychain ──────────────────────────────────────────── +// +// Persist the sync passphrase across app launches via the OS keychain +// so native users don't re-enter it every session. The replica-sync +// CryptoSession (TS side) reads/writes via these commands; web users +// keep using the in-memory ephemeral store. + +#[derive(Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SetSyncPassphraseRequest { + pub passphrase: String, +} + +#[derive(Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SyncPassphraseResponse { + pub success: bool, + pub error: Option, +} + +#[derive(Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct GetSyncPassphraseResponse { + /// Present iff a passphrase is stored. Absent (and `error: None`) + /// means "no entry on this device" — caller should prompt. + pub passphrase: Option, + pub error: Option, +} + +#[derive(Debug, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct SyncKeychainAvailableResponse { + pub available: bool, + pub error: Option, +} diff --git a/apps/readest-app/src/__tests__/hooks/useReplicaPull.test.tsx b/apps/readest-app/src/__tests__/hooks/useReplicaPull.test.tsx index d74a0e0f..7cb14b54 100644 --- a/apps/readest-app/src/__tests__/hooks/useReplicaPull.test.tsx +++ b/apps/readest-app/src/__tests__/hooks/useReplicaPull.test.tsx @@ -55,12 +55,48 @@ vi.mock('@/store/customDictionaryStore', () => ({ findDictionaryByContentId: () => undefined, })); +vi.mock('@/store/customFontStore', () => ({ + useCustomFontStore: { + getState: () => ({ + applyRemoteFont: vi.fn(), + softDeleteByContentId: vi.fn(), + loadCustomFonts: vi.fn(async () => {}), + }), + }, + findFontByContentId: () => undefined, + migrateLegacyFonts: vi.fn(async () => {}), +})); + +vi.mock('@/store/customTextureStore', () => ({ + useCustomTextureStore: { + getState: () => ({ + applyRemoteTexture: vi.fn(), + softDeleteByContentId: vi.fn(), + loadCustomTextures: vi.fn(async () => {}), + }), + }, + findTextureByContentId: () => undefined, + migrateLegacyTextures: vi.fn(async () => {}), +})); + +vi.mock('@/store/customOPDSStore', () => ({ + useCustomOPDSStore: { + getState: () => ({ + applyRemoteCatalog: vi.fn(), + softDeleteByContentId: vi.fn(), + loadCustomOPDSCatalogs: vi.fn(async () => {}), + }), + }, + findOPDSCatalogByContentId: () => undefined, +})); + vi.mock('@/utils/access', () => ({ getAccessToken: async () => 'token', })); vi.mock('@/utils/misc', () => ({ uniqueId: () => 'fresh-bundle', + stubTranslation: (s: string) => s, })); import { useReplicaPull, __resetReplicaPullForTests } from '@/hooks/useReplicaPull'; diff --git a/apps/readest-app/src/__tests__/libs/crypto/passphrase.test.ts b/apps/readest-app/src/__tests__/libs/crypto/passphrase.test.ts index 17c0cb7b..8cfc8414 100644 --- a/apps/readest-app/src/__tests__/libs/crypto/passphrase.test.ts +++ b/apps/readest-app/src/__tests__/libs/crypto/passphrase.test.ts @@ -1,5 +1,36 @@ -import { describe, expect, test } from 'vitest'; -import { EphemeralPassphraseStore, TauriPassphraseStore } from '@/libs/crypto/passphrase'; +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; + +const invokeMock = vi.fn<(...args: unknown[]) => Promise>(); +vi.mock('@tauri-apps/api/core', () => ({ + invoke: (...args: unknown[]) => invokeMock(...args), +})); + +let isTauriAppPlatformValue = false; +vi.mock('@/services/environment', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + isTauriAppPlatform: () => isTauriAppPlatformValue, + }; +}); + +import { + EphemeralPassphraseStore, + TauriPassphraseStore, + __resetPassphraseStoreForTests, + createPassphraseStore, + upgradeToKeychainIfAvailable, +} from '@/libs/crypto/passphrase'; + +beforeEach(() => { + invokeMock.mockReset(); + __resetPassphraseStoreForTests(); + isTauriAppPlatformValue = false; +}); + +afterEach(() => { + __resetPassphraseStoreForTests(); +}); describe('EphemeralPassphraseStore', () => { test('set then get returns the same passphrase', async () => { @@ -42,14 +73,90 @@ describe('EphemeralPassphraseStore', () => { }); }); -describe('TauriPassphraseStore (stub until plugin lands)', () => { - test('stub: set throws NOT_IMPLEMENTED for v1', async () => { +describe('TauriPassphraseStore', () => { + test('set delegates to plugin:native-bridge|set_sync_passphrase', async () => { + invokeMock.mockResolvedValue({ success: true }); const store = new TauriPassphraseStore(); - await expect(store.set('x')).rejects.toThrow(/Tauri keychain backend not yet wired/); + await store.set('hunter2'); + expect(invokeMock).toHaveBeenCalledWith('plugin:native-bridge|set_sync_passphrase', { + payload: { passphrase: 'hunter2' }, + }); }); - test('stub: isAvailable returns false', () => { + test('set throws CRYPTO_UNAVAILABLE when the bridge reports failure', async () => { + invokeMock.mockResolvedValue({ success: false, error: 'OSStatus -25300' }); const store = new TauriPassphraseStore(); - expect(store.isAvailable()).toBe(false); + await expect(store.set('hunter2')).rejects.toMatchObject({ + name: 'SyncError', + code: 'CRYPTO_UNAVAILABLE', + }); + }); + + test('get returns the saved passphrase', async () => { + invokeMock.mockResolvedValue({ passphrase: 'hunter2' }); + const store = new TauriPassphraseStore(); + expect(await store.get()).toBe('hunter2'); + }); + + test('get returns null when keychain has no entry (empty response)', async () => { + invokeMock.mockResolvedValue({}); + const store = new TauriPassphraseStore(); + expect(await store.get()).toBeNull(); + }); + + test('get returns null when bridge reports an error (fail-soft)', async () => { + invokeMock.mockResolvedValue({ error: 'OSStatus -25291' }); + const store = new TauriPassphraseStore(); + expect(await store.get()).toBeNull(); + }); + + test('clear delegates to plugin:native-bridge|clear_sync_passphrase', async () => { + invokeMock.mockResolvedValue({ success: true }); + const store = new TauriPassphraseStore(); + await store.clear(); + expect(invokeMock).toHaveBeenCalledWith('plugin:native-bridge|clear_sync_passphrase'); + }); + + test('isAvailable returns true (true availability is gated by upgrade probe)', () => { + expect(new TauriPassphraseStore().isAvailable()).toBe(true); + }); +}); + +describe('createPassphraseStore + upgradeToKeychainIfAvailable', () => { + test('returns ephemeral on web; upgrade is a no-op (no keychain probe)', async () => { + isTauriAppPlatformValue = false; + const store = createPassphraseStore(); + expect(store).toBeInstanceOf(EphemeralPassphraseStore); + const upgraded = await upgradeToKeychainIfAvailable(); + expect(upgraded).toBe(store); + expect(invokeMock).not.toHaveBeenCalled(); + }); + + test('upgrades to TauriPassphraseStore on Tauri when keychain probe succeeds', async () => { + isTauriAppPlatformValue = true; + invokeMock.mockResolvedValue({ available: true }); + expect(createPassphraseStore()).toBeInstanceOf(EphemeralPassphraseStore); + const upgraded = await upgradeToKeychainIfAvailable(); + expect(upgraded).toBeInstanceOf(TauriPassphraseStore); + // Subsequent createPassphraseStore returns the upgraded singleton. + expect(createPassphraseStore()).toBe(upgraded); + }); + + test('falls back to ephemeral when probe reports unavailable', async () => { + isTauriAppPlatformValue = true; + invokeMock.mockResolvedValue({ available: false, error: 'sandbox' }); + const initial = createPassphraseStore(); + const upgraded = await upgradeToKeychainIfAvailable(); + expect(upgraded).toBe(initial); + expect(upgraded).toBeInstanceOf(EphemeralPassphraseStore); + }); + + test('falls back to ephemeral when probe throws', async () => { + isTauriAppPlatformValue = true; + invokeMock.mockRejectedValue(new Error('bridge unreachable')); + const initial = createPassphraseStore(); + const upgraded = await upgradeToKeychainIfAvailable(); + expect(upgraded).toBe(initial); + expect(upgraded).toBeInstanceOf(EphemeralPassphraseStore); }); }); diff --git a/apps/readest-app/src/__tests__/libs/crypto/session.test.ts b/apps/readest-app/src/__tests__/libs/crypto/session.test.ts index ae241f0e..27d7dc3c 100644 --- a/apps/readest-app/src/__tests__/libs/crypto/session.test.ts +++ b/apps/readest-app/src/__tests__/libs/crypto/session.test.ts @@ -29,6 +29,7 @@ class FakeClient { rows: ReplicaKeyRow[] = []; listCalls = 0; createCalls = 0; + forgetCalls = 0; async listReplicaKeys(): Promise { this.listCalls += 1; @@ -42,6 +43,11 @@ class FakeClient { this.rows.push(row); return row; } + + async forgetReplicaKeys(): Promise { + this.forgetCalls += 1; + this.rows = []; + } } const makeSession = (client: FakeClient) => new CryptoSession({ client, iterations: ITER }); @@ -150,6 +156,16 @@ describe('CryptoSession', () => { expect((caught as SyncError).code).toMatch(/DECRYPT|INTEGRITY/); }); + test('forget() clears server salts and locks the session', async () => { + await session.setup('pw'); + expect(session.isUnlocked()).toBe(true); + expect(client.rows).toHaveLength(1); + await session.forget(); + expect(session.isUnlocked()).toBe(false); + expect(client.forgetCalls).toBe(1); + expect(client.rows).toHaveLength(0); + }); + test('lock() clears state; encryptField throws after lock', async () => { await session.setup('pw'); expect(session.isUnlocked()).toBe(true); diff --git a/apps/readest-app/src/__tests__/services/sync/adapters/opdsCatalog.test.ts b/apps/readest-app/src/__tests__/services/sync/adapters/opdsCatalog.test.ts index 3848f69c..4905a8b6 100644 --- a/apps/readest-app/src/__tests__/services/sync/adapters/opdsCatalog.test.ts +++ b/apps/readest-app/src/__tests__/services/sync/adapters/opdsCatalog.test.ts @@ -56,15 +56,23 @@ describe('opdsCatalogAdapter', () => { expect(opdsCatalogAdapter.binary).toBeUndefined(); }); - test('pack omits username and password (encrypted-credential PR)', () => { + test('pack passes username + password through as plaintext (middleware encrypts)', () => { const withCreds: OPDSCatalog = { ...sample, username: 'alice', password: 'hunter2' }; const fields = opdsCatalogAdapter.pack(withCreds); - expect(fields['username']).toBeUndefined(); - expect(fields['password']).toBeUndefined(); + // Adapter is plaintext-in-plaintext-out. The publish-side + // replicaCryptoMiddleware.encryptPackedFields wraps these in + // cipher envelopes (or drops them if the session is locked) + // before they reach fields_jsonb. + expect(fields['username']).toBe('alice'); + expect(fields['password']).toBe('hunter2'); expect(fields['name']).toBe('My Library'); expect(fields['url']).toBe('https://example.com/opds'); }); + test('declares encryptedFields = [username, password]', () => { + expect(opdsCatalogAdapter.encryptedFields).toEqual(['username', 'password']); + }); + test('pack ∘ unpack is identity for non-credential fields', async () => { const fields = opdsCatalogAdapter.pack(sample); const out = opdsCatalogAdapter.unpack(fields); diff --git a/apps/readest-app/src/__tests__/services/sync/passphraseGate.test.ts b/apps/readest-app/src/__tests__/services/sync/passphraseGate.test.ts new file mode 100644 index 00000000..15752b9f --- /dev/null +++ b/apps/readest-app/src/__tests__/services/sync/passphraseGate.test.ts @@ -0,0 +1,121 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; +import { CryptoSession } from '@/libs/crypto/session'; +import { + ensurePassphraseUnlocked, + setPassphrasePrompter, + __resetPassphraseGateForTests, +} from '@/services/sync/passphraseGate'; +import { isSyncError } from '@/libs/errors'; +import type { ReplicaKeyRow } from '@/libs/replicaSyncClient'; + +const ITER = 1000; +const PBKDF2_ALG = 'pbkdf2-600k-sha256'; + +const bytesToBase64 = (bytes: Uint8Array): string => { + let s = ''; + for (let i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i]!); + return btoa(s); +}; + +const makeSaltRow = (saltId: string, createdAt: string): ReplicaKeyRow => { + const bytes = new Uint8Array(32); + for (let i = 0; i < 32; i++) bytes[i] = (i + saltId.length) & 0xff; + return { saltId, alg: PBKDF2_ALG, salt: bytesToBase64(bytes), createdAt }; +}; + +class FakeClient { + rows: ReplicaKeyRow[] = []; + async listReplicaKeys(): Promise { + return [...this.rows]; + } + async createReplicaKey(): Promise { + const row = makeSaltRow(`salt-${this.rows.length + 1}`, new Date().toISOString()); + this.rows.push(row); + return row; + } + async forgetReplicaKeys(): Promise { + this.rows = []; + } +} + +describe('ensurePassphraseUnlocked', () => { + let client: FakeClient; + let session: CryptoSession; + + beforeEach(() => { + client = new FakeClient(); + session = new CryptoSession({ client, iterations: ITER }); + }); + + afterEach(() => { + __resetPassphraseGateForTests(); + }); + + test('no-op when session is already unlocked', async () => { + await session.setup('pw'); + const prompter = vi.fn(); + setPassphrasePrompter(prompter); + await ensurePassphraseUnlocked({ session, client }); + expect(prompter).not.toHaveBeenCalled(); + }); + + test('throws NO_PASSPHRASE when no prompter is registered', async () => { + let caught: unknown = null; + try { + await ensurePassphraseUnlocked({ session, client }); + } catch (e) { + caught = e; + } + expect(isSyncError(caught) && caught.code).toBe('NO_PASSPHRASE'); + }); + + test('prompts with kind=setup when account has no salt', async () => { + setPassphrasePrompter(async ({ kind }) => { + expect(kind).toBe('setup'); + return 'pw'; + }); + await ensurePassphraseUnlocked({ session, client }); + expect(session.isUnlocked()).toBe(true); + expect(client.rows).toHaveLength(1); + }); + + test('prompts with kind=unlock when account has a salt', async () => { + // Pre-seed via a different session so this one starts locked. + const seeder = new CryptoSession({ client, iterations: ITER }); + await seeder.setup('pw'); + + setPassphrasePrompter(async ({ kind }) => { + expect(kind).toBe('unlock'); + return 'pw'; + }); + await ensurePassphraseUnlocked({ session, client }); + expect(session.isUnlocked()).toBe(true); + }); + + test('rejects with NO_PASSPHRASE when user cancels (returns null)', async () => { + setPassphrasePrompter(async () => null); + let caught: unknown = null; + try { + await ensurePassphraseUnlocked({ session, client }); + } catch (e) { + caught = e; + } + expect(isSyncError(caught) && caught.code).toBe('NO_PASSPHRASE'); + expect(session.isUnlocked()).toBe(false); + }); + + test('coalesces concurrent calls into a single prompt', async () => { + let calls = 0; + setPassphrasePrompter(async () => { + calls += 1; + return 'pw'; + }); + await Promise.all([ + ensurePassphraseUnlocked({ session, client }), + ensurePassphraseUnlocked({ session, client }), + ensurePassphraseUnlocked({ session, client }), + ]); + expect(calls).toBe(1); + expect(session.isUnlocked()).toBe(true); + }); +}); diff --git a/apps/readest-app/src/__tests__/services/sync/replicaCryptoMiddleware.test.ts b/apps/readest-app/src/__tests__/services/sync/replicaCryptoMiddleware.test.ts new file mode 100644 index 00000000..b85f742a --- /dev/null +++ b/apps/readest-app/src/__tests__/services/sync/replicaCryptoMiddleware.test.ts @@ -0,0 +1,245 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest'; +import { CryptoSession } from '@/libs/crypto/session'; +import { + captureCipherTexts, + cipherTextsChanged, + collectDecryptSuccess, + decryptRowFields, + encryptPackedFields, +} from '@/services/sync/replicaCryptoMiddleware'; +import { isCipherEnvelope } from '@/types/replica'; +import type { CipherEnvelope, FieldsObject, Hlc } from '@/types/replica'; +import type { ReplicaKeyRow } from '@/libs/replicaSyncClient'; + +const ITER = 1000; +const PBKDF2_ALG = 'pbkdf2-600k-sha256'; + +const bytesToBase64 = (bytes: Uint8Array): string => { + let s = ''; + for (let i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i]!); + return btoa(s); +}; + +const makeSaltRow = (saltId: string, createdAt: string): ReplicaKeyRow => { + const bytes = new Uint8Array(32); + for (let i = 0; i < 32; i++) bytes[i] = (i + saltId.length) & 0xff; + return { saltId, alg: PBKDF2_ALG, salt: bytesToBase64(bytes), createdAt }; +}; + +class FakeClient { + rows: ReplicaKeyRow[] = []; + async listReplicaKeys(): Promise { + return [...this.rows]; + } + async createReplicaKey(): Promise { + const row = makeSaltRow(`salt-${this.rows.length + 1}`, new Date().toISOString()); + this.rows.push(row); + return row; + } + async forgetReplicaKeys(): Promise { + this.rows = []; + } +} + +const HLC = '00000000001-00000000-dev' as Hlc; +const wrapField = (v: unknown) => ({ v, t: HLC, s: 'dev' }); + +describe('replicaCryptoMiddleware', () => { + let client: FakeClient; + let session: CryptoSession; + + beforeEach(async () => { + client = new FakeClient(); + session = new CryptoSession({ client, iterations: ITER }); + }); + + describe('encryptPackedFields', () => { + test('replaces named fields with cipher envelopes when unlocked', async () => { + await session.setup('pw'); + const packed: Record = { name: 'Public', password: 'hunter2' }; + await encryptPackedFields(packed, ['password'], session); + expect(packed['name']).toBe('Public'); + expect(isCipherEnvelope(packed['password'])).toBe(true); + }); + + test('drops named fields when session is locked (no plaintext leak)', async () => { + const packed: Record = { name: 'Public', password: 'hunter2' }; + await encryptPackedFields(packed, ['password'], session); + expect(packed['name']).toBe('Public'); + expect(packed['password']).toBeUndefined(); + expect('password' in packed).toBe(false); + }); + + test('skips empty / undefined values without erroring', async () => { + await session.setup('pw'); + const packed: Record = { name: 'X', password: '', username: undefined }; + await encryptPackedFields(packed, ['password', 'username'], session); + expect(packed['password']).toBe(''); + expect(packed['username']).toBeUndefined(); + }); + + test('no-op when encryptedFields is undefined or empty', async () => { + await session.setup('pw'); + const packed: Record = { password: 'hunter2' }; + await encryptPackedFields(packed, undefined, session); + expect(packed['password']).toBe('hunter2'); + await encryptPackedFields(packed, [], session); + expect(packed['password']).toBe('hunter2'); + }); + }); + + describe('decryptRowFields', () => { + test('replaces cipher envelope values with plaintext when unlocked', async () => { + await session.setup('pw'); + const cipher = await session.encryptField('hunter2'); + const fields: FieldsObject = { + name: wrapField('Public'), + password: wrapField(cipher), + }; + await decryptRowFields(fields, ['password'], session); + expect((fields['name'] as { v: unknown }).v).toBe('Public'); + expect((fields['password'] as { v: unknown }).v).toBe('hunter2'); + }); + + test('drops the field on locked session (preserves local plaintext at the merge layer)', async () => { + // Encrypt with one session, decrypt with another that's locked. + const writer = new CryptoSession({ client, iterations: ITER }); + await writer.setup('pw'); + const cipher = await writer.encryptField('secret'); + + const reader = new CryptoSession({ client, iterations: ITER }); + const fields: FieldsObject = { password: wrapField(cipher) }; + await decryptRowFields(fields, ['password'], reader); + expect(fields['password']).toBeUndefined(); + }); + + test('locked session + onLocked callback unlocks then decrypts', async () => { + // Writer creates the cipher payload under one passphrase. + const writer = new CryptoSession({ client, iterations: ITER }); + await writer.setup('correct'); + const cipher = await writer.encryptField('secret'); + + // Reader starts locked but has the same backing FakeClient (so + // it can find the salt). The onLocked callback unlocks the + // reader with the right passphrase — the middleware should + // detect the now-unlocked state and decrypt. + const reader = new CryptoSession({ client, iterations: ITER }); + const onLocked = vi.fn(async () => { + await reader.unlock('correct'); + }); + const fields: FieldsObject = { + password: wrapField(cipher), + username: wrapField(await writer.encryptField('alice')), + }; + await decryptRowFields(fields, ['username', 'password'], reader, onLocked); + // Callback fired exactly once even though there are two cipher + // fields — once unlocked, the second field decrypts directly. + expect(onLocked).toHaveBeenCalledTimes(1); + expect((fields['username'] as { v: unknown }).v).toBe('alice'); + expect((fields['password'] as { v: unknown }).v).toBe('secret'); + }); + + test('onLocked rejection drops the cipher fields cleanly', async () => { + const writer = new CryptoSession({ client, iterations: ITER }); + await writer.setup('pw'); + const cipher = await writer.encryptField('secret'); + + const reader = new CryptoSession({ client, iterations: ITER }); + const onLocked = vi.fn(async () => { + throw new Error('user cancelled'); + }); + const fields: FieldsObject = { password: wrapField(cipher) }; + await decryptRowFields(fields, ['password'], reader, onLocked); + expect(onLocked).toHaveBeenCalledTimes(1); + expect(fields['password']).toBeUndefined(); + }); + + test('drops the field on wrong-passphrase decrypt failure', async () => { + const writer = new CryptoSession({ client, iterations: ITER }); + await writer.setup('correct'); + const cipher = await writer.encryptField('secret'); + + const reader = new CryptoSession({ client, iterations: ITER }); + await reader.unlock('wrong'); + const fields: FieldsObject = { password: wrapField(cipher) }; + await decryptRowFields(fields, ['password'], reader); + expect(fields['password']).toBeUndefined(); + }); + + test('leaves plaintext envelopes untouched (no cipher detected)', async () => { + await session.setup('pw'); + const fields: FieldsObject = { + password: wrapField('not-encrypted-but-we-asked'), + }; + await decryptRowFields(fields, ['password'], session); + // Not a cipher envelope → middleware leaves it alone. + expect((fields['password'] as { v: unknown }).v).toBe('not-encrypted-but-we-asked'); + }); + + test('no-op when encryptedFields is undefined or empty', async () => { + await session.setup('pw'); + const cipher: CipherEnvelope = { c: 'x', i: 'y', s: 'salt-1', alg: 'rot13', h: 'z' }; + const fields: FieldsObject = { password: wrapField(cipher) }; + await decryptRowFields(fields, undefined, session); + expect(fields['password']).toBeDefined(); + }); + }); + + describe('captureCipherTexts / cipherTextsChanged / collectDecryptSuccess', () => { + test('captureCipherTexts returns each cipher field by name', async () => { + await session.setup('pw'); + const userCipher = await session.encryptField('alice'); + const passCipher = await session.encryptField('hunter2'); + const fields: FieldsObject = { + username: wrapField(userCipher), + password: wrapField(passCipher), + name: wrapField('Public'), + }; + const out = captureCipherTexts(fields, ['username', 'password']); + expect(out['username']).toBe(userCipher.c); + expect(out['password']).toBe(passCipher.c); + expect(out['name']).toBeUndefined(); + }); + + test('cipherTextsChanged: undefined lastSeen counts as changed (fresh device)', () => { + expect(cipherTextsChanged({ password: 'abc' }, undefined)).toBe(true); + }); + + test('cipherTextsChanged: same ciphers → false (skip prompt path)', () => { + expect( + cipherTextsChanged({ username: 'a', password: 'b' }, { username: 'a', password: 'b' }), + ).toBe(false); + }); + + test('cipherTextsChanged: differing cipher → true (rotation path)', () => { + expect(cipherTextsChanged({ password: 'new' }, { password: 'old' })).toBe(true); + }); + + test('cipherTextsChanged: new field not in lastSeen → true', () => { + expect(cipherTextsChanged({ username: 'a', password: 'b' }, { username: 'a' })).toBe(true); + }); + + test('collectDecryptSuccess records only fields whose decrypt succeeded', async () => { + const writer = new CryptoSession({ client, iterations: ITER }); + await writer.setup('pw'); + const userCipher = await writer.encryptField('alice'); + const passCipher = await writer.encryptField('hunter2'); + const before = { username: userCipher.c, password: passCipher.c }; + + // Reader will succeed on user, "fail" on password by deleting before decrypt. + const reader = new CryptoSession({ client, iterations: ITER }); + await reader.unlock('pw'); + const fields: FieldsObject = { + username: wrapField(userCipher), + password: wrapField(passCipher), + }; + await decryptRowFields(fields, ['username'], reader); // only decrypt username + // Pretend the password decrypt failed by removing it. + delete fields['password']; + + const out = collectDecryptSuccess(fields, before); + expect(out['username']).toBe(userCipher.c); + expect(out['password']).toBeUndefined(); + }); + }); +}); diff --git a/apps/readest-app/src/app/opds/components/CatalogManager.tsx b/apps/readest-app/src/app/opds/components/CatalogManager.tsx index 79aeaf86..d8e08668 100644 --- a/apps/readest-app/src/app/opds/components/CatalogManager.tsx +++ b/apps/readest-app/src/app/opds/components/CatalogManager.tsx @@ -18,6 +18,8 @@ import { useEnv } from '@/context/EnvContext'; import { useTranslation } from '@/hooks/useTranslation'; import { isWebAppPlatform } from '@/services/environment'; import { useCustomOPDSStore } from '@/store/customOPDSStore'; +import { ensurePassphraseUnlocked } from '@/services/sync/passphraseGate'; +import { isSyncError } from '@/libs/errors'; import { OPDSCatalog } from '@/types/opds'; import { isLanAddress } from '@/utils/network'; import { eventDispatcher } from '@/utils/event'; @@ -211,6 +213,27 @@ export function CatalogManager() { ? parsedHeaders.headers : undefined; + // If the user provided credentials, unlock (or set up) the sync + // passphrase BEFORE saving. The crypto middleware drops creds + // from the push when the session is locked, so this gate is what + // turns the credentials into actual cross-device sync. User + // cancel = save proceeds without sync (the catalog still works + // locally with the entered creds). + const hasCredentials = !!(newCatalog.username || newCatalog.password); + if (hasCredentials) { + try { + await ensurePassphraseUnlocked(); + } catch (err) { + if (!(isSyncError(err) && err.code === 'NO_PASSPHRASE')) { + // Surface unexpected errors; cancel-by-user is silent. + setUrlError(err instanceof Error ? err.message : String(err)); + setIsValidating(false); + return; + } + // User cancelled the prompt — save locally without encrypted sync. + } + } + if (editingCatalogId) { useCustomOPDSStore.getState().updateCatalog(editingCatalogId, { name: newCatalog.name, diff --git a/apps/readest-app/src/app/user/components/SyncPassphraseSection.tsx b/apps/readest-app/src/app/user/components/SyncPassphraseSection.tsx new file mode 100644 index 00000000..250729b5 --- /dev/null +++ b/apps/readest-app/src/app/user/components/SyncPassphraseSection.tsx @@ -0,0 +1,111 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { useTranslation } from '@/hooks/useTranslation'; +import { cryptoSession } from '@/libs/crypto/session'; +import { ensurePassphraseUnlocked } from '@/services/sync/passphraseGate'; +import { replicaSyncClient } from '@/libs/replicaSyncClient'; +import { isSyncError } from '@/libs/errors'; + +type SyncPassphraseStatus = 'loading' | 'unset' | 'set' | 'error'; + +const isAuthError = (err: unknown): boolean => isSyncError(err) && err.code === 'AUTH'; + +export function SyncPassphraseSection() { + const _ = useTranslation(); + const [status, setStatus] = useState('loading'); + const [busy, setBusy] = useState(false); + const [message, setMessage] = useState(null); + + const refreshStatus = async () => { + try { + const rows = await replicaSyncClient.listReplicaKeys(); + setStatus(rows.length === 0 ? 'unset' : 'set'); + setMessage(null); + } catch (err) { + if (isAuthError(err)) { + // Not signed in — hide the panel by leaving status as 'loading' + // until the auth context re-renders. + return; + } + setStatus('error'); + } + }; + + useEffect(() => { + void refreshStatus(); + }, []); + + if (status === 'loading') return null; + + // "Unlock now" lets the user proactively enter the passphrase ahead + // of an action that needs it (e.g., adding a credentialed catalog + // right after a hard refresh) so they don't get interrupted by a + // modal mid-flow. When the session is already unlocked it's a quick + // no-op that just confirms readiness. The page refresh itself is + // the "lock this device" action — there's no separate Lock button. + const handleSetOrUnlock = async () => { + setBusy(true); + setMessage(null); + try { + await ensurePassphraseUnlocked(); + await refreshStatus(); + setMessage(_('Sync passphrase ready')); + } catch (err) { + setMessage(err instanceof Error ? err.message : String(err)); + } finally { + setBusy(false); + } + }; + + const handleForget = async () => { + if ( + !confirm( + _( + 'This permanently deletes the encrypted credentials we sync (e.g., OPDS catalog passwords) on every device. Local copies are preserved. You will need to re-enter the sync passphrase or set a new one. Continue?', + ), + ) + ) { + return; + } + setBusy(true); + setMessage(null); + try { + await cryptoSession.forget(); + await refreshStatus(); + setMessage(_('Sync passphrase forgotten — all encrypted fields cleared')); + } catch (err) { + setMessage(err instanceof Error ? err.message : String(err)); + } finally { + setBusy(false); + } + }; + + return ( +
+

{_('Sync passphrase')}

+

+ {status === 'unset' + ? _( + 'Encrypts sensitive synced fields (like OPDS catalog credentials) before they leave your device. Set one now or wait — it will be requested when needed.', + ) + : _('Set on this account. Will be requested when needed to decrypt synced credentials.')} +

+ {message &&

{message}

} +
+ + {status === 'set' && ( + + )} +
+
+ ); +} diff --git a/apps/readest-app/src/app/user/page.tsx b/apps/readest-app/src/app/user/page.tsx index 5fd4b223..7d3dcfe7 100644 --- a/apps/readest-app/src/app/user/page.tsx +++ b/apps/readest-app/src/app/user/page.tsx @@ -41,6 +41,7 @@ import PlansComparison from './components/PlansComparison'; import AccountActions from './components/AccountActions'; import StorageManager from './components/StorageManager'; import SharedLinksSection from './components/SharedLinksSection'; +import { SyncPassphraseSection } from './components/SyncPassphraseSection'; import Checkout from './components/Checkout'; type CheckoutState = { @@ -325,6 +326,7 @@ const ProfilePage = () => { />
+ void; +} + +/** + * Singleton passphrase prompt for the encrypted-fields flow. Mount + * once at the app root. Registers itself with the passphrase gate; + * any caller that invokes `ensurePassphraseUnlocked` causes this + * modal to render and resolve with the entered passphrase (or null + * on cancel). + */ +export default function PassphrasePrompt() { + const _ = useTranslation(); + const [pending, setPending] = useState(null); + const [value, setValue] = useState(''); + const [confirm, setConfirm] = useState(''); + const [error, setError] = useState(''); + const inputRef = useRef(null); + + useEffect(() => { + setPassphrasePrompter(({ kind }) => { + return new Promise((resolve) => { + setValue(''); + setConfirm(''); + setError(''); + setPending({ kind, resolve }); + }); + }); + return () => setPassphrasePrompter(null); + }, []); + + useEffect(() => { + if (pending) { + const t = setTimeout(() => inputRef.current?.focus(), 0); + return () => clearTimeout(t); + } + return undefined; + }, [pending]); + + if (!pending) return null; + + const isSetup = pending.kind === 'setup'; + + const close = (passphrase: string | null) => { + pending.resolve(passphrase); + setPending(null); + setValue(''); + setConfirm(''); + setError(''); + }; + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (value.length < 8) { + setError(_('Passphrase must be at least 8 characters')); + return; + } + if (isSetup && value !== confirm) { + setError(_('Passphrases do not match')); + return; + } + close(value); + }; + + // Input pill — modern style for color themes; eink-bordered swaps to + // 1px border + base-100 bg under [data-eink='true']. + const inputClass = + 'eink-bordered w-full rounded-xl bg-base-300/60 px-4 py-3 text-sm placeholder:text-base-content/40 ' + + 'border border-transparent transition-colors focus:border-base-content/20 focus:bg-base-300'; + + return ( + + +
+

+ {isSetup ? _('Set sync passphrase') : _('Enter sync passphrase')} +

+

+ {isSetup + ? _( + 'A sync passphrase encrypts your sensitive fields (like OPDS catalog credentials) before they sync. We never see this passphrase. Pick something memorable — there is no recovery without it.', + ) + : _( + 'Enter the sync passphrase you set on another device to decrypt your synced credentials.', + )} +

+
+ { + setValue(e.target.value); + setError(''); + }} + placeholder={_('Sync passphrase')} + className={inputClass} + autoComplete='new-password' + required + /> + {isSetup && ( + { + setConfirm(e.target.value); + setError(''); + }} + placeholder={_('Confirm passphrase')} + className={inputClass} + autoComplete='new-password' + required + /> + )} + {error &&

{error}

} +
+ {/* + * Cancel: ghost in color themes, eink-bordered (white bg + * + base-content border) under eink. Submit: bg-primary + * in color themes, picks up the existing + * `[data-eink] .btn-primary` rule (inverted to + * base-content bg + base-100 text) under eink — so the + * two buttons stay visually distinct on e-paper. + */} + + +
+
+
+
+
+ ); +} diff --git a/apps/readest-app/src/components/Providers.tsx b/apps/readest-app/src/components/Providers.tsx index 347a671e..6346e334 100644 --- a/apps/readest-app/src/components/Providers.tsx +++ b/apps/readest-app/src/components/Providers.tsx @@ -19,6 +19,9 @@ import { getDirFromUILanguage } from '@/utils/rtl'; import { DropdownProvider } from '@/context/DropdownContext'; import { CommandPaletteProvider, CommandPalette } from '@/components/command-palette'; import AtmosphereOverlay from '@/components/AtmosphereOverlay'; +import PassphrasePrompt from '@/components/PassphrasePrompt'; +import { upgradeToKeychainIfAvailable } from '@/libs/crypto/passphrase'; +import { cryptoSession } from '@/libs/crypto/session'; const Providers = ({ children }: { children: React.ReactNode }) => { const { envConfig, appService } = useEnv(); @@ -63,6 +66,18 @@ const Providers = ({ children }: { children: React.ReactNode }) => { } }, [envConfig, appService, applyUILanguage, applyBackgroundTexture, applyEinkMode]); + // Sync-passphrase boot path: upgrade the passphrase store from + // ephemeral to OS keychain on Tauri (probe is async — must run after + // the platform check resolves), then attempt a silent unlock from + // the saved passphrase. Failures are silent — the gate prompts on + // first encrypted-field operation if we couldn't restore. + useEffect(() => { + void (async () => { + await upgradeToKeychainIfAvailable(); + await cryptoSession.tryRestoreFromStore(); + })(); + }, []); + // Make sure appService is available in all children components if (!appService) return; @@ -76,6 +91,7 @@ const Providers = ({ children }: { children: React.ReactNode }) => { {children} + diff --git a/apps/readest-app/src/components/settings/color/BackgroundTextureSelector.tsx b/apps/readest-app/src/components/settings/color/BackgroundTextureSelector.tsx index 889ca2e4..4f8d9e31 100644 --- a/apps/readest-app/src/components/settings/color/BackgroundTextureSelector.tsx +++ b/apps/readest-app/src/components/settings/color/BackgroundTextureSelector.tsx @@ -47,10 +47,23 @@ const BackgroundTextureSelector: React.FC = ({

{_('Background Image')}

{allTextures.map((texture) => ( - )} - +
))} {/* Custom Image Upload */} diff --git a/apps/readest-app/src/hooks/useReplicaPull.ts b/apps/readest-app/src/hooks/useReplicaPull.ts index 8e5f9862..236d9b3e 100644 --- a/apps/readest-app/src/hooks/useReplicaPull.ts +++ b/apps/readest-app/src/hooks/useReplicaPull.ts @@ -40,12 +40,12 @@ export type ReplicaKind = 'dictionary' | 'font' | 'texture' | 'opds_catalog'; export interface UseReplicaPullOpts { /** Replica kinds this page wants pulled. */ kinds: readonly ReplicaKind[]; - /** Delay before firing the pull. Defaults to 10s — keeps the boot + /** Delay before firing the pull. Defaults to 5s — keeps the boot * critical path clean and lets feature mounts hydrate first. */ delayMs?: number; } -const REPLICA_PULL_DEFAULT_DELAY_MS = 10_000; +const REPLICA_PULL_DEFAULT_DELAY_MS = 5_000; // Module-level dedup so navigating between pages (library → reader → …) // doesn't fire a fresh pull every time. Periodic resync is handled by diff --git a/apps/readest-app/src/libs/crypto/passphrase.ts b/apps/readest-app/src/libs/crypto/passphrase.ts index e4434a8d..a8ba1461 100644 --- a/apps/readest-app/src/libs/crypto/passphrase.ts +++ b/apps/readest-app/src/libs/crypto/passphrase.ts @@ -1,4 +1,11 @@ import { SyncError } from '@/libs/errors'; +import { isTauriAppPlatform } from '@/services/environment'; +import { + clearSyncPassphrase, + getSyncPassphrase, + isSyncKeychainAvailable, + setSyncPassphrase, +} from '@/utils/bridge'; export interface PassphraseStore { set(passphrase: string): Promise; @@ -7,6 +14,22 @@ export interface PassphraseStore { isAvailable(): boolean; } +/** + * In-memory passphrase store. Lifetime is "this page load" — every + * hard refresh wipes it. This is the default on web; the cipher + * fingerprint heuristic in replicaPullAndApply.applyRow makes sure a + * fresh page doesn't re-prompt on pull as long as the local copy's + * lastSeenCipher matches the row's incoming cipher. + * + * The only path that still re-prompts after refresh is adding a new + * credentialed catalog (the encrypt path needs an unlocked session). + * That's an infrequent action; not worth the XSS surface of + * persisting the passphrase to localStorage / sessionStorage. + * + * On Tauri, this store is replaced at boot by TauriPassphraseStore + * via upgradeToKeychainIfAvailable; the OS keychain provides + * cross-launch persistence on native without the XSS surface. + */ export class EphemeralPassphraseStore implements PassphraseStore { private value: string | null = null; @@ -27,23 +50,91 @@ export class EphemeralPassphraseStore implements PassphraseStore { } } +/** + * OS-keychain backed passphrase store. Calls the native-bridge plugin + * commands wired in PR 4d: + * * macOS / Windows / Linux desktop → keyring crate (Security + * framework / Credential Manager / libsecret). + * * iOS → Security framework Keychain via SecItemAdd/Copy/Delete. + * * Android → EncryptedSharedPreferences (AndroidKeystore-derived + * AES-GCM master key). + * + * `set` is fail-loud: if the keychain rejects the write, the caller + * sees the error so the UI can warn the user. `get` is fail-soft: + * keychain errors return null so the gate falls back to prompting. + */ export class TauriPassphraseStore implements PassphraseStore { - async set(_passphrase: string): Promise { - throw new SyncError( - 'CRYPTO_UNAVAILABLE', - 'Tauri keychain backend not yet wired (TODO before PR 4 ships).', - ); + async set(passphrase: string): Promise { + const res = await setSyncPassphrase({ passphrase }); + if (!res.success) { + throw new SyncError( + 'CRYPTO_UNAVAILABLE', + `OS keychain rejected the passphrase: ${res.error ?? 'unknown error'}`, + ); + } } async get(): Promise { - return null; + try { + const res = await getSyncPassphrase(); + if (res.error) { + console.warn('[passphrase] keychain get failed', res.error); + return null; + } + return res.passphrase ?? null; + } catch (err) { + console.warn('[passphrase] keychain get threw', err); + return null; + } } - async clear(): Promise {} + async clear(): Promise { + try { + await clearSyncPassphrase(); + } catch (err) { + console.warn('[passphrase] keychain clear threw', err); + } + } isAvailable(): boolean { - return false; + return true; } } -export const createPassphraseStore = (): PassphraseStore => new EphemeralPassphraseStore(); +let cached: PassphraseStore | null = null; + +/** + * Pick the right backend at boot. On Tauri, probe the native bridge + * once and cache the result; if the keychain is reachable use it, + * otherwise fall back to the ephemeral store. The probe is async; the + * sync `createPassphraseStore` returns ephemeral immediately and + * `upgradeToKeychainIfAvailable` swaps the cached backend in once the + * probe resolves. CryptoSession reads the current backend each time + * it touches storage, so the swap is transparent. + */ +export const createPassphraseStore = (): PassphraseStore => { + if (cached) return cached; + cached = new EphemeralPassphraseStore(); + return cached; +}; + +export const upgradeToKeychainIfAvailable = async (): Promise => { + const current = createPassphraseStore(); + if (current instanceof TauriPassphraseStore) return current; + if (!isTauriAppPlatform()) return current; + try { + const res = await isSyncKeychainAvailable(); + if (res.available) { + cached = new TauriPassphraseStore(); + return cached; + } + } catch (err) { + console.warn('[passphrase] keychain probe threw, staying on ephemeral', err); + } + return current; +}; + +/** Test seam — reset the cached backend between specs. */ +export const __resetPassphraseStoreForTests = (): void => { + cached = null; +}; diff --git a/apps/readest-app/src/libs/crypto/session.ts b/apps/readest-app/src/libs/crypto/session.ts index af2da4db..896d8930 100644 --- a/apps/readest-app/src/libs/crypto/session.ts +++ b/apps/readest-app/src/libs/crypto/session.ts @@ -4,6 +4,8 @@ import { replicaSyncClient } from '@/libs/replicaSyncClient'; import type { ReplicaKeyRow, ReplicaSyncClient } from '@/libs/replicaSyncClient'; import { derivePbkdf2Key } from './derive'; import { CURRENT_ALG, decryptFromEnvelope, encryptToEnvelope } from './envelope'; +import { createPassphraseStore } from './passphrase'; +import type { PassphraseStore } from './passphrase'; const PBKDF2_ALG = 'pbkdf2-600k-sha256'; @@ -21,9 +23,15 @@ interface KnownSalt { } export interface CryptoSessionDeps { - client?: Pick; + client?: Pick; /** Override PBKDF2 iterations. Tests pass a low value; production omits. */ iterations?: number; + /** + * Where to persist the passphrase across app launches. Production + * defaults to the lazy module-level store (ephemeral on web, + * upgrades to OS keychain on Tauri). Tests pass a mock. + */ + store?: PassphraseStore; } export class CryptoSession { @@ -31,12 +39,27 @@ export class CryptoSession { private salts = new Map(); private keys = new Map(); private activeSaltId: string | null = null; - private readonly client: Pick; + private readonly client: Pick< + ReplicaSyncClient, + 'listReplicaKeys' | 'createReplicaKey' | 'forgetReplicaKeys' + >; private readonly iterations: number | undefined; + /** + * Optional store override, only set when a test passes a mock. + * Production resolves the store via `createPassphraseStore()` on + * every storage touch so the keychain upgrade (probed + * asynchronously at boot) is picked up transparently. + */ + private readonly storeOverride: PassphraseStore | undefined; constructor(deps: CryptoSessionDeps = {}) { this.client = deps.client ?? replicaSyncClient; this.iterations = deps.iterations; + this.storeOverride = deps.store; + } + + private store(): PassphraseStore { + return this.storeOverride ?? createPassphraseStore(); } isUnlocked(): boolean { @@ -54,6 +77,8 @@ export class CryptoSession { /** * Derive against the user's existing newest salt. Throws NO_PASSPHRASE if * the account has no salt yet — callers must call setup() instead. + * On success, persists the passphrase to the configured store so the + * next launch can silently restore (Tauri keychain) or re-prompt (web). */ async unlock(passphrase: string): Promise { const rows = await this.client.listReplicaKeys(); @@ -67,12 +92,33 @@ export class CryptoSession { this.passphrase = passphrase; this.activeSaltId = rows[0]!.saltId; await this.deriveKeyFor(this.activeSaltId); + await this.persistPassphrase(passphrase); + } + + /** + * Forget the user's passphrase entirely: server-side wipe of every + * encrypted-field envelope across all the user's replica rows + drop + * every salt + clear the local keychain entry. The next encrypted + * push from any device will mint a fresh salt + key. Local plaintext + * copies on each device are preserved — the user just has to + * re-enter the sync passphrase (or set a new one) to start re- + * encrypting. + */ + async forget(): Promise { + await this.client.forgetReplicaKeys(); + try { + await this.store().clear(); + } catch (err) { + console.warn('[cryptoSession] failed to clear passphrase store', err); + } + this.lock(); } /** * Create a fresh salt server-side, then derive against it. Used on first * passphrase setup. If a salt already exists this still appends a new one, - * matching passphrase-rotation semantics. + * matching passphrase-rotation semantics. On success, persists the + * passphrase to the configured store. */ async setup(passphrase: string): Promise { const row = await this.client.createReplicaKey(PBKDF2_ALG); @@ -80,6 +126,51 @@ export class CryptoSession { this.passphrase = passphrase; this.activeSaltId = row.saltId; await this.deriveKeyFor(row.saltId); + await this.persistPassphrase(passphrase); + } + + /** + * Try to silently unlock the session by reading a previously-saved + * passphrase from the store (OS keychain on Tauri). No-op when: + * * already unlocked + * * no passphrase is stored locally + * * the account has no salt server-side (treated as "no setup yet") + * * any underlying call throws (logged + swallowed) + * + * Called from the Providers boot effect so the user doesn't see the + * passphrase modal on every app launch on native. + */ + async tryRestoreFromStore(): Promise { + if (this.isUnlocked()) return true; + try { + const saved = await this.store().get(); + if (!saved) return false; + const rows = await this.client.listReplicaKeys(); + if (rows.length === 0) { + // Account has no salt: stale local entry. Clean it up so the + // next prompt path runs setup() cleanly. + await this.store().clear(); + return false; + } + this.ingestRows(rows); + this.passphrase = saved; + this.activeSaltId = rows[0]!.saltId; + await this.deriveKeyFor(this.activeSaltId); + return true; + } catch (err) { + console.warn('[cryptoSession] silent restore failed', err); + return false; + } + } + + private async persistPassphrase(passphrase: string): Promise { + try { + await this.store().set(passphrase); + } catch (err) { + // Persistence failure is non-fatal: the session is unlocked in + // memory for this run, but we log so a broken keychain shows up. + console.warn('[cryptoSession] failed to persist passphrase', err); + } } async encryptField(plaintext: string): Promise { diff --git a/apps/readest-app/src/libs/replicaSchemas.ts b/apps/readest-app/src/libs/replicaSchemas.ts index c243c817..538833d5 100644 --- a/apps/readest-app/src/libs/replicaSchemas.ts +++ b/apps/readest-app/src/libs/replicaSchemas.ts @@ -62,6 +62,11 @@ const opdsCatalogFieldsSchema = z autoDownload: fieldEnvelopeSchema.optional(), disabled: fieldEnvelopeSchema.optional(), addedAt: fieldEnvelopeSchema.optional(), + // Encrypted-credential fields. The CRDT envelope wraps a cipher + // envelope as `v` when the publishing device had its CryptoSession + // unlocked; otherwise the field is omitted from the row. + username: fieldEnvelopeWithCipher.optional(), + password: fieldEnvelopeWithCipher.optional(), }) .catchall(fieldEnvelopeWithCipher); diff --git a/apps/readest-app/src/libs/replicaSyncClient.ts b/apps/readest-app/src/libs/replicaSyncClient.ts index 695e61ed..84510fcd 100644 --- a/apps/readest-app/src/libs/replicaSyncClient.ts +++ b/apps/readest-app/src/libs/replicaSyncClient.ts @@ -122,6 +122,28 @@ export class ReplicaSyncClient { return data.rows ?? []; } + async forgetReplicaKeys(): Promise { + const token = await requireToken(); + let response: Response; + try { + response = await fetch(KEYS_ENDPOINT(), { + method: 'DELETE', + headers: { Authorization: `Bearer ${token}` }, + }); + } catch (cause) { + throw new SyncError('SERVER', 'Network failure during replica-keys forget', { cause }); + } + if (!response.ok) { + const body = await parseErrorBody(response); + const code = body.code ?? statusToDefaultCode(response.status); + throw new SyncError( + code, + body.error ?? `replica-keys forget failed with status ${response.status}`, + { status: response.status }, + ); + } + } + async createReplicaKey(alg: string): Promise { const token = await requireToken(); let response: Response; diff --git a/apps/readest-app/src/pages/api/sync/replica-keys.ts b/apps/readest-app/src/pages/api/sync/replica-keys.ts index b4aa90ee..5d467bc8 100644 --- a/apps/readest-app/src/pages/api/sync/replica-keys.ts +++ b/apps/readest-app/src/pages/api/sync/replica-keys.ts @@ -77,6 +77,20 @@ export async function POST(req: NextRequest) { return NextResponse.json({ row: toResponseRow(data) }, { status: 201 }); } +export async function DELETE(req: NextRequest) { + const { user, token } = await validateUserAndToken(req.headers.get('authorization')); + if (!user || !token) { + return errorResponse(401, 'AUTH', 'Not authenticated'); + } + const supabase = createSupabaseClient(token); + const { error } = await supabase.rpc('replica_keys_forget'); + if (error) { + console.error('replica_keys_forget failed', { userId: user.id, error }); + return errorResponse(500, 'SERVER', error.message); + } + return NextResponse.json({ ok: true }, { status: 200 }); +} + const handler = async (req: NextApiRequest, res: NextApiResponse) => { if (!req.url) { return res.status(400).json({ error: 'Invalid request URL' }); @@ -102,8 +116,14 @@ const handler = async (req: NextApiRequest, res: NextApiResponse) => { body: JSON.stringify(req.body), }); response = await POST(nextReq); + } else if (req.method === 'DELETE') { + const nextReq = new NextRequest(url.toString(), { + headers: new Headers(req.headers as Record), + method: 'DELETE', + }); + response = await DELETE(nextReq); } else { - res.setHeader('Allow', ['GET', 'POST']); + res.setHeader('Allow', ['GET', 'POST', 'DELETE']); return res.status(405).json({ error: 'Method Not Allowed' }); } diff --git a/apps/readest-app/src/services/sync/adapters/opdsCatalog.ts b/apps/readest-app/src/services/sync/adapters/opdsCatalog.ts index 0ee2d84d..ded99266 100644 --- a/apps/readest-app/src/services/sync/adapters/opdsCatalog.ts +++ b/apps/readest-app/src/services/sync/adapters/opdsCatalog.ts @@ -16,6 +16,8 @@ interface UnwrappedOpdsFields { autoDownload?: boolean; disabled?: boolean; addedAt?: number; + username?: string; + password?: string; } const unwrapOpdsFields = (fields: FieldsObject): UnwrappedOpdsFields => { @@ -27,6 +29,13 @@ const unwrapOpdsFields = (fields: FieldsObject): UnwrappedOpdsFields => { const autoDownload = unwrap(fields['autoDownload']); const disabled = unwrap(fields['disabled']); const addedAt = unwrap(fields['addedAt']); + // Crypto middleware decrypted these in place before unpackRow ran + // (see replicaCryptoMiddleware.decryptRowFields). A missing entry + // means either the publishing device hadn't unlocked yet or the + // local CryptoSession couldn't decrypt — local plaintext copy is + // preserved by customOPDSStore.applyRemoteCatalog. + const username = unwrap(fields['username']); + const password = unwrap(fields['password']); return { name: typeof name === 'string' ? name : undefined, url: typeof url === 'string' ? url : undefined, @@ -39,6 +48,8 @@ const unwrapOpdsFields = (fields: FieldsObject): UnwrappedOpdsFields => { autoDownload: autoDownload === true ? true : undefined, disabled: disabled === true ? true : undefined, addedAt: typeof addedAt === 'number' ? addedAt : undefined, + username: typeof username === 'string' ? username : undefined, + password: typeof password === 'string' ? password : undefined, }; }; @@ -68,6 +79,13 @@ export const opdsCatalogAdapter: ReplicaAdapter = { if (catalog.customHeaders !== undefined) fields['customHeaders'] = catalog.customHeaders; if (catalog.autoDownload !== undefined) fields['autoDownload'] = catalog.autoDownload; if (catalog.disabled !== undefined) fields['disabled'] = catalog.disabled; + // Pass credentials as plaintext here — the publish-side crypto + // middleware (replicaCryptoMiddleware.encryptPackedFields) wraps + // them in cipher envelopes before they hit fields_jsonb. If the + // CryptoSession isn't unlocked, the middleware drops them + // entirely so they don't leak as plaintext. + if (catalog.username !== undefined) fields['username'] = catalog.username; + if (catalog.password !== undefined) fields['password'] = catalog.password; return fields; }, @@ -85,6 +103,8 @@ export const opdsCatalogAdapter: ReplicaAdapter = { autoDownload: fields['autoDownload'] === true ? true : undefined, disabled: fields['disabled'] === true ? true : undefined, addedAt: fields['addedAt'] !== undefined ? Number(fields['addedAt']) : undefined, + username: fields['username'] !== undefined ? String(fields['username']) : undefined, + password: fields['password'] !== undefined ? String(fields['password']) : undefined, }; }, @@ -108,9 +128,15 @@ export const opdsCatalogAdapter: ReplicaAdapter = { if (fields.autoDownload !== undefined) catalog.autoDownload = fields.autoDownload; if (fields.disabled !== undefined) catalog.disabled = fields.disabled; if (fields.addedAt !== undefined) catalog.addedAt = fields.addedAt; + if (fields.username !== undefined) catalog.username = fields.username; + if (fields.password !== undefined) catalog.password = fields.password; if (row.reincarnation) catalog.reincarnation = row.reincarnation; return catalog; }, + // Plaintext slot here; the publish/pull middleware handles the + // crypto round trip. Adapters never see ciphertext. + encryptedFields: ['username', 'password'] as const, + // No `binary` capability — opds_catalog is metadata-only. }; diff --git a/apps/readest-app/src/services/sync/passphraseGate.ts b/apps/readest-app/src/services/sync/passphraseGate.ts new file mode 100644 index 00000000..89dcb8da --- /dev/null +++ b/apps/readest-app/src/services/sync/passphraseGate.ts @@ -0,0 +1,98 @@ +/** + * Coordinates the passphrase prompt UI with the CryptoSession. + * + * - The UI registers a prompter at app boot via `setPassphrasePrompter`. + * - Callers about to sync an encrypted field call `ensurePassphraseUnlocked`. + * - If the session is already unlocked, returns immediately. + * - If the user has an existing salt on the server, the prompter is + * called with `{ kind: 'unlock' }` and the entered passphrase is + * used to derive the existing key. + * - If the server has no salt yet (first encrypted op for the + * account), the prompter is called with `{ kind: 'setup' }` and + * the entered passphrase mints a fresh salt + key. + * - When the user cancels the modal, ensurePassphraseUnlocked rejects + * with `NO_PASSPHRASE`. Callers handle by aborting the sync action + * (e.g., refusing to save credentials, falling back to plaintext-only). + * + * The gate is platform-agnostic — same path on web (ephemeral session) + * and native (also ephemeral until PR 4d wires the keychain). Once 4d + * lands, the only change is that `cryptoSession.unlock` reads the + * passphrase from the keychain on subsequent launches without + * re-prompting. + */ +import { SyncError } from '@/libs/errors'; +import { cryptoSession as defaultCryptoSession } from '@/libs/crypto/session'; +import { replicaSyncClient } from '@/libs/replicaSyncClient'; +import type { CryptoSession } from '@/libs/crypto/session'; +import type { ReplicaSyncClient } from '@/libs/replicaSyncClient'; + +export type PassphrasePromptKind = 'unlock' | 'setup'; + +export interface PassphrasePromptRequest { + kind: PassphrasePromptKind; +} + +export type PassphrasePrompter = (req: PassphrasePromptRequest) => Promise; + +let prompter: PassphrasePrompter | null = null; +let inflight: Promise | null = null; + +export const setPassphrasePrompter = (p: PassphrasePrompter | null): void => { + prompter = p; +}; + +interface EnsureUnlockedDeps { + session?: CryptoSession; + client?: Pick; +} + +/** + * Resolves once the CryptoSession is unlocked. If unlocked already, + * resolves immediately. If a prompt is already in flight, awaits the + * existing one (so concurrent calls don't open multiple modals). + * + * Throws `NO_PASSPHRASE` when the user cancels or no prompter has + * been registered. Throws other SyncError codes for crypto failures + * (CRYPTO_UNAVAILABLE, AUTH, SERVER, ...). + */ +export const ensurePassphraseUnlocked = async (deps: EnsureUnlockedDeps = {}): Promise => { + const session = deps.session ?? defaultCryptoSession; + const client = deps.client ?? replicaSyncClient; + if (session.isUnlocked()) return; + if (inflight) return inflight; + + inflight = (async () => { + try { + if (!prompter) { + throw new SyncError('NO_PASSPHRASE', 'No passphrase prompter registered'); + } + // Decide setup vs unlock by checking whether the server has any + // salt rows for this user. The gate doesn't try to silently + // unlock — it always prompts; the kind argument lets the modal + // render the right copy. + const rows = await client.listReplicaKeys(); + const kind: PassphrasePromptKind = rows.length === 0 ? 'setup' : 'unlock'; + + const passphrase = await prompter({ kind }); + if (passphrase === null || passphrase === '') { + throw new SyncError('NO_PASSPHRASE', 'User cancelled the passphrase prompt'); + } + + if (kind === 'setup') { + await session.setup(passphrase); + } else { + await session.unlock(passphrase); + } + } finally { + inflight = null; + } + })(); + + return inflight; +}; + +/** Test seam — clear in-flight + prompter between specs. */ +export const __resetPassphraseGateForTests = (): void => { + prompter = null; + inflight = null; +}; diff --git a/apps/readest-app/src/services/sync/replicaCryptoMiddleware.ts b/apps/readest-app/src/services/sync/replicaCryptoMiddleware.ts new file mode 100644 index 00000000..d389a16d --- /dev/null +++ b/apps/readest-app/src/services/sync/replicaCryptoMiddleware.ts @@ -0,0 +1,189 @@ +/** + * Encryption middleware for replica adapters with `encryptedFields`. + * + * The publish path runs `encryptPackedFields` between adapter.pack and + * envelope creation; the pull path runs `decryptRowFields` on the row + * fields_jsonb before adapter.unpackRow sees it. Adapters themselves + * stay sync and see plaintext only. + * + * Encryption is best-effort: when the CryptoSession is locked, encrypted + * fields are silently dropped from the push (`encryptPackedFields` + * deletes them from the packed object) and decryption failures on pull + * leave the field absent (`decryptRowFields` deletes the cipher entry) + * so the adapter's unpack sees nothing rather than ciphertext-as-string. + * Local plaintext copies are preserved by the store's applyRemote + * merge — see customOPDSStore.applyRemoteCatalog. + */ +import { isSyncError, SyncError } from '@/libs/errors'; +import { isCipherEnvelope } from '@/types/replica'; +import type { CipherEnvelope, FieldsObject } from '@/types/replica'; +import type { CryptoSession } from '@/libs/crypto/session'; +import { cryptoSession as defaultCryptoSession } from '@/libs/crypto/session'; + +/** + * Encrypt the named fields of a packed-fields object in place. Fields + * with undefined / empty values are skipped. When the session can't + * encrypt (locked, no passphrase, web crypto unavailable), the + * affected fields are deleted from the object so they don't leak as + * plaintext into fields_jsonb. + */ +export const encryptPackedFields = async ( + packed: Record, + encryptedFields: readonly string[] | undefined, + session: CryptoSession = defaultCryptoSession, +): Promise => { + if (!encryptedFields || encryptedFields.length === 0) return; + if (!session.isUnlocked()) { + for (const f of encryptedFields) delete packed[f]; + return; + } + for (const fieldName of encryptedFields) { + const value = packed[fieldName]; + if (value === undefined || value === null || value === '') continue; + try { + packed[fieldName] = await session.encryptField(String(value)); + } catch (err) { + // Encryption failure on a single field shouldn't block the push of + // the other fields. Drop this one and log. + console.warn( + `[replicaCrypto] failed to encrypt field "${fieldName}" — dropping from push`, + err, + ); + delete packed[fieldName]; + } + } +}; + +/** + * Detect whether the row carries at least one cipher envelope in any of + * the named fields. The orchestrator uses this to decide whether to + * trigger a passphrase prompt before the decrypt loop runs — the + * common case (no encrypted credentials on the row) skips the prompt + * entirely. + */ +export const rowHasCipherFields = ( + fields: FieldsObject, + encryptedFields: readonly string[] | undefined, +): boolean => { + if (!encryptedFields || encryptedFields.length === 0) return false; + for (const fieldName of encryptedFields) { + const envelope = fields[fieldName]; + if (!envelope || typeof envelope !== 'object' || !('v' in envelope)) continue; + if (isCipherEnvelope((envelope as { v: unknown }).v)) return true; + } + return false; +}; + +/** + * Snapshot the cipher ciphertexts (the `c` slot of each cipher envelope) + * for the named fields, BEFORE decryptRowFields mutates them in place. + * Used by the orchestrator to detect when a cipher has changed since + * the last pull (rotation / password update on another device). + */ +export const captureCipherTexts = ( + fields: FieldsObject, + encryptedFields: readonly string[] | undefined, +): Record => { + const out: Record = {}; + if (!encryptedFields) return out; + for (const f of encryptedFields) { + const env = fields[f]; + if (!env || typeof env !== 'object' || !('v' in env)) continue; + const v = (env as { v: unknown }).v; + if (isCipherEnvelope(v)) out[f] = (v as { c: string }).c; + } + return out; +}; + +/** + * True if any ciphertext in `current` differs from the corresponding + * entry in `lastSeen`. New cipher fields (not previously seen) count + * as changed — that's the fresh-device path and should prompt. + */ +export const cipherTextsChanged = ( + current: Record, + lastSeen: Record | undefined, +): boolean => { + for (const [f, c] of Object.entries(current)) { + if (!lastSeen || lastSeen[f] !== c) return true; + } + return false; +}; + +/** + * After decryptRowFields runs, walk the named fields and return the + * cipher snapshot for those whose decryption succeeded (the field's + * `v` slot is now a string). Used by the orchestrator to update the + * local record's `lastSeenCipher` so the next pull compares against + * the most recently-decrypted cipher rather than re-prompting. + */ +export const collectDecryptSuccess = ( + fields: FieldsObject, + beforeDecrypt: Record, +): Record => { + const out: Record = {}; + for (const f of Object.keys(beforeDecrypt)) { + const env = fields[f]; + if (!env || typeof env !== 'object' || !('v' in env)) continue; + const v = (env as { v: unknown }).v; + if (typeof v === 'string') out[f] = beforeDecrypt[f]!; + } + return out; +}; + +/** + * Decrypt the named fields of a row's fields_jsonb in place. Each named + * field's CRDT envelope value (the `v` slot) is replaced with the + * decrypted plaintext so the adapter's unpackRow sees a plain value. + * Fields whose envelope value isn't a CipherEnvelope (e.g., the + * publishing device hadn't unlocked yet, or this is a metadata-only + * legacy row) are left untouched. Decrypt failures delete the field + * from fields_jsonb entirely. + * + * `onLocked` is invoked at most once per call when the session is + * locked AND a cipher field is encountered. The orchestrator wires + * this to the passphrase gate so a sync fresh device prompts the user + * before silently dropping the encrypted creds. + */ +export const decryptRowFields = async ( + fields: FieldsObject, + encryptedFields: readonly string[] | undefined, + session: CryptoSession = defaultCryptoSession, + onLocked?: () => Promise, +): Promise => { + if (!encryptedFields || encryptedFields.length === 0) return; + let promptAttempted = false; + for (const fieldName of encryptedFields) { + const envelope = fields[fieldName]; + if (!envelope || typeof envelope !== 'object' || !('v' in envelope)) continue; + const v = (envelope as { v: unknown }).v; + if (!isCipherEnvelope(v)) continue; + // Locked session + cipher field: ask the gate to unlock once per + // decryptRowFields call. If the unlock succeeds, fall through to + // decrypt; if it fails (user cancelled, gate has no prompter), + // drop the field and preserve the local plaintext copy. + if (!session.isUnlocked() && onLocked && !promptAttempted) { + promptAttempted = true; + try { + await onLocked(); + } catch { + // Ignore — the next isUnlocked() check below decides what to do. + } + } + if (!session.isUnlocked()) { + delete fields[fieldName]; + continue; + } + try { + const plaintext = await session.decryptField(v as CipherEnvelope); + (envelope as { v: unknown }).v = plaintext; + } catch (err) { + const code = isSyncError(err) ? (err as SyncError).code : 'unknown'; + console.warn( + `[replicaCrypto] failed to decrypt field "${fieldName}" (${code}) — preserving local copy`, + err, + ); + delete fields[fieldName]; + } + } +}; diff --git a/apps/readest-app/src/services/sync/replicaPublish.ts b/apps/readest-app/src/services/sync/replicaPublish.ts index 9ea0bc81..bc4e8843 100644 --- a/apps/readest-app/src/services/sync/replicaPublish.ts +++ b/apps/readest-app/src/services/sync/replicaPublish.ts @@ -2,6 +2,7 @@ import { setField, removeReplica, hlcMax } from '@/libs/crdt'; import { getUserID } from '@/utils/access'; import { getReplicaAdapter } from './replicaRegistry'; import { getReplicaSync } from './replicaSync'; +import { encryptPackedFields } from './replicaCryptoMiddleware'; import type { FieldsObject, Hlc, ReplicaRow } from '@/types/replica'; /** @@ -29,6 +30,9 @@ export const publishReplicaUpsert = async ( if (!userId) return; const packed = adapter.pack(record); + // Encrypts the named encryptedFields in place. Locked session → + // those fields are dropped from the push (sync without creds). + await encryptPackedFields(packed, adapter.encryptedFields); let fields: FieldsObject = {}; let maxFieldHlc: Hlc | null = null; for (const [key, value] of Object.entries(packed)) { diff --git a/apps/readest-app/src/services/sync/replicaPullAndApply.ts b/apps/readest-app/src/services/sync/replicaPullAndApply.ts index ab899e5f..81eb581e 100644 --- a/apps/readest-app/src/services/sync/replicaPullAndApply.ts +++ b/apps/readest-app/src/services/sync/replicaPullAndApply.ts @@ -3,6 +3,14 @@ import type { ReplicaRow } from '@/types/replica'; import type { ReplicaTransferFile } from '@/store/transferStore'; import type { BaseDir } from '@/types/system'; import type { ReplicaAdapter } from './replicaRegistry'; +import { + captureCipherTexts, + cipherTextsChanged, + collectDecryptSuccess, + decryptRowFields, +} from './replicaCryptoMiddleware'; +import { ensurePassphraseUnlocked } from './passphraseGate'; +import { cryptoSession } from '@/libs/crypto/session'; export interface ReplicaLocalRecord { /** @@ -13,6 +21,12 @@ export interface ReplicaLocalRecord { bundleDir?: string; name: string; deletedAt?: number; + /** + * Per-field cipher fingerprint of the last successfully-decrypted + * pull. Used to skip the passphrase prompt when nothing's changed + * since last apply. See replicaCryptoMiddleware.captureCipherTexts. + */ + lastSeenCipher?: Record; } export interface PullAndApplyDeps { @@ -97,6 +111,40 @@ const applyRow = async ( deps: PullAndApplyDeps, ): Promise => { const local = deps.findByContentId(row.replica_id); + + // Decrypt encrypted-field cipher payloads in place so unpackRow sees + // plaintext. The prompt-decision heuristic compares the row's + // incoming ciphers against the local record's last-seen-cipher + // fingerprint: + // * fingerprint matches → we already have the plaintext for this + // exact ciphertext; skip the prompt + decrypt entirely + // * fingerprint differs (or local has no fingerprint yet) AND + // session is locked → prompt the gate so we can decrypt the new + // value (covers fresh device + password rotation on Device A) + // * session already unlocked → just decrypt; no prompt + // Cancel / failure leaves the field absent; the store's applyRemote + // merge preserves any local plaintext copy. + const encryptedFields = deps.adapter.encryptedFields; + const beforeDecrypt = captureCipherTexts(row.fields_jsonb, encryptedFields); + const localLastSeen = local?.lastSeenCipher; + + const needsPrompt = + !cryptoSession.isUnlocked() && + Object.keys(beforeDecrypt).length > 0 && + cipherTextsChanged(beforeDecrypt, localLastSeen); + const onLocked = needsPrompt ? () => ensurePassphraseUnlocked() : undefined; + await decryptRowFields(row.fields_jsonb, encryptedFields, undefined, onLocked); + + // Build the lastSeenCipher fingerprint to attach to the unpacked + // record. Only fields whose decrypt succeeded contribute — a failed + // decrypt leaves the previous fingerprint in place so we'll re-try + // (and re-prompt) on the next pull. + const decryptedThisRound = collectDecryptSuccess(row.fields_jsonb, beforeDecrypt); + const mergedLastSeen = + Object.keys(decryptedThisRound).length > 0 || localLastSeen + ? { ...(localLastSeen ?? {}), ...decryptedThisRound } + : undefined; + const alive = isReplicaRowAlive(row); if (!alive) { @@ -120,10 +168,25 @@ const applyRow = async ( if (needsBundleDir && !local.bundleDir) return; bundleDir = local.bundleDir ?? ''; displayName = deps.adapter.getDisplayName?.(local) ?? local.name; + // For metadata-only kinds, always re-apply the unpacked row so + // per-field updates merge into the local copy: renames pushed + // from another device, newly-decrypted credentials that weren't + // available on the previous pull (session was locked then), etc. + // The store's applyRemote merge preserves identity-stable local + // state. Binary kinds keep the skip-rebuild semantic so we don't + // re-download files we already have. + if (!needsBundleDir) { + const record = deps.adapter.unpackRow(row, bundleDir); + if (record) { + if (mergedLastSeen) record.lastSeenCipher = mergedLastSeen; + deps.applyRemote(record); + } + } } else { bundleDir = needsBundleDir ? await deps.createBundleDir() : ''; const record = deps.adapter.unpackRow(row, bundleDir); if (!record) return; + if (mergedLastSeen) record.lastSeenCipher = mergedLastSeen; deps.applyRemote(record); displayName = deps.adapter.getDisplayName?.(record) ?? record.name; } diff --git a/apps/readest-app/src/services/sync/replicaRegistry.ts b/apps/readest-app/src/services/sync/replicaRegistry.ts index d3db9701..180904c7 100644 --- a/apps/readest-app/src/services/sync/replicaRegistry.ts +++ b/apps/readest-app/src/services/sync/replicaRegistry.ts @@ -33,6 +33,18 @@ export interface ReplicaAdapter { getDisplayName?(record: T): string; binary?: BinaryCapability; lifecycle?: LifecycleHooks; + /** + * Field names whose values are encrypted before push and decrypted + * after pull. The publish/pull middleware handles the crypto round + * trip via the active CryptoSession; pack/unpack always see plain + * values. Absent or empty means the kind has no encrypted fields. + * + * Encryption is best-effort: if the session isn't unlocked, encrypted + * fields are dropped from the push (the row syncs without them) and + * decrypt failure on pull is logged + the field is omitted from the + * unpacked record so local plaintext copies are preserved. + */ + encryptedFields?: readonly string[]; } const registry = new Map>(); diff --git a/apps/readest-app/src/store/customOPDSStore.ts b/apps/readest-app/src/store/customOPDSStore.ts index 1c5b2c7f..04c0706a 100644 --- a/apps/readest-app/src/store/customOPDSStore.ts +++ b/apps/readest-app/src/store/customOPDSStore.ts @@ -169,13 +169,24 @@ export const useCustomOPDSStore = create((set, get) => ({ set((state) => { const idx = state.catalogs.findIndex((c) => c.contentId === catalog.contentId); if (idx >= 0) { - // Preserve local-only fields (username/password — not yet in the - // synced field set) when overlaying a remote update. + // Preserve local credentials when remote arrives without them + // (publishing device hadn't unlocked the CryptoSession, or the + // local session couldn't decrypt). When remote DOES include + // decrypted creds, accept them — that's the cross-device sync + // path enabled by replicaCryptoMiddleware.decryptRowFields. + // `??` is nullish so an explicit "" from remote (user cleared + // the password) still overwrites. const old = state.catalogs[idx]!; const merged: OPDSCatalog = { ...catalog, - username: old.username, - password: old.password, + username: catalog.username ?? old.username, + password: catalog.password ?? old.password, + // Preserve the previously-applied cipher fingerprint when + // the orchestrator didn't attach a fresh one (e.g., row + // carried no cipher fields, or every decrypt failed). + // Without this fallback the next pull would treat the row + // as "never decrypted" and prompt again unnecessarily. + lastSeenCipher: catalog.lastSeenCipher ?? old.lastSeenCipher, deletedAt: undefined, }; return { catalogs: state.catalogs.map((c, i) => (i === idx ? merged : c)) }; diff --git a/apps/readest-app/src/styles/globals.css b/apps/readest-app/src/styles/globals.css index 0d799a1a..480ea23a 100644 --- a/apps/readest-app/src/styles/globals.css +++ b/apps/readest-app/src/styles/globals.css @@ -821,3 +821,26 @@ body.atmosphere #atmosphere-overlay { .animate-shake { animation: shake 0.8s ease-in-out; } + +/* + * Suppress the daisyUI / browser focus ring on text inputs and + * textareas. The element's own border (input-bordered, textarea-bordered) + * already conveys focus via color shift, so the additional outer ring + * was visually redundant and read as a "double border". Buttons keep + * their focus ring for accessibility — only typed-in surfaces lose it. + */ +input:not([type='checkbox']):not([type='radio']):not([type='range']):focus, +input:not([type='checkbox']):not([type='radio']):not([type='range']):focus-visible, +input:not([type='checkbox']):not([type='radio']):not([type='range']):focus-within, +textarea:focus, +textarea:focus-visible, +textarea:focus-within, +.input:focus, +.input:focus-visible, +.input:focus-within, +.textarea:focus, +.textarea:focus-visible, +.textarea:focus-within { + outline: none !important; + box-shadow: none !important; +} diff --git a/apps/readest-app/src/types/opds.ts b/apps/readest-app/src/types/opds.ts index 89edb114..e57ebffa 100644 --- a/apps/readest-app/src/types/opds.ts +++ b/apps/readest-app/src/types/opds.ts @@ -43,6 +43,16 @@ export interface OPDSCatalog { deletedAt?: number; /** Reincarnation token (re-import after server tombstone). */ reincarnation?: string; + /** + * Per-field cipher fingerprint of the last successfully-decrypted + * pull. Maps `fieldName` → cipher's `c` (base64 ciphertext). The + * orchestrator compares the row's incoming cipher against this on + * each pull: same → skip the passphrase prompt (we already have + * the plaintext); different → prompt to re-decrypt (rotation or + * value change on another device). Sync-only metadata; never + * surfaced in the OPDS UI. + */ + lastSeenCipher?: Record; } export interface OPDSFeed { diff --git a/apps/readest-app/src/utils/bridge.ts b/apps/readest-app/src/utils/bridge.ts index 222bc502..6fcea6b9 100644 --- a/apps/readest-app/src/utils/bridge.ts +++ b/apps/readest-app/src/utils/bridge.ts @@ -214,3 +214,48 @@ export async function getStorefrontRegionCode(): Promise { + return invoke('plugin:native-bridge|set_sync_passphrase', { + payload: request, + }); +} + +export async function getSyncPassphrase(): Promise { + return invoke('plugin:native-bridge|get_sync_passphrase'); +} + +export async function clearSyncPassphrase(): Promise { + return invoke('plugin:native-bridge|clear_sync_passphrase'); +} + +export async function isSyncKeychainAvailable(): Promise { + return invoke('plugin:native-bridge|is_sync_keychain_available'); +} diff --git a/docker/volumes/db/migrations/010_replica_keys_forget.sql b/docker/volumes/db/migrations/010_replica_keys_forget.sql new file mode 100644 index 00000000..78c83721 --- /dev/null +++ b/docker/volumes/db/migrations/010_replica_keys_forget.sql @@ -0,0 +1,53 @@ +-- Migration 010: replica_keys_forget RPC. +-- Wipes every encrypted-field envelope from the user's replica rows +-- (any field whose `v` slot contains a cipher envelope, identified by +-- the `alg` key) and deletes every replica_keys row for the user. The +-- next encrypted push from any device will mint a fresh salt + key. +-- +-- Plaintext fields are untouched. Local plaintext copies on each +-- device survive — the user just has to re-enter their sync passphrase +-- on each device and the encrypted fields will be re-encrypted under +-- the new key on the next push. +-- +-- SECURITY INVOKER: RLS on replicas + replica_keys gates the writes +-- to the calling user's rows. + +CREATE OR REPLACE FUNCTION public.replica_keys_forget() +RETURNS void +LANGUAGE plpgsql +SECURITY INVOKER +AS $$ +DECLARE + v_user_id uuid := (SELECT auth.uid()); +BEGIN + IF v_user_id IS NULL THEN + RAISE EXCEPTION 'replica_keys_forget called without an authenticated user'; + END IF; + + -- Strip cipher envelopes from each row's fields_jsonb. A cipher + -- envelope is the value of a field's `v` slot when that value is an + -- object containing the `alg` key (per CipherEnvelope shape: + -- {c, i, s, alg, h}). Plain field envelopes have a non-object `v`. + UPDATE public.replicas r + SET fields_jsonb = ( + SELECT COALESCE(jsonb_object_agg(key, value), '{}'::jsonb) + FROM jsonb_each(r.fields_jsonb) + WHERE NOT ( + jsonb_typeof(value -> 'v') = 'object' + AND value -> 'v' ? 'alg' + ) + ) + WHERE r.user_id = v_user_id + AND EXISTS ( + SELECT 1 FROM jsonb_each(r.fields_jsonb) e + WHERE jsonb_typeof(e.value -> 'v') = 'object' + AND e.value -> 'v' ? 'alg' + ); + + -- Drop every salt row. The next encrypted push will create a fresh + -- one via replica_keys_create. + DELETE FROM public.replica_keys WHERE user_id = v_user_id; +END; +$$; + +GRANT EXECUTE ON FUNCTION public.replica_keys_forget() TO authenticated;