57 lines
1.7 KiB
TypeScript
57 lines
1.7 KiB
TypeScript
import { z } from 'zod'
|
|
|
|
export const PAIRING_OFFER_VERSION = 2
|
|
|
|
export const PairingOfferSchema = z.object({
|
|
v: z.literal(PAIRING_OFFER_VERSION),
|
|
endpoint: z.string().min(1),
|
|
deviceToken: z.string().min(1),
|
|
// Why: the desktop's Curve25519 public key, base64-encoded. The mobile client
|
|
// uses this to derive a shared secret via ECDH for end-to-end encryption.
|
|
publicKeyB64: z.string().min(1)
|
|
})
|
|
|
|
export type PairingOffer = z.infer<typeof PairingOfferSchema>
|
|
|
|
export function encodePairingOffer(offer: PairingOffer): string {
|
|
const json = JSON.stringify(offer)
|
|
const base64url = Buffer.from(json, 'utf-8')
|
|
.toString('base64')
|
|
.replace(/\+/g, '-')
|
|
.replace(/\//g, '_')
|
|
.replace(/=+$/, '')
|
|
return `orca://pair#${base64url}`
|
|
}
|
|
|
|
export function decodePairingOffer(url: string): PairingOffer {
|
|
const hashIndex = url.indexOf('#')
|
|
if (!url.startsWith('orca://pair') || hashIndex === -1) {
|
|
throw new Error('Invalid pairing URL: must start with orca://pair#')
|
|
}
|
|
return decodePairingBase64(url.slice(hashIndex + 1))
|
|
}
|
|
|
|
// Why: accept either an `orca://pair#<base64>` 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 {
|
|
const trimmed = input.trim()
|
|
if (!trimmed) {
|
|
return null
|
|
}
|
|
try {
|
|
if (trimmed.startsWith('orca://pair')) {
|
|
return decodePairingOffer(trimmed)
|
|
}
|
|
return decodePairingBase64(trimmed)
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
function decodePairingBase64(base64url: string): PairingOffer {
|
|
const base64 = base64url.replace(/-/g, '+').replace(/_/g, '/')
|
|
const json = Buffer.from(base64, 'base64').toString('utf-8')
|
|
return PairingOfferSchema.parse(JSON.parse(json))
|
|
}
|