mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 00:57:45 +00:00
* 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>
83 lines
3.0 KiB
TypeScript
83 lines
3.0 KiB
TypeScript
import { randomUUID } from 'node:crypto'
|
|
import type { DeviceEntry } from './device-registry'
|
|
import { devicePrincipalOf } from './device-principal'
|
|
|
|
const MAX_FORKS_PER_TOKEN_ROW = 32
|
|
|
|
export class DeviceKeyForkError extends Error {
|
|
constructor(
|
|
readonly code:
|
|
| 'device_not_found'
|
|
| 'device_already_keyed'
|
|
| 'delegated_device'
|
|
| 'device_key_in_use'
|
|
| 'too_many_device_keys'
|
|
) {
|
|
super(code)
|
|
}
|
|
}
|
|
|
|
export type DeviceKeyFork = { nextDevices: DeviceEntry[]; entry: DeviceEntry; created: boolean }
|
|
|
|
/**
|
|
* Forks a token row into a keyed row for one installation. The token row stays, so every other
|
|
* holder of the same token (other tabs, machines, env-var users) and its live sockets keep working.
|
|
* Idempotent per (token row, key): a lost reply retried with the same key returns the same row.
|
|
*/
|
|
export function planDeviceKeyFork(
|
|
devices: readonly DeviceEntry[],
|
|
legacyDeviceId: string,
|
|
devicePublicKeyB64: string,
|
|
label: string,
|
|
now: number,
|
|
options: { ephemeral?: boolean } = {}
|
|
): DeviceKeyFork {
|
|
const legacy = devices.find((device) => device.deviceId === legacyDeviceId)
|
|
if (!legacy) {
|
|
throw new DeviceKeyForkError('device_not_found')
|
|
}
|
|
if (legacy.credentialKind === 'device-key' || legacy.token === undefined) {
|
|
throw new DeviceKeyForkError('device_already_keyed')
|
|
}
|
|
// Why: a relayed child authenticates through its parent desktop, which owns its credential.
|
|
if (legacy.parentDeviceId !== undefined) {
|
|
throw new DeviceKeyForkError('delegated_device')
|
|
}
|
|
const sameKey = devices.filter((device) => device.devicePublicKeyB64 === devicePublicKeyB64)
|
|
const existing = sameKey.find((device) => device.forkedFrom === legacyDeviceId)
|
|
if (existing) {
|
|
return { nextDevices: [...devices], entry: existing, created: false }
|
|
}
|
|
// Why: one key is one installation; reusing it under another row would merge two identities.
|
|
if (sameKey.length > 0) {
|
|
throw new DeviceKeyForkError('device_key_in_use')
|
|
}
|
|
// Why: bounds registry growth from one shared code; real installations are a handful.
|
|
if (
|
|
devices.filter((device) => device.forkedFrom === legacyDeviceId).length >=
|
|
MAX_FORKS_PER_TOKEN_ROW
|
|
) {
|
|
throw new DeviceKeyForkError('too_many_device_keys')
|
|
}
|
|
const entry: DeviceEntry = {
|
|
deviceId: randomUUID(),
|
|
name: label,
|
|
scope: legacy.scope,
|
|
...(legacy.grants ? { grants: [...legacy.grants] } : {}),
|
|
pairingReach: legacy.pairingReach,
|
|
...(legacy.mobilePairingConnectionMode
|
|
? { mobilePairingConnectionMode: legacy.mobilePairingConnectionMode }
|
|
: {}),
|
|
credentialKind: 'device-key',
|
|
devicePublicKeyB64,
|
|
forkedFrom: legacy.deviceId,
|
|
...(options.ephemeral ? { ephemeral: true as const } : {}),
|
|
// Why: orchestration work owned under the token keeps its owner after migration.
|
|
callerFingerprint: devicePrincipalOf(legacy).callerFingerprint,
|
|
pairedAt: now,
|
|
// Why: lastSeenAt 0 marks a never-scanned pending row, which rotation deletes.
|
|
lastSeenAt: now
|
|
}
|
|
return { nextDevices: [...devices, entry], entry, created: true }
|
|
}
|