From 20f8cb02c12d343a4e2f5a3dc8d8fabd388ddb07 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Sun, 11 Oct 2026 10:06:05 -0700 Subject: [PATCH] feat(mobile): verified https pairing links; custom scheme carries no secret (#27475) * feat(mobile): verified https pairing links; custom scheme carries no secret The desktop's copy-link now emits https://www.onorca.dev/pair#, 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 * 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 * fix(mobile): escape the apostrophe in the refused-link notice Co-Authored-By: Claude * 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 * fix(mobile): decode nested pairing links to any depth before refusing codes Co-Authored-By: Claude * fix(mobile): ignore URL-stripped whitespace when refusing nested pairing codes Co-Authored-By: Claude * 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 * 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 * test(mobile): pin line-terminator fragment links as refused Co-Authored-By: Claude --------- Co-authored-by: m4air Co-authored-by: Claude --- .github/workflows/mobile-ios-release.yml | 2 + config/verified-links/README.md | 72 ++++++++ .../verified-links/apple-app-site-association | 15 ++ config/verified-links/assetlinks.json | 10 ++ config/verified-links/pair.html | 155 ++++++++++++++++++ mobile/app.config.js | 25 +++ mobile/app/+native-intent.ts | 6 + mobile/app/_layout.tsx | 31 +--- mobile/app/pair-confirm.tsx | 45 ++++- mobile/app/pair-scan.tsx | 35 +++- mobile/app/pair.tsx | 87 +--------- mobile/src/pair-scan-styles.ts | 11 ++ ...ming-pairing-link-router-agreement.test.ts | 109 ++++++++++++ .../transport/incoming-pairing-link.test.ts | 77 +++++++++ mobile/src/transport/incoming-pairing-link.ts | 80 +++++++++ .../src/transport/pair-confirm-state.test.ts | 27 ++- mobile/src/transport/pair-confirm-state.ts | 9 + mobile/src/transport/pairing.test.ts | 12 ++ mobile/src/transport/pairing.ts | 17 +- src/main/ipc/mobile-pairing-copy-link.test.ts | 38 +++++ src/main/ipc/mobile.test.ts | 4 +- src/main/ipc/mobile.ts | 5 +- src/main/server/serve-readiness.test.ts | 19 +++ src/main/server/serve-readiness.ts | 7 + src/preload/api/mobile-api.ts | 1 + src/preload/api/mobile-bridge.ts | 1 + src/shared/pairing.test.ts | 12 ++ src/shared/pairing.ts | 19 ++- .../verified-pairing-link-files.test.ts | 113 +++++++++++++ src/shared/verified-pairing-link.test.ts | 48 ++++++ src/shared/verified-pairing-link.ts | 24 +++ 31 files changed, 982 insertions(+), 134 deletions(-) create mode 100644 config/verified-links/README.md create mode 100644 config/verified-links/apple-app-site-association create mode 100644 config/verified-links/assetlinks.json create mode 100644 config/verified-links/pair.html create mode 100644 mobile/app/+native-intent.ts create mode 100644 mobile/src/transport/incoming-pairing-link-router-agreement.test.ts create mode 100644 mobile/src/transport/incoming-pairing-link.test.ts create mode 100644 mobile/src/transport/incoming-pairing-link.ts create mode 100644 src/main/ipc/mobile-pairing-copy-link.test.ts create mode 100644 src/shared/verified-pairing-link-files.test.ts create mode 100644 src/shared/verified-pairing-link.test.ts create mode 100644 src/shared/verified-pairing-link.ts 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 +}