mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 00:57:45 +00:00
* feat(mobile): verified https pairing links; custom scheme carries no secret The desktop's copy-link now emits https://www.onorca.dev/pair#<code>, with the code only in the fragment. Orca Mobile claims that link as a verified app link and refuses any pairing code delivered through orca://, which any app can claim; orca://pair alone opens the scanner with an explanation. In-app QR scan and paste still accept the custom scheme, so older desktops and phones keep pairing. The confirm screen shows the desktop address and host key fingerprint before pairing. config/verified-links/ holds the apple-app-site-association, assetlinks.json and fallback page to deploy on www.onorca.dev, with a test that keeps them, mobile/app.config.js and the link builder in agreement. Part of #17397. Co-Authored-By: Claude <noreply@anthropic.com> * fix(mobile): refuse pairing codes nested in or normalized out of unverified links The router unwraps expo-development-client ?url= links and normalizes dot segments after +native-intent runs, so an orca:// wrapper could still deliver a code to the confirm screen. Refuse any unverified link carrying code= at any encoding depth, and match pairing routes on decoded, dot-free segments. Co-Authored-By: Claude <noreply@anthropic.com> * fix(mobile): escape the apostrophe in the refused-link notice Co-Authored-By: Claude <noreply@anthropic.com> * fix(mobile): decode escapes byte by byte before refusing nested pairing codes An invalid UTF-8 escape made the whole escape run fail to decode, hiding an adjacent encoded &code= that the router still decodes leniently. Co-Authored-By: Claude <noreply@anthropic.com> * fix(mobile): decode nested pairing links to any depth before refusing codes Co-Authored-By: Claude <noreply@anthropic.com> * fix(mobile): ignore URL-stripped whitespace when refusing nested pairing codes Co-Authored-By: Claude <noreply@anthropic.com> * refactor(mobile): decide pairing-link refusal on the router's own parse Stop re-implementing URL decoding. The guard now runs expo-router's own extractExpoPathFromURL and parseUrlUsingCustomBase, the functions its linking applies after +native-intent, and refuses any link from which the router would read a `code` param or reach a pairing route with a payload. A link the router cannot parse is refused. Release builds refuse expo-development-client wrappers outright. A property-style test walks wrapping depths, encoding layers, stripped control characters, case and unicode, and asserts that the guard never passes on a link from which the router would read a code. Co-Authored-By: Claude <noreply@anthropic.com> * fix(mobile): match pairing routes on the top-level segment only A host link with a segment named pair was sent to the scanner. Co-Authored-By: Claude <noreply@anthropic.com> * test(mobile): pin line-terminator fragment links as refused Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: m4air <m4air@Mac.localdomain> Co-authored-by: Claude <noreply@anthropic.com>
151 lines
5.2 KiB
TypeScript
151 lines
5.2 KiB
TypeScript
import { describe, expect, it, vi } from 'vitest'
|
|
import {
|
|
encodePairingOffer,
|
|
decodePairingOffer,
|
|
parsePairingCode,
|
|
toVerifiedPairingLink,
|
|
type PairingOffer
|
|
} from './pairing'
|
|
|
|
describe('pairing offer', () => {
|
|
const offer: PairingOffer = {
|
|
v: 2,
|
|
endpoint: 'ws://192.168.1.10:6768',
|
|
deviceToken: 'abcdef1234567890abcdef1234567890abcdef1234567890',
|
|
publicKeyB64: 'dGVzdC1wdWJsaWMta2V5LWJhc2U2NC1lbmNvZGVk'
|
|
}
|
|
|
|
it('encode then decode round-trips correctly', () => {
|
|
const url = encodePairingOffer(offer)
|
|
expect(url).toMatch(/^orca:\/\/pair\?code=/)
|
|
|
|
const decoded = decodePairingOffer(url)
|
|
expect(decoded).toEqual(offer)
|
|
})
|
|
|
|
it('decodes in browser runtimes without Buffer', () => {
|
|
const url = encodePairingOffer(offer)
|
|
const nodeBuffer = Buffer
|
|
let decoded: PairingOffer | null = null
|
|
try {
|
|
vi.stubGlobal('Buffer', undefined)
|
|
decoded = decodePairingOffer(url)
|
|
} finally {
|
|
vi.stubGlobal('Buffer', nodeBuffer)
|
|
}
|
|
expect(decoded).toEqual(offer)
|
|
})
|
|
|
|
it('preserves optional device scope metadata', () => {
|
|
const scopedOffer: PairingOffer = { ...offer, scope: 'mobile' }
|
|
expect(decodePairingOffer(encodePairingOffer(scopedOffer))).toEqual(scopedOffer)
|
|
})
|
|
|
|
it('round-trips a TLS reverse-proxy endpoint with an explicit port and path', () => {
|
|
const proxiedOffer = {
|
|
...offer,
|
|
endpoint: 'wss://proxy.example:443/orca/runtime'
|
|
}
|
|
|
|
expect(decodePairingOffer(encodePairingOffer(proxiedOffer))).toEqual(proxiedOffer)
|
|
})
|
|
|
|
it('encoded URL uses base64url (no +, /, or = characters)', () => {
|
|
const url = encodePairingOffer(offer)
|
|
const code = new URLSearchParams(url.slice(url.indexOf('?') + 1)).get('code')!
|
|
expect(code).not.toMatch(/[+/=]/)
|
|
})
|
|
|
|
it('rejects URLs with wrong scheme', () => {
|
|
expect(() => decodePairingOffer('https://example.com#abc')).toThrow('Invalid pairing URL')
|
|
})
|
|
|
|
it('rejects orca URLs outside the exact pairing route', () => {
|
|
const url = encodePairingOffer(offer)
|
|
const code = new URLSearchParams(url.slice(url.indexOf('?') + 1)).get('code')!
|
|
|
|
expect(parsePairingCode(`orca://pairing?code=${code}`)).toBeNull()
|
|
expect(parsePairingCode(`orca://pair-extra?code=${code}`)).toBeNull()
|
|
expect(() => decodePairingOffer(`orca://pairing?code=${code}`)).toThrow('Invalid pairing URL')
|
|
})
|
|
|
|
it('rejects URLs without a pairing code', () => {
|
|
expect(() => decodePairingOffer('orca://pair')).toThrow('Invalid pairing URL')
|
|
})
|
|
|
|
it('decodes legacy hash URLs', () => {
|
|
const url = encodePairingOffer(offer)
|
|
const code = new URLSearchParams(url.slice(url.indexOf('?') + 1)).get('code')!
|
|
expect(decodePairingOffer(`orca://pair#${code}`)).toEqual(offer)
|
|
})
|
|
|
|
it('rejects payloads with missing fields', () => {
|
|
const partial = { v: 2, endpoint: 'ws://host:1234' }
|
|
const base64 = Buffer.from(JSON.stringify(partial)).toString('base64')
|
|
expect(() => decodePairingOffer(`orca://pair#${base64}`)).toThrow()
|
|
})
|
|
|
|
it('rejects payloads with wrong version', () => {
|
|
const wrong = { ...offer, v: 1 }
|
|
const base64 = Buffer.from(JSON.stringify(wrong)).toString('base64')
|
|
expect(() => decodePairingOffer(`orca://pair#${base64}`)).toThrow()
|
|
})
|
|
|
|
it('rejects payloads with missing publicKeyB64', () => {
|
|
const wrong = { v: 2, endpoint: 'ws://host:1234', deviceToken: 'tok' }
|
|
const base64 = Buffer.from(JSON.stringify(wrong)).toString('base64')
|
|
expect(() => decodePairingOffer(`orca://pair#${base64}`)).toThrow()
|
|
})
|
|
})
|
|
|
|
describe('parsePairingCode', () => {
|
|
const offer: PairingOffer = {
|
|
v: 2,
|
|
endpoint: 'ws://192.168.1.10:6768',
|
|
deviceToken: 'token-abc',
|
|
publicKeyB64: 'pubkey-xyz'
|
|
}
|
|
|
|
it('parses a full orca://pair# URL', () => {
|
|
const url = encodePairingOffer(offer)
|
|
expect(parsePairingCode(url)).toEqual(offer)
|
|
})
|
|
|
|
it('parses a bare base64url payload (without scheme prefix)', () => {
|
|
const url = encodePairingOffer(offer)
|
|
const base64url = new URLSearchParams(url.slice(url.indexOf('?') + 1)).get('code')!
|
|
expect(parsePairingCode(base64url)).toEqual(offer)
|
|
})
|
|
|
|
it('tolerates surrounding whitespace from clipboard', () => {
|
|
const url = encodePairingOffer(offer)
|
|
expect(parsePairingCode(` ${url}\n`)).toEqual(offer)
|
|
})
|
|
|
|
it('returns null for empty input', () => {
|
|
expect(parsePairingCode('')).toBeNull()
|
|
expect(parsePairingCode(' ')).toBeNull()
|
|
})
|
|
|
|
it('returns null for garbage input', () => {
|
|
expect(parsePairingCode('not a pairing code')).toBeNull()
|
|
expect(parsePairingCode('https://example.com')).toBeNull()
|
|
})
|
|
|
|
it('parses the verified https pairing link a phone receives', () => {
|
|
const link = toVerifiedPairingLink(encodePairingOffer(offer))
|
|
expect(link).toMatch(/^https:\/\/www\.onorca\.dev\/pair#/)
|
|
expect(parsePairingCode(link!)).toEqual(offer)
|
|
expect(decodePairingOffer(link!)).toEqual(offer)
|
|
})
|
|
|
|
it('returns no verified link for a URL without a pairing code', () => {
|
|
expect(toVerifiedPairingLink('orca://pair')).toBeNull()
|
|
})
|
|
|
|
it('returns null for valid base64 of unrelated JSON', () => {
|
|
const bogus = Buffer.from(JSON.stringify({ hello: 'world' })).toString('base64')
|
|
expect(parsePairingCode(bogus)).toBeNull()
|
|
})
|
|
})
|