mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
feat(pairing): re-pairing the same server keeps its environment (#27197)
* feat(pairing): re-pairing the same server keeps its environment The web client minted a new environment id on every runtime-scope pairing link, even for the same server key, so existing terminals kept the old id and failed as ambiguous. A key-matched re-pair now keeps the id (bumping the pairing revision) and pins the server's host descriptor; a same-key server with a different installation gets a new id, and a downgraded server never replaces the pin. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(web): a reply from the replaced pairing never overwrites a same-server re-pair Re-pairing now keeps the environment id, so fence on the pairing revision too: a request captured before the re-pair can neither reopen a client with the replaced token nor save the old pairing back over the new one. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: m4air <m4air@Mac.localdomain> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
m4air
parent
dab3f6efeb
commit
b80c420054
@@ -91,6 +91,10 @@ export function getClientForEnvironment(
|
||||
if (manuallyDisconnectedEnvironmentIds.has(environment.id)) {
|
||||
throw new Error('runtime_manually_disconnected')
|
||||
}
|
||||
// Why: a request captured before a re-pair must not reopen a client with the replaced token.
|
||||
if (!isActivePairing(environment)) {
|
||||
throw new Error(PAIRING_CHANGED_MESSAGE)
|
||||
}
|
||||
if (
|
||||
!webRuntimeState.activeClient ||
|
||||
webRuntimeState.activeClientEnvironmentId !== environment.id
|
||||
@@ -173,17 +177,32 @@ export function requireActiveEnvironmentOrNull(): StoredWebRuntimeEnvironment |
|
||||
return webRuntimeState.activeEnvironment
|
||||
}
|
||||
|
||||
const PAIRING_CHANGED_MESSAGE = 'The paired Orca server changed while the request was in progress.'
|
||||
|
||||
export function assertActiveEnvironment(environmentId: string): void {
|
||||
if (requireActiveEnvironment().id !== environmentId) {
|
||||
throw new Error('The paired Orca server changed while the request was in progress.')
|
||||
throw new Error(PAIRING_CHANGED_MESSAGE)
|
||||
}
|
||||
}
|
||||
|
||||
function pairingRevisionOf(environment: StoredWebRuntimeEnvironment): number {
|
||||
return environment.pairingRevision ?? environment.createdAt
|
||||
}
|
||||
|
||||
/** Re-pairing the same server keeps its id, so only the revision tells the pairings apart. */
|
||||
function isActivePairing(environment: StoredWebRuntimeEnvironment): boolean {
|
||||
const active = webRuntimeState.activeEnvironment
|
||||
return (
|
||||
active?.id === environment.id && pairingRevisionOf(active) === pairingRevisionOf(environment)
|
||||
)
|
||||
}
|
||||
|
||||
export function updateEnvironmentFromResponse(
|
||||
environment: StoredWebRuntimeEnvironment,
|
||||
response: RuntimeRpcResponse<unknown>
|
||||
): void {
|
||||
if (webRuntimeState.activeEnvironment?.id !== environment.id) {
|
||||
const active = webRuntimeState.activeEnvironment
|
||||
if (!active || !isActivePairing(environment)) {
|
||||
return
|
||||
}
|
||||
const runtimeId = response.ok ? response._meta.runtimeId : (response._meta?.runtimeId ?? null)
|
||||
@@ -194,8 +213,9 @@ export function updateEnvironmentFromResponse(
|
||||
typeof (response.result as { pairedDeviceId?: unknown }).pairedDeviceId === 'string'
|
||||
? (response.result as { pairedDeviceId: string }).pairedDeviceId
|
||||
: undefined
|
||||
// Why the active record: the captured one may predate another reply's update of this pairing.
|
||||
webRuntimeState.activeEnvironment = updateStoredEnvironmentRuntimeId(
|
||||
environment,
|
||||
active,
|
||||
runtimeId,
|
||||
pairedDeviceId
|
||||
)
|
||||
|
||||
@@ -49,6 +49,17 @@ describe('web pairing input', () => {
|
||||
expect(parseWebPairingInput(encodeOffer({ endpoint }))).toMatchObject({ endpoint: expected })
|
||||
})
|
||||
|
||||
it('keeps a readable host descriptor and drops an unreadable one without refusing', () => {
|
||||
const hostDescriptor = { installationId: '11111111-1111-4111-8111-111111111111' }
|
||||
expect(parseWebPairingInput(encodeOffer({ hostDescriptor }))).toEqual({
|
||||
...offer,
|
||||
hostDescriptor
|
||||
})
|
||||
expect(parseWebPairingInput(encodeOffer({ hostDescriptor: { installationId: 7 } }))).toEqual(
|
||||
offer
|
||||
)
|
||||
})
|
||||
|
||||
it('treats invalid device scope metadata as unknown', () => {
|
||||
expect(parseWebPairingInput(`orca://pair?code=${encodeOffer({ scope: 'admin' })}`)).toEqual(
|
||||
offer
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import {
|
||||
RuntimeHostDescriptorSchema,
|
||||
type RuntimeHostDescriptor
|
||||
} from '../../../shared/runtime-host-descriptor'
|
||||
import type { DeviceScope } from '../../../shared/runtime-types'
|
||||
|
||||
const PAIRING_OFFER_VERSION = 2
|
||||
@@ -9,6 +13,7 @@ export type WebPairingOffer = {
|
||||
publicKeyB64: string
|
||||
pairedDeviceId?: string
|
||||
scope?: DeviceScope
|
||||
hostDescriptor?: RuntimeHostDescriptor
|
||||
}
|
||||
|
||||
export type WebPairingStartupDecision =
|
||||
@@ -104,13 +109,16 @@ function decodePairingPayload(base64url: string): WebPairingOffer | null {
|
||||
typeof parsed.pairedDeviceId === 'string' && parsed.pairedDeviceId.length > 0
|
||||
? parsed.pairedDeviceId
|
||||
: null
|
||||
// Why safeParse: a descriptor this build cannot read is dropped, never a reason to refuse pairing.
|
||||
const hostDescriptor = RuntimeHostDescriptorSchema.safeParse(parsed.hostDescriptor).data
|
||||
return {
|
||||
v: PAIRING_OFFER_VERSION,
|
||||
endpoint: normalizeWebSocketEndpoint(parsed.endpoint),
|
||||
deviceToken: parsed.deviceToken,
|
||||
publicKeyB64: parsed.publicKeyB64,
|
||||
...(pairedDeviceId ? { pairedDeviceId } : {}),
|
||||
...(scope ? { scope } : {})
|
||||
...(scope ? { scope } : {}),
|
||||
...(hostDescriptor ? { hostDescriptor } : {})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { RuntimeRpcResponse } from '../../../shared/runtime-rpc-envelope'
|
||||
import {
|
||||
encodePairingCode,
|
||||
installBrowserGlobals,
|
||||
writeStoredRuntimeEnvironment
|
||||
} from './web-preload-api-test-harness'
|
||||
|
||||
const ENVIRONMENT_KEY = 'orca.web.runtimeEnvironment.v1'
|
||||
const INSTALL_A = '11111111-1111-4111-8111-111111111111'
|
||||
const INSTALL_B = '22222222-2222-4222-8222-222222222222'
|
||||
|
||||
function readStored(storage: Storage): Record<string, unknown> {
|
||||
const stored: unknown = JSON.parse(storage.getItem(ENVIRONMENT_KEY) ?? '{}')
|
||||
return typeof stored === 'object' && stored !== null
|
||||
? Object.fromEntries(Object.entries(stored))
|
||||
: {}
|
||||
}
|
||||
|
||||
async function installWithStoredServer(
|
||||
pin?: Record<string, unknown>
|
||||
): Promise<ReturnType<typeof installBrowserGlobals>> {
|
||||
const globals = installBrowserGlobals('Linux')
|
||||
writeStoredRuntimeEnvironment(globals.storage, 'web-server-a')
|
||||
if (pin) {
|
||||
globals.storage.setItem(
|
||||
ENVIRONMENT_KEY,
|
||||
JSON.stringify({ ...readStored(globals.storage), hostDescriptor: pin })
|
||||
)
|
||||
}
|
||||
const { installWebPreloadApi } = await import('./web-preload-api')
|
||||
installWebPreloadApi()
|
||||
return globals
|
||||
}
|
||||
|
||||
describe('web re-pairing the same server', () => {
|
||||
beforeEach(() => {
|
||||
vi.resetModules()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
vi.doUnmock('./web-runtime-client')
|
||||
})
|
||||
|
||||
it('never lets a reply from the replaced pairing overwrite the new one', async () => {
|
||||
const tokens: string[] = []
|
||||
const pending: { answer?: () => void } = {}
|
||||
vi.doMock('./web-runtime-client', () => ({
|
||||
WebRuntimeClient: class {
|
||||
constructor(offer: { deviceToken: string }) {
|
||||
tokens.push(offer.deviceToken)
|
||||
}
|
||||
|
||||
call(method: string): Promise<RuntimeRpcResponse<unknown>> {
|
||||
const reply = { id: method, ok: true as const, result: {}, _meta: { runtimeId: 'r-1' } }
|
||||
return new Promise((resolve) => {
|
||||
pending.answer = () => resolve(reply)
|
||||
})
|
||||
}
|
||||
|
||||
close(): void {}
|
||||
}
|
||||
}))
|
||||
const globals = await installWithStoredServer()
|
||||
const staleCall = globals.window.api.runtimeEnvironments.call({
|
||||
selector: 'web-server-a',
|
||||
method: 'repos.list'
|
||||
})
|
||||
await vi.waitFor(() => expect(pending.answer).toBeDefined())
|
||||
const answerStale = pending.answer
|
||||
|
||||
await globals.window.api.runtimeEnvironments.addFromPairingCode({
|
||||
name: 'Server A',
|
||||
pairingCode: encodePairingCode({ publicKeyB64: 'public-key', deviceToken: 'fresh-token' })
|
||||
})
|
||||
answerStale?.()
|
||||
await staleCall
|
||||
|
||||
expect(readStored(globals.storage)).toMatchObject({
|
||||
id: 'web-server-a',
|
||||
endpoints: [{ deviceToken: 'fresh-token' }]
|
||||
})
|
||||
void globals.window.api.runtimeEnvironments.call({
|
||||
selector: 'web-server-a',
|
||||
method: 'repos.list'
|
||||
})
|
||||
await vi.waitFor(() => expect(tokens).toEqual(['token', 'fresh-token']))
|
||||
})
|
||||
|
||||
it('keeps the execution-host id and refreshes the credentials (#11574)', async () => {
|
||||
const globals = await installWithStoredServer()
|
||||
|
||||
const paired = await globals.window.api.runtimeEnvironments.addFromPairingCode({
|
||||
name: 'Server A again',
|
||||
pairingCode: encodePairingCode({
|
||||
publicKeyB64: 'public-key',
|
||||
deviceToken: 'fresh-token',
|
||||
hostDescriptor: { installationId: INSTALL_A }
|
||||
})
|
||||
})
|
||||
|
||||
expect(paired.environment).toMatchObject({ id: 'web-server-a', createdAt: 1 })
|
||||
expect(paired.environment.pairingRevision).toBeGreaterThan(1)
|
||||
expect(paired.environment).not.toHaveProperty('hostDescriptor')
|
||||
expect(readStored(globals.storage)).toMatchObject({
|
||||
id: 'web-server-a',
|
||||
hostDescriptor: { installationId: INSTALL_A },
|
||||
endpoints: [{ id: 'ws-web-server-a', deviceToken: 'fresh-token' }]
|
||||
})
|
||||
expect(readStored(globals.storage)).not.toHaveProperty('compatibleEnvironmentIds')
|
||||
})
|
||||
|
||||
it('mints a new id for a same-key server whose pinned installation changed', async () => {
|
||||
const globals = await installWithStoredServer({ installationId: INSTALL_A })
|
||||
|
||||
const paired = await globals.window.api.runtimeEnvironments.addFromPairingCode({
|
||||
name: 'Clone',
|
||||
pairingCode: encodePairingCode({
|
||||
publicKeyB64: 'public-key',
|
||||
hostDescriptor: { installationId: INSTALL_B }
|
||||
})
|
||||
})
|
||||
|
||||
expect(paired.environment.id).not.toBe('web-server-a')
|
||||
expect(readStored(globals.storage)).toMatchObject({
|
||||
hostDescriptor: { installationId: INSTALL_B }
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps the old pin when a downgraded server sends no descriptor', async () => {
|
||||
const globals = await installWithStoredServer({ installationId: INSTALL_A })
|
||||
|
||||
const paired = await globals.window.api.runtimeEnvironments.addFromPairingCode({
|
||||
name: 'Server A',
|
||||
pairingCode: encodePairingCode({ publicKeyB64: 'public-key' })
|
||||
})
|
||||
|
||||
expect(paired.environment.id).toBe('web-server-a')
|
||||
expect(readStored(globals.storage)).toMatchObject({
|
||||
hostDescriptor: { installationId: INSTALL_A }
|
||||
})
|
||||
})
|
||||
|
||||
it('drops a malformed pinned descriptor instead of trusting it', async () => {
|
||||
const globals = await installWithStoredServer({ installationId: 'not-a-uuid' })
|
||||
|
||||
const paired = await globals.window.api.runtimeEnvironments.addFromPairingCode({
|
||||
name: 'Server A',
|
||||
pairingCode: encodePairingCode({
|
||||
publicKeyB64: 'public-key',
|
||||
hostDescriptor: { installationId: INSTALL_B }
|
||||
})
|
||||
})
|
||||
|
||||
expect(paired.environment.id).toBe('web-server-a')
|
||||
expect(readStored(globals.storage)).toMatchObject({
|
||||
hostDescriptor: { installationId: INSTALL_B }
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,10 +1,18 @@
|
||||
import type { PublicKnownRuntimeEnvironment } from '../../../shared/runtime-environments'
|
||||
import {
|
||||
RuntimeHostDescriptorSchema,
|
||||
type RuntimeHostDescriptor
|
||||
} from '../../../shared/runtime-host-descriptor'
|
||||
import { classifyRuntimeHostRePair } from '../../../shared/runtime-host-pairing-identity'
|
||||
import type { WebPairingOffer } from './web-pairing'
|
||||
import { createBrowserUuid } from '@/lib/browser-uuid'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
export type StoredWebRuntimeEnvironment = Omit<PublicKnownRuntimeEnvironment, 'endpoints'> & {
|
||||
/** Ids minted by re-pairs before re-pairing kept the id; read-only, never extended. */
|
||||
compatibleEnvironmentIds?: string[]
|
||||
/** The server's descriptor pinned at pairing; see classifyRuntimeHostRePair. */
|
||||
hostDescriptor?: RuntimeHostDescriptor
|
||||
endpoints: {
|
||||
id: string
|
||||
kind: 'websocket'
|
||||
@@ -41,15 +49,18 @@ export function readStoredWebRuntimeEnvironment(): StoredWebRuntimeEnvironment |
|
||||
typeof parsed.pairedDeviceId === 'string' && parsed.pairedDeviceId.trim().length > 0
|
||||
? parsed.pairedDeviceId.trim()
|
||||
: null
|
||||
const hostDescriptor = RuntimeHostDescriptorSchema.safeParse(parsed.hostDescriptor).data
|
||||
const {
|
||||
compatibleEnvironmentIds: _unvalidatedIds,
|
||||
pairedDeviceId: _unvalidatedDeviceId,
|
||||
hostDescriptor: _unvalidatedDescriptor,
|
||||
...environment
|
||||
} = parsed
|
||||
return {
|
||||
...environment,
|
||||
...(pairedDeviceId ? { pairedDeviceId } : {}),
|
||||
...(compatibleEnvironmentIds.length > 0 ? { compatibleEnvironmentIds } : {})
|
||||
...(compatibleEnvironmentIds.length > 0 ? { compatibleEnvironmentIds } : {}),
|
||||
...(hostDescriptor ? { hostDescriptor } : {})
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
@@ -70,19 +81,37 @@ export function createStoredWebRuntimeEnvironment(args: {
|
||||
previousEnvironment?: StoredWebRuntimeEnvironment | null
|
||||
connectionDependency?: 'ssh-tunnel'
|
||||
}): StoredWebRuntimeEnvironment {
|
||||
const id = `web-${createBrowserUuid()}`
|
||||
const previous = args.previousEnvironment
|
||||
const match = previous
|
||||
? classifyRuntimeHostRePair(
|
||||
{
|
||||
publicKeys: previous.endpoints.map((endpoint) => endpoint.publicKeyB64),
|
||||
pin: previous.hostDescriptor
|
||||
},
|
||||
args.offer
|
||||
)
|
||||
: { kind: 'different-host' as const }
|
||||
// Why keep the id: it is this server's execution-host identity; a new one strands its sessions (#11574).
|
||||
const sameHost = match.kind === 'same-host' ? previous : null
|
||||
const id = sameHost?.id ?? `web-${createBrowserUuid()}`
|
||||
const now = Date.now()
|
||||
const compatibleEnvironmentIds = getCompatibleEnvironmentIds(args.previousEnvironment, args.offer)
|
||||
const pin = match.kind === 'same-host' ? match.pin : args.offer.hostDescriptor
|
||||
const compatibleEnvironmentIds = sameHost?.compatibleEnvironmentIds ?? []
|
||||
return {
|
||||
id,
|
||||
name: args.name.trim() || 'Orca Server',
|
||||
createdAt: now,
|
||||
createdAt: sameHost?.createdAt ?? now,
|
||||
updatedAt: now,
|
||||
// Why bump: request fences compare revisions, and the device token they captured is gone.
|
||||
...(sameHost
|
||||
? { pairingRevision: Math.max(now, (sameHost.pairingRevision ?? sameHost.createdAt) + 1) }
|
||||
: {}),
|
||||
lastUsedAt: null,
|
||||
runtimeId: null,
|
||||
...(args.offer.pairedDeviceId ? { pairedDeviceId: args.offer.pairedDeviceId } : {}),
|
||||
...(args.connectionDependency ? { connectionDependency: args.connectionDependency } : {}),
|
||||
...(compatibleEnvironmentIds.length > 0 ? { compatibleEnvironmentIds } : {}),
|
||||
...(pin ? { hostDescriptor: pin } : {}),
|
||||
preferredEndpointId: `ws-${id}`,
|
||||
endpoints: [
|
||||
{
|
||||
@@ -97,20 +126,14 @@ export function createStoredWebRuntimeEnvironment(args: {
|
||||
}
|
||||
}
|
||||
|
||||
function getCompatibleEnvironmentIds(
|
||||
previous: StoredWebRuntimeEnvironment | null | undefined,
|
||||
offer: WebPairingOffer
|
||||
): string[] {
|
||||
if (!previous?.endpoints.some((endpoint) => endpoint.publicKeyB64 === offer.publicKeyB64)) {
|
||||
return []
|
||||
}
|
||||
return [...new Set([...(previous.compatibleEnvironmentIds ?? []), previous.id])]
|
||||
}
|
||||
|
||||
export function redactStoredWebRuntimeEnvironment(
|
||||
environment: StoredWebRuntimeEnvironment
|
||||
): PublicKnownRuntimeEnvironment {
|
||||
const { compatibleEnvironmentIds: _compatibleEnvironmentIds, ...publicEnvironment } = environment
|
||||
const {
|
||||
compatibleEnvironmentIds: _compatibleEnvironmentIds,
|
||||
hostDescriptor: _hostDescriptor,
|
||||
...publicEnvironment
|
||||
} = environment
|
||||
return {
|
||||
...publicEnvironment,
|
||||
endpoints: environment.endpoints.map(
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { classifyRuntimeHostRePair } from './runtime-host-pairing-identity'
|
||||
|
||||
const INSTALL_A = '11111111-1111-4111-8111-111111111111'
|
||||
const INSTALL_B = '22222222-2222-4222-8222-222222222222'
|
||||
const BIND_1 = 'a'.repeat(43)
|
||||
const BIND_2 = 'b'.repeat(43)
|
||||
|
||||
describe('classifyRuntimeHostRePair', () => {
|
||||
it('treats a different host key as a different server, whatever the descriptor says', () => {
|
||||
expect(
|
||||
classifyRuntimeHostRePair(
|
||||
{ publicKeys: ['key-a'], pin: { installationId: INSTALL_A } },
|
||||
{ publicKeyB64: 'key-b', hostDescriptor: { installationId: INSTALL_A } }
|
||||
)
|
||||
).toEqual({ kind: 'different-host' })
|
||||
})
|
||||
|
||||
it('keeps an unpinned server matched by key and pins its first descriptor', () => {
|
||||
expect(classifyRuntimeHostRePair({ publicKeys: ['key-a'] }, { publicKeyB64: 'key-a' })).toEqual(
|
||||
{ kind: 'same-host', pin: undefined, descriptor: 'unpinned' }
|
||||
)
|
||||
expect(
|
||||
classifyRuntimeHostRePair(
|
||||
{ publicKeys: ['key-a'] },
|
||||
{ publicKeyB64: 'key-a', hostDescriptor: { installationId: INSTALL_A } }
|
||||
)
|
||||
).toEqual({ kind: 'same-host', pin: { installationId: INSTALL_A }, descriptor: 'pinned' })
|
||||
})
|
||||
|
||||
it('separates a same-key server whose installation changed', () => {
|
||||
expect(
|
||||
classifyRuntimeHostRePair(
|
||||
{ publicKeys: ['key-a'], pin: { installationId: INSTALL_A } },
|
||||
{ publicKeyB64: 'key-a', hostDescriptor: { installationId: INSTALL_B } }
|
||||
)
|
||||
).toEqual({ kind: 'different-host' })
|
||||
})
|
||||
|
||||
it('keeps a moved or reinstalled server and re-pins its new binding', () => {
|
||||
const next = { installationId: INSTALL_A, machineBinding: BIND_2 }
|
||||
expect(
|
||||
classifyRuntimeHostRePair(
|
||||
{ publicKeys: ['key-a'], pin: { installationId: INSTALL_A, machineBinding: BIND_1 } },
|
||||
{ publicKeyB64: 'key-a', hostDescriptor: next }
|
||||
)
|
||||
).toEqual({ kind: 'same-host', pin: next, descriptor: 'moved' })
|
||||
})
|
||||
|
||||
it('never learns from a downgraded server that stopped sending a descriptor', () => {
|
||||
const pin = { installationId: INSTALL_A, machineBinding: BIND_1 }
|
||||
expect(
|
||||
classifyRuntimeHostRePair({ publicKeys: ['key-a'], pin }, { publicKeyB64: 'key-a' })
|
||||
).toEqual({ kind: 'same-host', pin, descriptor: 'unverifiable' })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { RuntimeHostDescriptor } from './runtime-host-descriptor'
|
||||
|
||||
/**
|
||||
* How a new pairing offer relates to a server this client already pairs with.
|
||||
* `pin` is the descriptor to keep pinned. `descriptor`:
|
||||
* - `pinned`: the offer's descriptor matches (or is now pinned for the first time).
|
||||
* - `moved`: same installation, different machine binding: the profile moved or was reinstalled.
|
||||
* - `unverifiable`: a pinned server now sends no descriptor (downgraded); the old pin is kept.
|
||||
* - `unpinned`: neither side has a descriptor yet.
|
||||
*/
|
||||
export type RuntimeHostRePairMatch =
|
||||
| {
|
||||
kind: 'same-host'
|
||||
pin: RuntimeHostDescriptor | undefined
|
||||
descriptor: 'pinned' | 'moved' | 'unverifiable' | 'unpinned'
|
||||
}
|
||||
| { kind: 'different-host' }
|
||||
|
||||
/**
|
||||
* The host key, which the pairing handshake proves, is the trust root: a different key is a
|
||||
* different server. The descriptor only separates a reinstall or a deliberate clone that kept the key.
|
||||
*/
|
||||
export function classifyRuntimeHostRePair(
|
||||
previous: { publicKeys: readonly string[]; pin?: RuntimeHostDescriptor },
|
||||
offer: { publicKeyB64: string; hostDescriptor?: RuntimeHostDescriptor }
|
||||
): RuntimeHostRePairMatch {
|
||||
if (!previous.publicKeys.includes(offer.publicKeyB64)) {
|
||||
return { kind: 'different-host' }
|
||||
}
|
||||
const { pin } = previous
|
||||
const next = offer.hostDescriptor
|
||||
if (!next) {
|
||||
// Why keep the old pin: an older server must never let this client learn a replacement.
|
||||
return { kind: 'same-host', pin, descriptor: pin ? 'unverifiable' : 'unpinned' }
|
||||
}
|
||||
if (!pin) {
|
||||
return { kind: 'same-host', pin: next, descriptor: 'pinned' }
|
||||
}
|
||||
if (pin.installationId !== next.installationId) {
|
||||
return { kind: 'different-host' }
|
||||
}
|
||||
const moved =
|
||||
pin.machineBinding !== undefined &&
|
||||
next.machineBinding !== undefined &&
|
||||
pin.machineBinding !== next.machineBinding
|
||||
return { kind: 'same-host', pin: next, descriptor: moved ? 'moved' : 'pinned' }
|
||||
}
|
||||
Reference in New Issue
Block a user