Files
orca/src/shared/pairing.ts
T
82512a43a3 fix(pairing): normalize base64 consistently across clients (#31465)
Normalize bounded Base64 input consistently across desktop, browser, and mobile pairing paths while preserving lossless legacy padding and trailing-bit spellings. Retain offer validation and authentication behavior.

Adapted from #12027. Compatibility verified across 378 spellings with no previously accepted inputs lost; full PR workflow and aggregate gate passed for a84dfaee35.

Co-authored-by: Henry Su <henrysu4707@gmail.com>
Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
2026-10-21 16:34:37 -07:00

94 lines
2.8 KiB
TypeScript

import {
PAIRING_OFFER_VERSION,
PairingOfferSchema,
type PairingOffer
} from './mobile-relay-pairing-offer'
import { normalizePairingBase64 } from './mobile-pairing-base64'
import {
PAIRING_CODE_MAX_CHARACTERS,
PAIRING_INPUT_MAX_CHARACTERS
} from './mobile-pairing-protocol-limits'
export { PAIRING_OFFER_VERSION, PairingOfferSchema }
export type { PairingOffer }
export function encodePairingOffer(offer: PairingOffer): string {
const json = JSON.stringify(PairingOfferSchema.parse(offer))
const base64url = Buffer.from(json, 'utf-8')
.toString('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '')
if (base64url.length > PAIRING_CODE_MAX_CHARACTERS) {
throw new Error('Pairing offer exceeds safe size')
}
// Why: Android camera intents and Expo Router preserve query params more
// reliably than URL fragments when launching a custom-scheme app.
return `orca://pair?code=${base64url}`
}
export function decodePairingOffer(url: string): PairingOffer {
if (url.length > PAIRING_INPUT_MAX_CHARACTERS) {
throw new Error('Invalid pairing URL: pairing code exceeds safe size')
}
const code = extractPairingCodeFromUrl(url)
if (!code) {
throw new Error('Invalid pairing URL: must start with orca://pair and include a pairing code')
}
return decodePairingBase64(code)
}
function extractPairingCodeFromUrl(url: string): string | null {
let parsed: URL
try {
parsed = new URL(url)
} catch {
return null
}
// Why: prefix checks accepted routes like `orca://pairing?...`; only the
// pairing deep-link host may carry runtime auth material.
if (parsed.protocol !== 'orca:' || parsed.hostname !== 'pair') {
return null
}
if (parsed.pathname !== '' && parsed.pathname !== '/') {
return null
}
const code = parsed.searchParams.get('code')
if (code) {
return code
}
return parsed.hash ? parsed.hash.slice(1) || null : null
}
// 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 {
if (input.length > PAIRING_INPUT_MAX_CHARACTERS) {
return null
}
const trimmed = input.trim()
if (!trimmed) {
return null
}
try {
if (trimmed.toLowerCase().startsWith('orca://')) {
return decodePairingOffer(trimmed)
}
return decodePairingBase64(trimmed)
} catch {
return null
}
}
function decodePairingBase64(base64url: string): PairingOffer {
const base64 = normalizePairingBase64(base64url)
const json =
typeof Buffer === 'undefined'
? new TextDecoder().decode(
Uint8Array.from(atob(base64), (character) => character.charCodeAt(0))
)
: Buffer.from(base64, 'base64').toString('utf-8')
return PairingOfferSchema.parse(JSON.parse(json))
}