fix(mobile): preserve replacement relay credentials

This commit is contained in:
Brennan Benson
2026-08-01 16:05:19 -07:00
parent 6152cb3dd2
commit d8166fa1c2
3 changed files with 226 additions and 4 deletions
@@ -25,6 +25,7 @@ import {
getHostProfilePublicationRevision,
publishHostProfileTransaction,
recordDurableHostIdentity,
retireHostProfilePublication,
resetHostProfilePublicationForTests,
serializeHostProfilePublication
} from './host-profile-publication'
@@ -139,7 +140,7 @@ describe('existing host relay endpoint lifecycle publication', () => {
expect(writeBundle).not.toHaveBeenCalled()
})
it('conditionally cleans a bundle written after its endpoint lifecycle retires', async () => {
it('preserves a committed bundle for a replacement endpoint lifecycle', async () => {
let releaseWrite: (() => void) | null = null
let markWriteStarted: (() => void) | null = null
const writeStarted = new Promise<void>((resolve) => {
@@ -165,9 +166,57 @@ describe('existing host relay endpoint lifecycle publication', () => {
beginHostEndpointPublicationLifecycle(HOST.id)
releaseWrite?.()
await expect(staleWrite).rejects.toBeInstanceOf(MobileRelayUpgradeLifecycleRetiredError)
expect(cleanupBundle).not.toHaveBeenCalled()
})
it('cleans a committed bundle after its host identity is removed', async () => {
let releaseWrite: (() => void) | null = null
const staleBundle = credentialBundle(1)
const cleanupBundle = vi.fn(async () => true)
const lifecycle = beginHostEndpointPublicationLifecycle(HOST.id)
const staleWrite = writeExistingHostRelayCredentialBundle(
HOST,
staleBundle,
() =>
new Promise<void>((resolve) => {
releaseWrite = resolve
}),
lifecycle,
cleanupBundle
)
await vi.waitFor(() => expect(releaseWrite).not.toBeNull())
retireHostProfilePublication(HOST.id)
hostStoreMock.loadStoredHostIdentity.mockResolvedValue(null)
releaseWrite?.()
await expect(staleWrite).rejects.toBeInstanceOf(MobileRelayUpgradeLifecycleRetiredError)
await vi.waitFor(() => expect(cleanupBundle).toHaveBeenCalledWith(staleBundle))
expect(await cleanupBundle.mock.results[0]!.value).toBe(false)
})
it('cleans a committed bundle after its host identity is superseded', async () => {
let releaseWrite: (() => void) | null = null
const staleBundle = credentialBundle(1)
const cleanupBundle = vi.fn(async () => true)
const lifecycle = beginHostEndpointPublicationLifecycle(HOST.id)
const staleWrite = writeExistingHostRelayCredentialBundle(
HOST,
staleBundle,
() =>
new Promise<void>((resolve) => {
releaseWrite = resolve
}),
lifecycle,
cleanupBundle
)
await vi.waitFor(() => expect(releaseWrite).not.toBeNull())
recordDurableHostIdentity({ ...HOST, deviceToken: 'token-2' })
releaseWrite?.()
await expect(staleWrite).rejects.toBeInstanceOf(MobileRelayUpgradeHostSupersededError)
await vi.waitFor(() => expect(cleanupBundle).toHaveBeenCalledWith(staleBundle))
})
it('rechecks routing publication after its credential write', async () => {
@@ -71,7 +71,9 @@ export async function writeExistingHostRelayCredentialBundle(
try {
requireCurrentPublication(validated, endpointLifecycle)
} catch (error) {
void cleanupBundle(bundle).catch(() => {})
if (!(await currentHostMayOwnBundle(validated))) {
void cleanupBundle(bundle).catch(() => {})
}
throw error
}
})
@@ -110,6 +112,31 @@ function requireMatchingHostMetadata(
}
}
async function currentHostMayOwnBundle(host: HostProfile): Promise<boolean> {
const publishedIdentity = getPublishedHostIdentity(host.id)
if (publishedIdentity) {
return (
publishedIdentity.deviceToken === host.deviceToken &&
publishedIdentity.publicKeyB64 === host.publicKeyB64
)
}
let existing: Pick<HostProfile, 'publicKeyB64'> | null
try {
existing = await loadStoredHostIdentity(host.id)
} catch {
return true
}
if (!existing || existing.publicKeyB64 !== host.publicKeyB64) {
return false
}
try {
return (await readHostDeviceToken(host.id)) === host.deviceToken
} catch {
// Why: uncertain storage ownership must not destroy the only resumable Relay credential.
return true
}
}
function requireCurrentPublication(
host: HostProfile,
endpointLifecycle: HostEndpointPublicationLifecycle
@@ -10,9 +10,43 @@ import type { RpcClient } from './rpc-client'
import type { MobileConnectionPath, StableLogicalRpcClient } from './stable-logical-rpc-client'
import type { ConnectionState, HostProfile, RpcResponse } from './types'
const asyncStorageMock = vi.hoisted(() => ({
getItem: vi.fn(),
setItem: vi.fn(),
removeItem: vi.fn()
}))
const secureStoreMock = vi.hoisted(() => ({
getItemAsync: vi.fn(),
setItemAsync: vi.fn(),
deleteItemAsync: vi.fn()
}))
const hostStoreMock = vi.hoisted(() => ({ loadStoredHostIdentity: vi.fn() }))
const tokenStoreMock = vi.hoisted(() => ({ readHostDeviceToken: vi.fn() }))
vi.mock('@react-native-async-storage/async-storage', () => ({ default: asyncStorageMock }))
vi.mock('react-native', () => ({ Platform: { OS: 'ios' } }))
vi.mock('expo-secure-store', () => ({ WHEN_UNLOCKED_THIS_DEVICE_ONLY: 'when-unlocked' }))
vi.mock('expo-secure-store', () => ({
WHEN_UNLOCKED_THIS_DEVICE_ONLY: 'when-unlocked',
...secureStoreMock
}))
vi.mock('expo-crypto', () => ({ getRandomBytes: (length: number) => new Uint8Array(length) }))
vi.mock('./host-store', () => hostStoreMock)
vi.mock('./host-device-token-store', () => tokenStoreMock)
import {
deleteMobileRelayCredentialBundleIfCurrent,
readMobileRelayCredentialBundle,
writeMobileRelayCredentialBundle
} from './mobile-relay-credential-bundle'
import {
MobileRelayUpgradeLifecycleRetiredError,
writeExistingHostRelayCredentialBundle
} from './existing-host-relay-routing'
import {
beginHostEndpointPublicationLifecycle,
resetHostProfilePublicationForTests
} from './host-profile-publication'
import { resetPairingKeychainForTests } from './pairing-keychain'
class FakeSession implements RpcClient {
readonly sendRequest = vi.fn(
@@ -162,9 +196,27 @@ function mockCredentialRotation(logical: FakeLogicalClient): void {
}
describe('mobile endpoint supervisor retirement', () => {
let storedCredentialRaw: string | null
beforeEach(() => {
vi.clearAllMocks()
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-07-13T12:00:00Z'))
resetPairingKeychainForTests()
resetHostProfilePublicationForTests()
storedCredentialRaw = null
asyncStorageMock.getItem.mockResolvedValue(null)
asyncStorageMock.setItem.mockResolvedValue(undefined)
asyncStorageMock.removeItem.mockResolvedValue(undefined)
secureStoreMock.getItemAsync.mockImplementation(async () => storedCredentialRaw)
secureStoreMock.setItemAsync.mockImplementation(async (_key: string, value: string) => {
storedCredentialRaw = value
})
secureStoreMock.deleteItemAsync.mockImplementation(async () => {
storedCredentialRaw = null
})
hostStoreMock.loadStoredHostIdentity.mockResolvedValue(host)
tokenStoreMock.readHostDeviceToken.mockResolvedValue(host.deviceToken)
})
afterEach(() => vi.useRealTimers())
@@ -219,4 +271,98 @@ describe('mobile endpoint supervisor retirement', () => {
expect(deps.saveHost).not.toHaveBeenCalled()
expect(onHostUpdated).not.toHaveBeenCalled()
})
it('keeps a replacement-owned rotation durable across direct loss and restart', async () => {
const rotatedBundle: MobileRelayCredentialBundle = {
...bundle,
current: {
token: 'C'.repeat(43),
hash: 'D'.repeat(43),
version: 3,
expiresAt: Number.MAX_SAFE_INTEGER
},
grace: bundle.current
}
await writeMobileRelayCredentialBundle(bundle)
let finishSecureWrite: (() => void) | null = null
let markSecureWriteStarted: (() => void) | null = null
const secureWriteStarted = new Promise<void>((resolve) => {
markSecureWriteStarted = resolve
})
secureStoreMock.setItemAsync.mockImplementation(async (_key: string, value: string) => {
if (value === JSON.stringify(rotatedBundle)) {
markSecureWriteStarted?.()
await new Promise<void>((resolve) => {
finishSecureWrite = resolve
})
}
storedCredentialRaw = value
})
let markReplacementRecovered: (() => void) | null = null
const replacementRecovered = new Promise<void>((resolve) => {
markReplacementRecovered = resolve
})
const cleanupBundle = vi.fn(async (value: MobileRelayCredentialBundle) => {
await replacementRecovered
return deleteMobileRelayCredentialBundleIfCurrent(value)
})
const oldLifecycle = beginHostEndpointPublicationLifecycle(host.id)
const oldWrite = writeExistingHostRelayCredentialBundle(
host,
rotatedBundle,
writeMobileRelayCredentialBundle,
oldLifecycle,
cleanupBundle
)
await secureWriteStarted
beginHostEndpointPublicationLifecycle(host.id)
const replacementLogical = new FakeLogicalClient('disconnected', 'lan')
const replacementOpenRelay = vi.fn(() => {
markReplacementRecovered?.()
return new FakeRelaySession('connected')
})
const replacement = new MobileEndpointSupervisor(
replacementLogical,
host,
dependencies({ readBundle: readMobileRelayCredentialBundle, openRelay: replacementOpenRelay })
)
const replacementStart = replacement.start()
finishSecureWrite?.()
await expect(oldWrite).rejects.toBeInstanceOf(MobileRelayUpgradeLifecycleRetiredError)
await replacementStart
expect(replacementOpenRelay).toHaveBeenCalledWith(
relay,
expect.objectContaining({
token: rotatedBundle.current.token,
version: rotatedBundle.current.version
}),
expect.any(String)
)
replacement.stop()
const restartedLogical = new FakeLogicalClient('disconnected', 'lan')
const restartedOpenRelay = vi.fn(() => new FakeRelaySession('connected'))
const restarted = new MobileEndpointSupervisor(
restartedLogical,
host,
dependencies({ readBundle: readMobileRelayCredentialBundle, openRelay: restartedOpenRelay })
)
await restarted.start()
expect(restartedOpenRelay).toHaveBeenCalledWith(
relay,
expect.objectContaining({
token: rotatedBundle.current.token,
version: rotatedBundle.current.version
}),
expect.any(String)
)
expect(JSON.parse(storedCredentialRaw ?? 'null')).toEqual(rotatedBundle)
expect(cleanupBundle).not.toHaveBeenCalled()
restarted.stop()
})
})