Revert "feat(mobile): verified https pairing links; custom scheme carries no secret (#27475)" (#27693)

This reverts commit 20f8cb02c1.

Co-authored-by: m4air <m4air@Mac.localdomain>
This commit is contained in:
OrcaWin
2026-10-11 17:45:02 -07:00
committed by GitHub
co-authored by m4air
parent 599a610a5a
commit cf58a13202
31 changed files with 134 additions and 982 deletions
-2
View File
@@ -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
-72
View File
@@ -1,72 +0,0 @@
# Verified pairing links
Orca Mobile pairs from `https://www.onorca.dev/pair#<code>`. 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`.
@@ -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."
}
]
}
]
}
}
-10
View File
@@ -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"]
}
}
]
-155
View File
@@ -1,155 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="referrer" content="no-referrer" />
<meta name="robots" content="noindex" />
<!-- The pairing code is in the URL fragment: never load third-party scripts on this page. -->
<meta
http-equiv="Content-Security-Policy"
content="default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; img-src 'self'"
/>
<title>Pair Orca Mobile</title>
<style>
:root {
color-scheme: light dark;
--bg: #ffffff;
--fg: #111111;
--muted: #5c5c5c;
--border: #e2e2e2;
--button-bg: #111111;
--button-fg: #ffffff;
}
@media (prefers-color-scheme: dark) {
:root {
--bg: #111111;
--fg: #f2f2f2;
--muted: #a3a3a3;
--border: #2c2c2c;
--button-bg: #f2f2f2;
--button-fg: #111111;
}
}
body {
margin: 0;
background: var(--bg);
color: var(--fg);
font:
16px/1.5 -apple-system,
BlinkMacSystemFont,
'Segoe UI',
Roboto,
sans-serif;
}
main {
max-width: 520px;
margin: 0 auto;
padding: 48px 16px;
}
h1 {
font-size: 24px;
margin: 0 0 8px;
}
p,
li {
color: var(--muted);
}
ol {
padding-left: 20px;
}
li {
margin-bottom: 12px;
}
strong {
color: var(--fg);
}
button {
margin-top: 8px;
padding: 10px 16px;
border: 0;
border-radius: 8px;
background: var(--button-bg);
color: var(--button-fg);
font: inherit;
font-weight: 600;
}
input {
width: 100%;
box-sizing: border-box;
margin-top: 8px;
padding: 8px;
border: 1px solid var(--border);
border-radius: 8px;
background: transparent;
color: var(--fg);
font:
12px ui-monospace,
monospace;
}
.note {
border-top: 1px solid var(--border);
padding-top: 16px;
font-size: 14px;
}
[hidden] {
display: none;
}
</style>
</head>
<body>
<main>
<h1>Pair Orca Mobile</h1>
<p>
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.
</p>
<p><strong>Seeing this page instead?</strong></p>
<ol>
<li>Update Orca Mobile, then open the link again.</li>
<li>
Or open Orca Mobile, tap <strong>Pair</strong>, and scan the QR code shown in Orca on your
computer under <strong>Settings → Mobile</strong>.
</li>
<li id="copy-step" hidden>
Or copy the pairing code and paste it in Orca Mobile under
<strong>Pair → Paste code</strong>.
<br />
<button id="copy-button" type="button">Copy pairing code</button>
<input id="code-fallback" type="text" readonly hidden aria-label="Pairing code" />
</li>
</ol>
<p>Don't have the app? <a href="/download">Download Orca Mobile</a>.</p>
<p class="note">
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.
</p>
</main>
<script>
;(function () {
var code = window.location.hash.slice(1)
if (!code) {
return
}
var step = document.getElementById('copy-step')
var button = document.getElementById('copy-button')
var fallback = document.getElementById('code-fallback')
step.hidden = false
function showFallback() {
fallback.value = code
fallback.hidden = false
fallback.select()
}
button.addEventListener('click', function () {
if (!navigator.clipboard) {
showFallback()
return
}
navigator.clipboard.writeText(code).then(function () {
button.textContent = 'Copied'
}, showFallback)
})
})()
</script>
</body>
</html>
-25
View File
@@ -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'
? [
-6
View File
@@ -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
}
+30 -1
View File
@@ -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
+2 -43
View File
@@ -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() {
<>
<Text style={styles.title}>Pair with this desktop?</Text>
<Text style={styles.subtitle}>
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.
</Text>
{host && (
<View style={styles.hostDetails}>
<Text style={styles.detailLabel}>Address</Text>
<Text style={styles.detailValue} selectable>
{host.endpoint}
</Text>
{host.keyFingerprint && (
<>
<Text style={styles.detailLabel}>Host key</Text>
<Text style={styles.detailValue} selectable>
{host.keyFingerprint}
</Text>
</>
)}
</View>
)}
<View style={styles.actionStack}>
<Pressable style={styles.primaryButton} onPress={() => void confirm()}>
<Text style={styles.primaryButtonText}>Pair</Text>
@@ -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,
+5 -30
View File
@@ -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 (
<Text style={styles.notice}>
That orca:// link can’t pair your phone, because any app can open orca:// links. Scan the QR
code on your computer instead.
</Text>
)
}
function Step({ number, text }: { number: number; text: string }) {
return (
<View style={styles.step}>
@@ -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<PreProfilePairingAttempt | null>(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 (
<View ref={setPairScanRootRef} style={[styles.container, containerPadding]}>
<Pressable style={styles.backButton} onPress={goBack}>
<Pressable style={styles.backButton} onPress={() => router.back()}>
<ChevronLeft size={22} color={colors.textSecondary} />
</Pressable>
<LinkRefusedNotice notice={notice} />
<View style={styles.centered}>
<Text style={styles.title}>
{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 (
<View ref={setPairScanRootRef} style={[styles.container, containerPadding]}>
<Pressable style={styles.backButton} onPress={goBack}>
<Pressable style={styles.backButton} onPress={() => router.back()}>
<ChevronLeft size={22} color={colors.textSecondary} />
</Pressable>
<LinkRefusedNotice notice={notice} />
<View style={styles.steps}>
<Step number={1} text="Open Orca on your computer" />
<Step number={2} text="Go to Settings → Mobile" />
@@ -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)}
/>
+84 -3
View File
@@ -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 <Redirect href="/pair-scan" />
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 (
<View style={styles.container}>
{missingCode ? (
<>
<Text style={styles.errorText}>Missing pairing code</Text>
<Pressable style={styles.primaryButton} onPress={goHome}>
<Text style={styles.primaryButtonText}>Back to home</Text>
</Pressable>
</>
) : (
<ActivityIndicator size="large" color={colors.textSecondary} />
)}
</View>
)
}
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'
}
})
-11
View File
@@ -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,
@@ -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<string> {
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<string> {
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<string> {
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()
})
})
@@ -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()
})
})
@@ -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<typeof resolveRouterTarget>
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#<code>` 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
}
}
@@ -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()
})
})
@@ -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) }
}
-12
View File
@@ -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,
+6 -11
View File
@@ -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)
@@ -1,38 +0,0 @@
import { describe, expect, it, vi } from 'vitest'
const { handlers } = vi.hoisted(() => ({
handlers: new Map<string, (...args: unknown[]) => 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')
})
})
+2 -2
View File
@@ -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'
+1 -4
View File
@@ -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.
-19
View File
@@ -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',
-7
View File
@@ -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}`)
-1
View File
@@ -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
-1
View File
@@ -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
-12
View File
@@ -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()
+3 -16
View File
@@ -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)
@@ -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<T>(name: string): T {
return JSON.parse(readFileSync(join(deployDir, name), 'utf8'))
}
type ExpoConfig = {
ios: { associatedDomains?: string[]; entitlements: Record<string, string> }
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<string, string>[] }[] }
}>('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(/<script[^>]+src=/i)
expect(html).toContain('window.location.hash')
})
})
-48
View File
@@ -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()
}
})
})
-24
View File
@@ -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
}