mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
feat: tighten runtime policy + transport guards v3 (#3306)
Co-authored-by: Steven Enamakel <enamakel@tinyhumans.ai>
This commit is contained in:
co-authored by
Steven Enamakel
parent
e3ebaca8fd
commit
3119965c70
Generated
+1
@@ -5171,6 +5171,7 @@ dependencies = [
|
||||
"futures-util",
|
||||
"glob",
|
||||
"hex",
|
||||
"hkdf",
|
||||
"hmac 0.12.1",
|
||||
"hostname",
|
||||
"hound",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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() {
|
||||
<CommandProvider>
|
||||
<ServiceBlockingGate>
|
||||
<AppShell />
|
||||
<SecurityBanner />
|
||||
{!onMobile && <DictationHotkeyManager />}
|
||||
{!onMobile && <LocalAIDownloadSnackbar />}
|
||||
{!onMobile && <AppUpdatePrompt />}
|
||||
|
||||
@@ -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<ApprovalGateBootState>;
|
||||
}
|
||||
|
||||
const SecurityBanner = ({ fetchState = fetchApprovalGateState }: SecurityBannerProps) => {
|
||||
const { t } = useT();
|
||||
const [state, setState] = useState<ApprovalGateBootState | null>(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 (
|
||||
<div
|
||||
role="alert"
|
||||
aria-live="polite"
|
||||
data-testid="security-banner-gate-disabled"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
zIndex: 9000,
|
||||
padding: '10px 16px',
|
||||
background: '#7f1d1d',
|
||||
color: '#fef2f2',
|
||||
fontFamily: 'Inter, system-ui, sans-serif',
|
||||
fontSize: 13,
|
||||
fontWeight: 500,
|
||||
textAlign: 'center',
|
||||
borderBottom: '1px solid #b91c1c',
|
||||
boxShadow: '0 1px 2px rgba(0,0,0,0.15)',
|
||||
}}>
|
||||
<strong style={{ marginRight: 8 }}>{t('security.approvalGateDisabled.title')}</strong>
|
||||
<span style={{ opacity: 0.92 }}>{t('security.approvalGateDisabled.body')}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (state.overrideIgnored && !dismissedIgnored) {
|
||||
return (
|
||||
<div
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
data-testid="security-banner-override-ignored"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
zIndex: 9000,
|
||||
padding: '8px 16px',
|
||||
background: '#78350f',
|
||||
color: '#fffbeb',
|
||||
fontFamily: 'Inter, system-ui, sans-serif',
|
||||
fontSize: 12,
|
||||
fontWeight: 500,
|
||||
textAlign: 'center',
|
||||
borderBottom: '1px solid #b45309',
|
||||
}}>
|
||||
<strong style={{ marginRight: 8 }}>
|
||||
{t('security.approvalGateOverrideIgnored.title')}
|
||||
</strong>
|
||||
<span style={{ opacity: 0.92 }}>{t('security.approvalGateOverrideIgnored.body')}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export default SecurityBanner;
|
||||
@@ -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<ApprovalGateBootState>) {
|
||||
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(
|
||||
<Provider store={store}>
|
||||
<I18nProvider>
|
||||
<SecurityBanner fetchState={fetchState} />
|
||||
</I18nProvider>
|
||||
</Provider>
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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': 'مقاطعة',
|
||||
|
||||
@@ -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': 'বাধা দিন',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': 'बाधित करें',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': '중단',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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': 'Прервать',
|
||||
|
||||
@@ -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': '中断',
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
+150
-17
@@ -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);
|
||||
|
||||
@@ -83,3 +83,46 @@ export const fetchPendingApprovals = async (): Promise<PendingApproval[]> => {
|
||||
const raw = await callCoreRpc<unknown>({ method: 'openhuman.approval_list_pending' });
|
||||
return unwrapRows<PendingApproval>(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 = <T>(raw: unknown): T => {
|
||||
if (raw && typeof raw === 'object' && 'result' in (raw as Record<string, unknown>)) {
|
||||
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<ApprovalGateBootState> => {
|
||||
try {
|
||||
const raw = await callCoreRpc<unknown>({ method: 'openhuman.approval_get_gate_state' });
|
||||
return unwrapValue<ApprovalGateBootState>(raw);
|
||||
} catch {
|
||||
return { installed: true, disabledByEnv: false, overrideIgnored: false, host: 'unknown' };
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
|
||||
+73
-9
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
},
|
||||
|
||||
@@ -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<String>,
|
||||
reply_target: String,
|
||||
message_id: String,
|
||||
},
|
||||
|
||||
@@ -83,6 +83,50 @@ pub fn parse_approval_reply(message: &str) -> Option<ApprovalDecision> {
|
||||
|
||||
static GLOBAL_GATE: OnceLock<Arc<ApprovalGate>> = 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<ApprovalGateBootState> = 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<ApprovalGateBootState> {
|
||||
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("<unknown>"),
|
||||
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(),
|
||||
};
|
||||
|
||||
@@ -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<RpcOutcome<ApprovalGateBootState>> {
|
||||
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
|
||||
|
||||
@@ -17,6 +17,7 @@ pub fn all_controller_schemas() -> Vec<ControllerSchema> {
|
||||
schemas("list_pending"),
|
||||
schemas("list_recent_decisions"),
|
||||
schemas("decide"),
|
||||
schemas("get_gate_state"),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -34,6 +35,10 @@ pub fn all_registered_controllers() -> Vec<RegisteredController> {
|
||||
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<String, Value>) -> ControllerFuture
|
||||
})
|
||||
}
|
||||
|
||||
fn handle_get_gate_state(_params: Map<String, Value>) -> 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<String, Value>) -> 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",
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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:<chat_id>"`,
|
||||
@@ -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() {
|
||||
|
||||
@@ -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(),
|
||||
};
|
||||
|
||||
+313
-14
@@ -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::<Sha256>::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<Vec<u8>, 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<Vec<u8>, 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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1318,6 +1318,11 @@ async fn run_subagent_tool(params: &Map<String, Value>) -> Result<Value, ToolCal
|
||||
// run must route through the gate's audit + TTL-deny path.
|
||||
let origin = crate::openhuman::agent::turn_origin::AgentTurnOrigin::ExternalChannel {
|
||||
channel: "mcp_server".to_string(),
|
||||
// MCP server callers don't carry a per-user identity at this
|
||||
// layer — the calling MCP client is the addressing primitive.
|
||||
// Leave sender unset; the gate's per-channel TTL-deny still
|
||||
// gates any external_effect tool the agent tries to run.
|
||||
sender: None,
|
||||
reply_target: agent_id.clone(),
|
||||
message_id: uuid::Uuid::new_v4().to_string(),
|
||||
};
|
||||
|
||||
@@ -936,6 +936,11 @@ async fn llm_meeting_agentic(prompt: &str, request_id: &str) -> Result<String, S
|
||||
let fut = crate::openhuman::agent::turn_origin::with_origin(
|
||||
crate::openhuman::agent::turn_origin::AgentTurnOrigin::ExternalChannel {
|
||||
channel: "meet".to_string(),
|
||||
// Meet utterances don't carry a stable per-participant identity
|
||||
// at this layer (the room is the addressing primitive); leave
|
||||
// sender unset and let the gate fall back to the per-channel
|
||||
// audit-row + TTL-deny policy.
|
||||
sender: None,
|
||||
reply_target: request_id.to_string(),
|
||||
message_id: format!("meet-{request_id}-{now_ms}"),
|
||||
},
|
||||
|
||||
@@ -1057,7 +1057,12 @@ async fn approval_schema_handlers_validate_params_and_surface_empty_gate_state()
|
||||
.iter()
|
||||
.map(|schema| schema.function)
|
||||
.collect::<Vec<_>>(),
|
||||
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");
|
||||
|
||||
Reference in New Issue
Block a user