feat: tighten runtime policy + transport guards v3 (#3306)

Co-authored-by: Steven Enamakel <enamakel@tinyhumans.ai>
This commit is contained in:
oxoxDev
2026-06-04 08:13:16 -04:00
committed by GitHub
co-authored by Steven Enamakel
parent e3ebaca8fd
commit 3119965c70
36 changed files with 1463 additions and 47 deletions
Generated
+1
View File
@@ -5171,6 +5171,7 @@ dependencies = [
"futures-util",
"glob",
"hex",
"hkdf",
"hmac 0.12.1",
"hostname",
"hound",
+1
View File
@@ -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
+2
View File
@@ -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 />}
+129
View File
@@ -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();
});
});
+7
View File
@@ -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': 'مقاطعة',
+7
View File
@@ -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': 'বাধা দিন',
+7
View File
@@ -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',
+8
View File
@@ -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',
+7
View File
@@ -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',
+7
View File
@@ -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',
+7
View File
@@ -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': 'बाधित करें',
+7
View File
@@ -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',
+7
View File
@@ -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',
+7
View File
@@ -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': '중단',
+7
View File
@@ -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',
+7
View File
@@ -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',
+7
View File
@@ -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': 'Прервать',
+7
View File
@@ -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': '中断',
+97
View File
@@ -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
View File
@@ -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);
+43
View File
@@ -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' };
}
};
+30 -1
View File
@@ -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
View File
@@ -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
+182
View File
@@ -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);
}
}
}
+5
View File
@@ -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(),
},
+10
View File
@@ -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,
},
+47
View File
@@ -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(),
};
+26 -1
View File
@@ -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
+34 -2
View File
@@ -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(&params, "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",
]
);
}
+78 -2
View File
@@ -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
View File
@@ -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);
}
}
+5
View File
@@ -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(),
};
+5
View File
@@ -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");