diff --git a/.github/workflows/mobile-ios-release.yml b/.github/workflows/mobile-ios-release.yml index 975b0618c7f..64f527377b5 100644 --- a/.github/workflows/mobile-ios-release.yml +++ b/.github/workflows/mobile-ios-release.yml @@ -104,6 +104,8 @@ jobs: - name: Expo prebuild env: ORCA_IOS_APS_ENVIRONMENT: production + # Set the repo variable to 1 only after the App ID has Associated Domains enabled. + ORCA_IOS_ASSOCIATED_DOMAINS: ${{ vars.ORCA_IOS_ASSOCIATED_DOMAINS }} run: npx expo prebuild --platform ios --no-install - name: Install CocoaPods diff --git a/config/verified-links/README.md b/config/verified-links/README.md new file mode 100644 index 00000000000..6c1899457eb --- /dev/null +++ b/config/verified-links/README.md @@ -0,0 +1,72 @@ +# Verified pairing links + +Orca Mobile pairs from `https://www.onorca.dev/pair#`. The pairing code is in the URL +fragment, which browsers never send to a server. iOS and Android open that link in Orca Mobile only +after they have fetched and verified the two files in this directory from `www.onorca.dev`. Links +that use the `orca://` scheme no longer carry a code into the app, because any app can claim that +scheme. + +The marketing site that serves `www.onorca.dev` is not in this repository, so these files must be +deployed there by hand. + +## What to deploy + +| File in this directory | Serve at | Content-Type | +| ---------------------------- | --------------------------------------------------------------- | ------------------ | +| `apple-app-site-association` | `https://www.onorca.dev/.well-known/apple-app-site-association` | `application/json` | +| `assetlinks.json` | `https://www.onorca.dev/.well-known/assetlinks.json` | `application/json` | +| `pair.html` | `https://www.onorca.dev/pair` | `text/html` | + +Both `.well-known` files must: + +- return `200` directly, with **no redirect**. The apex `onorca.dev` redirects to `www`, which is + why the links use `www.onorca.dev`. Neither OS follows a redirect while verifying. +- need **no authentication**, cookie, bot challenge, or other interstitial. Exempt `/.well-known/` + from any Cloudflare bot or challenge rule. +- be served with `Content-Type: application/json`. The Apple file has no extension, so most hosts + serve it as `application/octet-stream` unless a header rule overrides that. + +`pair.html` is the page people see when the app is not installed, is too old to claim the link, or +the OS has not verified the domain yet. It explains how to pair instead, and can copy the code from +the fragment for the paste flow, which older app versions also accept. Do not add analytics or any +third-party script to this page: a script on it could read the code from the fragment. + +## Placeholders to fill in before deploying + +- `apple-app-site-association`: replace `REPLACE_WITH_APPLE_TEAM_ID` with the Apple Developer Team + ID (the `APPLE_TEAM_ID` secret used by `mobile/fastlane`). +- `assetlinks.json`: replace `REPLACE_WITH_RELEASE_SIGNING_CERT_SHA256` with the SHA-256 fingerprint + of the certificate that signs the shipped APK. If the app is published on Google Play, use the + **app signing key** fingerprint from Play Console → App integrity, not the upload key. You can list + more than one fingerprint, for example both the Play key and a key used for direct APK downloads. + +The direct-download APK built by `.github/workflows/mobile-android-release.yml` is currently signed +with the build template's public debug keystore. Do not list that fingerprint (`FA:C6:17:45:…`): it +is public, so any app signed with it could claim the links. +`src/shared/verified-pairing-link-files.test.ts` fails if it appears. + +## App side + +- Android: `mobile/app.config.js` adds an `autoVerify` intent filter for `https://www.onorca.dev/pair`. + Android checks `assetlinks.json` when the app is installed or updated. +- iOS: the associated-domains entitlement is opt-in, because an App Store archive with it fails to + sign until the App ID has the capability. To turn it on: + 1. In the Apple Developer portal, enable **Associated Domains** on the App ID + `com.stably.orca.mobile`. The release lane regenerates the provisioning profile on every run. + 2. Set the GitHub repository variable `ORCA_IOS_ASSOCIATED_DOMAINS` to `1`. The iOS release + workflow then builds with `applinks:www.onorca.dev`. + +The host, the path, and the app identifiers are mirrored in `src/shared/verified-pairing-link.ts`, +`mobile/app.config.js`, and the files in this directory. `src/shared/verified-pairing-link-files.test.ts` +fails if they drift apart. + +## Checking a deployment + +```sh +curl -sI https://www.onorca.dev/.well-known/apple-app-site-association # 200, application/json +curl -sI https://www.onorca.dev/.well-known/assetlinks.json # 200, application/json +``` + +Apple fetches the file through its own CDN, which can lag by up to a day: +`https://app-site-association.cdn-apple.com/a/v1/www.onorca.dev`. On an Android device, run +`adb shell pm get-app-links com.stably.orca.mobile` and look for `www.onorca.dev: verified`. diff --git a/config/verified-links/apple-app-site-association b/config/verified-links/apple-app-site-association new file mode 100644 index 00000000000..a6569427f3c --- /dev/null +++ b/config/verified-links/apple-app-site-association @@ -0,0 +1,15 @@ +{ + "applinks": { + "details": [ + { + "appIDs": ["REPLACE_WITH_APPLE_TEAM_ID.com.stably.orca.mobile"], + "components": [ + { + "/": "/pair", + "comment": "Orca Mobile pairing link. The pairing code is in the fragment." + } + ] + } + ] + } +} diff --git a/config/verified-links/assetlinks.json b/config/verified-links/assetlinks.json new file mode 100644 index 00000000000..149654528e4 --- /dev/null +++ b/config/verified-links/assetlinks.json @@ -0,0 +1,10 @@ +[ + { + "relation": ["delegate_permission/common.handle_all_urls"], + "target": { + "namespace": "android_app", + "package_name": "com.stably.orca.mobile", + "sha256_cert_fingerprints": ["REPLACE_WITH_RELEASE_SIGNING_CERT_SHA256"] + } + } +] diff --git a/config/verified-links/pair.html b/config/verified-links/pair.html new file mode 100644 index 00000000000..7be5160fd2f --- /dev/null +++ b/config/verified-links/pair.html @@ -0,0 +1,155 @@ + + + + + + + + + + Pair Orca Mobile + + + +
+

Pair Orca Mobile

+

+ This link pairs your phone with Orca on your computer. It opens in the Orca app when Orca + Mobile is installed and up to date. +

+

Seeing this page instead?

+
    +
  1. Update Orca Mobile, then open the link again.
  2. +
  3. + Or open Orca Mobile, tap Pair, and scan the QR code shown in Orca on your + computer under Settings → Mobile. +
  4. + +
+

Don't have the app? Download Orca Mobile.

+

+ Treat the pairing code like a password. It is in the part of this link after “#”, which your + browser keeps on this device and never sends to onorca.dev. +

