From 43f681985a717e3dfd47cd9ed9c04b572b53e21b Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 30 May 2026 22:58:36 -0700 Subject: [PATCH 1/4] feat: prompt user consent when OS keyring is unavailable (#3053) When the OS keyring is unreachable (Linux headless without Secret Service, macOS with denied keychain access, etc.), OpenHuman now stops and asks the user before falling back to local encrypted storage. Rust core: - New `keyring_consent` domain with types, policy gate, RPC controllers - `keyring_consent::policy::check_secret_access()` replaces raw `is_available()` at all fallback sites (credentials, config, wallet) - Resettable keyring probe (`reset_availability_cache`) for retry flow - `KeyringStatus` added to app snapshot (polled every 2s) - `ConsentPreference` persisted in `StoredAppState` - `KeyringConsentRequired` / `KeyringDecryptFailed` domain events - `decrypt_optional_secret` now publishes event before clearing fields Frontend: - `KeyringConsentOverlay` modal (consent / retry / decline) - `SecurityPanel` in Settings (mode badge, availability, retry) - `keyringApi` service for RPC calls - Types added to `CoreAppSnapshot` and `CoreLocalState` i18n: all 14 locales with real translations (keyring.consent.*, keyring.settings.*, pages.settings.account.security*). Docs: consent flow documented in os-keyring-and-secret-storage.md. --- app/src/App.tsx | 2 + .../keyring/KeyringConsentOverlay.tsx | 133 ++++++++++++ .../settings/panels/SecurityPanel.tsx | 161 ++++++++++++++ app/src/lib/coreState/store.ts | 22 +- app/src/lib/i18n/ar.ts | 35 +++ app/src/lib/i18n/bn.ts | 36 ++++ app/src/lib/i18n/de.ts | 36 ++++ app/src/lib/i18n/en.ts | 40 +++- app/src/lib/i18n/es.ts | 35 +++ app/src/lib/i18n/fr.ts | 35 +++ app/src/lib/i18n/hi.ts | 35 +++ app/src/lib/i18n/id.ts | 35 +++ app/src/lib/i18n/it.ts | 35 +++ app/src/lib/i18n/ko.ts | 35 +++ app/src/lib/i18n/pl.ts | 36 ++++ app/src/lib/i18n/pt.ts | 35 +++ app/src/lib/i18n/ru.ts | 36 ++++ app/src/lib/i18n/zh-CN.ts | 34 +++ app/src/pages/Settings.tsx | 19 ++ app/src/providers/CoreStateProvider.tsx | 7 + app/src/services/__tests__/keyringApi.test.ts | 42 ++++ app/src/services/coreStateApi.ts | 20 +- app/src/services/keyringApi.ts | 26 +++ .../features/os-keyring-and-secret-storage.md | 29 +++ src/core/all.rs | 4 + src/core/event_bus/events.rs | 15 ++ src/openhuman/app_state/ops.rs | 20 +- src/openhuman/app_state/ops_tests.rs | 3 +- src/openhuman/app_state/schemas.rs | 7 + src/openhuman/config/schema/load.rs | 4 + src/openhuman/credentials/profiles.rs | 12 +- src/openhuman/keyring/mod.rs | 3 +- src/openhuman/keyring/ops.rs | 45 +++- src/openhuman/keyring_consent/mod.rs | 12 ++ src/openhuman/keyring_consent/ops.rs | 82 +++++++ src/openhuman/keyring_consent/policy.rs | 201 ++++++++++++++++++ src/openhuman/keyring_consent/schemas.rs | 150 +++++++++++++ src/openhuman/keyring_consent/types.rs | 142 +++++++++++++ src/openhuman/mod.rs | 1 + src/openhuman/wallet/ops.rs | 19 +- ...threads_memory_sources_raw_coverage_e2e.rs | 2 + ...ntials_threads_round24_raw_coverage_e2e.rs | 2 + ...hreads_sources_round26_raw_coverage_e2e.rs | 1 + .../app_state_credentials_raw_coverage_e2e.rs | 1 + ...osio_credentials_state_raw_coverage_e2e.rs | 2 + ...s_credentials_appstate_raw_coverage_e2e.rs | 2 + ...mposio_tools_ops_state_raw_coverage_e2e.rs | 1 + tests/config_credentials_raw_coverage_e2e.rs | 4 + tests/near90_closure_raw_coverage_e2e.rs | 1 + ...gent_credentials_state_raw_coverage_e2e.rs | 2 + 50 files changed, 1667 insertions(+), 30 deletions(-) create mode 100644 app/src/components/keyring/KeyringConsentOverlay.tsx create mode 100644 app/src/components/settings/panels/SecurityPanel.tsx create mode 100644 app/src/services/__tests__/keyringApi.test.ts create mode 100644 app/src/services/keyringApi.ts create mode 100644 src/openhuman/keyring_consent/mod.rs create mode 100644 src/openhuman/keyring_consent/ops.rs create mode 100644 src/openhuman/keyring_consent/policy.rs create mode 100644 src/openhuman/keyring_consent/schemas.rs create mode 100644 src/openhuman/keyring_consent/types.rs diff --git a/app/src/App.tsx b/app/src/App.tsx index f7d0db50e7..3fd7a3c8c0 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -13,6 +13,7 @@ import CommandProvider from './components/commands/CommandProvider'; import ServiceBlockingGate from './components/daemon/ServiceBlockingGate'; import DictationHotkeyManager from './components/DictationHotkeyManager'; import ErrorFallbackScreen from './components/ErrorFallbackScreen'; +import KeyringConsentOverlay from './components/keyring/KeyringConsentOverlay'; import LocalAIDownloadSnackbar from './components/LocalAIDownloadSnackbar'; import SecretPromptDialog from './components/mcp-setup/SecretPromptDialog'; import OpenhumanLinkModal from './components/OpenhumanLinkModal'; @@ -107,6 +108,7 @@ function App() { {!onMobile && } {!onMobile && } {!onMobile && } + diff --git a/app/src/components/keyring/KeyringConsentOverlay.tsx b/app/src/components/keyring/KeyringConsentOverlay.tsx new file mode 100644 index 0000000000..4ecd1819e4 --- /dev/null +++ b/app/src/components/keyring/KeyringConsentOverlay.tsx @@ -0,0 +1,133 @@ +import { useState } from 'react'; + +import { useT } from '../../lib/i18n/I18nContext'; +import { useCoreState } from '../../providers/CoreStateProvider'; +import { decideKeyringConsent, retryKeyringProbe } from '../../services/keyringApi'; + +const KeyringConsentOverlay = () => { + const { t } = useT(); + const { snapshot } = useCoreState(); + const [isRetrying, setIsRetrying] = useState(false); + const [isConsenting, setIsConsenting] = useState(false); + const [showDetails, setShowDetails] = useState(false); + const [error, setError] = useState(null); + + const keyringStatus = snapshot.keyringStatus; + const needsConsent = keyringStatus.activeMode === 'consent_pending'; + + if (!needsConsent) { + return null; + } + + const handleConsent = async () => { + setIsConsenting(true); + setError(null); + try { + await decideKeyringConsent('local_encrypted'); + } catch { + setError(t('keyring.consent.error')); + } finally { + setIsConsenting(false); + } + }; + + const handleDecline = async () => { + setIsConsenting(true); + setError(null); + try { + await decideKeyringConsent('declined'); + } catch { + setError(t('keyring.consent.error')); + } finally { + setIsConsenting(false); + } + }; + + const handleRetry = async () => { + setIsRetrying(true); + setError(null); + try { + await retryKeyringProbe(); + } catch { + setError(t('keyring.consent.retryFailed')); + } finally { + setIsRetrying(false); + } + }; + + const failureReason = keyringStatus.failureReason; + + return ( +
+
+
+
+ + + +
+

{t('keyring.consent.title')}

+
+ +

{t('keyring.consent.description')}

+ + {failureReason && ( +

+ {t('keyring.consent.reasonPrefix')} {failureReason} +

+ )} + + + + {showDetails && ( +
+

{t('keyring.consent.tradeoffTitle')}

+

{t('keyring.consent.tradeoffBody')}

+
+ )} + + {error &&

{error}

} + +
+ + + +
+
+
+ ); +}; + +export default KeyringConsentOverlay; diff --git a/app/src/components/settings/panels/SecurityPanel.tsx b/app/src/components/settings/panels/SecurityPanel.tsx new file mode 100644 index 0000000000..907782a0ef --- /dev/null +++ b/app/src/components/settings/panels/SecurityPanel.tsx @@ -0,0 +1,161 @@ +import { useState } from 'react'; + +import { useT } from '../../../lib/i18n/I18nContext'; +import { useCoreState } from '../../../providers/CoreStateProvider'; +import { decideKeyringConsent, retryKeyringProbe } from '../../../services/keyringApi'; +import SettingsHeader from '../components/SettingsHeader'; +import { useSettingsNavigation } from '../hooks/useSettingsNavigation'; + +const MODE_BADGE: Record = { + os_keyring: { + label: 'keyring.settings.mode.osKeychain', + className: + 'bg-sage-50 dark:bg-sage-500/10 text-sage-700 dark:text-sage-300 border-sage-200 dark:border-sage-500/30', + }, + local_encrypted: { + label: 'keyring.settings.mode.encryptedFile', + className: + 'bg-amber-50 dark:bg-amber-500/10 text-amber-700 dark:text-amber-300 border-amber-200 dark:border-amber-500/30', + }, + consent_pending: { + label: 'keyring.settings.mode.consentPending', + className: + 'bg-stone-100 dark:bg-neutral-800 text-stone-700 dark:text-neutral-200 border-stone-200 dark:border-neutral-800', + }, + declined: { + label: 'keyring.settings.mode.declined', + className: + 'bg-coral-50 dark:bg-coral-500/10 text-coral-700 dark:text-coral-300 border-coral-200 dark:border-coral-500/30', + }, +}; + +const SecurityPanel = () => { + const { navigateBack, breadcrumbs } = useSettingsNavigation(); + const { snapshot } = useCoreState(); + const { t } = useT(); + const [isRetrying, setIsRetrying] = useState(false); + const [isUpdating, setIsUpdating] = useState(false); + const [error, setError] = useState(null); + + const keyringStatus = snapshot.keyringStatus; + const modeBadge = MODE_BADGE[keyringStatus.activeMode] ?? MODE_BADGE.consent_pending; + + const handleRetryProbe = async () => { + setIsRetrying(true); + setError(null); + try { + await retryKeyringProbe(); + } catch { + setError(t('keyring.settings.retryFailed')); + } finally { + setIsRetrying(false); + } + }; + + const handleConsentChange = async (mode: 'local_encrypted' | 'declined') => { + setIsUpdating(true); + setError(null); + try { + await decideKeyringConsent(mode); + } catch { + setError(t('keyring.consent.error')); + } finally { + setIsUpdating(false); + } + }; + + return ( +
+ + +
+ {/* Storage mode */} +
+

+ {t('keyring.settings.storageMode')} +

+
+ + {t(modeBadge.label)} + + + {t('keyring.settings.backend')}: {keyringStatus.backendName} + +
+
+ + {/* Availability */} +
+

+ {t('keyring.settings.availability')} +

+
+
+
+ + {keyringStatus.available + ? t('keyring.settings.available') + : t('keyring.settings.unavailable')} + +
+ {keyringStatus.failureReason && ( +

+ {keyringStatus.failureReason} +

+ )} + +
+
+ + {/* Consent management (only when keyring is unavailable) */} + {!keyringStatus.available && ( +
+

+ {t('keyring.settings.consentTitle')} +

+

+ {t('keyring.settings.consentDescription')} +

+
+ {keyringStatus.activeMode !== 'local_encrypted' && ( + + )} + {keyringStatus.activeMode !== 'declined' && ( + + )} +
+
+ )} + + {error &&

{error}

} +
+
+ ); +}; + +export default SecurityPanel; diff --git a/app/src/lib/coreState/store.ts b/app/src/lib/coreState/store.ts index bf96a32c23..194f440819 100644 --- a/app/src/lib/coreState/store.ts +++ b/app/src/lib/coreState/store.ts @@ -14,9 +14,22 @@ export interface CoreOnboardingTasks { updatedAtMs?: number; } +export interface KeyringConsentPreference { + storageMode: string; + consentedAtMs?: number; +} + +export interface KeyringStatus { + available: boolean; + failureReason?: string | null; + activeMode: string; + backendName: string; +} + export interface CoreLocalState { encryptionKey: string | null; onboardingTasks: CoreOnboardingTasks | null; + keyringConsent: KeyringConsentPreference | null; } export interface CoreRuntimeSnapshot { @@ -55,6 +68,7 @@ export interface CoreAppSnapshot { */ meetAutoOrchestratorHandoff: boolean; localState: CoreLocalState; + keyringStatus: KeyringStatus; runtime: CoreRuntimeSnapshot; } @@ -75,7 +89,13 @@ const emptySnapshot: CoreAppSnapshot = { chatOnboardingCompleted: false, analyticsEnabled: false, meetAutoOrchestratorHandoff: false, - localState: { encryptionKey: null, onboardingTasks: null }, + localState: { encryptionKey: null, onboardingTasks: null, keyringConsent: null }, + keyringStatus: { + available: true, + failureReason: null, + activeMode: 'os_keyring', + backendName: 'os', + }, runtime: { screenIntelligence: null, localAi: null, autocomplete: null, service: null }, }; diff --git a/app/src/lib/i18n/ar.ts b/app/src/lib/i18n/ar.ts index c89c01610e..ef3589a1a3 100644 --- a/app/src/lib/i18n/ar.ts +++ b/app/src/lib/i18n/ar.ts @@ -4306,6 +4306,41 @@ const messages: TranslationMap = { 'graphCohesion.summaryCaption': 'متوسط التجميع {avg} · التعدّي {transitivity}', 'graphCohesion.title': 'تماسك الرسم البياني', 'memory.tab.cohesion': 'Cohesion', + + 'keyring.consent.title': 'التخزين الآمن غير متاح', + 'keyring.consent.description': + 'سلسلة مفاتيح نظام التشغيل غير متاحة. يحتاج OpenHuman إلى إذنك لتخزين الأسرار باستخدام التخزين المشفّر المحلي بدلاً من ذلك.', + 'keyring.consent.reasonPrefix': 'السبب:', + 'keyring.consent.showDetails': 'ماذا يعني هذا؟', + 'keyring.consent.hideDetails': 'إخفاء التفاصيل', + 'keyring.consent.tradeoffTitle': 'مقايضة أمنية', + 'keyring.consent.tradeoffBody': + 'مع التخزين المشفّر المحلي، تُشفَّر أسرارك على القرص باستخدام مفتاح رئيسي مخزَّن بجانب البيانات. يُعدّ هذا أقل أماناً من OS Keychain الذي يستخدم حماية مدعومة بالأجهزة. قد تتضمن النسخ الاحتياطية أو مزامنة الملفات البيانات المشفّرة.', + 'keyring.consent.consentButton': 'استخدام التخزين المشفّر المحلي', + 'keyring.consent.retryButton': 'إعادة المحاولة مع OS Keychain', + 'keyring.consent.declineButton': 'تخطّ', + 'keyring.consent.retrying': 'جارٍ إعادة المحاولة…', + 'keyring.consent.error': 'فشل حفظ التفضيل. يرجى المحاولة مرة أخرى.', + 'keyring.consent.retryFailed': 'سلسلة المفاتيح لا تزال غير متاحة.', + 'keyring.settings.title': 'الأمان', + 'keyring.settings.storageMode': 'وضع تخزين الأسرار', + 'keyring.settings.mode.osKeychain': 'OS Keychain', + 'keyring.settings.mode.encryptedFile': 'مشفَّر محلياً', + 'keyring.settings.mode.consentPending': 'غير مُهيَّأ', + 'keyring.settings.mode.declined': 'مرفوض', + 'keyring.settings.availability': 'توفّر سلسلة المفاتيح', + 'keyring.settings.available': 'OS Keychain متاح', + 'keyring.settings.unavailable': 'OS Keychain غير متاح', + 'keyring.settings.backend': 'Backend', + 'keyring.settings.retryButton': 'إعادة محاولة الكشف عن سلسلة المفاتيح', + 'keyring.settings.retryFailed': 'فشلت إعادة المحاولة. سلسلة المفاتيح لا تزال غير متاحة.', + 'keyring.settings.consentTitle': 'موافقة التخزين', + 'keyring.settings.consentDescription': + 'اختر كيفية تخزين الأسرار عندما لا تكون سلسلة مفاتيح نظام التشغيل متاحة.', + 'keyring.settings.grantConsent': 'السماح بالتخزين المشفّر المحلي', + 'keyring.settings.revokeConsent': 'رفض التخزين المحلي', + 'pages.settings.account.security': 'الأمان', + 'pages.settings.account.securityDesc': 'وضع تخزين الأسرار وحالة سلسلة المفاتيح', }; export default messages; diff --git a/app/src/lib/i18n/bn.ts b/app/src/lib/i18n/bn.ts index 694a2b66c1..d281c2befb 100644 --- a/app/src/lib/i18n/bn.ts +++ b/app/src/lib/i18n/bn.ts @@ -4381,6 +4381,42 @@ const messages: TranslationMap = { 'graphCohesion.summaryCaption': 'গড় ক্লাস্টারিং {avg} · সংক্রমণতা {transitivity}', 'graphCohesion.title': 'গ্রাফ সংসক্তি', 'memory.tab.cohesion': 'Cohesion', + + // Keyring consent & security + 'keyring.consent.title': 'নিরাপদ সঞ্চয়স্থান অনুপলব্ধ', + 'keyring.consent.description': + 'আপনার অপারেটিং সিস্টেমের কিচেন অ্যাক্সেসযোগ্য নয়। OpenHuman-এর পরিবর্তে স্থানীয় এনক্রিপ্টেড সঞ্চয়স্থান ব্যবহার করে গোপনীয়তা সংরক্ষণ করতে আপনার অনুমতি প্রয়োজন।', + 'keyring.consent.reasonPrefix': 'কারণ:', + 'keyring.consent.showDetails': 'এটার মানে কী?', + 'keyring.consent.hideDetails': 'বিবরণ লুকান', + 'keyring.consent.tradeoffTitle': 'নিরাপত্তা বিনিময়', + 'keyring.consent.tradeoffBody': + 'স্থানীয় এনক্রিপ্টেড সঞ্চয়স্থানে, আপনার গোপনীয়তা ডিস্কে একটি মাস্টার কী দিয়ে এনক্রিপ্ট করা হয় যা ডেটার পাশাপাশি সংরক্ষিত থাকে। এটি OS কিচেনের চেয়ে কম নিরাপদ, যা হার্ডওয়্যার-সমর্থিত সুরক্ষা ব্যবহার করে। ব্যাকআপ বা ফাইল সিঙ্কিংয়ে এনক্রিপ্টেড ডেটা অন্তর্ভুক্ত হতে পারে।', + 'keyring.consent.consentButton': 'স্থানীয় এনক্রিপ্টেড সঞ্চয়স্থান ব্যবহার করুন', + 'keyring.consent.retryButton': 'OS Keychain পুনরায় চেষ্টা করুন', + 'keyring.consent.declineButton': 'এড়িয়ে যান', + 'keyring.consent.retrying': 'পুনরায় চেষ্টা হচ্ছে…', + 'keyring.consent.error': 'পছন্দ সংরক্ষণ ব্যর্থ। অনুগ্রহ করে আবার চেষ্টা করুন।', + 'keyring.consent.retryFailed': 'কিচেন এখনও অনুপলব্ধ।', + 'keyring.settings.title': 'নিরাপত্তা', + 'keyring.settings.storageMode': 'গোপনীয়তা সঞ্চয়স্থান মোড', + 'keyring.settings.mode.osKeychain': 'OS Keychain', + 'keyring.settings.mode.encryptedFile': 'স্থানীয় এনক্রিপ্টেড', + 'keyring.settings.mode.consentPending': 'কনফিগার করা হয়নি', + 'keyring.settings.mode.declined': 'প্রত্যাখ্যান করা হয়েছে', + 'keyring.settings.availability': 'কিচেন প্রাপ্যতা', + 'keyring.settings.available': 'OS কিচেন উপলব্ধ', + 'keyring.settings.unavailable': 'OS কিচেন অনুপলব্ধ', + 'keyring.settings.backend': 'Backend', + 'keyring.settings.retryButton': 'কিচেন সনাক্তকরণ পুনরায় চেষ্টা করুন', + 'keyring.settings.retryFailed': 'পুনরায় চেষ্টা ব্যর্থ। কিচেন এখনও অনুপলব্ধ।', + 'keyring.settings.consentTitle': 'সঞ্চয়স্থান সম্মতি', + 'keyring.settings.consentDescription': + 'OS কিচেন অনুপলব্ধ থাকলে গোপনীয়তা কীভাবে সংরক্ষণ করা হবে তা বেছে নিন।', + 'keyring.settings.grantConsent': 'স্থানীয় এনক্রিপ্টেড সঞ্চয়স্থান অনুমতি দিন', + 'keyring.settings.revokeConsent': 'স্থানীয় সঞ্চয়স্থান প্রত্যাখ্যান করুন', + 'pages.settings.account.security': 'নিরাপত্তা', + 'pages.settings.account.securityDesc': 'গোপনীয়তা সঞ্চয়স্থান মোড এবং কিচেন অবস্থা', }; export default messages; diff --git a/app/src/lib/i18n/de.ts b/app/src/lib/i18n/de.ts index 5b2907a8e7..0fd1e054c6 100644 --- a/app/src/lib/i18n/de.ts +++ b/app/src/lib/i18n/de.ts @@ -4497,6 +4497,42 @@ const messages: TranslationMap = { 'Durchschnittliches Clustering {avg} · Transitivität {transitivity}', 'graphCohesion.title': 'Graph-Kohäsion', 'memory.tab.cohesion': 'Cohesion', + + 'keyring.consent.title': 'Sicherer Speicher nicht verfügbar', + 'keyring.consent.description': + 'Der Schlüsselbund Ihres Betriebssystems ist nicht erreichbar. OpenHuman benötigt Ihre Erlaubnis, Geheimnisse stattdessen in einem lokal verschlüsselten Speicher abzulegen.', + 'keyring.consent.reasonPrefix': 'Grund:', + 'keyring.consent.showDetails': 'Was bedeutet das?', + 'keyring.consent.hideDetails': 'Details ausblenden', + 'keyring.consent.tradeoffTitle': 'Sicherheitskompromiss', + 'keyring.consent.tradeoffBody': + 'Bei lokal verschlüsseltem Speicher werden Ihre Geheimnisse mit einem Hauptschlüssel verschlüsselt, der neben den Daten gespeichert wird. Dies ist weniger sicher als der OS-Schlüsselbund, der hardwaregestützten Schutz bietet. Backups oder Dateisynchronisation können die verschlüsselten Daten enthalten.', + 'keyring.consent.consentButton': 'Lokal verschlüsselten Speicher verwenden', + 'keyring.consent.retryButton': 'OS Keychain erneut versuchen', + 'keyring.consent.declineButton': 'Überspringen', + 'keyring.consent.retrying': 'Erneuter Versuch…', + 'keyring.consent.error': 'Einstellung konnte nicht gespeichert werden. Bitte erneut versuchen.', + 'keyring.consent.retryFailed': 'Schlüsselbund ist weiterhin nicht verfügbar.', + 'keyring.settings.title': 'Sicherheit', + 'keyring.settings.storageMode': 'Geheimnisspeicher-Modus', + 'keyring.settings.mode.osKeychain': 'OS Keychain', + 'keyring.settings.mode.encryptedFile': 'Lokal verschlüsselt', + 'keyring.settings.mode.consentPending': 'Nicht konfiguriert', + 'keyring.settings.mode.declined': 'Abgelehnt', + 'keyring.settings.availability': 'Schlüsselbund-Verfügbarkeit', + 'keyring.settings.available': 'OS-Schlüsselbund ist verfügbar', + 'keyring.settings.unavailable': 'OS-Schlüsselbund ist nicht verfügbar', + 'keyring.settings.backend': 'Backend', + 'keyring.settings.retryButton': 'Schlüsselbund-Erkennung wiederholen', + 'keyring.settings.retryFailed': + 'Erneuter Versuch fehlgeschlagen. Schlüsselbund weiterhin nicht verfügbar.', + 'keyring.settings.consentTitle': 'Speicherzustimmung', + 'keyring.settings.consentDescription': + 'Wählen Sie, wie Geheimnisse gespeichert werden, wenn der OS-Schlüsselbund nicht verfügbar ist.', + 'keyring.settings.grantConsent': 'Lokal verschlüsselten Speicher erlauben', + 'keyring.settings.revokeConsent': 'Lokalen Speicher ablehnen', + 'pages.settings.account.security': 'Sicherheit', + 'pages.settings.account.securityDesc': 'Geheimnisspeicher-Modus und Schlüsselbund-Status', }; export default messages; diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts index a5e3ad69f8..b0a61ad0f2 100644 --- a/app/src/lib/i18n/en.ts +++ b/app/src/lib/i18n/en.ts @@ -4608,8 +4608,44 @@ const en: TranslationMap = { 'settings.agents.editor.toolsLoadError': 'Couldn’t load tools', 'settings.agents.editor.toolsEmpty': 'No tools match your search.', 'settings.agents.editor.toolsDone': 'Done', - 'settings.agents.editor.builtInReadonly': - 'Built-in agents can’t be edited. You can enable, disable, or reset them from the agents list.', + ‘settings.agents.editor.builtInReadonly’: + ‘Built-in agents can’t be edited. You can enable, disable, or reset them from the agents list.’, + + // Keyring consent & security + ‘keyring.consent.title’: ‘Secure Storage Unavailable’, + ‘keyring.consent.description’: + ‘Your operating system keychain is not accessible. OpenHuman needs your permission to store secrets using local encrypted storage instead.’, + ‘keyring.consent.reasonPrefix’: ‘Reason:’, + ‘keyring.consent.showDetails’: ‘What does this mean?’, + ‘keyring.consent.hideDetails’: ‘Hide details’, + ‘keyring.consent.tradeoffTitle’: ‘Security tradeoff’, + ‘keyring.consent.tradeoffBody’: + ‘With local encrypted storage, your secrets are encrypted on disk using a master key stored alongside the data. This is less secure than the OS keychain, which uses hardware-backed protection. Backups or file syncing may include the encrypted data.’, + ‘keyring.consent.consentButton’: ‘Use Local Encrypted Storage’, + ‘keyring.consent.retryButton’: ‘Retry OS Keychain’, + ‘keyring.consent.declineButton’: ‘Skip’, + ‘keyring.consent.retrying’: ‘Retrying…’, + ‘keyring.consent.error’: ‘Failed to save preference. Please try again.’, + ‘keyring.consent.retryFailed’: ‘Keychain is still unavailable.’, + ‘keyring.settings.title’: ‘Security’, + ‘keyring.settings.storageMode’: ‘Secret storage mode’, + ‘keyring.settings.mode.osKeychain’: ‘OS Keychain’, + ‘keyring.settings.mode.encryptedFile’: ‘Local Encrypted’, + ‘keyring.settings.mode.consentPending’: ‘Not configured’, + ‘keyring.settings.mode.declined’: ‘Declined’, + ‘keyring.settings.availability’: ‘Keychain availability’, + ‘keyring.settings.available’: ‘OS keychain is available’, + ‘keyring.settings.unavailable’: ‘OS keychain is unavailable’, + ‘keyring.settings.backend’: ‘Backend’, + ‘keyring.settings.retryButton’: ‘Retry keychain detection’, + ‘keyring.settings.retryFailed’: ‘Retry failed. Keychain is still unavailable.’, + ‘keyring.settings.consentTitle’: ‘Storage consent’, + ‘keyring.settings.consentDescription’: + ‘Choose how secrets are stored when the OS keychain is not available.’, + ‘keyring.settings.grantConsent’: ‘Allow local encrypted storage’, + ‘keyring.settings.revokeConsent’: ‘Decline local storage’, + ‘pages.settings.account.security’: ‘Security’, + ‘pages.settings.account.securityDesc’: ‘Secret storage mode and keychain status’, }; export default en; diff --git a/app/src/lib/i18n/es.ts b/app/src/lib/i18n/es.ts index 67113cb24c..a862333b4e 100644 --- a/app/src/lib/i18n/es.ts +++ b/app/src/lib/i18n/es.ts @@ -4464,6 +4464,41 @@ const messages: TranslationMap = { 'graphCohesion.summaryCaption': 'Agrupamiento promedio {avg} · transitividad {transitivity}', 'graphCohesion.title': 'Cohesión del grafo', 'memory.tab.cohesion': 'Cohesion', + + 'keyring.consent.title': 'Almacenamiento seguro no disponible', + 'keyring.consent.description': + 'El llavero de su sistema operativo no está accesible. OpenHuman necesita su permiso para almacenar secretos usando almacenamiento local cifrado.', + 'keyring.consent.reasonPrefix': 'Motivo:', + 'keyring.consent.showDetails': '¿Qué significa esto?', + 'keyring.consent.hideDetails': 'Ocultar detalles', + 'keyring.consent.tradeoffTitle': 'Compromiso de seguridad', + 'keyring.consent.tradeoffBody': + 'Con el almacenamiento local cifrado, sus secretos se cifran en disco usando una clave maestra almacenada junto a los datos. Esto es menos seguro que el llavero del SO, que usa protección respaldada por hardware. Las copias de seguridad o la sincronización de archivos pueden incluir los datos cifrados.', + 'keyring.consent.consentButton': 'Usar almacenamiento local cifrado', + 'keyring.consent.retryButton': 'Reintentar OS Keychain', + 'keyring.consent.declineButton': 'Omitir', + 'keyring.consent.retrying': 'Reintentando…', + 'keyring.consent.error': 'No se pudo guardar la preferencia. Inténtelo de nuevo.', + 'keyring.consent.retryFailed': 'El llavero sigue sin estar disponible.', + 'keyring.settings.title': 'Seguridad', + 'keyring.settings.storageMode': 'Modo de almacenamiento de secretos', + 'keyring.settings.mode.osKeychain': 'OS Keychain', + 'keyring.settings.mode.encryptedFile': 'Local cifrado', + 'keyring.settings.mode.consentPending': 'No configurado', + 'keyring.settings.mode.declined': 'Rechazado', + 'keyring.settings.availability': 'Disponibilidad del llavero', + 'keyring.settings.available': 'El llavero del SO está disponible', + 'keyring.settings.unavailable': 'El llavero del SO no está disponible', + 'keyring.settings.backend': 'Backend', + 'keyring.settings.retryButton': 'Reintentar detección del llavero', + 'keyring.settings.retryFailed': 'Reintento fallido. El llavero sigue sin estar disponible.', + 'keyring.settings.consentTitle': 'Consentimiento de almacenamiento', + 'keyring.settings.consentDescription': + 'Elija cómo se almacenan los secretos cuando el llavero del SO no está disponible.', + 'keyring.settings.grantConsent': 'Permitir almacenamiento local cifrado', + 'keyring.settings.revokeConsent': 'Rechazar almacenamiento local', + 'pages.settings.account.security': 'Seguridad', + 'pages.settings.account.securityDesc': 'Modo de almacenamiento de secretos y estado del llavero', }; export default messages; diff --git a/app/src/lib/i18n/fr.ts b/app/src/lib/i18n/fr.ts index 4012a3f3f0..f656c2c7cf 100644 --- a/app/src/lib/i18n/fr.ts +++ b/app/src/lib/i18n/fr.ts @@ -4479,6 +4479,41 @@ const messages: TranslationMap = { 'graphCohesion.summaryCaption': 'Regroupement moyen {avg} · transitivité {transitivity}', 'graphCohesion.title': 'Cohésion du graphe', 'memory.tab.cohesion': 'Cohesion', + + 'keyring.consent.title': 'Stockage sécurisé indisponible', + 'keyring.consent.description': + "Le trousseau de votre système d'exploitation n'est pas accessible. OpenHuman a besoin de votre autorisation pour stocker les secrets en utilisant un stockage local chiffré.", + 'keyring.consent.reasonPrefix': 'Raison :', + 'keyring.consent.showDetails': "Qu'est-ce que cela signifie ?", + 'keyring.consent.hideDetails': 'Masquer les détails', + 'keyring.consent.tradeoffTitle': 'Compromis de sécurité', + 'keyring.consent.tradeoffBody': + "Avec le stockage local chiffré, vos secrets sont chiffrés sur disque à l'aide d'une clé maître stockée à côté des données. C'est moins sécurisé que le trousseau du système, qui utilise une protection matérielle. Les sauvegardes ou la synchronisation de fichiers peuvent inclure les données chiffrées.", + 'keyring.consent.consentButton': 'Utiliser le stockage local chiffré', + 'keyring.consent.retryButton': 'Réessayer OS Keychain', + 'keyring.consent.declineButton': 'Ignorer', + 'keyring.consent.retrying': 'Nouvelle tentative…', + 'keyring.consent.error': "Impossible d'enregistrer la préférence. Veuillez réessayer.", + 'keyring.consent.retryFailed': 'Le trousseau est toujours indisponible.', + 'keyring.settings.title': 'Sécurité', + 'keyring.settings.storageMode': 'Mode de stockage des secrets', + 'keyring.settings.mode.osKeychain': 'OS Keychain', + 'keyring.settings.mode.encryptedFile': 'Local chiffré', + 'keyring.settings.mode.consentPending': 'Non configuré', + 'keyring.settings.mode.declined': 'Refusé', + 'keyring.settings.availability': 'Disponibilité du trousseau', + 'keyring.settings.available': 'Le trousseau du système est disponible', + 'keyring.settings.unavailable': 'Le trousseau du système est indisponible', + 'keyring.settings.backend': 'Backend', + 'keyring.settings.retryButton': 'Réessayer la détection du trousseau', + 'keyring.settings.retryFailed': 'Échec de la tentative. Le trousseau est toujours indisponible.', + 'keyring.settings.consentTitle': 'Consentement de stockage', + 'keyring.settings.consentDescription': + "Choisissez comment les secrets sont stockés lorsque le trousseau du système n'est pas disponible.", + 'keyring.settings.grantConsent': 'Autoriser le stockage local chiffré', + 'keyring.settings.revokeConsent': 'Refuser le stockage local', + 'pages.settings.account.security': 'Sécurité', + 'pages.settings.account.securityDesc': 'Mode de stockage des secrets et état du trousseau', }; export default messages; diff --git a/app/src/lib/i18n/hi.ts b/app/src/lib/i18n/hi.ts index 36d8e1c856..cd2629f40f 100644 --- a/app/src/lib/i18n/hi.ts +++ b/app/src/lib/i18n/hi.ts @@ -4389,6 +4389,41 @@ const messages: TranslationMap = { 'graphCohesion.summaryCaption': 'औसत क्लस्टरिंग {avg} · सकर्मकता {transitivity}', 'graphCohesion.title': 'ग्राफ संसक्ति', 'memory.tab.cohesion': 'Cohesion', + + 'keyring.consent.title': 'सुरक्षित भंडारण अनुपलब्ध', + 'keyring.consent.description': + 'आपके ऑपरेटिंग सिस्टम का कीचेन सुलभ नहीं है। OpenHuman को इसके बजाय स्थानीय एन्क्रिप्टेड भंडारण का उपयोग करके रहस्य संग्रहीत करने के लिए आपकी अनुमति चाहिए।', + 'keyring.consent.reasonPrefix': 'कारण:', + 'keyring.consent.showDetails': 'इसका क्या मतलब है?', + 'keyring.consent.hideDetails': 'विवरण छिपाएं', + 'keyring.consent.tradeoffTitle': 'सुरक्षा समझौता', + 'keyring.consent.tradeoffBody': + 'स्थानीय एन्क्रिप्टेड भंडारण में, आपके रहस्य डिस्क पर एक मास्टर कुंजी से एन्क्रिप्ट किए जाते हैं जो डेटा के साथ संग्रहीत होती है। यह OS कीचेन से कम सुरक्षित है, जो हार्डवेयर-समर्थित सुरक्षा का उपयोग करता है। बैकअप या फ़ाइल सिंकिंग में एन्क्रिप्टेड डेटा शामिल हो सकता है।', + 'keyring.consent.consentButton': 'स्थानीय एन्क्रिप्टेड भंडारण का उपयोग करें', + 'keyring.consent.retryButton': 'OS Keychain पुनः प्रयास करें', + 'keyring.consent.declineButton': 'छोड़ें', + 'keyring.consent.retrying': 'पुनः प्रयास हो रहा है…', + 'keyring.consent.error': 'प्राथमिकता सहेजने में विफल। कृपया पुनः प्रयास करें।', + 'keyring.consent.retryFailed': 'कीचेन अभी भी अनुपलब्ध है।', + 'keyring.settings.title': 'सुरक्षा', + 'keyring.settings.storageMode': 'रहस्य भंडारण मोड', + 'keyring.settings.mode.osKeychain': 'OS Keychain', + 'keyring.settings.mode.encryptedFile': 'स्थानीय एन्क्रिप्टेड', + 'keyring.settings.mode.consentPending': 'कॉन्फ़िगर नहीं किया गया', + 'keyring.settings.mode.declined': 'अस्वीकृत', + 'keyring.settings.availability': 'कीचेन उपलब्धता', + 'keyring.settings.available': 'OS कीचेन उपलब्ध है', + 'keyring.settings.unavailable': 'OS कीचेन अनुपलब्ध है', + 'keyring.settings.backend': 'Backend', + 'keyring.settings.retryButton': 'कीचेन पहचान पुनः प्रयास करें', + 'keyring.settings.retryFailed': 'पुनः प्रयास विफल। कीचेन अभी भी अनुपलब्ध है।', + 'keyring.settings.consentTitle': 'भंडारण सहमति', + 'keyring.settings.consentDescription': + 'चुनें कि OS कीचेन अनुपलब्ध होने पर रहस्य कैसे संग्रहीत किए जाएं।', + 'keyring.settings.grantConsent': 'स्थानीय एन्क्रिप्टेड भंडारण की अनुमति दें', + 'keyring.settings.revokeConsent': 'स्थानीय भंडारण अस्वीकार करें', + 'pages.settings.account.security': 'सुरक्षा', + 'pages.settings.account.securityDesc': 'रहस्य भंडारण मोड और कीचेन स्थिति', }; export default messages; diff --git a/app/src/lib/i18n/id.ts b/app/src/lib/i18n/id.ts index 46819010a2..a0fabf6a20 100644 --- a/app/src/lib/i18n/id.ts +++ b/app/src/lib/i18n/id.ts @@ -4398,6 +4398,41 @@ const messages: TranslationMap = { 'graphCohesion.summaryCaption': 'Pengelompokan rata-rata {avg} · transitivitas {transitivity}', 'graphCohesion.title': 'Kohesi Graf', 'memory.tab.cohesion': 'Cohesion', + + 'keyring.consent.title': 'Penyimpanan aman tidak tersedia', + 'keyring.consent.description': + 'Keychain sistem operasi Anda tidak dapat diakses. OpenHuman memerlukan izin Anda untuk menyimpan rahasia menggunakan penyimpanan lokal terenkripsi.', + 'keyring.consent.reasonPrefix': 'Alasan:', + 'keyring.consent.showDetails': 'Apa artinya ini?', + 'keyring.consent.hideDetails': 'Sembunyikan detail', + 'keyring.consent.tradeoffTitle': 'Kompromi keamanan', + 'keyring.consent.tradeoffBody': + 'Dengan penyimpanan lokal terenkripsi, rahasia Anda dienkripsi di disk menggunakan kunci master yang disimpan bersama data. Ini kurang aman dibandingkan keychain OS yang menggunakan perlindungan berbasis perangkat keras. Pencadangan atau sinkronisasi file mungkin menyertakan data terenkripsi.', + 'keyring.consent.consentButton': 'Gunakan penyimpanan lokal terenkripsi', + 'keyring.consent.retryButton': 'Coba ulang OS Keychain', + 'keyring.consent.declineButton': 'Lewati', + 'keyring.consent.retrying': 'Mencoba ulang…', + 'keyring.consent.error': 'Gagal menyimpan preferensi. Silakan coba lagi.', + 'keyring.consent.retryFailed': 'Keychain masih tidak tersedia.', + 'keyring.settings.title': 'Keamanan', + 'keyring.settings.storageMode': 'Mode penyimpanan rahasia', + 'keyring.settings.mode.osKeychain': 'OS Keychain', + 'keyring.settings.mode.encryptedFile': 'Lokal terenkripsi', + 'keyring.settings.mode.consentPending': 'Belum dikonfigurasi', + 'keyring.settings.mode.declined': 'Ditolak', + 'keyring.settings.availability': 'Ketersediaan keychain', + 'keyring.settings.available': 'OS keychain tersedia', + 'keyring.settings.unavailable': 'OS keychain tidak tersedia', + 'keyring.settings.backend': 'Backend', + 'keyring.settings.retryButton': 'Coba ulang deteksi keychain', + 'keyring.settings.retryFailed': 'Percobaan ulang gagal. Keychain masih tidak tersedia.', + 'keyring.settings.consentTitle': 'Persetujuan penyimpanan', + 'keyring.settings.consentDescription': + 'Pilih bagaimana rahasia disimpan saat keychain OS tidak tersedia.', + 'keyring.settings.grantConsent': 'Izinkan penyimpanan lokal terenkripsi', + 'keyring.settings.revokeConsent': 'Tolak penyimpanan lokal', + 'pages.settings.account.security': 'Keamanan', + 'pages.settings.account.securityDesc': 'Mode penyimpanan rahasia dan status keychain', }; export default messages; diff --git a/app/src/lib/i18n/it.ts b/app/src/lib/i18n/it.ts index 0b2b68ab2b..9539391a85 100644 --- a/app/src/lib/i18n/it.ts +++ b/app/src/lib/i18n/it.ts @@ -4456,6 +4456,41 @@ const messages: TranslationMap = { 'graphCohesion.summaryCaption': 'Raggruppamento medio {avg} · transitività {transitivity}', 'graphCohesion.title': 'Coesione del grafo', 'memory.tab.cohesion': 'Cohesion', + + 'keyring.consent.title': 'Archivio sicuro non disponibile', + 'keyring.consent.description': + "Il portachiavi del sistema operativo non è accessibile. OpenHuman necessita del tuo permesso per archiviare i segreti utilizzando l'archiviazione locale crittografata.", + 'keyring.consent.reasonPrefix': 'Motivo:', + 'keyring.consent.showDetails': 'Cosa significa?', + 'keyring.consent.hideDetails': 'Nascondi dettagli', + 'keyring.consent.tradeoffTitle': 'Compromesso di sicurezza', + 'keyring.consent.tradeoffBody': + "Con l'archiviazione locale crittografata, i tuoi segreti vengono crittografati su disco utilizzando una chiave master archiviata insieme ai dati. Questo è meno sicuro del portachiavi del SO, che utilizza protezione hardware. I backup o la sincronizzazione dei file possono includere i dati crittografati.", + 'keyring.consent.consentButton': 'Usa archiviazione locale crittografata', + 'keyring.consent.retryButton': 'Riprova OS Keychain', + 'keyring.consent.declineButton': 'Salta', + 'keyring.consent.retrying': 'Nuovo tentativo…', + 'keyring.consent.error': 'Impossibile salvare la preferenza. Riprova.', + 'keyring.consent.retryFailed': 'Il portachiavi è ancora non disponibile.', + 'keyring.settings.title': 'Sicurezza', + 'keyring.settings.storageMode': 'Modalità archiviazione segreti', + 'keyring.settings.mode.osKeychain': 'OS Keychain', + 'keyring.settings.mode.encryptedFile': 'Locale crittografato', + 'keyring.settings.mode.consentPending': 'Non configurato', + 'keyring.settings.mode.declined': 'Rifiutato', + 'keyring.settings.availability': 'Disponibilità portachiavi', + 'keyring.settings.available': 'Il portachiavi del SO è disponibile', + 'keyring.settings.unavailable': 'Il portachiavi del SO non è disponibile', + 'keyring.settings.backend': 'Backend', + 'keyring.settings.retryButton': 'Riprova rilevamento portachiavi', + 'keyring.settings.retryFailed': 'Tentativo fallito. Il portachiavi è ancora non disponibile.', + 'keyring.settings.consentTitle': 'Consenso archiviazione', + 'keyring.settings.consentDescription': + 'Scegli come vengono archiviati i segreti quando il portachiavi del SO non è disponibile.', + 'keyring.settings.grantConsent': 'Consenti archiviazione locale crittografata', + 'keyring.settings.revokeConsent': 'Rifiuta archiviazione locale', + 'pages.settings.account.security': 'Sicurezza', + 'pages.settings.account.securityDesc': 'Modalità archiviazione segreti e stato del portachiavi', }; export default messages; diff --git a/app/src/lib/i18n/ko.ts b/app/src/lib/i18n/ko.ts index da303403bf..3e4f89eeaf 100644 --- a/app/src/lib/i18n/ko.ts +++ b/app/src/lib/i18n/ko.ts @@ -4349,6 +4349,41 @@ const messages: TranslationMap = { 'graphCohesion.summaryCaption': '평균 군집계수 {avg} · 전이성 {transitivity}', 'graphCohesion.title': '그래프 응집도', 'memory.tab.cohesion': 'Cohesion', + + 'keyring.consent.title': '보안 저장소를 사용할 수 없음', + 'keyring.consent.description': + '운영 체제 키체인에 접근할 수 없습니다. OpenHuman이 로컬 암호화 저장소를 사용하여 비밀을 저장하려면 귀하의 허가가 필요합니다.', + 'keyring.consent.reasonPrefix': '이유:', + 'keyring.consent.showDetails': '이것은 무엇을 의미하나요?', + 'keyring.consent.hideDetails': '세부 정보 숨기기', + 'keyring.consent.tradeoffTitle': '보안 절충', + 'keyring.consent.tradeoffBody': + '로컬 암호화 저장소에서는 데이터와 함께 저장된 마스터 키를 사용하여 비밀이 디스크에 암호화됩니다. 이는 하드웨어 기반 보호를 사용하는 OS 키체인보다 덜 안전합니다. 백업이나 파일 동기화에 암호화된 데이터가 포함될 수 있습니다.', + 'keyring.consent.consentButton': '로컬 암호화 저장소 사용', + 'keyring.consent.retryButton': 'OS Keychain 재시도', + 'keyring.consent.declineButton': '건너뛰기', + 'keyring.consent.retrying': '재시도 중…', + 'keyring.consent.error': '설정을 저장하지 못했습니다. 다시 시도해 주세요.', + 'keyring.consent.retryFailed': '키체인이 여전히 사용할 수 없습니다.', + 'keyring.settings.title': '보안', + 'keyring.settings.storageMode': '비밀 저장 모드', + 'keyring.settings.mode.osKeychain': 'OS Keychain', + 'keyring.settings.mode.encryptedFile': '로컬 암호화', + 'keyring.settings.mode.consentPending': '구성되지 않음', + 'keyring.settings.mode.declined': '거부됨', + 'keyring.settings.availability': '키체인 가용성', + 'keyring.settings.available': 'OS 키체인 사용 가능', + 'keyring.settings.unavailable': 'OS 키체인 사용 불가', + 'keyring.settings.backend': 'Backend', + 'keyring.settings.retryButton': '키체인 감지 재시도', + 'keyring.settings.retryFailed': '재시도 실패. 키체인이 여전히 사용할 수 없습니다.', + 'keyring.settings.consentTitle': '저장소 동의', + 'keyring.settings.consentDescription': + 'OS 키체인을 사용할 수 없을 때 비밀을 저장하는 방법을 선택하세요.', + 'keyring.settings.grantConsent': '로컬 암호화 저장소 허용', + 'keyring.settings.revokeConsent': '로컬 저장소 거부', + 'pages.settings.account.security': '보안', + 'pages.settings.account.securityDesc': '비밀 저장 모드 및 키체인 상태', }; export default messages; diff --git a/app/src/lib/i18n/pl.ts b/app/src/lib/i18n/pl.ts index 621d08fdc7..3f3ce9d228 100644 --- a/app/src/lib/i18n/pl.ts +++ b/app/src/lib/i18n/pl.ts @@ -4455,6 +4455,42 @@ const messages: TranslationMap = { 'graphCohesion.summaryCaption': 'Średnia klasteryzacja {avg} · tranzytywność {transitivity}', 'graphCohesion.title': 'Spójność grafu', 'memory.tab.cohesion': 'Cohesion', + + 'keyring.consent.title': 'Bezpieczne przechowywanie niedostępne', + 'keyring.consent.description': + 'Pęk kluczy systemu operacyjnego jest niedostępny. OpenHuman potrzebuje Twojej zgody na przechowywanie sekretów w lokalnym zaszyfrowanym magazynie.', + 'keyring.consent.reasonPrefix': 'Powód:', + 'keyring.consent.showDetails': 'Co to oznacza?', + 'keyring.consent.hideDetails': 'Ukryj szczegóły', + 'keyring.consent.tradeoffTitle': 'Kompromis bezpieczeństwa', + 'keyring.consent.tradeoffBody': + 'W przypadku lokalnego zaszyfrowanego magazynu sekrety są szyfrowane na dysku przy użyciu klucza głównego przechowywanego obok danych. Jest to mniej bezpieczne niż pęk kluczy systemu, który wykorzystuje ochronę sprzętową. Kopie zapasowe lub synchronizacja plików mogą zawierać zaszyfrowane dane.', + 'keyring.consent.consentButton': 'Użyj lokalnego zaszyfrowanego magazynu', + 'keyring.consent.retryButton': 'Ponów próbę OS Keychain', + 'keyring.consent.declineButton': 'Pomiń', + 'keyring.consent.retrying': 'Ponawiam próbę…', + 'keyring.consent.error': 'Nie udało się zapisać preferencji. Spróbuj ponownie.', + 'keyring.consent.retryFailed': 'Pęk kluczy jest nadal niedostępny.', + 'keyring.settings.title': 'Bezpieczeństwo', + 'keyring.settings.storageMode': 'Tryb przechowywania sekretów', + 'keyring.settings.mode.osKeychain': 'OS Keychain', + 'keyring.settings.mode.encryptedFile': 'Lokalnie zaszyfrowany', + 'keyring.settings.mode.consentPending': 'Nie skonfigurowano', + 'keyring.settings.mode.declined': 'Odrzucono', + 'keyring.settings.availability': 'Dostępność pęku kluczy', + 'keyring.settings.available': 'Pęk kluczy systemu jest dostępny', + 'keyring.settings.unavailable': 'Pęk kluczy systemu jest niedostępny', + 'keyring.settings.backend': 'Backend', + 'keyring.settings.retryButton': 'Ponów wykrywanie pęku kluczy', + 'keyring.settings.retryFailed': + 'Ponowna próba nie powiodła się. Pęk kluczy jest nadal niedostępny.', + 'keyring.settings.consentTitle': 'Zgoda na przechowywanie', + 'keyring.settings.consentDescription': + 'Wybierz sposób przechowywania sekretów, gdy pęk kluczy systemu jest niedostępny.', + 'keyring.settings.grantConsent': 'Zezwól na lokalne zaszyfrowane przechowywanie', + 'keyring.settings.revokeConsent': 'Odmów lokalnego przechowywania', + 'pages.settings.account.security': 'Bezpieczeństwo', + 'pages.settings.account.securityDesc': 'Tryb przechowywania sekretów i stan pęku kluczy', }; export default messages; diff --git a/app/src/lib/i18n/pt.ts b/app/src/lib/i18n/pt.ts index 1c32f79ab5..6521474fe7 100644 --- a/app/src/lib/i18n/pt.ts +++ b/app/src/lib/i18n/pt.ts @@ -4454,6 +4454,41 @@ const messages: TranslationMap = { 'graphCohesion.summaryCaption': 'Agrupamento médio {avg} · transitividade {transitivity}', 'graphCohesion.title': 'Coesão do grafo', 'memory.tab.cohesion': 'Cohesion', + + 'keyring.consent.title': 'Armazenamento seguro indisponível', + 'keyring.consent.description': + 'O chaveiro do sistema operacional não está acessível. O OpenHuman precisa da sua permissão para armazenar segredos usando armazenamento local criptografado.', + 'keyring.consent.reasonPrefix': 'Motivo:', + 'keyring.consent.showDetails': 'O que isso significa?', + 'keyring.consent.hideDetails': 'Ocultar detalhes', + 'keyring.consent.tradeoffTitle': 'Compromisso de segurança', + 'keyring.consent.tradeoffBody': + 'Com o armazenamento local criptografado, seus segredos são criptografados no disco usando uma chave mestra armazenada junto aos dados. Isso é menos seguro que o chaveiro do SO, que usa proteção por hardware. Backups ou sincronização de arquivos podem incluir os dados criptografados.', + 'keyring.consent.consentButton': 'Usar armazenamento local criptografado', + 'keyring.consent.retryButton': 'Tentar novamente OS Keychain', + 'keyring.consent.declineButton': 'Pular', + 'keyring.consent.retrying': 'Tentando novamente…', + 'keyring.consent.error': 'Falha ao salvar preferência. Tente novamente.', + 'keyring.consent.retryFailed': 'O chaveiro ainda está indisponível.', + 'keyring.settings.title': 'Segurança', + 'keyring.settings.storageMode': 'Modo de armazenamento de segredos', + 'keyring.settings.mode.osKeychain': 'OS Keychain', + 'keyring.settings.mode.encryptedFile': 'Local criptografado', + 'keyring.settings.mode.consentPending': 'Não configurado', + 'keyring.settings.mode.declined': 'Recusado', + 'keyring.settings.availability': 'Disponibilidade do chaveiro', + 'keyring.settings.available': 'O chaveiro do SO está disponível', + 'keyring.settings.unavailable': 'O chaveiro do SO está indisponível', + 'keyring.settings.backend': 'Backend', + 'keyring.settings.retryButton': 'Tentar novamente detecção do chaveiro', + 'keyring.settings.retryFailed': 'Tentativa falhou. O chaveiro ainda está indisponível.', + 'keyring.settings.consentTitle': 'Consentimento de armazenamento', + 'keyring.settings.consentDescription': + 'Escolha como os segredos são armazenados quando o chaveiro do SO não está disponível.', + 'keyring.settings.grantConsent': 'Permitir armazenamento local criptografado', + 'keyring.settings.revokeConsent': 'Recusar armazenamento local', + 'pages.settings.account.security': 'Segurança', + 'pages.settings.account.securityDesc': 'Modo de armazenamento de segredos e status do chaveiro', }; export default messages; diff --git a/app/src/lib/i18n/ru.ts b/app/src/lib/i18n/ru.ts index c006928993..c22b042e81 100644 --- a/app/src/lib/i18n/ru.ts +++ b/app/src/lib/i18n/ru.ts @@ -4423,6 +4423,42 @@ const messages: TranslationMap = { 'graphCohesion.summaryCaption': 'Средняя кластеризация {avg} · транзитивность {transitivity}', 'graphCohesion.title': 'Связность графа', 'memory.tab.cohesion': 'Cohesion', + + 'keyring.consent.title': 'Безопасное хранилище недоступно', + 'keyring.consent.description': + 'Связка ключей вашей операционной системы недоступна. OpenHuman необходимо ваше разрешение на хранение секретов в локальном зашифрованном хранилище.', + 'keyring.consent.reasonPrefix': 'Причина:', + 'keyring.consent.showDetails': 'Что это значит?', + 'keyring.consent.hideDetails': 'Скрыть подробности', + 'keyring.consent.tradeoffTitle': 'Компромисс безопасности', + 'keyring.consent.tradeoffBody': + 'При локальном зашифрованном хранении ваши секреты шифруются на диске с помощью мастер-ключа, который хранится рядом с данными. Это менее безопасно, чем связка ключей ОС, которая использует аппаратную защиту. Резервные копии или синхронизация файлов могут включать зашифрованные данные.', + 'keyring.consent.consentButton': 'Использовать локальное зашифрованное хранилище', + 'keyring.consent.retryButton': 'Повторить OS Keychain', + 'keyring.consent.declineButton': 'Пропустить', + 'keyring.consent.retrying': 'Повторная попытка…', + 'keyring.consent.error': 'Не удалось сохранить настройку. Попробуйте снова.', + 'keyring.consent.retryFailed': 'Связка ключей по-прежнему недоступна.', + 'keyring.settings.title': 'Безопасность', + 'keyring.settings.storageMode': 'Режим хранения секретов', + 'keyring.settings.mode.osKeychain': 'OS Keychain', + 'keyring.settings.mode.encryptedFile': 'Локальное шифрование', + 'keyring.settings.mode.consentPending': 'Не настроено', + 'keyring.settings.mode.declined': 'Отклонено', + 'keyring.settings.availability': 'Доступность связки ключей', + 'keyring.settings.available': 'Связка ключей ОС доступна', + 'keyring.settings.unavailable': 'Связка ключей ОС недоступна', + 'keyring.settings.backend': 'Backend', + 'keyring.settings.retryButton': 'Повторить обнаружение связки ключей', + 'keyring.settings.retryFailed': + 'Повторная попытка не удалась. Связка ключей по-прежнему недоступна.', + 'keyring.settings.consentTitle': 'Согласие на хранение', + 'keyring.settings.consentDescription': + 'Выберите способ хранения секретов, когда связка ключей ОС недоступна.', + 'keyring.settings.grantConsent': 'Разрешить локальное зашифрованное хранилище', + 'keyring.settings.revokeConsent': 'Отклонить локальное хранилище', + 'pages.settings.account.security': 'Безопасность', + 'pages.settings.account.securityDesc': 'Режим хранения секретов и статус связки ключей', }; export default messages; diff --git a/app/src/lib/i18n/zh-CN.ts b/app/src/lib/i18n/zh-CN.ts index 510f28c9e2..f7af7cb181 100644 --- a/app/src/lib/i18n/zh-CN.ts +++ b/app/src/lib/i18n/zh-CN.ts @@ -4172,6 +4172,40 @@ const messages: TranslationMap = { 'graphCohesion.summaryCaption': '平均聚类系数 {avg} · 传递性 {transitivity}', 'graphCohesion.title': '图的凝聚度', 'memory.tab.cohesion': 'Cohesion', + + 'keyring.consent.title': '安全存储不可用', + 'keyring.consent.description': + '您的操作系统密钥链不可访问。OpenHuman 需要您的许可,以便使用本地加密存储来保存密钥。', + 'keyring.consent.reasonPrefix': '原因:', + 'keyring.consent.showDetails': '这是什么意思?', + 'keyring.consent.hideDetails': '隐藏详情', + 'keyring.consent.tradeoffTitle': '安全权衡', + 'keyring.consent.tradeoffBody': + '使用本地加密存储时,您的密钥使用与数据一起存储的主密钥进行磁盘加密。这不如使用硬件保护的操作系统密钥链安全。备份或文件同步可能包含加密数据。', + 'keyring.consent.consentButton': '使用本地加密存储', + 'keyring.consent.retryButton': '重试 OS Keychain', + 'keyring.consent.declineButton': '跳过', + 'keyring.consent.retrying': '正在重试…', + 'keyring.consent.error': '保存偏好失败,请重试。', + 'keyring.consent.retryFailed': '密钥链仍然不可用。', + 'keyring.settings.title': '安全', + 'keyring.settings.storageMode': '密钥存储模式', + 'keyring.settings.mode.osKeychain': 'OS Keychain', + 'keyring.settings.mode.encryptedFile': '本地加密', + 'keyring.settings.mode.consentPending': '未配置', + 'keyring.settings.mode.declined': '已拒绝', + 'keyring.settings.availability': '密钥链可用性', + 'keyring.settings.available': '操作系统密钥链可用', + 'keyring.settings.unavailable': '操作系统密钥链不可用', + 'keyring.settings.backend': 'Backend', + 'keyring.settings.retryButton': '重试密钥链检测', + 'keyring.settings.retryFailed': '重试失败。密钥链仍然不可用。', + 'keyring.settings.consentTitle': '存储同意', + 'keyring.settings.consentDescription': '选择操作系统密钥链不可用时如何存储密钥。', + 'keyring.settings.grantConsent': '允许本地加密存储', + 'keyring.settings.revokeConsent': '拒绝本地存储', + 'pages.settings.account.security': '安全', + 'pages.settings.account.securityDesc': '密钥存储模式和密钥链状态', }; export default messages; diff --git a/app/src/pages/Settings.tsx b/app/src/pages/Settings.tsx index 4c0b5752a3..d1c451e4f2 100644 --- a/app/src/pages/Settings.tsx +++ b/app/src/pages/Settings.tsx @@ -40,6 +40,7 @@ import RecoveryPhrasePanel from '../components/settings/panels/RecoveryPhrasePan import ScreenAwarenessDebugPanel from '../components/settings/panels/ScreenAwarenessDebugPanel'; import ScreenIntelligencePanel from '../components/settings/panels/ScreenIntelligencePanel'; import SearchPanel from '../components/settings/panels/SearchPanel'; +import SecurityPanel from '../components/settings/panels/SecurityPanel'; import SkillsRunnerPanel from '../components/settings/panels/SkillsRunnerPanel'; import TaskSourcesPanel from '../components/settings/panels/TaskSourcesPanel'; import TeamInvitesPanel from '../components/settings/panels/TeamInvitesPanel'; @@ -90,6 +91,16 @@ const PrivacyIcon = ( /> ); +const SecurityIcon = ( + + + +); const MigrationIcon = ( { route: 'privacy', icon: PrivacyIcon, }, + { + id: 'security', + title: t('pages.settings.account.security'), + description: t('pages.settings.account.securityDesc'), + route: 'security', + icon: SecurityIcon, + }, { id: 'migration', title: t('pages.settings.account.migration'), @@ -557,6 +575,7 @@ const Settings = () => { {/* BillingPanel intentionally uses its own wider layout. */} } /> )} /> + )} /> )} /> )} /> {/* Features leaf panels */} diff --git a/app/src/providers/CoreStateProvider.tsx b/app/src/providers/CoreStateProvider.tsx index 20be398877..7bc0d50cb1 100644 --- a/app/src/providers/CoreStateProvider.tsx +++ b/app/src/providers/CoreStateProvider.tsx @@ -223,6 +223,13 @@ function normalizeSnapshot( localState: { encryptionKey: result.localState.encryptionKey ?? null, onboardingTasks: result.localState.onboardingTasks ?? null, + keyringConsent: result.localState.keyringConsent ?? null, + }, + keyringStatus: result.keyringStatus ?? { + available: true, + failureReason: null, + activeMode: 'os_keyring', + backendName: 'os', }, runtime: { screenIntelligence: result.runtime?.screenIntelligence ?? null, diff --git a/app/src/services/__tests__/keyringApi.test.ts b/app/src/services/__tests__/keyringApi.test.ts new file mode 100644 index 0000000000..4e115a3d2f --- /dev/null +++ b/app/src/services/__tests__/keyringApi.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { decideKeyringConsent, fetchKeyringStatus, retryKeyringProbe } from '../keyringApi'; + +vi.mock('../coreRpcClient', () => ({ callCoreRpc: vi.fn() })); + +const { callCoreRpc } = await import('../coreRpcClient'); +const mockRpc = vi.mocked(callCoreRpc); + +describe('keyringApi', () => { + afterEach(() => vi.clearAllMocks()); + + it('fetchKeyringStatus calls correct RPC method', async () => { + const status = { available: true, activeMode: 'os_keyring', backendName: 'os' }; + mockRpc.mockResolvedValueOnce({ result: status }); + + const result = await fetchKeyringStatus(); + expect(result).toEqual(status); + expect(mockRpc).toHaveBeenCalledWith({ method: 'openhuman.keyring_consent_status' }); + }); + + it('decideKeyringConsent sends mode parameter', async () => { + const pref = { storageMode: 'local_encrypted', consentedAtMs: 123 }; + mockRpc.mockResolvedValueOnce({ result: pref }); + + const result = await decideKeyringConsent('local_encrypted'); + expect(result).toEqual(pref); + expect(mockRpc).toHaveBeenCalledWith({ + method: 'openhuman.keyring_consent_decide', + params: { mode: 'local_encrypted' }, + }); + }); + + it('retryKeyringProbe calls correct RPC method', async () => { + const status = { available: false, activeMode: 'consent_pending', backendName: 'os' }; + mockRpc.mockResolvedValueOnce({ result: status }); + + const result = await retryKeyringProbe(); + expect(result).toEqual(status); + expect(mockRpc).toHaveBeenCalledWith({ method: 'openhuman.keyring_consent_retry_probe' }); + }); +}); diff --git a/app/src/services/coreStateApi.ts b/app/src/services/coreStateApi.ts index b65dd823d1..c124772cb0 100644 --- a/app/src/services/coreStateApi.ts +++ b/app/src/services/coreStateApi.ts @@ -15,9 +15,22 @@ export interface OnboardingTasks { updatedAtMs?: number; } +export interface KeyringConsentPreference { + storageMode: string; + consentedAtMs?: number; +} + +export interface KeyringStatus { + available: boolean; + failureReason?: string | null; + activeMode: string; + backendName: string; +} + export interface UpdateCoreLocalStateParams { encryptionKey?: string | null; onboardingTasks?: OnboardingTasks | null; + keyringConsent?: KeyringConsentPreference | null; } interface AppStateSnapshotResult { @@ -39,7 +52,12 @@ interface AppStateSnapshotResult { * never observe `undefined` here. */ meetAutoOrchestratorHandoff?: boolean; - localState: { encryptionKey?: string | null; onboardingTasks?: OnboardingTasks | null }; + localState: { + encryptionKey?: string | null; + onboardingTasks?: OnboardingTasks | null; + keyringConsent?: KeyringConsentPreference | null; + }; + keyringStatus?: KeyringStatus; runtime: { screenIntelligence: AccessibilityStatus; localAi: LocalAiStatus; diff --git a/app/src/services/keyringApi.ts b/app/src/services/keyringApi.ts new file mode 100644 index 0000000000..8bd5c101fe --- /dev/null +++ b/app/src/services/keyringApi.ts @@ -0,0 +1,26 @@ +import { callCoreRpc } from './coreRpcClient'; +import type { KeyringConsentPreference, KeyringStatus } from './coreStateApi'; + +export const fetchKeyringStatus = async (): Promise => { + const response = await callCoreRpc<{ result: KeyringStatus }>({ + method: 'openhuman.keyring_consent_status', + }); + return response.result; +}; + +export const decideKeyringConsent = async ( + mode: 'local_encrypted' | 'declined' +): Promise => { + const response = await callCoreRpc<{ result: KeyringConsentPreference }>({ + method: 'openhuman.keyring_consent_decide', + params: { mode }, + }); + return response.result; +}; + +export const retryKeyringProbe = async (): Promise => { + const response = await callCoreRpc<{ result: KeyringStatus }>({ + method: 'openhuman.keyring_consent_retry_probe', + }); + return response.result; +}; diff --git a/gitbooks/features/os-keyring-and-secret-storage.md b/gitbooks/features/os-keyring-and-secret-storage.md index ea236cd9ad..1ce6ab3b7b 100644 --- a/gitbooks/features/os-keyring-and-secret-storage.md +++ b/gitbooks/features/os-keyring-and-secret-storage.md @@ -101,6 +101,35 @@ Current desktop builds migrate that material into the OS keyring and keep the en *** +## Consent flow when the keyring is unavailable + +When the OS keyring is unreachable — for example on Linux without a Secret Service daemon, or macOS when keychain access is denied — OpenHuman **stops and asks** before falling back to local encrypted storage. + +### How it works + +1. **Detection** — On startup the core probes the OS keychain. If the probe fails, it classifies the reason (no daemon, locked, denied) and reports a structured `KeyringStatus` via the `openhuman.keyring_consent_status` RPC and the app snapshot. + +2. **Consent prompt** — The first time a secret must be read or written and no consent has been recorded, a modal overlay explains what happened, what "store locally" means, and what the risks are. The user can: + - **Use Local Encrypted Storage** — consent to ChaCha20-Poly1305 encrypted files (master key also on disk). + - **Retry OS Keychain** — re-probe (useful after granting OS permission). + - **Skip** — decline local storage; features that need secrets will be unavailable. + +3. **Persisted preference** — The choice is recorded in `app-state.json` (`keyringConsent` field) and cached in-process. The app re-probes on each launch and re-prompts if the keyring becomes available after a local-only session. + +4. **Settings visibility** — **Settings → Security** shows the active storage mode, keychain availability, failure reason, and buttons to retry or change consent. + +### Unified fallback policy + +Auth profiles, config secrets, wallet mnemonic, and the `secrets.enc` backend all call `keyring_consent::policy::check_secret_access()` instead of raw `is_available()`. This ensures no code path silently switches storage modes. + +| Policy decision | Meaning | +| --- | --- | +| `Proceed` | OS keyring available, or user consented to local encrypted | +| `ConsentRequired` | Keyring unavailable, no consent yet — block and prompt | +| `Declined` | User refused local storage — skip the secret operation | + +*** + ## Platform note This page describes **desktop** OpenHuman: the Tauri app on macOS, Windows, and Linux. diff --git a/src/core/all.rs b/src/core/all.rs index eab00b37c1..ce36f9d856 100644 --- a/src/core/all.rs +++ b/src/core/all.rs @@ -139,6 +139,9 @@ fn build_registered_controllers() -> Vec { controllers.extend(crate::openhuman::doctor::all_doctor_registered_controllers()); // Secret storage and encryption controllers.extend(crate::openhuman::encryption::all_encryption_registered_controllers()); + // Keyring consent — user approval before local secret storage fallback + controllers + .extend(crate::openhuman::keyring_consent::all_keyring_consent_registered_controllers()); // Security policy metadata controllers.extend(crate::openhuman::security::all_security_registered_controllers()); // Interactive approval workflow (#1339 — gate external-effect tool calls) @@ -312,6 +315,7 @@ fn build_declared_controller_schemas() -> Vec { schemas.extend(crate::openhuman::health::all_health_controller_schemas()); schemas.extend(crate::openhuman::doctor::all_doctor_controller_schemas()); schemas.extend(crate::openhuman::encryption::all_encryption_controller_schemas()); + schemas.extend(crate::openhuman::keyring_consent::all_keyring_consent_controller_schemas()); schemas.extend(crate::openhuman::security::all_security_controller_schemas()); schemas.extend(crate::openhuman::approval::all_approval_controller_schemas()); schemas.extend(crate::openhuman::artifacts::all_artifacts_controller_schemas()); diff --git a/src/core/event_bus/events.rs b/src/core/event_bus/events.rs index 13ed2d5b20..58e9a9eea1 100644 --- a/src/core/event_bus/events.rs +++ b/src/core/event_bus/events.rs @@ -652,6 +652,17 @@ pub enum DomainEvent { /// A component restart was observed. HealthRestarted { component: String }, + // ── Keyring ───────────────────────────────────────────────────────── + /// The OS keyring is unavailable and no user consent for local fallback + /// has been recorded. Published once (deduplicated) when a secret + /// operation hits the consent gate. The frontend surfaces a consent + /// dialog in response. + KeyringConsentRequired, + /// A secret field failed to decrypt (rotated master key, corrupted + /// ciphertext, keychain reset). Published so the frontend can surface + /// a recovery prompt instead of silently clearing the field. + KeyringDecryptFailed { field_name: String, reason: String }, + // ── Auth ──────────────────────────────────────────────────────────── /// The local app session is no longer valid — typically detected when /// the backend returns 401 to an LLM inference call or a JSON-RPC @@ -789,6 +800,8 @@ impl DomainEvent { | Self::HealthChanged { .. } | Self::HealthRestarted { .. } => "system", + Self::KeyringConsentRequired | Self::KeyringDecryptFailed { .. } => "keyring", + Self::SessionExpired { .. } => "auth", Self::TaskSourceFetched { .. } @@ -879,6 +892,8 @@ impl DomainEvent { Self::AutonomyConfigChanged => "AutonomyConfigChanged", Self::HealthChanged { .. } => "HealthChanged", Self::HealthRestarted { .. } => "HealthRestarted", + Self::KeyringConsentRequired => "KeyringConsentRequired", + Self::KeyringDecryptFailed { .. } => "KeyringDecryptFailed", Self::SessionExpired { .. } => "SessionExpired", Self::ApprovalRequested { .. } => "ApprovalRequested", Self::ApprovalDecided { .. } => "ApprovalDecided", diff --git a/src/openhuman/app_state/ops.rs b/src/openhuman/app_state/ops.rs index 26ccc4aa88..702875d64f 100644 --- a/src/openhuman/app_state/ops.rs +++ b/src/openhuman/app_state/ops.rs @@ -79,6 +79,8 @@ pub struct StoredAppState { pub encryption_key: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub onboarding_tasks: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub keyring_consent: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -102,6 +104,7 @@ pub struct AppStateSnapshot { /// issue #1299. pub meet_auto_orchestrator_handoff: bool, pub local_state: StoredAppState, + pub keyring_status: crate::openhuman::keyring_consent::KeyringStatus, pub runtime: RuntimeSnapshot, } @@ -121,6 +124,8 @@ pub struct StoredAppStatePatch { pub encryption_key: Option>, #[serde(default)] pub onboarding_tasks: Option>, + #[serde(default)] + pub keyring_consent: Option>, } fn app_state_path(config: &Config) -> Result { @@ -257,7 +262,7 @@ fn save_stored_app_state_unlocked(config: &Config, state: &StoredAppState) -> Re Ok(()) } -fn save_stored_app_state(config: &Config, state: &StoredAppState) -> Result<(), String> { +pub fn save_app_state(config: &Config, state: &StoredAppState) -> Result<(), String> { let _guard = APP_STATE_FILE_LOCK.lock(); save_stored_app_state_unlocked(config, state) } @@ -649,6 +654,7 @@ pub async fn snapshot() -> Result, String> { let t_local_state = Instant::now(); let local_state = load_stored_app_state(&config)?; + crate::openhuman::keyring_consent::policy::initialize(local_state.keyring_consent.clone()); let local_state_ms = t_local_state.elapsed().as_millis(); let total_ms = t_total.elapsed().as_millis(); @@ -671,6 +677,8 @@ pub async fn snapshot() -> Result, String> { runtime.service.state ); + let keyring_status = crate::openhuman::keyring_consent::policy::current_status(); + Ok(RpcOutcome::new( AppStateSnapshot { auth, @@ -681,6 +689,7 @@ pub async fn snapshot() -> Result, String> { analytics_enabled: config.observability.analytics_enabled, meet_auto_orchestrator_handoff: config.meet.auto_orchestrator_handoff, local_state, + keyring_status, runtime, }, vec!["core app state snapshot fetched".to_string()], @@ -773,12 +782,17 @@ pub async fn update_local_state( current.onboarding_tasks = onboarding_tasks; } + if let Some(keyring_consent) = patch.keyring_consent { + current.keyring_consent = keyring_consent; + } + save_stored_app_state_unlocked(&config, ¤t)?; debug!( - "{LOG_PREFIX} local state updated encryption_key={} onboarding_tasks={}", + "{LOG_PREFIX} local state updated encryption_key={} onboarding_tasks={} keyring_consent={}", current.encryption_key.is_some(), - current.onboarding_tasks.is_some() + current.onboarding_tasks.is_some(), + current.keyring_consent.is_some(), ); Ok(RpcOutcome::new( diff --git a/src/openhuman/app_state/ops_tests.rs b/src/openhuman/app_state/ops_tests.rs index eab67d560b..c9bd019002 100644 --- a/src/openhuman/app_state/ops_tests.rs +++ b/src/openhuman/app_state/ops_tests.rs @@ -126,9 +126,10 @@ fn save_and_reload_stored_app_state_round_trips() { connected_sources: vec!["telegram".into()], updated_at_ms: Some(42), }), + keyring_consent: None, }; - save_stored_app_state(&cfg, &state).expect("save app state"); + save_app_state(&cfg, &state).expect("save app state"); let reloaded = load_stored_app_state(&cfg).expect("reload app state"); assert_eq!(reloaded.encryption_key, Some("enc-key".into())); let tasks = reloaded.onboarding_tasks.expect("onboarding tasks"); diff --git a/src/openhuman/app_state/schemas.rs b/src/openhuman/app_state/schemas.rs index bae42fa99e..eaa6a2b9cd 100644 --- a/src/openhuman/app_state/schemas.rs +++ b/src/openhuman/app_state/schemas.rs @@ -14,6 +14,8 @@ struct UpdateLocalStateParams { encryption_key: Option>, #[serde(default, deserialize_with = "deserialize_nullable_patch")] onboarding_tasks: Option>, + #[serde(default, deserialize_with = "deserialize_nullable_patch")] + keyring_consent: Option>, } pub fn all_app_state_controller_schemas() -> Vec { @@ -63,6 +65,10 @@ pub fn app_state_schemas(function: &str) -> ControllerSchema { "onboardingTasks", "Set or clear locally stored onboarding task progress.", ), + optional_json( + "keyringConsent", + "Set or clear the user's keyring consent preference.", + ), ], outputs: vec![FieldSchema { name: "result", @@ -101,6 +107,7 @@ fn handle_update_local_state(params: Map) -> ControllerFuture { crate::openhuman::app_state::update_local_state(StoredAppStatePatch { encryption_key: payload.encryption_key, onboarding_tasks: payload.onboarding_tasks, + keyring_consent: payload.keyring_consent, }) .await? .into_cli_compatible_json() diff --git a/src/openhuman/config/schema/load.rs b/src/openhuman/config/schema/load.rs index 57396a6bd4..75615388c3 100644 --- a/src/openhuman/config/schema/load.rs +++ b/src/openhuman/config/schema/load.rs @@ -409,6 +409,10 @@ fn decrypt_optional_secret( log::warn!( "[config] Failed to decrypt {field_name} — field cleared (key inaccessible): {e}" ); + crate::openhuman::keyring_consent::policy::notify_decrypt_failure( + field_name, + &e.to_string(), + ); *value = None; } } diff --git a/src/openhuman/credentials/profiles.rs b/src/openhuman/credentials/profiles.rs index 6a5751d482..8fd5872b53 100644 --- a/src/openhuman/credentials/profiles.rs +++ b/src/openhuman/credentials/profiles.rs @@ -204,18 +204,16 @@ pub struct AuthProfilesStore { impl AuthProfilesStore { pub fn new(state_dir: &Path, encrypt_secrets: bool) -> Self { let user_id = user_id_from_state_dir(state_dir); - let use_keychain = crate::openhuman::keyring::is_available(); + let policy = crate::openhuman::keyring_consent::policy::check_secret_access(); + let use_keychain = policy == crate::openhuman::keyring_consent::PolicyDecision::Proceed + && crate::openhuman::keyring::is_available(); log::debug!( - "[auth] AuthProfilesStore::new state_dir={} user_id={user_id} use_keychain={use_keychain}", + "[auth] AuthProfilesStore::new state_dir={} user_id={user_id} use_keychain={use_keychain} policy={policy:?}", state_dir.display() ); if !use_keychain { - // Surface the consequence of a failed keychain probe at info: auth - // secrets will be read/written via the encrypted JSON fallback, not - // the OS keychain. This is the state change that drove the - // "logged out / no backend session token" confusion. log::info!( - "[auth] keychain unavailable (is_available=false) — using encrypted JSON for auth profiles user_id={user_id}" + "[auth] keychain unavailable or consent pending — using encrypted JSON for auth profiles user_id={user_id} policy={policy:?}" ); } Self { diff --git a/src/openhuman/keyring/mod.rs b/src/openhuman/keyring/mod.rs index b045be0143..2c26ad9cc2 100644 --- a/src/openhuman/keyring/mod.rs +++ b/src/openhuman/keyring/mod.rs @@ -43,7 +43,8 @@ pub use encrypted_file_backend::init_master_key; pub use encrypted_store::SecretStore; pub use error::KeyringError; pub use ops::{ - delete, get, get_or_create_random, is_available, migrate_from_file, set, MigrationOutcome, + backend_name, delete, get, get_or_create_random, is_available, migrate_from_file, + reset_availability_cache, set, MigrationOutcome, }; pub use store::init_workspace; diff --git a/src/openhuman/keyring/ops.rs b/src/openhuman/keyring/ops.rs index 2dd0b1dbb8..2310bf1956 100644 --- a/src/openhuman/keyring/ops.rs +++ b/src/openhuman/keyring/ops.rs @@ -3,20 +3,19 @@ //! All public functions delegate to the active backend selected by [`crate::openhuman::keyring::store`]. use std::path::Path; -use std::sync::OnceLock; +use std::sync::atomic::{AtomicBool, Ordering}; use chacha20poly1305::aead::{rand_core::RngCore, OsRng}; +use parking_lot::RwLock; use crate::openhuman::keyring::error::KeyringError; use crate::openhuman::keyring::store::backend; -// Cached result of the one-time keychain probe. Running the probe on every -// `is_available()` call means 4 OS-keychain round-trips (delete / set / get / -// delete) per call, which triggers repeated macOS access-permission dialogs -// and starves callers that poll frequently (e.g. snapshot, wallet guards). -// The backend selection is already frozen in a OnceLock, so the probe result -// is stable for the lifetime of the process — caching it here is safe. -static AVAILABILITY_CACHE: OnceLock = OnceLock::new(); +// Cached result of the keychain probe. Uses RwLock> instead of +// OnceLock so the cache can be reset for retry-probe flows (the user retries +// keychain access from Settings after granting OS permission). +static AVAILABILITY_CACHE: RwLock> = RwLock::new(None); +static AVAILABILITY_PROBED: AtomicBool = AtomicBool::new(false); // ── Outcome type ───────────────────────────────────────────────────────────── @@ -97,7 +96,35 @@ pub fn delete(user_id: &str, key: &str) -> Result<(), KeyringError> { /// the macOS access-permission dialogs they trigger) when polled by /// wallet guards or snapshot loops. pub fn is_available() -> bool { - *AVAILABILITY_CACHE.get_or_init(probe_availability) + { + let cached = AVAILABILITY_CACHE.read(); + if let Some(val) = *cached { + return val; + } + } + if !AVAILABILITY_PROBED.swap(true, Ordering::SeqCst) { + let result = probe_availability(); + *AVAILABILITY_CACHE.write() = Some(result); + result + } else { + let cached = AVAILABILITY_CACHE.read(); + cached.unwrap_or(false) + } +} + +/// Reset the cached probe result so the next [`is_available`] call re-runs +/// the OS keychain probe. Used by the retry-probe flow when the user grants +/// keychain access from Settings. +pub fn reset_availability_cache() { + log::info!("[keyring] reset_availability_cache: clearing cached probe result"); + AVAILABILITY_PROBED.store(false, Ordering::SeqCst); + *AVAILABILITY_CACHE.write() = None; +} + +/// Returns the name of the active keyring backend (e.g. `"os"`, `"file"`, +/// `"encrypted_file"`). +pub fn backend_name() -> String { + backend().name().to_string() } fn probe_availability() -> bool { diff --git a/src/openhuman/keyring_consent/mod.rs b/src/openhuman/keyring_consent/mod.rs new file mode 100644 index 0000000000..70647218c2 --- /dev/null +++ b/src/openhuman/keyring_consent/mod.rs @@ -0,0 +1,12 @@ +//! Keyring consent domain — explicit user consent before falling back to local +//! encrypted storage when the OS keyring is unavailable. + +pub mod ops; +pub mod policy; +mod schemas; +pub mod types; + +pub use schemas::{ + all_keyring_consent_controller_schemas, all_keyring_consent_registered_controllers, +}; +pub use types::{ConsentPreference, KeyringStatus, PolicyDecision, StorageMode}; diff --git a/src/openhuman/keyring_consent/ops.rs b/src/openhuman/keyring_consent/ops.rs new file mode 100644 index 0000000000..8c2b2a3349 --- /dev/null +++ b/src/openhuman/keyring_consent/ops.rs @@ -0,0 +1,82 @@ +//! RPC handler implementations for keyring consent. + +use crate::rpc::RpcOutcome; + +use super::policy; +use super::types::{ConsentPreference, KeyringStatus}; + +const LOG_PREFIX: &str = "[keyring_consent]"; + +pub async fn keyring_status() -> Result, String> { + let status = policy::current_status(); + log::debug!( + "{LOG_PREFIX} keyring_status available={} mode={} backend={}", + status.available, + status.active_mode, + status.backend_name, + ); + Ok(RpcOutcome::single_log(status, "keyring status fetched")) +} + +pub async fn keyring_consent_decide(mode: String) -> Result, String> { + if mode != "local_encrypted" && mode != "declined" { + return Err(format!( + "invalid mode '{mode}': expected 'local_encrypted' or 'declined'" + )); + } + log::info!("{LOG_PREFIX} keyring_consent_decide mode={mode}"); + + let pref = policy::record_consent(&mode); + + persist_consent(&pref).await?; + + Ok(RpcOutcome::single_log( + pref, + format!("keyring consent recorded: {mode}"), + )) +} + +pub async fn keyring_retry_probe() -> Result, String> { + log::info!("{LOG_PREFIX} keyring_retry_probe"); + let status = policy::retry_probe(); + Ok(RpcOutcome::single_log( + status, + "keyring probe retried".to_string(), + )) +} + +async fn persist_consent(pref: &ConsentPreference) -> Result<(), String> { + let patch = crate::openhuman::app_state::StoredAppStatePatch { + keyring_consent: Some(Some(pref.clone())), + ..Default::default() + }; + crate::openhuman::app_state::update_local_state(patch).await?; + log::debug!("{LOG_PREFIX} consent persisted to app state"); + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn keyring_status_returns_ok() { + let result = keyring_status().await; + assert!(result.is_ok()); + let outcome = result.unwrap(); + assert!(!outcome.value.backend_name.is_empty()); + } + + #[tokio::test] + async fn keyring_consent_decide_rejects_invalid_mode() { + let result = keyring_consent_decide("invalid".to_string()).await; + assert!(result.is_err()); + assert!(result.unwrap_err().contains("invalid mode")); + } + + #[tokio::test] + async fn keyring_retry_probe_returns_ok() { + let result = keyring_retry_probe().await; + assert!(result.is_ok()); + } +} diff --git a/src/openhuman/keyring_consent/policy.rs b/src/openhuman/keyring_consent/policy.rs new file mode 100644 index 0000000000..4e998735a2 --- /dev/null +++ b/src/openhuman/keyring_consent/policy.rs @@ -0,0 +1,201 @@ +//! Unified keyring fallback policy gate. +//! +//! All code paths that read or write secrets should call [`check_secret_access`] +//! instead of raw `keyring::is_available()`. This centralises the consent check +//! so the app never silently falls back to local encrypted storage without the +//! user's explicit agreement. + +use std::sync::atomic::{AtomicBool, Ordering}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use log::{debug, info, warn}; +use parking_lot::RwLock; + +use super::types::{ + ConsentPreference, KeyringFailureReason, KeyringStatus, PolicyDecision, StorageMode, +}; + +const LOG_PREFIX: &str = "[keyring_consent]"; + +static CONSENT_EVENT_PUBLISHED: AtomicBool = AtomicBool::new(false); + +/// Process-wide cached consent preference. Updated by [`record_consent`] and +/// [`initialize`]. Read by [`check_secret_access`] and [`current_status`] so +/// they never touch disk on the hot path. +static CONSENT_CACHE: RwLock> = RwLock::new(None); + +/// Pre-populate the consent cache from persisted app state. Call once at core +/// startup after config is loadable. +pub fn initialize(consent: Option) { + info!( + "{LOG_PREFIX} initialize cached_consent={}", + consent.as_ref().map_or("none", |p| p.storage_mode.as_str()), + ); + *CONSENT_CACHE.write() = consent; +} + +/// Check whether the caller is allowed to proceed with secret storage. +pub fn check_secret_access() -> PolicyDecision { + if crate::openhuman::keyring::is_available() { + return PolicyDecision::Proceed; + } + + let cached = CONSENT_CACHE.read().clone(); + match cached { + Some(ref pref) if pref.storage_mode == "local_encrypted" => { + debug!("{LOG_PREFIX} check_secret_access: consent=local_encrypted, proceeding"); + PolicyDecision::Proceed + } + Some(ref pref) if pref.storage_mode == "declined" => { + debug!("{LOG_PREFIX} check_secret_access: consent=declined"); + PolicyDecision::Declined + } + _ => { + debug!("{LOG_PREFIX} check_secret_access: keyring unavailable, no consent recorded"); + if !CONSENT_EVENT_PUBLISHED.swap(true, Ordering::SeqCst) { + info!("{LOG_PREFIX} publishing KeyringConsentRequired event"); + crate::core::event_bus::publish_global( + crate::core::event_bus::DomainEvent::KeyringConsentRequired, + ); + } + PolicyDecision::ConsentRequired + } + } +} + +/// Build the current keyring status for RPC / snapshot consumption. +pub fn current_status() -> KeyringStatus { + let available = crate::openhuman::keyring::is_available(); + let backend_name = crate::openhuman::keyring::backend_name(); + + let (active_mode, failure_reason) = if available { + (StorageMode::OsKeyring, None) + } else { + let reason = classify_failure_reason(&backend_name); + let cached = CONSENT_CACHE.read().clone(); + let mode = match cached { + Some(ref p) if p.storage_mode == "local_encrypted" => StorageMode::LocalEncrypted, + Some(ref p) if p.storage_mode == "declined" => StorageMode::Declined, + _ => StorageMode::ConsentPending, + }; + (mode, Some(reason)) + }; + + KeyringStatus { + available, + failure_reason, + active_mode, + backend_name, + } +} + +/// Record the user's consent decision: update the in-memory cache and return +/// the preference for the RPC caller to persist via `update_local_state`. +pub fn record_consent(mode: &str) -> ConsentPreference { + let now_ms = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_millis() as u64) + .unwrap_or(0); + + let pref = ConsentPreference { + storage_mode: mode.to_string(), + consented_at_ms: Some(now_ms), + }; + + info!("{LOG_PREFIX} record_consent mode={mode} at_ms={now_ms}"); + + *CONSENT_CACHE.write() = Some(pref.clone()); + CONSENT_EVENT_PUBLISHED.store(false, Ordering::SeqCst); + + pref +} + +/// Reset the cached keyring probe and re-run it. +pub fn retry_probe() -> KeyringStatus { + info!("{LOG_PREFIX} retry_probe: resetting availability cache"); + crate::openhuman::keyring::reset_availability_cache(); + CONSENT_EVENT_PUBLISHED.store(false, Ordering::SeqCst); + current_status() +} + +/// Publish a decrypt-failure event for frontend notification. +pub fn notify_decrypt_failure(field_name: &str, reason: &str) { + warn!("{LOG_PREFIX} decrypt failure field={field_name} reason={reason}"); + crate::core::event_bus::publish_global( + crate::core::event_bus::DomainEvent::KeyringDecryptFailed { + field_name: field_name.to_string(), + reason: reason.to_string(), + }, + ); +} + +fn classify_failure_reason(backend_name: &str) -> KeyringFailureReason { + match backend_name { + "os" => { + if cfg!(target_os = "linux") { + KeyringFailureReason::NoSecretService + } else if cfg!(target_os = "macos") { + KeyringFailureReason::AccessDenied + } else { + KeyringFailureReason::Unknown("OS keyring probe failed".to_string()) + } + } + "encrypted_file" => KeyringFailureReason::MasterKeyUnavailable, + _ => KeyringFailureReason::Unknown(format!("Backend '{backend_name}' unavailable")), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn classify_failure_linux() { + if cfg!(target_os = "linux") { + let reason = classify_failure_reason("os"); + assert_eq!(reason, KeyringFailureReason::NoSecretService); + } + } + + #[test] + fn classify_failure_macos() { + if cfg!(target_os = "macos") { + let reason = classify_failure_reason("os"); + assert_eq!(reason, KeyringFailureReason::AccessDenied); + } + } + + #[test] + fn classify_failure_encrypted_file() { + let reason = classify_failure_reason("encrypted_file"); + assert_eq!(reason, KeyringFailureReason::MasterKeyUnavailable); + } + + #[test] + fn classify_failure_unknown() { + let reason = classify_failure_reason("weird_backend"); + assert!(matches!(reason, KeyringFailureReason::Unknown(_))); + } + + #[test] + fn record_consent_updates_cache() { + let pref = record_consent("local_encrypted"); + assert_eq!(pref.storage_mode, "local_encrypted"); + assert!(pref.consented_at_ms.is_some()); + + let cached = CONSENT_CACHE.read().clone(); + assert!(cached.is_some()); + assert_eq!(cached.unwrap().storage_mode, "local_encrypted"); + } + + #[test] + fn initialize_populates_cache() { + let pref = ConsentPreference { + storage_mode: "declined".to_string(), + consented_at_ms: Some(12345), + }; + initialize(Some(pref.clone())); + let cached = CONSENT_CACHE.read().clone(); + assert_eq!(cached.unwrap().storage_mode, "declined"); + } +} diff --git a/src/openhuman/keyring_consent/schemas.rs b/src/openhuman/keyring_consent/schemas.rs new file mode 100644 index 0000000000..fed77a7ac4 --- /dev/null +++ b/src/openhuman/keyring_consent/schemas.rs @@ -0,0 +1,150 @@ +//! Controller registration for keyring consent RPC methods. + +use serde_json::{Map, Value}; + +use crate::core::all::{ControllerFuture, RegisteredController}; +use crate::core::{ControllerSchema, FieldSchema, TypeSchema}; + +pub fn all_keyring_consent_controller_schemas() -> Vec { + vec![ + keyring_consent_schema("status"), + keyring_consent_schema("decide"), + keyring_consent_schema("retry_probe"), + ] +} + +pub fn all_keyring_consent_registered_controllers() -> Vec { + vec![ + RegisteredController { + schema: keyring_consent_schema("status"), + handler: handle_status, + }, + RegisteredController { + schema: keyring_consent_schema("decide"), + handler: handle_decide, + }, + RegisteredController { + schema: keyring_consent_schema("retry_probe"), + handler: handle_retry_probe, + }, + ] +} + +fn keyring_consent_schema(function: &str) -> ControllerSchema { + match function { + "status" => ControllerSchema { + namespace: "keyring_consent", + function: "status", + description: "Returns the current keyring availability, failure reason, active storage mode, and backend name.", + inputs: vec![], + outputs: vec![FieldSchema { + name: "result", + ty: TypeSchema::Json, + comment: "Structured keyring status.", + required: true, + }], + }, + "decide" => ControllerSchema { + namespace: "keyring_consent", + function: "decide", + description: "Record the user's consent decision for local secret storage fallback.", + inputs: vec![FieldSchema { + name: "mode", + ty: TypeSchema::String, + comment: "Either 'local_encrypted' (consent to local storage) or 'declined' (refuse local storage).", + required: true, + }], + outputs: vec![FieldSchema { + name: "result", + ty: TypeSchema::Json, + comment: "Persisted consent preference.", + required: true, + }], + }, + "retry_probe" => ControllerSchema { + namespace: "keyring_consent", + function: "retry_probe", + description: "Reset the cached keyring probe and re-test OS keyring availability.", + inputs: vec![], + outputs: vec![FieldSchema { + name: "result", + ty: TypeSchema::Json, + comment: "Updated keyring status after re-probe.", + required: true, + }], + }, + _ => ControllerSchema { + namespace: "keyring_consent", + function: "unknown", + description: "Unknown keyring_consent controller.", + inputs: vec![], + outputs: vec![FieldSchema { + name: "error", + ty: TypeSchema::String, + comment: "Lookup error details.", + required: true, + }], + }, + } +} + +fn handle_status(_params: Map) -> ControllerFuture { + Box::pin(async move { + super::ops::keyring_status() + .await? + .into_cli_compatible_json() + }) +} + +fn handle_decide(params: Map) -> ControllerFuture { + Box::pin(async move { + let mode = params + .get("mode") + .and_then(|v| v.as_str()) + .ok_or("missing required param 'mode'")? + .to_string(); + super::ops::keyring_consent_decide(mode) + .await? + .into_cli_compatible_json() + }) +} + +fn handle_retry_probe(_params: Map) -> ControllerFuture { + Box::pin(async move { + super::ops::keyring_retry_probe() + .await? + .into_cli_compatible_json() + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn schemas_and_controllers_match() { + let s = all_keyring_consent_controller_schemas(); + let c = all_keyring_consent_registered_controllers(); + assert_eq!(s.len(), c.len()); + for (schema, ctrl) in s.iter().zip(c.iter()) { + assert_eq!(schema.function, ctrl.schema.function); + assert_eq!(schema.namespace, ctrl.schema.namespace); + } + } + + #[test] + fn all_schemas_use_keyring_consent_namespace() { + for s in all_keyring_consent_controller_schemas() { + assert_eq!(s.namespace, "keyring_consent"); + assert!(!s.description.is_empty()); + } + } + + #[test] + fn decide_schema_requires_mode() { + let s = keyring_consent_schema("decide"); + assert_eq!(s.inputs.len(), 1); + assert!(s.inputs[0].required); + assert_eq!(s.inputs[0].name, "mode"); + } +} diff --git a/src/openhuman/keyring_consent/types.rs b/src/openhuman/keyring_consent/types.rs new file mode 100644 index 0000000000..a0cde46712 --- /dev/null +++ b/src/openhuman/keyring_consent/types.rs @@ -0,0 +1,142 @@ +//! Types for the keyring consent domain. + +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum StorageMode { + OsKeyring, + LocalEncrypted, + ConsentPending, + Declined, +} + +impl std::fmt::Display for StorageMode { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::OsKeyring => write!(f, "os_keyring"), + Self::LocalEncrypted => write!(f, "local_encrypted"), + Self::ConsentPending => write!(f, "consent_pending"), + Self::Declined => write!(f, "declined"), + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum KeyringFailureReason { + NoSecretService, + KeychainLocked, + AccessDenied, + MasterKeyUnavailable, + Unknown(String), +} + +impl std::fmt::Display for KeyringFailureReason { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::NoSecretService => write!(f, "No Secret Service daemon available"), + Self::KeychainLocked => write!(f, "OS keychain is locked"), + Self::AccessDenied => write!(f, "Access to OS keychain was denied"), + Self::MasterKeyUnavailable => write!(f, "Master encryption key unavailable"), + Self::Unknown(msg) => write!(f, "{msg}"), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct KeyringStatus { + pub available: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub failure_reason: Option, + pub active_mode: StorageMode, + pub backend_name: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct ConsentPreference { + #[serde(default)] + pub storage_mode: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub consented_at_ms: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PolicyDecision { + Proceed, + ConsentRequired, + Declined, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn storage_mode_serialization_roundtrip() { + let modes = [ + StorageMode::OsKeyring, + StorageMode::LocalEncrypted, + StorageMode::ConsentPending, + StorageMode::Declined, + ]; + for mode in modes { + let json = serde_json::to_string(&mode).unwrap(); + let deserialized: StorageMode = serde_json::from_str(&json).unwrap(); + assert_eq!(mode, deserialized); + } + } + + #[test] + fn storage_mode_display() { + assert_eq!(StorageMode::OsKeyring.to_string(), "os_keyring"); + assert_eq!(StorageMode::ConsentPending.to_string(), "consent_pending"); + } + + #[test] + fn failure_reason_display() { + assert_eq!( + KeyringFailureReason::NoSecretService.to_string(), + "No Secret Service daemon available" + ); + assert_eq!( + KeyringFailureReason::Unknown("custom".to_string()).to_string(), + "custom" + ); + } + + #[test] + fn keyring_status_serialization() { + let status = KeyringStatus { + available: false, + failure_reason: Some(KeyringFailureReason::NoSecretService), + active_mode: StorageMode::ConsentPending, + backend_name: "os".to_string(), + }; + let json = serde_json::to_value(&status).unwrap(); + assert_eq!(json["available"], false); + assert_eq!(json["activeMode"], "consent_pending"); + assert_eq!(json["failureReason"], "no_secret_service"); + } + + #[test] + fn keyring_status_omits_none_failure_reason() { + let status = KeyringStatus { + available: true, + failure_reason: None, + active_mode: StorageMode::OsKeyring, + backend_name: "os".to_string(), + }; + let json = serde_json::to_value(&status).unwrap(); + assert!(!json.as_object().unwrap().contains_key("failureReason")); + } + + #[test] + fn consent_preference_defaults() { + let pref = ConsentPreference::default(); + assert_eq!(pref.storage_mode, ""); + assert!(pref.consented_at_ms.is_none()); + } +} diff --git a/src/openhuman/mod.rs b/src/openhuman/mod.rs index 6600780845..d27a0b1a3b 100644 --- a/src/openhuman/mod.rs +++ b/src/openhuman/mod.rs @@ -53,6 +53,7 @@ pub mod inference; pub mod integrations; pub mod javascript; pub mod keyring; +pub mod keyring_consent; pub mod learning; pub mod mcp_audit; pub mod mcp_client; diff --git a/src/openhuman/wallet/ops.rs b/src/openhuman/wallet/ops.rs index aabc0e36c0..ce7507ffdc 100644 --- a/src/openhuman/wallet/ops.rs +++ b/src/openhuman/wallet/ops.rs @@ -52,8 +52,11 @@ fn wallet_user_id(config: &Config) -> String { /// /// Returns `None` if the keychain is unavailable or the entry does not exist. fn keychain_load_mnemonic(config: &Config) -> Option { - if !crate::openhuman::keyring::is_available() { - log::debug!("{LOG_PREFIX} keychain unavailable, skipping mnemonic load"); + let policy = crate::openhuman::keyring_consent::policy::check_secret_access(); + if policy != crate::openhuman::keyring_consent::PolicyDecision::Proceed + || !crate::openhuman::keyring::is_available() + { + log::debug!("{LOG_PREFIX} keychain unavailable or consent pending, skipping mnemonic load policy={policy:?}"); return None; } let user_id = wallet_user_id(config); @@ -77,8 +80,11 @@ fn keychain_load_mnemonic(config: &Config) -> Option { /// /// Returns `true` if the write succeeded. fn keychain_save_mnemonic(config: &Config, encrypted_mnemonic: &str) -> bool { - if !crate::openhuman::keyring::is_available() { - log::debug!("{LOG_PREFIX} keychain unavailable, skipping mnemonic save"); + let policy = crate::openhuman::keyring_consent::policy::check_secret_access(); + if policy != crate::openhuman::keyring_consent::PolicyDecision::Proceed + || !crate::openhuman::keyring::is_available() + { + log::debug!("{LOG_PREFIX} keychain unavailable or consent pending, skipping mnemonic save policy={policy:?}"); return false; } let user_id = wallet_user_id(config); @@ -96,7 +102,10 @@ fn keychain_save_mnemonic(config: &Config, encrypted_mnemonic: &str) -> bool { /// Whether a keychain entry exists for the encrypted mnemonic. fn keychain_has_mnemonic(config: &Config) -> bool { - if !crate::openhuman::keyring::is_available() { + let policy = crate::openhuman::keyring_consent::policy::check_secret_access(); + if policy != crate::openhuman::keyring_consent::PolicyDecision::Proceed + || !crate::openhuman::keyring::is_available() + { return false; } let user_id = wallet_user_id(config); diff --git a/tests/app_credentials_threads_memory_sources_raw_coverage_e2e.rs b/tests/app_credentials_threads_memory_sources_raw_coverage_e2e.rs index bb0b23551b..5229b2b247 100644 --- a/tests/app_credentials_threads_memory_sources_raw_coverage_e2e.rs +++ b/tests/app_credentials_threads_memory_sources_raw_coverage_e2e.rs @@ -242,6 +242,7 @@ async fn round19_app_state_local_state_snapshot_and_corruption_edges() { .expect("seed local app session"); let updated = update_local_state(StoredAppStatePatch { + keyring_consent: None, encryption_key: Some(Some(" round19-key ".to_string())), onboarding_tasks: Some(Some(StoredOnboardingTasks { accessibility_permission_granted: true, @@ -281,6 +282,7 @@ async fn round19_app_state_local_state_snapshot_and_corruption_edges() { assert!(snap.meet_auto_orchestrator_handoff); let cleared = update_local_state(StoredAppStatePatch { + keyring_consent: None, encryption_key: Some(Some(" ".to_string())), onboarding_tasks: Some(None), }) diff --git a/tests/app_credentials_threads_round24_raw_coverage_e2e.rs b/tests/app_credentials_threads_round24_raw_coverage_e2e.rs index 976bb6edab..e60f13dc9c 100644 --- a/tests/app_credentials_threads_round24_raw_coverage_e2e.rs +++ b/tests/app_credentials_threads_round24_raw_coverage_e2e.rs @@ -142,6 +142,7 @@ async fn round24_app_state_update_and_snapshot_preserve_local_state() { }; let updated = update_local_state(StoredAppStatePatch { + keyring_consent: None, encryption_key: Some(Some(" round24-key ".into())), onboarding_tasks: Some(Some(tasks.clone())), }) @@ -166,6 +167,7 @@ async fn round24_app_state_update_and_snapshot_preserve_local_state() { assert!(snapshot.session_token.is_none()); let cleared = update_local_state(StoredAppStatePatch { + keyring_consent: None, encryption_key: Some(Some(" ".into())), onboarding_tasks: Some(None), }) diff --git a/tests/app_credentials_threads_sources_round26_raw_coverage_e2e.rs b/tests/app_credentials_threads_sources_round26_raw_coverage_e2e.rs index 9a8596762a..19313b4c95 100644 --- a/tests/app_credentials_threads_sources_round26_raw_coverage_e2e.rs +++ b/tests/app_credentials_threads_sources_round26_raw_coverage_e2e.rs @@ -154,6 +154,7 @@ async fn round26_app_state_quarantines_corrupt_local_file_and_preserves_patch_no ); let unchanged = update_local_state(StoredAppStatePatch { + keyring_consent: None, encryption_key: None, onboarding_tasks: None, }) diff --git a/tests/app_state_credentials_raw_coverage_e2e.rs b/tests/app_state_credentials_raw_coverage_e2e.rs index 9ba827e460..59d1363a8c 100644 --- a/tests/app_state_credentials_raw_coverage_e2e.rs +++ b/tests/app_state_credentials_raw_coverage_e2e.rs @@ -220,6 +220,7 @@ async fn round14_snapshot_preserves_rich_local_state_with_backend_or_stored_user .expect("seed app session profile"); let updated = update_local_state(StoredAppStatePatch { + keyring_consent: None, encryption_key: Some(Some(" round14-key ".to_string())), onboarding_tasks: Some(Some(StoredOnboardingTasks { accessibility_permission_granted: true, diff --git a/tests/composio_credentials_state_raw_coverage_e2e.rs b/tests/composio_credentials_state_raw_coverage_e2e.rs index c992071266..fa8d43db58 100644 --- a/tests/composio_credentials_state_raw_coverage_e2e.rs +++ b/tests/composio_credentials_state_raw_coverage_e2e.rs @@ -440,6 +440,7 @@ async fn round15_app_state_corruption_clear_and_snapshot_local_session_paths() { .expect("state dir"); std::fs::write(harness.app_state_file(), "{bad-json").expect("write corrupt app state"); let recovered = update_local_state(StoredAppStatePatch { + keyring_consent: None, encryption_key: Some(Some(" round15-secret ".to_string())), onboarding_tasks: Some(Some(StoredOnboardingTasks { accessibility_permission_granted: true, @@ -466,6 +467,7 @@ async fn round15_app_state_corruption_clear_and_snapshot_local_session_paths() { assert!(quarantined, "corrupt app state should be quarantined"); let cleared = update_local_state(StoredAppStatePatch { + keyring_consent: None, encryption_key: Some(None), onboarding_tasks: Some(None), }) diff --git a/tests/composio_ops_credentials_appstate_raw_coverage_e2e.rs b/tests/composio_ops_credentials_appstate_raw_coverage_e2e.rs index 19142c0217..7dac8601d2 100644 --- a/tests/composio_ops_credentials_appstate_raw_coverage_e2e.rs +++ b/tests/composio_ops_credentials_appstate_raw_coverage_e2e.rs @@ -511,6 +511,7 @@ async fn round18_app_state_snapshot_uses_local_session_cache_and_patch_edges() { .expect("store local app session"); let first = update_local_state(StoredAppStatePatch { + keyring_consent: None, encryption_key: Some(Some(" ".to_string())), onboarding_tasks: Some(Some(StoredOnboardingTasks { accessibility_permission_granted: true, @@ -528,6 +529,7 @@ async fn round18_app_state_snapshot_uses_local_session_cache_and_patch_edges() { assert!(first.onboarding_tasks.is_some()); let cleared = update_local_state(StoredAppStatePatch { + keyring_consent: None, encryption_key: Some(None), onboarding_tasks: Some(None), }) diff --git a/tests/composio_tools_ops_state_raw_coverage_e2e.rs b/tests/composio_tools_ops_state_raw_coverage_e2e.rs index b13f7077ad..cddc6eb6bb 100644 --- a/tests/composio_tools_ops_state_raw_coverage_e2e.rs +++ b/tests/composio_tools_ops_state_raw_coverage_e2e.rs @@ -340,6 +340,7 @@ async fn round17_ops_trigger_history_app_state_and_profiles_cover_local_edges() std::fs::create_dir_all(&state_dir).expect("state dir"); std::fs::write(state_dir.join("app-state.json"), "{not-json").expect("corrupt state"); let updated = update_local_state(StoredAppStatePatch { + keyring_consent: None, encryption_key: Some(Some(" round17-key ".into())), onboarding_tasks: Some(Some(StoredOnboardingTasks { accessibility_permission_granted: false, diff --git a/tests/config_credentials_raw_coverage_e2e.rs b/tests/config_credentials_raw_coverage_e2e.rs index 123e5173ea..83ba9a8e5f 100644 --- a/tests/config_credentials_raw_coverage_e2e.rs +++ b/tests/config_credentials_raw_coverage_e2e.rs @@ -283,6 +283,7 @@ async fn raw_round13_app_state_update_trims_clears_and_preserves_optional_local_ let harness = round13_setup(); let updated = update_local_state(StoredAppStatePatch { + keyring_consent: None, encryption_key: Some(Some(" raw-key ".to_string())), onboarding_tasks: Some(Some(Default::default())), }) @@ -293,6 +294,7 @@ async fn raw_round13_app_state_update_trims_clears_and_preserves_optional_local_ assert!(updated.onboarding_tasks.is_some()); let cleared_key = update_local_state(StoredAppStatePatch { + keyring_consent: None, encryption_key: Some(Some(" ".to_string())), onboarding_tasks: None, }) @@ -303,6 +305,7 @@ async fn raw_round13_app_state_update_trims_clears_and_preserves_optional_local_ assert!(cleared_key.onboarding_tasks.is_some()); let cleared_tasks = update_local_state(StoredAppStatePatch { + keyring_consent: None, encryption_key: None, onboarding_tasks: Some(None), }) @@ -313,6 +316,7 @@ async fn raw_round13_app_state_update_trims_clears_and_preserves_optional_local_ assert!(cleared_tasks.onboarding_tasks.is_none()); let unchanged = update_local_state(StoredAppStatePatch { + keyring_consent: None, encryption_key: None, onboarding_tasks: None, }) diff --git a/tests/near90_closure_raw_coverage_e2e.rs b/tests/near90_closure_raw_coverage_e2e.rs index a17bb86477..9c179e4267 100644 --- a/tests/near90_closure_raw_coverage_e2e.rs +++ b/tests/near90_closure_raw_coverage_e2e.rs @@ -245,6 +245,7 @@ async fn round20_app_state_quarantines_directory_state_and_uses_stored_user_on_h .expect("seed app session"); let first = update_local_state(StoredAppStatePatch { + keyring_consent: None, encryption_key: None, onboarding_tasks: Some(Some(StoredOnboardingTasks { accessibility_permission_granted: false, diff --git a/tests/tools_agent_credentials_state_raw_coverage_e2e.rs b/tests/tools_agent_credentials_state_raw_coverage_e2e.rs index 4be7edcd1a..8d20f610bb 100644 --- a/tests/tools_agent_credentials_state_raw_coverage_e2e.rs +++ b/tests/tools_agent_credentials_state_raw_coverage_e2e.rs @@ -850,6 +850,7 @@ async fn round16_app_state_config_and_session_snapshot_edges() { std::fs::write(harness.app_state_file(), "{broken").expect("write corrupt app state"); let stored = update_local_state(StoredAppStatePatch { + keyring_consent: None, encryption_key: Some(Some(" round16-key ".to_string())), onboarding_tasks: Some(Some(StoredOnboardingTasks { accessibility_permission_granted: true, @@ -918,6 +919,7 @@ async fn round16_app_state_config_and_session_snapshot_edges() { ); let cleared = update_local_state(StoredAppStatePatch { + keyring_consent: None, encryption_key: Some(None), onboarding_tasks: Some(None), }) From ad1a7725585eed4786faf7dbc68dc8a70457f025 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 30 May 2026 23:11:02 -0700 Subject: [PATCH 2/4] fix: resolve smart-quote parse errors and add keyringStatus to test snapshots - Replace U+2018/U+2019 smart quotes with ASCII in en.ts (5 values with contractions switched to double-quoted strings) - Add keyringConsent and keyringStatus to all frontend test snapshot constructions that use CoreAppSnapshot / CoreLocalState --- .../__tests__/BottomTabBar.test.tsx | 8 +- .../__tests__/oauthAuthReadiness.test.ts | 16 +++- app/src/lib/coreState/__tests__/store.test.ts | 8 +- app/src/lib/i18n/en.ts | 84 +++++++++---------- .../__tests__/OnboardingLayout.test.tsx | 8 +- .../CoreStateProvider.identityFlip.test.tsx | 8 +- .../__tests__/CoreStateProvider.test.tsx | 8 +- app/src/services/coreStateApi.test.ts | 8 +- .../store/__tests__/socketSelectors.test.ts | 8 +- 9 files changed, 105 insertions(+), 51 deletions(-) diff --git a/app/src/components/__tests__/BottomTabBar.test.tsx b/app/src/components/__tests__/BottomTabBar.test.tsx index 1160056cb3..83d81d9520 100644 --- a/app/src/components/__tests__/BottomTabBar.test.tsx +++ b/app/src/components/__tests__/BottomTabBar.test.tsx @@ -71,7 +71,13 @@ async function renderBottomTabBar(pathname = '/home', opts: RenderOpts | boolean onboardingCompleted: true, chatOnboardingCompleted: true, analyticsEnabled: false, - localState: { encryptionKey: null, onboardingTasks: null }, + localState: { encryptionKey: null, onboardingTasks: null, keyringConsent: null }, + keyringStatus: { + available: true, + failureReason: null, + activeMode: 'os_keyring', + backendName: 'os', + }, runtime: { screenIntelligence: null, localAi: null, autocomplete: null, service: null }, }, isBootstrapping: false, diff --git a/app/src/components/oauth/__tests__/oauthAuthReadiness.test.ts b/app/src/components/oauth/__tests__/oauthAuthReadiness.test.ts index 6b9048b969..f5424a196a 100644 --- a/app/src/components/oauth/__tests__/oauthAuthReadiness.test.ts +++ b/app/src/components/oauth/__tests__/oauthAuthReadiness.test.ts @@ -51,7 +51,13 @@ describe('oauthAuthReadiness', () => { chatOnboardingCompleted: false, analyticsEnabled: false, meetAutoOrchestratorHandoff: false, - localState: { encryptionKey: null, onboardingTasks: null }, + localState: { encryptionKey: null, onboardingTasks: null, keyringConsent: null }, + keyringStatus: { + available: true, + failureReason: null, + activeMode: 'os_keyring', + backendName: 'os', + }, runtime: { screenIntelligence: null, localAi: null, autocomplete: null, service: null }, }, teams: [], @@ -112,7 +118,13 @@ describe('oauthAuthReadiness', () => { chatOnboardingCompleted: false, analyticsEnabled: false, meetAutoOrchestratorHandoff: false, - localState: { encryptionKey: null, onboardingTasks: null }, + localState: { encryptionKey: null, onboardingTasks: null, keyringConsent: null }, + keyringStatus: { + available: true, + failureReason: null, + activeMode: 'os_keyring', + backendName: 'os', + }, runtime: { screenIntelligence: null, localAi: null, autocomplete: null, service: null }, }, teams: [], diff --git a/app/src/lib/coreState/__tests__/store.test.ts b/app/src/lib/coreState/__tests__/store.test.ts index 56f1b31a5c..9d5d40b29e 100644 --- a/app/src/lib/coreState/__tests__/store.test.ts +++ b/app/src/lib/coreState/__tests__/store.test.ts @@ -11,7 +11,13 @@ function makeSnapshot(overrides: Partial = {}): CoreAppSnapshot chatOnboardingCompleted: false, analyticsEnabled: false, meetAutoOrchestratorHandoff: false, - localState: { encryptionKey: null, onboardingTasks: null }, + localState: { encryptionKey: null, onboardingTasks: null, keyringConsent: null }, + keyringStatus: { + available: true, + failureReason: null, + activeMode: 'os_keyring', + backendName: 'os', + }, runtime: { screenIntelligence: null, localAi: null, autocomplete: null, service: null }, ...overrides, }; diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts index 76795df041..693f76cbee 100644 --- a/app/src/lib/i18n/en.ts +++ b/app/src/lib/i18n/en.ts @@ -941,7 +941,7 @@ const en: TranslationMap = { 'settings.search.menuDesc': 'Default to OpenHuman-managed search or wire up your own provider with an API key.', 'settings.search.description': - 'Pick the search engine the agent uses, or disable search tools entirely. Managed uses OpenHuman’s backend (no setup). Parallel, Brave, and Querit run direct from your machine using your API key.', + "Pick the search engine the agent uses, or disable search tools entirely. Managed uses OpenHuman's backend (no setup). Parallel, Brave, and Querit run direct from your machine using your API key.", 'settings.search.engineAria': 'Search engine', 'settings.search.engineDisabledLabel': 'Disabled', 'settings.search.engineDisabledDesc': @@ -2384,7 +2384,7 @@ const en: TranslationMap = { 'composio.envVarOverrides': 'is set, it overrides this setting.', 'composio.previewBadge': 'Preview', 'composio.previewTooltip': - 'Agent integration coming soon — you can connect, but the agent can’t use this toolkit yet.', + "Agent integration coming soon — you can connect, but the agent can't use this toolkit yet.", // Memory: day-of-week labels for heatmap 'memory.day.sun': 'Sun', @@ -2493,7 +2493,7 @@ const en: TranslationMap = { 'app.openhumanLink.notifications.send': 'Send test notification', 'app.openhumanLink.notifications.sendFailed': "Couldn't send: {error}", 'app.openhumanLink.notifications.sent': - 'Test notification sent. If you didn’t receive it, go to System Settings → Notifications → OpenHuman, turn on Allow Notifications, and set Banner Style to Persistent.', + "Test notification sent. If you didn't receive it, go to System Settings → Notifications → OpenHuman, turn on Allow Notifications, and set Banner Style to Persistent.", 'app.openhumanLink.skipForNow': 'Skip for now', 'app.openhumanLink.telegramUnavailable': 'Telegram unavailable', 'app.openhumanLink.title.accounts': 'Connect your apps', @@ -3702,7 +3702,7 @@ const en: TranslationMap = { 'settings.skillsRunner.repoPicker.empty': 'No repositories returned. Connect GitHub via Composio to populate this list.', 'settings.skillsRunner.repoPicker.notConnected': - 'GitHub isn’t connected via Composio. Connect it under Skills → Composio first.', + "GitHub isn't connected via Composio. Connect it under Skills → Composio first.", 'settings.skillsRunner.repoPicker.privateTag': '(private)', 'settings.skillsRunner.branchPicker.needRepo': 'Pick a repo first…', 'settings.skillsRunner.branchPicker.loading': 'Loading branches…', @@ -4399,7 +4399,7 @@ const en: TranslationMap = { 'walletBalances.errorGeneric': 'Unable to load wallet balances. Set up your wallet in Recovery Phrase and try again.', 'walletBalances.setupHint': - 'Your recovery phrase isn’t set up yet. Set it up to enable your wallet and see live balances.', + "Your recovery phrase isn't set up yet. Set it up to enable your wallet and see live balances.", 'walletBalances.setupCta': 'Set up recovery phrase', 'walletBalances.notSetUp': 'Not set up', 'walletBalances.send': 'Send', @@ -4606,47 +4606,47 @@ const en: TranslationMap = { 'settings.agents.editor.toolsAllowAll': 'Allow all tools (*)', 'settings.agents.editor.toolsAllowAllHint': 'This agent can use every available tool.', 'settings.agents.editor.toolsLoading': 'Loading tools…', - 'settings.agents.editor.toolsLoadError': 'Couldn’t load tools', + 'settings.agents.editor.toolsLoadError': "Couldn't load tools", 'settings.agents.editor.toolsEmpty': 'No tools match your search.', 'settings.agents.editor.toolsDone': 'Done', - ‘settings.agents.editor.builtInReadonly’: - ‘Built-in agents can’t be edited. You can enable, disable, or reset them from the agents list.’, + 'settings.agents.editor.builtInReadonly': + "Built-in agents can't be edited. You can enable, disable, or reset them from the agents list.", // Keyring consent & security - ‘keyring.consent.title’: ‘Secure Storage Unavailable’, - ‘keyring.consent.description’: - ‘Your operating system keychain is not accessible. OpenHuman needs your permission to store secrets using local encrypted storage instead.’, - ‘keyring.consent.reasonPrefix’: ‘Reason:’, - ‘keyring.consent.showDetails’: ‘What does this mean?’, - ‘keyring.consent.hideDetails’: ‘Hide details’, - ‘keyring.consent.tradeoffTitle’: ‘Security tradeoff’, - ‘keyring.consent.tradeoffBody’: - ‘With local encrypted storage, your secrets are encrypted on disk using a master key stored alongside the data. This is less secure than the OS keychain, which uses hardware-backed protection. Backups or file syncing may include the encrypted data.’, - ‘keyring.consent.consentButton’: ‘Use Local Encrypted Storage’, - ‘keyring.consent.retryButton’: ‘Retry OS Keychain’, - ‘keyring.consent.declineButton’: ‘Skip’, - ‘keyring.consent.retrying’: ‘Retrying…’, - ‘keyring.consent.error’: ‘Failed to save preference. Please try again.’, - ‘keyring.consent.retryFailed’: ‘Keychain is still unavailable.’, - ‘keyring.settings.title’: ‘Security’, - ‘keyring.settings.storageMode’: ‘Secret storage mode’, - ‘keyring.settings.mode.osKeychain’: ‘OS Keychain’, - ‘keyring.settings.mode.encryptedFile’: ‘Local Encrypted’, - ‘keyring.settings.mode.consentPending’: ‘Not configured’, - ‘keyring.settings.mode.declined’: ‘Declined’, - ‘keyring.settings.availability’: ‘Keychain availability’, - ‘keyring.settings.available’: ‘OS keychain is available’, - ‘keyring.settings.unavailable’: ‘OS keychain is unavailable’, - ‘keyring.settings.backend’: ‘Backend’, - ‘keyring.settings.retryButton’: ‘Retry keychain detection’, - ‘keyring.settings.retryFailed’: ‘Retry failed. Keychain is still unavailable.’, - ‘keyring.settings.consentTitle’: ‘Storage consent’, - ‘keyring.settings.consentDescription’: - ‘Choose how secrets are stored when the OS keychain is not available.’, - ‘keyring.settings.grantConsent’: ‘Allow local encrypted storage’, - ‘keyring.settings.revokeConsent’: ‘Decline local storage’, - ‘pages.settings.account.security’: ‘Security’, - ‘pages.settings.account.securityDesc’: ‘Secret storage mode and keychain status’, + 'keyring.consent.title': 'Secure Storage Unavailable', + 'keyring.consent.description': + 'Your operating system keychain is not accessible. OpenHuman needs your permission to store secrets using local encrypted storage instead.', + 'keyring.consent.reasonPrefix': 'Reason:', + 'keyring.consent.showDetails': 'What does this mean?', + 'keyring.consent.hideDetails': 'Hide details', + 'keyring.consent.tradeoffTitle': 'Security tradeoff', + 'keyring.consent.tradeoffBody': + 'With local encrypted storage, your secrets are encrypted on disk using a master key stored alongside the data. This is less secure than the OS keychain, which uses hardware-backed protection. Backups or file syncing may include the encrypted data.', + 'keyring.consent.consentButton': 'Use Local Encrypted Storage', + 'keyring.consent.retryButton': 'Retry OS Keychain', + 'keyring.consent.declineButton': 'Skip', + 'keyring.consent.retrying': 'Retrying…', + 'keyring.consent.error': 'Failed to save preference. Please try again.', + 'keyring.consent.retryFailed': 'Keychain is still unavailable.', + 'keyring.settings.title': 'Security', + 'keyring.settings.storageMode': 'Secret storage mode', + 'keyring.settings.mode.osKeychain': 'OS Keychain', + 'keyring.settings.mode.encryptedFile': 'Local Encrypted', + 'keyring.settings.mode.consentPending': 'Not configured', + 'keyring.settings.mode.declined': 'Declined', + 'keyring.settings.availability': 'Keychain availability', + 'keyring.settings.available': 'OS keychain is available', + 'keyring.settings.unavailable': 'OS keychain is unavailable', + 'keyring.settings.backend': 'Backend', + 'keyring.settings.retryButton': 'Retry keychain detection', + 'keyring.settings.retryFailed': 'Retry failed. Keychain is still unavailable.', + 'keyring.settings.consentTitle': 'Storage consent', + 'keyring.settings.consentDescription': + 'Choose how secrets are stored when the OS keychain is not available.', + 'keyring.settings.grantConsent': 'Allow local encrypted storage', + 'keyring.settings.revokeConsent': 'Decline local storage', + 'pages.settings.account.security': 'Security', + 'pages.settings.account.securityDesc': 'Secret storage mode and keychain status', }; export default en; diff --git a/app/src/pages/onboarding/__tests__/OnboardingLayout.test.tsx b/app/src/pages/onboarding/__tests__/OnboardingLayout.test.tsx index 6d42db4fe3..9ff114a81e 100644 --- a/app/src/pages/onboarding/__tests__/OnboardingLayout.test.tsx +++ b/app/src/pages/onboarding/__tests__/OnboardingLayout.test.tsx @@ -114,7 +114,13 @@ async function setupLayout() { onboardingCompleted: false, chatOnboardingCompleted: false, analyticsEnabled: false, - localState: { encryptionKey: null, onboardingTasks: null }, + localState: { encryptionKey: null, onboardingTasks: null, keyringConsent: null }, + keyringStatus: { + available: true, + failureReason: null, + activeMode: 'os_keyring', + backendName: 'os', + }, runtime: { screenIntelligence: null, localAi: null, autocomplete: null, service: null }, }, isBootstrapping: false, diff --git a/app/src/providers/__tests__/CoreStateProvider.identityFlip.test.tsx b/app/src/providers/__tests__/CoreStateProvider.identityFlip.test.tsx index c7812774f9..ad76d60da2 100644 --- a/app/src/providers/__tests__/CoreStateProvider.identityFlip.test.tsx +++ b/app/src/providers/__tests__/CoreStateProvider.identityFlip.test.tsx @@ -75,7 +75,13 @@ function resetCoreStateStore() { chatOnboardingCompleted: false, analyticsEnabled: false, meetAutoOrchestratorHandoff: false, - localState: { encryptionKey: null, onboardingTasks: null }, + localState: { encryptionKey: null, onboardingTasks: null, keyringConsent: null }, + keyringStatus: { + available: true, + failureReason: null, + activeMode: 'os_keyring', + backendName: 'os', + }, runtime: { screenIntelligence: null, localAi: null, autocomplete: null, service: null }, }, teams: [], diff --git a/app/src/providers/__tests__/CoreStateProvider.test.tsx b/app/src/providers/__tests__/CoreStateProvider.test.tsx index 3b26733d5d..073278bcff 100644 --- a/app/src/providers/__tests__/CoreStateProvider.test.tsx +++ b/app/src/providers/__tests__/CoreStateProvider.test.tsx @@ -102,7 +102,13 @@ function resetCoreStateStore() { chatOnboardingCompleted: false, analyticsEnabled: false, meetAutoOrchestratorHandoff: false, - localState: { encryptionKey: null, onboardingTasks: null }, + localState: { encryptionKey: null, onboardingTasks: null, keyringConsent: null }, + keyringStatus: { + available: true, + failureReason: null, + activeMode: 'os_keyring', + backendName: 'os', + }, runtime: { screenIntelligence: null, localAi: null, autocomplete: null, service: null }, }, teams: [], diff --git a/app/src/services/coreStateApi.test.ts b/app/src/services/coreStateApi.test.ts index 92f2f2350f..54b055a5fe 100644 --- a/app/src/services/coreStateApi.test.ts +++ b/app/src/services/coreStateApi.test.ts @@ -15,7 +15,13 @@ function makeSnapshotResult(overrides: Record = {}) { currentUser: null, onboardingCompleted: false, analyticsEnabled: true, - localState: { encryptionKey: null, onboardingTasks: null }, + localState: { encryptionKey: null, onboardingTasks: null, keyringConsent: null }, + keyringStatus: { + available: true, + failureReason: null, + activeMode: 'os_keyring', + backendName: 'os', + }, runtime: { screenIntelligence: {}, localAi: {}, autocomplete: {}, service: {} }, ...overrides, }; diff --git a/app/src/store/__tests__/socketSelectors.test.ts b/app/src/store/__tests__/socketSelectors.test.ts index 92f2de69b2..122a2b82ff 100644 --- a/app/src/store/__tests__/socketSelectors.test.ts +++ b/app/src/store/__tests__/socketSelectors.test.ts @@ -22,7 +22,13 @@ function makeCoreState(token: string | null, userId: string | null = null): Core chatOnboardingCompleted: false, analyticsEnabled: false, meetAutoOrchestratorHandoff: false, - localState: { encryptionKey: null, onboardingTasks: null }, + localState: { encryptionKey: null, onboardingTasks: null, keyringConsent: null }, + keyringStatus: { + available: true, + failureReason: null, + activeMode: 'os_keyring', + backendName: 'os', + }, runtime: { screenIntelligence: null, localAi: null, autocomplete: null, service: null }, }, teams: [], From 39c1f264c25e189b0ed23040d82586d845e82984 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 30 May 2026 23:17:36 -0700 Subject: [PATCH 3/4] fix(test): import afterEach from vitest in keyringApi test --- app/src/services/__tests__/keyringApi.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/services/__tests__/keyringApi.test.ts b/app/src/services/__tests__/keyringApi.test.ts index 4e115a3d2f..04bb2094d4 100644 --- a/app/src/services/__tests__/keyringApi.test.ts +++ b/app/src/services/__tests__/keyringApi.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { decideKeyringConsent, fetchKeyringStatus, retryKeyringProbe } from '../keyringApi'; From 0a1e63d0ee48a3a46b3b265e9508c60cd3a87524 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Sat, 30 May 2026 23:30:53 -0700 Subject: [PATCH 4/4] =?UTF-8?q?fix:=20address=20CodeRabbit=20review=20?= =?UTF-8?q?=E2=80=94=20dialog=20a11y,=20persist-before-cache,=20probe=20ra?= =?UTF-8?q?ce,=20i18n=20completeness?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add role="dialog", aria-modal, aria-labelledby to consent overlay - Merge isRetrying/isUpdating into single isLoading in SecurityPanel - Persist consent to disk before updating in-memory cache (ops.rs) - Split record_consent into build_consent_preference + apply_consent - Use parking_lot::Mutex for probe cache to prevent race between concurrent callers - Fix credentials/profiles.rs logging to distinguish ConsentRequired vs Declined vs unavailable - Update schema test expectation from 2 to 3 inputs - Translate "Backend" and "OS Keychain" labels in all 13 non-English locales; fix Arabic/French decline button wording --- .../keyring/KeyringConsentOverlay.tsx | 10 ++++- .../settings/panels/SecurityPanel.tsx | 19 ++++---- app/src/lib/i18n/ar.ts | 6 +-- app/src/lib/i18n/bn.ts | 6 +-- app/src/lib/i18n/de.ts | 4 +- app/src/lib/i18n/es.ts | 6 +-- app/src/lib/i18n/fr.ts | 6 +-- app/src/lib/i18n/hi.ts | 6 +-- app/src/lib/i18n/id.ts | 4 +- app/src/lib/i18n/it.ts | 6 +-- app/src/lib/i18n/ko.ts | 6 +-- app/src/lib/i18n/pl.ts | 6 +-- app/src/lib/i18n/pt.ts | 6 +-- app/src/lib/i18n/ru.ts | 6 +-- app/src/lib/i18n/zh-CN.ts | 6 +-- src/openhuman/app_state/schemas.rs | 2 +- src/openhuman/credentials/profiles.rs | 26 +++++++++-- src/openhuman/keyring/ops.rs | 45 ++++++++++--------- src/openhuman/keyring_consent/ops.rs | 8 +++- src/openhuman/keyring_consent/policy.rs | 39 ++++++++++++---- 20 files changed, 138 insertions(+), 85 deletions(-) diff --git a/app/src/components/keyring/KeyringConsentOverlay.tsx b/app/src/components/keyring/KeyringConsentOverlay.tsx index 4ecd1819e4..7994aa0c2e 100644 --- a/app/src/components/keyring/KeyringConsentOverlay.tsx +++ b/app/src/components/keyring/KeyringConsentOverlay.tsx @@ -59,7 +59,11 @@ const KeyringConsentOverlay = () => { return (
-
+
{ />
-

{t('keyring.consent.title')}

+

{t('keyring.consent.description')}

diff --git a/app/src/components/settings/panels/SecurityPanel.tsx b/app/src/components/settings/panels/SecurityPanel.tsx index 907782a0ef..d283d7800f 100644 --- a/app/src/components/settings/panels/SecurityPanel.tsx +++ b/app/src/components/settings/panels/SecurityPanel.tsx @@ -33,34 +33,33 @@ const SecurityPanel = () => { const { navigateBack, breadcrumbs } = useSettingsNavigation(); const { snapshot } = useCoreState(); const { t } = useT(); - const [isRetrying, setIsRetrying] = useState(false); - const [isUpdating, setIsUpdating] = useState(false); + const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); const keyringStatus = snapshot.keyringStatus; const modeBadge = MODE_BADGE[keyringStatus.activeMode] ?? MODE_BADGE.consent_pending; const handleRetryProbe = async () => { - setIsRetrying(true); + setIsLoading(true); setError(null); try { await retryKeyringProbe(); } catch { setError(t('keyring.settings.retryFailed')); } finally { - setIsRetrying(false); + setIsLoading(false); } }; const handleConsentChange = async (mode: 'local_encrypted' | 'declined') => { - setIsUpdating(true); + setIsLoading(true); setError(null); try { await decideKeyringConsent(mode); } catch { setError(t('keyring.consent.error')); } finally { - setIsUpdating(false); + setIsLoading(false); } }; @@ -113,9 +112,9 @@ const SecurityPanel = () => {
@@ -134,7 +133,7 @@ const SecurityPanel = () => { @@ -143,7 +142,7 @@ const SecurityPanel = () => { diff --git a/app/src/lib/i18n/ar.ts b/app/src/lib/i18n/ar.ts index 978bb8b7eb..11613b672b 100644 --- a/app/src/lib/i18n/ar.ts +++ b/app/src/lib/i18n/ar.ts @@ -4319,20 +4319,20 @@ const messages: TranslationMap = { 'مع التخزين المشفّر المحلي، تُشفَّر أسرارك على القرص باستخدام مفتاح رئيسي مخزَّن بجانب البيانات. يُعدّ هذا أقل أماناً من OS Keychain الذي يستخدم حماية مدعومة بالأجهزة. قد تتضمن النسخ الاحتياطية أو مزامنة الملفات البيانات المشفّرة.', 'keyring.consent.consentButton': 'استخدام التخزين المشفّر المحلي', 'keyring.consent.retryButton': 'إعادة المحاولة مع OS Keychain', - 'keyring.consent.declineButton': 'تخطّ', + 'keyring.consent.declineButton': 'رفض', 'keyring.consent.retrying': 'جارٍ إعادة المحاولة…', 'keyring.consent.error': 'فشل حفظ التفضيل. يرجى المحاولة مرة أخرى.', 'keyring.consent.retryFailed': 'سلسلة المفاتيح لا تزال غير متاحة.', 'keyring.settings.title': 'الأمان', 'keyring.settings.storageMode': 'وضع تخزين الأسرار', - 'keyring.settings.mode.osKeychain': 'OS Keychain', + 'keyring.settings.mode.osKeychain': 'سلسلة مفاتيح النظام', 'keyring.settings.mode.encryptedFile': 'مشفَّر محلياً', 'keyring.settings.mode.consentPending': 'غير مُهيَّأ', 'keyring.settings.mode.declined': 'مرفوض', 'keyring.settings.availability': 'توفّر سلسلة المفاتيح', 'keyring.settings.available': 'OS Keychain متاح', 'keyring.settings.unavailable': 'OS Keychain غير متاح', - 'keyring.settings.backend': 'Backend', + 'keyring.settings.backend': 'الواجهة الخلفية', 'keyring.settings.retryButton': 'إعادة محاولة الكشف عن سلسلة المفاتيح', 'keyring.settings.retryFailed': 'فشلت إعادة المحاولة. سلسلة المفاتيح لا تزال غير متاحة.', 'keyring.settings.consentTitle': 'موافقة التخزين', diff --git a/app/src/lib/i18n/bn.ts b/app/src/lib/i18n/bn.ts index 0cf48f0b8f..c9640bfb0b 100644 --- a/app/src/lib/i18n/bn.ts +++ b/app/src/lib/i18n/bn.ts @@ -4395,20 +4395,20 @@ const messages: TranslationMap = { 'স্থানীয় এনক্রিপ্টেড সঞ্চয়স্থানে, আপনার গোপনীয়তা ডিস্কে একটি মাস্টার কী দিয়ে এনক্রিপ্ট করা হয় যা ডেটার পাশাপাশি সংরক্ষিত থাকে। এটি OS কিচেনের চেয়ে কম নিরাপদ, যা হার্ডওয়্যার-সমর্থিত সুরক্ষা ব্যবহার করে। ব্যাকআপ বা ফাইল সিঙ্কিংয়ে এনক্রিপ্টেড ডেটা অন্তর্ভুক্ত হতে পারে।', 'keyring.consent.consentButton': 'স্থানীয় এনক্রিপ্টেড সঞ্চয়স্থান ব্যবহার করুন', 'keyring.consent.retryButton': 'OS Keychain পুনরায় চেষ্টা করুন', - 'keyring.consent.declineButton': 'এড়িয়ে যান', + 'keyring.consent.declineButton': 'প্রত্যাখ্যান করুন', 'keyring.consent.retrying': 'পুনরায় চেষ্টা হচ্ছে…', 'keyring.consent.error': 'পছন্দ সংরক্ষণ ব্যর্থ। অনুগ্রহ করে আবার চেষ্টা করুন।', 'keyring.consent.retryFailed': 'কিচেন এখনও অনুপলব্ধ।', 'keyring.settings.title': 'নিরাপত্তা', 'keyring.settings.storageMode': 'গোপনীয়তা সঞ্চয়স্থান মোড', - 'keyring.settings.mode.osKeychain': 'OS Keychain', + 'keyring.settings.mode.osKeychain': 'OS কীচেইন', 'keyring.settings.mode.encryptedFile': 'স্থানীয় এনক্রিপ্টেড', 'keyring.settings.mode.consentPending': 'কনফিগার করা হয়নি', 'keyring.settings.mode.declined': 'প্রত্যাখ্যান করা হয়েছে', 'keyring.settings.availability': 'কিচেন প্রাপ্যতা', 'keyring.settings.available': 'OS কিচেন উপলব্ধ', 'keyring.settings.unavailable': 'OS কিচেন অনুপলব্ধ', - 'keyring.settings.backend': 'Backend', + 'keyring.settings.backend': 'ব্যাকএন্ড', 'keyring.settings.retryButton': 'কিচেন সনাক্তকরণ পুনরায় চেষ্টা করুন', 'keyring.settings.retryFailed': 'পুনরায় চেষ্টা ব্যর্থ। কিচেন এখনও অনুপলব্ধ।', 'keyring.settings.consentTitle': 'সঞ্চয়স্থান সম্মতি', diff --git a/app/src/lib/i18n/de.ts b/app/src/lib/i18n/de.ts index 94a79bb5e9..8d776aa339 100644 --- a/app/src/lib/i18n/de.ts +++ b/app/src/lib/i18n/de.ts @@ -4510,13 +4510,13 @@ const messages: TranslationMap = { 'Bei lokal verschlüsseltem Speicher werden Ihre Geheimnisse mit einem Hauptschlüssel verschlüsselt, der neben den Daten gespeichert wird. Dies ist weniger sicher als der OS-Schlüsselbund, der hardwaregestützten Schutz bietet. Backups oder Dateisynchronisation können die verschlüsselten Daten enthalten.', 'keyring.consent.consentButton': 'Lokal verschlüsselten Speicher verwenden', 'keyring.consent.retryButton': 'OS Keychain erneut versuchen', - 'keyring.consent.declineButton': 'Überspringen', + 'keyring.consent.declineButton': 'Ablehnen', 'keyring.consent.retrying': 'Erneuter Versuch…', 'keyring.consent.error': 'Einstellung konnte nicht gespeichert werden. Bitte erneut versuchen.', 'keyring.consent.retryFailed': 'Schlüsselbund ist weiterhin nicht verfügbar.', 'keyring.settings.title': 'Sicherheit', 'keyring.settings.storageMode': 'Geheimnisspeicher-Modus', - 'keyring.settings.mode.osKeychain': 'OS Keychain', + 'keyring.settings.mode.osKeychain': 'OS-Schlüsselbund', 'keyring.settings.mode.encryptedFile': 'Lokal verschlüsselt', 'keyring.settings.mode.consentPending': 'Nicht konfiguriert', 'keyring.settings.mode.declined': 'Abgelehnt', diff --git a/app/src/lib/i18n/es.ts b/app/src/lib/i18n/es.ts index 9f5effd7cb..8700f867c8 100644 --- a/app/src/lib/i18n/es.ts +++ b/app/src/lib/i18n/es.ts @@ -4477,20 +4477,20 @@ const messages: TranslationMap = { 'Con el almacenamiento local cifrado, sus secretos se cifran en disco usando una clave maestra almacenada junto a los datos. Esto es menos seguro que el llavero del SO, que usa protección respaldada por hardware. Las copias de seguridad o la sincronización de archivos pueden incluir los datos cifrados.', 'keyring.consent.consentButton': 'Usar almacenamiento local cifrado', 'keyring.consent.retryButton': 'Reintentar OS Keychain', - 'keyring.consent.declineButton': 'Omitir', + 'keyring.consent.declineButton': 'Rechazar', 'keyring.consent.retrying': 'Reintentando…', 'keyring.consent.error': 'No se pudo guardar la preferencia. Inténtelo de nuevo.', 'keyring.consent.retryFailed': 'El llavero sigue sin estar disponible.', 'keyring.settings.title': 'Seguridad', 'keyring.settings.storageMode': 'Modo de almacenamiento de secretos', - 'keyring.settings.mode.osKeychain': 'OS Keychain', + 'keyring.settings.mode.osKeychain': 'Llavero del SO', 'keyring.settings.mode.encryptedFile': 'Local cifrado', 'keyring.settings.mode.consentPending': 'No configurado', 'keyring.settings.mode.declined': 'Rechazado', 'keyring.settings.availability': 'Disponibilidad del llavero', 'keyring.settings.available': 'El llavero del SO está disponible', 'keyring.settings.unavailable': 'El llavero del SO no está disponible', - 'keyring.settings.backend': 'Backend', + 'keyring.settings.backend': 'Motor', 'keyring.settings.retryButton': 'Reintentar detección del llavero', 'keyring.settings.retryFailed': 'Reintento fallido. El llavero sigue sin estar disponible.', 'keyring.settings.consentTitle': 'Consentimiento de almacenamiento', diff --git a/app/src/lib/i18n/fr.ts b/app/src/lib/i18n/fr.ts index db2351b0d9..dc895de729 100644 --- a/app/src/lib/i18n/fr.ts +++ b/app/src/lib/i18n/fr.ts @@ -4492,20 +4492,20 @@ const messages: TranslationMap = { "Avec le stockage local chiffré, vos secrets sont chiffrés sur disque à l'aide d'une clé maître stockée à côté des données. C'est moins sécurisé que le trousseau du système, qui utilise une protection matérielle. Les sauvegardes ou la synchronisation de fichiers peuvent inclure les données chiffrées.", 'keyring.consent.consentButton': 'Utiliser le stockage local chiffré', 'keyring.consent.retryButton': 'Réessayer OS Keychain', - 'keyring.consent.declineButton': 'Ignorer', + 'keyring.consent.declineButton': 'Refuser', 'keyring.consent.retrying': 'Nouvelle tentative…', 'keyring.consent.error': "Impossible d'enregistrer la préférence. Veuillez réessayer.", 'keyring.consent.retryFailed': 'Le trousseau est toujours indisponible.', 'keyring.settings.title': 'Sécurité', 'keyring.settings.storageMode': 'Mode de stockage des secrets', - 'keyring.settings.mode.osKeychain': 'OS Keychain', + 'keyring.settings.mode.osKeychain': 'Trousseau du système', 'keyring.settings.mode.encryptedFile': 'Local chiffré', 'keyring.settings.mode.consentPending': 'Non configuré', 'keyring.settings.mode.declined': 'Refusé', 'keyring.settings.availability': 'Disponibilité du trousseau', 'keyring.settings.available': 'Le trousseau du système est disponible', 'keyring.settings.unavailable': 'Le trousseau du système est indisponible', - 'keyring.settings.backend': 'Backend', + 'keyring.settings.backend': 'Moteur', 'keyring.settings.retryButton': 'Réessayer la détection du trousseau', 'keyring.settings.retryFailed': 'Échec de la tentative. Le trousseau est toujours indisponible.', 'keyring.settings.consentTitle': 'Consentement de stockage', diff --git a/app/src/lib/i18n/hi.ts b/app/src/lib/i18n/hi.ts index 253681abd2..ab05bbd618 100644 --- a/app/src/lib/i18n/hi.ts +++ b/app/src/lib/i18n/hi.ts @@ -4402,20 +4402,20 @@ const messages: TranslationMap = { 'स्थानीय एन्क्रिप्टेड भंडारण में, आपके रहस्य डिस्क पर एक मास्टर कुंजी से एन्क्रिप्ट किए जाते हैं जो डेटा के साथ संग्रहीत होती है। यह OS कीचेन से कम सुरक्षित है, जो हार्डवेयर-समर्थित सुरक्षा का उपयोग करता है। बैकअप या फ़ाइल सिंकिंग में एन्क्रिप्टेड डेटा शामिल हो सकता है।', 'keyring.consent.consentButton': 'स्थानीय एन्क्रिप्टेड भंडारण का उपयोग करें', 'keyring.consent.retryButton': 'OS Keychain पुनः प्रयास करें', - 'keyring.consent.declineButton': 'छोड़ें', + 'keyring.consent.declineButton': 'अस्वीकार करें', 'keyring.consent.retrying': 'पुनः प्रयास हो रहा है…', 'keyring.consent.error': 'प्राथमिकता सहेजने में विफल। कृपया पुनः प्रयास करें।', 'keyring.consent.retryFailed': 'कीचेन अभी भी अनुपलब्ध है।', 'keyring.settings.title': 'सुरक्षा', 'keyring.settings.storageMode': 'रहस्य भंडारण मोड', - 'keyring.settings.mode.osKeychain': 'OS Keychain', + 'keyring.settings.mode.osKeychain': 'OS कीचेन', 'keyring.settings.mode.encryptedFile': 'स्थानीय एन्क्रिप्टेड', 'keyring.settings.mode.consentPending': 'कॉन्फ़िगर नहीं किया गया', 'keyring.settings.mode.declined': 'अस्वीकृत', 'keyring.settings.availability': 'कीचेन उपलब्धता', 'keyring.settings.available': 'OS कीचेन उपलब्ध है', 'keyring.settings.unavailable': 'OS कीचेन अनुपलब्ध है', - 'keyring.settings.backend': 'Backend', + 'keyring.settings.backend': 'बैकएंड', 'keyring.settings.retryButton': 'कीचेन पहचान पुनः प्रयास करें', 'keyring.settings.retryFailed': 'पुनः प्रयास विफल। कीचेन अभी भी अनुपलब्ध है।', 'keyring.settings.consentTitle': 'भंडारण सहमति', diff --git a/app/src/lib/i18n/id.ts b/app/src/lib/i18n/id.ts index 7fd4253758..1e4a18904a 100644 --- a/app/src/lib/i18n/id.ts +++ b/app/src/lib/i18n/id.ts @@ -4411,13 +4411,13 @@ const messages: TranslationMap = { 'Dengan penyimpanan lokal terenkripsi, rahasia Anda dienkripsi di disk menggunakan kunci master yang disimpan bersama data. Ini kurang aman dibandingkan keychain OS yang menggunakan perlindungan berbasis perangkat keras. Pencadangan atau sinkronisasi file mungkin menyertakan data terenkripsi.', 'keyring.consent.consentButton': 'Gunakan penyimpanan lokal terenkripsi', 'keyring.consent.retryButton': 'Coba ulang OS Keychain', - 'keyring.consent.declineButton': 'Lewati', + 'keyring.consent.declineButton': 'Tolak', 'keyring.consent.retrying': 'Mencoba ulang…', 'keyring.consent.error': 'Gagal menyimpan preferensi. Silakan coba lagi.', 'keyring.consent.retryFailed': 'Keychain masih tidak tersedia.', 'keyring.settings.title': 'Keamanan', 'keyring.settings.storageMode': 'Mode penyimpanan rahasia', - 'keyring.settings.mode.osKeychain': 'OS Keychain', + 'keyring.settings.mode.osKeychain': 'Keychain OS', 'keyring.settings.mode.encryptedFile': 'Lokal terenkripsi', 'keyring.settings.mode.consentPending': 'Belum dikonfigurasi', 'keyring.settings.mode.declined': 'Ditolak', diff --git a/app/src/lib/i18n/it.ts b/app/src/lib/i18n/it.ts index 78fca18978..41e461f7d7 100644 --- a/app/src/lib/i18n/it.ts +++ b/app/src/lib/i18n/it.ts @@ -4469,20 +4469,20 @@ const messages: TranslationMap = { "Con l'archiviazione locale crittografata, i tuoi segreti vengono crittografati su disco utilizzando una chiave master archiviata insieme ai dati. Questo è meno sicuro del portachiavi del SO, che utilizza protezione hardware. I backup o la sincronizzazione dei file possono includere i dati crittografati.", 'keyring.consent.consentButton': 'Usa archiviazione locale crittografata', 'keyring.consent.retryButton': 'Riprova OS Keychain', - 'keyring.consent.declineButton': 'Salta', + 'keyring.consent.declineButton': 'Rifiuta', 'keyring.consent.retrying': 'Nuovo tentativo…', 'keyring.consent.error': 'Impossibile salvare la preferenza. Riprova.', 'keyring.consent.retryFailed': 'Il portachiavi è ancora non disponibile.', 'keyring.settings.title': 'Sicurezza', 'keyring.settings.storageMode': 'Modalità archiviazione segreti', - 'keyring.settings.mode.osKeychain': 'OS Keychain', + 'keyring.settings.mode.osKeychain': 'Portachiavi del SO', 'keyring.settings.mode.encryptedFile': 'Locale crittografato', 'keyring.settings.mode.consentPending': 'Non configurato', 'keyring.settings.mode.declined': 'Rifiutato', 'keyring.settings.availability': 'Disponibilità portachiavi', 'keyring.settings.available': 'Il portachiavi del SO è disponibile', 'keyring.settings.unavailable': 'Il portachiavi del SO non è disponibile', - 'keyring.settings.backend': 'Backend', + 'keyring.settings.backend': 'Motore', 'keyring.settings.retryButton': 'Riprova rilevamento portachiavi', 'keyring.settings.retryFailed': 'Tentativo fallito. Il portachiavi è ancora non disponibile.', 'keyring.settings.consentTitle': 'Consenso archiviazione', diff --git a/app/src/lib/i18n/ko.ts b/app/src/lib/i18n/ko.ts index fbf2f58800..846b5c237d 100644 --- a/app/src/lib/i18n/ko.ts +++ b/app/src/lib/i18n/ko.ts @@ -4362,20 +4362,20 @@ const messages: TranslationMap = { '로컬 암호화 저장소에서는 데이터와 함께 저장된 마스터 키를 사용하여 비밀이 디스크에 암호화됩니다. 이는 하드웨어 기반 보호를 사용하는 OS 키체인보다 덜 안전합니다. 백업이나 파일 동기화에 암호화된 데이터가 포함될 수 있습니다.', 'keyring.consent.consentButton': '로컬 암호화 저장소 사용', 'keyring.consent.retryButton': 'OS Keychain 재시도', - 'keyring.consent.declineButton': '건너뛰기', + 'keyring.consent.declineButton': '거부', 'keyring.consent.retrying': '재시도 중…', 'keyring.consent.error': '설정을 저장하지 못했습니다. 다시 시도해 주세요.', 'keyring.consent.retryFailed': '키체인이 여전히 사용할 수 없습니다.', 'keyring.settings.title': '보안', 'keyring.settings.storageMode': '비밀 저장 모드', - 'keyring.settings.mode.osKeychain': 'OS Keychain', + 'keyring.settings.mode.osKeychain': 'OS 키체인', 'keyring.settings.mode.encryptedFile': '로컬 암호화', 'keyring.settings.mode.consentPending': '구성되지 않음', 'keyring.settings.mode.declined': '거부됨', 'keyring.settings.availability': '키체인 가용성', 'keyring.settings.available': 'OS 키체인 사용 가능', 'keyring.settings.unavailable': 'OS 키체인 사용 불가', - 'keyring.settings.backend': 'Backend', + 'keyring.settings.backend': '백엔드', 'keyring.settings.retryButton': '키체인 감지 재시도', 'keyring.settings.retryFailed': '재시도 실패. 키체인이 여전히 사용할 수 없습니다.', 'keyring.settings.consentTitle': '저장소 동의', diff --git a/app/src/lib/i18n/pl.ts b/app/src/lib/i18n/pl.ts index 9a419262b5..eb541365c9 100644 --- a/app/src/lib/i18n/pl.ts +++ b/app/src/lib/i18n/pl.ts @@ -4468,20 +4468,20 @@ const messages: TranslationMap = { 'W przypadku lokalnego zaszyfrowanego magazynu sekrety są szyfrowane na dysku przy użyciu klucza głównego przechowywanego obok danych. Jest to mniej bezpieczne niż pęk kluczy systemu, który wykorzystuje ochronę sprzętową. Kopie zapasowe lub synchronizacja plików mogą zawierać zaszyfrowane dane.', 'keyring.consent.consentButton': 'Użyj lokalnego zaszyfrowanego magazynu', 'keyring.consent.retryButton': 'Ponów próbę OS Keychain', - 'keyring.consent.declineButton': 'Pomiń', + 'keyring.consent.declineButton': 'Odrzuć', 'keyring.consent.retrying': 'Ponawiam próbę…', 'keyring.consent.error': 'Nie udało się zapisać preferencji. Spróbuj ponownie.', 'keyring.consent.retryFailed': 'Pęk kluczy jest nadal niedostępny.', 'keyring.settings.title': 'Bezpieczeństwo', 'keyring.settings.storageMode': 'Tryb przechowywania sekretów', - 'keyring.settings.mode.osKeychain': 'OS Keychain', + 'keyring.settings.mode.osKeychain': 'Pęk kluczy systemu', 'keyring.settings.mode.encryptedFile': 'Lokalnie zaszyfrowany', 'keyring.settings.mode.consentPending': 'Nie skonfigurowano', 'keyring.settings.mode.declined': 'Odrzucono', 'keyring.settings.availability': 'Dostępność pęku kluczy', 'keyring.settings.available': 'Pęk kluczy systemu jest dostępny', 'keyring.settings.unavailable': 'Pęk kluczy systemu jest niedostępny', - 'keyring.settings.backend': 'Backend', + 'keyring.settings.backend': 'Silnik', 'keyring.settings.retryButton': 'Ponów wykrywanie pęku kluczy', 'keyring.settings.retryFailed': 'Ponowna próba nie powiodła się. Pęk kluczy jest nadal niedostępny.', diff --git a/app/src/lib/i18n/pt.ts b/app/src/lib/i18n/pt.ts index 54af633d2b..af6f68ae59 100644 --- a/app/src/lib/i18n/pt.ts +++ b/app/src/lib/i18n/pt.ts @@ -4467,20 +4467,20 @@ const messages: TranslationMap = { 'Com o armazenamento local criptografado, seus segredos são criptografados no disco usando uma chave mestra armazenada junto aos dados. Isso é menos seguro que o chaveiro do SO, que usa proteção por hardware. Backups ou sincronização de arquivos podem incluir os dados criptografados.', 'keyring.consent.consentButton': 'Usar armazenamento local criptografado', 'keyring.consent.retryButton': 'Tentar novamente OS Keychain', - 'keyring.consent.declineButton': 'Pular', + 'keyring.consent.declineButton': 'Recusar', 'keyring.consent.retrying': 'Tentando novamente…', 'keyring.consent.error': 'Falha ao salvar preferência. Tente novamente.', 'keyring.consent.retryFailed': 'O chaveiro ainda está indisponível.', 'keyring.settings.title': 'Segurança', 'keyring.settings.storageMode': 'Modo de armazenamento de segredos', - 'keyring.settings.mode.osKeychain': 'OS Keychain', + 'keyring.settings.mode.osKeychain': 'Chaveiro do SO', 'keyring.settings.mode.encryptedFile': 'Local criptografado', 'keyring.settings.mode.consentPending': 'Não configurado', 'keyring.settings.mode.declined': 'Recusado', 'keyring.settings.availability': 'Disponibilidade do chaveiro', 'keyring.settings.available': 'O chaveiro do SO está disponível', 'keyring.settings.unavailable': 'O chaveiro do SO está indisponível', - 'keyring.settings.backend': 'Backend', + 'keyring.settings.backend': 'Motor', 'keyring.settings.retryButton': 'Tentar novamente detecção do chaveiro', 'keyring.settings.retryFailed': 'Tentativa falhou. O chaveiro ainda está indisponível.', 'keyring.settings.consentTitle': 'Consentimento de armazenamento', diff --git a/app/src/lib/i18n/ru.ts b/app/src/lib/i18n/ru.ts index 971c9694d4..f2cbb7254c 100644 --- a/app/src/lib/i18n/ru.ts +++ b/app/src/lib/i18n/ru.ts @@ -4436,20 +4436,20 @@ const messages: TranslationMap = { 'При локальном зашифрованном хранении ваши секреты шифруются на диске с помощью мастер-ключа, который хранится рядом с данными. Это менее безопасно, чем связка ключей ОС, которая использует аппаратную защиту. Резервные копии или синхронизация файлов могут включать зашифрованные данные.', 'keyring.consent.consentButton': 'Использовать локальное зашифрованное хранилище', 'keyring.consent.retryButton': 'Повторить OS Keychain', - 'keyring.consent.declineButton': 'Пропустить', + 'keyring.consent.declineButton': 'Отклонить', 'keyring.consent.retrying': 'Повторная попытка…', 'keyring.consent.error': 'Не удалось сохранить настройку. Попробуйте снова.', 'keyring.consent.retryFailed': 'Связка ключей по-прежнему недоступна.', 'keyring.settings.title': 'Безопасность', 'keyring.settings.storageMode': 'Режим хранения секретов', - 'keyring.settings.mode.osKeychain': 'OS Keychain', + 'keyring.settings.mode.osKeychain': 'Связка ключей ОС', 'keyring.settings.mode.encryptedFile': 'Локальное шифрование', 'keyring.settings.mode.consentPending': 'Не настроено', 'keyring.settings.mode.declined': 'Отклонено', 'keyring.settings.availability': 'Доступность связки ключей', 'keyring.settings.available': 'Связка ключей ОС доступна', 'keyring.settings.unavailable': 'Связка ключей ОС недоступна', - 'keyring.settings.backend': 'Backend', + 'keyring.settings.backend': 'Движок', 'keyring.settings.retryButton': 'Повторить обнаружение связки ключей', 'keyring.settings.retryFailed': 'Повторная попытка не удалась. Связка ключей по-прежнему недоступна.', diff --git a/app/src/lib/i18n/zh-CN.ts b/app/src/lib/i18n/zh-CN.ts index 341fa8e6c3..86091f51e9 100644 --- a/app/src/lib/i18n/zh-CN.ts +++ b/app/src/lib/i18n/zh-CN.ts @@ -4185,20 +4185,20 @@ const messages: TranslationMap = { '使用本地加密存储时,您的密钥使用与数据一起存储的主密钥进行磁盘加密。这不如使用硬件保护的操作系统密钥链安全。备份或文件同步可能包含加密数据。', 'keyring.consent.consentButton': '使用本地加密存储', 'keyring.consent.retryButton': '重试 OS Keychain', - 'keyring.consent.declineButton': '跳过', + 'keyring.consent.declineButton': '拒绝', 'keyring.consent.retrying': '正在重试…', 'keyring.consent.error': '保存偏好失败,请重试。', 'keyring.consent.retryFailed': '密钥链仍然不可用。', 'keyring.settings.title': '安全', 'keyring.settings.storageMode': '密钥存储模式', - 'keyring.settings.mode.osKeychain': 'OS Keychain', + 'keyring.settings.mode.osKeychain': '操作系统密钥链', 'keyring.settings.mode.encryptedFile': '本地加密', 'keyring.settings.mode.consentPending': '未配置', 'keyring.settings.mode.declined': '已拒绝', 'keyring.settings.availability': '密钥链可用性', 'keyring.settings.available': '操作系统密钥链可用', 'keyring.settings.unavailable': '操作系统密钥链不可用', - 'keyring.settings.backend': 'Backend', + 'keyring.settings.backend': '后端', 'keyring.settings.retryButton': '重试密钥链检测', 'keyring.settings.retryFailed': '重试失败。密钥链仍然不可用。', 'keyring.settings.consentTitle': '存储同意', diff --git a/src/openhuman/app_state/schemas.rs b/src/openhuman/app_state/schemas.rs index eaa6a2b9cd..15ee85e2ca 100644 --- a/src/openhuman/app_state/schemas.rs +++ b/src/openhuman/app_state/schemas.rs @@ -166,7 +166,7 @@ mod tests { let s = app_state_schemas("update_local_state"); assert_eq!(s.namespace, "app_state"); assert_eq!(s.function, "update_local_state"); - assert_eq!(s.inputs.len(), 2); + assert_eq!(s.inputs.len(), 3); for input in &s.inputs { assert!(!input.required, "input '{}' should be optional", input.name); } diff --git a/src/openhuman/credentials/profiles.rs b/src/openhuman/credentials/profiles.rs index 8fd5872b53..ae4671c075 100644 --- a/src/openhuman/credentials/profiles.rs +++ b/src/openhuman/credentials/profiles.rs @@ -211,10 +211,28 @@ impl AuthProfilesStore { "[auth] AuthProfilesStore::new state_dir={} user_id={user_id} use_keychain={use_keychain} policy={policy:?}", state_dir.display() ); - if !use_keychain { - log::info!( - "[auth] keychain unavailable or consent pending — using encrypted JSON for auth profiles user_id={user_id} policy={policy:?}" - ); + match policy { + crate::openhuman::keyring_consent::PolicyDecision::Proceed => { + if !use_keychain { + // OS keychain unavailable despite Proceed policy (probe failed). + log::info!( + "[auth] OS keychain unavailable — using encrypted JSON for auth profiles user_id={user_id}" + ); + } + } + crate::openhuman::keyring_consent::PolicyDecision::ConsentRequired => { + log::warn!( + "[auth] keyring consent has not been given — secrets will NOT be persisted \ + to the OS keychain until the user grants consent. \ + Falling back to encrypted JSON for auth profiles user_id={user_id}" + ); + } + crate::openhuman::keyring_consent::PolicyDecision::Declined => { + log::warn!( + "[auth] user explicitly declined OS keychain storage — \ + using encrypted JSON for auth profiles user_id={user_id}" + ); + } } Self { path: state_dir.join(PROFILES_FILENAME), diff --git a/src/openhuman/keyring/ops.rs b/src/openhuman/keyring/ops.rs index 2310bf1956..fe361de011 100644 --- a/src/openhuman/keyring/ops.rs +++ b/src/openhuman/keyring/ops.rs @@ -3,19 +3,26 @@ //! All public functions delegate to the active backend selected by [`crate::openhuman::keyring::store`]. use std::path::Path; -use std::sync::atomic::{AtomicBool, Ordering}; use chacha20poly1305::aead::{rand_core::RngCore, OsRng}; -use parking_lot::RwLock; +use parking_lot::Mutex; use crate::openhuman::keyring::error::KeyringError; use crate::openhuman::keyring::store::backend; -// Cached result of the keychain probe. Uses RwLock> instead of -// OnceLock so the cache can be reset for retry-probe flows (the user retries -// keychain access from Settings after granting OS permission). -static AVAILABILITY_CACHE: RwLock> = RwLock::new(None); -static AVAILABILITY_PROBED: AtomicBool = AtomicBool::new(false); +// Cached result of the keychain probe. A single Mutex> is used +// instead of a separate AtomicBool + RwLock pair to eliminate the race where +// thread A sets AVAILABILITY_PROBED=true before writing the result, causing +// thread B to read None from the cache and incorrectly return false. +// +// With a single Mutex the first thread to acquire it runs the probe and stores +// the result; all other threads block on the Mutex until the result is ready, +// then read it on the same lock acquisition. +// +// The Mutex is never held across async suspension points so contention is +// bounded to the probe duration (a single keychain round-trip on the first +// call, a pointer read on every subsequent call). +static AVAILABILITY_CACHE: Mutex> = Mutex::new(None); // ── Outcome type ───────────────────────────────────────────────────────────── @@ -96,20 +103,15 @@ pub fn delete(user_id: &str, key: &str) -> Result<(), KeyringError> { /// the macOS access-permission dialogs they trigger) when polled by /// wallet guards or snapshot loops. pub fn is_available() -> bool { - { - let cached = AVAILABILITY_CACHE.read(); - if let Some(val) = *cached { - return val; - } - } - if !AVAILABILITY_PROBED.swap(true, Ordering::SeqCst) { - let result = probe_availability(); - *AVAILABILITY_CACHE.write() = Some(result); - result - } else { - let cached = AVAILABILITY_CACHE.read(); - cached.unwrap_or(false) + let mut cached = AVAILABILITY_CACHE.lock(); + if let Some(val) = *cached { + return val; } + // First caller: run the probe under the lock so concurrent callers block + // until the result is ready rather than racing on a separate atomic flag. + let result = probe_availability(); + *cached = Some(result); + result } /// Reset the cached probe result so the next [`is_available`] call re-runs @@ -117,8 +119,7 @@ pub fn is_available() -> bool { /// keychain access from Settings. pub fn reset_availability_cache() { log::info!("[keyring] reset_availability_cache: clearing cached probe result"); - AVAILABILITY_PROBED.store(false, Ordering::SeqCst); - *AVAILABILITY_CACHE.write() = None; + *AVAILABILITY_CACHE.lock() = None; } /// Returns the name of the active keyring backend (e.g. `"os"`, `"file"`, diff --git a/src/openhuman/keyring_consent/ops.rs b/src/openhuman/keyring_consent/ops.rs index 8c2b2a3349..c1606f6394 100644 --- a/src/openhuman/keyring_consent/ops.rs +++ b/src/openhuman/keyring_consent/ops.rs @@ -26,10 +26,16 @@ pub async fn keyring_consent_decide(mode: String) -> Result KeyringStatus { } } -/// Record the user's consent decision: update the in-memory cache and return -/// the preference for the RPC caller to persist via `update_local_state`. -pub fn record_consent(mode: &str) -> ConsentPreference { +/// Build a consent preference value without touching the in-memory cache. +/// +/// Callers that need to persist before caching should use this together with +/// [`apply_consent`]: build → persist → apply. This ordering ensures the cache +/// and disk never diverge (if persistence fails the cache is not updated). +pub fn build_consent_preference(mode: &str) -> ConsentPreference { let now_ms = SystemTime::now() .duration_since(UNIX_EPOCH) .map(|d| d.as_millis() as u64) .unwrap_or(0); - - let pref = ConsentPreference { + ConsentPreference { storage_mode: mode.to_string(), consented_at_ms: Some(now_ms), - }; - - info!("{LOG_PREFIX} record_consent mode={mode} at_ms={now_ms}"); + } +} +/// Apply a previously-built consent preference to the in-memory cache. +/// +/// Call this only after the preference has been successfully persisted to disk. +pub fn apply_consent(pref: &ConsentPreference) { + info!( + "{LOG_PREFIX} apply_consent mode={} at_ms={}", + pref.storage_mode, + pref.consented_at_ms.unwrap_or(0), + ); *CONSENT_CACHE.write() = Some(pref.clone()); CONSENT_EVENT_PUBLISHED.store(false, Ordering::SeqCst); +} +/// Record the user's consent decision: update the in-memory cache and return +/// the preference for the RPC caller to persist via `update_local_state`. +/// +/// Prefer the [`build_consent_preference`] + [`apply_consent`] pair when you +/// need to guarantee persistence happens before the cache is updated. +pub fn record_consent(mode: &str) -> ConsentPreference { + let pref = build_consent_preference(mode); + info!( + "{LOG_PREFIX} record_consent mode={mode} at_ms={}", + pref.consented_at_ms.unwrap_or(0) + ); + apply_consent(&pref); pref }