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:
Huang Xin
2026-05-08 19:22:49 +08:00
committed by GitHub
parent 6bfeb295d2
commit 712d564e9d
48 changed files with 2367 additions and 66 deletions
Generated
+95 -22
View File
@@ -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+)
+11
View File
@@ -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")
@@ -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
}
@@ -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")
@@ -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"]
@@ -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"]
@@ -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"]
@@ -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"]
@@ -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",
]
@@ -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>
);
}
+2
View File
@@ -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 */}
+2 -2
View File
@@ -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
+100 -9
View File
@@ -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;
};
+94 -3
View File
@@ -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>>();
+15 -4
View File
@@ -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)) };
+23
View File
@@ -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;
}
+10
View File
@@ -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 {
+45
View File
@@ -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;