+
+ + + diff --git a/mobile/app.config.js b/mobile/app.config.js index e4bb110089f..b3cb4df6f7c 100644 --- a/mobile/app.config.js +++ b/mobile/app.config.js @@ -11,12 +11,37 @@ const APS_ENVIRONMENT = process.env.ORCA_IOS_APS_ENVIRONMENT === 'production' ? 'production' : 'development' +// Verified pairing links. Mirrors src/shared/verified-pairing-link.ts and config/verified-links/; +// src/shared/verified-pairing-link-files.test.ts fails if they drift. +const VERIFIED_PAIRING_LINK_HOST = 'www.onorca.dev' +const VERIFIED_PAIRING_LINK_PATH = '/pair' +// Why opt-in: an App Store archive with this entitlement fails to sign until the App ID has the +// Associated Domains capability (config/verified-links/README.md). +const IOS_ASSOCIATED_DOMAINS = process.env.ORCA_IOS_ASSOCIATED_DOMAINS === '1' + module.exports = ({ config }) => ({ ...config, ios: { ...config.ios, + ...(IOS_ASSOCIATED_DOMAINS + ? { associatedDomains: [`applinks:${VERIFIED_PAIRING_LINK_HOST}`] } + : {}), entitlements: { ...config.ios?.entitlements, 'aps-environment': APS_ENVIRONMENT } }, + android: { + ...config.android, + intentFilters: [ + ...(config.android?.intentFilters ?? []), + { + action: 'VIEW', + autoVerify: true, + data: [ + { scheme: 'https', host: VERIFIED_PAIRING_LINK_HOST, path: VERIFIED_PAIRING_LINK_PATH } + ], + category: ['BROWSABLE', 'DEFAULT'] + } + ] + }, plugins: (config.plugins ?? []).map((plugin) => plugin === 'expo-notifications' ? [ diff --git a/mobile/app/+native-intent.ts b/mobile/app/+native-intent.ts new file mode 100644 index 00000000000..94ca9c7a94e --- /dev/null +++ b/mobile/app/+native-intent.ts @@ -0,0 +1,6 @@ +import { redirectIncomingPairingLink } from '../src/transport/incoming-pairing-link' + +// Why: rewrite before routing, so no OS-delivered custom-scheme link reaches a screen that reads a code. +export function redirectSystemPath({ path }: { path: string; initial: boolean }): string { + return redirectIncomingPairingLink(path) ?? path +} diff --git a/mobile/app/_layout.tsx b/mobile/app/_layout.tsx index 711ee41ffd3..54d9c2c4841 100644 --- a/mobile/app/_layout.tsx +++ b/mobile/app/_layout.tsx @@ -4,11 +4,10 @@ import { readNativeNotificationData } from '../src/notifications/native-notifica import { setNotificationViewingWorkspace } from '../src/notifications/notification-viewing-policy' import { useCallback, useEffect, useRef } from 'react' import { View, StyleSheet } from 'react-native' -import { Stack, useRouter, useGlobalSearchParams, usePathname } from 'expo-router' +import { Stack, useGlobalSearchParams, usePathname } from 'expo-router' import { StatusBar } from 'expo-status-bar' import * as SplashScreen from 'expo-splash-screen' import * as Notifications from 'expo-notifications' -import * as Linking from 'expo-linking' import { colors } from '../src/theme/mobile-theme' import { OrcaLogo } from '../src/components/OrcaLogo' import { RpcClientProvider } from '../src/transport/client-context' @@ -22,7 +21,6 @@ import { import { startPushTokenSync } from '../src/notifications/push-registration' import { ensureDesktopNotificationChannel } from '../src/notifications/desktop-notification-channel' import { loadHostCatalog } from '../src/transport/host-store' -import { extractPairingCodeFromUrl } from '../src/transport/pairing' import { recoverMobileRelayPairing } from '../src/transport/mobile-relay-pairing-recovery' import { appUpdateChecker } from '../src/app-update/app-update-runtime' @@ -43,7 +41,6 @@ Notifications.setNotificationHandler({ }) export default function RootLayout() { - const router = useRouter() const pathname = usePathname() const { hostId, worktreeId } = useGlobalSearchParams<{ hostId?: string; worktreeId?: string }>() useEffect(() => { @@ -70,32 +67,6 @@ export default function RootLayout() { useEffect(() => appUpdateChecker.start(), []) useEffect(() => startAndroidForegroundPushPresentation(), []) - // Why: route `orca://pair?...` deep links to the confirm screen so - // the same pairing flow runs whether the link arrived via QR scan, - // paste, AirDrop, Messages, or `xcrun simctl openurl`. getInitialURL - // covers cold-start (link tapped while app was closed); the listener - // covers warm-start (link tapped while app is in memory). - useEffect(() => { - function handleUrl(url: string) { - const code = extractPairingCodeFromUrl(url) - if (code) { - // Why: Android camera launches can leave Expo Router's unmatched - // `orca://pair` route underneath this screen; replacing keeps cancel - // and edge-back from revealing the router error page. - router.replace({ pathname: '/pair-confirm', params: { code } }) - } - } - - void Linking.getInitialURL().then((url) => { - if (url) { - handleUrl(url) - } - }) - - const sub = Linking.addEventListener('url', ({ url }) => handleUrl(url)) - return () => sub.remove() - }, [router]) - // ─── Notification tap routing ─── // Why: iOS delivers local notification taps through expo-notifications, // not Linking. Route both cold-start and warm-start responses to the host diff --git a/mobile/app/pair-confirm.tsx b/mobile/app/pair-confirm.tsx index be790427088..e4ac4881015 100644 --- a/mobile/app/pair-confirm.tsx +++ b/mobile/app/pair-confirm.tsx @@ -3,7 +3,10 @@ import { View, Text, StyleSheet, Pressable, ActivityIndicator, BackHandler } fro import { useSafeAreaInsets } from 'react-native-safe-area-context' import { useFocusEffect, useLocalSearchParams, useRouter } from 'expo-router' import { ChevronLeft } from 'lucide-react-native' -import { resolvePairConfirmRouteState } from '../src/transport/pair-confirm-state' +import { + describePairingHost, + resolvePairConfirmRouteState +} from '../src/transport/pair-confirm-state' import { startPreProfilePairing, type PreProfilePairingAttempt @@ -43,6 +46,7 @@ export default function PairConfirmScreen() { const routeState = resolvePairConfirmRouteState(params.code) const offer = routeState.offer + const host = offer ? describePairingHost(offer) : null const resolvedStatus = status === 'awaiting-confirm' && routeState.kind === 'error' ? 'error' : status const resolvedErrorMessage = @@ -152,8 +156,24 @@ export default function PairConfirmScreen() { <> Pair with this desktop? - You opened a pairing link from your desktop. Confirm to add it to your hosts. + Check that this address matches the one under the QR code in Orca on your computer. + {host && ( + + Address + + {host.endpoint} + + {host.keyFingerprint && ( + <> + Host key + + {host.keyFingerprint} + + + )} + + )} void confirm()}> Pair @@ -234,6 +254,27 @@ const styles = StyleSheet.create({ maxWidth: 520, alignSelf: 'center' }, + hostDetails: { + width: '100%', + maxWidth: 360, + alignSelf: 'center', + backgroundColor: colors.bgRaised, + borderColor: colors.borderSubtle, + borderWidth: 1, + borderRadius: radii.card, + padding: spacing.md, + marginBottom: spacing.xl + }, + detailLabel: { + color: colors.textSecondary, + fontSize: typography.metaSize + }, + detailValue: { + color: colors.textPrimary, + fontFamily: typography.monoFamily, + fontSize: typography.bodySize, + marginBottom: spacing.sm + }, actionStack: { width: '100%', maxWidth: 360, diff --git a/mobile/app/pair-scan.tsx b/mobile/app/pair-scan.tsx index 61e99f19f2a..3a93ade8482 100644 --- a/mobile/app/pair-scan.tsx +++ b/mobile/app/pair-scan.tsx @@ -9,9 +9,10 @@ import { } from 'react-native' import { useSafeAreaInsets } from 'react-native-safe-area-context' import { CameraView, useCameraPermissions } from 'expo-camera' -import { useRouter } from 'expo-router' +import { useLocalSearchParams, useRouter } from 'expo-router' import { ChevronLeft, Clipboard as ClipboardIcon, QrCode } from 'lucide-react-native' import { decodePairingUrl, parsePairingCode } from '../src/transport/pairing' +import { PAIR_SCAN_LINK_REFUSED_NOTICE } from '../src/transport/incoming-pairing-link' import { startPreProfilePairing, type PreProfilePairingAttempt @@ -34,6 +35,18 @@ const PAIRING_OVERALL_TIMEOUT_MS = 25_000 const SCAN_RETICLE_SCALE = 0.62 const SCAN_RETICLE_MAX_SIZE = 360 +function LinkRefusedNotice({ notice }: { notice: string | undefined }) { + if (notice !== PAIR_SCAN_LINK_REFUSED_NOTICE) { + return null + } + return ( + + That orca:// link can’t pair your phone, because any app can open orca:// links. Scan the QR + code on your computer instead. + + ) +} + function Step({ number, text }: { number: number; text: string }) { return ( @@ -49,6 +62,7 @@ export default function PairScanScreen() { const router = useRouter() const refreshHostClient = useRefreshHostClient() const insets = useSafeAreaInsets() + const { notice } = useLocalSearchParams<{ notice?: string }>() const [permission, requestPermission] = useCameraPermissions() const [status, setStatus] = useState<'scanning' | 'connecting' | 'error'>('scanning') const [errorMessage, setErrorMessage] = useState('') @@ -60,6 +74,15 @@ export default function PairScanScreen() { const mountedRef = useRef(true) const activePairingAttemptRef = useRef(null) + // Why: a refused link can cold-start the app on this screen, with nothing to go back to. + const goBack = useCallback(() => { + if (router.canGoBack()) { + router.back() + } else { + router.replace('/') + } + }, [router]) + const setPairScanRootRef = useCallback((node: View | null): void => { if (node !== null) { mountedRef.current = true @@ -218,9 +241,10 @@ export default function PairScanScreen() { const canAskAgain = permission.canAskAgain !== false return ( - router.back()}> + + {canAskAgain ? 'Pair with desktop' : 'Camera Access Disabled'} @@ -251,7 +275,7 @@ export default function PairScanScreen() { visible={pasteVisible} title="Paste pairing code" message="Copy the code shown under the QR on your computer." - placeholder="orca://pair?code=... or paste the code" + placeholder="Paste the pairing link or code" onSubmit={handlePasteSubmit} onCancel={() => setPasteVisible(false)} /> @@ -261,10 +285,11 @@ export default function PairScanScreen() { return ( - router.back()}> + + @@ -349,7 +374,7 @@ export default function PairScanScreen() { visible={pasteVisible} title="Paste pairing code" message="Copy the code shown under the QR on your computer." - placeholder="orca://pair?code=... or paste the code" + placeholder="Paste the pairing link or code" onSubmit={handlePasteSubmit} onCancel={() => setPasteVisible(false)} /> diff --git a/mobile/app/pair.tsx b/mobile/app/pair.tsx index 6f3483763e1..a1ec81f3772 100644 --- a/mobile/app/pair.tsx +++ b/mobile/app/pair.tsx @@ -1,87 +1,6 @@ -import { useCallback, useEffect, useState } from 'react' -import { ActivityIndicator, Linking, Pressable, StyleSheet, Text, View } from 'react-native' -import { useLocalSearchParams, useRouter } from 'expo-router' -import { colors, radii, spacing, typography } from '../src/theme/mobile-theme' -import { extractPairingCodeFromUrl } from '../src/transport/pairing' +import { Redirect } from 'expo-router' +// Why: +native-intent rewrites OS links before routing; this route never reads a pairing code. export default function PairRedirectScreen() { - const router = useRouter() - const params = useLocalSearchParams<{ code?: string }>() - const [missingCode, setMissingCode] = useState(false) - - const goHome = useCallback(() => { - router.replace('/') - }, [router]) - - useEffect(() => { - let disposed = false - - async function redirectToConfirm() { - const codeParam = Array.isArray(params.code) ? params.code[0] : params.code - if (codeParam) { - router.replace({ pathname: '/pair-confirm', params: { code: codeParam } }) - return - } - - const initialUrl = await Linking.getInitialURL().catch(() => null) - const code = initialUrl ? extractPairingCodeFromUrl(initialUrl) : null - if (disposed) { - return - } - if (code) { - router.replace({ pathname: '/pair-confirm', params: { code } }) - return - } - setMissingCode(true) - } - - void redirectToConfirm() - return () => { - disposed = true - } - }, [params.code, router]) - - return ( - - {missingCode ? ( - <> - Missing pairing code - - Back to home - - - ) : ( - - )} - - ) + return } - -const styles = StyleSheet.create({ - container: { - flex: 1, - alignItems: 'center', - justifyContent: 'center', - backgroundColor: colors.bgBase, - padding: spacing.lg - }, - errorText: { - color: colors.statusRed, - fontSize: typography.bodySize, - lineHeight: 20, - marginBottom: spacing.xl, - textAlign: 'center' - }, - primaryButton: { - alignItems: 'center', - backgroundColor: colors.textPrimary, - borderRadius: radii.button, - paddingHorizontal: spacing.xl, - paddingVertical: spacing.sm + 2 - }, - primaryButtonText: { - color: colors.bgBase, - fontSize: typography.bodySize, - fontWeight: '600' - } -}) diff --git a/mobile/src/pair-scan-styles.ts b/mobile/src/pair-scan-styles.ts index af1b936fb54..edd185b7339 100644 --- a/mobile/src/pair-scan-styles.ts +++ b/mobile/src/pair-scan-styles.ts @@ -15,6 +15,17 @@ export const pairScanStyles = StyleSheet.create({ justifyContent: 'center', marginBottom: spacing.sm }, + notice: { + color: colors.textPrimary, + backgroundColor: colors.bgRaised, + borderColor: colors.borderSubtle, + borderWidth: 1, + borderRadius: radii.card, + fontSize: typography.bodySize, + lineHeight: 20, + padding: spacing.md, + marginBottom: spacing.lg + }, steps: { gap: spacing.sm, marginBottom: spacing.lg, diff --git a/mobile/src/transport/incoming-pairing-link-router-agreement.test.ts b/mobile/src/transport/incoming-pairing-link-router-agreement.test.ts new file mode 100644 index 00000000000..570bcca55e2 --- /dev/null +++ b/mobile/src/transport/incoming-pairing-link-router-agreement.test.ts @@ -0,0 +1,109 @@ +import { extractExpoPathFromURL } from 'expo-router/build/fork/extractPathFromURL' +import { parseUrlUsingCustomBase } from 'expo-router/build/utils/url' +import { describe, expect, it } from 'vitest' +import { redirectIncomingPairingLink } from './incoming-pairing-link' + +// The oracle is the router itself: what its linking reads from an OS link after the hook passes it on. +function routerParams(url: string): URLSearchParams | null { + try { + return parseUrlUsingCustomBase(extractExpoPathFromURL([], url)).searchParams + } catch { + return null + } +} + +const TARGETS = [ + 'orca://pair-confirm?code=SECRET', + 'orca://pair?code=SECRET', + 'orca://pair-confirm?a=1&code=SECRET', + 'orca:///x/../pair-confirm?code=SECRET', + 'orca://(group)/pair-confirm?code=SECRET', + 'orca://h/host-1/session/wt-1?code=SECRET', + 'https://evil.example/pair-confirm?code=SECRET', + 'exp+orca-mobile://pair-confirm?code=SECRET' +] +// Tab, LF and CR are removed anywhere; C0 controls and space are trimmed from the ends. +const STRIPPED = ['\t', '\n', '\r'] +const TRIMMED = ['\u0000', '\u001f', ' '] + +function* mutations(url: string): Generator { + yield url + yield url.toUpperCase() + yield url.replace('code', 'CODE') + yield url.replace('code', '%63ode') + yield url.replace('code', 'code') + yield url.replace('pair-confirm', 'pair%2Dconfirm') + yield url.replace('?code=', '?code%3D') + yield url.replace('&code=', '%26code=') + for (const character of STRIPPED) { + for (let index = 0; index <= url.length; index++) { + yield url.slice(0, index) + character + url.slice(index) + } + yield url.replace('code', `co${encodeURIComponent(character)}de`) + } + for (const character of TRIMMED) { + yield `${character}${url}${character}` + } +} + +function* wrapped(url: string): Generator { + for (const depth of [0, 1, 2, 7]) { + for (const layers of [0, 1, 2, 3]) { + let current = url + for (let level = 0; level < depth; level++) { + let inner = current + for (let layer = 0; layer < layers; layer++) { + inner = encodeURIComponent(inner) + } + current = `orca://expo-development-client/?url=${inner}` + } + yield current + if (depth === 0) { + break + } + } + } +} + +function* corpus(): Generator { + for (const target of TARGETS) { + for (const mutated of mutations(target)) { + yield* wrapped(mutated) + } + } +} + +describe('redirectIncomingPairingLink agrees with the router', () => { + it('never passes on a link from which the router would read a code', () => { + let checked = 0 + for (const url of corpus()) { + for (const allowDevClientLinks of [true, false]) { + if (redirectIncomingPairingLink(url, allowDevClientLinks) === null) { + const params = routerParams(url) + expect(params, url).not.toBeNull() + expect(params?.has('code'), JSON.stringify(url)).toBe(false) + } + } + checked++ + } + expect(checked).toBeGreaterThan(1000) + }, 60_000) + + it('refuses every development-client wrapper in a release build', () => { + for (const url of corpus()) { + if (url.toLowerCase().includes('orca://expo-development-client')) { + expect(redirectIncomingPairingLink(url, false), JSON.stringify(url)).not.toBeNull() + } + } + }, 60_000) + + it('still lets ordinary links and the dev launcher through', () => { + expect(redirectIncomingPairingLink('orca://h/host-1/session/wt-1', false)).toBeNull() + expect( + redirectIncomingPairingLink( + `orca://expo-development-client/?url=${encodeURIComponent('http://192.168.1.5:8081')}`, + true + ) + ).toBeNull() + }) +}) diff --git a/mobile/src/transport/incoming-pairing-link.test.ts b/mobile/src/transport/incoming-pairing-link.test.ts new file mode 100644 index 00000000000..c8dc58568c7 --- /dev/null +++ b/mobile/src/transport/incoming-pairing-link.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from 'vitest' +import { PAIR_SCAN_LINK_REFUSED_NOTICE, redirectIncomingPairingLink } from './incoming-pairing-link' + +const refused = `/pair-scan?notice=${PAIR_SCAN_LINK_REFUSED_NOTICE}` + +describe('redirectIncomingPairingLink', () => { + it('sends a verified https link to the confirm screen with the fragment code', () => { + expect(redirectIncomingPairingLink('https://www.onorca.dev/pair#abc_1-2')).toBe( + '/pair-confirm?code=abc_1-2' + ) + }) + + it('refuses a pairing code carried by the custom scheme', () => { + for (const url of [ + 'orca://pair?code=abc123', + 'orca://pair#abc123', + 'ORCA://PAIR?code=abc123', + 'orca://pair/?code=abc123', + 'orca:///pair?code=abc123', + 'orca://pair-confirm?code=abc123', + 'orca:///pair-confirm?code=abc123', + 'exp+orca-mobile://pair-confirm?code=abc123', + 'orca://pair%2Dconfirm?code=abc123', + 'orca://./pair-confirm?code=abc123', + 'orca://h/host-1?code=abc123', + 'orca://pair-confirm?%63ode=abc123', + 'orca://pair-confirm?CODE=abc123', + 'orca:///ignored/../pair-confirm?code=abc123', + `orca://expo-development-client/?url=${encodeURIComponent('orca://pair-confirm?code=abc123')}`, + `exp+orca-mobile://expo-development-client/?url=${encodeURIComponent( + encodeURIComponent('orca://pair-confirm?code=abc123') + )}`, + `orca://expo-development-client/?url=${encodeURIComponent('orca://pair-confirm?%2563ode=abc')}`, + `orca://expo-development-client/?junk=%ZZ&url=${encodeURIComponent('orca://pair-confirm?code=x')}`, + 'orca://expo-development-client/?url=orca%3A%2F%2Fpair-confirm%3Fa%3D%E0%26code%3DSECRET', + 'orca://expo-development-client/?url=orca://pair-confirm?a%3D%E0%26code%3DSECRET', + 'orca://pair-confirm?code=SECRET#\u2029x', + 'orca://pair-confirm?code=SECRET#\nx', + 'orca://pair-confirm?code=SECRET#\u2028x', + 'orca://expo-development-client/?url=orca://pair-confirm?co%09de=SECRET', + 'orca://expo-development-client/?url=orca://pair-confirm?c%0Aode=SECRET', + 'orca://expo-development-client/?url=orca://pair-confirm%3Fcode%0D=SECRET' + ]) { + expect(redirectIncomingPairingLink(url), url).toBe(refused) + } + }) + + it('refuses a code nested in development-client wrappers at any depth', () => { + let url = 'orca://pair-confirm?code=abc123' + for (let depth = 0; depth < 12; depth++) { + url = `orca://expo-development-client/?url=${encodeURIComponent(url)}` + } + expect(redirectIncomingPairingLink(url)).toBe(refused) + }) + + it('opens the scanner for a bare custom-scheme pairing link', () => { + expect(redirectIncomingPairingLink('orca://pair')).toBe('/pair-scan') + expect(redirectIncomingPairingLink('orca://pair/')).toBe('/pair-scan') + }) + + it('refuses unverified https links that target a pairing screen', () => { + expect(redirectIncomingPairingLink('https://evil.example/pair#abc123')).toBe(refused) + expect(redirectIncomingPairingLink('https://www.onorca.dev/pair-confirm?code=abc')).toBe( + refused + ) + expect(redirectIncomingPairingLink('https://www.onorca.dev/pair?code=abc')).toBe(refused) + }) + + it('leaves every other link to the router', () => { + expect(redirectIncomingPairingLink('orca://h/host-1/session/wt-1')).toBeNull() + expect(redirectIncomingPairingLink('orca://pairing')).toBeNull() + expect(redirectIncomingPairingLink('orca://h/host-1?notice=x#y')).toBeNull() + expect(redirectIncomingPairingLink('orca://pair-scan')).toBeNull() + expect(redirectIncomingPairingLink('orca://h/host-1/session/pair')).toBeNull() + expect(redirectIncomingPairingLink('/settings')).toBeNull() + }) +}) diff --git a/mobile/src/transport/incoming-pairing-link.ts b/mobile/src/transport/incoming-pairing-link.ts new file mode 100644 index 00000000000..b08ef1d18e0 --- /dev/null +++ b/mobile/src/transport/incoming-pairing-link.ts @@ -0,0 +1,80 @@ +import { extractExpoPathFromURL } from 'expo-router/build/fork/extractPathFromURL' +import { parseUrlUsingCustomBase } from 'expo-router/build/utils/url' +import { extractVerifiedPairingLinkCode } from '../../../src/shared/verified-pairing-link' + +export const PAIR_SCAN_LINK_REFUSED_NOTICE = 'custom-scheme-link' + +const REFUSED_PATH = `/pair-scan?notice=${PAIR_SCAN_LINK_REFUSED_NOTICE}` +const PAIRING_ROUTES = new Set(['pair', 'pair-confirm']) +const DEV_CLIENT_HOST = 'expo-development-client' + +/** The router's own reading of an OS link: the same functions its linking uses, after this hook. */ +export function resolveRouterTarget(url: string): { + pathname: string + searchParams: URLSearchParams + hash: string +} { + const parsed = parseUrlUsingCustomBase(extractExpoPathFromURL([], url)) + return { pathname: parsed.pathname, searchParams: parsed.searchParams, hash: parsed.hash } +} + +/** + * Rewrites an OS-delivered link before the router sees it. Null leaves the link to the router. + * Why: the OS hands an `orca://` URL to whichever app claimed the scheme, so a code it carries may + * already be in another app's hands. Only a verified https link may start pairing. The decision is + * made on the router's own parse, never a re-implementation, and anything unparseable is refused. + */ +export function redirectIncomingPairingLink( + url: string, + allowDevClientLinks: boolean = typeof __DEV__ !== 'undefined' && __DEV__ +): string | null { + const verifiedCode = extractVerifiedPairingLinkCode(url) + if (verifiedCode) { + return `/pair-confirm?code=${encodeURIComponent(verifiedCode)}` + } + // Why: the router unwraps `expo-development-client/?url=` links; release builds have no use for them. + const outer = parseOuterUrl(url) + if (!allowDevClientLinks && outer?.hostname === DEV_CLIENT_HOST) { + return REFUSED_PATH + } + let target: ReturnType + try { + target = resolveRouterTarget(url) + } catch { + return REFUSED_PATH + } + if ([...target.searchParams.keys()].some((key) => key.toLowerCase() === 'code')) { + return REFUSED_PATH + } + // Pairing screens are top-level routes; groups like `(tabs)` don't count as segments. + const route = target.pathname + .split('/') + .map((segment) => safeDecode(segment).toLowerCase()) + .find((segment) => segment !== '' && !/^\(.*\)$/.test(segment)) + if (!route || !PAIRING_ROUTES.has(route)) { + return null + } + // Why the outer hash too: the router drops it, but a legacy `orca://pair#` still earns the notice. + const carriesPayload = + [...target.searchParams.keys()].length > 0 || + target.hash.length > 1 || + (outer?.hash.length ?? 0) > 1 + return carriesPayload ? REFUSED_PATH : '/pair-scan' +} + +// Same check the router applies before unwrapping a development-client link. +function parseOuterUrl(url: string): URL | null { + try { + return new URL(url) + } catch { + return null + } +} + +function safeDecode(value: string): string { + try { + return decodeURIComponent(value) + } catch { + return value + } +} diff --git a/mobile/src/transport/pair-confirm-state.test.ts b/mobile/src/transport/pair-confirm-state.test.ts index 1a318dd7e07..11f2f0bc79c 100644 --- a/mobile/src/transport/pair-confirm-state.test.ts +++ b/mobile/src/transport/pair-confirm-state.test.ts @@ -1,7 +1,7 @@ import { Buffer } from 'node:buffer' import { describe, expect, it } from 'vitest' -import { resolvePairConfirmRouteState } from './pair-confirm-state' +import { describePairingHost, resolvePairConfirmRouteState } from './pair-confirm-state' import type { PairingOffer } from './types' function encodeOffer(offer: PairingOffer): string { @@ -52,3 +52,28 @@ describe('resolvePairConfirmRouteState', () => { }) }) }) + +describe('describePairingHost', () => { + const publicKeyB64 = Buffer.alloc(32, 7).toString('base64') + + it('shows the endpoint and a host key fingerprint before pairing', () => { + const host = describePairingHost({ + v: 2, + endpoint: 'ws://192.168.1.10:6768', + deviceToken: 'token-abc', + publicKeyB64 + }) + expect(host.endpoint).toBe('ws://192.168.1.10:6768') + expect(host.keyFingerprint).toMatch(/^[A-Za-z0-9_-]{16}$/) + }) + + it('omits the fingerprint for an unreadable key', () => { + const host = describePairingHost({ + v: 2, + endpoint: 'ws://192.168.1.10:6768', + deviceToken: 'token-abc', + publicKeyB64: 'pubkey-xyz' + }) + expect(host.keyFingerprint).toBeNull() + }) +}) diff --git a/mobile/src/transport/pair-confirm-state.ts b/mobile/src/transport/pair-confirm-state.ts index 242e67d7ffe..c9d58232e25 100644 --- a/mobile/src/transport/pair-confirm-state.ts +++ b/mobile/src/transport/pair-confirm-state.ts @@ -1,3 +1,4 @@ +import { deriveHostFingerprint } from '../notifications/push-host-fingerprint' import { parsePairingCode } from './pairing' import type { PairingOffer } from './types' @@ -17,3 +18,11 @@ export function resolvePairConfirmRouteState(code: string | undefined): PairConf return { kind: 'ready', offer, errorMessage: '' } } + +/** What the user checks against the desktop before pairing; a verified link proves the app, not the host. */ +export function describePairingHost(offer: PairingOffer): { + endpoint: string + keyFingerprint: string | null +} { + return { endpoint: offer.endpoint, keyFingerprint: deriveHostFingerprint(offer.publicKeyB64) } +} diff --git a/mobile/src/transport/pairing.test.ts b/mobile/src/transport/pairing.test.ts index 9b7b7514369..16a3141c911 100644 --- a/mobile/src/transport/pairing.test.ts +++ b/mobile/src/transport/pairing.test.ts @@ -39,6 +39,11 @@ describe('pairing deep links', () => { expect(extractPairingCodeFromUrl('orca://pair?code=query-code#hash-code')).toBe('query-code') }) + it('extracts the code from a verified https pairing link fragment', () => { + expect(extractPairingCodeFromUrl('https://www.onorca.dev/pair#abc123')).toBe('abc123') + expect(extractPairingCodeFromUrl('https://www.onorca.dev/pair?code=abc123')).toBeNull() + }) + it('ignores empty and unrelated URLs', () => { expect(extractPairingCodeFromUrl('orca://pair')).toBeNull() expect(extractPairingCodeFromUrl('https://example.com/pair#abc123')).toBeNull() @@ -63,6 +68,13 @@ describe('pairing deep links', () => { expect(parsePairingCode(code)).toEqual(offer) }) + it('parses a pasted or scanned verified https pairing link', () => { + const link = `https://www.onorca.dev/pair#${encodeOffer()}` + + expect(parsePairingCode(link)).toEqual(offer) + expect(decodePairingUrl(link)).toEqual(offer) + }) + it('preserves a TLS reverse-proxy endpoint with an explicit port and path', () => { const proxiedOffer = { ...offer, diff --git a/mobile/src/transport/pairing.ts b/mobile/src/transport/pairing.ts index d330f1834fa..5e3c0aab99b 100644 --- a/mobile/src/transport/pairing.ts +++ b/mobile/src/transport/pairing.ts @@ -1,3 +1,4 @@ +import { extractVerifiedPairingLinkCode } from '../../../src/shared/verified-pairing-link' import { PairingOfferSchema, type PairingOffer } from './types' // Why: this file mirrors src/shared/pairing.ts (which is covered by CI @@ -17,10 +18,14 @@ export function decodePairingUrl(url: string): PairingOffer | null { } } -// Why: system camera apps hand us the raw custom-scheme URL. Keeping -// extraction here makes QR scan, paste, and external deep-link flows -// accept the same URL shapes. +// Why: in-app QR scan and paste never pass through the OS link dispatcher, so +// they keep accepting the custom scheme that old desktops emit. OS-delivered +// links go through incoming-pairing-link.ts, which refuses custom-scheme codes. export function extractPairingCodeFromUrl(url: string): string | null { + const verifiedCode = extractVerifiedPairingLinkCode(url) + if (verifiedCode) { + return verifiedCode + } const trimmed = url.trim() const match = /^orca:\/\/([^/?#]*)([^?#]*)?/i.exec(trimmed) if (!match) { @@ -49,8 +54,8 @@ export function extractPairingCodeFromUrl(url: string): string | null { return null } -// Why: accept either an `orca://pair?...` URL or the bare base64 -// string so the paste-pair flow can take whichever the user actually +// Why: accept an `orca://pair?...` URL, a verified https link, or the bare +// base64 string so the paste-pair flow can take whichever the user actually // copied from desktop. export function parsePairingCode(input: string): PairingOffer | null { const trimmed = input.trim() @@ -58,7 +63,7 @@ export function parsePairingCode(input: string): PairingOffer | null { return null } try { - if (/^orca:\/\//i.test(trimmed)) { + if (/^(?:orca|https):\/\//i.test(trimmed)) { return decodePairingUrl(trimmed) } return decodePairingBase64(trimmed) diff --git a/src/main/ipc/mobile-pairing-copy-link.test.ts b/src/main/ipc/mobile-pairing-copy-link.test.ts new file mode 100644 index 00000000000..bc942b89544 --- /dev/null +++ b/src/main/ipc/mobile-pairing-copy-link.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it, vi } from 'vitest' + +const { handlers } = vi.hoisted(() => ({ + handlers: new Map unknown>() +})) + +vi.mock('electron', () => ({ + app: { isPackaged: false }, + ipcMain: { + handle: (channel: string, handler: (...args: unknown[]) => unknown) => + handlers.set(channel, handler) + }, + shell: { openExternal: vi.fn() } +})) + +import { registerMobileHandlers } from './mobile' + +describe('mobile pairing copy link', () => { + it('copies a verified https link while the QR keeps the custom scheme older phones scan', async () => { + const createMobilePairingOffer = vi.fn().mockResolvedValue({ + available: true, + pairingUrl: 'orca://pair?code=secret-code', + endpoint: 'ws://192.168.1.24:6768', + deviceId: 'mobile-1', + connectionMode: 'local-only' + }) + const encodePairingQr = vi + .fn() + .mockResolvedValue({ ok: true, qrDataUrl: 'data:image/png;base64,qr', qrSize: 58 }) + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: getPairingQR with an explicit address calls only createMobilePairingOffer. + registerMobileHandlers({ createMobilePairingOffer } as never, { encodePairingQr }) + + await expect( + handlers.get('mobile:getPairingQR')?.(null, { address: '192.168.1.24' }) + ).resolves.toMatchObject({ pairingUrl: 'https://www.onorca.dev/pair#secret-code' }) + expect(encodePairingQr).toHaveBeenCalledWith('orca://pair?code=secret-code') + }) +}) diff --git a/src/main/ipc/mobile.test.ts b/src/main/ipc/mobile.test.ts index c148abe95a1..a16f1449e5d 100644 --- a/src/main/ipc/mobile.test.ts +++ b/src/main/ipc/mobile.test.ts @@ -346,7 +346,7 @@ describe('registerMobileHandlers', () => { await expect(handlers.get('mobile:getPairingQR')?.(null, {})).resolves.toMatchObject({ available: true, qrSize: 58, - pairingUrl: 'orca://pair#mobile', + pairingUrl: 'https://www.onorca.dev/pair#mobile', endpoint: 'ws://100.102.47.57:6768', deviceId: 'mobile-1', connectionMode: 'automatic' @@ -426,7 +426,7 @@ describe('registerMobileHandlers', () => { qrDataUrl: null, qrSize: null, qrError: 'encoding_failed', - pairingUrl: 'orca://pair?code=copy-me', + pairingUrl: 'https://www.onorca.dev/pair#copy-me', endpoint: 'wss://pair.example/oversized', deviceId: 'mobile-large', connectionMode: 'local-only' diff --git a/src/main/ipc/mobile.ts b/src/main/ipc/mobile.ts index ccd3a6742ef..e1a20f76666 100644 --- a/src/main/ipc/mobile.ts +++ b/src/main/ipc/mobile.ts @@ -2,6 +2,7 @@ import { app, ipcMain, shell, type IpcMainInvokeEvent } from 'electron' import type { RuntimeAccessGrant } from '../../shared/runtime-access-grants' import type { MobilePairingConnectionMode } from '../../shared/mobile-pairing-connection-mode' import { classifyRemotePairingHostname } from '../../shared/remote-pairing-address' +import { toVerifiedPairingLink } from '../../shared/pairing' import type { RuntimePairingReach } from '../../shared/runtime-pairing-reach' import type { DeviceEntry } from '../runtime/device-registry' import { NETWORK_EXPOSURE_FAILED_GUIDANCE } from '../runtime/network-exposure-guidance' @@ -134,7 +135,9 @@ export function registerMobileHandlers( qrDataUrl: qr.ok ? qr.qrDataUrl : null, qrSize: qr.ok ? qr.qrSize : null, ...(!qr.ok ? { qrError: qr.reason } : {}), - pairingUrl: offer.pairingUrl, + // Why: the QR keeps the custom scheme older phones' scanners read, but a copied link can be + // tapped, and an `orca://` tap goes to whichever app claimed the scheme. + pairingUrl: toVerifiedPairingLink(offer.pairingUrl) ?? offer.pairingUrl, // Why: with nothing advertised the offer's endpoint is the loopback fallback, which points at // whichever device scans the QR — never this host. Report no endpoint so the UI omits it // instead of printing an address the phone can't reach. diff --git a/src/main/server/serve-readiness.test.ts b/src/main/server/serve-readiness.test.ts index e60bd4f04db..5d8c8f820e8 100644 --- a/src/main/server/serve-readiness.test.ts +++ b/src/main/server/serve-readiness.test.ts @@ -60,6 +60,25 @@ describe('ServeReadinessPublisher', () => { ) }) + it('prints a verified phone link for mobile pairing and none for runtime pairing', () => { + const mobile: ServeReadiness = { + ...ready, + pairing: { + available: true, + url: 'orca://pair?code=secret', + endpoint: 'wss://orca.example.test/runtime', + deviceId: 'device-1', + webClientUrl: null, + scope: 'mobile', + qr: null + } + } + const human = renderServeReadiness(mobile, { mode: 'human' }) + expect(human).toContain('Pairing URL: orca://pair?code=secret') + expect(human).toContain('Phone pairing link: https://www.onorca.dev/pair#secret') + expect(renderServeReadiness(ready, { mode: 'human' })).not.toContain('Phone pairing link') + }) + it('publishes a versioned JSON contract with explicit endpoints and pairing availability', () => { expect(JSON.parse(renderServeReadiness(ready, { mode: 'json' }))).toEqual({ type: 'orca_server_ready', diff --git a/src/main/server/serve-readiness.ts b/src/main/server/serve-readiness.ts index b0bdf14daba..6296ba838f2 100644 --- a/src/main/server/serve-readiness.ts +++ b/src/main/server/serve-readiness.ts @@ -1,5 +1,6 @@ import type { PairingOfferUnavailableReason } from '../runtime/runtime-rpc' import type { OrcadHealth } from '../orcad/orcad-health' +import { toVerifiedPairingLink } from '../../shared/pairing' export type ServePairingUnavailableReason = PairingOfferUnavailableReason | 'disabled_by_operator' @@ -119,6 +120,12 @@ function renderHumanReadiness(readiness: ServeReadiness): string { lines.push(`Mobile pairing QR:\n${readiness.pairing.qr}`) } lines.push(`Pairing URL: ${readiness.pairing.url}`) + // Why: tapping an `orca://` link on a phone hands the code to whichever app claimed the scheme. + const phoneLink = + readiness.pairing.scope === 'mobile' ? toVerifiedPairingLink(readiness.pairing.url) : null + if (phoneLink) { + lines.push(`Phone pairing link: ${phoneLink}`) + } } else { lines.push(`Pairing unavailable: ${readiness.pairing.reason}`) lines.push(`Pairing guidance: ${readiness.pairing.guidance}`) diff --git a/src/preload/api/mobile-api.ts b/src/preload/api/mobile-api.ts index 23c40f81504..6fc3e31ae7c 100644 --- a/src/preload/api/mobile-api.ts +++ b/src/preload/api/mobile-api.ts @@ -25,6 +25,7 @@ export type MobileApi = { /** Natural bitmap width and height in pixels. */ qrSize: number | null qrError?: 'encoding_failed' + /** Phone-facing copy link: verified https, code in the fragment. The QR keeps `orca://`. */ pairingUrl: string /** Null when no direct address was advertised — the QR pairs over Relay alone. */ endpoint: string | null diff --git a/src/preload/api/mobile-bridge.ts b/src/preload/api/mobile-bridge.ts index d6144409bd2..f8103cc0cd7 100644 --- a/src/preload/api/mobile-bridge.ts +++ b/src/preload/api/mobile-bridge.ts @@ -27,6 +27,7 @@ export const mobileApi = { /** Natural bitmap width and height in pixels. */ qrSize: number | null qrError?: 'encoding_failed' + /** Phone-facing copy link: verified https, code in the fragment. The QR keeps `orca://`. */ pairingUrl: string /** Null when no direct address was advertised — the QR pairs over Relay alone. */ endpoint: string | null diff --git a/src/shared/pairing.test.ts b/src/shared/pairing.test.ts index 1d1879587be..e1f127b9d7b 100644 --- a/src/shared/pairing.test.ts +++ b/src/shared/pairing.test.ts @@ -3,6 +3,7 @@ import { encodePairingOffer, decodePairingOffer, parsePairingCode, + toVerifiedPairingLink, type PairingOffer } from './pairing' @@ -131,6 +132,17 @@ describe('parsePairingCode', () => { 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() diff --git a/src/shared/pairing.ts b/src/shared/pairing.ts index 3c8335162a7..9a4f2271e88 100644 --- a/src/shared/pairing.ts +++ b/src/shared/pairing.ts @@ -7,6 +7,7 @@ import { PAIRING_CODE_MAX_CHARACTERS, PAIRING_INPUT_MAX_CHARACTERS } from './mobile-pairing-protocol-limits' +import { buildVerifiedPairingLink, extractVerifiedPairingLinkCode } from './verified-pairing-link' export { PAIRING_OFFER_VERSION, PairingOfferSchema } export type { PairingOffer } @@ -32,12 +33,24 @@ export function decodePairingOffer(url: string): PairingOffer { } const code = extractPairingCodeFromUrl(url) if (!code) { - throw new Error('Invalid pairing URL: must start with orca://pair and include a pairing code') + throw new Error( + 'Invalid pairing URL: must be an orca://pair or verified https pairing link with a code' + ) } return decodePairingBase64(code) } +/** The form handed to a phone: the custom scheme can be claimed by any app, this link cannot. */ +export function toVerifiedPairingLink(pairingUrl: string): string | null { + const code = extractPairingCodeFromUrl(pairingUrl) + return code ? buildVerifiedPairingLink(code) : null +} + function extractPairingCodeFromUrl(url: string): string | null { + const verifiedCode = extractVerifiedPairingLinkCode(url) + if (verifiedCode) { + return verifiedCode + } let parsed: URL try { parsed = new URL(url) @@ -59,7 +72,7 @@ function extractPairingCodeFromUrl(url: string): string | null { return parsed.hash ? parsed.hash.slice(1) || null : null } -// Why: accept either an `orca://pair?...` URL or the bare base64 +// Why: accept an `orca://pair?...` URL, a verified https link, or the bare base64 // string so the mobile paste-pair flow can take whichever the user // actually copied from desktop. export function parsePairingCode(input: string): PairingOffer | null { @@ -71,7 +84,7 @@ export function parsePairingCode(input: string): PairingOffer | null { return null } try { - if (trimmed.toLowerCase().startsWith('orca://')) { + if (/^(?:orca|https):\/\//i.test(trimmed)) { return decodePairingOffer(trimmed) } return decodePairingBase64(trimmed) diff --git a/src/shared/verified-pairing-link-files.test.ts b/src/shared/verified-pairing-link-files.test.ts new file mode 100644 index 00000000000..0c24dc9ee24 --- /dev/null +++ b/src/shared/verified-pairing-link-files.test.ts @@ -0,0 +1,113 @@ +import { readFileSync } from 'node:fs' +import { createRequire } from 'node:module' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { VERIFIED_PAIRING_LINK_HOST, VERIFIED_PAIRING_LINK_PATH } from './verified-pairing-link' + +const repoRoot = join(__dirname, '..', '..') +const deployDir = join(repoRoot, 'config', 'verified-links') +const appJson = JSON.parse(readFileSync(join(repoRoot, 'mobile', 'app.json'), 'utf8')) +const bundleId: string = appJson.expo.ios.bundleIdentifier +const androidPackage: string = appJson.expo.android.package +// The build template's public debug key: listing it would let anyone's APK claim the links. +const PUBLIC_TEMPLATE_DEBUG_CERT = + 'FA:C6:17:45:DC:09:03:78:6F:B9:ED:E6:2A:96:2B:39:9F:73:48:F0:BB:6F:89:9B:83:32:66:75:91:03:3B:9C' + +function readDeployJson(name: string): T { + return JSON.parse(readFileSync(join(deployDir, name), 'utf8')) +} + +type ExpoConfig = { + ios: { associatedDomains?: string[]; entitlements: Record } + android: { intentFilters: { autoVerify?: boolean }[] } +} + +function loadExpoConfig(associatedDomains: boolean): ExpoConfig { + const previous = process.env.ORCA_IOS_ASSOCIATED_DOMAINS + process.env.ORCA_IOS_ASSOCIATED_DOMAINS = associatedDomains ? '1' : '' + try { + const require = createRequire(__filename) + const configPath = require.resolve(join(repoRoot, 'mobile', 'app.config.js')) + delete require.cache[configPath] + return require(configPath)({ config: appJson.expo }) + } finally { + process.env.ORCA_IOS_ASSOCIATED_DOMAINS = previous + } +} + +afterEach(() => { + delete process.env.ORCA_IOS_ASSOCIATED_DOMAINS +}) + +describe('verified pairing link deploy files', () => { + it('scopes the Apple file to the pairing path of the iOS app', () => { + const aasa = readDeployJson<{ + applinks: { details: { appIDs: string[]; components: Record[] }[] } + }>('apple-app-site-association') + expect(Object.keys(aasa)).toEqual(['applinks']) + expect(aasa.applinks.details).toHaveLength(1) + const [detail] = aasa.applinks.details + expect(detail.appIDs).toHaveLength(1) + expect(detail.appIDs[0]).toMatch( + new RegExp( + `^(?:[A-Z0-9]{10}|REPLACE_WITH_APPLE_TEAM_ID)\\.${bundleId.replace(/\./g, '\\.')}$` + ) + ) + expect(detail.components.map((component) => component['/'])).toEqual([ + VERIFIED_PAIRING_LINK_PATH + ]) + // A query or fragment matcher would narrow nothing we rely on and break the fragment-only secret. + expect(detail.components.every((component) => !('?' in component) && !('#' in component))).toBe( + true + ) + }) + + it('binds the Android file to the app package and a private signing key', () => { + const statements = readDeployJson< + { + relation: string[] + target: { namespace: string; package_name: string; sha256_cert_fingerprints: string[] } + }[] + >('assetlinks.json') + expect(statements).toHaveLength(1) + const [statement] = statements + expect(statement.relation).toEqual(['delegate_permission/common.handle_all_urls']) + expect(statement.target.namespace).toBe('android_app') + expect(statement.target.package_name).toBe(androidPackage) + expect(statement.target.sha256_cert_fingerprints.length).toBeGreaterThan(0) + for (const fingerprint of statement.target.sha256_cert_fingerprints) { + expect(fingerprint).toMatch( + /^(?:(?:[0-9A-F]{2}:){31}[0-9A-F]{2}|REPLACE_WITH_RELEASE_SIGNING_CERT_SHA256)$/ + ) + expect(fingerprint).not.toBe(PUBLIC_TEMPLATE_DEBUG_CERT) + } + }) + + it('registers the same host and exact path in the Android app config', () => { + const config = loadExpoConfig(false) + const verifiedFilters = config.android.intentFilters.filter((filter) => filter.autoVerify) + expect(verifiedFilters).toEqual([ + { + action: 'VIEW', + autoVerify: true, + data: [ + { scheme: 'https', host: VERIFIED_PAIRING_LINK_HOST, path: VERIFIED_PAIRING_LINK_PATH } + ], + category: ['BROWSABLE', 'DEFAULT'] + } + ]) + }) + + it('adds the iOS associated domain only when the release opts in', () => { + expect(loadExpoConfig(false).ios.associatedDomains).toBeUndefined() + const config = loadExpoConfig(true) + expect(config.ios.associatedDomains).toEqual([`applinks:${VERIFIED_PAIRING_LINK_HOST}`]) + expect(config.ios.entitlements['aps-environment']).toBeDefined() + }) + + it('keeps the fallback page free of external scripts', () => { + const html = readFileSync(join(deployDir, 'pair.html'), 'utf8') + expect(html).not.toMatch(/]+src=/i) + expect(html).toContain('window.location.hash') + }) +}) diff --git a/src/shared/verified-pairing-link.test.ts b/src/shared/verified-pairing-link.test.ts new file mode 100644 index 00000000000..d5eb313c8d5 --- /dev/null +++ b/src/shared/verified-pairing-link.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'vitest' +import { + VERIFIED_PAIRING_LINK_HOST, + VERIFIED_PAIRING_LINK_PATH, + buildVerifiedPairingLink, + extractVerifiedPairingLinkCode +} from './verified-pairing-link' + +describe('verified pairing link', () => { + it('carries the code in the fragment, never the query string', () => { + const link = buildVerifiedPairingLink('abc_123-x') + expect(link).toBe( + `https://${VERIFIED_PAIRING_LINK_HOST}${VERIFIED_PAIRING_LINK_PATH}#abc_123-x` + ) + expect(link).not.toContain('?') + }) + + it('round-trips the code', () => { + expect(extractVerifiedPairingLinkCode(buildVerifiedPairingLink('abc123'))).toBe('abc123') + }) + + it('accepts host casing, surrounding whitespace and an ignored query', () => { + expect(extractVerifiedPairingLinkCode(' HTTPS://WWW.ONORCA.DEV/pair#abc\n')).toBe('abc') + expect(extractVerifiedPairingLinkCode('https://www.onorca.dev/pair?utm=x#abc')).toBe('abc') + }) + + it('never reads a code from the query string', () => { + expect(extractVerifiedPairingLinkCode('https://www.onorca.dev/pair?code=abc')).toBeNull() + }) + + it('rejects other hosts, ports, user info, schemes and paths', () => { + for (const url of [ + 'https://onorca.dev.evil.example/pair#abc', + 'https://evil.example/pair#abc', + 'https://onorca.dev/pair#abc', + 'https://www.onorca.dev:8443/pair#abc', + 'https://user@www.onorca.dev/pair#abc', + 'http://www.onorca.dev/pair#abc', + 'https://www.onorca.dev/pairing#abc', + 'https://www.onorca.dev/pair/#abc', + 'https://www.onorca.dev/pair-confirm#abc', + 'https://www.onorca.dev/pair#', + 'orca://pair#abc' + ]) { + expect(extractVerifiedPairingLinkCode(url), url).toBeNull() + } + }) +}) diff --git a/src/shared/verified-pairing-link.ts b/src/shared/verified-pairing-link.ts new file mode 100644 index 00000000000..efb0fa6dcce --- /dev/null +++ b/src/shared/verified-pairing-link.ts @@ -0,0 +1,24 @@ +// Why: an `orca://` link can be claimed by any app, so a pairing secret only leaves the desktop as +// an https link the OS hands to the app whose identity the domain vouches for. Keep these in sync +// with config/verified-links/ and mobile/app.config.js; verified-pairing-link-files.test.ts checks. +// Why www: the apex redirects to www, and neither OS verifies a link through a redirect. +export const VERIFIED_PAIRING_LINK_HOST = 'www.onorca.dev' +export const VERIFIED_PAIRING_LINK_PATH = '/pair' + +/** The secret rides in the fragment, which browsers never send to the server. */ +export function buildVerifiedPairingLink(pairingCode: string): string { + return `https://${VERIFIED_PAIRING_LINK_HOST}${VERIFIED_PAIRING_LINK_PATH}#${pairingCode}` +} + +/** String parsing only: Hermes's URL polyfill does not expose host or hash reliably. */ +export function extractVerifiedPairingLinkCode(url: string): string | null { + const match = /^https:\/\/([^/?#]*)([^?#]*)(?:\?[^#]*)?#(.*)$/i.exec(url.trim()) + if (!match) { + return null + } + const [, host, path, code] = match + if (host?.toLowerCase() !== VERIFIED_PAIRING_LINK_HOST || path !== VERIFIED_PAIRING_LINK_PATH) { + return null + } + return code || null +}