mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
feat(runtime): device-key authentication and per-installation key migration (host) (#27493)
* feat(runtime): device-key authentication and per-installation key migration (host) * fix(runtime): corrupt registry is never a revocation verdict; keyed sockets dispatch without a token; forks keep their Relay binding * fix(runtime): keep an unparseable device registry aside; an unlisted key is never 'revoked' while it exists * fix(runtime): a set-aside corrupt registry keeps verdicts doubtful even when the registry file is missing * test(runtime): registerDeviceKey reaches the bound socket through the dispatcher for runtime and mobile rows --------- Co-authored-by: m4air <m4air@Mac.localdomain>
This commit is contained in:
@@ -2,7 +2,11 @@ import { mintDeviceCredential } from './device-credential'
|
||||
import type { DelegatedPhone } from '../../shared/delegated-mobile-device-contract'
|
||||
import type { DeviceEntry } from './device-registry'
|
||||
|
||||
export type DelegatedMobileDeviceEntry = DeviceEntry & { parentDeviceId: string; phoneKey: string }
|
||||
export type DelegatedMobileDeviceEntry = DeviceEntry & {
|
||||
parentDeviceId: string
|
||||
phoneKey: string
|
||||
token: string
|
||||
}
|
||||
|
||||
/** The registry after making `parent`'s children cover `phones`, and those children in `phones` order. */
|
||||
export function planDelegatedMobileDevices(
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import type { DeviceScope } from '../../shared/runtime-types'
|
||||
import type { MobilePairingConnectionMode } from '../../shared/mobile-pairing-connection-mode'
|
||||
import type { MobilePushRegistration } from '../../shared/mobile-push-contract'
|
||||
import type { RuntimePairingReach } from '../../shared/runtime-pairing-reach'
|
||||
import type { RelayDeviceBinding } from './relay/relay-revoke-outbox'
|
||||
import type { RuntimeDeviceGrant } from './rpc/rpc-method-permission'
|
||||
|
||||
export type DeviceEntry = {
|
||||
deviceId: string
|
||||
name: string
|
||||
// Why: token rows only; a keyed row has no bearer, so an older build finds nothing to match (inert).
|
||||
token?: string
|
||||
scope: DeviceScope
|
||||
pairedAt: number
|
||||
lastSeenAt: number
|
||||
relayBinding?: RelayDeviceBinding
|
||||
mobilePairingConnectionMode?: MobilePairingConnectionMode
|
||||
// Why: STA-2370 — a grant minted for "This computer only" proves nothing about off-host reach when its
|
||||
// client connects, so the bind decision must be able to tell it apart from a LAN/phone grant.
|
||||
pairingReach?: RuntimePairingReach
|
||||
// Why: survives a desktop restart so the host can keep pushing without the phone
|
||||
// re-registering. Absent on every registry written before background push existed.
|
||||
pushRegistration?: MobilePushRegistration
|
||||
// Why: a phone relayed by a paired desktop is that desktop's child; it dies with the parent's grant.
|
||||
parentDeviceId?: string
|
||||
phoneKey?: string
|
||||
// Why: administrative permissions are granted only when pairing; absent on older rows means none.
|
||||
grants?: RuntimeDeviceGrant[]
|
||||
// Why: authenticated sockets are bound to this; a credential change bumps it. Absent = 0.
|
||||
credentialRevision?: number
|
||||
// Why: orchestration ownership survives a credential change. Absent = sha256(token).
|
||||
callerFingerprint?: string
|
||||
// Why: absent = token row. A keyed row authenticates only by a proof from this public key.
|
||||
credentialKind?: 'device-key'
|
||||
devicePublicKeyB64?: string
|
||||
// The token row this installation migrated from; that row stays for its other holders.
|
||||
forkedFrom?: string
|
||||
}
|
||||
|
||||
/** A row that authenticates by bearer token: every pairing offer and delegated child. */
|
||||
export type TokenDeviceEntry = DeviceEntry & { token: string }
|
||||
|
||||
export type DeviceKeyLookup =
|
||||
| { kind: 'found'; device: DeviceEntry & { devicePublicKeyB64: string } }
|
||||
| { kind: 'unknown' }
|
||||
// The row exists but has no usable key: never a revocation verdict.
|
||||
| { kind: 'not-keyed' }
|
||||
| { kind: 'unavailable' }
|
||||
|
||||
// Why: a never-scanned token row is a pairing offer; keyed rows are never offers or rotated away.
|
||||
export function isPendingTokenRow(device: DeviceEntry): device is TokenDeviceEntry {
|
||||
return (
|
||||
device.lastSeenAt === 0 && device.token !== undefined && device.credentialKind === undefined
|
||||
)
|
||||
}
|
||||
|
||||
export function sameGrants(entry: DeviceEntry, grants: readonly RuntimeDeviceGrant[]): boolean {
|
||||
const current = entry.grants ?? []
|
||||
return current.length === grants.length && current.every((grant) => grants.includes(grant))
|
||||
}
|
||||
|
||||
// Why: "unknown" must mean "readable and absent" — clients delete their key only on that verdict.
|
||||
export function lookupDeviceKeyRow(device: DeviceEntry | null): DeviceKeyLookup {
|
||||
if (!device) {
|
||||
return { kind: 'unknown' }
|
||||
}
|
||||
return device.credentialKind === 'device-key' && device.devicePublicKeyB64
|
||||
? { kind: 'found', device: { ...device, devicePublicKeyB64: device.devicePublicKeyB64 } }
|
||||
: { kind: 'not-keyed' }
|
||||
}
|
||||
|
||||
export type AuthenticatedPrincipalBinding = {
|
||||
deviceId: string
|
||||
credentialRevision: number
|
||||
deviceToken?: string
|
||||
}
|
||||
|
||||
export function isStillAuthenticated(
|
||||
device: DeviceEntry,
|
||||
principal: AuthenticatedPrincipalBinding
|
||||
): boolean {
|
||||
return (
|
||||
(device.credentialRevision ?? 0) === principal.credentialRevision &&
|
||||
(principal.deviceToken === undefined || device.token === principal.deviceToken) &&
|
||||
// Why: a keyed socket must not survive its row turning back into (or being replaced by) a token row.
|
||||
(principal.deviceToken !== undefined || device.credentialKind === 'device-key')
|
||||
)
|
||||
}
|
||||
|
||||
export function mobilePairingConnectionModeOf(
|
||||
device: DeviceEntry | null
|
||||
): MobilePairingConnectionMode | null {
|
||||
if (device?.scope !== 'mobile') {
|
||||
return null
|
||||
}
|
||||
// Why: pairings created before this preference existed used automatic
|
||||
// direct-first Relay fallback, so missing state must preserve that behavior.
|
||||
return device.mobilePairingConnectionMode === 'local-only' ? 'local-only' : 'automatic'
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
import { mkdtempSync, readdirSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { planDeviceKeyFork } from './device-key-fork'
|
||||
import { devicePrincipalOf } from './device-principal'
|
||||
import { DeviceRegistry, type DeviceEntry } from './device-registry'
|
||||
import { DEVICE_REGISTRY_FILENAME } from './mobile-pairing-files'
|
||||
|
||||
const KEY_A = Buffer.alloc(32, 1).toString('base64')
|
||||
const KEY_B = Buffer.alloc(32, 2).toString('base64')
|
||||
|
||||
function tokenRow(overrides: Partial<DeviceEntry> = {}): DeviceEntry {
|
||||
return {
|
||||
deviceId: 'legacy',
|
||||
name: 'Laptop',
|
||||
token: 'token',
|
||||
scope: 'runtime',
|
||||
grants: ['desktop-control'],
|
||||
pairingReach: 'this-computer',
|
||||
pairedAt: 1,
|
||||
lastSeenAt: 2,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
describe('planDeviceKeyFork', () => {
|
||||
it('copies authority and ownership, keeps the token row, and never widens', () => {
|
||||
const legacy = tokenRow()
|
||||
const { nextDevices, entry, created } = planDeviceKeyFork([legacy], 'legacy', KEY_A, 'Mac', 9)
|
||||
expect(created).toBe(true)
|
||||
expect(nextDevices[0]).toBe(legacy)
|
||||
expect(entry).toMatchObject({
|
||||
scope: 'runtime',
|
||||
grants: ['desktop-control'],
|
||||
pairingReach: 'this-computer',
|
||||
credentialKind: 'device-key',
|
||||
devicePublicKeyB64: KEY_A,
|
||||
forkedFrom: 'legacy',
|
||||
callerFingerprint: devicePrincipalOf(legacy).callerFingerprint,
|
||||
lastSeenAt: 9
|
||||
})
|
||||
expect(entry.token).toBeUndefined()
|
||||
expect(entry.deviceId).not.toBe('legacy')
|
||||
})
|
||||
|
||||
it('is idempotent per (token row, key) and gives each installation its own row', () => {
|
||||
const first = planDeviceKeyFork([tokenRow()], 'legacy', KEY_A, 'A', 1)
|
||||
const repeat = planDeviceKeyFork(first.nextDevices, 'legacy', KEY_A, 'A', 2)
|
||||
expect(repeat).toMatchObject({ created: false, entry: first.entry })
|
||||
const second = planDeviceKeyFork(first.nextDevices, 'legacy', KEY_B, 'B', 3)
|
||||
expect(second.created).toBe(true)
|
||||
expect(second.entry.deviceId).not.toBe(first.entry.deviceId)
|
||||
})
|
||||
|
||||
it('refuses keyed rows, delegated children, unknown rows and a key bound elsewhere', () => {
|
||||
const fork = planDeviceKeyFork(
|
||||
[tokenRow(), tokenRow({ deviceId: 'other', token: 't2' })],
|
||||
'legacy',
|
||||
KEY_A,
|
||||
'A',
|
||||
1
|
||||
)
|
||||
expect(() => planDeviceKeyFork(fork.nextDevices, fork.entry.deviceId, KEY_B, 'x', 1)).toThrow(
|
||||
'device_already_keyed'
|
||||
)
|
||||
expect(() => planDeviceKeyFork(fork.nextDevices, 'other', KEY_A, 'x', 1)).toThrow(
|
||||
'device_key_in_use'
|
||||
)
|
||||
expect(() =>
|
||||
planDeviceKeyFork([tokenRow({ parentDeviceId: 'p' })], 'legacy', KEY_A, 'x', 1)
|
||||
).toThrow('delegated_device')
|
||||
expect(() => planDeviceKeyFork([], 'legacy', KEY_A, 'x', 1)).toThrow('device_not_found')
|
||||
})
|
||||
|
||||
it('commits nothing when the registry cannot be read', () => {
|
||||
const registry = new DeviceRegistry(mkdtempSync(join(tmpdir(), 'orca-device-key-fork-')))
|
||||
const legacy = registry.addDevice('Laptop', 'runtime')
|
||||
registry['registryUnreadable'] = true
|
||||
expect(() => registry.forkDeviceKey(legacy.deviceId, KEY_A, 'Mac')).toThrow()
|
||||
expect(registry.listDevices()).toHaveLength(1)
|
||||
expect(registry.lookupDeviceKey('anything')).toEqual({ kind: 'unavailable' })
|
||||
})
|
||||
|
||||
it('keeps a corrupt registry aside and never calls an unlisted key revoked while it exists', () => {
|
||||
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-device-key-fork-'))
|
||||
writeFileSync(join(userDataPath, DEVICE_REGISTRY_FILENAME), '[{"deviceId":"keyed",')
|
||||
const afterCrash = new DeviceRegistry(userDataPath)
|
||||
expect(afterCrash.lookupDeviceKey('keyed')).toEqual({ kind: 'unavailable' })
|
||||
afterCrash.getOrCreatePendingDevice('Re-pair', 'mobile')
|
||||
expect(readdirSync(userDataPath).some((name) => name.includes('.corrupt-'))).toBe(true)
|
||||
|
||||
const nextLaunch = new DeviceRegistry(userDataPath)
|
||||
expect(nextLaunch.listDevices()).toHaveLength(1)
|
||||
expect(nextLaunch.lookupDeviceKey('keyed')).toEqual({ kind: 'unavailable' })
|
||||
})
|
||||
|
||||
it('stays doubtful when the rewrite after quarantine never landed', () => {
|
||||
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-device-key-fork-'))
|
||||
writeFileSync(join(userDataPath, DEVICE_REGISTRY_FILENAME), '{')
|
||||
writeFileSync(join(userDataPath, `${DEVICE_REGISTRY_FILENAME}.corrupt-1`), '{')
|
||||
rmSync(join(userDataPath, DEVICE_REGISTRY_FILENAME))
|
||||
expect(new DeviceRegistry(userDataPath).lookupDeviceKey('keyed')).toEqual({
|
||||
kind: 'unavailable'
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,80 @@
|
||||
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
|
||||
): 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,
|
||||
// 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 }
|
||||
}
|
||||
@@ -34,6 +34,7 @@ function socketFor(device: DeviceEntry): AuthenticatedMobileSocket {
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: dispatch only uses ws as a map key for abort registration.
|
||||
ws: {} as AuthenticatedMobileSocket['ws'],
|
||||
connectionId: 'connection-1',
|
||||
transcriptHashB64: null,
|
||||
device: { ...devicePrincipalOf(device), deviceToken: device.token },
|
||||
clientCapabilities: [],
|
||||
transport: { transport: 'direct' }
|
||||
|
||||
@@ -20,9 +20,10 @@ export function devicePrincipalOf(device: DeviceEntry): DevicePrincipal {
|
||||
return {
|
||||
deviceId: device.deviceId,
|
||||
scope: device.scope,
|
||||
clientId: device.token,
|
||||
clientId: device.token ?? device.deviceId,
|
||||
callerFingerprint:
|
||||
device.callerFingerprint ?? fingerprintAuthenticatedPairingCredential(device.token),
|
||||
device.callerFingerprint ??
|
||||
fingerprintAuthenticatedPairingCredential(device.token ?? `device:${device.deviceId}`),
|
||||
credentialRevision: device.credentialRevision ?? 0
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { readdirSync, renameSync } from 'node:fs'
|
||||
import { basename, dirname, join } from 'node:path'
|
||||
|
||||
// Why: a registry that failed to parse may still be restored from backup, and until it is,
|
||||
// "this device is not listed" proves nothing — a keyed client would delete its key on that verdict.
|
||||
function corruptCopyPrefix(registryPath: string): string {
|
||||
return `${basename(registryPath)}.corrupt-`
|
||||
}
|
||||
|
||||
export function corruptRegistryCopyExists(registryPath: string): boolean {
|
||||
try {
|
||||
const prefix = corruptCopyPrefix(registryPath)
|
||||
return readdirSync(dirname(registryPath)).some((name) => name.startsWith(prefix))
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/** Moves an unparseable registry aside so the first rewrite cannot destroy it. */
|
||||
export function quarantineCorruptRegistry(registryPath: string): void {
|
||||
const target = join(dirname(registryPath), `${corruptCopyPrefix(registryPath)}${Date.now()}`)
|
||||
try {
|
||||
renameSync(registryPath, target)
|
||||
} catch (error) {
|
||||
if (!isMissingFileError(error)) {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function isMissingFileError(error: unknown): boolean {
|
||||
return error instanceof Error && 'code' in error && error.code === 'ENOENT'
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import type { DeviceScope } from '../../shared/runtime-types'
|
||||
import type { RelayDeviceBinding } from './relay/relay-revoke-outbox'
|
||||
import type { DeviceEntry } from './device-registry'
|
||||
import { parseMobilePushRegistration } from '../../shared/mobile-push-contract'
|
||||
import { isDevicePublicKeyB64 } from '../../shared/device-key-proof'
|
||||
import { RUNTIME_DEVICE_GRANTS, type RuntimeDeviceGrant } from './rpc/rpc-method-permission'
|
||||
|
||||
const GRANTABLE: ReadonlySet<string> = new Set(RUNTIME_DEVICE_GRANTS)
|
||||
@@ -67,6 +68,23 @@ export function normalizeLoadedDeviceRow(device: DeviceEntry): DeviceEntry {
|
||||
callerFingerprint:
|
||||
typeof device.callerFingerprint === 'string' && device.callerFingerprint.length > 0
|
||||
? device.callerFingerprint
|
||||
: undefined
|
||||
: undefined,
|
||||
...validDeviceKeyFields(device)
|
||||
}
|
||||
}
|
||||
|
||||
// Why: a malformed keyed row stays keyed (so it never falls back to a token) but cannot authenticate.
|
||||
function validDeviceKeyFields(
|
||||
device: DeviceEntry
|
||||
): Pick<DeviceEntry, 'credentialKind' | 'devicePublicKeyB64' | 'forkedFrom'> {
|
||||
if (device.credentialKind !== 'device-key') {
|
||||
return { credentialKind: undefined, devicePublicKeyB64: undefined, forkedFrom: undefined }
|
||||
}
|
||||
return {
|
||||
credentialKind: 'device-key',
|
||||
devicePublicKeyB64: isDevicePublicKeyB64(device.devicePublicKeyB64)
|
||||
? device.devicePublicKeyB64
|
||||
: undefined,
|
||||
forkedFrom: typeof device.forkedFrom === 'string' ? device.forkedFrom : undefined
|
||||
}
|
||||
}
|
||||
|
||||
+130
-135
@@ -2,17 +2,24 @@
|
||||
// (mobile) connections. Each paired device gets its own revocable token so
|
||||
// compromising one device doesn't expose others. The registry is a simple
|
||||
// JSON file with hardened permissions matching the runtime metadata pattern.
|
||||
import { existsSync, readFileSync } from 'node:fs'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
import {
|
||||
hardenExistingSecureFile,
|
||||
isUnreadableError,
|
||||
writeDurableSecureJsonFile,
|
||||
writeSecureJsonFile
|
||||
} from '../../shared/secure-file'
|
||||
import type { DeviceScope } from '../../shared/runtime-types'
|
||||
import { removeStaleDurableWriteTempFiles } from '../durable-file-write'
|
||||
import { DEVICE_REGISTRY_FILENAME } from './mobile-pairing-files'
|
||||
import { mintDeviceCredential } from './device-credential'
|
||||
import { planDeviceKeyFork } from './device-key-fork'
|
||||
import {
|
||||
corruptRegistryCopyExists,
|
||||
isMissingFileError,
|
||||
quarantineCorruptRegistry
|
||||
} from './device-registry-corruption'
|
||||
import type { RelayDeviceBinding } from './relay/relay-revoke-outbox'
|
||||
import type { MobilePairingConnectionMode } from '../../shared/mobile-pairing-connection-mode'
|
||||
import type { RuntimePairingReach } from '../../shared/runtime-pairing-reach'
|
||||
@@ -23,40 +30,22 @@ import {
|
||||
} from './delegated-mobile-devices'
|
||||
import type { RuntimeDeviceGrant } from './rpc/rpc-method-permission'
|
||||
import { normalizeLoadedDeviceRow, validGrants } from './device-registry-row-validation'
|
||||
import {
|
||||
isPendingTokenRow,
|
||||
mobilePairingConnectionModeOf,
|
||||
isStillAuthenticated,
|
||||
type AuthenticatedPrincipalBinding,
|
||||
lookupDeviceKeyRow,
|
||||
sameGrants,
|
||||
type DeviceEntry,
|
||||
type DeviceKeyLookup,
|
||||
type TokenDeviceEntry
|
||||
} from './device-entry'
|
||||
import type { MobilePushRegistration } from '../../shared/mobile-push-contract'
|
||||
|
||||
export type { DeviceScope }
|
||||
|
||||
export type DeviceEntry = {
|
||||
deviceId: string
|
||||
name: string
|
||||
token: string
|
||||
scope: DeviceScope
|
||||
pairedAt: number
|
||||
lastSeenAt: number
|
||||
relayBinding?: RelayDeviceBinding
|
||||
mobilePairingConnectionMode?: MobilePairingConnectionMode
|
||||
// Why: STA-2370 — a grant minted for "This computer only" proves nothing about off-host reach when its
|
||||
// client connects, so the bind decision must be able to tell it apart from a LAN/phone grant.
|
||||
pairingReach?: RuntimePairingReach
|
||||
// Why: survives a desktop restart so the host can keep pushing without the phone
|
||||
// re-registering. Absent on every registry written before background push existed.
|
||||
pushRegistration?: MobilePushRegistration
|
||||
// Why: a phone relayed by a paired desktop is that desktop's child; it dies with the parent's grant.
|
||||
parentDeviceId?: string
|
||||
phoneKey?: string
|
||||
// Why: administrative permissions are granted only when pairing; absent on older rows means none.
|
||||
grants?: RuntimeDeviceGrant[]
|
||||
// Why: authenticated sockets are bound to this; a credential change bumps it. Absent = 0.
|
||||
credentialRevision?: number
|
||||
// Why: orchestration ownership survives a credential change. Absent = sha256(token).
|
||||
callerFingerprint?: string
|
||||
}
|
||||
|
||||
function sameGrants(entry: DeviceEntry, grants: readonly RuntimeDeviceGrant[]): boolean {
|
||||
const current = entry.grants ?? []
|
||||
return current.length === grants.length && current.every((grant) => grants.includes(grant))
|
||||
}
|
||||
export type { DeviceEntry, DeviceKeyLookup, TokenDeviceEntry }
|
||||
|
||||
// Why: a lastSeen refresh is pure bookkeeping, so coalesce reconnect bursts into one write instead of
|
||||
// paying a secure-file rewrite (two synchronous PowerShell ACL spawns on Windows) per connection.
|
||||
@@ -68,6 +57,9 @@ export class DeviceRegistry {
|
||||
private devices: DeviceEntry[] = []
|
||||
/** Set when the registry exists but could not be read, which makes `devices` a lie to save from. */
|
||||
private registryUnreadable = false
|
||||
/** A parse failure, now or in an earlier run, means an unlisted device may still exist. */
|
||||
private registryDoubtful = false
|
||||
private corruptFilePending = false
|
||||
private pendingLastSeenFlush: NodeJS.Timeout | null = null
|
||||
|
||||
constructor(userDataPath: string) {
|
||||
@@ -84,7 +76,7 @@ export class DeviceRegistry {
|
||||
scope: DeviceScope = 'mobile',
|
||||
pairingReach: RuntimePairingReach = 'network',
|
||||
grants: readonly RuntimeDeviceGrant[] = []
|
||||
): DeviceEntry {
|
||||
): TokenDeviceEntry {
|
||||
return this.createAndPersistDevice(this.devices, name, scope, pairingReach, grants)
|
||||
}
|
||||
|
||||
@@ -94,9 +86,9 @@ export class DeviceRegistry {
|
||||
scope: DeviceScope,
|
||||
pairingReach: RuntimePairingReach,
|
||||
grants: readonly RuntimeDeviceGrant[]
|
||||
): DeviceEntry {
|
||||
): TokenDeviceEntry {
|
||||
const validatedGrants = validGrants(grants, scope)
|
||||
const entry: DeviceEntry = {
|
||||
const entry: TokenDeviceEntry = {
|
||||
...mintDeviceCredential(),
|
||||
name,
|
||||
scope,
|
||||
@@ -123,11 +115,11 @@ export class DeviceRegistry {
|
||||
scope: DeviceScope = 'mobile',
|
||||
pairingReach: RuntimePairingReach = 'network',
|
||||
grants: readonly RuntimeDeviceGrant[] = []
|
||||
): DeviceEntry {
|
||||
): TokenDeviceEntry {
|
||||
// Why: a pending token is reused only for the same grants, so re-advertising never widens one.
|
||||
const existing = this.devices.find(
|
||||
(d) =>
|
||||
d.lastSeenAt === 0 && d.scope === scope && sameGrants(d, validGrants(grants, scope) ?? [])
|
||||
(d): d is TokenDeviceEntry =>
|
||||
isPendingTokenRow(d) && d.scope === scope && sameGrants(d, validGrants(grants, scope) ?? [])
|
||||
)
|
||||
if (existing) {
|
||||
// Why: the same pending token can be re-advertised at a broader reach; widen it but never narrow it,
|
||||
@@ -139,15 +131,12 @@ export class DeviceRegistry {
|
||||
return this.addDevice(name, scope, pairingReach, grants)
|
||||
}
|
||||
|
||||
private setPairingReach(existing: DeviceEntry, pairingReach: RuntimePairingReach): DeviceEntry {
|
||||
const updated: DeviceEntry = { ...existing, pairingReach }
|
||||
const nextDevices = this.devices.map((device) =>
|
||||
device.deviceId === existing.deviceId ? updated : device
|
||||
)
|
||||
// Why: persist before the memory swap so a failed write cannot leave the bind decision reading a
|
||||
// reach that never reached disk.
|
||||
this.save(nextDevices)
|
||||
this.devices = nextDevices
|
||||
private setPairingReach(
|
||||
existing: TokenDeviceEntry,
|
||||
pairingReach: RuntimePairingReach
|
||||
): TokenDeviceEntry {
|
||||
const updated: TokenDeviceEntry = { ...existing, pairingReach }
|
||||
this.replaceRow(existing.deviceId, () => updated)
|
||||
return updated
|
||||
}
|
||||
|
||||
@@ -162,9 +151,9 @@ export class DeviceRegistry {
|
||||
scope: DeviceScope = 'mobile',
|
||||
pairingReach: RuntimePairingReach = 'network',
|
||||
grants: readonly RuntimeDeviceGrant[] = []
|
||||
): DeviceEntry {
|
||||
): TokenDeviceEntry {
|
||||
// Why: a pending runtime device has no delegated children — its first auth sets lastSeenAt before any sync.
|
||||
const retainedDevices = this.devices.filter((d) => d.lastSeenAt !== 0 || d.scope !== scope)
|
||||
const retainedDevices = this.devices.filter((d) => !isPendingTokenRow(d) || d.scope !== scope)
|
||||
return this.createAndPersistDevice(retainedDevices, name, scope, pairingReach, grants)
|
||||
}
|
||||
|
||||
@@ -201,66 +190,41 @@ export class DeviceRegistry {
|
||||
return this.devices.find((d) => d.deviceId === deviceId) ?? null
|
||||
}
|
||||
|
||||
getPendingDevice(scope: DeviceScope = 'mobile'): DeviceEntry | null {
|
||||
return this.devices.find((device) => device.lastSeenAt === 0 && device.scope === scope) ?? null
|
||||
getPendingDevice(scope: DeviceScope = 'mobile'): TokenDeviceEntry | null {
|
||||
return (
|
||||
this.devices.find(
|
||||
(device): device is TokenDeviceEntry => isPendingTokenRow(device) && device.scope === scope
|
||||
) ?? null
|
||||
)
|
||||
}
|
||||
|
||||
setRelayBinding(deviceId: string, binding: RelayDeviceBinding): boolean {
|
||||
const index = this.devices.findIndex((candidate) => candidate.deviceId === deviceId)
|
||||
if (index === -1 || binding.relayDeviceId !== deviceId) {
|
||||
return false
|
||||
}
|
||||
const nextDevices = this.devices.map((device, candidateIndex) =>
|
||||
candidateIndex === index ? { ...device, relayBinding: binding } : device
|
||||
return (
|
||||
binding.relayDeviceId === deviceId &&
|
||||
this.replaceRow(deviceId, (device) => ({ ...device, relayBinding: binding }))
|
||||
)
|
||||
this.save(nextDevices)
|
||||
this.devices = nextDevices
|
||||
return true
|
||||
}
|
||||
|
||||
/** Passing null clears the registration (unregister, or a token the gateway reported dead). */
|
||||
setPushRegistration(deviceId: string, registration: MobilePushRegistration | null): boolean {
|
||||
const index = this.devices.findIndex((candidate) => candidate.deviceId === deviceId)
|
||||
if (index === -1 || this.devices[index]?.scope !== 'mobile') {
|
||||
return false
|
||||
}
|
||||
const nextDevices = this.devices.map((device, candidateIndex) => {
|
||||
if (candidateIndex !== index) {
|
||||
return device
|
||||
}
|
||||
const { pushRegistration: _dropped, ...rest } = device
|
||||
return registration ? { ...rest, pushRegistration: registration } : rest
|
||||
})
|
||||
// Why: persist before the memory swap so a failed write cannot leave the dispatcher
|
||||
// pushing to a registration disk says is gone (or vice versa on reload).
|
||||
this.save(nextDevices)
|
||||
this.devices = nextDevices
|
||||
return true
|
||||
return (
|
||||
this.getDevice(deviceId)?.scope === 'mobile' &&
|
||||
this.replaceRow(deviceId, (device) => {
|
||||
const { pushRegistration: _dropped, ...rest } = device
|
||||
return registration ? { ...rest, pushRegistration: registration } : rest
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
setMobilePairingConnectionMode(deviceId: string, mode: MobilePairingConnectionMode): boolean {
|
||||
const index = this.devices.findIndex((candidate) => candidate.deviceId === deviceId)
|
||||
if (index === -1 || this.devices[index]?.scope !== 'mobile') {
|
||||
return false
|
||||
}
|
||||
// Why: persist before swapping memory so a failed write does not leave a
|
||||
// mode the UI/runtime believe was stored.
|
||||
const nextDevices = this.devices.map((device, candidateIndex) =>
|
||||
candidateIndex === index ? { ...device, mobilePairingConnectionMode: mode } : device
|
||||
return (
|
||||
this.getDevice(deviceId)?.scope === 'mobile' &&
|
||||
this.replaceRow(deviceId, (device) => ({ ...device, mobilePairingConnectionMode: mode }))
|
||||
)
|
||||
this.save(nextDevices)
|
||||
this.devices = nextDevices
|
||||
return true
|
||||
}
|
||||
|
||||
getMobilePairingConnectionMode(deviceId: string): MobilePairingConnectionMode | null {
|
||||
const device = this.devices.find((candidate) => candidate.deviceId === deviceId)
|
||||
if (!device || device.scope !== 'mobile') {
|
||||
return null
|
||||
}
|
||||
// Why: pairings created before this preference existed used automatic
|
||||
// direct-first Relay fallback, so missing state must preserve that behavior.
|
||||
return device.mobilePairingConnectionMode === 'local-only' ? 'local-only' : 'automatic'
|
||||
return mobilePairingConnectionModeOf(this.getDevice(deviceId))
|
||||
}
|
||||
|
||||
listDevices(): readonly DeviceEntry[] {
|
||||
@@ -268,40 +232,46 @@ export class DeviceRegistry {
|
||||
}
|
||||
|
||||
validateToken(token: string): DeviceEntry | null {
|
||||
return this.devices.find((d) => d.token === token) ?? null
|
||||
return (
|
||||
(token && this.devices.find((d) => d.credentialKind === undefined && d.token === token)) ||
|
||||
null
|
||||
)
|
||||
}
|
||||
|
||||
// Why: "unknown" must mean "readable and absent" — clients delete their key only on that verdict.
|
||||
lookupDeviceKey(deviceId: string): DeviceKeyLookup {
|
||||
const lookup = lookupDeviceKeyRow(this.getDevice(deviceId))
|
||||
return lookup.kind === 'unknown' && (this.registryUnreadable || this.registryDoubtful)
|
||||
? { kind: 'unavailable' }
|
||||
: lookup
|
||||
}
|
||||
|
||||
/** Forks `legacyDeviceId` into a keyed row; durable before it returns (security transition). */
|
||||
forkDeviceKey(legacyDeviceId: string, devicePublicKeyB64: string, label: string): DeviceEntry {
|
||||
const fork = planDeviceKeyFork(
|
||||
this.devices,
|
||||
legacyDeviceId,
|
||||
devicePublicKeyB64,
|
||||
label,
|
||||
Date.now()
|
||||
)
|
||||
if (fork.created) {
|
||||
this.save(fork.nextDevices, { durable: true })
|
||||
this.devices = fork.nextDevices
|
||||
}
|
||||
return fork.entry
|
||||
}
|
||||
|
||||
/** The row an authenticated socket is still entitled to, or null once revoked or re-credentialed. */
|
||||
resolveAuthenticatedPrincipal(principal: {
|
||||
deviceId: string
|
||||
credentialRevision: number
|
||||
deviceToken?: string
|
||||
}): DeviceEntry | null {
|
||||
resolveAuthenticatedPrincipal(principal: AuthenticatedPrincipalBinding): DeviceEntry | null {
|
||||
const device = this.getDevice(principal.deviceId)
|
||||
if (
|
||||
!device ||
|
||||
(device.credentialRevision ?? 0) !== principal.credentialRevision ||
|
||||
(principal.deviceToken !== undefined && device.token !== principal.deviceToken)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
return device
|
||||
return device && isStillAuthenticated(device, principal) ? device : null
|
||||
}
|
||||
|
||||
updateLastSeen(deviceId: string): void {
|
||||
const index = this.devices.findIndex((d) => d.deviceId === deviceId)
|
||||
if (index === -1) {
|
||||
return
|
||||
}
|
||||
// Why: persist before memory swap so a failed write cannot leave a scanned
|
||||
// device looking never-scanned on disk, where rotation would drop it.
|
||||
// Why: a failed write must not leave a scanned device never-scanned on disk, where rotation drops it.
|
||||
const seenAt = Date.now()
|
||||
const nextDevices = this.devices.map((device, candidateIndex) =>
|
||||
candidateIndex === index ? { ...device, lastSeenAt: seenAt } : device
|
||||
)
|
||||
this.save(nextDevices)
|
||||
this.devices = nextDevices
|
||||
this.cancelPendingLastSeenFlush()
|
||||
this.replaceRow(deviceId, (device) => ({ ...device, lastSeenAt: seenAt }))
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -311,17 +281,13 @@ export class DeviceRegistry {
|
||||
* never scanned, so only that 0 -> non-zero transition is load-bearing.
|
||||
*/
|
||||
updateLastSeenDeferred(deviceId: string): void {
|
||||
const index = this.devices.findIndex((d) => d.deviceId === deviceId)
|
||||
if (index === -1) {
|
||||
return
|
||||
}
|
||||
if (this.devices[index]!.lastSeenAt === 0) {
|
||||
this.updateLastSeen(deviceId)
|
||||
return
|
||||
const existing = this.getDevice(deviceId)
|
||||
if (!existing || existing.lastSeenAt === 0) {
|
||||
return existing ? this.updateLastSeen(deviceId) : undefined
|
||||
}
|
||||
const seenAt = Date.now()
|
||||
this.devices = this.devices.map((device, candidateIndex) =>
|
||||
candidateIndex === index ? { ...device, lastSeenAt: seenAt } : device
|
||||
this.devices = this.devices.map((device) =>
|
||||
device === existing ? { ...device, lastSeenAt: seenAt } : device
|
||||
)
|
||||
if (this.pendingLastSeenFlush) {
|
||||
return
|
||||
@@ -356,30 +322,59 @@ export class DeviceRegistry {
|
||||
}
|
||||
|
||||
private load(): void {
|
||||
if (!existsSync(this.registryPath)) {
|
||||
this.devices = []
|
||||
return
|
||||
}
|
||||
// Why: a set-aside corrupt copy keeps revocation verdicts off until the owner removes it, even
|
||||
// when the rewrite after quarantine never landed and the registry file itself is missing.
|
||||
this.registryDoubtful = corruptRegistryCopyExists(this.registryPath)
|
||||
try {
|
||||
hardenExistingSecureFile(this.registryPath)
|
||||
const parsed = JSON.parse(readFileSync(this.registryPath, 'utf-8')) as DeviceEntry[]
|
||||
this.devices = parsed.map(normalizeLoadedDeviceRow)
|
||||
this.registryUnreadable = false
|
||||
} catch (error) {
|
||||
this.devices = []
|
||||
if (isMissingFileError(error)) {
|
||||
return
|
||||
}
|
||||
// "Cannot read" is not "is empty". Saving an empty list over a registry we were merely
|
||||
// denied would erase every paired device's bearer token, and the write would succeed.
|
||||
this.registryUnreadable = isUnreadableError(error)
|
||||
this.devices = []
|
||||
// Why: a corrupt file is not evidence any device was revoked; never tell a keyed client so.
|
||||
this.registryDoubtful = true
|
||||
this.corruptFilePending = !this.registryUnreadable
|
||||
}
|
||||
}
|
||||
|
||||
private save(devices: DeviceEntry[]): void {
|
||||
// Why: persist before the memory swap so a failed write never leaves memory claiming what disk lacks.
|
||||
private replaceRow(deviceId: string, update: (device: DeviceEntry) => DeviceEntry): boolean {
|
||||
const index = this.devices.findIndex((candidate) => candidate.deviceId === deviceId)
|
||||
if (index === -1) {
|
||||
return false
|
||||
}
|
||||
const nextDevices = this.devices.map((device, candidateIndex) =>
|
||||
candidateIndex === index ? update(device) : device
|
||||
)
|
||||
this.save(nextDevices)
|
||||
this.devices = nextDevices
|
||||
return true
|
||||
}
|
||||
|
||||
private assertReadable(): void {
|
||||
if (this.registryUnreadable) {
|
||||
throw new Error(
|
||||
`Cannot read the device registry at ${this.registryPath}: the read failed. Refusing to overwrite it, which would revoke every paired device.`
|
||||
)
|
||||
}
|
||||
writeSecureJsonFile(this.registryPath, devices)
|
||||
if (this.corruptFilePending) {
|
||||
quarantineCorruptRegistry(this.registryPath)
|
||||
this.corruptFilePending = false
|
||||
}
|
||||
}
|
||||
|
||||
// Why: durable (fsync'd) for security transitions such as a device-key fork.
|
||||
private save(devices: DeviceEntry[], options: { durable?: boolean } = {}): void {
|
||||
this.assertReadable()
|
||||
const write = options.durable ? writeDurableSecureJsonFile : writeSecureJsonFile
|
||||
write(this.registryPath, devices)
|
||||
// Why: every registry save includes the latest in-memory timestamps, so a later timer would rewrite it.
|
||||
this.cancelPendingLastSeenFlush()
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
import { deriveSharedKey } from '../rpc/e2ee-crypto'
|
||||
import { deriveMobileE2EEV2KeySchedule } from '../rpc/mobile-e2ee-v2-key-schedule'
|
||||
|
||||
// Why: the relay integration test needs an independent mobile-side wire peer
|
||||
// Why: desktop-side tests need an independent mobile-side wire peer
|
||||
// without importing Expo modules into the desktop Node TypeScript project.
|
||||
export class SimulatedMobileE2EEV2Peer {
|
||||
readonly hello: MobileE2EEV2Hello
|
||||
@@ -23,7 +23,8 @@ export class SimulatedMobileE2EEV2Peer {
|
||||
constructor(
|
||||
private readonly clientKeys: nacl.BoxKeyPair,
|
||||
private readonly desktopPublicKey: Uint8Array,
|
||||
relayHostId: string,
|
||||
// null = a direct (LAN) socket.
|
||||
relayHostId: string | null,
|
||||
clientNonce = nacl.randomBytes(32)
|
||||
) {
|
||||
this.hello = {
|
||||
@@ -36,8 +37,9 @@ export class SimulatedMobileE2EEV2Peer {
|
||||
protocol: 'orca-mobile-e2ee',
|
||||
initiator: 'mobile',
|
||||
responder: 'desktop',
|
||||
transport: 'relay',
|
||||
relayHostId
|
||||
...(relayHostId === null
|
||||
? { transport: 'direct' as const }
|
||||
: { transport: 'relay' as const, relayHostId })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,15 @@ import type { OrchestrationSessionCaller } from '../orchestration/orchestration-
|
||||
import type { RpcCallerIdentity } from './rpc-caller-identity'
|
||||
import type { RpcBinarySender } from './rpc-binary-sender'
|
||||
import type { RpcMethodPermission } from './rpc-method-permission'
|
||||
import type {
|
||||
RegisterDeviceKeyParams,
|
||||
RegisterDeviceKeyResult
|
||||
} from '../../../shared/device-key-registration-contract'
|
||||
|
||||
// Why: bound to the authenticated socket by the transport, so a caller can only re-key itself.
|
||||
export type DeviceSelfRpcContext = {
|
||||
registerDeviceKey(params: RegisterDeviceKeyParams): RegisterDeviceKeyResult
|
||||
}
|
||||
|
||||
export type PairingRpcContext = {
|
||||
getEndpoints(params: PairingGetEndpointsParams): Promise<PairingGetEndpointsResult>
|
||||
@@ -125,6 +134,7 @@ export type RpcContext = {
|
||||
// Why: federation pins the authenticated saved-environment caller without exposing its token to handlers or storage.
|
||||
authenticatedCallerFingerprint?: string
|
||||
pairing?: PairingRpcContext
|
||||
deviceSelf?: DeviceSelfRpcContext
|
||||
// Why: set only on a desktop that relays; it lists the configured servers it shows.
|
||||
mobileRelayHosts?: {
|
||||
list(): MobileRelayHostsListResult
|
||||
|
||||
@@ -0,0 +1,300 @@
|
||||
import { mkdtempSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import nacl from 'tweetnacl'
|
||||
import type { WebSocket } from 'ws'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { encodeDeviceProofMessage, type DeviceProofPurpose } from '../../../shared/device-key-proof'
|
||||
import { DeviceRegistry } from '../device-registry'
|
||||
import { DEVICE_REGISTRY_FILENAME } from '../mobile-pairing-files'
|
||||
import { SimulatedMobileE2EEV2Peer } from '../relay/simulated-mobile-e2ee-v2-peer'
|
||||
import { registerSocketDeviceKey } from '../runtime-rpc/runtime-rpc-device-key-registration'
|
||||
import { generateKeyPair } from './e2ee-crypto'
|
||||
import {
|
||||
MobileSocketWiring,
|
||||
type AuthenticatedMobileSocket,
|
||||
type MobileSocketTransport,
|
||||
type MobileSocketTransportMetadata
|
||||
} from './mobile-socket-wiring'
|
||||
|
||||
class FakeSocket {
|
||||
readonly OPEN = 1
|
||||
readyState = this.OPEN
|
||||
bufferedAmount = 0
|
||||
readonly sent: (string | Buffer)[] = []
|
||||
readonly send = vi.fn((data: string | Buffer) => this.sent.push(data))
|
||||
readonly close = vi.fn()
|
||||
}
|
||||
|
||||
class FakeTransport implements MobileSocketTransport {
|
||||
private messageHandler: Parameters<MobileSocketTransport['onMessage']>[0] | null = null
|
||||
readonly setClientId = vi.fn()
|
||||
readonly terminateClientConnections = vi.fn(() => 0)
|
||||
onMessage(handler: Parameters<MobileSocketTransport['onMessage']>[0]): void {
|
||||
this.messageHandler = handler
|
||||
}
|
||||
onConnectionClose(): void {}
|
||||
receive(ws: FakeSocket, message: string): void {
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the wiring only sends, closes and reads OPEN/readyState/bufferedAmount.
|
||||
this.messageHandler?.(message, vi.fn(), ws as unknown as WebSocket)
|
||||
}
|
||||
}
|
||||
|
||||
const desktop = generateKeyPair()
|
||||
|
||||
function sign(key: nacl.SignKeyPair, purpose: DeviceProofPurpose, transcriptHashB64: string) {
|
||||
const message = encodeDeviceProofMessage(purpose, Buffer.from(transcriptHashB64, 'base64'))
|
||||
return Buffer.from(nacl.sign.detached(message, key.secretKey)).toString('base64')
|
||||
}
|
||||
|
||||
function harness(registry: DeviceRegistry, metadata?: MobileSocketTransportMetadata) {
|
||||
const transport = new FakeTransport()
|
||||
const ready: AuthenticatedMobileSocket[] = []
|
||||
const wiring = new MobileSocketWiring({
|
||||
deviceRegistry: registry,
|
||||
e2eeKeypair: {
|
||||
publicKey: desktop.publicKey,
|
||||
secretKey: desktop.secretKey,
|
||||
publicKeyB64: Buffer.from(desktop.publicKey).toString('base64')
|
||||
},
|
||||
onText: vi.fn(),
|
||||
onBinary: vi.fn(),
|
||||
onClose: vi.fn(),
|
||||
onReady: (socket) => ready.push(socket)
|
||||
})
|
||||
wiring.attachTransport(transport, () => metadata ?? { transport: 'direct' })
|
||||
|
||||
/** Opens a direct v2 socket and sends `auth(transcriptHashB64)`; returns the decoded verdict. */
|
||||
function connect(auth: (transcriptHashB64: string) => Record<string, unknown>) {
|
||||
const ws = new FakeSocket()
|
||||
const peer = new SimulatedMobileE2EEV2Peer(
|
||||
nacl.box.keyPair(),
|
||||
desktop.publicKey,
|
||||
metadata?.transport === 'relay' ? metadata.relayHostId : null
|
||||
)
|
||||
transport.receive(ws, JSON.stringify(peer.hello))
|
||||
expect(peer.acceptReady(JSON.parse(String(ws.sent[0])))).toBe(true)
|
||||
const transcriptHashB64 = peer.transcriptHashB64
|
||||
transport.receive(
|
||||
ws,
|
||||
peer.sealText(
|
||||
JSON.stringify({ type: 'e2ee_auth', v: 2, transcriptHashB64, ...auth(transcriptHashB64) })
|
||||
)
|
||||
)
|
||||
const reply = ws.sent[1] ? JSON.parse(peer.openText(String(ws.sent[1]))!) : null
|
||||
return { ws, peer, transcriptHashB64, reply, socket: ready.at(-1) }
|
||||
}
|
||||
return { connect, ready }
|
||||
}
|
||||
|
||||
function freshRegistry(): DeviceRegistry {
|
||||
return new DeviceRegistry(mkdtempSync(join(tmpdir(), 'orca-device-key-')))
|
||||
}
|
||||
|
||||
describe('device-key authentication (host)', () => {
|
||||
it('forks a token row per installation, then authenticates the key and keeps the token working', () => {
|
||||
const registry = freshRegistry()
|
||||
const legacy = registry.addDevice('Phone', 'mobile')
|
||||
registry.updateLastSeen(legacy.deviceId)
|
||||
const { connect } = harness(registry)
|
||||
|
||||
const migrating = connect(() => ({ deviceToken: legacy.token }))
|
||||
expect(migrating.reply).toMatchObject({ type: 'e2ee_authenticated' })
|
||||
const key = nacl.sign.keyPair()
|
||||
const params = {
|
||||
devicePublicKeyB64: Buffer.from(key.publicKey).toString('base64'),
|
||||
deviceProofB64: sign(key, 'register', migrating.transcriptHashB64),
|
||||
deviceLabel: 'Pixel evil\u0007'
|
||||
}
|
||||
const forked = registerSocketDeviceKey(registry, migrating.socket!, params)
|
||||
// A lost reply retried with the same key returns the same row.
|
||||
expect(registerSocketDeviceKey(registry, migrating.socket!, params)).toEqual(forked)
|
||||
expect(registry.getDevice(forked.deviceId)).toMatchObject({
|
||||
credentialKind: 'device-key',
|
||||
forkedFrom: legacy.deviceId,
|
||||
name: 'Pixel evil',
|
||||
scope: 'mobile'
|
||||
})
|
||||
expect(registry.getDevice(forked.deviceId)?.token).toBeUndefined()
|
||||
|
||||
const keyed = connect((hash) => ({
|
||||
deviceId: forked.deviceId,
|
||||
deviceProofB64: sign(key, 'auth', hash)
|
||||
}))
|
||||
expect(keyed.reply).toMatchObject({ type: 'e2ee_authenticated' })
|
||||
expect(keyed.socket?.device).toMatchObject({
|
||||
deviceId: forked.deviceId,
|
||||
clientId: forked.deviceId,
|
||||
callerFingerprint: migrating.socket!.device.callerFingerprint
|
||||
})
|
||||
expect(keyed.socket?.device.deviceToken).toBeUndefined()
|
||||
|
||||
// Another installation holding the same token is unaffected and forks its own row.
|
||||
const other = connect(() => ({ deviceToken: legacy.token }))
|
||||
expect(other.reply).toMatchObject({ type: 'e2ee_authenticated' })
|
||||
const otherKey = nacl.sign.keyPair()
|
||||
const otherFork = registerSocketDeviceKey(registry, other.socket!, {
|
||||
devicePublicKeyB64: Buffer.from(otherKey.publicKey).toString('base64'),
|
||||
deviceProofB64: sign(otherKey, 'register', other.transcriptHashB64)
|
||||
})
|
||||
expect(otherFork.deviceId).not.toBe(forked.deviceId)
|
||||
})
|
||||
|
||||
it('refuses a keyed row presented with a token, a replayed proof and a wrong-purpose proof', () => {
|
||||
const registry = freshRegistry()
|
||||
const legacy = registry.addDevice('Phone', 'mobile')
|
||||
const { connect } = harness(registry)
|
||||
const migrating = connect(() => ({ deviceToken: legacy.token }))
|
||||
const key = nacl.sign.keyPair()
|
||||
const devicePublicKeyB64 = Buffer.from(key.publicKey).toString('base64')
|
||||
expect(() =>
|
||||
registerSocketDeviceKey(registry, migrating.socket!, {
|
||||
devicePublicKeyB64,
|
||||
deviceProofB64: sign(key, 'auth', migrating.transcriptHashB64)
|
||||
})
|
||||
).toThrow('bad_proof')
|
||||
const forked = registerSocketDeviceKey(registry, migrating.socket!, {
|
||||
devicePublicKeyB64,
|
||||
deviceProofB64: sign(key, 'register', migrating.transcriptHashB64)
|
||||
})
|
||||
|
||||
const first = connect((hash) => ({
|
||||
deviceId: forked.deviceId,
|
||||
deviceProofB64: sign(key, 'auth', hash)
|
||||
}))
|
||||
expect(first.reply).toMatchObject({ type: 'e2ee_authenticated' })
|
||||
const replayedProof = sign(key, 'auth', first.transcriptHashB64)
|
||||
const replay = connect(() => ({ deviceId: forked.deviceId, deviceProofB64: replayedProof }))
|
||||
expect(replay.reply).toEqual({ type: 'e2ee_error', error: { code: 'bad_proof' } })
|
||||
expect(replay.ws.close).toHaveBeenCalledWith(4001, 'Invalid e2ee_auth')
|
||||
|
||||
const mixed = connect((hash) => ({
|
||||
deviceId: forked.deviceId,
|
||||
deviceProofB64: sign(key, 'auth', hash),
|
||||
deviceToken: legacy.token
|
||||
}))
|
||||
expect(mixed.reply).toEqual({ type: 'e2ee_error', error: { code: 'bad_auth' } })
|
||||
})
|
||||
|
||||
it("accepts a fork over its token row's Relay binding and refuses other bindings without a revocation verdict", () => {
|
||||
const registry = freshRegistry()
|
||||
const legacy = registry.addDevice('Phone', 'mobile')
|
||||
const stranger = registry.addDevice('Other phone', 'mobile')
|
||||
const key = nacl.sign.keyPair()
|
||||
const forked = registry.forkDeviceKey(
|
||||
legacy.deviceId,
|
||||
Buffer.from(key.publicKey).toString('base64'),
|
||||
'Phone'
|
||||
)
|
||||
const relay = (relayDeviceId: string): MobileSocketTransportMetadata => ({
|
||||
transport: 'relay',
|
||||
relayHostId: 'AbCdEf0123_-xyZ9',
|
||||
relayDeviceId,
|
||||
basisConnId: 'basis',
|
||||
credentialKind: 'resume'
|
||||
})
|
||||
const keyAuth = (hash: string) => ({
|
||||
deviceId: forked.deviceId,
|
||||
deviceProofB64: sign(key, 'auth', hash)
|
||||
})
|
||||
expect(harness(registry, relay(legacy.deviceId)).connect(keyAuth).reply).toMatchObject({
|
||||
type: 'e2ee_authenticated'
|
||||
})
|
||||
expect(harness(registry, relay(stranger.deviceId)).connect(keyAuth).reply).toEqual({
|
||||
type: 'e2ee_error',
|
||||
error: { code: 'unauthorized' }
|
||||
})
|
||||
})
|
||||
|
||||
it('answers registry_unavailable, never device_revoked, after loading a corrupt registry', () => {
|
||||
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-device-key-'))
|
||||
writeFileSync(join(userDataPath, DEVICE_REGISTRY_FILENAME), '[{"deviceId":')
|
||||
const key = nacl.sign.keyPair()
|
||||
const attempt = harness(new DeviceRegistry(userDataPath)).connect((hash) => ({
|
||||
deviceId: 'keyed',
|
||||
deviceProofB64: sign(key, 'auth', hash)
|
||||
}))
|
||||
expect(attempt.reply).toEqual({ type: 'e2ee_error', error: { code: 'registry_unavailable' } })
|
||||
})
|
||||
|
||||
it('never answers device_revoked for a row that exists without a key', () => {
|
||||
const registry = freshRegistry()
|
||||
const legacy = registry.addDevice('Phone', 'mobile')
|
||||
const key = nacl.sign.keyPair()
|
||||
const attempt = harness(registry).connect((hash) => ({
|
||||
deviceId: legacy.deviceId,
|
||||
deviceProofB64: sign(key, 'auth', hash)
|
||||
}))
|
||||
expect(attempt.reply).toEqual({ type: 'e2ee_error', error: { code: 'bad_auth' } })
|
||||
})
|
||||
|
||||
it('answers device_revoked only when the registry is readable', () => {
|
||||
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-device-key-'))
|
||||
const key = nacl.sign.keyPair()
|
||||
const revoked = harness(new DeviceRegistry(userDataPath)).connect((hash) => ({
|
||||
deviceId: 'gone',
|
||||
deviceProofB64: sign(key, 'auth', hash)
|
||||
}))
|
||||
expect(revoked.reply).toEqual({
|
||||
type: 'e2ee_error',
|
||||
error: { code: 'device_revoked', deviceId: 'gone' }
|
||||
})
|
||||
|
||||
const unreadable = new DeviceRegistry(userDataPath)
|
||||
unreadable['registryUnreadable'] = true
|
||||
const unavailable = harness(unreadable).connect((hash) => ({
|
||||
deviceId: 'gone',
|
||||
deviceProofB64: sign(key, 'auth', hash)
|
||||
}))
|
||||
expect(unavailable.reply).toEqual({
|
||||
type: 'e2ee_error',
|
||||
error: { code: 'registry_unavailable' }
|
||||
})
|
||||
expect(unavailable.ws.close).toHaveBeenCalledWith(4001, 'Device registry unavailable')
|
||||
})
|
||||
|
||||
it('refuses registration over legacy framing', () => {
|
||||
const registry = freshRegistry()
|
||||
const legacy = registry.addDevice('Phone', 'mobile')
|
||||
const key = nacl.sign.keyPair()
|
||||
const params = {
|
||||
devicePublicKeyB64: Buffer.from(key.publicKey).toString('base64'),
|
||||
deviceProofB64: Buffer.alloc(64).toString('base64')
|
||||
}
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: registration reads only device and transcriptHashB64.
|
||||
const legacySocket = {
|
||||
device: { deviceId: legacy.deviceId, credentialRevision: 0, deviceToken: legacy.token },
|
||||
transcriptHashB64: null
|
||||
} as unknown as AuthenticatedMobileSocket
|
||||
expect(() => registerSocketDeviceKey(registry, legacySocket, params)).toThrow(
|
||||
'device_key_requires_e2ee_v2'
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps keyed rows out of pairing offers and rotation, and inert for token lookups', () => {
|
||||
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-device-key-'))
|
||||
const keyB64 = Buffer.from(nacl.sign.keyPair().publicKey).toString('base64')
|
||||
writeFileSync(
|
||||
join(userDataPath, DEVICE_REGISTRY_FILENAME),
|
||||
JSON.stringify([
|
||||
{
|
||||
deviceId: 'keyed',
|
||||
name: 'Phone',
|
||||
scope: 'mobile',
|
||||
pairedAt: 1,
|
||||
lastSeenAt: 0,
|
||||
credentialKind: 'device-key',
|
||||
devicePublicKeyB64: keyB64
|
||||
}
|
||||
])
|
||||
)
|
||||
const registry = new DeviceRegistry(userDataPath)
|
||||
expect(registry.validateToken('')).toBeNull()
|
||||
expect(registry.getPendingDevice('mobile')).toBeNull()
|
||||
const offered = registry.getOrCreatePendingDevice('New', 'mobile')
|
||||
expect(offered.deviceId).not.toBe('keyed')
|
||||
registry.rotatePendingDevice('Rotated', 'mobile')
|
||||
expect(registry.getDevice('keyed')).toMatchObject({ credentialKind: 'device-key' })
|
||||
expect(registry.lookupDeviceKey('keyed')).toMatchObject({ kind: 'found' })
|
||||
})
|
||||
})
|
||||
@@ -16,6 +16,7 @@ export type RpcDispatchStreamingOptions = Pick<
|
||||
| 'clientCapabilities'
|
||||
| 'updateClientCapabilities'
|
||||
| 'pairing'
|
||||
| 'deviceSelf'
|
||||
| 'mobileRelayHosts'
|
||||
| 'delegatedMobileDevices'
|
||||
| 'sendBinary'
|
||||
@@ -50,6 +51,7 @@ export function rpcContextFromTransport(
|
||||
updateClientCapabilities: options?.updateClientCapabilities,
|
||||
authenticatedCallerFingerprint: options?.authenticatedCallerFingerprint,
|
||||
pairing: options?.pairing,
|
||||
deviceSelf: options?.deviceSelf,
|
||||
mobileRelayHosts: options?.mobileRelayHosts,
|
||||
delegatedMobileDevices: options?.delegatedMobileDevices,
|
||||
sendBinary: options?.sendBinary,
|
||||
|
||||
@@ -7,15 +7,17 @@ import {
|
||||
} from './mobile-e2ee-v2-desktop-session'
|
||||
import type { DesktopMobileE2EEV2OutboundItem as V2OutboundItem } from './mobile-e2ee-v2-desktop-outbound'
|
||||
import { handleDesktopMobileE2EEV2Inbound } from './mobile-e2ee-v2-desktop-inbound'
|
||||
import { authenticateMobileE2EE, decodeMobileE2EEPublicKey } from './mobile-e2ee-auth-validation'
|
||||
import {
|
||||
isMobileE2EEBinaryPayloadWithinLimit,
|
||||
isMobileE2EEOutboundItemWithinLimit,
|
||||
isMobileE2EETextPayloadWithinLimit
|
||||
} from './mobile-e2ee-outbound-admission'
|
||||
authenticateMobileE2EE,
|
||||
decodeMobileE2EEPublicKey,
|
||||
describeMobileE2EEAuthFailure,
|
||||
type DeviceKeyResolution
|
||||
} from './mobile-e2ee-auth-validation'
|
||||
import { isMobileE2EEOutboundItemWithinLimit } from './mobile-e2ee-outbound-admission'
|
||||
import { parseRemoteRuntimeJsonText } from '../../../shared/remote-runtime-request-frames'
|
||||
import type { MobileE2EEOutboundMemoryBudget } from './mobile-e2ee-outbound-memory-budget'
|
||||
import { MobileE2EEDesktopOutboundOwner } from './mobile-e2ee-desktop-outbound-owner'
|
||||
import { createLegacyReplySenders } from './mobile-e2ee-legacy-reply-senders'
|
||||
import { parseRuntimeClientCapabilities } from './runtime-client-capabilities'
|
||||
import type {
|
||||
RpcBinarySendOptions,
|
||||
@@ -35,6 +37,8 @@ const MAX_CONSECUTIVE_DECRYPT_FAILURES = 5
|
||||
export type E2EEChannelOptions = {
|
||||
serverSecretKey: Uint8Array
|
||||
resolveAuthenticatedDevice: (token: string) => E2EEAuthenticatedDevice | null
|
||||
// Absent: key auth is refused as bad_auth, the same as before device keys existed.
|
||||
resolveAuthenticatedDeviceKey?: (deviceId: string) => DeviceKeyResolution<E2EEAuthenticatedDevice>
|
||||
onReady: (channel: E2EEChannel, device: E2EEAuthenticatedDevice) => void
|
||||
onError: (code: number, reason: string) => void
|
||||
transportContext?: DesktopMobileE2EEV2Context
|
||||
@@ -43,7 +47,8 @@ export type E2EEChannelOptions = {
|
||||
}
|
||||
|
||||
export type E2EEAuthenticatedDevice = DevicePrincipal & {
|
||||
deviceToken: string
|
||||
// Absent for a keyed row, which never presented a bearer.
|
||||
deviceToken?: string
|
||||
}
|
||||
|
||||
export class E2EEChannel {
|
||||
@@ -53,7 +58,10 @@ export class E2EEChannel {
|
||||
private handshakeTimer: ReturnType<typeof setTimeout> | null = null
|
||||
private readonly ws: WebSocket
|
||||
private readonly serverSecretKey: Uint8Array
|
||||
private readonly resolveAuthenticatedDevice: (token: string) => E2EEAuthenticatedDevice | null
|
||||
private readonly resolvers: Pick<
|
||||
E2EEChannelOptions,
|
||||
'resolveAuthenticatedDevice' | 'resolveAuthenticatedDeviceKey'
|
||||
>
|
||||
private readonly onReady: (channel: E2EEChannel, device: E2EEAuthenticatedDevice) => void
|
||||
private readonly onError: (code: number, reason: string) => void
|
||||
private readonly transportContext: DesktopMobileE2EEV2Context
|
||||
@@ -71,13 +79,18 @@ export class E2EEChannel {
|
||||
private binaryMessageHandler: ((plaintext: Uint8Array<ArrayBufferLike>) => void) | null = null
|
||||
|
||||
deviceToken: string | null = null
|
||||
|
||||
/** The v2 transcript this socket authenticated under; null on legacy framing. */
|
||||
get transcriptHashB64(): string | null {
|
||||
return this.v2Session?.transcriptHashB64 ?? null
|
||||
}
|
||||
authenticatedDevice: E2EEAuthenticatedDevice | null = null
|
||||
clientCapabilities: readonly RuntimeCapability[] = []
|
||||
|
||||
constructor(ws: WebSocket, options: E2EEChannelOptions) {
|
||||
this.ws = ws
|
||||
this.serverSecretKey = options.serverSecretKey
|
||||
this.resolveAuthenticatedDevice = options.resolveAuthenticatedDevice
|
||||
this.resolvers = options
|
||||
this.onReady = options.onReady
|
||||
this.onError = options.onError
|
||||
this.transportContext = options.transportContext ?? { transport: 'direct' }
|
||||
@@ -154,28 +167,13 @@ export class E2EEChannel {
|
||||
return
|
||||
}
|
||||
|
||||
// Why: streaming emits can outlive destroy(), so late replies must not encrypt with a cleared key.
|
||||
const encryptedReply = (response: string) => {
|
||||
if (!this.sharedKey || this.ws.readyState !== this.ws.OPEN) {
|
||||
return
|
||||
}
|
||||
if (!isMobileE2EETextPayloadWithinLimit(response)) {
|
||||
this.closeForOutboundBudget('size')
|
||||
return
|
||||
}
|
||||
this.outbound.enqueueLegacyText(encrypt(response, this.sharedKey))
|
||||
}
|
||||
const encryptedBinaryReply: RpcBinarySender = (response, options) => {
|
||||
if (!this.sharedKey || this.ws.readyState !== this.ws.OPEN) {
|
||||
return false
|
||||
}
|
||||
if (!isMobileE2EEBinaryPayloadWithinLimit(response)) {
|
||||
this.closeForOutboundBudget('size')
|
||||
return false
|
||||
}
|
||||
return this.outbound.enqueueLegacyBinary(response, this.sharedKey, options)
|
||||
}
|
||||
this.messageHandler?.(plaintext, encryptedReply, encryptedBinaryReply)
|
||||
const reply = createLegacyReplySenders({
|
||||
ws: this.ws,
|
||||
outbound: this.outbound,
|
||||
getSharedKey: () => this.sharedKey,
|
||||
onOversize: () => this.closeForOutboundBudget('size')
|
||||
})
|
||||
this.messageHandler?.(plaintext, reply.text, reply.binary)
|
||||
}
|
||||
|
||||
private trackDecryptFailure(): void {
|
||||
@@ -245,17 +243,19 @@ export class E2EEChannel {
|
||||
const authentication = authenticateMobileE2EE({
|
||||
plaintext,
|
||||
v2Session: this.v2Session,
|
||||
resolveDevice: this.resolveAuthenticatedDevice
|
||||
resolveDevice: this.resolvers.resolveAuthenticatedDevice,
|
||||
resolveDeviceKey: this.resolvers.resolveAuthenticatedDeviceKey
|
||||
})
|
||||
if (!authentication.ok) {
|
||||
this.sendEncryptedControl({ type: 'e2ee_error', error: { code: authentication.code } })
|
||||
this.onError(4001, authentication.code === 'bad_auth' ? 'Invalid e2ee_auth' : 'Unauthorized')
|
||||
const failure = describeMobileE2EEAuthFailure(authentication)
|
||||
this.sendEncryptedControl(failure.sealedError)
|
||||
this.onError(4001, failure.closeReason)
|
||||
return
|
||||
}
|
||||
const authenticatedDevice = authentication.device
|
||||
|
||||
this.clientCapabilities = parseRuntimeClientCapabilities(authentication.auth.clientCapabilities)
|
||||
this.deviceToken = authenticatedDevice.deviceToken
|
||||
this.clientCapabilities = parseRuntimeClientCapabilities(authentication.clientCapabilities)
|
||||
this.deviceToken = authenticatedDevice.deviceToken ?? null
|
||||
this.authenticatedDevice = authenticatedDevice
|
||||
this.state = 'ready'
|
||||
|
||||
|
||||
@@ -3,6 +3,8 @@ import {
|
||||
DELEGATED_MOBILE_DEVICE_SYNC_METHOD,
|
||||
DelegatedMobileDeviceSyncParamsSchema
|
||||
} from '../../../../shared/delegated-mobile-device-contract'
|
||||
import { RegisterDeviceKeyParamsSchema } from '../../../../shared/device-key-registration-contract'
|
||||
import { REGISTER_DEVICE_KEY_METHOD } from '../../../../shared/device-key-proof'
|
||||
import {
|
||||
PairingGetEndpointsParamsSchema,
|
||||
PairingProvisionRelayParamsSchema
|
||||
@@ -31,6 +33,17 @@ export const PAIRING_METHODS = [
|
||||
return await ctx.pairing.provisionRelay(params)
|
||||
}
|
||||
}),
|
||||
defineMethod({
|
||||
name: REGISTER_DEVICE_KEY_METHOD,
|
||||
permission: 'device-self',
|
||||
params: RegisterDeviceKeyParamsSchema,
|
||||
handler: (params, ctx) => {
|
||||
if (!ctx.deviceSelf) {
|
||||
throw new Error('device_key_registration_unavailable')
|
||||
}
|
||||
return ctx.deviceSelf.registerDeviceKey(params)
|
||||
}
|
||||
}),
|
||||
defineMethod({
|
||||
name: DELEGATED_MOBILE_DEVICE_SYNC_METHOD,
|
||||
// Why: paired desktops call this and are never granted pairing-admin; children reach no further (rpc-caller-scope.ts).
|
||||
|
||||
@@ -185,6 +185,7 @@ describe('other clients after a clear', () => {
|
||||
{
|
||||
ws: socket,
|
||||
connectionId: 'phone-connection',
|
||||
transcriptHashB64: null,
|
||||
device: { ...devicePrincipalOf(phone), deviceToken: phone.token },
|
||||
clientCapabilities: capabilities,
|
||||
transport: { transport: 'direct' }
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { classifyE2EEAuth } from './mobile-e2ee-auth-validation'
|
||||
import type { DesktopMobileE2EEV2Session } from './mobile-e2ee-v2-desktop-session'
|
||||
|
||||
const transcriptHashB64 = Buffer.alloc(32, 3).toString('base64')
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: classification reads only transcriptHashB64.
|
||||
const v2 = { transcriptHashB64 } as DesktopMobileE2EEV2Session
|
||||
const proof = Buffer.alloc(64, 1).toString('base64')
|
||||
const auth = (fields: Record<string, unknown>) => JSON.stringify({ type: 'e2ee_auth', ...fields })
|
||||
|
||||
describe('classifyE2EEAuth', () => {
|
||||
it('keeps both shipped token shapes', () => {
|
||||
expect(classifyE2EEAuth(auth({ deviceToken: 't', clientCapabilities: ['x'] }), null)).toEqual({
|
||||
kind: 'token',
|
||||
deviceToken: 't',
|
||||
clientCapabilities: ['x']
|
||||
})
|
||||
expect(classifyE2EEAuth(auth({ v: 2, transcriptHashB64, deviceToken: 't' }), v2)).toEqual({
|
||||
kind: 'token',
|
||||
deviceToken: 't'
|
||||
})
|
||||
})
|
||||
|
||||
it('accepts key auth only inside the matching v2 transcript', () => {
|
||||
const keyAuth = { v: 2, transcriptHashB64, deviceId: 'd', deviceProofB64: proof }
|
||||
expect(classifyE2EEAuth(auth({ ...keyAuth, clientCapabilities: ['x'] }), v2)).toMatchObject({
|
||||
kind: 'device-key',
|
||||
deviceId: 'd'
|
||||
})
|
||||
expect(classifyE2EEAuth(auth(keyAuth), null).kind).toBe('invalid')
|
||||
expect(
|
||||
classifyE2EEAuth(
|
||||
auth({ ...keyAuth, transcriptHashB64: Buffer.alloc(32).toString('base64') }),
|
||||
v2
|
||||
).kind
|
||||
).toBe('invalid')
|
||||
})
|
||||
|
||||
it('rejects mixtures and malformed proofs', () => {
|
||||
const base = { v: 2, transcriptHashB64 }
|
||||
expect(
|
||||
classifyE2EEAuth(
|
||||
auth({ ...base, deviceId: 'd', deviceProofB64: proof, deviceToken: 't' }),
|
||||
v2
|
||||
).kind
|
||||
).toBe('invalid')
|
||||
expect(
|
||||
classifyE2EEAuth(auth({ ...base, deviceToken: 't', clientCapabilities: [] }), v2).kind
|
||||
).toBe('invalid')
|
||||
expect(
|
||||
classifyE2EEAuth(auth({ ...base, deviceId: 'd', deviceProofB64: 'short' }), v2).kind
|
||||
).toBe('invalid')
|
||||
expect(classifyE2EEAuth(auth({ deviceToken: 't', deviceId: 'd' }), null).kind).toBe('invalid')
|
||||
expect(classifyE2EEAuth('[]', null).kind).toBe('invalid')
|
||||
})
|
||||
})
|
||||
@@ -1,54 +1,157 @@
|
||||
import type { DesktopMobileE2EEV2Session } from './mobile-e2ee-v2-desktop-session'
|
||||
import { publicKeyFromBase64 } from './e2ee-crypto'
|
||||
import { parseRemoteRuntimeJsonText } from '../../../shared/remote-runtime-request-frames'
|
||||
import { isDeviceProofB64, verifyDeviceProof } from '../../../shared/device-key-proof'
|
||||
|
||||
export type MobileE2EEAuth = {
|
||||
type: 'e2ee_auth'
|
||||
deviceToken: string
|
||||
clientCapabilities?: unknown
|
||||
v?: 2
|
||||
transcriptHashB64?: string
|
||||
}
|
||||
export type ClassifiedE2EEAuth =
|
||||
| { kind: 'token'; deviceToken: string; clientCapabilities?: unknown }
|
||||
| { kind: 'device-key'; deviceId: string; deviceProofB64: string; clientCapabilities?: unknown }
|
||||
| { kind: 'invalid' }
|
||||
|
||||
export function isValidMobileE2EEAuthVersion(
|
||||
auth: MobileE2EEAuth,
|
||||
const V2_TOKEN_KEYS = 'deviceToken,transcriptHashB64,type,v'
|
||||
const V2_DEVICE_KEY_KEYS = new Set(['clientCapabilities', 'deviceId', 'deviceProofB64'])
|
||||
|
||||
/** The one parser for e2ee_auth: every accepted shape, nothing mixed. */
|
||||
export function classifyE2EEAuth(
|
||||
plaintext: string,
|
||||
v2Session: DesktopMobileE2EEV2Session | null
|
||||
): boolean {
|
||||
if (!v2Session) {
|
||||
return auth.v === undefined && auth.transcriptHashB64 === undefined
|
||||
): ClassifiedE2EEAuth {
|
||||
let auth: Record<string, unknown>
|
||||
try {
|
||||
const parsed: unknown = parseRemoteRuntimeJsonText(plaintext)
|
||||
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
return { kind: 'invalid' }
|
||||
}
|
||||
auth = { ...parsed }
|
||||
} catch {
|
||||
return { kind: 'invalid' }
|
||||
}
|
||||
// Why: mobile v2 keeps an exact transcript-bound shape; runtime capabilities use legacy paired-runtime auth.
|
||||
return (
|
||||
Object.keys(auth).sort().join(',') === 'deviceToken,transcriptHashB64,type,v' &&
|
||||
auth.v === 2 &&
|
||||
auth.transcriptHashB64 === v2Session.transcriptHashB64
|
||||
)
|
||||
if (auth.type !== 'e2ee_auth') {
|
||||
return { kind: 'invalid' }
|
||||
}
|
||||
if (!v2Session) {
|
||||
// Why: legacy framing keeps today's token shape; key auth exists only inside a v2 transcript.
|
||||
return typeof auth.deviceToken === 'string' &&
|
||||
auth.deviceToken &&
|
||||
auth.v === undefined &&
|
||||
auth.transcriptHashB64 === undefined &&
|
||||
auth.deviceId === undefined &&
|
||||
auth.deviceProofB64 === undefined
|
||||
? {
|
||||
kind: 'token',
|
||||
deviceToken: auth.deviceToken,
|
||||
clientCapabilities: auth.clientCapabilities
|
||||
}
|
||||
: { kind: 'invalid' }
|
||||
}
|
||||
if (auth.v !== 2 || auth.transcriptHashB64 !== v2Session.transcriptHashB64) {
|
||||
return { kind: 'invalid' }
|
||||
}
|
||||
const keys = Object.keys(auth).sort()
|
||||
// Why: mobile v2 token auth stays exact-key; capabilities ride only on key auth.
|
||||
if (
|
||||
keys.join(',') === V2_TOKEN_KEYS &&
|
||||
typeof auth.deviceToken === 'string' &&
|
||||
auth.deviceToken
|
||||
) {
|
||||
return { kind: 'token', deviceToken: auth.deviceToken }
|
||||
}
|
||||
const rest = keys.filter((key) => key !== 'type' && key !== 'v' && key !== 'transcriptHashB64')
|
||||
if (
|
||||
rest.every((key) => V2_DEVICE_KEY_KEYS.has(key)) &&
|
||||
typeof auth.deviceId === 'string' &&
|
||||
auth.deviceId.length > 0 &&
|
||||
auth.deviceId.length <= 128 &&
|
||||
isDeviceProofB64(auth.deviceProofB64)
|
||||
) {
|
||||
return {
|
||||
kind: 'device-key',
|
||||
deviceId: auth.deviceId,
|
||||
deviceProofB64: auth.deviceProofB64,
|
||||
clientCapabilities: auth.clientCapabilities
|
||||
}
|
||||
}
|
||||
return { kind: 'invalid' }
|
||||
}
|
||||
|
||||
export function authenticateMobileE2EE<TDevice extends { deviceToken: string }>(args: {
|
||||
export type MobileE2EEAuthFailureCode =
|
||||
| 'bad_auth'
|
||||
| 'unauthorized'
|
||||
| 'device_revoked'
|
||||
| 'registry_unavailable'
|
||||
| 'bad_proof'
|
||||
|
||||
export type DeviceKeyResolution<TDevice> =
|
||||
| { kind: 'found'; device: TDevice; devicePublicKeyB64: string }
|
||||
| { kind: 'unknown' }
|
||||
| { kind: 'not-keyed' }
|
||||
// The device exists but may not authenticate on this transport (e.g. another Relay binding).
|
||||
| { kind: 'refused' }
|
||||
| { kind: 'unavailable' }
|
||||
|
||||
export function authenticateMobileE2EE<TDevice extends { deviceToken?: string }>(args: {
|
||||
plaintext: string
|
||||
v2Session: DesktopMobileE2EEV2Session | null
|
||||
resolveDevice: (token: string) => TDevice | null
|
||||
// Absent on a host that does not accept device keys: key auth is then bad_auth, as before.
|
||||
resolveDeviceKey?: (deviceId: string) => DeviceKeyResolution<TDevice>
|
||||
}):
|
||||
| { ok: true; device: TDevice; auth: MobileE2EEAuth }
|
||||
| { ok: false; code: 'bad_auth' | 'unauthorized' } {
|
||||
let auth: MobileE2EEAuth
|
||||
try {
|
||||
auth = parseRemoteRuntimeJsonText(args.plaintext) as MobileE2EEAuth
|
||||
} catch {
|
||||
| { ok: true; device: TDevice; clientCapabilities: unknown }
|
||||
| { ok: false; code: MobileE2EEAuthFailureCode; deviceId?: string } {
|
||||
const auth = classifyE2EEAuth(args.plaintext, args.v2Session)
|
||||
if (auth.kind === 'token') {
|
||||
const device = args.resolveDevice(auth.deviceToken)
|
||||
// Why: a keyed row has no token, so presenting one can never match it.
|
||||
return device?.deviceToken === auth.deviceToken
|
||||
? { ok: true, device, clientCapabilities: auth.clientCapabilities }
|
||||
: { ok: false, code: 'unauthorized' }
|
||||
}
|
||||
if (auth.kind === 'invalid' || !args.resolveDeviceKey || !args.v2Session) {
|
||||
return { ok: false, code: 'bad_auth' }
|
||||
}
|
||||
if (
|
||||
auth.type !== 'e2ee_auth' ||
|
||||
!auth.deviceToken ||
|
||||
!isValidMobileE2EEAuthVersion(auth, args.v2Session)
|
||||
) {
|
||||
const resolution = args.resolveDeviceKey(auth.deviceId)
|
||||
if (resolution.kind === 'unavailable') {
|
||||
return { ok: false, code: 'registry_unavailable' }
|
||||
}
|
||||
if (resolution.kind === 'unknown') {
|
||||
return { ok: false, code: 'device_revoked', deviceId: auth.deviceId }
|
||||
}
|
||||
if (resolution.kind === 'not-keyed') {
|
||||
return { ok: false, code: 'bad_auth' }
|
||||
}
|
||||
const device = args.resolveDevice(auth.deviceToken)
|
||||
return device?.deviceToken === auth.deviceToken
|
||||
? { ok: true, device, auth }
|
||||
: { ok: false, code: 'unauthorized' }
|
||||
if (resolution.kind === 'refused') {
|
||||
return { ok: false, code: 'unauthorized' }
|
||||
}
|
||||
const verified = verifyDeviceProof({
|
||||
devicePublicKeyB64: resolution.devicePublicKeyB64,
|
||||
deviceProofB64: auth.deviceProofB64,
|
||||
purpose: 'auth',
|
||||
transcriptHashB64: args.v2Session.transcriptHashB64
|
||||
})
|
||||
return verified
|
||||
? { ok: true, device: resolution.device, clientCapabilities: auth.clientCapabilities }
|
||||
: { ok: false, code: 'bad_proof' }
|
||||
}
|
||||
|
||||
// Why: sealed, so only the pinned host can tell a client its key is revoked (it then deletes it);
|
||||
// 'Unauthorized' marks an unpaired device for the re-pair prompt, storage trouble is not one.
|
||||
export function describeMobileE2EEAuthFailure(failure: {
|
||||
code: MobileE2EEAuthFailureCode
|
||||
deviceId?: string
|
||||
}): { sealedError: unknown; closeReason: string } {
|
||||
const closeReason =
|
||||
failure.code === 'unauthorized' || failure.code === 'device_revoked'
|
||||
? 'Unauthorized'
|
||||
: failure.code === 'registry_unavailable'
|
||||
? 'Device registry unavailable'
|
||||
: 'Invalid e2ee_auth'
|
||||
return {
|
||||
sealedError: {
|
||||
type: 'e2ee_error',
|
||||
error: { code: failure.code, ...(failure.deviceId ? { deviceId: failure.deviceId } : {}) }
|
||||
},
|
||||
closeReason
|
||||
}
|
||||
}
|
||||
|
||||
export function decodeMobileE2EEPublicKey(value: string): Uint8Array | null {
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { WebSocket } from 'ws'
|
||||
import { encrypt } from './e2ee-crypto'
|
||||
import type { MobileE2EEDesktopOutboundOwner } from './mobile-e2ee-desktop-outbound-owner'
|
||||
import {
|
||||
isMobileE2EEBinaryPayloadWithinLimit,
|
||||
isMobileE2EETextPayloadWithinLimit
|
||||
} from './mobile-e2ee-outbound-admission'
|
||||
import type { RpcBinarySender } from './rpc-binary-sender'
|
||||
|
||||
/** Reply senders for one legacy-framed request. */
|
||||
export function createLegacyReplySenders(args: {
|
||||
ws: WebSocket
|
||||
outbound: MobileE2EEDesktopOutboundOwner
|
||||
getSharedKey: () => Uint8Array | null
|
||||
onOversize: () => void
|
||||
}): { text: (response: string) => void; binary: RpcBinarySender } {
|
||||
return {
|
||||
// Why: streaming emits can outlive destroy(), so late replies must not encrypt with a cleared key.
|
||||
text: (response) => {
|
||||
const sharedKey = args.getSharedKey()
|
||||
if (!sharedKey || args.ws.readyState !== args.ws.OPEN) {
|
||||
return
|
||||
}
|
||||
if (!isMobileE2EETextPayloadWithinLimit(response)) {
|
||||
args.onOversize()
|
||||
return
|
||||
}
|
||||
args.outbound.enqueueLegacyText(encrypt(response, sharedKey))
|
||||
},
|
||||
binary: (response, options) => {
|
||||
const sharedKey = args.getSharedKey()
|
||||
if (!sharedKey || args.ws.readyState !== args.ws.OPEN) {
|
||||
return false
|
||||
}
|
||||
if (!isMobileE2EEBinaryPayloadWithinLimit(response)) {
|
||||
args.onOversize()
|
||||
return false
|
||||
}
|
||||
return args.outbound.enqueueLegacyBinary(response, sharedKey, options)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,8 @@ export type AuthenticatedMobileSocket = {
|
||||
ws: WebSocket
|
||||
connectionId: string
|
||||
device: E2EEAuthenticatedDevice
|
||||
// Why: a device-key registration proves possession over this exact connection.
|
||||
transcriptHashB64: string | null
|
||||
clientCapabilities: readonly RuntimeCapability[]
|
||||
transport: MobileSocketTransportMetadata
|
||||
}
|
||||
@@ -60,7 +62,10 @@ type MobileSocketWiringOptions = {
|
||||
}
|
||||
|
||||
function toAuthenticatedDevice(device: DeviceEntry): E2EEAuthenticatedDevice {
|
||||
return { ...devicePrincipalOf(device), deviceToken: device.token }
|
||||
return {
|
||||
...devicePrincipalOf(device),
|
||||
...(device.token !== undefined ? { deviceToken: device.token } : {})
|
||||
}
|
||||
}
|
||||
|
||||
export class MobileSocketWiring {
|
||||
@@ -158,11 +163,33 @@ export class MobileSocketWiring {
|
||||
}
|
||||
return toAuthenticatedDevice(device)
|
||||
},
|
||||
resolveAuthenticatedDeviceKey: (deviceId) => {
|
||||
const lookup = this.deviceRegistry.lookupDeviceKey(deviceId)
|
||||
if (lookup.kind !== 'found') {
|
||||
return lookup
|
||||
}
|
||||
// Why: same relay binding rule as token auth — the outer relay cannot pick the identity.
|
||||
// A fork keeps its token row's Relay binding (the key proof names the installation).
|
||||
// Never "unknown": the device exists, and that verdict would make the client delete its key.
|
||||
if (
|
||||
metadata.transport === 'relay' &&
|
||||
metadata.relayDeviceId !== lookup.device.deviceId &&
|
||||
metadata.relayDeviceId !== lookup.device.forkedFrom
|
||||
) {
|
||||
return { kind: 'refused' }
|
||||
}
|
||||
return {
|
||||
kind: 'found',
|
||||
device: toAuthenticatedDevice(lookup.device),
|
||||
devicePublicKeyB64: lookup.device.devicePublicKeyB64
|
||||
}
|
||||
},
|
||||
onReady: (channel, device) => {
|
||||
const socket = {
|
||||
ws,
|
||||
connectionId,
|
||||
device,
|
||||
transcriptHashB64: channel.transcriptHashB64,
|
||||
// Why: the channel owns the set for the whole connection, so this reads
|
||||
// through rather than snapshotting. It must also WRITE through — the
|
||||
// capability RPC updates the socket, and a getter-only property makes
|
||||
|
||||
@@ -29,7 +29,8 @@ const RUNTIME_PAIRED_STANDARD: ReadonlySet<RpcMethodPermission> = new Set([
|
||||
'settings-write',
|
||||
'accounts-admin',
|
||||
'skills-admin',
|
||||
'host-admin'
|
||||
'host-admin',
|
||||
'device-self'
|
||||
])
|
||||
|
||||
/**
|
||||
|
||||
@@ -28,6 +28,8 @@ export type RpcMethodPermission =
|
||||
| 'pairing-admin'
|
||||
/** Updates, managed servers, SSH connections, plugins and network tunnels on this host. */
|
||||
| 'host-admin'
|
||||
/** Changes only the caller's own paired-device credential (never another device's). */
|
||||
| 'device-self'
|
||||
|
||||
/** Administrative permissions a paired runtime client holds only when granted at pairing time. */
|
||||
export const RUNTIME_DEVICE_GRANTS = [
|
||||
@@ -42,5 +44,6 @@ export const RPC_METHOD_PERMISSIONS: readonly RpcMethodPermission[] = [
|
||||
'settings-write',
|
||||
'skills-admin',
|
||||
'pairing-admin',
|
||||
'host-admin'
|
||||
'host-admin',
|
||||
'device-self'
|
||||
]
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import { mkdtempSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import nacl from 'tweetnacl'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { encodeDeviceProofMessage } from '../../shared/device-key-proof'
|
||||
import { devicePrincipalOf } from './device-principal'
|
||||
import { DeviceRegistry } from './device-registry'
|
||||
import type { OrcaRuntimeService } from './orca-runtime'
|
||||
import { OrcaRuntimeRpcServer } from './runtime-rpc'
|
||||
import type { AuthenticatedMobileSocket } from './rpc/mobile-socket-wiring'
|
||||
|
||||
function keyedServer() {
|
||||
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-device-key-dispatch-'))
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: status.get only reads these three members.
|
||||
const runtime = {
|
||||
configureNotificationDismissalStore: () => {},
|
||||
getRuntimeId: () => 'test-runtime',
|
||||
getStatus: vi.fn().mockResolvedValue({ graphStatus: 'ok' })
|
||||
} as unknown as OrcaRuntimeService
|
||||
const server = new OrcaRuntimeRpcServer({ runtime, userDataPath, enableWebSocket: false })
|
||||
const registry = new DeviceRegistry(userDataPath)
|
||||
server['deviceRegistry'] = registry
|
||||
const legacy = registry.addDevice('Laptop', 'runtime')
|
||||
const keyed = registry.forkDeviceKey(
|
||||
legacy.deviceId,
|
||||
Buffer.alloc(32, 5).toString('base64'),
|
||||
'Mac'
|
||||
)
|
||||
const socket: AuthenticatedMobileSocket = {
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: dispatch only uses ws as a map key for abort registration.
|
||||
ws: {} as AuthenticatedMobileSocket['ws'],
|
||||
connectionId: 'connection-1',
|
||||
transcriptHashB64: Buffer.alloc(32).toString('base64'),
|
||||
device: devicePrincipalOf(keyed),
|
||||
clientCapabilities: [],
|
||||
transport: { transport: 'direct' }
|
||||
}
|
||||
const send = async (request: Record<string, unknown>) => {
|
||||
const replies: string[] = []
|
||||
await server['handleWebSocketMessage'](
|
||||
JSON.stringify(request),
|
||||
(response) => replies.push(response),
|
||||
() => {},
|
||||
undefined,
|
||||
undefined,
|
||||
null,
|
||||
socket
|
||||
)
|
||||
const reply: unknown = JSON.parse(replies.at(-1)!)
|
||||
return reply
|
||||
}
|
||||
return { server, registry, keyed, legacy, send }
|
||||
}
|
||||
|
||||
describe('requests on a key-authenticated socket', () => {
|
||||
it('dispatch with the device id as client id and no token', async () => {
|
||||
const { server, keyed, send } = keyedServer()
|
||||
const dispatch = vi.spyOn(server['dispatcher'], 'dispatchStreaming')
|
||||
const reply = await send({ id: 'r1', method: 'status.get' })
|
||||
expect(reply).not.toMatchObject({ error: { code: 'unauthorized' } })
|
||||
expect(dispatch.mock.calls[0]?.[2]).toMatchObject({
|
||||
clientId: keyed.deviceId,
|
||||
pairedDeviceId: keyed.deviceId
|
||||
})
|
||||
})
|
||||
|
||||
it('refuses a request that carries any token, and every request once the row is revoked', async () => {
|
||||
const { registry, keyed, legacy, send } = keyedServer()
|
||||
expect(await send({ id: 'r1', method: 'status.get', deviceToken: legacy.token })).toMatchObject(
|
||||
{
|
||||
ok: false,
|
||||
error: { code: 'unauthorized' }
|
||||
}
|
||||
)
|
||||
registry.removeDevice(keyed.deviceId)
|
||||
expect(await send({ id: 'r2', method: 'status.get' })).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: 'unauthorized' }
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('pairing.registerDeviceKey over the dispatcher', () => {
|
||||
it.each(['runtime', 'mobile'] as const)(
|
||||
'forks a %s token row bound to the calling socket',
|
||||
async (scope) => {
|
||||
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-device-key-register-'))
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: registration never touches the runtime service.
|
||||
const runtime = {
|
||||
configureNotificationDismissalStore: () => {},
|
||||
getRuntimeId: () => 'test-runtime'
|
||||
} as unknown as OrcaRuntimeService
|
||||
const server = new OrcaRuntimeRpcServer({ runtime, userDataPath, enableWebSocket: false })
|
||||
const registry = new DeviceRegistry(userDataPath)
|
||||
server['deviceRegistry'] = registry
|
||||
const legacy = registry.addDevice('Device', scope)
|
||||
const transcriptHashB64 = Buffer.alloc(32, 9).toString('base64')
|
||||
const key = nacl.sign.keyPair()
|
||||
const proof = nacl.sign.detached(
|
||||
encodeDeviceProofMessage('register', Buffer.from(transcriptHashB64, 'base64')),
|
||||
key.secretKey
|
||||
)
|
||||
const replies: string[] = []
|
||||
await server['handleWebSocketMessage'](
|
||||
JSON.stringify({
|
||||
id: 'register',
|
||||
method: 'pairing.registerDeviceKey',
|
||||
params: {
|
||||
devicePublicKeyB64: Buffer.from(key.publicKey).toString('base64'),
|
||||
deviceProofB64: Buffer.from(proof).toString('base64'),
|
||||
deviceLabel: 'Laptop'
|
||||
}
|
||||
}),
|
||||
(response) => replies.push(response),
|
||||
() => {},
|
||||
undefined,
|
||||
undefined,
|
||||
legacy.token,
|
||||
{
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: dispatch only uses ws as a map key for abort registration.
|
||||
ws: {} as AuthenticatedMobileSocket['ws'],
|
||||
connectionId: 'connection-1',
|
||||
transcriptHashB64,
|
||||
device: { ...devicePrincipalOf(legacy), deviceToken: legacy.token },
|
||||
clientCapabilities: [],
|
||||
transport: { transport: 'direct' }
|
||||
}
|
||||
)
|
||||
const reply: unknown = JSON.parse(replies.at(-1)!)
|
||||
expect(reply).toMatchObject({ id: 'register', ok: true, result: { credentialRevision: 0 } })
|
||||
expect(
|
||||
registry.listDevices().filter((row) => row.forkedFrom === legacy.deviceId)
|
||||
).toHaveLength(1)
|
||||
}
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,41 @@
|
||||
import type {
|
||||
RegisterDeviceKeyParams,
|
||||
RegisterDeviceKeyResult
|
||||
} from '../../../shared/device-key-registration-contract'
|
||||
import { sanitizeDeviceLabel, verifyDeviceProof } from '../../../shared/device-key-proof'
|
||||
import type { DeviceRegistry } from '../device-registry'
|
||||
import type { AuthenticatedMobileSocket } from '../rpc/mobile-socket-wiring'
|
||||
|
||||
/**
|
||||
* Registers the calling installation's key by forking its token row (device-key-fork.ts). Proof of
|
||||
* possession is bound to this socket's v2 transcript, so a captured proof is useless elsewhere.
|
||||
*/
|
||||
export function registerSocketDeviceKey(
|
||||
registry: DeviceRegistry,
|
||||
socket: AuthenticatedMobileSocket,
|
||||
params: RegisterDeviceKeyParams
|
||||
): RegisterDeviceKeyResult {
|
||||
if (!socket.transcriptHashB64) {
|
||||
throw new Error('device_key_requires_e2ee_v2')
|
||||
}
|
||||
if (
|
||||
!verifyDeviceProof({
|
||||
devicePublicKeyB64: params.devicePublicKeyB64,
|
||||
deviceProofB64: params.deviceProofB64,
|
||||
purpose: 'register',
|
||||
transcriptHashB64: socket.transcriptHashB64
|
||||
})
|
||||
) {
|
||||
throw new Error('bad_proof')
|
||||
}
|
||||
const legacy = registry.resolveAuthenticatedPrincipal(socket.device)
|
||||
if (!legacy) {
|
||||
throw new Error('unauthorized')
|
||||
}
|
||||
const entry = registry.forkDeviceKey(
|
||||
legacy.deviceId,
|
||||
params.devicePublicKeyB64,
|
||||
sanitizeDeviceLabel(params.deviceLabel, legacy.name)
|
||||
)
|
||||
return { deviceId: entry.deviceId, credentialRevision: entry.credentialRevision ?? 0 }
|
||||
}
|
||||
@@ -235,7 +235,7 @@ export class RuntimeRpcLifecycle extends RuntimeRpcWebSocketDispatch {
|
||||
sendBinary,
|
||||
undefined,
|
||||
socket.ws,
|
||||
socket.device.deviceToken,
|
||||
socket.device.deviceToken ?? null,
|
||||
socket
|
||||
)
|
||||
)
|
||||
|
||||
@@ -12,6 +12,7 @@ export const PAIRED_DESKTOP_METHODS = [
|
||||
'notifications.unsubscribe',
|
||||
'pairing.getEndpoints',
|
||||
'pairing.provisionRelay',
|
||||
'pairing.registerDeviceKey',
|
||||
// The configured servers the desktop shows, and their workspace lists as it last fetched them.
|
||||
'mobileRelay.hosts.list',
|
||||
'mobileRelay.hosts.worktrees',
|
||||
|
||||
@@ -167,7 +167,7 @@ export class RuntimeRpcMobilePairing extends RuntimeRpcPairing {
|
||||
}
|
||||
const device = this.deviceRegistry?.getDevice(direct.deviceId)
|
||||
const publicKeyB64 = this.getE2EEPublicKey()
|
||||
if (!device || !publicKeyB64) {
|
||||
if (!device?.token || !publicKeyB64) {
|
||||
return refuseAutomaticWithoutRelay({
|
||||
code: 'e2ee_key_unavailable',
|
||||
stage: 'e2ee_missing',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { RuntimeDeviceGrant } from '../rpc/rpc-caller-scope'
|
||||
import type { DeviceEntry, DeviceRegistry, DeviceScope } from '../device-registry'
|
||||
import type { DeviceRegistry, DeviceScope, TokenDeviceEntry } from '../device-registry'
|
||||
import type { E2EEKeypair } from '../e2ee-keypair'
|
||||
import type { MobileSocketWiring } from '../rpc/mobile-socket-wiring'
|
||||
import type { RelayDeviceBinding, RelayRevokeOutbox } from '../relay/relay-revoke-outbox'
|
||||
@@ -171,7 +171,7 @@ export class RuntimeRpcPairing extends RuntimeRpcNetworkExposure {
|
||||
const endpoint = advertised.endpoint
|
||||
const deviceName = args.name ?? `CLI ${new Date().toLocaleDateString()}`
|
||||
const scope = args.scope ?? 'runtime'
|
||||
let device: DeviceEntry
|
||||
let device: TokenDeviceEntry
|
||||
try {
|
||||
const reach = args.reach ?? 'network'
|
||||
const grants = args.grants ?? []
|
||||
|
||||
@@ -4,6 +4,7 @@ import type {
|
||||
PairingProvisionRelayParams
|
||||
} from '../../../shared/mobile-relay-credential-contract'
|
||||
import { callerScopeOf, devicePrincipalOf } from '../device-principal'
|
||||
import { registerSocketDeviceKey } from './runtime-rpc-device-key-registration'
|
||||
import type { AuthenticatedMobileSocket } from '../rpc/mobile-socket-wiring'
|
||||
import type { RpcRequest, RpcResponse } from '../rpc/core'
|
||||
import type { WebSocketTransport } from '../rpc/ws-transport'
|
||||
@@ -81,7 +82,7 @@ export class RuntimeRpcWebSocketDispatch extends RuntimeRpcMobileDevices {
|
||||
{
|
||||
connectionId: socket.connectionId,
|
||||
deviceId: socket.device.deviceId,
|
||||
deviceToken: socket.device.deviceToken,
|
||||
deviceToken: socket.device.clientId,
|
||||
clientCapabilities: () => socket.clientCapabilities,
|
||||
reply,
|
||||
sendBinary: (bytes) => {
|
||||
@@ -127,13 +128,17 @@ export class RuntimeRpcWebSocketDispatch extends RuntimeRpcMobileDevices {
|
||||
typeof (request as Record<string, unknown>).deviceToken === 'string'
|
||||
? ((request as Record<string, unknown>).deviceToken as string)
|
||||
: null
|
||||
if (authenticatedDeviceToken && requestToken && requestToken !== authenticatedDeviceToken) {
|
||||
// Why: a keyed socket proved no token, so any request token is a mismatch.
|
||||
const boundToken = authenticatedSocket
|
||||
? (authenticatedSocket.device.deviceToken ?? null)
|
||||
: authenticatedDeviceToken
|
||||
if (requestToken && (authenticatedSocket || boundToken) && requestToken !== boundToken) {
|
||||
reply(JSON.stringify(this.buildError(request.id, 'unauthorized', 'Device token mismatch')))
|
||||
return
|
||||
}
|
||||
// Why: E2EE already authenticated the channel; authorize by that bound identity, not a repeated request field.
|
||||
const token = authenticatedDeviceToken ?? requestToken
|
||||
if (!token) {
|
||||
const token = boundToken ?? requestToken
|
||||
if (!token && !authenticatedSocket) {
|
||||
reply(JSON.stringify(this.buildError(request.id, 'unauthorized', 'Missing device token')))
|
||||
return
|
||||
}
|
||||
@@ -141,7 +146,7 @@ export class RuntimeRpcWebSocketDispatch extends RuntimeRpcMobileDevices {
|
||||
// a revoke or credential change refuses its next request even before the socket closes.
|
||||
const device = authenticatedSocket
|
||||
? this.deviceRegistry?.resolveAuthenticatedPrincipal(authenticatedSocket.device)
|
||||
: this.deviceRegistry?.validateToken(token)
|
||||
: this.deviceRegistry?.validateToken(token ?? '')
|
||||
if (!device) {
|
||||
reply(JSON.stringify(this.buildError(request.id, 'unauthorized', 'Invalid device token')))
|
||||
return
|
||||
@@ -230,6 +235,13 @@ export class RuntimeRpcWebSocketDispatch extends RuntimeRpcMobileDevices {
|
||||
}
|
||||
: undefined,
|
||||
pairing: pairingContext,
|
||||
deviceSelf:
|
||||
authenticatedSocket && this.deviceRegistry
|
||||
? {
|
||||
registerDeviceKey: (params) =>
|
||||
registerSocketDeviceKey(this.deviceRegistry!, authenticatedSocket, params)
|
||||
}
|
||||
: undefined,
|
||||
mobileRelayHosts: this.mobileRelayHostCatalog ?? undefined,
|
||||
delegatedMobileDevices:
|
||||
device.scope === 'runtime'
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import nacl from 'tweetnacl'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
encodeDeviceProofMessage,
|
||||
isDevicePublicKeyB64,
|
||||
sanitizeDeviceLabel,
|
||||
verifyDeviceProof
|
||||
} from './device-key-proof'
|
||||
|
||||
const hash = new Uint8Array(32).fill(7)
|
||||
const hashB64 = Buffer.from(hash).toString('base64')
|
||||
const key = nacl.sign.keyPair()
|
||||
const devicePublicKeyB64 = Buffer.from(key.publicKey).toString('base64')
|
||||
|
||||
function proof(purpose: 'auth' | 'register', transcript = hash): string {
|
||||
return Buffer.from(
|
||||
nacl.sign.detached(encodeDeviceProofMessage(purpose, transcript), key.secretKey)
|
||||
).toString('base64')
|
||||
}
|
||||
|
||||
describe('device key proof', () => {
|
||||
it('domain-separates the purpose and binds the transcript', () => {
|
||||
const message = new TextDecoder().decode(encodeDeviceProofMessage('auth', hash).slice(0, 26))
|
||||
expect(message).toBe('orca-device-proof/v1\0auth\0')
|
||||
expect(
|
||||
verifyDeviceProof({
|
||||
devicePublicKeyB64,
|
||||
deviceProofB64: proof('auth'),
|
||||
purpose: 'auth',
|
||||
transcriptHashB64: hashB64
|
||||
})
|
||||
).toBe(true)
|
||||
expect(
|
||||
verifyDeviceProof({
|
||||
devicePublicKeyB64,
|
||||
deviceProofB64: proof('register'),
|
||||
purpose: 'auth',
|
||||
transcriptHashB64: hashB64
|
||||
})
|
||||
).toBe(false)
|
||||
expect(
|
||||
verifyDeviceProof({
|
||||
devicePublicKeyB64,
|
||||
deviceProofB64: proof('auth', new Uint8Array(32).fill(8)),
|
||||
purpose: 'auth',
|
||||
transcriptHashB64: hashB64
|
||||
})
|
||||
).toBe(false)
|
||||
const otherKey = Buffer.from(nacl.sign.keyPair().publicKey).toString('base64')
|
||||
expect(
|
||||
verifyDeviceProof({
|
||||
devicePublicKeyB64: otherKey,
|
||||
deviceProofB64: proof('auth'),
|
||||
purpose: 'auth',
|
||||
transcriptHashB64: hashB64
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts only canonical base64 of the exact length', () => {
|
||||
expect(isDevicePublicKeyB64(devicePublicKeyB64)).toBe(true)
|
||||
expect(isDevicePublicKeyB64(devicePublicKeyB64.replace(/=$/, ''))).toBe(false)
|
||||
expect(isDevicePublicKeyB64(Buffer.alloc(31).toString('base64'))).toBe(false)
|
||||
expect(isDevicePublicKeyB64(42)).toBe(false)
|
||||
})
|
||||
|
||||
it('strips control and bidi characters from labels', () => {
|
||||
expect(sanitizeDeviceLabel('My Phone\u0000\n', 'x')).toBe('My Phone')
|
||||
expect(sanitizeDeviceLabel('\u0007', 'Fallback')).toBe('Fallback')
|
||||
expect(sanitizeDeviceLabel('a'.repeat(100), 'x')).toHaveLength(64)
|
||||
expect(sanitizeDeviceLabel(undefined, 'Fallback')).toBe('Fallback')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,89 @@
|
||||
import nacl from 'tweetnacl'
|
||||
|
||||
// Why: an older host refuses the key-auth shape with bad_auth and has no
|
||||
// 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
|
||||
|
||||
export const REGISTER_DEVICE_KEY_METHOD = 'pairing.registerDeviceKey' as const
|
||||
|
||||
export type DeviceProofPurpose = 'auth' | 'redeem' | 'register'
|
||||
|
||||
const DOMAIN = 'orca-device-proof/v1'
|
||||
const TRANSCRIPT_HASH_BYTES = 32
|
||||
const PUBLIC_KEY_BYTES = 32
|
||||
const SIGNATURE_BYTES = 64
|
||||
|
||||
/** `orca-device-proof/v1 \0 purpose \0 transcriptHash`: one connection, one purpose. */
|
||||
export function encodeDeviceProofMessage(
|
||||
purpose: DeviceProofPurpose,
|
||||
transcriptHash: Uint8Array
|
||||
): Uint8Array {
|
||||
if (transcriptHash.length !== TRANSCRIPT_HASH_BYTES) {
|
||||
throw new Error('device proof needs a 32-byte transcript hash')
|
||||
}
|
||||
const prefix = new TextEncoder().encode(`${DOMAIN}\0${purpose}\0`)
|
||||
const message = new Uint8Array(prefix.length + transcriptHash.length)
|
||||
message.set(prefix)
|
||||
message.set(transcriptHash, prefix.length)
|
||||
return message
|
||||
}
|
||||
|
||||
export function decodeCanonicalBase64(value: unknown, byteLength: number): Uint8Array | null {
|
||||
if (typeof value !== 'string' || value.length === 0 || value.length > 256) {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
const binary = atob(value)
|
||||
if (binary.length !== byteLength || btoa(binary) !== value) {
|
||||
return null
|
||||
}
|
||||
const bytes = new Uint8Array(binary.length)
|
||||
for (let index = 0; index < binary.length; index++) {
|
||||
bytes[index] = binary.charCodeAt(index)
|
||||
}
|
||||
return bytes
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function isDevicePublicKeyB64(value: unknown): value is string {
|
||||
return decodeCanonicalBase64(value, PUBLIC_KEY_BYTES) !== null
|
||||
}
|
||||
|
||||
export function isDeviceProofB64(value: unknown): value is string {
|
||||
return decodeCanonicalBase64(value, SIGNATURE_BYTES) !== null
|
||||
}
|
||||
|
||||
export function verifyDeviceProof(args: {
|
||||
devicePublicKeyB64: unknown
|
||||
deviceProofB64: unknown
|
||||
purpose: DeviceProofPurpose
|
||||
transcriptHashB64: string
|
||||
}): boolean {
|
||||
const publicKey = decodeCanonicalBase64(args.devicePublicKeyB64, PUBLIC_KEY_BYTES)
|
||||
const signature = decodeCanonicalBase64(args.deviceProofB64, SIGNATURE_BYTES)
|
||||
const transcriptHash = decodeCanonicalBase64(args.transcriptHashB64, TRANSCRIPT_HASH_BYTES)
|
||||
if (!publicKey || !signature || !transcriptHash) {
|
||||
return false
|
||||
}
|
||||
return nacl.sign.detached.verify(
|
||||
encodeDeviceProofMessage(args.purpose, transcriptHash),
|
||||
signature,
|
||||
publicKey
|
||||
)
|
||||
}
|
||||
|
||||
// Why: labels reach the owner's device list and approval dialog; control and bidi
|
||||
// characters could disguise one device as another.
|
||||
export function sanitizeDeviceLabel(value: unknown, fallback: string): string {
|
||||
if (typeof value !== 'string') {
|
||||
return fallback
|
||||
}
|
||||
const cleaned = value
|
||||
.replace(/[\p{Cc}\p{Cf}\p{Zl}\p{Zp}]/gu, '')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.slice(0, 64)
|
||||
return cleaned.length > 0 ? cleaned : fallback
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { z } from 'zod'
|
||||
import { isDeviceProofB64, isDevicePublicKeyB64 } from './device-key-proof'
|
||||
|
||||
export const RegisterDeviceKeyParamsSchema = z
|
||||
.object({
|
||||
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()
|
||||
})
|
||||
.strict()
|
||||
|
||||
export type RegisterDeviceKeyParams = z.infer<typeof RegisterDeviceKeyParamsSchema>
|
||||
|
||||
export type RegisterDeviceKeyResult = {
|
||||
deviceId: string
|
||||
credentialRevision: number
|
||||
}
|
||||
@@ -45,6 +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-proof'
|
||||
// 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'
|
||||
@@ -355,6 +356,7 @@ export const ANTIGRAVITY_CONFIGURED_MODEL_RUNTIME_CAPABILITY =
|
||||
|
||||
export const RUNTIME_CAPABILITIES = [
|
||||
...ORCAD_RUNTIME_CAPABILITIES,
|
||||
DEVICE_KEY_RUNTIME_CAPABILITY,
|
||||
QODER_OWNED_TERMINAL_CREATE_CAPABILITY,
|
||||
...AGENT_SESSION_STOP_RUNTIME_CAPABILITIES,
|
||||
...AGENT_SESSION_CREATE_RUNTIME_CAPABILITIES,
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
} from '../browser-client-host-protocol'
|
||||
import { BrowserClientPageMetadataParams } from '../browser-client-page-metadata-protocol'
|
||||
import { DelegatedMobileDeviceSyncParamsSchema } from '../delegated-mobile-device-contract'
|
||||
import { RegisterDeviceKeyParamsSchema } from '../device-key-registration-contract'
|
||||
import {
|
||||
PairingGetEndpointsParamsSchema,
|
||||
PairingProvisionRelayParamsSchema
|
||||
@@ -1116,6 +1117,7 @@ export const RPC_PARAMS_BY_METHOD = {
|
||||
'pairing.delegatedMobileDevice.sync': DelegatedMobileDeviceSyncParamsSchema,
|
||||
'pairing.getEndpoints': PairingGetEndpointsParamsSchema,
|
||||
'pairing.provisionRelay': PairingProvisionRelayParamsSchema,
|
||||
'pairing.registerDeviceKey': RegisterDeviceKeyParamsSchema,
|
||||
'plugins.consent': pluginConsentRequestSchema,
|
||||
'plugins.invokeCommand': PluginInvokeCommandParams,
|
||||
'plugins.list': null,
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import nacl from 'tweetnacl'
|
||||
import { beforeAll, describe, expect, it, vi } from 'vitest'
|
||||
import { encodeDeviceProofMessage } from '../../../src/shared/device-key-proof'
|
||||
import { DeviceRegistry } from '../../../src/main/runtime/device-registry'
|
||||
import { SimulatedMobileE2EEV2Peer } from '../../../src/main/runtime/relay/simulated-mobile-e2ee-v2-peer'
|
||||
import {
|
||||
importReleaseCheckoutModule,
|
||||
materializeReleaseCheckout,
|
||||
resolveBaselineReleaseRef
|
||||
} from './release-checkout'
|
||||
|
||||
/**
|
||||
* Device keys add keyed rows (no token) to orca-devices.json and a key-auth shape inside E2EE v2.
|
||||
* A released build must keep keyed rows intact and inert, keep authenticating token rows, and
|
||||
* refuse key auth with bad_auth so an updated client can report "host downgraded" and keep its key.
|
||||
*/
|
||||
const SUITE_TIMEOUT_MS = 180_000
|
||||
|
||||
type RegistryLike = {
|
||||
validateToken(token: string): { deviceId: string } | null
|
||||
updateLastSeen(deviceId: string): void
|
||||
listDevices(): readonly Record<string, unknown>[]
|
||||
}
|
||||
type ChannelLike = { handleRawMessage(raw: string): void }
|
||||
|
||||
let ReleasedRegistry: new (userDataPath: string) => RegistryLike
|
||||
let ReleasedChannel: new (ws: unknown, options: Record<string, unknown>) => ChannelLike
|
||||
|
||||
beforeAll(async () => {
|
||||
const checkout = await materializeReleaseCheckout(resolveBaselineReleaseRef())
|
||||
const [registryModule, channelModule] = await Promise.all([
|
||||
importReleaseCheckoutModule(checkout, 'src/main/runtime/device-registry.ts'),
|
||||
importReleaseCheckoutModule(checkout, 'src/main/runtime/rpc/e2ee-channel.ts')
|
||||
])
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the pinned release's DeviceRegistry class; a changed shape fails the assertions below.
|
||||
ReleasedRegistry = registryModule.DeviceRegistry as typeof ReleasedRegistry
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the pinned release's E2EEChannel class; a changed shape fails the assertions below.
|
||||
ReleasedChannel = channelModule.E2EEChannel as typeof ReleasedChannel
|
||||
}, SUITE_TIMEOUT_MS)
|
||||
|
||||
describe('cross-version: device keys against a released host', () => {
|
||||
it('a released registry keeps keyed rows intact and inert, and token rows working', () => {
|
||||
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-xv-device-key-'))
|
||||
const current = new DeviceRegistry(userDataPath)
|
||||
const legacy = current.addDevice('Phone', 'mobile')
|
||||
current.updateLastSeen(legacy.deviceId)
|
||||
const keyB64 = Buffer.from(nacl.sign.keyPair().publicKey).toString('base64')
|
||||
const forked = current.forkDeviceKey(legacy.deviceId, keyB64, 'Phone (key)')
|
||||
|
||||
const released = new ReleasedRegistry(userDataPath)
|
||||
expect(released.validateToken(legacy.token)?.deviceId).toBe(legacy.deviceId)
|
||||
expect(released.validateToken('undefined')).toBeNull()
|
||||
released.updateLastSeen(legacy.deviceId)
|
||||
|
||||
const reread = new DeviceRegistry(userDataPath)
|
||||
expect(reread.getDevice(forked.deviceId)).toMatchObject({
|
||||
credentialKind: 'device-key',
|
||||
devicePublicKeyB64: keyB64,
|
||||
forkedFrom: legacy.deviceId
|
||||
})
|
||||
expect(reread.lookupDeviceKey(forked.deviceId)).toMatchObject({ kind: 'found' })
|
||||
expect(JSON.parse(readFileSync(join(userDataPath, 'orca-devices.json'), 'utf-8'))).toHaveLength(
|
||||
2
|
||||
)
|
||||
})
|
||||
|
||||
it('a released host refuses key auth with bad_auth (not unauthorized)', () => {
|
||||
const desktop = nacl.box.keyPair()
|
||||
const sent: string[] = []
|
||||
const ws = {
|
||||
OPEN: 1,
|
||||
readyState: 1,
|
||||
bufferedAmount: 0,
|
||||
send: (data: string) => sent.push(data),
|
||||
close: vi.fn()
|
||||
}
|
||||
const onError = vi.fn()
|
||||
const channel = new ReleasedChannel(ws, {
|
||||
serverSecretKey: desktop.secretKey,
|
||||
resolveAuthenticatedDevice: () => null,
|
||||
onReady: vi.fn(),
|
||||
onError
|
||||
})
|
||||
const peer = new SimulatedMobileE2EEV2Peer(nacl.box.keyPair(), desktop.publicKey, null)
|
||||
channel.handleRawMessage(JSON.stringify(peer.hello))
|
||||
expect(peer.acceptReady(JSON.parse(sent[0]!))).toBe(true)
|
||||
const key = nacl.sign.keyPair()
|
||||
const proof = nacl.sign.detached(
|
||||
encodeDeviceProofMessage('auth', Buffer.from(peer.transcriptHashB64, 'base64')),
|
||||
key.secretKey
|
||||
)
|
||||
channel.handleRawMessage(
|
||||
peer.sealText(
|
||||
JSON.stringify({
|
||||
type: 'e2ee_auth',
|
||||
v: 2,
|
||||
transcriptHashB64: peer.transcriptHashB64,
|
||||
deviceId: 'keyed',
|
||||
deviceProofB64: Buffer.from(proof).toString('base64')
|
||||
})
|
||||
)
|
||||
)
|
||||
expect(JSON.parse(peer.openText(sent[1]!)!)).toEqual({
|
||||
type: 'e2ee_error',
|
||||
error: { code: 'bad_auth' }
|
||||
})
|
||||
expect(onError).toHaveBeenCalledWith(4001, 'Invalid e2ee_auth')
|
||||
})
|
||||
|
||||
it('old registry files load unchanged', () => {
|
||||
const userDataPath = mkdtempSync(join(tmpdir(), 'orca-xv-device-key-'))
|
||||
const released = new ReleasedRegistry(userDataPath)
|
||||
expect(released.listDevices()).toHaveLength(0)
|
||||
writeFileSync(
|
||||
join(userDataPath, 'orca-devices.json'),
|
||||
JSON.stringify([
|
||||
{ deviceId: 'd', name: 'n', token: 't', scope: 'mobile', pairedAt: 1, lastSeenAt: 2 }
|
||||
])
|
||||
)
|
||||
expect(new DeviceRegistry(userDataPath).validateToken('t')?.deviceId).toBe('d')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user