mirror of
https://github.com/stablyai/orca.git
synced 2026-11-03 02:27:43 +00:00
feat(runtime): session-only browser keys and opt-in for pairing from plain-http pages (host) (#27540)
* feat(runtime): session-only browser keys and an opt-in for pairing from plain-http pages (host) - pairing.registerDeviceKey accepts ephemeral: true (advertised as pairing.device-key-ephemeral.v1). Such keyed rows are dropped when the host restarts and after 12 hours with no live socket; a sweep removes them and cuts any socket still bound to them, so later key auth gets device_revoked. - The WebSocket upgrade Origin is recorded per socket. A first pairing (a never-used pairing code) from an http:// page on another machine is refused with a sealed insecure_origin error unless the operator sets ORCA_ALLOW_INSECURE_WEB_PAIRING=1. Already-paired browsers, https and loopback pages, and native clients are unchanged. - Registry file reading moved to device-registry-file-read.ts to keep the registry under the size cap. * fix(runtime): gate first pairings only for the marked web client, never phones React Native sends an http Origin on every LAN WebSocket, so the Origin alone refused first-time QR pairing from released phones. The web bundle now marks its upgrade with ?orca-client=web; only a marked socket from an http page on another machine is gated. Released hosts ignore the query string. --------- Co-authored-by: m4air <m4air@Mac.localdomain>
This commit is contained in:
@@ -5,4 +5,12 @@
|
||||
// pairing.registerDeviceKey; clients migrate or present a key only when this is advertised.
|
||||
export const DEVICE_KEY_RUNTIME_CAPABILITY = 'pairing.device-key.v1' as const
|
||||
|
||||
// Why: registerDeviceKey's params are strict on older hosts, so `ephemeral` is sent only when advertised.
|
||||
export const DEVICE_KEY_EPHEMERAL_RUNTIME_CAPABILITY = 'pairing.device-key-ephemeral.v1' as const
|
||||
|
||||
export const DEVICE_KEY_RUNTIME_CAPABILITIES = [
|
||||
DEVICE_KEY_RUNTIME_CAPABILITY,
|
||||
DEVICE_KEY_EPHEMERAL_RUNTIME_CAPABILITY
|
||||
] as const
|
||||
|
||||
export const REGISTER_DEVICE_KEY_METHOD = 'pairing.registerDeviceKey' as const
|
||||
|
||||
@@ -6,7 +6,10 @@ export const RegisterDeviceKeyParamsSchema = z
|
||||
devicePublicKeyB64: z.string().refine(isDevicePublicKeyB64, 'expected a 32-byte Ed25519 key'),
|
||||
// Ed25519 over encodeDeviceProofMessage('register', this connection's v2 transcript hash).
|
||||
deviceProofB64: z.string().refine(isDeviceProofB64, 'expected a 64-byte signature'),
|
||||
deviceLabel: z.string().max(256).optional()
|
||||
deviceLabel: z.string().max(256).optional(),
|
||||
// Session-only browser key: the host drops the row on restart and after idling.
|
||||
// Sent only to hosts advertising DEVICE_KEY_EPHEMERAL_RUNTIME_CAPABILITY (older hosts are strict).
|
||||
ephemeral: z.literal(true).optional()
|
||||
})
|
||||
.strict()
|
||||
|
||||
|
||||
@@ -45,7 +45,7 @@ import { AGENT_SESSION_RESUME_RUNTIME_CAPABILITIES } from './agent-session-resum
|
||||
import { QODER_OWNED_TERMINAL_CREATE_CAPABILITY } from './qoder-terminal-create-capability'
|
||||
import { AGENT_SESSION_CONTINUE_INTERRUPTED_RUNTIME_CAPABILITY } from './agent-session-continue-interrupted-capability'
|
||||
import { ORCAD_RUNTIME_CAPABILITIES } from './orcad-runtime-capabilities'
|
||||
import { DEVICE_KEY_RUNTIME_CAPABILITY } from './device-key-capability'
|
||||
import { DEVICE_KEY_RUNTIME_CAPABILITIES } from './device-key-capability'
|
||||
// Why a star: every resume capability is part of the runtime contract this module re-exports.
|
||||
export * from './agent-session-resume-runtime-capabilities'
|
||||
import { AGENT_SESSION_BACKGROUND_TASK_CHILD_VIEWS_CAPABILITY } from './agent-session-background-task-child-views-capability'
|
||||
@@ -356,7 +356,7 @@ export const ANTIGRAVITY_CONFIGURED_MODEL_RUNTIME_CAPABILITY =
|
||||
|
||||
export const RUNTIME_CAPABILITIES = [
|
||||
...ORCAD_RUNTIME_CAPABILITIES,
|
||||
DEVICE_KEY_RUNTIME_CAPABILITY,
|
||||
...DEVICE_KEY_RUNTIME_CAPABILITIES,
|
||||
QODER_OWNED_TERMINAL_CREATE_CAPABILITY,
|
||||
...AGENT_SESSION_STOP_RUNTIME_CAPABILITIES,
|
||||
...AGENT_SESSION_CREATE_RUNTIME_CAPABILITIES,
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
hasWebClientUpgradeMarker,
|
||||
isInsecureRemoteWebOrigin,
|
||||
withWebClientUpgradeMarker
|
||||
} from './web-origin-security'
|
||||
|
||||
describe('isInsecureRemoteWebOrigin', () => {
|
||||
it('flags plain http pages served to other machines', () => {
|
||||
expect(isInsecureRemoteWebOrigin('http://192.168.1.20:6768')).toBe(true)
|
||||
expect(isInsecureRemoteWebOrigin('http://desk.local')).toBe(true)
|
||||
expect(isInsecureRemoteWebOrigin('http://[fe80::1]:6768')).toBe(true)
|
||||
})
|
||||
|
||||
it('treats https and loopback pages as secure', () => {
|
||||
expect(isInsecureRemoteWebOrigin('https://desk.example.com')).toBe(false)
|
||||
expect(isInsecureRemoteWebOrigin('http://localhost:6768')).toBe(false)
|
||||
expect(isInsecureRemoteWebOrigin('http://app.localhost')).toBe(false)
|
||||
expect(isInsecureRemoteWebOrigin('http://127.0.0.1:6768')).toBe(false)
|
||||
expect(isInsecureRemoteWebOrigin('http://127.8.0.1')).toBe(false)
|
||||
expect(isInsecureRemoteWebOrigin('http://[::1]:6768')).toBe(false)
|
||||
})
|
||||
|
||||
it('leaves non-browser and unparseable origins to token auth', () => {
|
||||
expect(isInsecureRemoteWebOrigin(undefined)).toBe(false)
|
||||
expect(isInsecureRemoteWebOrigin('null')).toBe(false)
|
||||
expect(isInsecureRemoteWebOrigin('file://')).toBe(false)
|
||||
expect(isInsecureRemoteWebOrigin('not a url')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('web client upgrade marker', () => {
|
||||
it('round-trips through the endpoint the web bundle dials', () => {
|
||||
const marked = withWebClientUpgradeMarker('ws://192.168.1.20:6768')
|
||||
expect(marked).toBe('ws://192.168.1.20:6768/?orca-client=web')
|
||||
expect(hasWebClientUpgradeMarker(new URL(marked).pathname + new URL(marked).search)).toBe(true)
|
||||
expect(withWebClientUpgradeMarker('wss://desk.example.com/orca?x=1')).toBe(
|
||||
'wss://desk.example.com/orca?x=1&orca-client=web'
|
||||
)
|
||||
})
|
||||
|
||||
it('is absent for phones, CLIs and other paths', () => {
|
||||
expect(hasWebClientUpgradeMarker('/')).toBe(false)
|
||||
expect(hasWebClientUpgradeMarker(undefined)).toBe(false)
|
||||
expect(hasWebClientUpgradeMarker('/?orca-client=phone')).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,61 @@
|
||||
// Why: a plain-http page on another machine can be swapped by anyone on the LAN path, so a
|
||||
// pairing code typed into it is exposed. Browsers treat loopback http as a secure context.
|
||||
export function isInsecureRemoteWebOrigin(origin: string | undefined): boolean {
|
||||
if (!origin) {
|
||||
return false
|
||||
}
|
||||
let url: URL
|
||||
try {
|
||||
url = new URL(origin)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
return url.protocol === 'http:' && !isLoopbackHostname(url.hostname)
|
||||
}
|
||||
|
||||
function isLoopbackHostname(hostname: string): boolean {
|
||||
const host = hostname.toLowerCase()
|
||||
return (
|
||||
host === 'localhost' ||
|
||||
host.endsWith('.localhost') ||
|
||||
host === '[::1]' ||
|
||||
/^127(?:\.\d{1,3}){3}$/.test(host)
|
||||
)
|
||||
}
|
||||
|
||||
/** Sealed auth error for a first pairing from such a page without operator opt-in. */
|
||||
export const INSECURE_WEB_ORIGIN_AUTH_CODE = 'insecure_origin' as const
|
||||
|
||||
// Operator opt-in, read by the host serving the page (desktop or `orca serve`).
|
||||
export const ALLOW_INSECURE_WEB_PAIRING_ENV = 'ORCA_ALLOW_INSECURE_WEB_PAIRING' as const
|
||||
|
||||
// Why a marker: React Native phones also send an http Origin on LAN sockets, so the Origin alone
|
||||
// cannot say "browser". Only the web bundle adds this query to its upgrade URL; hosts that predate
|
||||
// it ignore the query string, so the marked upgrade still connects to them.
|
||||
export const WEB_CLIENT_UPGRADE_PARAM = 'orca-client' as const
|
||||
export const WEB_CLIENT_UPGRADE_VALUE = 'web' as const
|
||||
|
||||
export function withWebClientUpgradeMarker(endpoint: string): string {
|
||||
try {
|
||||
const url = new URL(endpoint)
|
||||
url.searchParams.set(WEB_CLIENT_UPGRADE_PARAM, WEB_CLIENT_UPGRADE_VALUE)
|
||||
return url.toString()
|
||||
} catch {
|
||||
return endpoint
|
||||
}
|
||||
}
|
||||
|
||||
/** True when an upgrade request path (`/path?query`) carries the web bundle's marker. */
|
||||
export function hasWebClientUpgradeMarker(requestUrl: string | undefined): boolean {
|
||||
if (!requestUrl) {
|
||||
return false
|
||||
}
|
||||
try {
|
||||
return (
|
||||
new URL(requestUrl, 'http://upgrade.invalid').searchParams.get(WEB_CLIENT_UPGRADE_PARAM) ===
|
||||
WEB_CLIENT_UPGRADE_VALUE
|
||||
)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user