diff --git a/Cargo.lock b/Cargo.lock index 2e6436341..33d5a2986 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5171,6 +5171,7 @@ dependencies = [ "futures-util", "glob", "hex", + "hkdf", "hmac 0.12.1", "hostname", "hound", diff --git a/Cargo.toml b/Cargo.toml index 6cb061612..b10aa85a1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -83,6 +83,7 @@ anyhow = "1.0" async-trait = "0.1" chacha20poly1305 = "0.10" x25519-dalek = { version = "2", features = ["static_secrets"] } +hkdf = "0.12" hex = "0.4" tokio-util = { version = "0.7", features = ["rt", "io"] } # tokio-tungstenite is declared per-target below so the TLS backend diff --git a/app/src/App.tsx b/app/src/App.tsx index 3fd7a3c8c..e83d1da95 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -18,6 +18,7 @@ import LocalAIDownloadSnackbar from './components/LocalAIDownloadSnackbar'; import SecretPromptDialog from './components/mcp-setup/SecretPromptDialog'; import OpenhumanLinkModal from './components/OpenhumanLinkModal'; import PersistRehydrationScreen from './components/PersistRehydrationScreen'; +import SecurityBanner from './components/SecurityBanner'; import GlobalUpsellBanner from './components/upsell/GlobalUpsellBanner'; import AppWalkthrough from './components/walkthrough/AppWalkthrough'; import { MascotFrameProducer } from './features/meet/MascotFrameProducer'; @@ -105,6 +106,7 @@ function App() { + {!onMobile && } {!onMobile && } {!onMobile && } diff --git a/app/src/components/SecurityBanner.tsx b/app/src/components/SecurityBanner.tsx new file mode 100644 index 000000000..698e89ec5 --- /dev/null +++ b/app/src/components/SecurityBanner.tsx @@ -0,0 +1,129 @@ +/** + * Security banner — surfaces the host-aware approval-gate boot state to the + * user. Reads `openhuman.approval_get_gate_state` on mount and renders one of + * two banners: + * + * - Persistent red banner when `disabledByEnv === true` — operator set + * `OPENHUMAN_APPROVAL_GATE=0` on a standalone host (CLI / Docker) and the + * gate is actually OFF. External-effect tool calls will run unprompted. + * + * - One-shot yellow info banner when `overrideIgnored === true` — the same + * env override was attempted under the Tauri desktop shell, which always + * rejects it. The user sees that their attempt was blocked, but the gate + * is still on. Auto-dismisses after 10 s. + * + * Otherwise renders nothing — the steady-state desktop boot path is silent. + * + * Mounted near the top of the provider chain in `App.tsx` so it surfaces + * regardless of route. + */ +import { useEffect, useState } from 'react'; + +import { useT } from '../lib/i18n/I18nContext'; +import { type ApprovalGateBootState, fetchApprovalGateState } from '../services/api/approvalApi'; + +const OVERRIDE_IGNORED_AUTO_DISMISS_MS = 10_000; + +interface SecurityBannerProps { + /** Override the fetcher for tests; defaults to the live RPC client. */ + fetchState?: () => Promise; +} + +const SecurityBanner = ({ fetchState = fetchApprovalGateState }: SecurityBannerProps) => { + const { t } = useT(); + const [state, setState] = useState(null); + const [dismissedIgnored, setDismissedIgnored] = useState(false); + + useEffect(() => { + let cancelled = false; + fetchState() + .then(s => { + if (!cancelled) setState(s); + }) + .catch(() => { + // Silently ignore — the fetcher already returns a benign default + // on RPC failure. A degraded core must never blank the app shell. + }); + return () => { + cancelled = true; + }; + }, [fetchState]); + + // Auto-dismiss the override-ignored info banner after the timeout — it's a + // one-shot acknowledgement, not a persistent warning. The persistent red + // banner for `disabledByEnv` does NOT auto-dismiss; the user has to act + // (restart with the env unset) to clear it. + useEffect(() => { + if (!state?.overrideIgnored) return; + const id = window.setTimeout(() => setDismissedIgnored(true), OVERRIDE_IGNORED_AUTO_DISMISS_MS); + return () => { + window.clearTimeout(id); + }; + }, [state?.overrideIgnored]); + + if (!state) return null; + + // Order matters: when both flags are somehow true (shouldn't happen given + // the Rust decision tree is mutually exclusive, but defend in depth), the + // persistent disabled banner wins — it's the higher-severity message. + if (state.disabledByEnv) { + return ( +
+ {t('security.approvalGateDisabled.title')} + {t('security.approvalGateDisabled.body')} +
+ ); + } + + if (state.overrideIgnored && !dismissedIgnored) { + return ( +
+ + {t('security.approvalGateOverrideIgnored.title')} + + {t('security.approvalGateOverrideIgnored.body')} +
+ ); + } + + return null; +}; + +export default SecurityBanner; diff --git a/app/src/components/__tests__/SecurityBanner.test.tsx b/app/src/components/__tests__/SecurityBanner.test.tsx new file mode 100644 index 000000000..dc767ec43 --- /dev/null +++ b/app/src/components/__tests__/SecurityBanner.test.tsx @@ -0,0 +1,121 @@ +/** + * Tests for the SecurityBanner. + * + * Confirms the host-aware approval-gate boot state surfaces the right banner: + * - `disabledByEnv` renders the persistent red banner. + * - `overrideIgnored` renders the one-shot yellow info banner that auto-dismisses. + * - Steady-state (installed, no override) renders nothing. + */ +import { configureStore } from '@reduxjs/toolkit'; +import { act, render, screen, waitFor } from '@testing-library/react'; +import { Provider } from 'react-redux'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { I18nProvider } from '../../lib/i18n/I18nContext'; +import type { ApprovalGateBootState } from '../../services/api/approvalApi'; +import localeReducer from '../../store/localeSlice'; +import SecurityBanner from '../SecurityBanner'; + +function renderBanner(state: ApprovalGateBootState | Promise) { + const fetchState = vi + .fn() + .mockReturnValue(state instanceof Promise ? state : Promise.resolve(state)); + const store = configureStore({ + reducer: { locale: localeReducer }, + preloadedState: { locale: { current: 'en' as const } }, + }); + return { + fetchState, + ...render( + + + + + + ), + }; +} + +describe('SecurityBanner', () => { + beforeEach(() => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.clearAllMocks(); + }); + + it('renders nothing on the steady-state desktop boot path', async () => { + const { fetchState } = renderBanner({ + installed: true, + disabledByEnv: false, + overrideIgnored: false, + host: 'tauri-shell', + }); + + await waitFor(() => expect(fetchState).toHaveBeenCalledTimes(1)); + + expect(screen.queryByTestId('security-banner-gate-disabled')).toBeNull(); + expect(screen.queryByTestId('security-banner-override-ignored')).toBeNull(); + }); + + it('renders the persistent red banner when the gate is disabled by env override', async () => { + renderBanner({ installed: false, disabledByEnv: true, overrideIgnored: false, host: 'cli' }); + + const banner = await screen.findByTestId('security-banner-gate-disabled'); + expect(banner).toBeInTheDocument(); + expect(banner.getAttribute('role')).toBe('alert'); + }); + + it('renders the one-shot info banner when the env override was ignored under desktop shell', async () => { + renderBanner({ + installed: true, + disabledByEnv: false, + overrideIgnored: true, + host: 'tauri-shell', + }); + + const banner = await screen.findByTestId('security-banner-override-ignored'); + expect(banner).toBeInTheDocument(); + expect(banner.getAttribute('role')).toBe('status'); + }); + + it('auto-dismisses the override-ignored info banner after 10 seconds', async () => { + renderBanner({ + installed: true, + disabledByEnv: false, + overrideIgnored: true, + host: 'tauri-shell', + }); + + await screen.findByTestId('security-banner-override-ignored'); + + await act(async () => { + vi.advanceTimersByTime(10_000); + }); + + expect(screen.queryByTestId('security-banner-override-ignored')).toBeNull(); + }); + + it('does NOT auto-dismiss the persistent disabled banner', async () => { + renderBanner({ installed: false, disabledByEnv: true, overrideIgnored: false, host: 'cli' }); + + await screen.findByTestId('security-banner-gate-disabled'); + + await act(async () => { + vi.advanceTimersByTime(60_000); + }); + + expect(screen.queryByTestId('security-banner-gate-disabled')).toBeInTheDocument(); + }); + + it('renders nothing on RPC failure (degraded core must not blank the app shell)', async () => { + const { fetchState } = renderBanner(Promise.reject(new Error('rpc failed'))); + + await waitFor(() => expect(fetchState).toHaveBeenCalledTimes(1)); + + expect(screen.queryByTestId('security-banner-gate-disabled')).toBeNull(); + expect(screen.queryByTestId('security-banner-override-ignored')).toBeNull(); + }); +}); diff --git a/app/src/lib/i18n/ar.ts b/app/src/lib/i18n/ar.ts index 8e8ed9fee..2d3bf1125 100644 --- a/app/src/lib/i18n/ar.ts +++ b/app/src/lib/i18n/ar.ts @@ -4662,6 +4662,13 @@ const messages: TranslationMap = { 'memoryData.windowError': 'نافذة الذاكرة', 'memoryData.windowUpdated': 'تم تحديث نافذة الذاكرة', 'memoryData.windowUpdatedMsg': 'تم الضبط على {window}.', + // Security banner (approval-gate host-aware boot state) + 'security.approvalGateDisabled.title': 'تم تعطيل بوابة الموافقة', + 'security.approvalGateDisabled.body': + 'تم تعيين OPENHUMAN_APPROVAL_GATE=0 في بيئتك. ستعمل الأدوات ذات التأثير الخارجي دون طلب تأكيد.', + 'security.approvalGateOverrideIgnored.title': 'تم حظر التجاوز', + 'security.approvalGateOverrideIgnored.body': + 'تم اكتشاف تجاوز OPENHUMAN_APPROVAL_GATE=0 لكنه تم تجاهله: يحافظ تطبيق سطح المكتب دائمًا على تفعيل بوابة الموافقة.', // Run queue 'runQueue.mode.interrupt': 'مقاطعة', diff --git a/app/src/lib/i18n/bn.ts b/app/src/lib/i18n/bn.ts index bfd8673a6..d67f82e50 100644 --- a/app/src/lib/i18n/bn.ts +++ b/app/src/lib/i18n/bn.ts @@ -4752,6 +4752,13 @@ const messages: TranslationMap = { 'memoryData.windowError': 'মেমোরি উইন্ডো', 'memoryData.windowUpdated': 'মেমোরি উইন্ডো আপডেট হয়েছে', 'memoryData.windowUpdatedMsg': '{window}-এ সেট করা হয়েছে।', + // Security banner (approval-gate host-aware boot state) + 'security.approvalGateDisabled.title': 'অনুমোদন গেট নিষ্ক্রিয়', + 'security.approvalGateDisabled.body': + 'আপনার পরিবেশে OPENHUMAN_APPROVAL_GATE=0 সেট করা আছে। বাহ্যিক প্রভাব সম্পন্ন টুলগুলি নিশ্চিতকরণ ছাড়াই চলবে।', + 'security.approvalGateOverrideIgnored.title': 'ওভাররাইড অবরুদ্ধ', + 'security.approvalGateOverrideIgnored.body': + 'একটি OPENHUMAN_APPROVAL_GATE=0 ওভাররাইড সনাক্ত করা হয়েছিল কিন্তু উপেক্ষা করা হয়েছে: ডেস্কটপ অ্যাপ সর্বদা অনুমোদন গেট চালু রাখে।', // Run queue 'runQueue.mode.interrupt': 'বাধা দিন', diff --git a/app/src/lib/i18n/de.ts b/app/src/lib/i18n/de.ts index b705b8358..fcd654fff 100644 --- a/app/src/lib/i18n/de.ts +++ b/app/src/lib/i18n/de.ts @@ -4887,6 +4887,13 @@ const messages: TranslationMap = { 'memoryData.windowError': 'Speicherfenster', 'memoryData.windowUpdated': 'Speicherfenster aktualisiert', 'memoryData.windowUpdatedMsg': 'Auf {window} gesetzt.', + // Security banner (approval-gate host-aware boot state) + 'security.approvalGateDisabled.title': 'Genehmigungstor deaktiviert', + 'security.approvalGateDisabled.body': + 'OPENHUMAN_APPROVAL_GATE=0 ist in deiner Umgebung gesetzt. Tools mit externer Wirkung werden ohne Bestätigungsabfrage ausgeführt.', + 'security.approvalGateOverrideIgnored.title': 'Override blockiert', + 'security.approvalGateOverrideIgnored.body': + 'Ein OPENHUMAN_APPROVAL_GATE=0-Override wurde erkannt, aber ignoriert: Die Desktop-App lässt das Genehmigungstor immer eingeschaltet.', // Run queue 'runQueue.mode.interrupt': 'Unterbrechen', diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts index ec033e153..cacb9fdfa 100644 --- a/app/src/lib/i18n/en.ts +++ b/app/src/lib/i18n/en.ts @@ -4999,6 +4999,14 @@ const en: TranslationMap = { 'monthlyCost.badge': '${amount} this month', 'monthlyCost.noData': 'No syncs this month', + // Security banner (approval-gate host-aware boot state) + 'security.approvalGateDisabled.title': 'Approval gate disabled', + 'security.approvalGateDisabled.body': + 'OPENHUMAN_APPROVAL_GATE=0 is set in your environment. External-effect tools will run without asking for confirmation.', + 'security.approvalGateOverrideIgnored.title': 'Override blocked', + 'security.approvalGateOverrideIgnored.body': + 'An OPENHUMAN_APPROVAL_GATE=0 override was detected but ignored: the desktop app always keeps the approval gate on.', + // Run queue 'runQueue.mode.interrupt': 'Interrupt', 'runQueue.mode.steer': 'Steer', diff --git a/app/src/lib/i18n/es.ts b/app/src/lib/i18n/es.ts index 96ee3d78a..e69a212ec 100644 --- a/app/src/lib/i18n/es.ts +++ b/app/src/lib/i18n/es.ts @@ -4854,6 +4854,13 @@ const messages: TranslationMap = { 'memoryData.windowError': 'Ventana de memoria', 'memoryData.windowUpdated': 'Ventana de memoria actualizada', 'memoryData.windowUpdatedMsg': 'Establecida en {window}.', + // Security banner (approval-gate host-aware boot state) + 'security.approvalGateDisabled.title': 'Puerta de aprobación desactivada', + 'security.approvalGateDisabled.body': + 'OPENHUMAN_APPROVAL_GATE=0 está configurado en tu entorno. Las herramientas con efectos externos se ejecutarán sin pedir confirmación.', + 'security.approvalGateOverrideIgnored.title': 'Anulación bloqueada', + 'security.approvalGateOverrideIgnored.body': + 'Se detectó una anulación OPENHUMAN_APPROVAL_GATE=0, pero se ignoró: la aplicación de escritorio mantiene siempre activa la puerta de aprobación.', // Run queue 'runQueue.mode.interrupt': 'Interrumpir', diff --git a/app/src/lib/i18n/fr.ts b/app/src/lib/i18n/fr.ts index 8ac8f1c51..49b9abb6d 100644 --- a/app/src/lib/i18n/fr.ts +++ b/app/src/lib/i18n/fr.ts @@ -4869,6 +4869,13 @@ const messages: TranslationMap = { 'memoryData.windowError': 'Fenêtre de mémoire', 'memoryData.windowUpdated': 'Fenêtre de mémoire mise à jour', 'memoryData.windowUpdatedMsg': 'Définie sur {window}.', + // Security banner (approval-gate host-aware boot state) + 'security.approvalGateDisabled.title': "Porte d'approbation désactivée", + 'security.approvalGateDisabled.body': + "OPENHUMAN_APPROVAL_GATE=0 est défini dans votre environnement. Les outils à effet externe s'exécuteront sans demander de confirmation.", + 'security.approvalGateOverrideIgnored.title': 'Contournement bloqué', + 'security.approvalGateOverrideIgnored.body': + "Un contournement OPENHUMAN_APPROVAL_GATE=0 a été détecté mais ignoré : l'application de bureau garde toujours la porte d'approbation activée.", // Run queue 'runQueue.mode.interrupt': 'Interrompre', diff --git a/app/src/lib/i18n/hi.ts b/app/src/lib/i18n/hi.ts index ca1eb50bb..7454ef2a7 100644 --- a/app/src/lib/i18n/hi.ts +++ b/app/src/lib/i18n/hi.ts @@ -4759,6 +4759,13 @@ const messages: TranslationMap = { 'memoryData.windowError': 'मेमोरी विंडो', 'memoryData.windowUpdated': 'मेमोरी विंडो अपडेट हुई', 'memoryData.windowUpdatedMsg': '{window} पर सेट किया गया।', + // Security banner (approval-gate host-aware boot state) + 'security.approvalGateDisabled.title': 'अनुमोदन गेट अक्षम', + 'security.approvalGateDisabled.body': + 'आपके परिवेश में OPENHUMAN_APPROVAL_GATE=0 सेट है। बाहरी प्रभाव वाले टूल पुष्टि माँगे बिना चलेंगे।', + 'security.approvalGateOverrideIgnored.title': 'ओवरराइड अवरुद्ध', + 'security.approvalGateOverrideIgnored.body': + 'एक OPENHUMAN_APPROVAL_GATE=0 ओवरराइड का पता चला लेकिन अनदेखा कर दिया गया: डेस्कटॉप ऐप अनुमोदन गेट को हमेशा चालू रखता है।', // Run queue 'runQueue.mode.interrupt': 'बाधित करें', diff --git a/app/src/lib/i18n/id.ts b/app/src/lib/i18n/id.ts index 5504c1c48..71e0226cd 100644 --- a/app/src/lib/i18n/id.ts +++ b/app/src/lib/i18n/id.ts @@ -4771,6 +4771,13 @@ const messages: TranslationMap = { 'memoryData.windowError': 'Jendela memori', 'memoryData.windowUpdated': 'Jendela memori diperbarui', 'memoryData.windowUpdatedMsg': 'Diatur ke {window}.', + // Security banner (approval-gate host-aware boot state) + 'security.approvalGateDisabled.title': 'Gerbang persetujuan dinonaktifkan', + 'security.approvalGateDisabled.body': + 'OPENHUMAN_APPROVAL_GATE=0 disetel di lingkungan Anda. Alat dengan efek eksternal akan berjalan tanpa meminta konfirmasi.', + 'security.approvalGateOverrideIgnored.title': 'Override diblokir', + 'security.approvalGateOverrideIgnored.body': + 'Override OPENHUMAN_APPROVAL_GATE=0 terdeteksi tetapi diabaikan: aplikasi desktop selalu menjaga gerbang persetujuan tetap aktif.', // Run queue 'runQueue.mode.interrupt': 'Interupsi', diff --git a/app/src/lib/i18n/it.ts b/app/src/lib/i18n/it.ts index 0fd113f59..8c9176741 100644 --- a/app/src/lib/i18n/it.ts +++ b/app/src/lib/i18n/it.ts @@ -4844,6 +4844,13 @@ const messages: TranslationMap = { 'memoryData.windowError': 'Finestra di memoria', 'memoryData.windowUpdated': 'Finestra di memoria aggiornata', 'memoryData.windowUpdatedMsg': 'Impostata su {window}.', + // Security banner (approval-gate host-aware boot state) + 'security.approvalGateDisabled.title': 'Porta di approvazione disattivata', + 'security.approvalGateDisabled.body': + 'OPENHUMAN_APPROVAL_GATE=0 è impostato nel tuo ambiente. Gli strumenti con effetti esterni verranno eseguiti senza chiedere conferma.', + 'security.approvalGateOverrideIgnored.title': 'Override bloccato', + 'security.approvalGateOverrideIgnored.body': + "È stato rilevato un override OPENHUMAN_APPROVAL_GATE=0 ma è stato ignorato: l'app desktop mantiene sempre attiva la porta di approvazione.", // Run queue 'runQueue.mode.interrupt': 'Interrompi', diff --git a/app/src/lib/i18n/ko.ts b/app/src/lib/i18n/ko.ts index 059104d37..15b2ebc46 100644 --- a/app/src/lib/i18n/ko.ts +++ b/app/src/lib/i18n/ko.ts @@ -4707,6 +4707,13 @@ const messages: TranslationMap = { 'memoryData.windowError': '메모리 창', 'memoryData.windowUpdated': '메모리 창 업데이트됨', 'memoryData.windowUpdatedMsg': '{window}(으)로 설정되었습니다.', + // Security banner (approval-gate host-aware boot state) + 'security.approvalGateDisabled.title': '승인 게이트 비활성화됨', + 'security.approvalGateDisabled.body': + '환경에 OPENHUMAN_APPROVAL_GATE=0이 설정되어 있습니다. 외부 영향이 있는 도구가 확인을 요청하지 않고 실행됩니다.', + 'security.approvalGateOverrideIgnored.title': '재정의 차단됨', + 'security.approvalGateOverrideIgnored.body': + 'OPENHUMAN_APPROVAL_GATE=0 재정의가 감지되었지만 무시되었습니다: 데스크톱 앱은 승인 게이트를 항상 켜둡니다.', // Run queue 'runQueue.mode.interrupt': '중단', diff --git a/app/src/lib/i18n/pl.ts b/app/src/lib/i18n/pl.ts index c613cc7a5..b44d25e0d 100644 --- a/app/src/lib/i18n/pl.ts +++ b/app/src/lib/i18n/pl.ts @@ -4835,6 +4835,13 @@ const messages: TranslationMap = { 'memoryData.windowError': 'Okno pamięci', 'memoryData.windowUpdated': 'Okno pamięci zaktualizowane', 'memoryData.windowUpdatedMsg': 'Ustawiono na {window}.', + // Security banner (approval-gate host-aware boot state) + 'security.approvalGateDisabled.title': 'Bramka zatwierdzania wyłączona', + 'security.approvalGateDisabled.body': + 'W twoim środowisku ustawiono OPENHUMAN_APPROVAL_GATE=0. Narzędzia o zewnętrznym wpływie będą działać bez pytania o potwierdzenie.', + 'security.approvalGateOverrideIgnored.title': 'Nadpisanie zablokowane', + 'security.approvalGateOverrideIgnored.body': + 'Wykryto nadpisanie OPENHUMAN_APPROVAL_GATE=0, ale je zignorowano: aplikacja desktopowa zawsze utrzymuje bramkę zatwierdzania włączoną.', // Run queue 'runQueue.mode.interrupt': 'Przerwij', diff --git a/app/src/lib/i18n/pt.ts b/app/src/lib/i18n/pt.ts index 214e3adab..39594219e 100644 --- a/app/src/lib/i18n/pt.ts +++ b/app/src/lib/i18n/pt.ts @@ -4840,6 +4840,13 @@ const messages: TranslationMap = { 'memoryData.windowError': 'Janela de memória', 'memoryData.windowUpdated': 'Janela de memória atualizada', 'memoryData.windowUpdatedMsg': 'Definida para {window}.', + // Security banner (approval-gate host-aware boot state) + 'security.approvalGateDisabled.title': 'Portão de aprovação desativado', + 'security.approvalGateDisabled.body': + 'OPENHUMAN_APPROVAL_GATE=0 está definido no seu ambiente. Ferramentas com efeito externo serão executadas sem pedir confirmação.', + 'security.approvalGateOverrideIgnored.title': 'Substituição bloqueada', + 'security.approvalGateOverrideIgnored.body': + 'Uma substituição OPENHUMAN_APPROVAL_GATE=0 foi detetada mas ignorada: o aplicativo de desktop mantém sempre o portão de aprovação ativado.', // Run queue 'runQueue.mode.interrupt': 'Interromper', diff --git a/app/src/lib/i18n/ru.ts b/app/src/lib/i18n/ru.ts index 9795ee47e..a7f5933a3 100644 --- a/app/src/lib/i18n/ru.ts +++ b/app/src/lib/i18n/ru.ts @@ -4800,6 +4800,13 @@ const messages: TranslationMap = { 'memoryData.windowError': 'Окно памяти', 'memoryData.windowUpdated': 'Окно памяти обновлено', 'memoryData.windowUpdatedMsg': 'Установлено значение {window}.', + // Security banner (approval-gate host-aware boot state) + 'security.approvalGateDisabled.title': 'Шлюз одобрения отключён', + 'security.approvalGateDisabled.body': + 'В вашей среде установлено OPENHUMAN_APPROVAL_GATE=0. Инструменты с внешним эффектом будут запускаться без запроса подтверждения.', + 'security.approvalGateOverrideIgnored.title': 'Переопределение заблокировано', + 'security.approvalGateOverrideIgnored.body': + 'Обнаружено переопределение OPENHUMAN_APPROVAL_GATE=0, но оно проигнорировано: настольное приложение всегда держит шлюз одобрения включённым.', // Run queue 'runQueue.mode.interrupt': 'Прервать', diff --git a/app/src/lib/i18n/zh-CN.ts b/app/src/lib/i18n/zh-CN.ts index 272f7ceca..e76829e01 100644 --- a/app/src/lib/i18n/zh-CN.ts +++ b/app/src/lib/i18n/zh-CN.ts @@ -4521,6 +4521,13 @@ const messages: TranslationMap = { 'memoryData.windowError': '记忆时间窗口', 'memoryData.windowUpdated': '记忆时间窗口已更新', 'memoryData.windowUpdatedMsg': '已设置为 {window}。', + // Security banner (approval-gate host-aware boot state) + 'security.approvalGateDisabled.title': '审批门已禁用', + 'security.approvalGateDisabled.body': + '您的环境中设置了 OPENHUMAN_APPROVAL_GATE=0。具有外部影响的工具将在不请求确认的情况下运行。', + 'security.approvalGateOverrideIgnored.title': '覆盖已阻止', + 'security.approvalGateOverrideIgnored.body': + '检测到 OPENHUMAN_APPROVAL_GATE=0 覆盖,但已忽略:桌面应用始终保持审批门开启。', // Run queue 'runQueue.mode.interrupt': '中断', diff --git a/app/src/lib/tunnel/crypto.test.ts b/app/src/lib/tunnel/crypto.test.ts index 6b2ca32d7..51037d9d8 100644 --- a/app/src/lib/tunnel/crypto.test.ts +++ b/app/src/lib/tunnel/crypto.test.ts @@ -6,13 +6,19 @@ import { describe, expect, it } from 'vitest'; import { base64urlDecode, base64urlEncode, + deriveSessionKeys, deriveSharedSecret, + FRAME_VERSION, generateKeypair, + HKDF_INFO_C2S, + HKDF_INFO_S2C, + LEGACY_FRAME_VERSION_V1, open, openHandshake, ReplayTracker, seal, sealHandshake, + TunnelCipher, } from './crypto'; // -- base64url helpers ------------------------------------------------------- @@ -147,6 +153,97 @@ describe('sealHandshake / openHandshake', () => { }); }); +// -- HKDF directional subkeys + frame v2 ------------------------------------- + +describe('deriveSessionKeys', () => { + const staticDh = new Uint8Array(32).fill(0x11); + const ephDh = new Uint8Array(32).fill(0x22); + const clientEph = new Uint8Array(32).fill(0x33); + const serverEph = new Uint8Array(32).fill(0x44); + + it('derives distinct directional subkeys for the same IKM+salt', () => { + const keys = deriveSessionKeys(staticDh, ephDh, clientEph, serverEph); + expect(keys.c2s).toHaveLength(32); + expect(keys.s2c).toHaveLength(32); + expect(Array.from(keys.c2s)).not.toEqual(Array.from(keys.s2c)); + }); + + it('is deterministic across re-derivation', () => { + const a = deriveSessionKeys(staticDh, ephDh, clientEph, serverEph); + const b = deriveSessionKeys(staticDh, ephDh, clientEph, serverEph); + expect(Array.from(a.c2s)).toEqual(Array.from(b.c2s)); + expect(Array.from(a.s2c)).toEqual(Array.from(b.s2c)); + }); + + it('pins the HKDF info tags so a rename fails loudly', () => { + // The exact byte strings are part of the wire contract with the Rust + // peer; if these change, peers fail to derive the same session keys + // even though the underlying secret is identical. + expect(new TextDecoder().decode(HKDF_INFO_C2S)).toBe('openhuman-tunnel/v1/c2s'); + expect(new TextDecoder().decode(HKDF_INFO_S2C)).toBe('openhuman-tunnel/v1/s2c'); + }); + + it('rejects DH inputs that are not 32 bytes', () => { + expect(() => deriveSessionKeys(new Uint8Array(31), ephDh, clientEph, serverEph)).toThrow( + /32 bytes/i + ); + expect(() => deriveSessionKeys(staticDh, ephDh, new Uint8Array(0), serverEph)).toThrow( + /32 bytes/i + ); + }); + + it('static-only adversary cannot recover prior session keys (FS sanity)', () => { + const a = deriveSessionKeys(staticDh, new Uint8Array(32).fill(0xaa), clientEph, serverEph); + const b = deriveSessionKeys(staticDh, new Uint8Array(32).fill(0xbb), clientEph, serverEph); + expect(Array.from(a.c2s)).not.toEqual(Array.from(b.c2s)); + expect(Array.from(a.s2c)).not.toEqual(Array.from(b.s2c)); + }); +}); + +describe('TunnelCipher (directional v2)', () => { + const keys = deriveSessionKeys( + new Uint8Array(32).fill(0x55), + new Uint8Array(32).fill(0x66), + new Uint8Array(32).fill(0x77), + new Uint8Array(32).fill(0x88) + ); + + it('client→server round-trips', () => { + const client = new TunnelCipher('client', keys); + const server = new TunnelCipher('server', keys); + const plaintext = new TextEncoder().encode('hi from client'); + const frame = client.seal(plaintext); + expect(frame[0]).toBe(FRAME_VERSION); + const recovered = server.open(frame); + expect(Array.from(recovered)).toEqual(Array.from(plaintext)); + }); + + it('server→client round-trips', () => { + const client = new TunnelCipher('client', keys); + const server = new TunnelCipher('server', keys); + const plaintext = new TextEncoder().encode('hi from server'); + const frame = server.seal(plaintext); + const recovered = client.open(frame); + expect(Array.from(recovered)).toEqual(Array.from(plaintext)); + }); + + it('cross-direction reflection fails (server cannot decrypt its own outbound frame)', () => { + const server = new TunnelCipher('server', keys); + const serverOpener = new TunnelCipher('server', keys); + const frame = server.seal(new TextEncoder().encode('frame from server')); + expect(() => serverOpener.open(frame)).toThrow(/authentication failed/i); + }); + + it('legacy v1 frames are explicitly rejected with re-pair hint', () => { + // Hand-roll a v1-shaped frame: 0x01 || nonce(24) || ct(16 bytes). + const v1Frame = new Uint8Array(1 + 24 + 16); + v1Frame[0] = LEGACY_FRAME_VERSION_V1; + const client = new TunnelCipher('client', keys); + expect(() => client.open(v1Frame)).toThrow(/UnsupportedFrameVersion/); + expect(() => client.open(v1Frame)).toThrow(/re-pair/); + }); +}); + // -- ReplayTracker ----------------------------------------------------------- describe('ReplayTracker', () => { diff --git a/app/src/lib/tunnel/crypto.ts b/app/src/lib/tunnel/crypto.ts index 32db8191f..9aac31130 100644 --- a/app/src/lib/tunnel/crypto.ts +++ b/app/src/lib/tunnel/crypto.ts @@ -1,17 +1,26 @@ /** - * Tunnel crypto: X25519 key agreement + XChaCha20-Poly1305 frame encryption. + * Tunnel crypto: X25519 key agreement + HKDF-SHA256 directional subkeys + + * XChaCha20-Poly1305 frame encryption. * - * Wire format (encrypted frame): - * version(1=0x01) || nonce(24) || ciphertext+tag + * Wire format (encrypted frame, v2): + * version(1=0x02) || nonce(24) || ciphertext+tag * - * Sealed-handshake format (device → core, first frame): - * 0x01 || eph_pub(32) || nonce(24) || ciphertext+tag + * Frames produced with the previous `version=0x01` shape (single shared + * key, same key in both directions, no KDF) are no longer accepted. Peers + * see a distinctive "re-pair required" error instead of a generic AEAD + * authentication failure. + * + * Sealed-handshake format (device → core, first frame) keeps the v1 byte + * because that flow uses raw XChaCha20Poly1305 outside the post-pairing + * `TunnelCipher` — it's the bootstrap, not the session. * * Mirrors src/openhuman/devices/crypto.rs — keep in sync. */ import { xchacha20poly1305 } from '@noble/ciphers/chacha'; import { randomBytes } from '@noble/ciphers/webcrypto'; import { x25519 } from '@noble/curves/ed25519.js'; +import { hkdf } from '@noble/hashes/hkdf.js'; +import { sha256 } from '@noble/hashes/sha2.js'; import debug from 'debug'; const cryptoLog = debug('crypto'); @@ -19,11 +28,19 @@ const cryptoErr = debug('crypto:error'); // -- constants --------------------------------------------------------------- -const FRAME_VERSION = 0x01; +/** Current frame version. v2 wraps HKDF-derived directional subkeys. */ +export const FRAME_VERSION = 0x02; +/** Previous frame version. Surfaced so callers can recognise the legacy shape. */ +export const LEGACY_FRAME_VERSION_V1 = 0x01; const NONCE_LEN = 24; // XChaCha20-Poly1305 nonce const EPH_PUB_LEN = 32; // X25519 public key const REPLAY_WINDOW = 128; +/** HKDF info tags. Pinned strings — peer implementations MUST use the + * byte-identical values. */ +export const HKDF_INFO_C2S = new TextEncoder().encode('openhuman-tunnel/v1/c2s'); +export const HKDF_INFO_S2C = new TextEncoder().encode('openhuman-tunnel/v1/s2c'); + // -- base64url helpers ------------------------------------------------------- /** Encode bytes to base64url without padding. */ @@ -67,13 +84,105 @@ export function deriveSharedSecret(myPriv: Uint8Array, theirPub: Uint8Array): Ui return shared; } -// -- frame cipher ------------------------------------------------------------ +// -- session-key derivation (HKDF over static + ephemeral DH) ---------------- /** - * Seal `plaintext` into a versioned frame. - * Output: version(1) || nonce(24) || ciphertext+tag + * Two 32-byte directional subkeys derived from the static + ephemeral DH + * pair. `c2s` (client-to-server) is the key the iOS device uses to seal + * outgoing frames; `s2c` is the inverse direction. A frame the server + * emits cannot decrypt under the server's own opener — guards against + * cross-direction reflection. */ -export function seal(key: Uint8Array, plaintext: Uint8Array): Uint8Array { +export interface SessionKeys { + c2s: Uint8Array; + s2c: Uint8Array; +} + +/** + * Derive `(c2s, s2c)` directional subkeys from the static + ephemeral DH + * pair. Mirrors `derive_session_keys` in src/openhuman/devices/crypto.rs: + * + * ikm = static_dh || eph_dh + * salt = client_eph_pub || server_eph_pub + * c2s = HKDF-SHA256(ikm, salt, info = "openhuman-tunnel/v1/c2s", 32) + * s2c = HKDF-SHA256(ikm, salt, info = "openhuman-tunnel/v1/s2c", 32) + * + * `static_dh` authenticates the peer via the long-term paired (QR-code) + * keys; `eph_dh` is negotiated against fresh ephemeral keypairs at session + * start to provide forward secrecy. + */ +export function deriveSessionKeys( + staticDh: Uint8Array, + ephDh: Uint8Array, + clientEphPub: Uint8Array, + serverEphPub: Uint8Array +): SessionKeys { + if (staticDh.length !== 32 || ephDh.length !== 32) { + throw new Error('[crypto] DH inputs must be 32 bytes each'); + } + if (clientEphPub.length !== 32 || serverEphPub.length !== 32) { + throw new Error('[crypto] ephemeral public keys must be 32 bytes each'); + } + const ikm = new Uint8Array(64); + ikm.set(staticDh, 0); + ikm.set(ephDh, 32); + + const salt = new Uint8Array(64); + salt.set(clientEphPub, 0); + salt.set(serverEphPub, 32); + + const c2s = hkdf(sha256, ikm, salt, HKDF_INFO_C2S, 32); + const s2c = hkdf(sha256, ikm, salt, HKDF_INFO_S2C, 32); + + cryptoLog('[crypto] derived directional session keys (HKDF-SHA256)'); + return { c2s, s2c }; +} + +/** Which side of the tunnel is operating the cipher. */ +export type TunnelRole = 'client' | 'server'; + +/** + * Stateful directional cipher mirroring Rust `TunnelCipher::for_role`. Holds + * two XChaCha20-Poly1305 instances: `sealKey` for the local direction and + * `openKey` for the peer's. + */ +export class TunnelCipher { + private readonly sealKey: Uint8Array; + private readonly openKey: Uint8Array; + private readonly replay: ReplayTracker; + + constructor(role: TunnelRole, keys: SessionKeys, replay: ReplayTracker = new ReplayTracker()) { + if (role === 'client') { + this.sealKey = keys.c2s; + this.openKey = keys.s2c; + } else { + this.sealKey = keys.s2c; + this.openKey = keys.c2s; + } + this.replay = replay; + cryptoLog('[crypto] TunnelCipher created role=%s (directional subkeys)', role); + } + + /** + * Seal `plaintext` into a versioned frame. + * Output: version(1=0x02) || nonce(24) || ciphertext+tag. + */ + seal(plaintext: Uint8Array): Uint8Array { + return sealWithKey(this.sealKey, plaintext); + } + + /** + * Open a versioned frame. Throws on version mismatch (including legacy + * v1 frames), replay, or authentication failure. + */ + open(frame: Uint8Array): Uint8Array { + return openWithKey(this.openKey, frame, this.replay); + } +} + +// -- frame cipher ------------------------------------------------------------ + +function sealWithKey(key: Uint8Array, plaintext: Uint8Array): Uint8Array { const nonce = randomBytes(NONCE_LEN); const cipher = xchacha20poly1305(key, nonce); const ciphertext = cipher.encrypt(plaintext); @@ -87,14 +196,15 @@ export function seal(key: Uint8Array, plaintext: Uint8Array): Uint8Array { return frame; } -/** - * Open a versioned frame. - * Throws on version mismatch, replay, or authentication failure. - */ -export function open(key: Uint8Array, frame: Uint8Array, tracker: ReplayTracker): Uint8Array { +function openWithKey(key: Uint8Array, frame: Uint8Array, tracker: ReplayTracker): Uint8Array { if (frame.length === 0) { throw new Error('[crypto] empty frame'); } + if (frame[0] === LEGACY_FRAME_VERSION_V1) { + throw new Error( + '[crypto] UnsupportedFrameVersion: legacy v1 frame rejected — peer must re-pair to upgrade to v2 directional subkeys' + ); + } if (frame[0] !== FRAME_VERSION) { throw new Error(`[crypto] unsupported frame version: 0x${frame[0].toString(16)}`); } @@ -121,6 +231,22 @@ export function open(key: Uint8Array, frame: Uint8Array, tracker: ReplayTracker) } } +/** + * Legacy single-key seal — kept for non-session bootstrap callers (e.g. the + * device-pubkey handshake path in `transport.ts`). New session frames go + * through `TunnelCipher#seal` so they pick up directional subkeys. + */ +export function seal(key: Uint8Array, plaintext: Uint8Array): Uint8Array { + return sealWithKey(key, plaintext); +} + +/** + * Legacy single-key open. See {@link seal} for the rationale. + */ +export function open(key: Uint8Array, frame: Uint8Array, tracker: ReplayTracker): Uint8Array { + return openWithKey(key, frame, tracker); +} + // -- sealed handshake -------------------------------------------------------- /** @@ -139,8 +265,14 @@ export function sealHandshake(corePubkey: Uint8Array, payload: Uint8Array): Uint const ciphertext = cipher.encrypt(payload); // 0x01 || eph_pub(32) || nonce(24) || ciphertext+tag + // NOTE: the layer-2 sealed-handshake byte is intentionally pinned to + // 0x01 (LEGACY_FRAME_VERSION_V1), NOT the current FRAME_VERSION. The + // handshake lives outside the post-pairing session and uses raw + // XChaCha20Poly1305 on the device-pubkey-bearing first frame; the byte + // is a wire marker for `devices/bus.rs::handle_tunnel_frame`, not a + // signal of the post-session key schedule. const frame = new Uint8Array(1 + EPH_PUB_LEN + NONCE_LEN + ciphertext.length); - frame[0] = FRAME_VERSION; + frame[0] = LEGACY_FRAME_VERSION_V1; frame.set(eph.publicKey, 1); frame.set(nonce, 1 + EPH_PUB_LEN); frame.set(ciphertext, 1 + EPH_PUB_LEN + NONCE_LEN); @@ -157,7 +289,8 @@ export function openHandshake(myPriv: Uint8Array, frame: Uint8Array): Uint8Array if (frame.length < 1 + EPH_PUB_LEN + NONCE_LEN + 16) { throw new Error('[crypto] sealed-handshake frame too short'); } - if (frame[0] !== FRAME_VERSION) { + // The layer-2 sealed-handshake byte is pinned to 0x01 — see sealHandshake. + if (frame[0] !== LEGACY_FRAME_VERSION_V1) { throw new Error(`[crypto] bad handshake version: 0x${frame[0].toString(16)}`); } const ephPub = frame.slice(1, 1 + EPH_PUB_LEN); diff --git a/app/src/services/api/approvalApi.ts b/app/src/services/api/approvalApi.ts index b24eef775..32c84519e 100644 --- a/app/src/services/api/approvalApi.ts +++ b/app/src/services/api/approvalApi.ts @@ -83,3 +83,46 @@ export const fetchPendingApprovals = async (): Promise => { const raw = await callCoreRpc({ method: 'openhuman.approval_list_pending' }); return unwrapRows(raw); }; + +/** + * Snapshot of the host-aware approval-gate boot decision. Mirrors the Rust + * `ApprovalGateBootState` struct in `src/openhuman/approval/gate.rs`. + * + * - `installed` — gate was installed at boot and `external_effect` tool calls + * will be intercepted. + * - `disabledByEnv` — operator set `OPENHUMAN_APPROVAL_GATE=0` AND the host + * honored it (CLI / Docker). Gate is OFF; the UI shows the persistent red + * banner. + * - `overrideIgnored` — operator set `OPENHUMAN_APPROVAL_GATE=0` under the + * Tauri desktop shell, which always ignores the override. The UI shows a + * one-shot yellow info banner so the user knows the attempt was rejected. + * - `host` — `"tauri-shell"` / `"cli"` / `"docker"` / `"unknown"` (boot + * state was never recorded — older tests / direct gate spawn paths). + */ +export interface ApprovalGateBootState { + installed: boolean; + disabledByEnv: boolean; + overrideIgnored: boolean; + host: string; +} + +const unwrapValue = (raw: unknown): T => { + if (raw && typeof raw === 'object' && 'result' in (raw as Record)) { + return (raw as { result: T }).result; + } + return raw as T; +}; + +/** + * Fetch the boot-time approval-gate state for the security banner. Returns a + * benign "no banner needed" fallback when the call fails, so a degraded core + * can never blank the whole app shell. + */ +export const fetchApprovalGateState = async (): Promise => { + try { + const raw = await callCoreRpc({ method: 'openhuman.approval_get_gate_state' }); + return unwrapValue(raw); + } catch { + return { installed: true, disabledByEnv: false, overrideIgnored: false, host: 'unknown' }; + } +}; diff --git a/src/core/event_bus/events.rs b/src/core/event_bus/events.rs index 0c25ee8a9..2f88e0815 100644 --- a/src/core/event_bus/events.rs +++ b/src/core/event_bus/events.rs @@ -790,6 +790,30 @@ pub enum DomainEvent { reason: String, }, + /// An `OPENHUMAN_APPROVAL_GATE=0` env override was observed but + /// IGNORED because the host is the Tauri desktop shell. The gate is + /// always installed under the desktop host; this event lets the UI + /// surface a one-shot info banner so the user sees the override was + /// rejected. Audit-only; carries no payload content. + ApprovalGateOverrideIgnored { + /// Host tag (currently always `"tauri-shell"` — added for forward + /// compatibility when more desktop hosts land). + host: String, + }, + /// The approval gate was NOT installed because an + /// `OPENHUMAN_APPROVAL_GATE=0` env override was honored on a + /// standalone host (CLI / Docker). Surfaces the elevated-privilege + /// state so any connected dashboard can flag it; the desktop UI + /// banner subscribes to this variant. + ApprovalGateDisabled { + /// Host tag (`"cli"` or `"docker"`). + host: String, + /// Short reason code so downstream consumers can switch on the + /// cause without parsing free-text logs. Currently always + /// `"env-override"`. + reason: String, + }, + // ── System lifecycle ──────────────────────────────────────────────── /// A system component started up. SystemStartup { component: String }, @@ -1024,7 +1048,10 @@ impl DomainEvent { Self::TaskPlanAwaitingApproval { .. } | Self::TaskRunReclaimed { .. } => "agent", - Self::ApprovalRequested { .. } | Self::ApprovalDecided { .. } => "approval", + Self::ApprovalRequested { .. } + | Self::ApprovalDecided { .. } + | Self::ApprovalGateOverrideIgnored { .. } + | Self::ApprovalGateDisabled { .. } => "approval", Self::ArtifactReady { .. } | Self::ArtifactFailed { .. } @@ -1131,6 +1158,8 @@ impl DomainEvent { Self::SessionExpired { .. } => "SessionExpired", Self::ApprovalRequested { .. } => "ApprovalRequested", Self::ApprovalDecided { .. } => "ApprovalDecided", + Self::ApprovalGateOverrideIgnored { .. } => "ApprovalGateOverrideIgnored", + Self::ApprovalGateDisabled { .. } => "ApprovalGateDisabled", Self::ArtifactReady { .. } => "ArtifactReady", Self::ArtifactFailed { .. } => "ArtifactFailed", Self::ArtifactPending { .. } => "ArtifactPending", diff --git a/src/core/jsonrpc.rs b/src/core/jsonrpc.rs index bab1585e3..adc1c0c18 100644 --- a/src/core/jsonrpc.rs +++ b/src/core/jsonrpc.rs @@ -1652,7 +1652,15 @@ async fn run_server_inner( let app = build_core_http_router(socketio_enabled); // --- Core runtime bootstrap -------------------------------------------- - bootstrap_core_runtime(embedded_core).await; + // Map the legacy `embedded_core` boolean to the typed [`HostKind`] the + // bootstrap path now takes. Embedded == Tauri shell; standalone splits + // CLI / Docker via `HostKind::detect_standalone`. + let host_kind = if embedded_core { + crate::core::types::HostKind::TauriShell + } else { + crate::core::types::HostKind::detect_standalone() + }; + bootstrap_core_runtime(host_kind).await; log::info!( "[core] OpenHuman core is ready — listening on http://{bind_addr} (version {})", @@ -1977,9 +1985,20 @@ fn register_domain_subscribers( } /// Initializes long-lived socket/event-bus infrastructure. -pub async fn bootstrap_core_runtime(embedded_core: bool) { +/// +/// `host_kind` identifies the embedding process (Tauri desktop shell vs +/// standalone CLI / Docker). It drives the approval-gate's host-aware +/// decision tree: under the Tauri shell, the `OPENHUMAN_APPROVAL_GATE=0` +/// env override is ignored and a domain event is published so the UI can +/// surface a banner; under CLI / Docker the override is honored (with a +/// noisy log + a domain event so any connected dashboard can flag it). +pub async fn bootstrap_core_runtime(host_kind: crate::core::types::HostKind) { + use crate::core::types::HostKind; use crate::openhuman::socket::{set_global_socket_manager, SocketManager}; use std::sync::Arc; + // `embedded_core` derived from host_kind so the rest of the function (which + // already keys behavior off the boolean) stays unchanged. + let embedded_core = host_kind.is_desktop_shell(); let cfg = match crate::openhuman::config::Config::load_or_init().await { Ok(cfg) => cfg, Err(e) => { @@ -2068,13 +2087,51 @@ pub async fn bootstrap_core_runtime(embedded_core: bool) { // OS access panel) AND only *interactive chat* turns park — background / // triage / cron turns carry no chat context and pass straight through, so // autonomous automation is never blocked. - if std::env::var("OPENHUMAN_APPROVAL_GATE") + // + // Host-aware override evaluation: under the Tauri desktop shell the env + // override is treated as advisory only — the gate ALWAYS installs and a + // `DomainEvent::ApprovalGateOverrideIgnored` is published so the UI can + // surface a one-shot banner explaining the override was rejected. Under + // standalone CLI / Docker (env-as-config is the operator's chosen + // surface) the override is honored, but a `DomainEvent::ApprovalGateDisabled` + // is still published so any connected dashboard / log shipper can + // surface the elevated-privilege state. + let env_override_requested = std::env::var("OPENHUMAN_APPROVAL_GATE") .map(|v| { let t = v.trim(); - !(t == "0" || t.eq_ignore_ascii_case("false")) + t == "0" || t.eq_ignore_ascii_case("false") }) - .unwrap_or(true) - { + .unwrap_or(false); + let decision = + crate::core::types::approval_gate_boot_decision(host_kind, env_override_requested); + // Record the boot decision before publishing the warning event so the + // first poll of `approval_get_gate_state` after boot reflects the same + // host-aware verdict the event itself describes — no race. + crate::openhuman::approval::gate::record_boot_state( + crate::openhuman::approval::gate::ApprovalGateBootState { + installed: decision.install_gate, + disabled_by_env: decision.gate_disabled_by_override, + override_ignored: decision.override_ignored, + host: match host_kind { + crate::core::types::HostKind::TauriShell => "tauri-shell", + crate::core::types::HostKind::Cli => "cli", + crate::core::types::HostKind::Docker => "docker", + }, + }, + ); + if decision.override_ignored { + log::warn!( + "[runtime] OPENHUMAN_APPROVAL_GATE=0 IGNORED under desktop shell — \ + gate is always on for the Tauri host (host={})", + host_kind.tag() + ); + crate::core::event_bus::publish_global( + crate::core::event_bus::DomainEvent::ApprovalGateOverrideIgnored { + host: host_kind.tag().to_string(), + }, + ); + } + if decision.install_gate { // Per-launch correlation token for the approval gate. This is // a fresh UUID every boot — it is NOT derived from the // JSON-RPC bearer (`OPENHUMAN_CORE_TOKEN` / the in-memory @@ -2099,9 +2156,16 @@ pub async fn bootstrap_core_runtime(embedded_core: bool) { crate::openhuman::channels::providers::web::register_approval_surface_subscriber(); crate::openhuman::channels::providers::web::register_artifact_surface_subscriber(); } else { - log::info!( - "[runtime] approval gate disabled (OPENHUMAN_APPROVAL_GATE=0) — \ - Prompt-class external-effect tool calls run unprompted" + log::error!( + "[runtime] approval gate DISABLED (OPENHUMAN_APPROVAL_GATE=0 honored on host={}) — \ + Prompt-class external-effect tool calls run unprompted", + host_kind.tag() + ); + crate::core::event_bus::publish_global( + crate::core::event_bus::DomainEvent::ApprovalGateDisabled { + host: host_kind.tag().to_string(), + reason: "env-override".to_string(), + }, ); } // Artifact surface bridges DomainEvent::ArtifactReady/Failed onto the web diff --git a/src/core/types.rs b/src/core/types.rs index 35badb343..277db68b5 100644 --- a/src/core/types.rs +++ b/src/core/types.rs @@ -141,6 +141,110 @@ pub struct AppState { pub core_version: String, } +/// Identifies the host process that started the core runtime. Threaded into +/// `bootstrap_core_runtime` so policy-sensitive boot decisions (currently the +/// approval-gate env-override evaluation, future host-specific defaults) can +/// be made deterministically rather than guessed from the absence of other +/// signals. +/// +/// Mapping: +/// - [`HostKind::TauriShell`] — the desktop app shell embedded the core as an +/// in-process tokio task. Operator-supplied env-as-config is treated as +/// advisory only; the gate ALWAYS installs. +/// - [`HostKind::Cli`] — `openhuman-core` standalone binary spawned by an +/// operator on the command line. Env-as-config is the operator's chosen +/// override surface; the gate honours it. +/// - [`HostKind::Docker`] — containerised deployment. Same env honour-rule as +/// CLI; the host shell is not the user's desktop so there is no UI surface +/// to route an approval prompt to. +/// +/// [`HostKind::detect_standalone`] picks `Docker` vs `Cli` for standalone +/// invocations using the standard Docker signals (`/.dockerenv` or +/// `OPENHUMAN_DOCKER=1`). Tauri-shell callers MUST pass `TauriShell` +/// explicitly — there is no env detection because the embedding shell is the +/// only authority on this fact. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum HostKind { + TauriShell, + Cli, + Docker, +} + +impl HostKind { + /// Choose between [`HostKind::Cli`] and [`HostKind::Docker`] for a + /// standalone-core invocation. Tauri-shell callers must NOT use this — + /// they pass [`HostKind::TauriShell`] directly because the shell is the + /// only authority on whether the core is embedded. + pub fn detect_standalone() -> Self { + if std::env::var("OPENHUMAN_DOCKER") + .map(|v| { + let t = v.trim(); + t == "1" || t.eq_ignore_ascii_case("true") || t.eq_ignore_ascii_case("yes") + }) + .unwrap_or(false) + || std::path::Path::new("/.dockerenv").exists() + { + HostKind::Docker + } else { + HostKind::Cli + } + } + + /// True when the host is the desktop Tauri shell. Used by the approval + /// gate to decide whether the `OPENHUMAN_APPROVAL_GATE=0` env override + /// is honoured (CLI / Docker) or ignored with a UI-routable warning + /// event (Tauri shell). + pub fn is_desktop_shell(self) -> bool { + matches!(self, HostKind::TauriShell) + } + + /// Short tag used in structured logs / event payloads. Stable — + /// downstream subscribers may key on the exact string. + pub fn tag(self) -> &'static str { + match self { + HostKind::TauriShell => "tauri-shell", + HostKind::Cli => "cli", + HostKind::Docker => "docker", + } + } +} + +/// Pure decision helper for the approval-gate host-aware bootstrap branch. +/// Takes the host kind and whether an `OPENHUMAN_APPROVAL_GATE=0` env +/// override was observed; returns: +/// - `install_gate`: true when the gate should be installed at boot +/// - `override_ignored`: true when an env override was seen but suppressed +/// (Tauri shell with override-requested) +/// - `gate_disabled_by_override`: true when an env override was honored and +/// the gate is intentionally not installed (CLI / Docker) +/// +/// Extracted as a pure function so the host-aware policy can be exercised +/// in isolation without standing up the full `bootstrap_core_runtime` path. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ApprovalGateBootDecision { + pub install_gate: bool, + pub override_ignored: bool, + pub gate_disabled_by_override: bool, +} + +pub fn approval_gate_boot_decision( + host: HostKind, + env_override_requested: bool, +) -> ApprovalGateBootDecision { + match host { + HostKind::TauriShell => ApprovalGateBootDecision { + install_gate: true, + override_ignored: env_override_requested, + gate_disabled_by_override: false, + }, + HostKind::Cli | HostKind::Docker => ApprovalGateBootDecision { + install_gate: !env_override_requested, + override_ignored: false, + gate_disabled_by_override: env_override_requested, + }, + } +} + #[cfg(test)] mod tests { use super::*; @@ -262,4 +366,82 @@ mod tests { let cloned = state.clone(); assert_eq!(cloned.core_version, "0.1.0"); } + + #[test] + fn host_kind_tag_is_stable() { + // Downstream consumers (event-bus subscribers, log shippers) key + // on the exact tag strings; pin them so a rename is loud. + assert_eq!(HostKind::TauriShell.tag(), "tauri-shell"); + assert_eq!(HostKind::Cli.tag(), "cli"); + assert_eq!(HostKind::Docker.tag(), "docker"); + } + + #[test] + fn host_kind_is_desktop_shell_only_for_tauri() { + assert!(HostKind::TauriShell.is_desktop_shell()); + assert!(!HostKind::Cli.is_desktop_shell()); + assert!(!HostKind::Docker.is_desktop_shell()); + } + + #[test] + fn desktop_shell_ignores_env_override() { + // Operator sets OPENHUMAN_APPROVAL_GATE=0 inside a Tauri-shell + // boot — the gate MUST still install, and the override-ignored + // signal MUST fire so the UI can banner. + let d = approval_gate_boot_decision(HostKind::TauriShell, true); + assert!(d.install_gate, "tauri shell must always install the gate"); + assert!( + d.override_ignored, + "tauri shell must surface that an override was attempted + ignored" + ); + assert!( + !d.gate_disabled_by_override, + "desktop path never reports the gate as disabled by env" + ); + } + + #[test] + fn desktop_shell_with_no_override_keeps_gate_silent() { + // Normal Tauri boot, no env override — gate installs, no banners, + // no warning event. This is the steady-state desktop path. + let d = approval_gate_boot_decision(HostKind::TauriShell, false); + assert!(d.install_gate); + assert!(!d.override_ignored); + assert!(!d.gate_disabled_by_override); + } + + #[test] + fn standalone_cli_honors_env_override_with_warning_signal() { + let d = approval_gate_boot_decision(HostKind::Cli, true); + assert!(!d.install_gate, "CLI must honor the operator env override"); + assert!( + d.gate_disabled_by_override, + "CLI must surface the elevated-privilege state via the disabled event" + ); + assert!( + !d.override_ignored, + "CLI doesn't ignore; it honors — only desktop ignores" + ); + } + + #[test] + fn standalone_docker_honors_env_override_with_warning_signal() { + let d = approval_gate_boot_decision(HostKind::Docker, true); + assert!(!d.install_gate); + assert!(d.gate_disabled_by_override); + assert!(!d.override_ignored); + } + + #[test] + fn standalone_with_no_env_override_installs_gate_silently() { + for host in [HostKind::Cli, HostKind::Docker] { + let d = approval_gate_boot_decision(host, false); + assert!( + d.install_gate, + "{host:?} with no override must install the gate" + ); + assert!(!d.override_ignored); + assert!(!d.gate_disabled_by_override); + } + } } diff --git a/src/openhuman/agent/triage/evaluator.rs b/src/openhuman/agent/triage/evaluator.rs index 390c4e4db..b36015d8b 100644 --- a/src/openhuman/agent/triage/evaluator.rs +++ b/src/openhuman/agent/triage/evaluator.rs @@ -497,6 +497,11 @@ async fn try_arm( // classifies and routes — but label correctly for defense in depth). origin: crate::openhuman::agent::turn_origin::AgentTurnOrigin::ExternalChannel { channel: envelope.source.slug().to_string(), + // Triage runs over an upstream envelope (composio / webhook / + // cron / external caller) that doesn't carry a per-user sender + // at this layer. Leave it unset and let the gate apply the + // strict per-channel TTL-deny default. + sender: None, reply_target: envelope.display_label.clone(), message_id: envelope.external_id.clone(), }, diff --git a/src/openhuman/agent/turn_origin.rs b/src/openhuman/agent/turn_origin.rs index 6caec6186..68ef5b2e4 100644 --- a/src/openhuman/agent/turn_origin.rs +++ b/src/openhuman/agent/turn_origin.rs @@ -34,8 +34,18 @@ pub enum AgentTurnOrigin { /// TTL-deny because no caller picks up the chat-routed approval on this /// surface yet — which is the correct fail-closed default for remote /// inputs. + /// + /// `sender` carries the per-user identity (Discord user id, Telegram + /// from_account, Slack user id, etc.) when available so per-user + /// isolation invariants survive into the gate's audit trail. Legacy + /// publishers that don't surface the sender pass `None`; the gate still + /// fails closed because the channel input is remote-untrusted regardless + /// of which sender produced it. Distinct senders in the same shared + /// channel produce distinct origins so a co-channel attacker cannot + /// resume a victim's parked approval flow. ExternalChannel { channel: String, + sender: Option, reply_target: String, message_id: String, }, diff --git a/src/openhuman/approval/gate.rs b/src/openhuman/approval/gate.rs index b00439cfd..a3657a3a9 100644 --- a/src/openhuman/approval/gate.rs +++ b/src/openhuman/approval/gate.rs @@ -83,6 +83,50 @@ pub fn parse_approval_reply(message: &str) -> Option { static GLOBAL_GATE: OnceLock> = OnceLock::new(); +/// Snapshot of the host-aware boot decision the runtime made when it +/// evaluated `OPENHUMAN_APPROVAL_GATE`. Surfaced to the UI banner via +/// `approval_get_gate_state` so the user sees a banner the *first* time +/// they open the app after an override was honored, not only when a +/// connected socket happens to receive the boot-time domain event. +/// +/// Set exactly once on boot from `bootstrap_core_runtime`; subsequent +/// reads return the same snapshot for the lifetime of the process. +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ApprovalGateBootState { + /// True when the gate was installed at boot. + pub installed: bool, + /// True when an `OPENHUMAN_APPROVAL_GATE=0` env override was honored + /// (CLI / Docker host) — the gate is OFF and external_effect tools + /// run unprompted. UI banners on this state. + pub disabled_by_env: bool, + /// True when an `OPENHUMAN_APPROVAL_GATE=0` env override was observed + /// but suppressed because the host is the Tauri desktop shell. UI + /// surfaces a softer one-shot info banner so the user knows the + /// override was rejected. + pub override_ignored: bool, + /// Host tag the boot decision keyed off — `tauri-shell` / `cli` / + /// `docker`. Pinned strings; downstream consumers may switch on this. + pub host: &'static str, +} + +static BOOT_STATE: OnceLock = OnceLock::new(); + +/// Record the host-aware boot decision so the UI / RPC layer can read it +/// back. Idempotent — only the first call wins, mirroring the gate +/// `OnceLock` install pattern. +pub fn record_boot_state(state: ApprovalGateBootState) { + let _ = BOOT_STATE.set(state); +} + +/// Read the recorded boot state. Returns `None` when `record_boot_state` +/// was never called (e.g. older test paths that bring up the gate +/// directly without going through `bootstrap_core_runtime`); RPC and UI +/// callers treat that as "no banner needed". +pub fn try_boot_state() -> Option { + BOOT_STATE.get().copied() +} + /// Coordinator for pending approvals. pub struct ApprovalGate { config: Config, @@ -237,12 +281,14 @@ impl ApprovalGate { } AgentTurnOrigin::ExternalChannel { channel, + sender, reply_target, message_id, } => { tracing::info!( tool = tool_name, channel = %channel, + sender = %sender.as_deref().unwrap_or(""), reply_target = %reply_target, message_id = %message_id, "[approval::gate] external channel turn — persisting audit row and parking \ @@ -956,6 +1002,7 @@ mod tests { let gate = Arc::new(gate); let origin = AgentTurnOrigin::ExternalChannel { channel: "telegram".into(), + sender: Some("tg-user-1".into()), reply_target: "tg-chat-1".into(), message_id: "msg-1".into(), }; diff --git a/src/openhuman/approval/rpc.rs b/src/openhuman/approval/rpc.rs index 82367a2a2..ca7bd86de 100644 --- a/src/openhuman/approval/rpc.rs +++ b/src/openhuman/approval/rpc.rs @@ -7,9 +7,34 @@ use anyhow::anyhow; use crate::rpc::RpcOutcome; -use super::gate::ApprovalGate; +use super::gate::{try_boot_state, ApprovalGate, ApprovalGateBootState}; use super::types::{ApprovalAuditEntry, ApprovalDecision, PendingApproval}; +/// Read the host-aware approval-gate boot decision so the UI banner can +/// render the right state on first paint (rather than waiting for a +/// connected socket subscriber to catch a transient boot-time event). +/// +/// Returns a benign "installed, no banner" default when the boot state was +/// never recorded — older test paths that bring up the gate directly bypass +/// `bootstrap_core_runtime` and therefore never call `record_boot_state`. +pub async fn approval_get_gate_state() -> anyhow::Result> { + tracing::debug!("[rpc:approval_get_gate_state] entry"); + let state = try_boot_state().unwrap_or(ApprovalGateBootState { + installed: ApprovalGate::try_global().is_some(), + disabled_by_env: false, + override_ignored: false, + host: "unknown", + }); + tracing::debug!( + installed = state.installed, + disabled_by_env = state.disabled_by_env, + override_ignored = state.override_ignored, + host = state.host, + "[rpc:approval_get_gate_state] exit" + ); + Ok(RpcOutcome::new(state, vec![])) +} + /// List rows still awaiting a user decision in the current session. /// /// Returns an empty list (not an error) when the gate is not diff --git a/src/openhuman/approval/schemas.rs b/src/openhuman/approval/schemas.rs index f80eb2fd0..bc21dd378 100644 --- a/src/openhuman/approval/schemas.rs +++ b/src/openhuman/approval/schemas.rs @@ -17,6 +17,7 @@ pub fn all_controller_schemas() -> Vec { schemas("list_pending"), schemas("list_recent_decisions"), schemas("decide"), + schemas("get_gate_state"), ] } @@ -34,6 +35,10 @@ pub fn all_registered_controllers() -> Vec { schema: schemas("decide"), handler: handle_decide, }, + RegisteredController { + schema: schemas("get_gate_state"), + handler: handle_get_gate_state, + }, ] } @@ -69,6 +74,19 @@ pub fn schemas(function: &str) -> ControllerSchema { required: true, }], }, + "get_gate_state" => ControllerSchema { + namespace: "approval", + function: "get_gate_state", + description: + "Read the host-aware approval-gate boot state so the UI can render the right banner on first paint.", + inputs: vec![], + outputs: vec![FieldSchema { + name: "state", + ty: TypeSchema::Ref("ApprovalGateBootState"), + comment: "Snapshot of the boot decision: installed / disabled-by-env / override-ignored / host tag.", + required: true, + }], + }, "decide" => ControllerSchema { namespace: "approval", function: "decide", @@ -130,6 +148,15 @@ fn handle_list_recent_decisions(params: Map) -> ControllerFuture }) } +fn handle_get_gate_state(_params: Map) -> ControllerFuture { + Box::pin(async move { + let outcome = approval_rpc::approval_get_gate_state() + .await + .map_err(|e| e.to_string())?; + to_json(outcome) + }) +} + fn handle_decide(params: Map) -> ControllerFuture { Box::pin(async move { let request_id = read_required_string(¶ms, "request_id")?; @@ -218,11 +245,16 @@ mod tests { #[test] fn all_registered_controllers_has_handler_per_schema() { let controllers = all_registered_controllers(); - assert_eq!(controllers.len(), 3); + assert_eq!(controllers.len(), 4); let names: Vec<_> = controllers.iter().map(|c| c.schema.function).collect(); assert_eq!( names, - vec!["list_pending", "list_recent_decisions", "decide"] + vec![ + "list_pending", + "list_recent_decisions", + "decide", + "get_gate_state", + ] ); } diff --git a/src/openhuman/channels/bus.rs b/src/openhuman/channels/bus.rs index 90598ad4e..613994c61 100644 --- a/src/openhuman/channels/bus.rs +++ b/src/openhuman/channels/bus.rs @@ -70,7 +70,17 @@ impl EventHandler for ChannelInboundSubscriber { reply_target.as_deref(), thread_ts.as_deref(), ); - let client_id = "inbound".to_string(); + // Per-sender client_id so the `AGENT_TURN_ORIGIN.WebChat.client_id` + // and the wallet `QuoteOwner.client_id` paired with it differ across + // distinct senders in the same shared channel. The thread_id is + // already per-sender via `derive_inbound_thread_id`, and the + // wallet/approval gates compare both halves of the (thread_id, + // client_id) owner pair for equality — but a single shared + // `client_id="inbound"` collapses the surface for any downstream + // consumer that keys on client_id alone (audit logs, future + // session-scoped caches, etc.). Build a stable per-sender label + // here so the surface stays segregated end-to-end. + let client_id = derive_inbound_client_id(channel, sender.as_deref()); let mut event_rx = crate::openhuman::channels::providers::web::subscribe_web_channel_events(); @@ -989,6 +999,27 @@ pub(crate) fn derive_inbound_thread_id( key } +/// Build the per-turn `client_id` for an inbound socket message. Inbound +/// messages do not have a Socket.IO client id of their own — they arrive +/// from the channel transport layer rather than from a connected web +/// browser. Mint a stable label so downstream consumers that key on +/// `client_id` (the agent-turn origin, approval-chat-context, wallet +/// QuoteOwner pair, future audit-log keys) see distinct values for +/// distinct senders sharing a single Discord / Slack channel. +/// +/// `None` (legacy publisher that didn't fill `sender`) maps to the bare +/// `"inbound"` literal that the path used historically, preserving +/// behavior for single-DM flows where no co-channel attacker exists. +pub(crate) fn derive_inbound_client_id(channel: &str, sender: Option<&str>) -> String { + let trimmed_channel = channel.trim(); + let trimmed = sender.map(|s| s.trim()).filter(|s| !s.is_empty()); + match trimmed { + Some(s) if !trimmed_channel.is_empty() => format!("inbound:{trimmed_channel}:{s}"), + Some(s) => format!("inbound:{s}"), + None => "inbound".to_string(), + } +} + /// True for any inbound channel string that addresses Telegram, whether /// the publisher uses the canonical slug (`"telegram"`) or the raw /// provider-prefixed form the socket layer emits (`"tg:"`, @@ -1003,7 +1034,52 @@ fn channel_is_telegram(channel: &str) -> bool { #[cfg(test)] mod inbound_thread_id_tests { - use super::derive_inbound_thread_id; + use super::{derive_inbound_client_id, derive_inbound_thread_id}; + + #[test] + fn socket_inbound_client_id_keys_per_sender() { + // Distinct senders in the same shared channel must produce distinct + // client_id labels so downstream consumers that key on client_id + // (audit log, future session caches) stay segregated. The + // thread_id is already per-sender; this is the matching client_id + // half of the pair. + let alice = derive_inbound_client_id("discord", Some("alice")); + let bob = derive_inbound_client_id("discord", Some("bob")); + assert_ne!(alice, bob, "co-channel senders must not collapse"); + assert!(alice.starts_with("inbound")); + assert!(bob.starts_with("inbound")); + } + + #[test] + fn socket_inbound_client_id_legacy_fallback_keeps_bare_inbound() { + // Legacy publishers that don't fill `sender` keep the historical + // `"inbound"` literal so single-DM flows (where there's no + // co-channel surface) are unchanged. + assert_eq!(derive_inbound_client_id("discord", None), "inbound"); + assert_eq!(derive_inbound_client_id("discord", Some("")), "inbound"); + assert_eq!(derive_inbound_client_id("discord", Some(" ")), "inbound"); + } + + #[test] + fn socket_inbound_keys_per_sender_combined_with_thread_id() { + // Regression: in a shared Discord channel, two distinct senders + // sending into the same channel/reply_target produce a fully + // distinct (client_id, thread_id) pair. This is the surface the + // wallet preparer-binding and parked-approval routing both rely + // on for per-user isolation. + let alice_thread = + derive_inbound_thread_id("discord", Some("alice"), Some("#general"), None); + let bob_thread = derive_inbound_thread_id("discord", Some("bob"), Some("#general"), None); + let alice_client = derive_inbound_client_id("discord", Some("alice")); + let bob_client = derive_inbound_client_id("discord", Some("bob")); + + assert_ne!(alice_thread, bob_thread); + assert_ne!(alice_client, bob_client); + assert_ne!( + (alice_client.as_str(), alice_thread.as_str()), + (bob_client.as_str(), bob_thread.as_str()), + ); + } #[test] fn legacy_channel_only_keeps_old_shape() { diff --git a/src/openhuman/channels/runtime/dispatch.rs b/src/openhuman/channels/runtime/dispatch.rs index e501ce22c..23a5fe9a0 100644 --- a/src/openhuman/channels/runtime/dispatch.rs +++ b/src/openhuman/channels/runtime/dispatch.rs @@ -1013,8 +1013,14 @@ pub(crate) async fn process_channel_message( // as untrusted (remote-attacker-controlled). Cron-driven channel // deliveries get a `TrustedAutomation { Cron }` label from the // scheduler instead and never reach this dispatch path. + // Per-sender provenance flows into the origin so a co-channel attacker + // who reads a leaked `quote_id` / approval prompt from a shared Discord / + // Slack channel cannot use it from their own session — distinct senders + // produce distinct origins, and the wallet preparer / parked-approval + // gates compare these for equality before honouring confirmations. let turn_origin = crate::openhuman::agent::turn_origin::AgentTurnOrigin::ExternalChannel { channel: msg.channel.clone(), + sender: Some(msg.sender.clone()), reply_target: msg.reply_target.clone(), message_id: msg.id.clone(), }; diff --git a/src/openhuman/devices/crypto.rs b/src/openhuman/devices/crypto.rs index 93b843ee1..263ac72fe 100644 --- a/src/openhuman/devices/crypto.rs +++ b/src/openhuman/devices/crypto.rs @@ -1,22 +1,53 @@ -//! X25519 key agreement + XChaCha20-Poly1305 frame encryption for device tunnels. +//! X25519 key agreement + HKDF-SHA256 directional subkey derivation + +//! XChaCha20-Poly1305 frame encryption for device tunnels. //! -//! Frame format: `version(1) || nonce(24) || ciphertext+tag` -//! Version byte is currently 0x01. Nonces are random per frame. -//! Replay protection uses a fixed-size sliding window over 64-bit sequence numbers -//! embedded in the AAD; for the simpler random-nonce scheme here we track the last -//! `WINDOW_SIZE` nonces and reject duplicates. +//! Wire format (frame v2): `version(1=0x02) || nonce(24) || ciphertext+tag`. +//! Frames produced with the previous `version=0x01` shape (single shared key, +//! same key in both directions, no KDF) are no longer accepted; peers MUST +//! re-pair after upgrade. The iOS client is marked in-progress / non-shipping +//! in CLAUDE.md, so the forced re-pair is acceptable. +//! +//! Session key derivation (`derive_session_keys`): +//! ```text +//! ikm = static_dh(32) || eph_dh(32) +//! salt = client_eph_pub || server_eph_pub +//! c2s = HKDF-SHA256(ikm, salt, info = "openhuman-tunnel/v1/c2s", 32) +//! s2c = HKDF-SHA256(ikm, salt, info = "openhuman-tunnel/v1/s2c", 32) +//! ``` +//! Each peer holds two `XChaCha20Poly1305` instances: one for its own +//! direction (seal) and one for the peer's (open). Static DH continues to +//! authenticate the peer via the paired QR-code provenance; the ephemeral +//! DH provides forward secrecy. +//! +//! Replay protection still uses a sliding window over the last +//! `WINDOW_SIZE` raw nonces, applied per opener. use chacha20poly1305::{ aead::{Aead, AeadCore, KeyInit, OsRng as ChaChaOsRng}, XChaCha20Poly1305, XNonce, }; +use hkdf::Hkdf; +use sha2::Sha256; use std::collections::VecDeque; use x25519_dalek::{PublicKey, StaticSecret}; -const FRAME_VERSION: u8 = 0x01; +/// Current frame version. Bumped from `0x01` to `0x02` for HKDF + directional +/// subkeys + ephemeral DH. v1 frames are rejected with +/// [`UnsupportedFrameVersion`]. +pub const FRAME_VERSION: u8 = 0x02; +/// Previous frame version. Surfaced so callers can build a stable error +/// message when an older peer sends a v1 frame post-upgrade. +pub const LEGACY_FRAME_VERSION_V1: u8 = 0x01; const NONCE_LEN: usize = 24; // XChaCha20-Poly1305 nonce = 192 bits const WINDOW_SIZE: usize = 128; // replay protection window +/// HKDF info tags for the two directional subkeys. Pinned strings — peer +/// implementations MUST use byte-identical values. Versioned under `v1` so +/// a future KDF-tag change can land without disturbing the FRAME_VERSION +/// counter. +pub const HKDF_INFO_C2S: &[u8] = b"openhuman-tunnel/v1/c2s"; +pub const HKDF_INFO_S2C: &[u8] = b"openhuman-tunnel/v1/s2c"; + // --------------------------------------------------------------------------- // Key material // --------------------------------------------------------------------------- @@ -81,36 +112,138 @@ impl DeviceKeypair { } } +// --------------------------------------------------------------------------- +// Session-key derivation (HKDF over static + ephemeral DH) +// --------------------------------------------------------------------------- + +/// Two 32-byte directional subkeys derived from the static + ephemeral DH +/// pair. `c2s` (client-to-server) is the key the device uses to seal +/// outgoing frames; `s2c` (server-to-client) is the key the core uses for +/// the reverse direction. Each peer's `TunnelCipher` carries both — its own +/// direction as the `seal_cipher` and the peer's as the `open_cipher` — so +/// a frame the server emits can never replay into the server's own decryptor +/// (cross-direction reflection attack class). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SessionKeys { + pub c2s: [u8; 32], + pub s2c: [u8; 32], +} + +/// Derive `(c2s, s2c)` from a static DH secret and an ephemeral DH secret. +/// +/// `static_dh` authenticates the peer (it was negotiated against the +/// long-term, paired-via-QR-code keys). `eph_dh` is negotiated against +/// fresh ephemeral keypairs minted at session start — so even if the static +/// keys leak later, prior session traffic cannot be recovered. +/// +/// `salt = client_eph_pub || server_eph_pub` (in that fixed order) binds +/// the derived keys to both halves of the ephemeral exchange so neither +/// side can unilaterally pin the salt. +/// +/// HKDF-SHA256 is constant-time and produces independent-looking subkeys +/// for the two `info` tags even when the underlying IKM is the same. +pub fn derive_session_keys( + static_dh: &[u8; 32], + eph_dh: &[u8; 32], + client_eph_pub: &[u8; 32], + server_eph_pub: &[u8; 32], +) -> SessionKeys { + let mut ikm = [0u8; 64]; + ikm[..32].copy_from_slice(static_dh); + ikm[32..].copy_from_slice(eph_dh); + + let mut salt = [0u8; 64]; + salt[..32].copy_from_slice(client_eph_pub); + salt[32..].copy_from_slice(server_eph_pub); + + let h = Hkdf::::new(Some(&salt), &ikm); + let mut c2s = [0u8; 32]; + let mut s2c = [0u8; 32]; + h.expand(HKDF_INFO_C2S, &mut c2s) + .expect("hkdf expand c2s len=32 fits in Sha256 output budget"); + h.expand(HKDF_INFO_S2C, &mut s2c) + .expect("hkdf expand s2c len=32 fits in Sha256 output budget"); + + log::debug!("[devices/crypto] derived directional session keys (HKDF-SHA256)"); + SessionKeys { c2s, s2c } +} + +/// Which side of the tunnel is operating the cipher. Drives the +/// seal/open subkey selection in [`TunnelCipher::for_role`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum TunnelRole { + /// Mobile / iOS device — seals with `c2s`, opens `s2c`. + Client, + /// Desktop core — seals with `s2c`, opens `c2s`. + Server, +} + // --------------------------------------------------------------------------- // Frame cipher // --------------------------------------------------------------------------- /// Stateful cipher for sealing / opening tunnel frames. /// +/// Holds two `XChaCha20Poly1305` instances: `seal_cipher` for the local +/// direction and `open_cipher` for the peer's. When both peers derive keys +/// via [`derive_session_keys`] and call [`TunnelCipher::for_role`] with +/// their respective [`TunnelRole`], a frame the server emits cannot decrypt +/// under the server's own opener (since that opener holds `c2s`, while the +/// server seals with `s2c`). +/// /// Maintains a replay-protection window of the last `WINDOW_SIZE` nonces. /// Thread safety: wrap in a `Mutex` or `RwLock` at the call site. pub struct TunnelCipher { - cipher: XChaCha20Poly1305, + seal_cipher: XChaCha20Poly1305, + open_cipher: XChaCha20Poly1305, seen_nonces: VecDeque<[u8; NONCE_LEN]>, } impl TunnelCipher { - /// Construct from a 32-byte symmetric key (derived via X25519 DH). + /// LEGACY: construct from a single 32-byte symmetric key. Both seal + /// and open use the same key — preserved for the layer-2 sealed + /// handshake path in `devices/bus.rs` that lives outside the + /// post-pairing session. New session callers MUST go through + /// [`Self::for_role`] which holds directional subkeys. pub fn new(key: &[u8; 32]) -> Self { - log::debug!("[devices/crypto] TunnelCipher created"); + log::debug!("[devices/crypto] TunnelCipher created (legacy single-key mode)"); + let cipher = XChaCha20Poly1305::new(key.into()); Self { - cipher: XChaCha20Poly1305::new(key.into()), + seal_cipher: cipher.clone(), + open_cipher: cipher, + seen_nonces: VecDeque::with_capacity(WINDOW_SIZE + 1), + } + } + + /// Construct a directional cipher for the given role. + /// + /// `Client` seals with `c2s` and opens `s2c`; `Server` is the inverse. + /// A v2 frame the server emits will NOT decrypt under the server's own + /// opener (the opener holds `c2s`, the frame is sealed with `s2c`) — + /// closing the cross-direction reflection attack class. + pub fn for_role(role: TunnelRole, keys: &SessionKeys) -> Self { + let (seal_key, open_key) = match role { + TunnelRole::Client => (&keys.c2s, &keys.s2c), + TunnelRole::Server => (&keys.s2c, &keys.c2s), + }; + log::debug!( + "[devices/crypto] TunnelCipher created role={:?} (directional subkeys)", + role + ); + Self { + seal_cipher: XChaCha20Poly1305::new(seal_key.into()), + open_cipher: XChaCha20Poly1305::new(open_key.into()), seen_nonces: VecDeque::with_capacity(WINDOW_SIZE + 1), } } /// Seal `plaintext` into a framed ciphertext. /// - /// Returns `version(1) || nonce(24) || ciphertext+tag`. + /// Returns `version(1=0x02) || nonce(24) || ciphertext+tag`. pub fn seal(&self, plaintext: &[u8]) -> Result, String> { let nonce = XChaCha20Poly1305::generate_nonce(&mut ChaChaOsRng); let ciphertext = self - .cipher + .seal_cipher .encrypt(&nonce, plaintext) .map_err(|e| format!("[devices/crypto] seal failed: {e}"))?; @@ -131,10 +264,22 @@ impl TunnelCipher { /// /// Rejects frames with a wrong version byte, a replayed nonce, or /// authentication failure (tampered ciphertext). + /// + /// Frames with `version = 0x01` (the pre-upgrade single-key shape) are + /// rejected with an explicit `UnsupportedFrameVersion` message so peers + /// see a clear "re-pair required" signal instead of a generic AEAD + /// failure. pub fn open(&mut self, frame: &[u8]) -> Result, String> { if frame.is_empty() { return Err("[devices/crypto] empty frame".into()); } + if frame[0] == LEGACY_FRAME_VERSION_V1 { + return Err( + "[devices/crypto] UnsupportedFrameVersion: legacy v1 frame rejected — \ + peer must re-pair to upgrade to v2 directional subkeys" + .into(), + ); + } if frame[0] != FRAME_VERSION { return Err(format!( "[devices/crypto] unsupported frame version: 0x{:02x}", @@ -155,7 +300,7 @@ impl TunnelCipher { let nonce = XNonce::from(nonce_bytes); let plaintext = self - .cipher + .open_cipher .decrypt(&nonce, ciphertext) .map_err(|_| "[devices/crypto] authentication failed — tampered frame")?; @@ -292,4 +437,158 @@ mod tests { assert!(result.is_err()); assert!(result.unwrap_err().contains("unsupported frame version")); } + + // ----------------------------------------------------------------- + // HKDF + directional subkeys + frame v2 (cluster C regression set) + // ----------------------------------------------------------------- + + /// Fixed-vector smoke: known inputs produce known outputs. Locks the + /// HKDF parameters (info tags, IKM layout, salt layout) so a future + /// rename of either info tag fails loudly here rather than silently + /// re-keying every peer. + #[test] + fn hkdf_derives_distinct_directional_subkeys() { + let static_dh = [0x11u8; 32]; + let eph_dh = [0x22u8; 32]; + let client_eph = [0x33u8; 32]; + let server_eph = [0x44u8; 32]; + let keys = derive_session_keys(&static_dh, &eph_dh, &client_eph, &server_eph); + + // The two subkeys MUST differ even though the IKM + salt are the + // same — only the `info` tag changes between them. + assert_ne!( + keys.c2s, keys.s2c, + "directional subkeys must differ for the same IKM+salt" + ); + + // Re-deriving with the same inputs returns byte-identical keys — + // peers can recompute the session key independently. + let again = derive_session_keys(&static_dh, &eph_dh, &client_eph, &server_eph); + assert_eq!(again, keys); + } + + /// Cross-direction reflection MUST fail. A frame sealed by the server + /// (using `s2c`) replayed back to the server's own opener (which + /// holds `c2s`) is an AEAD authentication failure — not a "version + /// not recognised" or "padding wrong" error. This is the load-bearing + /// invariant of the directional-subkey design. + #[test] + fn cross_direction_reflection_fails() { + let static_dh = [0x55u8; 32]; + let eph_dh = [0x66u8; 32]; + let client_eph = [0x77u8; 32]; + let server_eph = [0x88u8; 32]; + let keys = derive_session_keys(&static_dh, &eph_dh, &client_eph, &server_eph); + + let server = TunnelCipher::for_role(TunnelRole::Server, &keys); + let mut server_opener = TunnelCipher::for_role(TunnelRole::Server, &keys); + + let frame = server.seal(b"frame from server").unwrap(); + let err = server_opener + .open(&frame) + .expect_err("server must not be able to decrypt its own outbound frame"); + assert!( + err.contains("authentication failed"), + "expected AEAD auth failure on reflection, got: {err}" + ); + } + + /// Server seals → client opens succeeds. Same inputs as the + /// reflection test, but the client opener holds `s2c` for opening, + /// which matches the server's seal key. + #[test] + fn directional_roundtrip_server_to_client_succeeds() { + let static_dh = [0x55u8; 32]; + let eph_dh = [0x66u8; 32]; + let client_eph = [0x77u8; 32]; + let server_eph = [0x88u8; 32]; + let keys = derive_session_keys(&static_dh, &eph_dh, &client_eph, &server_eph); + + let server = TunnelCipher::for_role(TunnelRole::Server, &keys); + let mut client = TunnelCipher::for_role(TunnelRole::Client, &keys); + + let frame = server.seal(b"hi from server").unwrap(); + let recovered = client.open(&frame).expect("server→client must round-trip"); + assert_eq!(recovered, b"hi from server"); + } + + /// Client seals → server opens succeeds — the other direction of the + /// same round-trip invariant. + #[test] + fn directional_roundtrip_client_to_server_succeeds() { + let static_dh = [0x33u8; 32]; + let eph_dh = [0x44u8; 32]; + let client_eph = [0x99u8; 32]; + let server_eph = [0xAAu8; 32]; + let keys = derive_session_keys(&static_dh, &eph_dh, &client_eph, &server_eph); + + let client = TunnelCipher::for_role(TunnelRole::Client, &keys); + let mut server = TunnelCipher::for_role(TunnelRole::Server, &keys); + + let frame = client.seal(b"hi from client").unwrap(); + let recovered = server.open(&frame).expect("client→server must round-trip"); + assert_eq!(recovered, b"hi from client"); + } + + /// A legacy `version=0x01` frame MUST be rejected post-upgrade with a + /// distinctive error message — peers see "re-pair required" instead + /// of a generic AEAD failure. + #[test] + fn frame_v1_rejected_after_upgrade() { + // Hand-roll a v1-shaped frame: 0x01 || nonce(24) || ct(_at-least-16-for-tag) + let mut v1_frame = Vec::with_capacity(1 + NONCE_LEN + 16); + v1_frame.push(LEGACY_FRAME_VERSION_V1); + v1_frame.extend_from_slice(&[0u8; NONCE_LEN]); + v1_frame.extend_from_slice(&[0u8; 16]); // arbitrary "tag bytes" + + // Build any v2 cipher — the v1 rejection must trip before the + // AEAD decrypt is attempted. + let keys = derive_session_keys(&[1u8; 32], &[2u8; 32], &[3u8; 32], &[4u8; 32]); + let mut client = TunnelCipher::for_role(TunnelRole::Client, &keys); + + let err = client + .open(&v1_frame) + .expect_err("v1 frame must be rejected"); + assert!( + err.contains("UnsupportedFrameVersion") && err.contains("re-pair"), + "expected explicit UnsupportedFrameVersion + re-pair hint, got: {err}" + ); + } + + /// Forward secrecy sanity: two sessions with the same static DH but + /// distinct ephemeral DH produce non-equal session keys. A static-key + /// leak therefore does not retroactively decrypt historical traffic. + #[test] + fn ephemeral_dh_prevents_session_key_recovery_from_static_only() { + let static_dh = [0x42u8; 32]; + let eph_a = [0xAAu8; 32]; + let eph_b = [0xBBu8; 32]; + let client_eph_a = [0xC1u8; 32]; + let server_eph_a = [0xC2u8; 32]; + let client_eph_b = [0xD1u8; 32]; + let server_eph_b = [0xD2u8; 32]; + + let session_a = derive_session_keys(&static_dh, &eph_a, &client_eph_a, &server_eph_a); + let session_b = derive_session_keys(&static_dh, &eph_b, &client_eph_b, &server_eph_b); + assert_ne!( + session_a, session_b, + "static-DH-only adversary must not recover prior session keys" + ); + } + + /// Even when both halves of the ephemeral exchange differ but the + /// static DH is identical, the two derived sessions remain + /// independent — guards against accidental info-leak from session A + /// into session B's cipher state. + #[test] + fn directional_subkeys_are_independent_per_session() { + let static_dh = [0x42u8; 32]; + let eph_dh = [0x21u8; 32]; + + let sess1 = derive_session_keys(&static_dh, &eph_dh, &[1u8; 32], &[2u8; 32]); + let sess2 = derive_session_keys(&static_dh, &eph_dh, &[3u8; 32], &[4u8; 32]); + + assert_ne!(sess1.c2s, sess2.c2s); + assert_ne!(sess1.s2c, sess2.s2c); + } } diff --git a/src/openhuman/mcp_server/tools.rs b/src/openhuman/mcp_server/tools.rs index 8d681a78a..4a5a97f88 100644 --- a/src/openhuman/mcp_server/tools.rs +++ b/src/openhuman/mcp_server/tools.rs @@ -1318,6 +1318,11 @@ async fn run_subagent_tool(params: &Map) -> Result Result>(), - vec!["list_pending", "list_recent_decisions", "decide"] + vec![ + "list_pending", + "list_recent_decisions", + "decide", + "get_gate_state" + ] ); let unknown = openhuman_core::openhuman::approval::schemas::schemas("missing"); assert_eq!(unknown.namespace, "approval");