diff --git a/src/main/runtime/delegated-mobile-devices.ts b/src/main/runtime/delegated-mobile-devices.ts index 1919ef2b7e2..edb03d1c631 100644 --- a/src/main/runtime/delegated-mobile-devices.ts +++ b/src/main/runtime/delegated-mobile-devices.ts @@ -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( diff --git a/src/main/runtime/device-entry.ts b/src/main/runtime/device-entry.ts new file mode 100644 index 00000000000..41804c1163d --- /dev/null +++ b/src/main/runtime/device-entry.ts @@ -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' +} diff --git a/src/main/runtime/device-key-fork.test.ts b/src/main/runtime/device-key-fork.test.ts new file mode 100644 index 00000000000..e079faf12e3 --- /dev/null +++ b/src/main/runtime/device-key-fork.test.ts @@ -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 { + 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' + }) + }) +}) diff --git a/src/main/runtime/device-key-fork.ts b/src/main/runtime/device-key-fork.ts new file mode 100644 index 00000000000..00099a31bdb --- /dev/null +++ b/src/main/runtime/device-key-fork.ts @@ -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 } +} diff --git a/src/main/runtime/device-principal.test.ts b/src/main/runtime/device-principal.test.ts index bd8a8f21ee0..1a9f9cc4a34 100644 --- a/src/main/runtime/device-principal.test.ts +++ b/src/main/runtime/device-principal.test.ts @@ -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' } diff --git a/src/main/runtime/device-principal.ts b/src/main/runtime/device-principal.ts index f0e28063459..cee62a255d0 100644 --- a/src/main/runtime/device-principal.ts +++ b/src/main/runtime/device-principal.ts @@ -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 } } diff --git a/src/main/runtime/device-registry-corruption.ts b/src/main/runtime/device-registry-corruption.ts new file mode 100644 index 00000000000..99bac4368ed --- /dev/null +++ b/src/main/runtime/device-registry-corruption.ts @@ -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' +} diff --git a/src/main/runtime/device-registry-row-validation.ts b/src/main/runtime/device-registry-row-validation.ts index 94f926e20d4..591732b98ca 100644 --- a/src/main/runtime/device-registry-row-validation.ts +++ b/src/main/runtime/device-registry-row-validation.ts @@ -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 = 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 { + 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 } } diff --git a/src/main/runtime/device-registry.ts b/src/main/runtime/device-registry.ts index f61961eedee..677d5b189b7 100644 --- a/src/main/runtime/device-registry.ts +++ b/src/main/runtime/device-registry.ts @@ -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() } diff --git a/src/main/runtime/relay/simulated-mobile-e2ee-v2-peer.ts b/src/main/runtime/relay/simulated-mobile-e2ee-v2-peer.ts index ce590affde3..12de0de01cb 100644 --- a/src/main/runtime/relay/simulated-mobile-e2ee-v2-peer.ts +++ b/src/main/runtime/relay/simulated-mobile-e2ee-v2-peer.ts @@ -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 }) } } } diff --git a/src/main/runtime/rpc/core.ts b/src/main/runtime/rpc/core.ts index e8b4caecf7b..ba11c2cf4fc 100644 --- a/src/main/runtime/rpc/core.ts +++ b/src/main/runtime/rpc/core.ts @@ -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 @@ -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 diff --git a/src/main/runtime/rpc/device-key-auth.test.ts b/src/main/runtime/rpc/device-key-auth.test.ts new file mode 100644 index 00000000000..e7451a315df --- /dev/null +++ b/src/main/runtime/rpc/device-key-auth.test.ts @@ -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[0] | null = null + readonly setClientId = vi.fn() + readonly terminateClientConnections = vi.fn(() => 0) + onMessage(handler: Parameters[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) { + 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' }) + }) +}) diff --git a/src/main/runtime/rpc/dispatcher-stream-options.ts b/src/main/runtime/rpc/dispatcher-stream-options.ts index c9c8fc404b7..7de6511411e 100644 --- a/src/main/runtime/rpc/dispatcher-stream-options.ts +++ b/src/main/runtime/rpc/dispatcher-stream-options.ts @@ -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, diff --git a/src/main/runtime/rpc/e2ee-channel.ts b/src/main/runtime/rpc/e2ee-channel.ts index d389a11aee8..b7c3506d1f9 100644 --- a/src/main/runtime/rpc/e2ee-channel.ts +++ b/src/main/runtime/rpc/e2ee-channel.ts @@ -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 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 | 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) => 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' diff --git a/src/main/runtime/rpc/methods/pairing.ts b/src/main/runtime/rpc/methods/pairing.ts index e6d366681b8..07eb8be6719 100644 --- a/src/main/runtime/rpc/methods/pairing.ts +++ b/src/main/runtime/rpc/methods/pairing.ts @@ -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). diff --git a/src/main/runtime/rpc/methods/structured-chat-tab-table.test.ts b/src/main/runtime/rpc/methods/structured-chat-tab-table.test.ts index f3976c12d1e..c624296d3c1 100644 --- a/src/main/runtime/rpc/methods/structured-chat-tab-table.test.ts +++ b/src/main/runtime/rpc/methods/structured-chat-tab-table.test.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' } diff --git a/src/main/runtime/rpc/mobile-e2ee-auth-classification.test.ts b/src/main/runtime/rpc/mobile-e2ee-auth-classification.test.ts new file mode 100644 index 00000000000..680bba26d4b --- /dev/null +++ b/src/main/runtime/rpc/mobile-e2ee-auth-classification.test.ts @@ -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) => 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') + }) +}) diff --git a/src/main/runtime/rpc/mobile-e2ee-auth-validation.ts b/src/main/runtime/rpc/mobile-e2ee-auth-validation.ts index 4f7db9fb6a3..18e15fa6281 100644 --- a/src/main/runtime/rpc/mobile-e2ee-auth-validation.ts +++ b/src/main/runtime/rpc/mobile-e2ee-auth-validation.ts @@ -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 + 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(args: { +export type MobileE2EEAuthFailureCode = + | 'bad_auth' + | 'unauthorized' + | 'device_revoked' + | 'registry_unavailable' + | 'bad_proof' + +export type DeviceKeyResolution = + | { 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(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 }): - | { 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 { diff --git a/src/main/runtime/rpc/mobile-e2ee-legacy-reply-senders.ts b/src/main/runtime/rpc/mobile-e2ee-legacy-reply-senders.ts new file mode 100644 index 00000000000..629ea9ce637 --- /dev/null +++ b/src/main/runtime/rpc/mobile-e2ee-legacy-reply-senders.ts @@ -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) + } + } +} diff --git a/src/main/runtime/rpc/mobile-socket-wiring.ts b/src/main/runtime/rpc/mobile-socket-wiring.ts index 81d0313e163..009a35c9363 100644 --- a/src/main/runtime/rpc/mobile-socket-wiring.ts +++ b/src/main/runtime/rpc/mobile-socket-wiring.ts @@ -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 diff --git a/src/main/runtime/rpc/rpc-caller-scope.ts b/src/main/runtime/rpc/rpc-caller-scope.ts index f87c1e9ac88..9d505e8aeec 100644 --- a/src/main/runtime/rpc/rpc-caller-scope.ts +++ b/src/main/runtime/rpc/rpc-caller-scope.ts @@ -29,7 +29,8 @@ const RUNTIME_PAIRED_STANDARD: ReadonlySet = new Set([ 'settings-write', 'accounts-admin', 'skills-admin', - 'host-admin' + 'host-admin', + 'device-self' ]) /** diff --git a/src/main/runtime/rpc/rpc-method-permission.ts b/src/main/runtime/rpc/rpc-method-permission.ts index bc4ab5270f0..859f35d8bf0 100644 --- a/src/main/runtime/rpc/rpc-method-permission.ts +++ b/src/main/runtime/rpc/rpc-method-permission.ts @@ -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' ] diff --git a/src/main/runtime/runtime-rpc-device-key-dispatch.test.ts b/src/main/runtime/runtime-rpc-device-key-dispatch.test.ts new file mode 100644 index 00000000000..17b7fea336d --- /dev/null +++ b/src/main/runtime/runtime-rpc-device-key-dispatch.test.ts @@ -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) => { + 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) + } + ) +}) diff --git a/src/main/runtime/runtime-rpc/runtime-rpc-device-key-registration.ts b/src/main/runtime/runtime-rpc/runtime-rpc-device-key-registration.ts new file mode 100644 index 00000000000..e63b2d7f38c --- /dev/null +++ b/src/main/runtime/runtime-rpc/runtime-rpc-device-key-registration.ts @@ -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 } +} diff --git a/src/main/runtime/runtime-rpc/runtime-rpc-lifecycle.ts b/src/main/runtime/runtime-rpc/runtime-rpc-lifecycle.ts index a11cae855c4..3159f9f6286 100644 --- a/src/main/runtime/runtime-rpc/runtime-rpc-lifecycle.ts +++ b/src/main/runtime/runtime-rpc/runtime-rpc-lifecycle.ts @@ -235,7 +235,7 @@ export class RuntimeRpcLifecycle extends RuntimeRpcWebSocketDispatch { sendBinary, undefined, socket.ws, - socket.device.deviceToken, + socket.device.deviceToken ?? null, socket ) ) diff --git a/src/main/runtime/runtime-rpc/runtime-rpc-mobile-paired-desktop-methods.ts b/src/main/runtime/runtime-rpc/runtime-rpc-mobile-paired-desktop-methods.ts index 2534aaa79ac..99a5a9a1332 100644 --- a/src/main/runtime/runtime-rpc/runtime-rpc-mobile-paired-desktop-methods.ts +++ b/src/main/runtime/runtime-rpc/runtime-rpc-mobile-paired-desktop-methods.ts @@ -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', diff --git a/src/main/runtime/runtime-rpc/runtime-rpc-mobile-pairing.ts b/src/main/runtime/runtime-rpc/runtime-rpc-mobile-pairing.ts index 688a91365cc..c783eb213f7 100644 --- a/src/main/runtime/runtime-rpc/runtime-rpc-mobile-pairing.ts +++ b/src/main/runtime/runtime-rpc/runtime-rpc-mobile-pairing.ts @@ -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', diff --git a/src/main/runtime/runtime-rpc/runtime-rpc-pairing.ts b/src/main/runtime/runtime-rpc/runtime-rpc-pairing.ts index 66c8e92e2df..ecf7f944486 100644 --- a/src/main/runtime/runtime-rpc/runtime-rpc-pairing.ts +++ b/src/main/runtime/runtime-rpc/runtime-rpc-pairing.ts @@ -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 ?? [] diff --git a/src/main/runtime/runtime-rpc/runtime-rpc-websocket-dispatch.ts b/src/main/runtime/runtime-rpc/runtime-rpc-websocket-dispatch.ts index 1507a68afc7..b826d0eedb3 100644 --- a/src/main/runtime/runtime-rpc/runtime-rpc-websocket-dispatch.ts +++ b/src/main/runtime/runtime-rpc/runtime-rpc-websocket-dispatch.ts @@ -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).deviceToken === 'string' ? ((request as Record).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' diff --git a/src/shared/device-key-proof.test.ts b/src/shared/device-key-proof.test.ts new file mode 100644 index 00000000000..1e94cb33464 --- /dev/null +++ b/src/shared/device-key-proof.test.ts @@ -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') + }) +}) diff --git a/src/shared/device-key-proof.ts b/src/shared/device-key-proof.ts new file mode 100644 index 00000000000..0a1e28c9eaf --- /dev/null +++ b/src/shared/device-key-proof.ts @@ -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 +} diff --git a/src/shared/device-key-registration-contract.ts b/src/shared/device-key-registration-contract.ts new file mode 100644 index 00000000000..8655cc67acb --- /dev/null +++ b/src/shared/device-key-registration-contract.ts @@ -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 + +export type RegisterDeviceKeyResult = { + deviceId: string + credentialRevision: number +} diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts index d36df38610d..449bbf85234 100644 --- a/src/shared/protocol-version.ts +++ b/src/shared/protocol-version.ts @@ -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, diff --git a/src/shared/rpc-contract/rpc-params-catalog.generated.ts b/src/shared/rpc-contract/rpc-params-catalog.generated.ts index 7af15f2fd04..15f13f317e7 100644 --- a/src/shared/rpc-contract/rpc-params-catalog.generated.ts +++ b/src/shared/rpc-contract/rpc-params-catalog.generated.ts @@ -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, diff --git a/tests/e2e/cross-version-wire/cross-version-device-key-registry.unit.test.ts b/tests/e2e/cross-version-wire/cross-version-device-key-registry.unit.test.ts new file mode 100644 index 00000000000..80a0630ef3b --- /dev/null +++ b/tests/e2e/cross-version-wire/cross-version-device-key-registry.unit.test.ts @@ -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[] +} +type ChannelLike = { handleRawMessage(raw: string): void } + +let ReleasedRegistry: new (userDataPath: string) => RegistryLike +let ReleasedChannel: new (ws: unknown, options: Record) => 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') + }) +})