From c7dde52ddf0d803bef37f2adb286b436c01057b2 Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Tue, 25 Aug 2026 16:02:18 -0400 Subject: [PATCH] fix(mobile): recover from rejected relay resume tokens --- ...relay-director-credential-fallback.test.ts | 49 +++++++++++++++++++ .../src/transport/mobile-relay-e2ee-link.ts | 9 ++-- .../mobile-relay-resume-director.test.ts | 15 ++++++ .../transport/mobile-relay-resume-director.ts | 5 ++ mobile/src/transport/relay-outer-error.ts | 5 ++ 5 files changed, 77 insertions(+), 6 deletions(-) create mode 100644 mobile/src/transport/mobile-relay-director-credential-fallback.test.ts create mode 100644 mobile/src/transport/relay-outer-error.ts diff --git a/mobile/src/transport/mobile-relay-director-credential-fallback.test.ts b/mobile/src/transport/mobile-relay-director-credential-fallback.test.ts new file mode 100644 index 00000000000..2531337293e --- /dev/null +++ b/mobile/src/transport/mobile-relay-director-credential-fallback.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it, vi } from 'vitest' +import { MobileEndpointSupervisor } from './mobile-endpoint-supervisor' +import { + bundle, + dependencies, + FakeLogicalClient, + FakeRelaySession, + host +} from './mobile-endpoint-supervisor-test-fakes' +import { RelayOuterError } from './mobile-relay-e2ee-link' +import { resolveMobileRelayEndpoint } from './mobile-relay-resume-director' + +vi.mock('react-native', () => ({ Platform: { OS: 'ios' } })) +vi.mock('expo-secure-store', () => ({ WHEN_UNLOCKED_THIS_DEVICE_ONLY: 'when-unlocked' })) +vi.mock('expo-crypto', () => ({ getRandomBytes: (length: number) => new Uint8Array(length) })) + +describe('mobile Relay director credential fallback', () => { + it('uses grace when the director rejects the current resume credential', async () => { + const logical = new FakeLogicalClient('disconnected', 'lan') + const openRelay = vi.fn( + (_relay, credential: { version: number }) => + new FakeRelaySession( + credential.version === bundle.current.version ? 'disconnected' : 'connected', + credential.version === bundle.current.version ? new RelayOuterError(4409) : null + ) + ) + const deps = dependencies({ + readBundle: vi.fn(async () => ({ + ...bundle, + grace: { ...bundle.current, token: 'C'.repeat(43), hash: 'D'.repeat(43), version: 1 } + })), + openRelay, + resolveRelay: ({ relay, resumeToken }) => + resolveMobileRelayEndpoint({ + relay, + resumeToken, + fetchImpl: vi.fn(async () => new Response('', { status: 401 })) + }) + }) + const supervisor = new MobileEndpointSupervisor(logical, host, deps) + + await supervisor.start() + + expect(openRelay).toHaveBeenCalledTimes(2) + expect(openRelay.mock.calls[1]?.[1]).toEqual(expect.objectContaining({ version: 1 })) + expect(logical.getActivePath()).toBe('relay') + supervisor.stop() + }) +}) diff --git a/mobile/src/transport/mobile-relay-e2ee-link.ts b/mobile/src/transport/mobile-relay-e2ee-link.ts index f19417a60f1..b52a7f26615 100644 --- a/mobile/src/transport/mobile-relay-e2ee-link.ts +++ b/mobile/src/transport/mobile-relay-e2ee-link.ts @@ -5,17 +5,14 @@ import { import { MobileE2EEV2ClientSession } from './mobile-e2ee-v2-client-session' import { MobileE2EEV2PhysicalChannel } from './mobile-e2ee-v2-physical-channel' import { websocketPayloadToUint8 } from './websocket-payload-bytes' +import { RelayOuterError } from './relay-outer-error' + +export { RelayOuterError } from './relay-outer-error' // Native WebSockets normally emit close immediately after error; bound the // missing-close case so a dead socket cannot leave recovery pending forever. const RELAY_ERROR_CLOSE_GRACE_MS = 250 -export class RelayOuterError extends Error { - constructor(readonly code: number) { - super(`relay_outer_${code}`) - } -} - type MobileRelayE2eeLinkOptions = { endpoint: { cellUrl: string; relayHostId: string } credential: string diff --git a/mobile/src/transport/mobile-relay-resume-director.test.ts b/mobile/src/transport/mobile-relay-resume-director.test.ts index 426ff89ba07..1653332112f 100644 --- a/mobile/src/transport/mobile-relay-resume-director.test.ts +++ b/mobile/src/transport/mobile-relay-resume-director.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it, vi } from 'vitest' +import { MOBILE_RELAY_CLOSE_CODE } from '../../../src/shared/mobile-relay-close-codes' import { resolveMobileRelayEndpoint } from './mobile-relay-resume-director' const relay = { @@ -11,6 +12,20 @@ const relay = { } describe('mobile relay resume director', () => { + it('classifies a rejected resume credential for grace fallback', async () => { + const rejected = vi.fn(async () => new Response('', { status: 401 })) + + const resolution = resolveMobileRelayEndpoint({ + relay, + resumeToken: 'A'.repeat(43), + fetchImpl: rejected + }) + + await expect(resolution).rejects.toMatchObject({ + code: MOBILE_RELAY_CLOSE_CODE.BAD_OUTER_CREDENTIAL + }) + }) + it('uses a bounded POST body and never puts the bearer in the URL', async () => { const fetchImpl = vi.fn( async () => diff --git a/mobile/src/transport/mobile-relay-resume-director.ts b/mobile/src/transport/mobile-relay-resume-director.ts index 86fd283b9a5..26461591d5f 100644 --- a/mobile/src/transport/mobile-relay-resume-director.ts +++ b/mobile/src/transport/mobile-relay-resume-director.ts @@ -1,5 +1,7 @@ import { z } from 'zod' +import { MOBILE_RELAY_CLOSE_CODE } from '../../../src/shared/mobile-relay-close-codes' import type { MobileRelayEndpoint } from '../../../src/shared/mobile-relay-credential-contract' +import { RelayOuterError } from './relay-outer-error' const MAX_RESPONSE_BYTES = 16 * 1024 const ResolveResponseSchema = z @@ -32,6 +34,9 @@ export async function resolveMobileRelayEndpoint(args: { signal: controller.signal }) if (!response.ok) { + if (response.status === 401) { + throw new RelayOuterError(MOBILE_RELAY_CLOSE_CODE.BAD_OUTER_CREDENTIAL) + } throw new Error(`relay director resolve failed (${response.status})`) } const declaredLength = Number(response.headers.get('content-length') ?? 0) diff --git a/mobile/src/transport/relay-outer-error.ts b/mobile/src/transport/relay-outer-error.ts new file mode 100644 index 00000000000..513d55ed1e8 --- /dev/null +++ b/mobile/src/transport/relay-outer-error.ts @@ -0,0 +1,5 @@ +export class RelayOuterError extends Error { + constructor(readonly code: number) { + super(`relay_outer_${code}`) + } +}