mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
feat(mobile): verified https pairing links; custom scheme carries no secret (#27475)
* feat(mobile): verified https pairing links; custom scheme carries no secret The desktop's copy-link now emits https://www.onorca.dev/pair#<code>, with the code only in the fragment. Orca Mobile claims that link as a verified app link and refuses any pairing code delivered through orca://, which any app can claim; orca://pair alone opens the scanner with an explanation. In-app QR scan and paste still accept the custom scheme, so older desktops and phones keep pairing. The confirm screen shows the desktop address and host key fingerprint before pairing. config/verified-links/ holds the apple-app-site-association, assetlinks.json and fallback page to deploy on www.onorca.dev, with a test that keeps them, mobile/app.config.js and the link builder in agreement. Part of #17397. Co-Authored-By: Claude <noreply@anthropic.com> * fix(mobile): refuse pairing codes nested in or normalized out of unverified links The router unwraps expo-development-client ?url= links and normalizes dot segments after +native-intent runs, so an orca:// wrapper could still deliver a code to the confirm screen. Refuse any unverified link carrying code= at any encoding depth, and match pairing routes on decoded, dot-free segments. Co-Authored-By: Claude <noreply@anthropic.com> * fix(mobile): escape the apostrophe in the refused-link notice Co-Authored-By: Claude <noreply@anthropic.com> * fix(mobile): decode escapes byte by byte before refusing nested pairing codes An invalid UTF-8 escape made the whole escape run fail to decode, hiding an adjacent encoded &code= that the router still decodes leniently. Co-Authored-By: Claude <noreply@anthropic.com> * fix(mobile): decode nested pairing links to any depth before refusing codes Co-Authored-By: Claude <noreply@anthropic.com> * fix(mobile): ignore URL-stripped whitespace when refusing nested pairing codes Co-Authored-By: Claude <noreply@anthropic.com> * refactor(mobile): decide pairing-link refusal on the router's own parse Stop re-implementing URL decoding. The guard now runs expo-router's own extractExpoPathFromURL and parseUrlUsingCustomBase, the functions its linking applies after +native-intent, and refuses any link from which the router would read a `code` param or reach a pairing route with a payload. A link the router cannot parse is refused. Release builds refuse expo-development-client wrappers outright. A property-style test walks wrapping depths, encoding layers, stripped control characters, case and unicode, and asserts that the guard never passes on a link from which the router would read a code. Co-Authored-By: Claude <noreply@anthropic.com> * fix(mobile): match pairing routes on the top-level segment only A host link with a segment named pair was sent to the scanner. Co-Authored-By: Claude <noreply@anthropic.com> * test(mobile): pin line-terminator fragment links as refused Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: m4air <m4air@Mac.localdomain> Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -104,6 +104,8 @@ jobs:
|
||||
- name: Expo prebuild
|
||||
env:
|
||||
ORCA_IOS_APS_ENVIRONMENT: production
|
||||
# Set the repo variable to 1 only after the App ID has Associated Domains enabled.
|
||||
ORCA_IOS_ASSOCIATED_DOMAINS: ${{ vars.ORCA_IOS_ASSOCIATED_DOMAINS }}
|
||||
run: npx expo prebuild --platform ios --no-install
|
||||
|
||||
- name: Install CocoaPods
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
# 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`.
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"applinks": {
|
||||
"details": [
|
||||
{
|
||||
"appIDs": ["REPLACE_WITH_APPLE_TEAM_ID.com.stably.orca.mobile"],
|
||||
"components": [
|
||||
{
|
||||
"/": "/pair",
|
||||
"comment": "Orca Mobile pairing link. The pairing code is in the fragment."
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
[
|
||||
{
|
||||
"relation": ["delegate_permission/common.handle_all_urls"],
|
||||
"target": {
|
||||
"namespace": "android_app",
|
||||
"package_name": "com.stably.orca.mobile",
|
||||
"sha256_cert_fingerprints": ["REPLACE_WITH_RELEASE_SIGNING_CERT_SHA256"]
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,155 @@
|
||||
<!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>
|
||||
@@ -11,12 +11,37 @@
|
||||
const APS_ENVIRONMENT =
|
||||
process.env.ORCA_IOS_APS_ENVIRONMENT === 'production' ? 'production' : 'development'
|
||||
|
||||
// Verified pairing links. Mirrors src/shared/verified-pairing-link.ts and config/verified-links/;
|
||||
// src/shared/verified-pairing-link-files.test.ts fails if they drift.
|
||||
const VERIFIED_PAIRING_LINK_HOST = 'www.onorca.dev'
|
||||
const VERIFIED_PAIRING_LINK_PATH = '/pair'
|
||||
// Why opt-in: an App Store archive with this entitlement fails to sign until the App ID has the
|
||||
// Associated Domains capability (config/verified-links/README.md).
|
||||
const IOS_ASSOCIATED_DOMAINS = process.env.ORCA_IOS_ASSOCIATED_DOMAINS === '1'
|
||||
|
||||
module.exports = ({ config }) => ({
|
||||
...config,
|
||||
ios: {
|
||||
...config.ios,
|
||||
...(IOS_ASSOCIATED_DOMAINS
|
||||
? { associatedDomains: [`applinks:${VERIFIED_PAIRING_LINK_HOST}`] }
|
||||
: {}),
|
||||
entitlements: { ...config.ios?.entitlements, 'aps-environment': APS_ENVIRONMENT }
|
||||
},
|
||||
android: {
|
||||
...config.android,
|
||||
intentFilters: [
|
||||
...(config.android?.intentFilters ?? []),
|
||||
{
|
||||
action: 'VIEW',
|
||||
autoVerify: true,
|
||||
data: [
|
||||
{ scheme: 'https', host: VERIFIED_PAIRING_LINK_HOST, path: VERIFIED_PAIRING_LINK_PATH }
|
||||
],
|
||||
category: ['BROWSABLE', 'DEFAULT']
|
||||
}
|
||||
]
|
||||
},
|
||||
plugins: (config.plugins ?? []).map((plugin) =>
|
||||
plugin === 'expo-notifications'
|
||||
? [
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import { redirectIncomingPairingLink } from '../src/transport/incoming-pairing-link'
|
||||
|
||||
// Why: rewrite before routing, so no OS-delivered custom-scheme link reaches a screen that reads a code.
|
||||
export function redirectSystemPath({ path }: { path: string; initial: boolean }): string {
|
||||
return redirectIncomingPairingLink(path) ?? path
|
||||
}
|
||||
+1
-30
@@ -4,11 +4,10 @@ import { readNativeNotificationData } from '../src/notifications/native-notifica
|
||||
import { setNotificationViewingWorkspace } from '../src/notifications/notification-viewing-policy'
|
||||
import { useCallback, useEffect, useRef } from 'react'
|
||||
import { View, StyleSheet } from 'react-native'
|
||||
import { Stack, useRouter, useGlobalSearchParams, usePathname } from 'expo-router'
|
||||
import { Stack, useGlobalSearchParams, usePathname } from 'expo-router'
|
||||
import { StatusBar } from 'expo-status-bar'
|
||||
import * as SplashScreen from 'expo-splash-screen'
|
||||
import * as Notifications from 'expo-notifications'
|
||||
import * as Linking from 'expo-linking'
|
||||
import { colors } from '../src/theme/mobile-theme'
|
||||
import { OrcaLogo } from '../src/components/OrcaLogo'
|
||||
import { RpcClientProvider } from '../src/transport/client-context'
|
||||
@@ -22,7 +21,6 @@ import {
|
||||
import { startPushTokenSync } from '../src/notifications/push-registration'
|
||||
import { ensureDesktopNotificationChannel } from '../src/notifications/desktop-notification-channel'
|
||||
import { loadHostCatalog } from '../src/transport/host-store'
|
||||
import { extractPairingCodeFromUrl } from '../src/transport/pairing'
|
||||
import { recoverMobileRelayPairing } from '../src/transport/mobile-relay-pairing-recovery'
|
||||
import { appUpdateChecker } from '../src/app-update/app-update-runtime'
|
||||
|
||||
@@ -43,7 +41,6 @@ Notifications.setNotificationHandler({
|
||||
})
|
||||
|
||||
export default function RootLayout() {
|
||||
const router = useRouter()
|
||||
const pathname = usePathname()
|
||||
const { hostId, worktreeId } = useGlobalSearchParams<{ hostId?: string; worktreeId?: string }>()
|
||||
useEffect(() => {
|
||||
@@ -70,32 +67,6 @@ export default function RootLayout() {
|
||||
useEffect(() => appUpdateChecker.start(), [])
|
||||
useEffect(() => startAndroidForegroundPushPresentation(), [])
|
||||
|
||||
// Why: route `orca://pair?...` deep links to the confirm screen so
|
||||
// the same pairing flow runs whether the link arrived via QR scan,
|
||||
// paste, AirDrop, Messages, or `xcrun simctl openurl`. getInitialURL
|
||||
// covers cold-start (link tapped while app was closed); the listener
|
||||
// covers warm-start (link tapped while app is in memory).
|
||||
useEffect(() => {
|
||||
function handleUrl(url: string) {
|
||||
const code = extractPairingCodeFromUrl(url)
|
||||
if (code) {
|
||||
// Why: Android camera launches can leave Expo Router's unmatched
|
||||
// `orca://pair` route underneath this screen; replacing keeps cancel
|
||||
// and edge-back from revealing the router error page.
|
||||
router.replace({ pathname: '/pair-confirm', params: { code } })
|
||||
}
|
||||
}
|
||||
|
||||
void Linking.getInitialURL().then((url) => {
|
||||
if (url) {
|
||||
handleUrl(url)
|
||||
}
|
||||
})
|
||||
|
||||
const sub = Linking.addEventListener('url', ({ url }) => handleUrl(url))
|
||||
return () => sub.remove()
|
||||
}, [router])
|
||||
|
||||
// ─── Notification tap routing ───
|
||||
// Why: iOS delivers local notification taps through expo-notifications,
|
||||
// not Linking. Route both cold-start and warm-start responses to the host
|
||||
|
||||
@@ -3,7 +3,10 @@ import { View, Text, StyleSheet, Pressable, ActivityIndicator, BackHandler } fro
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||
import { useFocusEffect, useLocalSearchParams, useRouter } from 'expo-router'
|
||||
import { ChevronLeft } from 'lucide-react-native'
|
||||
import { resolvePairConfirmRouteState } from '../src/transport/pair-confirm-state'
|
||||
import {
|
||||
describePairingHost,
|
||||
resolvePairConfirmRouteState
|
||||
} from '../src/transport/pair-confirm-state'
|
||||
import {
|
||||
startPreProfilePairing,
|
||||
type PreProfilePairingAttempt
|
||||
@@ -43,6 +46,7 @@ export default function PairConfirmScreen() {
|
||||
|
||||
const routeState = resolvePairConfirmRouteState(params.code)
|
||||
const offer = routeState.offer
|
||||
const host = offer ? describePairingHost(offer) : null
|
||||
const resolvedStatus =
|
||||
status === 'awaiting-confirm' && routeState.kind === 'error' ? 'error' : status
|
||||
const resolvedErrorMessage =
|
||||
@@ -152,8 +156,24 @@ export default function PairConfirmScreen() {
|
||||
<>
|
||||
<Text style={styles.title}>Pair with this desktop?</Text>
|
||||
<Text style={styles.subtitle}>
|
||||
You opened a pairing link from your desktop. Confirm to add it to your hosts.
|
||||
Check that this address matches the one under the QR code in Orca on your computer.
|
||||
</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>
|
||||
@@ -234,6 +254,27 @@ const styles = StyleSheet.create({
|
||||
maxWidth: 520,
|
||||
alignSelf: 'center'
|
||||
},
|
||||
hostDetails: {
|
||||
width: '100%',
|
||||
maxWidth: 360,
|
||||
alignSelf: 'center',
|
||||
backgroundColor: colors.bgRaised,
|
||||
borderColor: colors.borderSubtle,
|
||||
borderWidth: 1,
|
||||
borderRadius: radii.card,
|
||||
padding: spacing.md,
|
||||
marginBottom: spacing.xl
|
||||
},
|
||||
detailLabel: {
|
||||
color: colors.textSecondary,
|
||||
fontSize: typography.metaSize
|
||||
},
|
||||
detailValue: {
|
||||
color: colors.textPrimary,
|
||||
fontFamily: typography.monoFamily,
|
||||
fontSize: typography.bodySize,
|
||||
marginBottom: spacing.sm
|
||||
},
|
||||
actionStack: {
|
||||
width: '100%',
|
||||
maxWidth: 360,
|
||||
|
||||
@@ -9,9 +9,10 @@ import {
|
||||
} from 'react-native'
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||
import { CameraView, useCameraPermissions } from 'expo-camera'
|
||||
import { useRouter } from 'expo-router'
|
||||
import { useLocalSearchParams, useRouter } from 'expo-router'
|
||||
import { ChevronLeft, Clipboard as ClipboardIcon, QrCode } from 'lucide-react-native'
|
||||
import { decodePairingUrl, parsePairingCode } from '../src/transport/pairing'
|
||||
import { PAIR_SCAN_LINK_REFUSED_NOTICE } from '../src/transport/incoming-pairing-link'
|
||||
import {
|
||||
startPreProfilePairing,
|
||||
type PreProfilePairingAttempt
|
||||
@@ -34,6 +35,18 @@ const PAIRING_OVERALL_TIMEOUT_MS = 25_000
|
||||
const SCAN_RETICLE_SCALE = 0.62
|
||||
const SCAN_RETICLE_MAX_SIZE = 360
|
||||
|
||||
function LinkRefusedNotice({ notice }: { notice: string | undefined }) {
|
||||
if (notice !== PAIR_SCAN_LINK_REFUSED_NOTICE) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<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}>
|
||||
@@ -49,6 +62,7 @@ export default function PairScanScreen() {
|
||||
const router = useRouter()
|
||||
const refreshHostClient = useRefreshHostClient()
|
||||
const insets = useSafeAreaInsets()
|
||||
const { notice } = useLocalSearchParams<{ notice?: string }>()
|
||||
const [permission, requestPermission] = useCameraPermissions()
|
||||
const [status, setStatus] = useState<'scanning' | 'connecting' | 'error'>('scanning')
|
||||
const [errorMessage, setErrorMessage] = useState('')
|
||||
@@ -60,6 +74,15 @@ export default function PairScanScreen() {
|
||||
const mountedRef = useRef(true)
|
||||
const activePairingAttemptRef = useRef<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
|
||||
@@ -218,9 +241,10 @@ export default function PairScanScreen() {
|
||||
const canAskAgain = permission.canAskAgain !== false
|
||||
return (
|
||||
<View ref={setPairScanRootRef} style={[styles.container, containerPadding]}>
|
||||
<Pressable style={styles.backButton} onPress={() => router.back()}>
|
||||
<Pressable style={styles.backButton} onPress={goBack}>
|
||||
<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'}
|
||||
@@ -251,7 +275,7 @@ export default function PairScanScreen() {
|
||||
visible={pasteVisible}
|
||||
title="Paste pairing code"
|
||||
message="Copy the code shown under the QR on your computer."
|
||||
placeholder="orca://pair?code=... or paste the code"
|
||||
placeholder="Paste the pairing link or code"
|
||||
onSubmit={handlePasteSubmit}
|
||||
onCancel={() => setPasteVisible(false)}
|
||||
/>
|
||||
@@ -261,10 +285,11 @@ export default function PairScanScreen() {
|
||||
|
||||
return (
|
||||
<View ref={setPairScanRootRef} style={[styles.container, containerPadding]}>
|
||||
<Pressable style={styles.backButton} onPress={() => router.back()}>
|
||||
<Pressable style={styles.backButton} onPress={goBack}>
|
||||
<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" />
|
||||
@@ -349,7 +374,7 @@ export default function PairScanScreen() {
|
||||
visible={pasteVisible}
|
||||
title="Paste pairing code"
|
||||
message="Copy the code shown under the QR on your computer."
|
||||
placeholder="orca://pair?code=... or paste the code"
|
||||
placeholder="Paste the pairing link or code"
|
||||
onSubmit={handlePasteSubmit}
|
||||
onCancel={() => setPasteVisible(false)}
|
||||
/>
|
||||
|
||||
+3
-84
@@ -1,87 +1,6 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { ActivityIndicator, Linking, Pressable, StyleSheet, Text, View } from 'react-native'
|
||||
import { useLocalSearchParams, useRouter } from 'expo-router'
|
||||
import { colors, radii, spacing, typography } from '../src/theme/mobile-theme'
|
||||
import { extractPairingCodeFromUrl } from '../src/transport/pairing'
|
||||
import { Redirect } from 'expo-router'
|
||||
|
||||
// Why: +native-intent rewrites OS links before routing; this route never reads a pairing code.
|
||||
export default function PairRedirectScreen() {
|
||||
const router = useRouter()
|
||||
const params = useLocalSearchParams<{ code?: string }>()
|
||||
const [missingCode, setMissingCode] = useState(false)
|
||||
|
||||
const goHome = useCallback(() => {
|
||||
router.replace('/')
|
||||
}, [router])
|
||||
|
||||
useEffect(() => {
|
||||
let disposed = false
|
||||
|
||||
async function redirectToConfirm() {
|
||||
const codeParam = Array.isArray(params.code) ? params.code[0] : params.code
|
||||
if (codeParam) {
|
||||
router.replace({ pathname: '/pair-confirm', params: { code: codeParam } })
|
||||
return
|
||||
}
|
||||
|
||||
const initialUrl = await Linking.getInitialURL().catch(() => null)
|
||||
const code = initialUrl ? extractPairingCodeFromUrl(initialUrl) : null
|
||||
if (disposed) {
|
||||
return
|
||||
}
|
||||
if (code) {
|
||||
router.replace({ pathname: '/pair-confirm', params: { code } })
|
||||
return
|
||||
}
|
||||
setMissingCode(true)
|
||||
}
|
||||
|
||||
void redirectToConfirm()
|
||||
return () => {
|
||||
disposed = true
|
||||
}
|
||||
}, [params.code, router])
|
||||
|
||||
return (
|
||||
<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>
|
||||
)
|
||||
return <Redirect href="/pair-scan" />
|
||||
}
|
||||
|
||||
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'
|
||||
}
|
||||
})
|
||||
|
||||
@@ -15,6 +15,17 @@ export const pairScanStyles = StyleSheet.create({
|
||||
justifyContent: 'center',
|
||||
marginBottom: spacing.sm
|
||||
},
|
||||
notice: {
|
||||
color: colors.textPrimary,
|
||||
backgroundColor: colors.bgRaised,
|
||||
borderColor: colors.borderSubtle,
|
||||
borderWidth: 1,
|
||||
borderRadius: radii.card,
|
||||
fontSize: typography.bodySize,
|
||||
lineHeight: 20,
|
||||
padding: spacing.md,
|
||||
marginBottom: spacing.lg
|
||||
},
|
||||
steps: {
|
||||
gap: spacing.sm,
|
||||
marginBottom: spacing.lg,
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import { extractExpoPathFromURL } from 'expo-router/build/fork/extractPathFromURL'
|
||||
import { parseUrlUsingCustomBase } from 'expo-router/build/utils/url'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { redirectIncomingPairingLink } from './incoming-pairing-link'
|
||||
|
||||
// The oracle is the router itself: what its linking reads from an OS link after the hook passes it on.
|
||||
function routerParams(url: string): URLSearchParams | null {
|
||||
try {
|
||||
return parseUrlUsingCustomBase(extractExpoPathFromURL([], url)).searchParams
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
const TARGETS = [
|
||||
'orca://pair-confirm?code=SECRET',
|
||||
'orca://pair?code=SECRET',
|
||||
'orca://pair-confirm?a=1&code=SECRET',
|
||||
'orca:///x/../pair-confirm?code=SECRET',
|
||||
'orca://(group)/pair-confirm?code=SECRET',
|
||||
'orca://h/host-1/session/wt-1?code=SECRET',
|
||||
'https://evil.example/pair-confirm?code=SECRET',
|
||||
'exp+orca-mobile://pair-confirm?code=SECRET'
|
||||
]
|
||||
// Tab, LF and CR are removed anywhere; C0 controls and space are trimmed from the ends.
|
||||
const STRIPPED = ['\t', '\n', '\r']
|
||||
const TRIMMED = ['\u0000', '\u001f', ' ']
|
||||
|
||||
function* mutations(url: string): Generator<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()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,77 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { PAIR_SCAN_LINK_REFUSED_NOTICE, redirectIncomingPairingLink } from './incoming-pairing-link'
|
||||
|
||||
const refused = `/pair-scan?notice=${PAIR_SCAN_LINK_REFUSED_NOTICE}`
|
||||
|
||||
describe('redirectIncomingPairingLink', () => {
|
||||
it('sends a verified https link to the confirm screen with the fragment code', () => {
|
||||
expect(redirectIncomingPairingLink('https://www.onorca.dev/pair#abc_1-2')).toBe(
|
||||
'/pair-confirm?code=abc_1-2'
|
||||
)
|
||||
})
|
||||
|
||||
it('refuses a pairing code carried by the custom scheme', () => {
|
||||
for (const url of [
|
||||
'orca://pair?code=abc123',
|
||||
'orca://pair#abc123',
|
||||
'ORCA://PAIR?code=abc123',
|
||||
'orca://pair/?code=abc123',
|
||||
'orca:///pair?code=abc123',
|
||||
'orca://pair-confirm?code=abc123',
|
||||
'orca:///pair-confirm?code=abc123',
|
||||
'exp+orca-mobile://pair-confirm?code=abc123',
|
||||
'orca://pair%2Dconfirm?code=abc123',
|
||||
'orca://./pair-confirm?code=abc123',
|
||||
'orca://h/host-1?code=abc123',
|
||||
'orca://pair-confirm?%63ode=abc123',
|
||||
'orca://pair-confirm?CODE=abc123',
|
||||
'orca:///ignored/../pair-confirm?code=abc123',
|
||||
`orca://expo-development-client/?url=${encodeURIComponent('orca://pair-confirm?code=abc123')}`,
|
||||
`exp+orca-mobile://expo-development-client/?url=${encodeURIComponent(
|
||||
encodeURIComponent('orca://pair-confirm?code=abc123')
|
||||
)}`,
|
||||
`orca://expo-development-client/?url=${encodeURIComponent('orca://pair-confirm?%2563ode=abc')}`,
|
||||
`orca://expo-development-client/?junk=%ZZ&url=${encodeURIComponent('orca://pair-confirm?code=x')}`,
|
||||
'orca://expo-development-client/?url=orca%3A%2F%2Fpair-confirm%3Fa%3D%E0%26code%3DSECRET',
|
||||
'orca://expo-development-client/?url=orca://pair-confirm?a%3D%E0%26code%3DSECRET',
|
||||
'orca://pair-confirm?code=SECRET#\u2029x',
|
||||
'orca://pair-confirm?code=SECRET#\nx',
|
||||
'orca://pair-confirm?code=SECRET#\u2028x',
|
||||
'orca://expo-development-client/?url=orca://pair-confirm?co%09de=SECRET',
|
||||
'orca://expo-development-client/?url=orca://pair-confirm?c%0Aode=SECRET',
|
||||
'orca://expo-development-client/?url=orca://pair-confirm%3Fcode%0D=SECRET'
|
||||
]) {
|
||||
expect(redirectIncomingPairingLink(url), url).toBe(refused)
|
||||
}
|
||||
})
|
||||
|
||||
it('refuses a code nested in development-client wrappers at any depth', () => {
|
||||
let url = 'orca://pair-confirm?code=abc123'
|
||||
for (let depth = 0; depth < 12; depth++) {
|
||||
url = `orca://expo-development-client/?url=${encodeURIComponent(url)}`
|
||||
}
|
||||
expect(redirectIncomingPairingLink(url)).toBe(refused)
|
||||
})
|
||||
|
||||
it('opens the scanner for a bare custom-scheme pairing link', () => {
|
||||
expect(redirectIncomingPairingLink('orca://pair')).toBe('/pair-scan')
|
||||
expect(redirectIncomingPairingLink('orca://pair/')).toBe('/pair-scan')
|
||||
})
|
||||
|
||||
it('refuses unverified https links that target a pairing screen', () => {
|
||||
expect(redirectIncomingPairingLink('https://evil.example/pair#abc123')).toBe(refused)
|
||||
expect(redirectIncomingPairingLink('https://www.onorca.dev/pair-confirm?code=abc')).toBe(
|
||||
refused
|
||||
)
|
||||
expect(redirectIncomingPairingLink('https://www.onorca.dev/pair?code=abc')).toBe(refused)
|
||||
})
|
||||
|
||||
it('leaves every other link to the router', () => {
|
||||
expect(redirectIncomingPairingLink('orca://h/host-1/session/wt-1')).toBeNull()
|
||||
expect(redirectIncomingPairingLink('orca://pairing')).toBeNull()
|
||||
expect(redirectIncomingPairingLink('orca://h/host-1?notice=x#y')).toBeNull()
|
||||
expect(redirectIncomingPairingLink('orca://pair-scan')).toBeNull()
|
||||
expect(redirectIncomingPairingLink('orca://h/host-1/session/pair')).toBeNull()
|
||||
expect(redirectIncomingPairingLink('/settings')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,80 @@
|
||||
import { extractExpoPathFromURL } from 'expo-router/build/fork/extractPathFromURL'
|
||||
import { parseUrlUsingCustomBase } from 'expo-router/build/utils/url'
|
||||
import { extractVerifiedPairingLinkCode } from '../../../src/shared/verified-pairing-link'
|
||||
|
||||
export const PAIR_SCAN_LINK_REFUSED_NOTICE = 'custom-scheme-link'
|
||||
|
||||
const REFUSED_PATH = `/pair-scan?notice=${PAIR_SCAN_LINK_REFUSED_NOTICE}`
|
||||
const PAIRING_ROUTES = new Set(['pair', 'pair-confirm'])
|
||||
const DEV_CLIENT_HOST = 'expo-development-client'
|
||||
|
||||
/** The router's own reading of an OS link: the same functions its linking uses, after this hook. */
|
||||
export function resolveRouterTarget(url: string): {
|
||||
pathname: string
|
||||
searchParams: URLSearchParams
|
||||
hash: string
|
||||
} {
|
||||
const parsed = parseUrlUsingCustomBase(extractExpoPathFromURL([], url))
|
||||
return { pathname: parsed.pathname, searchParams: parsed.searchParams, hash: parsed.hash }
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewrites an OS-delivered link before the router sees it. Null leaves the link to the router.
|
||||
* Why: the OS hands an `orca://` URL to whichever app claimed the scheme, so a code it carries may
|
||||
* already be in another app's hands. Only a verified https link may start pairing. The decision is
|
||||
* made on the router's own parse, never a re-implementation, and anything unparseable is refused.
|
||||
*/
|
||||
export function redirectIncomingPairingLink(
|
||||
url: string,
|
||||
allowDevClientLinks: boolean = typeof __DEV__ !== 'undefined' && __DEV__
|
||||
): string | null {
|
||||
const verifiedCode = extractVerifiedPairingLinkCode(url)
|
||||
if (verifiedCode) {
|
||||
return `/pair-confirm?code=${encodeURIComponent(verifiedCode)}`
|
||||
}
|
||||
// Why: the router unwraps `expo-development-client/?url=` links; release builds have no use for them.
|
||||
const outer = parseOuterUrl(url)
|
||||
if (!allowDevClientLinks && outer?.hostname === DEV_CLIENT_HOST) {
|
||||
return REFUSED_PATH
|
||||
}
|
||||
let target: ReturnType<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 { resolvePairConfirmRouteState } from './pair-confirm-state'
|
||||
import { describePairingHost, resolvePairConfirmRouteState } from './pair-confirm-state'
|
||||
import type { PairingOffer } from './types'
|
||||
|
||||
function encodeOffer(offer: PairingOffer): string {
|
||||
@@ -52,3 +52,28 @@ describe('resolvePairConfirmRouteState', () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('describePairingHost', () => {
|
||||
const publicKeyB64 = Buffer.alloc(32, 7).toString('base64')
|
||||
|
||||
it('shows the endpoint and a host key fingerprint before pairing', () => {
|
||||
const host = describePairingHost({
|
||||
v: 2,
|
||||
endpoint: 'ws://192.168.1.10:6768',
|
||||
deviceToken: 'token-abc',
|
||||
publicKeyB64
|
||||
})
|
||||
expect(host.endpoint).toBe('ws://192.168.1.10:6768')
|
||||
expect(host.keyFingerprint).toMatch(/^[A-Za-z0-9_-]{16}$/)
|
||||
})
|
||||
|
||||
it('omits the fingerprint for an unreadable key', () => {
|
||||
const host = describePairingHost({
|
||||
v: 2,
|
||||
endpoint: 'ws://192.168.1.10:6768',
|
||||
deviceToken: 'token-abc',
|
||||
publicKeyB64: 'pubkey-xyz'
|
||||
})
|
||||
expect(host.keyFingerprint).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { deriveHostFingerprint } from '../notifications/push-host-fingerprint'
|
||||
import { parsePairingCode } from './pairing'
|
||||
import type { PairingOffer } from './types'
|
||||
|
||||
@@ -17,3 +18,11 @@ export function resolvePairConfirmRouteState(code: string | undefined): PairConf
|
||||
|
||||
return { kind: 'ready', offer, errorMessage: '' }
|
||||
}
|
||||
|
||||
/** What the user checks against the desktop before pairing; a verified link proves the app, not the host. */
|
||||
export function describePairingHost(offer: PairingOffer): {
|
||||
endpoint: string
|
||||
keyFingerprint: string | null
|
||||
} {
|
||||
return { endpoint: offer.endpoint, keyFingerprint: deriveHostFingerprint(offer.publicKeyB64) }
|
||||
}
|
||||
|
||||
@@ -39,6 +39,11 @@ describe('pairing deep links', () => {
|
||||
expect(extractPairingCodeFromUrl('orca://pair?code=query-code#hash-code')).toBe('query-code')
|
||||
})
|
||||
|
||||
it('extracts the code from a verified https pairing link fragment', () => {
|
||||
expect(extractPairingCodeFromUrl('https://www.onorca.dev/pair#abc123')).toBe('abc123')
|
||||
expect(extractPairingCodeFromUrl('https://www.onorca.dev/pair?code=abc123')).toBeNull()
|
||||
})
|
||||
|
||||
it('ignores empty and unrelated URLs', () => {
|
||||
expect(extractPairingCodeFromUrl('orca://pair')).toBeNull()
|
||||
expect(extractPairingCodeFromUrl('https://example.com/pair#abc123')).toBeNull()
|
||||
@@ -63,6 +68,13 @@ describe('pairing deep links', () => {
|
||||
expect(parsePairingCode(code)).toEqual(offer)
|
||||
})
|
||||
|
||||
it('parses a pasted or scanned verified https pairing link', () => {
|
||||
const link = `https://www.onorca.dev/pair#${encodeOffer()}`
|
||||
|
||||
expect(parsePairingCode(link)).toEqual(offer)
|
||||
expect(decodePairingUrl(link)).toEqual(offer)
|
||||
})
|
||||
|
||||
it('preserves a TLS reverse-proxy endpoint with an explicit port and path', () => {
|
||||
const proxiedOffer = {
|
||||
...offer,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { extractVerifiedPairingLinkCode } from '../../../src/shared/verified-pairing-link'
|
||||
import { PairingOfferSchema, type PairingOffer } from './types'
|
||||
|
||||
// Why: this file mirrors src/shared/pairing.ts (which is covered by CI
|
||||
@@ -17,10 +18,14 @@ export function decodePairingUrl(url: string): PairingOffer | null {
|
||||
}
|
||||
}
|
||||
|
||||
// Why: system camera apps hand us the raw custom-scheme URL. Keeping
|
||||
// extraction here makes QR scan, paste, and external deep-link flows
|
||||
// accept the same URL shapes.
|
||||
// Why: in-app QR scan and paste never pass through the OS link dispatcher, so
|
||||
// they keep accepting the custom scheme that old desktops emit. OS-delivered
|
||||
// links go through incoming-pairing-link.ts, which refuses custom-scheme codes.
|
||||
export function extractPairingCodeFromUrl(url: string): string | null {
|
||||
const verifiedCode = extractVerifiedPairingLinkCode(url)
|
||||
if (verifiedCode) {
|
||||
return verifiedCode
|
||||
}
|
||||
const trimmed = url.trim()
|
||||
const match = /^orca:\/\/([^/?#]*)([^?#]*)?/i.exec(trimmed)
|
||||
if (!match) {
|
||||
@@ -49,8 +54,8 @@ export function extractPairingCodeFromUrl(url: string): string | null {
|
||||
return null
|
||||
}
|
||||
|
||||
// Why: accept either an `orca://pair?...` URL or the bare base64
|
||||
// string so the paste-pair flow can take whichever the user actually
|
||||
// Why: accept an `orca://pair?...` URL, a verified https link, or the bare
|
||||
// base64 string so the paste-pair flow can take whichever the user actually
|
||||
// copied from desktop.
|
||||
export function parsePairingCode(input: string): PairingOffer | null {
|
||||
const trimmed = input.trim()
|
||||
@@ -58,7 +63,7 @@ export function parsePairingCode(input: string): PairingOffer | null {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
if (/^orca:\/\//i.test(trimmed)) {
|
||||
if (/^(?:orca|https):\/\//i.test(trimmed)) {
|
||||
return decodePairingUrl(trimmed)
|
||||
}
|
||||
return decodePairingBase64(trimmed)
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
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')
|
||||
})
|
||||
})
|
||||
@@ -346,7 +346,7 @@ describe('registerMobileHandlers', () => {
|
||||
await expect(handlers.get('mobile:getPairingQR')?.(null, {})).resolves.toMatchObject({
|
||||
available: true,
|
||||
qrSize: 58,
|
||||
pairingUrl: 'orca://pair#mobile',
|
||||
pairingUrl: 'https://www.onorca.dev/pair#mobile',
|
||||
endpoint: 'ws://100.102.47.57:6768',
|
||||
deviceId: 'mobile-1',
|
||||
connectionMode: 'automatic'
|
||||
@@ -426,7 +426,7 @@ describe('registerMobileHandlers', () => {
|
||||
qrDataUrl: null,
|
||||
qrSize: null,
|
||||
qrError: 'encoding_failed',
|
||||
pairingUrl: 'orca://pair?code=copy-me',
|
||||
pairingUrl: 'https://www.onorca.dev/pair#copy-me',
|
||||
endpoint: 'wss://pair.example/oversized',
|
||||
deviceId: 'mobile-large',
|
||||
connectionMode: 'local-only'
|
||||
|
||||
@@ -2,6 +2,7 @@ import { app, ipcMain, shell, type IpcMainInvokeEvent } from 'electron'
|
||||
import type { RuntimeAccessGrant } from '../../shared/runtime-access-grants'
|
||||
import type { MobilePairingConnectionMode } from '../../shared/mobile-pairing-connection-mode'
|
||||
import { classifyRemotePairingHostname } from '../../shared/remote-pairing-address'
|
||||
import { toVerifiedPairingLink } from '../../shared/pairing'
|
||||
import type { RuntimePairingReach } from '../../shared/runtime-pairing-reach'
|
||||
import type { DeviceEntry } from '../runtime/device-registry'
|
||||
import { NETWORK_EXPOSURE_FAILED_GUIDANCE } from '../runtime/network-exposure-guidance'
|
||||
@@ -134,7 +135,9 @@ export function registerMobileHandlers(
|
||||
qrDataUrl: qr.ok ? qr.qrDataUrl : null,
|
||||
qrSize: qr.ok ? qr.qrSize : null,
|
||||
...(!qr.ok ? { qrError: qr.reason } : {}),
|
||||
pairingUrl: offer.pairingUrl,
|
||||
// Why: the QR keeps the custom scheme older phones' scanners read, but a copied link can be
|
||||
// tapped, and an `orca://` tap goes to whichever app claimed the scheme.
|
||||
pairingUrl: toVerifiedPairingLink(offer.pairingUrl) ?? offer.pairingUrl,
|
||||
// Why: with nothing advertised the offer's endpoint is the loopback fallback, which points at
|
||||
// whichever device scans the QR — never this host. Report no endpoint so the UI omits it
|
||||
// instead of printing an address the phone can't reach.
|
||||
|
||||
@@ -60,6 +60,25 @@ describe('ServeReadinessPublisher', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('prints a verified phone link for mobile pairing and none for runtime pairing', () => {
|
||||
const mobile: ServeReadiness = {
|
||||
...ready,
|
||||
pairing: {
|
||||
available: true,
|
||||
url: 'orca://pair?code=secret',
|
||||
endpoint: 'wss://orca.example.test/runtime',
|
||||
deviceId: 'device-1',
|
||||
webClientUrl: null,
|
||||
scope: 'mobile',
|
||||
qr: null
|
||||
}
|
||||
}
|
||||
const human = renderServeReadiness(mobile, { mode: 'human' })
|
||||
expect(human).toContain('Pairing URL: orca://pair?code=secret')
|
||||
expect(human).toContain('Phone pairing link: https://www.onorca.dev/pair#secret')
|
||||
expect(renderServeReadiness(ready, { mode: 'human' })).not.toContain('Phone pairing link')
|
||||
})
|
||||
|
||||
it('publishes a versioned JSON contract with explicit endpoints and pairing availability', () => {
|
||||
expect(JSON.parse(renderServeReadiness(ready, { mode: 'json' }))).toEqual({
|
||||
type: 'orca_server_ready',
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { PairingOfferUnavailableReason } from '../runtime/runtime-rpc'
|
||||
import type { OrcadHealth } from '../orcad/orcad-health'
|
||||
import { toVerifiedPairingLink } from '../../shared/pairing'
|
||||
|
||||
export type ServePairingUnavailableReason = PairingOfferUnavailableReason | 'disabled_by_operator'
|
||||
|
||||
@@ -119,6 +120,12 @@ function renderHumanReadiness(readiness: ServeReadiness): string {
|
||||
lines.push(`Mobile pairing QR:\n${readiness.pairing.qr}`)
|
||||
}
|
||||
lines.push(`Pairing URL: ${readiness.pairing.url}`)
|
||||
// Why: tapping an `orca://` link on a phone hands the code to whichever app claimed the scheme.
|
||||
const phoneLink =
|
||||
readiness.pairing.scope === 'mobile' ? toVerifiedPairingLink(readiness.pairing.url) : null
|
||||
if (phoneLink) {
|
||||
lines.push(`Phone pairing link: ${phoneLink}`)
|
||||
}
|
||||
} else {
|
||||
lines.push(`Pairing unavailable: ${readiness.pairing.reason}`)
|
||||
lines.push(`Pairing guidance: ${readiness.pairing.guidance}`)
|
||||
|
||||
@@ -25,6 +25,7 @@ export type MobileApi = {
|
||||
/** Natural bitmap width and height in pixels. */
|
||||
qrSize: number | null
|
||||
qrError?: 'encoding_failed'
|
||||
/** Phone-facing copy link: verified https, code in the fragment. The QR keeps `orca://`. */
|
||||
pairingUrl: string
|
||||
/** Null when no direct address was advertised — the QR pairs over Relay alone. */
|
||||
endpoint: string | null
|
||||
|
||||
@@ -27,6 +27,7 @@ export const mobileApi = {
|
||||
/** Natural bitmap width and height in pixels. */
|
||||
qrSize: number | null
|
||||
qrError?: 'encoding_failed'
|
||||
/** Phone-facing copy link: verified https, code in the fragment. The QR keeps `orca://`. */
|
||||
pairingUrl: string
|
||||
/** Null when no direct address was advertised — the QR pairs over Relay alone. */
|
||||
endpoint: string | null
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
encodePairingOffer,
|
||||
decodePairingOffer,
|
||||
parsePairingCode,
|
||||
toVerifiedPairingLink,
|
||||
type PairingOffer
|
||||
} from './pairing'
|
||||
|
||||
@@ -131,6 +132,17 @@ describe('parsePairingCode', () => {
|
||||
expect(parsePairingCode('https://example.com')).toBeNull()
|
||||
})
|
||||
|
||||
it('parses the verified https pairing link a phone receives', () => {
|
||||
const link = toVerifiedPairingLink(encodePairingOffer(offer))
|
||||
expect(link).toMatch(/^https:\/\/www\.onorca\.dev\/pair#/)
|
||||
expect(parsePairingCode(link!)).toEqual(offer)
|
||||
expect(decodePairingOffer(link!)).toEqual(offer)
|
||||
})
|
||||
|
||||
it('returns no verified link for a URL without a pairing code', () => {
|
||||
expect(toVerifiedPairingLink('orca://pair')).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for valid base64 of unrelated JSON', () => {
|
||||
const bogus = Buffer.from(JSON.stringify({ hello: 'world' })).toString('base64')
|
||||
expect(parsePairingCode(bogus)).toBeNull()
|
||||
|
||||
+16
-3
@@ -7,6 +7,7 @@ import {
|
||||
PAIRING_CODE_MAX_CHARACTERS,
|
||||
PAIRING_INPUT_MAX_CHARACTERS
|
||||
} from './mobile-pairing-protocol-limits'
|
||||
import { buildVerifiedPairingLink, extractVerifiedPairingLinkCode } from './verified-pairing-link'
|
||||
|
||||
export { PAIRING_OFFER_VERSION, PairingOfferSchema }
|
||||
export type { PairingOffer }
|
||||
@@ -32,12 +33,24 @@ export function decodePairingOffer(url: string): PairingOffer {
|
||||
}
|
||||
const code = extractPairingCodeFromUrl(url)
|
||||
if (!code) {
|
||||
throw new Error('Invalid pairing URL: must start with orca://pair and include a pairing code')
|
||||
throw new Error(
|
||||
'Invalid pairing URL: must be an orca://pair or verified https pairing link with a code'
|
||||
)
|
||||
}
|
||||
return decodePairingBase64(code)
|
||||
}
|
||||
|
||||
/** The form handed to a phone: the custom scheme can be claimed by any app, this link cannot. */
|
||||
export function toVerifiedPairingLink(pairingUrl: string): string | null {
|
||||
const code = extractPairingCodeFromUrl(pairingUrl)
|
||||
return code ? buildVerifiedPairingLink(code) : null
|
||||
}
|
||||
|
||||
function extractPairingCodeFromUrl(url: string): string | null {
|
||||
const verifiedCode = extractVerifiedPairingLinkCode(url)
|
||||
if (verifiedCode) {
|
||||
return verifiedCode
|
||||
}
|
||||
let parsed: URL
|
||||
try {
|
||||
parsed = new URL(url)
|
||||
@@ -59,7 +72,7 @@ function extractPairingCodeFromUrl(url: string): string | null {
|
||||
return parsed.hash ? parsed.hash.slice(1) || null : null
|
||||
}
|
||||
|
||||
// Why: accept either an `orca://pair?...` URL or the bare base64
|
||||
// Why: accept an `orca://pair?...` URL, a verified https link, or the bare base64
|
||||
// string so the mobile paste-pair flow can take whichever the user
|
||||
// actually copied from desktop.
|
||||
export function parsePairingCode(input: string): PairingOffer | null {
|
||||
@@ -71,7 +84,7 @@ export function parsePairingCode(input: string): PairingOffer | null {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
if (trimmed.toLowerCase().startsWith('orca://')) {
|
||||
if (/^(?:orca|https):\/\//i.test(trimmed)) {
|
||||
return decodePairingOffer(trimmed)
|
||||
}
|
||||
return decodePairingBase64(trimmed)
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { createRequire } from 'node:module'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { VERIFIED_PAIRING_LINK_HOST, VERIFIED_PAIRING_LINK_PATH } from './verified-pairing-link'
|
||||
|
||||
const repoRoot = join(__dirname, '..', '..')
|
||||
const deployDir = join(repoRoot, 'config', 'verified-links')
|
||||
const appJson = JSON.parse(readFileSync(join(repoRoot, 'mobile', 'app.json'), 'utf8'))
|
||||
const bundleId: string = appJson.expo.ios.bundleIdentifier
|
||||
const androidPackage: string = appJson.expo.android.package
|
||||
// The build template's public debug key: listing it would let anyone's APK claim the links.
|
||||
const PUBLIC_TEMPLATE_DEBUG_CERT =
|
||||
'FA:C6:17:45:DC:09:03:78:6F:B9:ED:E6:2A:96:2B:39:9F:73:48:F0:BB:6F:89:9B:83:32:66:75:91:03:3B:9C'
|
||||
|
||||
function readDeployJson<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')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,48 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
VERIFIED_PAIRING_LINK_HOST,
|
||||
VERIFIED_PAIRING_LINK_PATH,
|
||||
buildVerifiedPairingLink,
|
||||
extractVerifiedPairingLinkCode
|
||||
} from './verified-pairing-link'
|
||||
|
||||
describe('verified pairing link', () => {
|
||||
it('carries the code in the fragment, never the query string', () => {
|
||||
const link = buildVerifiedPairingLink('abc_123-x')
|
||||
expect(link).toBe(
|
||||
`https://${VERIFIED_PAIRING_LINK_HOST}${VERIFIED_PAIRING_LINK_PATH}#abc_123-x`
|
||||
)
|
||||
expect(link).not.toContain('?')
|
||||
})
|
||||
|
||||
it('round-trips the code', () => {
|
||||
expect(extractVerifiedPairingLinkCode(buildVerifiedPairingLink('abc123'))).toBe('abc123')
|
||||
})
|
||||
|
||||
it('accepts host casing, surrounding whitespace and an ignored query', () => {
|
||||
expect(extractVerifiedPairingLinkCode(' HTTPS://WWW.ONORCA.DEV/pair#abc\n')).toBe('abc')
|
||||
expect(extractVerifiedPairingLinkCode('https://www.onorca.dev/pair?utm=x#abc')).toBe('abc')
|
||||
})
|
||||
|
||||
it('never reads a code from the query string', () => {
|
||||
expect(extractVerifiedPairingLinkCode('https://www.onorca.dev/pair?code=abc')).toBeNull()
|
||||
})
|
||||
|
||||
it('rejects other hosts, ports, user info, schemes and paths', () => {
|
||||
for (const url of [
|
||||
'https://onorca.dev.evil.example/pair#abc',
|
||||
'https://evil.example/pair#abc',
|
||||
'https://onorca.dev/pair#abc',
|
||||
'https://www.onorca.dev:8443/pair#abc',
|
||||
'https://user@www.onorca.dev/pair#abc',
|
||||
'http://www.onorca.dev/pair#abc',
|
||||
'https://www.onorca.dev/pairing#abc',
|
||||
'https://www.onorca.dev/pair/#abc',
|
||||
'https://www.onorca.dev/pair-confirm#abc',
|
||||
'https://www.onorca.dev/pair#',
|
||||
'orca://pair#abc'
|
||||
]) {
|
||||
expect(extractVerifiedPairingLinkCode(url), url).toBeNull()
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,24 @@
|
||||
// Why: an `orca://` link can be claimed by any app, so a pairing secret only leaves the desktop as
|
||||
// an https link the OS hands to the app whose identity the domain vouches for. Keep these in sync
|
||||
// with config/verified-links/ and mobile/app.config.js; verified-pairing-link-files.test.ts checks.
|
||||
// Why www: the apex redirects to www, and neither OS verifies a link through a redirect.
|
||||
export const VERIFIED_PAIRING_LINK_HOST = 'www.onorca.dev'
|
||||
export const VERIFIED_PAIRING_LINK_PATH = '/pair'
|
||||
|
||||
/** The secret rides in the fragment, which browsers never send to the server. */
|
||||
export function buildVerifiedPairingLink(pairingCode: string): string {
|
||||
return `https://${VERIFIED_PAIRING_LINK_HOST}${VERIFIED_PAIRING_LINK_PATH}#${pairingCode}`
|
||||
}
|
||||
|
||||
/** String parsing only: Hermes's URL polyfill does not expose host or hash reliably. */
|
||||
export function extractVerifiedPairingLinkCode(url: string): string | null {
|
||||
const match = /^https:\/\/([^/?#]*)([^?#]*)(?:\?[^#]*)?#(.*)$/i.exec(url.trim())
|
||||
if (!match) {
|
||||
return null
|
||||
}
|
||||
const [, host, path, code] = match
|
||||
if (host?.toLowerCase() !== VERIFIED_PAIRING_LINK_HOST || path !== VERIFIED_PAIRING_LINK_PATH) {
|
||||
return null
|
||||
}
|
||||
return code || null
|
||||
}
|
||||
Reference in New Issue
Block a user