+
+Denies the is_sync_keychain_available command without any pre-configured scope.
+
+
+
+
+
+
+
`native-bridge:allow-lock-screen-orientation`
@@ -827,6 +909,32 @@ Denies the set_screen_brightness command without any pre-configured scope.
+`native-bridge:allow-set-sync-passphrase`
+
+
+
+
+Enables the set_sync_passphrase command without any pre-configured scope.
+
+
+
+
+
+
+
+`native-bridge:deny-set-sync-passphrase`
+
+
+
+
+Denies the set_sync_passphrase command without any pre-configured scope.
+
+
+
+
+
+
+
`native-bridge:allow-set-system-ui-visibility`
diff --git a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/permissions/default.toml b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/permissions/default.toml
index e9fc5e8a..1773e1c3 100644
--- a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/permissions/default.toml
+++ b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/permissions/default.toml
@@ -31,4 +31,8 @@ permissions = [
"allow-request-permissions",
"allow-checkPermissions",
"allow-requestPermissions",
+ "allow-set-sync-passphrase",
+ "allow-get-sync-passphrase",
+ "allow-clear-sync-passphrase",
+ "allow-is-sync-keychain-available",
]
diff --git a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/permissions/schemas/schema.json b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/permissions/schemas/schema.json
index cb0cc2e3..3e6a82db 100644
--- a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/permissions/schemas/schema.json
+++ b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/permissions/schemas/schema.json
@@ -354,6 +354,18 @@
"const": "deny-check-permissions",
"markdownDescription": "Denies the check_permissions command without any pre-configured scope."
},
+ {
+ "description": "Enables the clear_sync_passphrase command without any pre-configured scope.",
+ "type": "string",
+ "const": "allow-clear-sync-passphrase",
+ "markdownDescription": "Enables the clear_sync_passphrase command without any pre-configured scope."
+ },
+ {
+ "description": "Denies the clear_sync_passphrase command without any pre-configured scope.",
+ "type": "string",
+ "const": "deny-clear-sync-passphrase",
+ "markdownDescription": "Denies the clear_sync_passphrase command without any pre-configured scope."
+ },
{
"description": "Enables the copy_uri_to_path command without any pre-configured scope.",
"type": "string",
@@ -426,6 +438,18 @@
"const": "deny-get-storefront-region-code",
"markdownDescription": "Denies the get_storefront_region_code command without any pre-configured scope."
},
+ {
+ "description": "Enables the get_sync_passphrase command without any pre-configured scope.",
+ "type": "string",
+ "const": "allow-get-sync-passphrase",
+ "markdownDescription": "Enables the get_sync_passphrase command without any pre-configured scope."
+ },
+ {
+ "description": "Denies the get_sync_passphrase command without any pre-configured scope.",
+ "type": "string",
+ "const": "deny-get-sync-passphrase",
+ "markdownDescription": "Denies the get_sync_passphrase command without any pre-configured scope."
+ },
{
"description": "Enables the get_sys_fonts_list command without any pre-configured scope.",
"type": "string",
@@ -534,6 +558,18 @@
"const": "deny-intercept-keys",
"markdownDescription": "Denies the intercept_keys command without any pre-configured scope."
},
+ {
+ "description": "Enables the is_sync_keychain_available command without any pre-configured scope.",
+ "type": "string",
+ "const": "allow-is-sync-keychain-available",
+ "markdownDescription": "Enables the is_sync_keychain_available command without any pre-configured scope."
+ },
+ {
+ "description": "Denies the is_sync_keychain_available command without any pre-configured scope.",
+ "type": "string",
+ "const": "deny-is-sync-keychain-available",
+ "markdownDescription": "Denies the is_sync_keychain_available command without any pre-configured scope."
+ },
{
"description": "Enables the lock_screen_orientation command without any pre-configured scope.",
"type": "string",
@@ -654,6 +690,18 @@
"const": "deny-set-screen-brightness",
"markdownDescription": "Denies the set_screen_brightness command without any pre-configured scope."
},
+ {
+ "description": "Enables the set_sync_passphrase command without any pre-configured scope.",
+ "type": "string",
+ "const": "allow-set-sync-passphrase",
+ "markdownDescription": "Enables the set_sync_passphrase command without any pre-configured scope."
+ },
+ {
+ "description": "Denies the set_sync_passphrase command without any pre-configured scope.",
+ "type": "string",
+ "const": "deny-set-sync-passphrase",
+ "markdownDescription": "Denies the set_sync_passphrase command without any pre-configured scope."
+ },
{
"description": "Enables the set_system_ui_visibility command without any pre-configured scope.",
"type": "string",
@@ -679,10 +727,10 @@
"markdownDescription": "Denies the use_background_audio command without any pre-configured scope."
},
{
- "description": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-auth-with-safari`\n- `allow-auth-with-custom-tab`\n- `allow-copy-uri-to-path`\n- `allow-use-background-audio`\n- `allow-install-package`\n- `allow-set-system-ui-visibility`\n- `allow-get-status-bar-height`\n- `allow-get-sys-fonts-list`\n- `allow-intercept-keys`\n- `allow-lock-screen-orientation`\n- `allow-iap-is-available`\n- `allow-iap-initialize`\n- `allow-iap-fetch-products`\n- `allow-iap-purchase-product`\n- `allow-iap-restore-purchases`\n- `allow-get-system-color-scheme`\n- `allow-get-safe-area-insets`\n- `allow-get-screen-brightness`\n- `allow-set-screen-brightness`\n- `allow-get-external-sdcard-path`\n- `allow-open-external-url`\n- `allow-select-directory`\n- `allow-get-storefront-region-code`\n- `allow-request-manage-storage-permission`\n- `allow-register-listener`\n- `allow-remove-listener`\n- `allow-check-permissions`\n- `allow-request-permissions`\n- `allow-checkPermissions`\n- `allow-requestPermissions`",
+ "description": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-auth-with-safari`\n- `allow-auth-with-custom-tab`\n- `allow-copy-uri-to-path`\n- `allow-use-background-audio`\n- `allow-install-package`\n- `allow-set-system-ui-visibility`\n- `allow-get-status-bar-height`\n- `allow-get-sys-fonts-list`\n- `allow-intercept-keys`\n- `allow-lock-screen-orientation`\n- `allow-iap-is-available`\n- `allow-iap-initialize`\n- `allow-iap-fetch-products`\n- `allow-iap-purchase-product`\n- `allow-iap-restore-purchases`\n- `allow-get-system-color-scheme`\n- `allow-get-safe-area-insets`\n- `allow-get-screen-brightness`\n- `allow-set-screen-brightness`\n- `allow-get-external-sdcard-path`\n- `allow-open-external-url`\n- `allow-select-directory`\n- `allow-get-storefront-region-code`\n- `allow-request-manage-storage-permission`\n- `allow-register-listener`\n- `allow-remove-listener`\n- `allow-check-permissions`\n- `allow-request-permissions`\n- `allow-checkPermissions`\n- `allow-requestPermissions`\n- `allow-set-sync-passphrase`\n- `allow-get-sync-passphrase`\n- `allow-clear-sync-passphrase`\n- `allow-is-sync-keychain-available`",
"type": "string",
"const": "default",
- "markdownDescription": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-auth-with-safari`\n- `allow-auth-with-custom-tab`\n- `allow-copy-uri-to-path`\n- `allow-use-background-audio`\n- `allow-install-package`\n- `allow-set-system-ui-visibility`\n- `allow-get-status-bar-height`\n- `allow-get-sys-fonts-list`\n- `allow-intercept-keys`\n- `allow-lock-screen-orientation`\n- `allow-iap-is-available`\n- `allow-iap-initialize`\n- `allow-iap-fetch-products`\n- `allow-iap-purchase-product`\n- `allow-iap-restore-purchases`\n- `allow-get-system-color-scheme`\n- `allow-get-safe-area-insets`\n- `allow-get-screen-brightness`\n- `allow-set-screen-brightness`\n- `allow-get-external-sdcard-path`\n- `allow-open-external-url`\n- `allow-select-directory`\n- `allow-get-storefront-region-code`\n- `allow-request-manage-storage-permission`\n- `allow-register-listener`\n- `allow-remove-listener`\n- `allow-check-permissions`\n- `allow-request-permissions`\n- `allow-checkPermissions`\n- `allow-requestPermissions`"
+ "markdownDescription": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-auth-with-safari`\n- `allow-auth-with-custom-tab`\n- `allow-copy-uri-to-path`\n- `allow-use-background-audio`\n- `allow-install-package`\n- `allow-set-system-ui-visibility`\n- `allow-get-status-bar-height`\n- `allow-get-sys-fonts-list`\n- `allow-intercept-keys`\n- `allow-lock-screen-orientation`\n- `allow-iap-is-available`\n- `allow-iap-initialize`\n- `allow-iap-fetch-products`\n- `allow-iap-purchase-product`\n- `allow-iap-restore-purchases`\n- `allow-get-system-color-scheme`\n- `allow-get-safe-area-insets`\n- `allow-get-screen-brightness`\n- `allow-set-screen-brightness`\n- `allow-get-external-sdcard-path`\n- `allow-open-external-url`\n- `allow-select-directory`\n- `allow-get-storefront-region-code`\n- `allow-request-manage-storage-permission`\n- `allow-register-listener`\n- `allow-remove-listener`\n- `allow-check-permissions`\n- `allow-request-permissions`\n- `allow-checkPermissions`\n- `allow-requestPermissions`\n- `allow-set-sync-passphrase`\n- `allow-get-sync-passphrase`\n- `allow-clear-sync-passphrase`\n- `allow-is-sync-keychain-available`"
}
]
}
diff --git a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/src/commands.rs b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/src/commands.rs
index a8b9c040..89b0021f 100644
--- a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/src/commands.rs
+++ b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/src/commands.rs
@@ -199,3 +199,32 @@ pub(crate) async fn request_manage_storage_permission(
) -> Result {
app.native_bridge().request_manage_storage_permission()
}
+
+#[command]
+pub(crate) async fn set_sync_passphrase(
+ app: AppHandle,
+ payload: SetSyncPassphraseRequest,
+) -> Result {
+ app.native_bridge().set_sync_passphrase(payload)
+}
+
+#[command]
+pub(crate) async fn get_sync_passphrase(
+ app: AppHandle,
+) -> Result {
+ app.native_bridge().get_sync_passphrase()
+}
+
+#[command]
+pub(crate) async fn clear_sync_passphrase(
+ app: AppHandle,
+) -> Result {
+ app.native_bridge().clear_sync_passphrase()
+}
+
+#[command]
+pub(crate) async fn is_sync_keychain_available(
+ app: AppHandle,
+) -> Result {
+ app.native_bridge().is_sync_keychain_available()
+}
diff --git a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/src/desktop.rs b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/src/desktop.rs
index a312dd42..3a6627b9 100644
--- a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/src/desktop.rs
+++ b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/src/desktop.rs
@@ -146,4 +146,85 @@ impl NativeBridge {
) -> crate::Result {
Err(crate::Error::UnsupportedPlatformError)
}
+
+ // ── Sync passphrase keychain ────────────────────────────────────────
+ //
+ // Uses the `keyring` crate, which transparently maps to:
+ // * macOS → Security framework Keychain
+ // * Windows → Credential Manager
+ // * Linux → Secret Service (libsecret-compatible)
+ //
+ // `service` and `user` form the keychain item identity. Service is
+ // the bundle id; user is a stable string ("default") so multiple
+ // Readest installs on the same machine could coexist with distinct
+ // user values if ever needed.
+
+ pub fn set_sync_passphrase(
+ &self,
+ payload: SetSyncPassphraseRequest,
+ ) -> crate::Result {
+ match keyring_entry().and_then(|e| e.set_password(&payload.passphrase)) {
+ Ok(()) => Ok(SyncPassphraseResponse {
+ success: true,
+ error: None,
+ }),
+ Err(err) => Ok(SyncPassphraseResponse {
+ success: false,
+ error: Some(err.to_string()),
+ }),
+ }
+ }
+
+ pub fn get_sync_passphrase(&self) -> crate::Result {
+ match keyring_entry().and_then(|e| e.get_password()) {
+ Ok(passphrase) => Ok(GetSyncPassphraseResponse {
+ passphrase: Some(passphrase),
+ error: None,
+ }),
+ Err(keyring::Error::NoEntry) => Ok(GetSyncPassphraseResponse {
+ passphrase: None,
+ error: None,
+ }),
+ Err(err) => Ok(GetSyncPassphraseResponse {
+ passphrase: None,
+ error: Some(err.to_string()),
+ }),
+ }
+ }
+
+ pub fn clear_sync_passphrase(&self) -> crate::Result {
+ match keyring_entry().and_then(|e| e.delete_credential()) {
+ Ok(()) | Err(keyring::Error::NoEntry) => Ok(SyncPassphraseResponse {
+ success: true,
+ error: None,
+ }),
+ Err(err) => Ok(SyncPassphraseResponse {
+ success: false,
+ error: Some(err.to_string()),
+ }),
+ }
+ }
+
+ pub fn is_sync_keychain_available(&self) -> crate::Result {
+ // Best-effort probe: open an entry handle. Surface the error
+ // string instead of throwing so the TS layer can fall back
+ // to the ephemeral store gracefully.
+ match keyring_entry() {
+ Ok(_) => Ok(SyncKeychainAvailableResponse {
+ available: true,
+ error: None,
+ }),
+ Err(err) => Ok(SyncKeychainAvailableResponse {
+ available: false,
+ error: Some(err.to_string()),
+ }),
+ }
+ }
+}
+
+const KEYRING_SERVICE: &str = "com.bilingify.readest.sync-passphrase";
+const KEYRING_USER: &str = "default";
+
+fn keyring_entry() -> std::result::Result {
+ keyring::Entry::new(KEYRING_SERVICE, KEYRING_USER)
}
diff --git a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/src/lib.rs b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/src/lib.rs
index c4d74818..7ac0df0f 100644
--- a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/src/lib.rs
+++ b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/src/lib.rs
@@ -79,6 +79,10 @@ pub fn init() -> TauriPlugin {
commands::select_directory,
commands::get_storefront_region_code,
commands::request_manage_storage_permission,
+ commands::set_sync_passphrase,
+ commands::get_sync_passphrase,
+ commands::clear_sync_passphrase,
+ commands::is_sync_keychain_available,
])
.setup(|app, api| {
#[cfg(mobile)]
diff --git a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/src/mobile.rs b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/src/mobile.rs
index 515e7885..4d5860dd 100644
--- a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/src/mobile.rs
+++ b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/src/mobile.rs
@@ -241,3 +241,38 @@ impl NativeBridge {
.map_err(Into::into)
}
}
+
+impl NativeBridge {
+ pub fn set_sync_passphrase(
+ &self,
+ payload: SetSyncPassphraseRequest,
+ ) -> crate::Result {
+ self.0
+ .run_mobile_plugin("set_sync_passphrase", payload)
+ .map_err(Into::into)
+ }
+}
+
+impl NativeBridge {
+ pub fn get_sync_passphrase(&self) -> crate::Result {
+ self.0
+ .run_mobile_plugin("get_sync_passphrase", ())
+ .map_err(Into::into)
+ }
+}
+
+impl NativeBridge {
+ pub fn clear_sync_passphrase(&self) -> crate::Result {
+ self.0
+ .run_mobile_plugin("clear_sync_passphrase", ())
+ .map_err(Into::into)
+ }
+}
+
+impl NativeBridge {
+ pub fn is_sync_keychain_available(&self) -> crate::Result {
+ self.0
+ .run_mobile_plugin("is_sync_keychain_available", ())
+ .map_err(Into::into)
+ }
+}
diff --git a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/src/models.rs b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/src/models.rs
index 3736a199..e8140d63 100644
--- a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/src/models.rs
+++ b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/src/models.rs
@@ -237,3 +237,39 @@ pub struct GetStorefrontRegionCodeResponse {
pub region_code: Option,
pub error: Option,
}
+
+// ── Sync passphrase keychain ────────────────────────────────────────────
+//
+// Persist the sync passphrase across app launches via the OS keychain
+// so native users don't re-enter it every session. The replica-sync
+// CryptoSession (TS side) reads/writes via these commands; web users
+// keep using the in-memory ephemeral store.
+
+#[derive(Debug, Deserialize, Serialize)]
+#[serde(rename_all = "camelCase")]
+pub struct SetSyncPassphraseRequest {
+ pub passphrase: String,
+}
+
+#[derive(Debug, Deserialize, Serialize)]
+#[serde(rename_all = "camelCase")]
+pub struct SyncPassphraseResponse {
+ pub success: bool,
+ pub error: Option,
+}
+
+#[derive(Debug, Deserialize, Serialize)]
+#[serde(rename_all = "camelCase")]
+pub struct GetSyncPassphraseResponse {
+ /// Present iff a passphrase is stored. Absent (and `error: None`)
+ /// means "no entry on this device" — caller should prompt.
+ pub passphrase: Option,
+ pub error: Option,
+}
+
+#[derive(Debug, Deserialize, Serialize)]
+#[serde(rename_all = "camelCase")]
+pub struct SyncKeychainAvailableResponse {
+ pub available: bool,
+ pub error: Option,
+}
diff --git a/apps/readest-app/src/__tests__/hooks/useReplicaPull.test.tsx b/apps/readest-app/src/__tests__/hooks/useReplicaPull.test.tsx
index d74a0e0f..7cb14b54 100644
--- a/apps/readest-app/src/__tests__/hooks/useReplicaPull.test.tsx
+++ b/apps/readest-app/src/__tests__/hooks/useReplicaPull.test.tsx
@@ -55,12 +55,48 @@ vi.mock('@/store/customDictionaryStore', () => ({
findDictionaryByContentId: () => undefined,
}));
+vi.mock('@/store/customFontStore', () => ({
+ useCustomFontStore: {
+ getState: () => ({
+ applyRemoteFont: vi.fn(),
+ softDeleteByContentId: vi.fn(),
+ loadCustomFonts: vi.fn(async () => {}),
+ }),
+ },
+ findFontByContentId: () => undefined,
+ migrateLegacyFonts: vi.fn(async () => {}),
+}));
+
+vi.mock('@/store/customTextureStore', () => ({
+ useCustomTextureStore: {
+ getState: () => ({
+ applyRemoteTexture: vi.fn(),
+ softDeleteByContentId: vi.fn(),
+ loadCustomTextures: vi.fn(async () => {}),
+ }),
+ },
+ findTextureByContentId: () => undefined,
+ migrateLegacyTextures: vi.fn(async () => {}),
+}));
+
+vi.mock('@/store/customOPDSStore', () => ({
+ useCustomOPDSStore: {
+ getState: () => ({
+ applyRemoteCatalog: vi.fn(),
+ softDeleteByContentId: vi.fn(),
+ loadCustomOPDSCatalogs: vi.fn(async () => {}),
+ }),
+ },
+ findOPDSCatalogByContentId: () => undefined,
+}));
+
vi.mock('@/utils/access', () => ({
getAccessToken: async () => 'token',
}));
vi.mock('@/utils/misc', () => ({
uniqueId: () => 'fresh-bundle',
+ stubTranslation: (s: string) => s,
}));
import { useReplicaPull, __resetReplicaPullForTests } from '@/hooks/useReplicaPull';
diff --git a/apps/readest-app/src/__tests__/libs/crypto/passphrase.test.ts b/apps/readest-app/src/__tests__/libs/crypto/passphrase.test.ts
index 17c0cb7b..8cfc8414 100644
--- a/apps/readest-app/src/__tests__/libs/crypto/passphrase.test.ts
+++ b/apps/readest-app/src/__tests__/libs/crypto/passphrase.test.ts
@@ -1,5 +1,36 @@
-import { describe, expect, test } from 'vitest';
-import { EphemeralPassphraseStore, TauriPassphraseStore } from '@/libs/crypto/passphrase';
+import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
+
+const invokeMock = vi.fn<(...args: unknown[]) => Promise>();
+vi.mock('@tauri-apps/api/core', () => ({
+ invoke: (...args: unknown[]) => invokeMock(...args),
+}));
+
+let isTauriAppPlatformValue = false;
+vi.mock('@/services/environment', async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ isTauriAppPlatform: () => isTauriAppPlatformValue,
+ };
+});
+
+import {
+ EphemeralPassphraseStore,
+ TauriPassphraseStore,
+ __resetPassphraseStoreForTests,
+ createPassphraseStore,
+ upgradeToKeychainIfAvailable,
+} from '@/libs/crypto/passphrase';
+
+beforeEach(() => {
+ invokeMock.mockReset();
+ __resetPassphraseStoreForTests();
+ isTauriAppPlatformValue = false;
+});
+
+afterEach(() => {
+ __resetPassphraseStoreForTests();
+});
describe('EphemeralPassphraseStore', () => {
test('set then get returns the same passphrase', async () => {
@@ -42,14 +73,90 @@ describe('EphemeralPassphraseStore', () => {
});
});
-describe('TauriPassphraseStore (stub until plugin lands)', () => {
- test('stub: set throws NOT_IMPLEMENTED for v1', async () => {
+describe('TauriPassphraseStore', () => {
+ test('set delegates to plugin:native-bridge|set_sync_passphrase', async () => {
+ invokeMock.mockResolvedValue({ success: true });
const store = new TauriPassphraseStore();
- await expect(store.set('x')).rejects.toThrow(/Tauri keychain backend not yet wired/);
+ await store.set('hunter2');
+ expect(invokeMock).toHaveBeenCalledWith('plugin:native-bridge|set_sync_passphrase', {
+ payload: { passphrase: 'hunter2' },
+ });
});
- test('stub: isAvailable returns false', () => {
+ test('set throws CRYPTO_UNAVAILABLE when the bridge reports failure', async () => {
+ invokeMock.mockResolvedValue({ success: false, error: 'OSStatus -25300' });
const store = new TauriPassphraseStore();
- expect(store.isAvailable()).toBe(false);
+ await expect(store.set('hunter2')).rejects.toMatchObject({
+ name: 'SyncError',
+ code: 'CRYPTO_UNAVAILABLE',
+ });
+ });
+
+ test('get returns the saved passphrase', async () => {
+ invokeMock.mockResolvedValue({ passphrase: 'hunter2' });
+ const store = new TauriPassphraseStore();
+ expect(await store.get()).toBe('hunter2');
+ });
+
+ test('get returns null when keychain has no entry (empty response)', async () => {
+ invokeMock.mockResolvedValue({});
+ const store = new TauriPassphraseStore();
+ expect(await store.get()).toBeNull();
+ });
+
+ test('get returns null when bridge reports an error (fail-soft)', async () => {
+ invokeMock.mockResolvedValue({ error: 'OSStatus -25291' });
+ const store = new TauriPassphraseStore();
+ expect(await store.get()).toBeNull();
+ });
+
+ test('clear delegates to plugin:native-bridge|clear_sync_passphrase', async () => {
+ invokeMock.mockResolvedValue({ success: true });
+ const store = new TauriPassphraseStore();
+ await store.clear();
+ expect(invokeMock).toHaveBeenCalledWith('plugin:native-bridge|clear_sync_passphrase');
+ });
+
+ test('isAvailable returns true (true availability is gated by upgrade probe)', () => {
+ expect(new TauriPassphraseStore().isAvailable()).toBe(true);
+ });
+});
+
+describe('createPassphraseStore + upgradeToKeychainIfAvailable', () => {
+ test('returns ephemeral on web; upgrade is a no-op (no keychain probe)', async () => {
+ isTauriAppPlatformValue = false;
+ const store = createPassphraseStore();
+ expect(store).toBeInstanceOf(EphemeralPassphraseStore);
+ const upgraded = await upgradeToKeychainIfAvailable();
+ expect(upgraded).toBe(store);
+ expect(invokeMock).not.toHaveBeenCalled();
+ });
+
+ test('upgrades to TauriPassphraseStore on Tauri when keychain probe succeeds', async () => {
+ isTauriAppPlatformValue = true;
+ invokeMock.mockResolvedValue({ available: true });
+ expect(createPassphraseStore()).toBeInstanceOf(EphemeralPassphraseStore);
+ const upgraded = await upgradeToKeychainIfAvailable();
+ expect(upgraded).toBeInstanceOf(TauriPassphraseStore);
+ // Subsequent createPassphraseStore returns the upgraded singleton.
+ expect(createPassphraseStore()).toBe(upgraded);
+ });
+
+ test('falls back to ephemeral when probe reports unavailable', async () => {
+ isTauriAppPlatformValue = true;
+ invokeMock.mockResolvedValue({ available: false, error: 'sandbox' });
+ const initial = createPassphraseStore();
+ const upgraded = await upgradeToKeychainIfAvailable();
+ expect(upgraded).toBe(initial);
+ expect(upgraded).toBeInstanceOf(EphemeralPassphraseStore);
+ });
+
+ test('falls back to ephemeral when probe throws', async () => {
+ isTauriAppPlatformValue = true;
+ invokeMock.mockRejectedValue(new Error('bridge unreachable'));
+ const initial = createPassphraseStore();
+ const upgraded = await upgradeToKeychainIfAvailable();
+ expect(upgraded).toBe(initial);
+ expect(upgraded).toBeInstanceOf(EphemeralPassphraseStore);
});
});
diff --git a/apps/readest-app/src/__tests__/libs/crypto/session.test.ts b/apps/readest-app/src/__tests__/libs/crypto/session.test.ts
index ae241f0e..27d7dc3c 100644
--- a/apps/readest-app/src/__tests__/libs/crypto/session.test.ts
+++ b/apps/readest-app/src/__tests__/libs/crypto/session.test.ts
@@ -29,6 +29,7 @@ class FakeClient {
rows: ReplicaKeyRow[] = [];
listCalls = 0;
createCalls = 0;
+ forgetCalls = 0;
async listReplicaKeys(): Promise {
this.listCalls += 1;
@@ -42,6 +43,11 @@ class FakeClient {
this.rows.push(row);
return row;
}
+
+ async forgetReplicaKeys(): Promise {
+ this.forgetCalls += 1;
+ this.rows = [];
+ }
}
const makeSession = (client: FakeClient) => new CryptoSession({ client, iterations: ITER });
@@ -150,6 +156,16 @@ describe('CryptoSession', () => {
expect((caught as SyncError).code).toMatch(/DECRYPT|INTEGRITY/);
});
+ test('forget() clears server salts and locks the session', async () => {
+ await session.setup('pw');
+ expect(session.isUnlocked()).toBe(true);
+ expect(client.rows).toHaveLength(1);
+ await session.forget();
+ expect(session.isUnlocked()).toBe(false);
+ expect(client.forgetCalls).toBe(1);
+ expect(client.rows).toHaveLength(0);
+ });
+
test('lock() clears state; encryptField throws after lock', async () => {
await session.setup('pw');
expect(session.isUnlocked()).toBe(true);
diff --git a/apps/readest-app/src/__tests__/services/sync/adapters/opdsCatalog.test.ts b/apps/readest-app/src/__tests__/services/sync/adapters/opdsCatalog.test.ts
index 3848f69c..4905a8b6 100644
--- a/apps/readest-app/src/__tests__/services/sync/adapters/opdsCatalog.test.ts
+++ b/apps/readest-app/src/__tests__/services/sync/adapters/opdsCatalog.test.ts
@@ -56,15 +56,23 @@ describe('opdsCatalogAdapter', () => {
expect(opdsCatalogAdapter.binary).toBeUndefined();
});
- test('pack omits username and password (encrypted-credential PR)', () => {
+ test('pack passes username + password through as plaintext (middleware encrypts)', () => {
const withCreds: OPDSCatalog = { ...sample, username: 'alice', password: 'hunter2' };
const fields = opdsCatalogAdapter.pack(withCreds);
- expect(fields['username']).toBeUndefined();
- expect(fields['password']).toBeUndefined();
+ // Adapter is plaintext-in-plaintext-out. The publish-side
+ // replicaCryptoMiddleware.encryptPackedFields wraps these in
+ // cipher envelopes (or drops them if the session is locked)
+ // before they reach fields_jsonb.
+ expect(fields['username']).toBe('alice');
+ expect(fields['password']).toBe('hunter2');
expect(fields['name']).toBe('My Library');
expect(fields['url']).toBe('https://example.com/opds');
});
+ test('declares encryptedFields = [username, password]', () => {
+ expect(opdsCatalogAdapter.encryptedFields).toEqual(['username', 'password']);
+ });
+
test('pack ∘ unpack is identity for non-credential fields', async () => {
const fields = opdsCatalogAdapter.pack(sample);
const out = opdsCatalogAdapter.unpack(fields);
diff --git a/apps/readest-app/src/__tests__/services/sync/passphraseGate.test.ts b/apps/readest-app/src/__tests__/services/sync/passphraseGate.test.ts
new file mode 100644
index 00000000..15752b9f
--- /dev/null
+++ b/apps/readest-app/src/__tests__/services/sync/passphraseGate.test.ts
@@ -0,0 +1,121 @@
+import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest';
+import { CryptoSession } from '@/libs/crypto/session';
+import {
+ ensurePassphraseUnlocked,
+ setPassphrasePrompter,
+ __resetPassphraseGateForTests,
+} from '@/services/sync/passphraseGate';
+import { isSyncError } from '@/libs/errors';
+import type { ReplicaKeyRow } from '@/libs/replicaSyncClient';
+
+const ITER = 1000;
+const PBKDF2_ALG = 'pbkdf2-600k-sha256';
+
+const bytesToBase64 = (bytes: Uint8Array): string => {
+ let s = '';
+ for (let i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i]!);
+ return btoa(s);
+};
+
+const makeSaltRow = (saltId: string, createdAt: string): ReplicaKeyRow => {
+ const bytes = new Uint8Array(32);
+ for (let i = 0; i < 32; i++) bytes[i] = (i + saltId.length) & 0xff;
+ return { saltId, alg: PBKDF2_ALG, salt: bytesToBase64(bytes), createdAt };
+};
+
+class FakeClient {
+ rows: ReplicaKeyRow[] = [];
+ async listReplicaKeys(): Promise {
+ return [...this.rows];
+ }
+ async createReplicaKey(): Promise {
+ const row = makeSaltRow(`salt-${this.rows.length + 1}`, new Date().toISOString());
+ this.rows.push(row);
+ return row;
+ }
+ async forgetReplicaKeys(): Promise {
+ this.rows = [];
+ }
+}
+
+describe('ensurePassphraseUnlocked', () => {
+ let client: FakeClient;
+ let session: CryptoSession;
+
+ beforeEach(() => {
+ client = new FakeClient();
+ session = new CryptoSession({ client, iterations: ITER });
+ });
+
+ afterEach(() => {
+ __resetPassphraseGateForTests();
+ });
+
+ test('no-op when session is already unlocked', async () => {
+ await session.setup('pw');
+ const prompter = vi.fn();
+ setPassphrasePrompter(prompter);
+ await ensurePassphraseUnlocked({ session, client });
+ expect(prompter).not.toHaveBeenCalled();
+ });
+
+ test('throws NO_PASSPHRASE when no prompter is registered', async () => {
+ let caught: unknown = null;
+ try {
+ await ensurePassphraseUnlocked({ session, client });
+ } catch (e) {
+ caught = e;
+ }
+ expect(isSyncError(caught) && caught.code).toBe('NO_PASSPHRASE');
+ });
+
+ test('prompts with kind=setup when account has no salt', async () => {
+ setPassphrasePrompter(async ({ kind }) => {
+ expect(kind).toBe('setup');
+ return 'pw';
+ });
+ await ensurePassphraseUnlocked({ session, client });
+ expect(session.isUnlocked()).toBe(true);
+ expect(client.rows).toHaveLength(1);
+ });
+
+ test('prompts with kind=unlock when account has a salt', async () => {
+ // Pre-seed via a different session so this one starts locked.
+ const seeder = new CryptoSession({ client, iterations: ITER });
+ await seeder.setup('pw');
+
+ setPassphrasePrompter(async ({ kind }) => {
+ expect(kind).toBe('unlock');
+ return 'pw';
+ });
+ await ensurePassphraseUnlocked({ session, client });
+ expect(session.isUnlocked()).toBe(true);
+ });
+
+ test('rejects with NO_PASSPHRASE when user cancels (returns null)', async () => {
+ setPassphrasePrompter(async () => null);
+ let caught: unknown = null;
+ try {
+ await ensurePassphraseUnlocked({ session, client });
+ } catch (e) {
+ caught = e;
+ }
+ expect(isSyncError(caught) && caught.code).toBe('NO_PASSPHRASE');
+ expect(session.isUnlocked()).toBe(false);
+ });
+
+ test('coalesces concurrent calls into a single prompt', async () => {
+ let calls = 0;
+ setPassphrasePrompter(async () => {
+ calls += 1;
+ return 'pw';
+ });
+ await Promise.all([
+ ensurePassphraseUnlocked({ session, client }),
+ ensurePassphraseUnlocked({ session, client }),
+ ensurePassphraseUnlocked({ session, client }),
+ ]);
+ expect(calls).toBe(1);
+ expect(session.isUnlocked()).toBe(true);
+ });
+});
diff --git a/apps/readest-app/src/__tests__/services/sync/replicaCryptoMiddleware.test.ts b/apps/readest-app/src/__tests__/services/sync/replicaCryptoMiddleware.test.ts
new file mode 100644
index 00000000..b85f742a
--- /dev/null
+++ b/apps/readest-app/src/__tests__/services/sync/replicaCryptoMiddleware.test.ts
@@ -0,0 +1,245 @@
+import { beforeEach, describe, expect, test, vi } from 'vitest';
+import { CryptoSession } from '@/libs/crypto/session';
+import {
+ captureCipherTexts,
+ cipherTextsChanged,
+ collectDecryptSuccess,
+ decryptRowFields,
+ encryptPackedFields,
+} from '@/services/sync/replicaCryptoMiddleware';
+import { isCipherEnvelope } from '@/types/replica';
+import type { CipherEnvelope, FieldsObject, Hlc } from '@/types/replica';
+import type { ReplicaKeyRow } from '@/libs/replicaSyncClient';
+
+const ITER = 1000;
+const PBKDF2_ALG = 'pbkdf2-600k-sha256';
+
+const bytesToBase64 = (bytes: Uint8Array): string => {
+ let s = '';
+ for (let i = 0; i < bytes.length; i++) s += String.fromCharCode(bytes[i]!);
+ return btoa(s);
+};
+
+const makeSaltRow = (saltId: string, createdAt: string): ReplicaKeyRow => {
+ const bytes = new Uint8Array(32);
+ for (let i = 0; i < 32; i++) bytes[i] = (i + saltId.length) & 0xff;
+ return { saltId, alg: PBKDF2_ALG, salt: bytesToBase64(bytes), createdAt };
+};
+
+class FakeClient {
+ rows: ReplicaKeyRow[] = [];
+ async listReplicaKeys(): Promise {
+ return [...this.rows];
+ }
+ async createReplicaKey(): Promise {
+ const row = makeSaltRow(`salt-${this.rows.length + 1}`, new Date().toISOString());
+ this.rows.push(row);
+ return row;
+ }
+ async forgetReplicaKeys(): Promise {
+ this.rows = [];
+ }
+}
+
+const HLC = '00000000001-00000000-dev' as Hlc;
+const wrapField = (v: unknown) => ({ v, t: HLC, s: 'dev' });
+
+describe('replicaCryptoMiddleware', () => {
+ let client: FakeClient;
+ let session: CryptoSession;
+
+ beforeEach(async () => {
+ client = new FakeClient();
+ session = new CryptoSession({ client, iterations: ITER });
+ });
+
+ describe('encryptPackedFields', () => {
+ test('replaces named fields with cipher envelopes when unlocked', async () => {
+ await session.setup('pw');
+ const packed: Record = { name: 'Public', password: 'hunter2' };
+ await encryptPackedFields(packed, ['password'], session);
+ expect(packed['name']).toBe('Public');
+ expect(isCipherEnvelope(packed['password'])).toBe(true);
+ });
+
+ test('drops named fields when session is locked (no plaintext leak)', async () => {
+ const packed: Record = { name: 'Public', password: 'hunter2' };
+ await encryptPackedFields(packed, ['password'], session);
+ expect(packed['name']).toBe('Public');
+ expect(packed['password']).toBeUndefined();
+ expect('password' in packed).toBe(false);
+ });
+
+ test('skips empty / undefined values without erroring', async () => {
+ await session.setup('pw');
+ const packed: Record = { name: 'X', password: '', username: undefined };
+ await encryptPackedFields(packed, ['password', 'username'], session);
+ expect(packed['password']).toBe('');
+ expect(packed['username']).toBeUndefined();
+ });
+
+ test('no-op when encryptedFields is undefined or empty', async () => {
+ await session.setup('pw');
+ const packed: Record = { password: 'hunter2' };
+ await encryptPackedFields(packed, undefined, session);
+ expect(packed['password']).toBe('hunter2');
+ await encryptPackedFields(packed, [], session);
+ expect(packed['password']).toBe('hunter2');
+ });
+ });
+
+ describe('decryptRowFields', () => {
+ test('replaces cipher envelope values with plaintext when unlocked', async () => {
+ await session.setup('pw');
+ const cipher = await session.encryptField('hunter2');
+ const fields: FieldsObject = {
+ name: wrapField('Public'),
+ password: wrapField(cipher),
+ };
+ await decryptRowFields(fields, ['password'], session);
+ expect((fields['name'] as { v: unknown }).v).toBe('Public');
+ expect((fields['password'] as { v: unknown }).v).toBe('hunter2');
+ });
+
+ test('drops the field on locked session (preserves local plaintext at the merge layer)', async () => {
+ // Encrypt with one session, decrypt with another that's locked.
+ const writer = new CryptoSession({ client, iterations: ITER });
+ await writer.setup('pw');
+ const cipher = await writer.encryptField('secret');
+
+ const reader = new CryptoSession({ client, iterations: ITER });
+ const fields: FieldsObject = { password: wrapField(cipher) };
+ await decryptRowFields(fields, ['password'], reader);
+ expect(fields['password']).toBeUndefined();
+ });
+
+ test('locked session + onLocked callback unlocks then decrypts', async () => {
+ // Writer creates the cipher payload under one passphrase.
+ const writer = new CryptoSession({ client, iterations: ITER });
+ await writer.setup('correct');
+ const cipher = await writer.encryptField('secret');
+
+ // Reader starts locked but has the same backing FakeClient (so
+ // it can find the salt). The onLocked callback unlocks the
+ // reader with the right passphrase — the middleware should
+ // detect the now-unlocked state and decrypt.
+ const reader = new CryptoSession({ client, iterations: ITER });
+ const onLocked = vi.fn(async () => {
+ await reader.unlock('correct');
+ });
+ const fields: FieldsObject = {
+ password: wrapField(cipher),
+ username: wrapField(await writer.encryptField('alice')),
+ };
+ await decryptRowFields(fields, ['username', 'password'], reader, onLocked);
+ // Callback fired exactly once even though there are two cipher
+ // fields — once unlocked, the second field decrypts directly.
+ expect(onLocked).toHaveBeenCalledTimes(1);
+ expect((fields['username'] as { v: unknown }).v).toBe('alice');
+ expect((fields['password'] as { v: unknown }).v).toBe('secret');
+ });
+
+ test('onLocked rejection drops the cipher fields cleanly', async () => {
+ const writer = new CryptoSession({ client, iterations: ITER });
+ await writer.setup('pw');
+ const cipher = await writer.encryptField('secret');
+
+ const reader = new CryptoSession({ client, iterations: ITER });
+ const onLocked = vi.fn(async () => {
+ throw new Error('user cancelled');
+ });
+ const fields: FieldsObject = { password: wrapField(cipher) };
+ await decryptRowFields(fields, ['password'], reader, onLocked);
+ expect(onLocked).toHaveBeenCalledTimes(1);
+ expect(fields['password']).toBeUndefined();
+ });
+
+ test('drops the field on wrong-passphrase decrypt failure', async () => {
+ const writer = new CryptoSession({ client, iterations: ITER });
+ await writer.setup('correct');
+ const cipher = await writer.encryptField('secret');
+
+ const reader = new CryptoSession({ client, iterations: ITER });
+ await reader.unlock('wrong');
+ const fields: FieldsObject = { password: wrapField(cipher) };
+ await decryptRowFields(fields, ['password'], reader);
+ expect(fields['password']).toBeUndefined();
+ });
+
+ test('leaves plaintext envelopes untouched (no cipher detected)', async () => {
+ await session.setup('pw');
+ const fields: FieldsObject = {
+ password: wrapField('not-encrypted-but-we-asked'),
+ };
+ await decryptRowFields(fields, ['password'], session);
+ // Not a cipher envelope → middleware leaves it alone.
+ expect((fields['password'] as { v: unknown }).v).toBe('not-encrypted-but-we-asked');
+ });
+
+ test('no-op when encryptedFields is undefined or empty', async () => {
+ await session.setup('pw');
+ const cipher: CipherEnvelope = { c: 'x', i: 'y', s: 'salt-1', alg: 'rot13', h: 'z' };
+ const fields: FieldsObject = { password: wrapField(cipher) };
+ await decryptRowFields(fields, undefined, session);
+ expect(fields['password']).toBeDefined();
+ });
+ });
+
+ describe('captureCipherTexts / cipherTextsChanged / collectDecryptSuccess', () => {
+ test('captureCipherTexts returns each cipher field by name', async () => {
+ await session.setup('pw');
+ const userCipher = await session.encryptField('alice');
+ const passCipher = await session.encryptField('hunter2');
+ const fields: FieldsObject = {
+ username: wrapField(userCipher),
+ password: wrapField(passCipher),
+ name: wrapField('Public'),
+ };
+ const out = captureCipherTexts(fields, ['username', 'password']);
+ expect(out['username']).toBe(userCipher.c);
+ expect(out['password']).toBe(passCipher.c);
+ expect(out['name']).toBeUndefined();
+ });
+
+ test('cipherTextsChanged: undefined lastSeen counts as changed (fresh device)', () => {
+ expect(cipherTextsChanged({ password: 'abc' }, undefined)).toBe(true);
+ });
+
+ test('cipherTextsChanged: same ciphers → false (skip prompt path)', () => {
+ expect(
+ cipherTextsChanged({ username: 'a', password: 'b' }, { username: 'a', password: 'b' }),
+ ).toBe(false);
+ });
+
+ test('cipherTextsChanged: differing cipher → true (rotation path)', () => {
+ expect(cipherTextsChanged({ password: 'new' }, { password: 'old' })).toBe(true);
+ });
+
+ test('cipherTextsChanged: new field not in lastSeen → true', () => {
+ expect(cipherTextsChanged({ username: 'a', password: 'b' }, { username: 'a' })).toBe(true);
+ });
+
+ test('collectDecryptSuccess records only fields whose decrypt succeeded', async () => {
+ const writer = new CryptoSession({ client, iterations: ITER });
+ await writer.setup('pw');
+ const userCipher = await writer.encryptField('alice');
+ const passCipher = await writer.encryptField('hunter2');
+ const before = { username: userCipher.c, password: passCipher.c };
+
+ // Reader will succeed on user, "fail" on password by deleting before decrypt.
+ const reader = new CryptoSession({ client, iterations: ITER });
+ await reader.unlock('pw');
+ const fields: FieldsObject = {
+ username: wrapField(userCipher),
+ password: wrapField(passCipher),
+ };
+ await decryptRowFields(fields, ['username'], reader); // only decrypt username
+ // Pretend the password decrypt failed by removing it.
+ delete fields['password'];
+
+ const out = collectDecryptSuccess(fields, before);
+ expect(out['username']).toBe(userCipher.c);
+ expect(out['password']).toBeUndefined();
+ });
+ });
+});
diff --git a/apps/readest-app/src/app/opds/components/CatalogManager.tsx b/apps/readest-app/src/app/opds/components/CatalogManager.tsx
index 79aeaf86..d8e08668 100644
--- a/apps/readest-app/src/app/opds/components/CatalogManager.tsx
+++ b/apps/readest-app/src/app/opds/components/CatalogManager.tsx
@@ -18,6 +18,8 @@ import { useEnv } from '@/context/EnvContext';
import { useTranslation } from '@/hooks/useTranslation';
import { isWebAppPlatform } from '@/services/environment';
import { useCustomOPDSStore } from '@/store/customOPDSStore';
+import { ensurePassphraseUnlocked } from '@/services/sync/passphraseGate';
+import { isSyncError } from '@/libs/errors';
import { OPDSCatalog } from '@/types/opds';
import { isLanAddress } from '@/utils/network';
import { eventDispatcher } from '@/utils/event';
@@ -211,6 +213,27 @@ export function CatalogManager() {
? parsedHeaders.headers
: undefined;
+ // If the user provided credentials, unlock (or set up) the sync
+ // passphrase BEFORE saving. The crypto middleware drops creds
+ // from the push when the session is locked, so this gate is what
+ // turns the credentials into actual cross-device sync. User
+ // cancel = save proceeds without sync (the catalog still works
+ // locally with the entered creds).
+ const hasCredentials = !!(newCatalog.username || newCatalog.password);
+ if (hasCredentials) {
+ try {
+ await ensurePassphraseUnlocked();
+ } catch (err) {
+ if (!(isSyncError(err) && err.code === 'NO_PASSPHRASE')) {
+ // Surface unexpected errors; cancel-by-user is silent.
+ setUrlError(err instanceof Error ? err.message : String(err));
+ setIsValidating(false);
+ return;
+ }
+ // User cancelled the prompt — save locally without encrypted sync.
+ }
+ }
+
if (editingCatalogId) {
useCustomOPDSStore.getState().updateCatalog(editingCatalogId, {
name: newCatalog.name,
diff --git a/apps/readest-app/src/app/user/components/SyncPassphraseSection.tsx b/apps/readest-app/src/app/user/components/SyncPassphraseSection.tsx
new file mode 100644
index 00000000..250729b5
--- /dev/null
+++ b/apps/readest-app/src/app/user/components/SyncPassphraseSection.tsx
@@ -0,0 +1,111 @@
+'use client';
+
+import { useEffect, useState } from 'react';
+import { useTranslation } from '@/hooks/useTranslation';
+import { cryptoSession } from '@/libs/crypto/session';
+import { ensurePassphraseUnlocked } from '@/services/sync/passphraseGate';
+import { replicaSyncClient } from '@/libs/replicaSyncClient';
+import { isSyncError } from '@/libs/errors';
+
+type SyncPassphraseStatus = 'loading' | 'unset' | 'set' | 'error';
+
+const isAuthError = (err: unknown): boolean => isSyncError(err) && err.code === 'AUTH';
+
+export function SyncPassphraseSection() {
+ const _ = useTranslation();
+ const [status, setStatus] = useState('loading');
+ const [busy, setBusy] = useState(false);
+ const [message, setMessage] = useState(null);
+
+ const refreshStatus = async () => {
+ try {
+ const rows = await replicaSyncClient.listReplicaKeys();
+ setStatus(rows.length === 0 ? 'unset' : 'set');
+ setMessage(null);
+ } catch (err) {
+ if (isAuthError(err)) {
+ // Not signed in — hide the panel by leaving status as 'loading'
+ // until the auth context re-renders.
+ return;
+ }
+ setStatus('error');
+ }
+ };
+
+ useEffect(() => {
+ void refreshStatus();
+ }, []);
+
+ if (status === 'loading') return null;
+
+ // "Unlock now" lets the user proactively enter the passphrase ahead
+ // of an action that needs it (e.g., adding a credentialed catalog
+ // right after a hard refresh) so they don't get interrupted by a
+ // modal mid-flow. When the session is already unlocked it's a quick
+ // no-op that just confirms readiness. The page refresh itself is
+ // the "lock this device" action — there's no separate Lock button.
+ const handleSetOrUnlock = async () => {
+ setBusy(true);
+ setMessage(null);
+ try {
+ await ensurePassphraseUnlocked();
+ await refreshStatus();
+ setMessage(_('Sync passphrase ready'));
+ } catch (err) {
+ setMessage(err instanceof Error ? err.message : String(err));
+ } finally {
+ setBusy(false);
+ }
+ };
+
+ const handleForget = async () => {
+ if (
+ !confirm(
+ _(
+ 'This permanently deletes the encrypted credentials we sync (e.g., OPDS catalog passwords) on every device. Local copies are preserved. You will need to re-enter the sync passphrase or set a new one. Continue?',
+ ),
+ )
+ ) {
+ return;
+ }
+ setBusy(true);
+ setMessage(null);
+ try {
+ await cryptoSession.forget();
+ await refreshStatus();
+ setMessage(_('Sync passphrase forgotten — all encrypted fields cleared'));
+ } catch (err) {
+ setMessage(err instanceof Error ? err.message : String(err));
+ } finally {
+ setBusy(false);
+ }
+ };
+
+ return (
+
+
{_('Sync passphrase')}
+
+ {status === 'unset'
+ ? _(
+ 'Encrypts sensitive synced fields (like OPDS catalog credentials) before they leave your device. Set one now or wait — it will be requested when needed.',
+ )
+ : _('Set on this account. Will be requested when needed to decrypt synced credentials.')}
+
+ {message &&
{message}
}
+
+
+ {status === 'set' && (
+
+ )}
+
+
+ );
+}
diff --git a/apps/readest-app/src/app/user/page.tsx b/apps/readest-app/src/app/user/page.tsx
index 5fd4b223..7d3dcfe7 100644
--- a/apps/readest-app/src/app/user/page.tsx
+++ b/apps/readest-app/src/app/user/page.tsx
@@ -41,6 +41,7 @@ import PlansComparison from './components/PlansComparison';
import AccountActions from './components/AccountActions';
import StorageManager from './components/StorageManager';
import SharedLinksSection from './components/SharedLinksSection';
+import { SyncPassphraseSection } from './components/SyncPassphraseSection';
import Checkout from './components/Checkout';
type CheckoutState = {
@@ -325,6 +326,7 @@ const ProfilePage = () => {
/>
+ void;
+}
+
+/**
+ * Singleton passphrase prompt for the encrypted-fields flow. Mount
+ * once at the app root. Registers itself with the passphrase gate;
+ * any caller that invokes `ensurePassphraseUnlocked` causes this
+ * modal to render and resolve with the entered passphrase (or null
+ * on cancel).
+ */
+export default function PassphrasePrompt() {
+ const _ = useTranslation();
+ const [pending, setPending] = useState(null);
+ const [value, setValue] = useState('');
+ const [confirm, setConfirm] = useState('');
+ const [error, setError] = useState('');
+ const inputRef = useRef(null);
+
+ useEffect(() => {
+ setPassphrasePrompter(({ kind }) => {
+ return new Promise((resolve) => {
+ setValue('');
+ setConfirm('');
+ setError('');
+ setPending({ kind, resolve });
+ });
+ });
+ return () => setPassphrasePrompter(null);
+ }, []);
+
+ useEffect(() => {
+ if (pending) {
+ const t = setTimeout(() => inputRef.current?.focus(), 0);
+ return () => clearTimeout(t);
+ }
+ return undefined;
+ }, [pending]);
+
+ if (!pending) return null;
+
+ const isSetup = pending.kind === 'setup';
+
+ const close = (passphrase: string | null) => {
+ pending.resolve(passphrase);
+ setPending(null);
+ setValue('');
+ setConfirm('');
+ setError('');
+ };
+
+ const handleSubmit = (e: React.FormEvent) => {
+ e.preventDefault();
+ if (value.length < 8) {
+ setError(_('Passphrase must be at least 8 characters'));
+ return;
+ }
+ if (isSetup && value !== confirm) {
+ setError(_('Passphrases do not match'));
+ return;
+ }
+ close(value);
+ };
+
+ // Input pill — modern style for color themes; eink-bordered swaps to
+ // 1px border + base-100 bg under [data-eink='true'].
+ const inputClass =
+ 'eink-bordered w-full rounded-xl bg-base-300/60 px-4 py-3 text-sm placeholder:text-base-content/40 ' +
+ 'border border-transparent transition-colors focus:border-base-content/20 focus:bg-base-300';
+
+ return (
+
+
+
+ );
+}
diff --git a/apps/readest-app/src/components/Providers.tsx b/apps/readest-app/src/components/Providers.tsx
index 347a671e..6346e334 100644
--- a/apps/readest-app/src/components/Providers.tsx
+++ b/apps/readest-app/src/components/Providers.tsx
@@ -19,6 +19,9 @@ import { getDirFromUILanguage } from '@/utils/rtl';
import { DropdownProvider } from '@/context/DropdownContext';
import { CommandPaletteProvider, CommandPalette } from '@/components/command-palette';
import AtmosphereOverlay from '@/components/AtmosphereOverlay';
+import PassphrasePrompt from '@/components/PassphrasePrompt';
+import { upgradeToKeychainIfAvailable } from '@/libs/crypto/passphrase';
+import { cryptoSession } from '@/libs/crypto/session';
const Providers = ({ children }: { children: React.ReactNode }) => {
const { envConfig, appService } = useEnv();
@@ -63,6 +66,18 @@ const Providers = ({ children }: { children: React.ReactNode }) => {
}
}, [envConfig, appService, applyUILanguage, applyBackgroundTexture, applyEinkMode]);
+ // Sync-passphrase boot path: upgrade the passphrase store from
+ // ephemeral to OS keychain on Tauri (probe is async — must run after
+ // the platform check resolves), then attempt a silent unlock from
+ // the saved passphrase. Failures are silent — the gate prompts on
+ // first encrypted-field operation if we couldn't restore.
+ useEffect(() => {
+ void (async () => {
+ await upgradeToKeychainIfAvailable();
+ await cryptoSession.tryRestoreFromStore();
+ })();
+ }, []);
+
// Make sure appService is available in all children components
if (!appService) return;
@@ -76,6 +91,7 @@ const Providers = ({ children }: { children: React.ReactNode }) => {
{children}
+
diff --git a/apps/readest-app/src/components/settings/color/BackgroundTextureSelector.tsx b/apps/readest-app/src/components/settings/color/BackgroundTextureSelector.tsx
index 889ca2e4..4f8d9e31 100644
--- a/apps/readest-app/src/components/settings/color/BackgroundTextureSelector.tsx
+++ b/apps/readest-app/src/components/settings/color/BackgroundTextureSelector.tsx
@@ -47,10 +47,23 @@ const BackgroundTextureSelector: React.FC = ({