forked from akai/readest
feat(sync): encrypted OPDS credentials + Tauri keychain (PR 4c + 4d) (#4090)
* 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> * 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) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Generated
+95
-22
@@ -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"
|
||||
|
||||
@@ -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+)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"] }
|
||||
|
||||
@@ -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")
|
||||
|
||||
+90
@@ -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
|
||||
}
|
||||
|
||||
+82
@@ -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")
|
||||
|
||||
+13
@@ -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"]
|
||||
+13
@@ -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"]
|
||||
+13
@@ -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"]
|
||||
+13
@@ -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"]
|
||||
+108
@@ -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.
|
||||
<tr>
|
||||
<td>
|
||||
|
||||
`native-bridge:allow-clear-sync-passphrase`
|
||||
|
||||
</td>
|
||||
<td>
|
||||
|
||||
Enables the clear_sync_passphrase command without any pre-configured scope.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>
|
||||
|
||||
`native-bridge:deny-clear-sync-passphrase`
|
||||
|
||||
</td>
|
||||
<td>
|
||||
|
||||
Denies the clear_sync_passphrase command without any pre-configured scope.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>
|
||||
|
||||
`native-bridge:allow-copy-uri-to-path`
|
||||
|
||||
</td>
|
||||
@@ -333,6 +363,32 @@ Denies the get_storefront_region_code command without any pre-configured scope.
|
||||
<tr>
|
||||
<td>
|
||||
|
||||
`native-bridge:allow-get-sync-passphrase`
|
||||
|
||||
</td>
|
||||
<td>
|
||||
|
||||
Enables the get_sync_passphrase command without any pre-configured scope.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>
|
||||
|
||||
`native-bridge:deny-get-sync-passphrase`
|
||||
|
||||
</td>
|
||||
<td>
|
||||
|
||||
Denies the get_sync_passphrase command without any pre-configured scope.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>
|
||||
|
||||
`native-bridge:allow-get-sys-fonts-list`
|
||||
|
||||
</td>
|
||||
@@ -567,6 +623,32 @@ Denies the intercept_keys command without any pre-configured scope.
|
||||
<tr>
|
||||
<td>
|
||||
|
||||
`native-bridge:allow-is-sync-keychain-available`
|
||||
|
||||
</td>
|
||||
<td>
|
||||
|
||||
Enables the is_sync_keychain_available command without any pre-configured scope.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>
|
||||
|
||||
`native-bridge:deny-is-sync-keychain-available`
|
||||
|
||||
</td>
|
||||
<td>
|
||||
|
||||
Denies the is_sync_keychain_available command without any pre-configured scope.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>
|
||||
|
||||
`native-bridge:allow-lock-screen-orientation`
|
||||
|
||||
</td>
|
||||
@@ -827,6 +909,32 @@ Denies the set_screen_brightness command without any pre-configured scope.
|
||||
<tr>
|
||||
<td>
|
||||
|
||||
`native-bridge:allow-set-sync-passphrase`
|
||||
|
||||
</td>
|
||||
<td>
|
||||
|
||||
Enables the set_sync_passphrase command without any pre-configured scope.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>
|
||||
|
||||
`native-bridge:deny-set-sync-passphrase`
|
||||
|
||||
</td>
|
||||
<td>
|
||||
|
||||
Denies the set_sync_passphrase command without any pre-configured scope.
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td>
|
||||
|
||||
`native-bridge:allow-set-system-ui-visibility`
|
||||
|
||||
</td>
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
+50
-2
@@ -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`"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -199,3 +199,32 @@ pub(crate) async fn request_manage_storage_permission<R: Runtime>(
|
||||
) -> Result<RequestManageStoragePermissionResponse> {
|
||||
app.native_bridge().request_manage_storage_permission()
|
||||
}
|
||||
|
||||
#[command]
|
||||
pub(crate) async fn set_sync_passphrase<R: Runtime>(
|
||||
app: AppHandle<R>,
|
||||
payload: SetSyncPassphraseRequest,
|
||||
) -> Result<SyncPassphraseResponse> {
|
||||
app.native_bridge().set_sync_passphrase(payload)
|
||||
}
|
||||
|
||||
#[command]
|
||||
pub(crate) async fn get_sync_passphrase<R: Runtime>(
|
||||
app: AppHandle<R>,
|
||||
) -> Result<GetSyncPassphraseResponse> {
|
||||
app.native_bridge().get_sync_passphrase()
|
||||
}
|
||||
|
||||
#[command]
|
||||
pub(crate) async fn clear_sync_passphrase<R: Runtime>(
|
||||
app: AppHandle<R>,
|
||||
) -> Result<SyncPassphraseResponse> {
|
||||
app.native_bridge().clear_sync_passphrase()
|
||||
}
|
||||
|
||||
#[command]
|
||||
pub(crate) async fn is_sync_keychain_available<R: Runtime>(
|
||||
app: AppHandle<R>,
|
||||
) -> Result<SyncKeychainAvailableResponse> {
|
||||
app.native_bridge().is_sync_keychain_available()
|
||||
}
|
||||
|
||||
@@ -146,4 +146,85 @@ impl<R: Runtime> NativeBridge<R> {
|
||||
) -> crate::Result<RequestManageStoragePermissionResponse> {
|
||||
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<SyncPassphraseResponse> {
|
||||
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<GetSyncPassphraseResponse> {
|
||||
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<SyncPassphraseResponse> {
|
||||
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<SyncKeychainAvailableResponse> {
|
||||
// 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, keyring::Error> {
|
||||
keyring::Entry::new(KEYRING_SERVICE, KEYRING_USER)
|
||||
}
|
||||
|
||||
@@ -79,6 +79,10 @@ pub fn init<R: Runtime>() -> TauriPlugin<R> {
|
||||
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)]
|
||||
|
||||
@@ -241,3 +241,38 @@ impl<R: Runtime> NativeBridge<R> {
|
||||
.map_err(Into::into)
|
||||
}
|
||||
}
|
||||
|
||||
impl<R: Runtime> NativeBridge<R> {
|
||||
pub fn set_sync_passphrase(
|
||||
&self,
|
||||
payload: SetSyncPassphraseRequest,
|
||||
) -> crate::Result<SyncPassphraseResponse> {
|
||||
self.0
|
||||
.run_mobile_plugin("set_sync_passphrase", payload)
|
||||
.map_err(Into::into)
|
||||
}
|
||||
}
|
||||
|
||||
impl<R: Runtime> NativeBridge<R> {
|
||||
pub fn get_sync_passphrase(&self) -> crate::Result<GetSyncPassphraseResponse> {
|
||||
self.0
|
||||
.run_mobile_plugin("get_sync_passphrase", ())
|
||||
.map_err(Into::into)
|
||||
}
|
||||
}
|
||||
|
||||
impl<R: Runtime> NativeBridge<R> {
|
||||
pub fn clear_sync_passphrase(&self) -> crate::Result<SyncPassphraseResponse> {
|
||||
self.0
|
||||
.run_mobile_plugin("clear_sync_passphrase", ())
|
||||
.map_err(Into::into)
|
||||
}
|
||||
}
|
||||
|
||||
impl<R: Runtime> NativeBridge<R> {
|
||||
pub fn is_sync_keychain_available(&self) -> crate::Result<SyncKeychainAvailableResponse> {
|
||||
self.0
|
||||
.run_mobile_plugin("is_sync_keychain_available", ())
|
||||
.map_err(Into::into)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -237,3 +237,39 @@ pub struct GetStorefrontRegionCodeResponse {
|
||||
pub region_code: Option<String>,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
// ── 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<String>,
|
||||
}
|
||||
|
||||
#[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<String>,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct SyncKeychainAvailableResponse {
|
||||
pub available: bool,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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<unknown>>();
|
||||
vi.mock('@tauri-apps/api/core', () => ({
|
||||
invoke: (...args: unknown[]) => invokeMock(...args),
|
||||
}));
|
||||
|
||||
let isTauriAppPlatformValue = false;
|
||||
vi.mock('@/services/environment', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/services/environment')>();
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -29,6 +29,7 @@ class FakeClient {
|
||||
rows: ReplicaKeyRow[] = [];
|
||||
listCalls = 0;
|
||||
createCalls = 0;
|
||||
forgetCalls = 0;
|
||||
|
||||
async listReplicaKeys(): Promise<ReplicaKeyRow[]> {
|
||||
this.listCalls += 1;
|
||||
@@ -42,6 +43,11 @@ class FakeClient {
|
||||
this.rows.push(row);
|
||||
return row;
|
||||
}
|
||||
|
||||
async forgetReplicaKeys(): Promise<void> {
|
||||
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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<ReplicaKeyRow[]> {
|
||||
return [...this.rows];
|
||||
}
|
||||
async createReplicaKey(): Promise<ReplicaKeyRow> {
|
||||
const row = makeSaltRow(`salt-${this.rows.length + 1}`, new Date().toISOString());
|
||||
this.rows.push(row);
|
||||
return row;
|
||||
}
|
||||
async forgetReplicaKeys(): Promise<void> {
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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<ReplicaKeyRow[]> {
|
||||
return [...this.rows];
|
||||
}
|
||||
async createReplicaKey(): Promise<ReplicaKeyRow> {
|
||||
const row = makeSaltRow(`salt-${this.rows.length + 1}`, new Date().toISOString());
|
||||
this.rows.push(row);
|
||||
return row;
|
||||
}
|
||||
async forgetReplicaKeys(): Promise<void> {
|
||||
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<string, unknown> = { 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<string, unknown> = { 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<string, unknown> = { 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<string, unknown> = { 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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<SyncPassphraseStatus>('loading');
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [message, setMessage] = useState<string | null>(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 (
|
||||
<section className='border-base-300 rounded-lg border p-4 text-sm'>
|
||||
<h3 className='mb-2 font-semibold'>{_('Sync passphrase')}</h3>
|
||||
<p className='text-base-content/70 mb-3'>
|
||||
{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.')}
|
||||
</p>
|
||||
{message && <p className='text-base-content/60 mb-3 text-xs'>{message}</p>}
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
<button className='btn btn-primary btn-sm' disabled={busy} onClick={handleSetOrUnlock}>
|
||||
{status === 'unset' ? _('Set passphrase') : _('Unlock now')}
|
||||
</button>
|
||||
{status === 'set' && (
|
||||
<button
|
||||
className='btn btn-error btn-outline btn-sm'
|
||||
disabled={busy}
|
||||
onClick={handleForget}
|
||||
>
|
||||
{_('Forgot passphrase')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -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 = () => {
|
||||
/>
|
||||
</div>
|
||||
<div className='flex flex-col gap-y-8 px-6'>
|
||||
<SyncPassphraseSection />
|
||||
<AccountActions
|
||||
userPlan={userProfilePlan}
|
||||
iapAvailable={iapAvailable}
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import ModalPortal from '@/components/ModalPortal';
|
||||
import { useTranslation } from '@/hooks/useTranslation';
|
||||
import { setPassphrasePrompter } from '@/services/sync/passphraseGate';
|
||||
import type { PassphrasePromptKind } from '@/services/sync/passphraseGate';
|
||||
|
||||
interface PendingPrompt {
|
||||
kind: PassphrasePromptKind;
|
||||
resolve: (passphrase: string | null) => 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<PendingPrompt | null>(null);
|
||||
const [value, setValue] = useState('');
|
||||
const [confirm, setConfirm] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setPassphrasePrompter(({ kind }) => {
|
||||
return new Promise<string | null>((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 (
|
||||
<ModalPortal>
|
||||
<dialog className='modal modal-open'>
|
||||
<div className='modal-box bg-base-200 max-w-md rounded-2xl p-6 shadow-2xl'>
|
||||
<h3 className='mb-1.5 text-lg font-semibold tracking-tight'>
|
||||
{isSetup ? _('Set sync passphrase') : _('Enter sync passphrase')}
|
||||
</h3>
|
||||
<p className='text-base-content/60 mb-5 text-sm leading-relaxed'>
|
||||
{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.',
|
||||
)}
|
||||
</p>
|
||||
<form onSubmit={handleSubmit} className='space-y-2.5'>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type='password'
|
||||
value={value}
|
||||
onChange={(e) => {
|
||||
setValue(e.target.value);
|
||||
setError('');
|
||||
}}
|
||||
placeholder={_('Sync passphrase')}
|
||||
className={inputClass}
|
||||
autoComplete='new-password'
|
||||
required
|
||||
/>
|
||||
{isSetup && (
|
||||
<input
|
||||
type='password'
|
||||
value={confirm}
|
||||
onChange={(e) => {
|
||||
setConfirm(e.target.value);
|
||||
setError('');
|
||||
}}
|
||||
placeholder={_('Confirm passphrase')}
|
||||
className={inputClass}
|
||||
autoComplete='new-password'
|
||||
required
|
||||
/>
|
||||
)}
|
||||
{error && <p className='text-error pt-0.5 text-xs'>{error}</p>}
|
||||
<div className='flex justify-end gap-2 pt-4'>
|
||||
{/*
|
||||
* 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.
|
||||
*/}
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => close(null)}
|
||||
className='eink-bordered hover:bg-base-300/70 rounded-lg px-4 py-2 text-sm font-medium transition-colors'
|
||||
>
|
||||
{_('Cancel')}
|
||||
</button>
|
||||
<button
|
||||
type='submit'
|
||||
className='btn btn-primary text-primary-content hover:bg-primary/90 active:bg-primary/80 rounded-lg border-0 px-4 py-2 text-sm font-medium transition-colors'
|
||||
>
|
||||
{isSetup ? _('Set passphrase') : _('Unlock')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</dialog>
|
||||
</ModalPortal>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
<CommandPalette />
|
||||
<AtmosphereOverlay />
|
||||
<PassphrasePrompt />
|
||||
</CommandPaletteProvider>
|
||||
</DropdownProvider>
|
||||
</SyncProvider>
|
||||
|
||||
@@ -47,10 +47,23 @@ const BackgroundTextureSelector: React.FC<BackgroundTextureSelectorProps> = ({
|
||||
<h2 className='mb-2 font-medium'>{_('Background Image')}</h2>
|
||||
<div className='mb-4 grid grid-cols-2 gap-4'>
|
||||
{allTextures.map((texture) => (
|
||||
<button
|
||||
// The swatch is a div (not a <button>) so the inner Delete
|
||||
// <button> can nest legally — interactive elements can't be
|
||||
// descendants of <button> per HTML, and React 18+ flags it
|
||||
// as a hydration error. Keyboard a11y is preserved via
|
||||
// role="button" + tabIndex + Enter/Space onKeyDown.
|
||||
<div
|
||||
key={texture.id}
|
||||
role='button'
|
||||
tabIndex={0}
|
||||
onClick={() => onTextureSelect(texture.id)}
|
||||
className={`bg-base-100 relative flex flex-col items-center justify-center rounded-lg border-2 p-4 shadow-md transition-all ${
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
onTextureSelect(texture.id);
|
||||
}
|
||||
}}
|
||||
className={`bg-base-100 relative flex cursor-pointer flex-col items-center justify-center rounded-lg border-2 p-4 shadow-md transition-all ${
|
||||
selectedTextureId === texture.id
|
||||
? 'ring-2 ring-indigo-500 ring-offset-2'
|
||||
: 'border-base-300'
|
||||
@@ -86,7 +99,7 @@ const BackgroundTextureSelector: React.FC<BackgroundTextureSelectorProps> = ({
|
||||
<MdClose size={16} />
|
||||
</button>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Custom Image Upload */}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<void>;
|
||||
@@ -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<void> {
|
||||
throw new SyncError(
|
||||
'CRYPTO_UNAVAILABLE',
|
||||
'Tauri keychain backend not yet wired (TODO before PR 4 ships).',
|
||||
);
|
||||
async set(passphrase: string): Promise<void> {
|
||||
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<string | null> {
|
||||
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<void> {}
|
||||
async clear(): Promise<void> {
|
||||
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<PassphraseStore> => {
|
||||
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;
|
||||
};
|
||||
|
||||
@@ -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<ReplicaSyncClient, 'listReplicaKeys' | 'createReplicaKey'>;
|
||||
client?: Pick<ReplicaSyncClient, 'listReplicaKeys' | 'createReplicaKey' | 'forgetReplicaKeys'>;
|
||||
/** 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<string, KnownSalt>();
|
||||
private keys = new Map<string, CryptoKey>();
|
||||
private activeSaltId: string | null = null;
|
||||
private readonly client: Pick<ReplicaSyncClient, 'listReplicaKeys' | 'createReplicaKey'>;
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<boolean> {
|
||||
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<void> {
|
||||
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<CipherEnvelope> {
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -122,6 +122,28 @@ export class ReplicaSyncClient {
|
||||
return data.rows ?? [];
|
||||
}
|
||||
|
||||
async forgetReplicaKeys(): Promise<void> {
|
||||
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<ReplicaKeyRow> {
|
||||
const token = await requireToken();
|
||||
let response: Response;
|
||||
|
||||
@@ -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<string, string>),
|
||||
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' });
|
||||
}
|
||||
|
||||
|
||||
@@ -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<OPDSCatalog> = {
|
||||
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<OPDSCatalog> = {
|
||||
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<OPDSCatalog> = {
|
||||
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.
|
||||
};
|
||||
|
||||
@@ -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<string | null>;
|
||||
|
||||
let prompter: PassphrasePrompter | null = null;
|
||||
let inflight: Promise<void> | null = null;
|
||||
|
||||
export const setPassphrasePrompter = (p: PassphrasePrompter | null): void => {
|
||||
prompter = p;
|
||||
};
|
||||
|
||||
interface EnsureUnlockedDeps {
|
||||
session?: CryptoSession;
|
||||
client?: Pick<ReplicaSyncClient, 'listReplicaKeys'>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<void> => {
|
||||
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;
|
||||
};
|
||||
@@ -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<string, unknown>,
|
||||
encryptedFields: readonly string[] | undefined,
|
||||
session: CryptoSession = defaultCryptoSession,
|
||||
): Promise<void> => {
|
||||
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<string, string> => {
|
||||
const out: Record<string, string> = {};
|
||||
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<string, string>,
|
||||
lastSeen: Record<string, string> | 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<string, string>,
|
||||
): Record<string, string> => {
|
||||
const out: Record<string, string> = {};
|
||||
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<void>,
|
||||
): Promise<void> => {
|
||||
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];
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -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 <T>(
|
||||
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)) {
|
||||
|
||||
@@ -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<string, string>;
|
||||
}
|
||||
|
||||
export interface PullAndApplyDeps<T extends ReplicaLocalRecord> {
|
||||
@@ -97,6 +111,40 @@ const applyRow = async <T extends ReplicaLocalRecord>(
|
||||
deps: PullAndApplyDeps<T>,
|
||||
): Promise<void> => {
|
||||
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 <T extends ReplicaLocalRecord>(
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -33,6 +33,18 @@ export interface ReplicaAdapter<T = unknown> {
|
||||
getDisplayName?(record: T): string;
|
||||
binary?: BinaryCapability<T>;
|
||||
lifecycle?: LifecycleHooks<T>;
|
||||
/**
|
||||
* 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<string, ReplicaAdapter<unknown>>();
|
||||
|
||||
@@ -169,13 +169,24 @@ export const useCustomOPDSStore = create<OPDSStoreState>((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)) };
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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<string, string>;
|
||||
}
|
||||
|
||||
export interface OPDSFeed {
|
||||
|
||||
@@ -214,3 +214,48 @@ export async function getStorefrontRegionCode(): Promise<GetStorefrontRegionCode
|
||||
);
|
||||
return result;
|
||||
}
|
||||
|
||||
// ── Sync passphrase keychain ────────────────────────────────────────────
|
||||
// Tauri-only. Wired into the TauriPassphraseStore (src/libs/crypto/
|
||||
// passphrase.ts) so the user's sync passphrase persists across app
|
||||
// launches via the OS keychain (macOS Keychain, Windows Credential
|
||||
// Manager, Linux libsecret, iOS Keychain, Android EncryptedSharedPrefs).
|
||||
|
||||
export interface SetSyncPassphraseRequest {
|
||||
passphrase: string;
|
||||
}
|
||||
|
||||
export interface SyncPassphraseResponse {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface GetSyncPassphraseResponse {
|
||||
passphrase?: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface SyncKeychainAvailableResponse {
|
||||
available: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export async function setSyncPassphrase(
|
||||
request: SetSyncPassphraseRequest,
|
||||
): Promise<SyncPassphraseResponse> {
|
||||
return invoke<SyncPassphraseResponse>('plugin:native-bridge|set_sync_passphrase', {
|
||||
payload: request,
|
||||
});
|
||||
}
|
||||
|
||||
export async function getSyncPassphrase(): Promise<GetSyncPassphraseResponse> {
|
||||
return invoke<GetSyncPassphraseResponse>('plugin:native-bridge|get_sync_passphrase');
|
||||
}
|
||||
|
||||
export async function clearSyncPassphrase(): Promise<SyncPassphraseResponse> {
|
||||
return invoke<SyncPassphraseResponse>('plugin:native-bridge|clear_sync_passphrase');
|
||||
}
|
||||
|
||||
export async function isSyncKeychainAvailable(): Promise<SyncKeychainAvailableResponse> {
|
||||
return invoke<SyncKeychainAvailableResponse>('plugin:native-bridge|is_sync_keychain_available');
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user