From cf58a1320280a6f1345fc9423845dc2c208162d5 Mon Sep 17 00:00:00 2001 From: OrcaWin Date: Sun, 11 Oct 2026 17:45:02 -0700 Subject: [PATCH] Revert "feat(mobile): verified https pairing links; custom scheme carries no secret (#27475)" (#27693) This reverts commit 20f8cb02c12d343a4e2f5a3dc8d8fabd388ddb07. Co-authored-by: m4air --- .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, 134 insertions(+), 982 deletions(-) delete mode 100644 config/verified-links/README.md delete mode 100644 config/verified-links/apple-app-site-association delete mode 100644 config/verified-links/assetlinks.json delete mode 100644 config/verified-links/pair.html delete mode 100644 mobile/app/+native-intent.ts delete mode 100644 mobile/src/transport/incoming-pairing-link-router-agreement.test.ts delete mode 100644 mobile/src/transport/incoming-pairing-link.test.ts delete mode 100644 mobile/src/transport/incoming-pairing-link.ts delete mode 100644 src/main/ipc/mobile-pairing-copy-link.test.ts delete mode 100644 src/shared/verified-pairing-link-files.test.ts delete mode 100644 src/shared/verified-pairing-link.test.ts delete 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 64f527377b5..975b0618c7f 100644 --- a/.github/workflows/mobile-ios-release.yml +++ b/.github/workflows/mobile-ios-release.yml @@ -104,8 +104,6 @@ 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 deleted file mode 100644 index 6c1899457eb..00000000000 --- a/config/verified-links/README.md +++ /dev/null @@ -1,72 +0,0 @@ -# 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 deleted file mode 100644 index a6569427f3c..00000000000 --- a/config/verified-links/apple-app-site-association +++ /dev/null @@ -1,15 +0,0 @@ -{ - "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 deleted file mode 100644 index 149654528e4..00000000000 --- a/config/verified-links/assetlinks.json +++ /dev/null @@ -1,10 +0,0 @@ -[ - { - "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 deleted file mode 100644 index 7be5160fd2f..00000000000 --- a/config/verified-links/pair.html +++ /dev/null @@ -1,155 +0,0 @@ - - - - - - - - - - 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 b3cb4df6f7c..e4bb110089f 100644 --- a/mobile/app.config.js +++ b/mobile/app.config.js @@ -11,37 +11,12 @@ 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 deleted file mode 100644 index 94ca9c7a94e..00000000000 --- a/mobile/app/+native-intent.ts +++ /dev/null @@ -1,6 +0,0 @@ -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 54d9c2c4841..711ee41ffd3 100644 --- a/mobile/app/_layout.tsx +++ b/mobile/app/_layout.tsx @@ -4,10 +4,11 @@ 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, useGlobalSearchParams, usePathname } from 'expo-router' +import { Stack, useRouter, 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' @@ -21,6 +22,7 @@ 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' @@ -41,6 +43,7 @@ Notifications.setNotificationHandler({ }) export default function RootLayout() { + const router = useRouter() const pathname = usePathname() const { hostId, worktreeId } = useGlobalSearchParams<{ hostId?: string; worktreeId?: string }>() useEffect(() => { @@ -67,6 +70,32 @@ 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 e4ac4881015..be790427088 100644 --- a/mobile/app/pair-confirm.tsx +++ b/mobile/app/pair-confirm.tsx @@ -3,10 +3,7 @@ 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 { - describePairingHost, - resolvePairConfirmRouteState -} from '../src/transport/pair-confirm-state' +import { resolvePairConfirmRouteState } from '../src/transport/pair-confirm-state' import { startPreProfilePairing, type PreProfilePairingAttempt @@ -46,7 +43,6 @@ 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 = @@ -156,24 +152,8 @@ export default function PairConfirmScreen() { <> Pair with this desktop? - Check that this address matches the one under the QR code in Orca on your computer. + You opened a pairing link from your desktop. Confirm to add it to your hosts. - {host && ( - - Address - - {host.endpoint} - - {host.keyFingerprint && ( - <> - Host key - - {host.keyFingerprint} - - - )} - - )} void confirm()}> Pair @@ -254,27 +234,6 @@ 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 3a93ade8482..61e99f19f2a 100644 --- a/mobile/app/pair-scan.tsx +++ b/mobile/app/pair-scan.tsx @@ -9,10 +9,9 @@ import { } from 'react-native' import { useSafeAreaInsets } from 'react-native-safe-area-context' import { CameraView, useCameraPermissions } from 'expo-camera' -import { useLocalSearchParams, useRouter } from 'expo-router' +import { 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 @@ -35,18 +34,6 @@ 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 ( @@ -62,7 +49,6 @@ 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('') @@ -74,15 +60,6 @@ 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 @@ -241,10 +218,9 @@ export default function PairScanScreen() { const canAskAgain = permission.canAskAgain !== false return ( - + router.back()}> - {canAskAgain ? 'Pair with desktop' : 'Camera Access Disabled'} @@ -275,7 +251,7 @@ export default function PairScanScreen() { visible={pasteVisible} title="Paste pairing code" message="Copy the code shown under the QR on your computer." - placeholder="Paste the pairing link or code" + placeholder="orca://pair?code=... or paste the code" onSubmit={handlePasteSubmit} onCancel={() => setPasteVisible(false)} /> @@ -285,11 +261,10 @@ export default function PairScanScreen() { return ( - + router.back()}> - @@ -374,7 +349,7 @@ export default function PairScanScreen() { visible={pasteVisible} title="Paste pairing code" message="Copy the code shown under the QR on your computer." - placeholder="Paste the pairing link or code" + placeholder="orca://pair?code=... or paste the code" onSubmit={handlePasteSubmit} onCancel={() => setPasteVisible(false)} /> diff --git a/mobile/app/pair.tsx b/mobile/app/pair.tsx index a1ec81f3772..6f3483763e1 100644 --- a/mobile/app/pair.tsx +++ b/mobile/app/pair.tsx @@ -1,6 +1,87 @@ -import { Redirect } from 'expo-router' +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' -// Why: +native-intent rewrites OS links before routing; this route never reads a pairing code. export default function PairRedirectScreen() { - return + 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 + + + ) : ( + + )} + + ) } + +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 edd185b7339..af1b936fb54 100644 --- a/mobile/src/pair-scan-styles.ts +++ b/mobile/src/pair-scan-styles.ts @@ -15,17 +15,6 @@ 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 deleted file mode 100644 index 570bcca55e2..00000000000 --- a/mobile/src/transport/incoming-pairing-link-router-agreement.test.ts +++ /dev/null @@ -1,109 +0,0 @@ -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 deleted file mode 100644 index c8dc58568c7..00000000000 --- a/mobile/src/transport/incoming-pairing-link.test.ts +++ /dev/null @@ -1,77 +0,0 @@ -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 deleted file mode 100644 index b08ef1d18e0..00000000000 --- a/mobile/src/transport/incoming-pairing-link.ts +++ /dev/null @@ -1,80 +0,0 @@ -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 11f2f0bc79c..1a318dd7e07 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 { describePairingHost, resolvePairConfirmRouteState } from './pair-confirm-state' +import { resolvePairConfirmRouteState } from './pair-confirm-state' import type { PairingOffer } from './types' function encodeOffer(offer: PairingOffer): string { @@ -52,28 +52,3 @@ 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 c9d58232e25..242e67d7ffe 100644 --- a/mobile/src/transport/pair-confirm-state.ts +++ b/mobile/src/transport/pair-confirm-state.ts @@ -1,4 +1,3 @@ -import { deriveHostFingerprint } from '../notifications/push-host-fingerprint' import { parsePairingCode } from './pairing' import type { PairingOffer } from './types' @@ -18,11 +17,3 @@ 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 16a3141c911..9b7b7514369 100644 --- a/mobile/src/transport/pairing.test.ts +++ b/mobile/src/transport/pairing.test.ts @@ -39,11 +39,6 @@ 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() @@ -68,13 +63,6 @@ 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 5e3c0aab99b..d330f1834fa 100644 --- a/mobile/src/transport/pairing.ts +++ b/mobile/src/transport/pairing.ts @@ -1,4 +1,3 @@ -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 @@ -18,14 +17,10 @@ export function decodePairingUrl(url: string): PairingOffer | null { } } -// 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. +// 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. 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) { @@ -54,8 +49,8 @@ export function extractPairingCodeFromUrl(url: string): string | null { return null } -// 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 +// Why: accept either an `orca://pair?...` URL 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() @@ -63,7 +58,7 @@ export function parsePairingCode(input: string): PairingOffer | null { return null } try { - if (/^(?:orca|https):\/\//i.test(trimmed)) { + if (/^orca:\/\//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 deleted file mode 100644 index bc942b89544..00000000000 --- a/src/main/ipc/mobile-pairing-copy-link.test.ts +++ /dev/null @@ -1,38 +0,0 @@ -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 a16f1449e5d..c148abe95a1 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: 'https://www.onorca.dev/pair#mobile', + pairingUrl: 'orca://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: 'https://www.onorca.dev/pair#copy-me', + pairingUrl: 'orca://pair?code=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 e1a20f76666..ccd3a6742ef 100644 --- a/src/main/ipc/mobile.ts +++ b/src/main/ipc/mobile.ts @@ -2,7 +2,6 @@ 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' @@ -135,9 +134,7 @@ export function registerMobileHandlers( qrDataUrl: qr.ok ? qr.qrDataUrl : null, qrSize: qr.ok ? qr.qrSize : null, ...(!qr.ok ? { qrError: qr.reason } : {}), - // 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, + 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 5d8c8f820e8..e60bd4f04db 100644 --- a/src/main/server/serve-readiness.test.ts +++ b/src/main/server/serve-readiness.test.ts @@ -60,25 +60,6 @@ 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 6296ba838f2..b0bdf14daba 100644 --- a/src/main/server/serve-readiness.ts +++ b/src/main/server/serve-readiness.ts @@ -1,6 +1,5 @@ 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' @@ -120,12 +119,6 @@ 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 6fc3e31ae7c..23c40f81504 100644 --- a/src/preload/api/mobile-api.ts +++ b/src/preload/api/mobile-api.ts @@ -25,7 +25,6 @@ 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 f8103cc0cd7..d6144409bd2 100644 --- a/src/preload/api/mobile-bridge.ts +++ b/src/preload/api/mobile-bridge.ts @@ -27,7 +27,6 @@ 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 e1f127b9d7b..1d1879587be 100644 --- a/src/shared/pairing.test.ts +++ b/src/shared/pairing.test.ts @@ -3,7 +3,6 @@ import { encodePairingOffer, decodePairingOffer, parsePairingCode, - toVerifiedPairingLink, type PairingOffer } from './pairing' @@ -132,17 +131,6 @@ 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 9a4f2271e88..3c8335162a7 100644 --- a/src/shared/pairing.ts +++ b/src/shared/pairing.ts @@ -7,7 +7,6 @@ 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 } @@ -33,24 +32,12 @@ export function decodePairingOffer(url: string): PairingOffer { } const code = extractPairingCodeFromUrl(url) if (!code) { - throw new Error( - 'Invalid pairing URL: must be an orca://pair or verified https pairing link with a code' - ) + throw new Error('Invalid pairing URL: must start with orca://pair and include a pairing 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) @@ -72,7 +59,7 @@ function extractPairingCodeFromUrl(url: string): string | null { return parsed.hash ? parsed.hash.slice(1) || null : null } -// Why: accept an `orca://pair?...` URL, a verified https link, or the bare base64 +// Why: accept either an `orca://pair?...` URL 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 { @@ -84,7 +71,7 @@ export function parsePairingCode(input: string): PairingOffer | null { return null } try { - if (/^(?:orca|https):\/\//i.test(trimmed)) { + if (trimmed.toLowerCase().startsWith('orca://')) { 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 deleted file mode 100644 index 0c24dc9ee24..00000000000 --- a/src/shared/verified-pairing-link-files.test.ts +++ /dev/null @@ -1,113 +0,0 @@ -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 deleted file mode 100644 index d5eb313c8d5..00000000000 --- a/src/shared/verified-pairing-link.test.ts +++ /dev/null @@ -1,48 +0,0 @@ -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 deleted file mode 100644 index efb0fa6dcce..00000000000 --- a/src/shared/verified-pairing-link.ts +++ /dev/null @@ -1,24 +0,0 @@ -// 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 -}