1234567891011121314151617181920212223242526272829303132import { noiseNKInitiator, noiseNKResponder } from "./noise-js";33import type { DeviceType, PairedDevice } from "./sync-types";34import {35 bytesToHex,36 deriveRendezvousSecret,37 normalizeRelayUrl,38} from "./rendezvous";3940export interface PairingTransport {41 send(msg: Uint8Array): Promise<void>;42 receive(): Promise<Uint8Array>;43}44454647export interface PairingHandshakeResult {48 device: PairedDevice;49 sas: string;50 5152535455 peerLicense?: string;56}5758export interface LocalIdentity {59 deviceId: string;60 deviceName: string;61 deviceType: DeviceType;62 longTermPublicKeyHex: string;63 signingPublicKeyHex?: string;64 65 wsPort?: number;66 67686970717273 vaultId?: string;74 7576777879 license?: string;80 relayUrl?: string;81}8283interface PairingFrame {84 type: "PAIR_IDENTITY";85 device_id: string;86 device_name: string;87 device_type: DeviceType;88 long_term_public_key_hex: string;89 signing_public_key_hex?: string;90 ws_port?: number;91 92 93 vault_id?: string;94 95 96 license?: string;97 relay_url?: string;98}99100export interface PostNoiseFrame {101 sas: string;102 rendezvousSecretHex: string;103 frame: unknown;104 channel: PairingTransport;105}106107export interface PostNoiseChannel {108 sas: string;109 rendezvousSecretHex: string;110 channel: PairingTransport;111}112113function encodeFrame(frame: PairingFrame): Uint8Array {114 return new TextEncoder().encode(JSON.stringify(frame));115}116117function decodeParsedFrame(parsed: unknown): PairingFrame {118 const value = parsed as Record<string, unknown> | null;119 if (value?.type !== "PAIR_IDENTITY") throw new Error("expected PAIR_IDENTITY frame");120 if (typeof value.device_id !== "string" || !value.device_id) throw new Error("device_id missing");121 if (typeof value.device_name !== "string") throw new Error("device_name missing");122 if (typeof value.device_type !== "string") throw new Error("device_type missing");123 if (typeof value.long_term_public_key_hex !== "string") throw new Error("long_term_public_key_hex missing");124 125 if (!/^[0-9a-f]{64}$/.test(value.long_term_public_key_hex)) {126 throw new Error("long_term_public_key_hex is not 32-byte hex");127 }128 if (129 value.signing_public_key_hex !== undefined &&130 (typeof value.signing_public_key_hex !== "string" || !/^[0-9a-f]{64}$/.test(value.signing_public_key_hex))131 ) {132 throw new Error("signing_public_key_hex is not 32-byte hex");133 }134 if (value.relay_url !== undefined) {135 if (typeof value.relay_url !== "string") throw new Error("relay_url invalid");136 value.relay_url = normalizeRelayUrl(value.relay_url);137 }138 return value as unknown as PairingFrame;139}140141142143144145146147148export async function runPairingInitiator(149 remoteEphemeralPublicKey: Uint8Array,150 ourIdentity: LocalIdentity,151 transport: PairingTransport,152): Promise<PairingHandshakeResult> {153 const postNoise = await runPairingInitiatorOpenChannel(remoteEphemeralPublicKey, transport);154155 156 await postNoise.channel.send(encodeFrame({157 type: "PAIR_IDENTITY",158 device_id: ourIdentity.deviceId,159 device_name: ourIdentity.deviceName,160 device_type: ourIdentity.deviceType,161 long_term_public_key_hex: ourIdentity.longTermPublicKeyHex,162 signing_public_key_hex: ourIdentity.signingPublicKeyHex,163 ws_port: ourIdentity.wsPort,164 vault_id: ourIdentity.vaultId,165 license: ourIdentity.license,166 relay_url: ourIdentity.relayUrl,167 }));168169 const theirFrame = decodeParsedFrame(JSON.parse(new TextDecoder().decode(await postNoise.channel.receive())));170 171 172 return {173 device: {174 ...frameToPairedDevice(theirFrame),175 rendezvousSecretHex: postNoise.rendezvousSecretHex,176 },177 sas: postNoise.sas,178 peerLicense: peerLicenseOf(theirFrame),179 };180}181182export async function runPairingInitiatorOpenChannel(183 remoteEphemeralPublicKey: Uint8Array,184 transport: PairingTransport,185): Promise<PostNoiseChannel> {186 const session = await noiseNKInitiator(187 remoteEphemeralPublicKey,188 transport.send,189 transport.receive,190 );191192 const channel: PairingTransport = {193 async send(msg) {194 await transport.send(session.encrypt(msg));195 },196 async receive() {197 return session.decrypt(await transport.receive());198 },199 };200 return {201 sas: derivePairingSAS(session.handshakeHash),202 rendezvousSecretHex: bytesToHex(deriveRendezvousSecret(session.handshakeHash)),203 channel,204 };205}206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253export function derivePairingSAS(handshakeHash: Uint8Array): string {254 255 256 257 if (handshakeHash.length < 4) return "000000";258 const n =259 ((handshakeHash[0] << 24) >>> 0) +260 (handshakeHash[1] << 16) +261 (handshakeHash[2] << 8) +262 handshakeHash[3];263 return String((n >>> 0) % 1_000_000).padStart(6, "0");264}265266267268269270271272export async function runPairingResponder(273 ourEphemeralPrivateKey: Uint8Array,274 ourEphemeralPublicKey: Uint8Array,275 ourIdentity: LocalIdentity,276 transport: PairingTransport,277): Promise<PairingHandshakeResult> {278 const postNoise = await runPairingResponderOpenFrame(279 ourEphemeralPrivateKey,280 ourEphemeralPublicKey,281 transport,282 );283 return handlePairIdentityFrame(284 postNoise.frame,285 postNoise.channel,286 ourIdentity,287 postNoise.sas,288 postNoise.rendezvousSecretHex,289 );290}291292export async function runPairingResponderOpenFrame(293 ourEphemeralPrivateKey: Uint8Array,294 ourEphemeralPublicKey: Uint8Array,295 transport: PairingTransport,296): Promise<PostNoiseFrame> {297 const session = await noiseNKResponder(298 ourEphemeralPrivateKey,299 ourEphemeralPublicKey,300 transport.send,301 transport.receive,302 );303304 const channel: PairingTransport = {305 async send(msg) {306 await transport.send(session.encrypt(msg));307 },308 async receive() {309 return session.decrypt(await transport.receive());310 },311 };312 const theirFrame = JSON.parse(new TextDecoder().decode(await channel.receive()));313 return {314 sas: derivePairingSAS(session.handshakeHash),315 rendezvousSecretHex: bytesToHex(deriveRendezvousSecret(session.handshakeHash)),316 frame: theirFrame,317 channel,318 };319}320321export async function handlePairIdentityFrame(322 frame: unknown,323 channel: PairingTransport,324 ourIdentity: LocalIdentity,325 sas: string,326 rendezvousSecretHex: string,327): Promise<PairingHandshakeResult> {328 const theirFrame = decodeParsedFrame(frame);329 const ourFrame = encodeFrame({330 type: "PAIR_IDENTITY",331 device_id: ourIdentity.deviceId,332 device_name: ourIdentity.deviceName,333 device_type: ourIdentity.deviceType,334 long_term_public_key_hex: ourIdentity.longTermPublicKeyHex,335 signing_public_key_hex: ourIdentity.signingPublicKeyHex,336 ws_port: ourIdentity.wsPort,337 vault_id: ourIdentity.vaultId,338 license: ourIdentity.license,339 relay_url: ourIdentity.relayUrl,340 });341 await channel.send(ourFrame);342343 344 345 346 return {347 device: {348 ...frameToPairedDevice(theirFrame),349 rendezvousSecretHex,350 },351 sas,352 peerLicense: peerLicenseOf(theirFrame),353 };354}355356357358function peerLicenseOf(frame: PairingFrame): string | undefined {359 return typeof frame.license === "string" && frame.license.length > 0 ? frame.license : undefined;360}361362function frameToPairedDevice(frame: PairingFrame): PairedDevice {363 return {364 deviceId: frame.device_id,365 deviceName: frame.device_name,366 deviceType: frame.device_type,367 longTermPublicKeyHex: frame.long_term_public_key_hex,368 signingPublicKeyHex: frame.signing_public_key_hex,369 trustSource: "direct",370 wsPort: typeof frame.ws_port === "number" ? frame.ws_port : undefined,371 relayUrl: frame.relay_url,372 pairedAt: Date.now(),373 };374}375376377378379380export function memoryTransportPair(): [PairingTransport, PairingTransport] {381 const a: Uint8Array[] = [];382 const b: Uint8Array[] = [];383 const aReaders: Array<(b: Uint8Array) => void> = [];384 const bReaders: Array<(b: Uint8Array) => void> = [];385386 function makeTransport(387 inbox: Uint8Array[],388 outbox: Uint8Array[],389 inboxReaders: Array<(b: Uint8Array) => void>,390 outboxReaders: Array<(b: Uint8Array) => void>,391 ): PairingTransport {392 return {393 async send(msg: Uint8Array) {394 const r = outboxReaders.shift();395 if (r) r(msg);396 else outbox.push(msg);397 },398 receive() {399 const msg = inbox.shift();400 if (msg) return Promise.resolve(msg);401 return new Promise<Uint8Array>((resolve) => inboxReaders.push(resolve));402 },403 };404 }405 return [makeTransport(a, b, aReaders, bReaders), makeTransport(b, a, bReaders, aReaders)];406}