12345678910111213141516171819202122232425262728import { noiseNKInitiator, noiseNKResponder } from "./noise-js";29import type { DeviceType, PairedDevice } from "./sync-types";3031export interface PairingTransport {32 send(msg: Uint8Array): Promise<void>;33 receive(): Promise<Uint8Array>;34}35363738export interface PairingHandshakeResult {39 device: PairedDevice;40 sas: string;41 4243444546 peerLicense?: string;47}4849export interface LocalIdentity {50 deviceId: string;51 deviceName: string;52 deviceType: DeviceType;53 longTermPublicKeyHex: string;54 signingPublicKeyHex?: string;55 56 wsPort?: number;57 58596061626364 vaultId?: string;65 6667686970 license?: string;71}7273interface PairingFrame {74 type: "PAIR_IDENTITY";75 device_id: string;76 device_name: string;77 device_type: DeviceType;78 long_term_public_key_hex: string;79 signing_public_key_hex?: string;80 ws_port?: number;81 82 83 vault_id?: string;84 85 86 license?: string;87}8889export interface PostNoiseFrame {90 sas: string;91 frame: unknown;92 channel: PairingTransport;93}9495export interface PostNoiseChannel {96 sas: string;97 channel: PairingTransport;98}99100function encodeFrame(frame: PairingFrame): Uint8Array {101 return new TextEncoder().encode(JSON.stringify(frame));102}103104function decodeParsedFrame(parsed: unknown): PairingFrame {105 const value = parsed as Record<string, unknown> | null;106 if (value?.type !== "PAIR_IDENTITY") throw new Error("expected PAIR_IDENTITY frame");107 if (typeof value.device_id !== "string" || !value.device_id) throw new Error("device_id missing");108 if (typeof value.device_name !== "string") throw new Error("device_name missing");109 if (typeof value.device_type !== "string") throw new Error("device_type missing");110 if (typeof value.long_term_public_key_hex !== "string") throw new Error("long_term_public_key_hex missing");111 112 if (!/^[0-9a-f]{64}$/.test(value.long_term_public_key_hex)) {113 throw new Error("long_term_public_key_hex is not 32-byte hex");114 }115 if (116 value.signing_public_key_hex !== undefined &&117 (typeof value.signing_public_key_hex !== "string" || !/^[0-9a-f]{64}$/.test(value.signing_public_key_hex))118 ) {119 throw new Error("signing_public_key_hex is not 32-byte hex");120 }121 return value as unknown as PairingFrame;122}123124125126127128129130131export async function runPairingInitiator(132 remoteEphemeralPublicKey: Uint8Array,133 ourIdentity: LocalIdentity,134 transport: PairingTransport,135): Promise<PairingHandshakeResult> {136 const postNoise = await runPairingInitiatorOpenChannel(remoteEphemeralPublicKey, transport);137138 139 await postNoise.channel.send(encodeFrame({140 type: "PAIR_IDENTITY",141 device_id: ourIdentity.deviceId,142 device_name: ourIdentity.deviceName,143 device_type: ourIdentity.deviceType,144 long_term_public_key_hex: ourIdentity.longTermPublicKeyHex,145 signing_public_key_hex: ourIdentity.signingPublicKeyHex,146 ws_port: ourIdentity.wsPort,147 vault_id: ourIdentity.vaultId,148 license: ourIdentity.license,149 }));150151 const theirFrame = decodeParsedFrame(JSON.parse(new TextDecoder().decode(await postNoise.channel.receive())));152 153 154 return {155 device: frameToPairedDevice(theirFrame),156 sas: postNoise.sas,157 peerLicense: peerLicenseOf(theirFrame),158 };159}160161export async function runPairingInitiatorOpenChannel(162 remoteEphemeralPublicKey: Uint8Array,163 transport: PairingTransport,164): Promise<PostNoiseChannel> {165 const session = await noiseNKInitiator(166 remoteEphemeralPublicKey,167 transport.send,168 transport.receive,169 );170171 const channel: PairingTransport = {172 async send(msg) {173 await transport.send(session.encrypt(msg));174 },175 async receive() {176 return session.decrypt(await transport.receive());177 },178 };179 return {180 sas: derivePairingSAS(session.handshakeHash),181 channel,182 };183}184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231export function derivePairingSAS(handshakeHash: Uint8Array): string {232 233 234 235 if (handshakeHash.length < 4) return "000000";236 const n =237 ((handshakeHash[0] << 24) >>> 0) +238 (handshakeHash[1] << 16) +239 (handshakeHash[2] << 8) +240 handshakeHash[3];241 return String((n >>> 0) % 1_000_000).padStart(6, "0");242}243244245246247248249250251export async function runPairingResponder(252 ourEphemeralPrivateKey: Uint8Array,253 ourEphemeralPublicKey: Uint8Array,254 ourIdentity: LocalIdentity,255 transport: PairingTransport,256): Promise<PairingHandshakeResult> {257 const postNoise = await runPairingResponderOpenFrame(258 ourEphemeralPrivateKey,259 ourEphemeralPublicKey,260 transport,261 );262 return handlePairIdentityFrame(postNoise.frame, postNoise.channel, ourIdentity, postNoise.sas);263}264265export async function runPairingResponderOpenFrame(266 ourEphemeralPrivateKey: Uint8Array,267 ourEphemeralPublicKey: Uint8Array,268 transport: PairingTransport,269): Promise<PostNoiseFrame> {270 const session = await noiseNKResponder(271 ourEphemeralPrivateKey,272 ourEphemeralPublicKey,273 transport.send,274 transport.receive,275 );276277 const channel: PairingTransport = {278 async send(msg) {279 await transport.send(session.encrypt(msg));280 },281 async receive() {282 return session.decrypt(await transport.receive());283 },284 };285 const theirFrame = JSON.parse(new TextDecoder().decode(await channel.receive()));286 return {287 sas: derivePairingSAS(session.handshakeHash),288 frame: theirFrame,289 channel,290 };291}292293export async function handlePairIdentityFrame(294 frame: unknown,295 channel: PairingTransport,296 ourIdentity: LocalIdentity,297 sas: string,298): Promise<PairingHandshakeResult> {299 const theirFrame = decodeParsedFrame(frame);300 const ourFrame = encodeFrame({301 type: "PAIR_IDENTITY",302 device_id: ourIdentity.deviceId,303 device_name: ourIdentity.deviceName,304 device_type: ourIdentity.deviceType,305 long_term_public_key_hex: ourIdentity.longTermPublicKeyHex,306 signing_public_key_hex: ourIdentity.signingPublicKeyHex,307 ws_port: ourIdentity.wsPort,308 vault_id: ourIdentity.vaultId,309 license: ourIdentity.license,310 });311 await channel.send(ourFrame);312313 314 315 316 return {317 device: frameToPairedDevice(theirFrame),318 sas,319 peerLicense: peerLicenseOf(theirFrame),320 };321}322323324325function peerLicenseOf(frame: PairingFrame): string | undefined {326 return typeof frame.license === "string" && frame.license.length > 0 ? frame.license : undefined;327}328329function frameToPairedDevice(frame: PairingFrame): PairedDevice {330 return {331 deviceId: frame.device_id,332 deviceName: frame.device_name,333 deviceType: frame.device_type,334 longTermPublicKeyHex: frame.long_term_public_key_hex,335 signingPublicKeyHex: frame.signing_public_key_hex,336 trustSource: "direct",337 wsPort: typeof frame.ws_port === "number" ? frame.ws_port : undefined,338 pairedAt: Date.now(),339 };340}341342343344345346export function memoryTransportPair(): [PairingTransport, PairingTransport] {347 const a: Uint8Array[] = [];348 const b: Uint8Array[] = [];349 const aReaders: Array<(b: Uint8Array) => void> = [];350 const bReaders: Array<(b: Uint8Array) => void> = [];351352 function makeTransport(353 inbox: Uint8Array[],354 outbox: Uint8Array[],355 inboxReaders: Array<(b: Uint8Array) => void>,356 outboxReaders: Array<(b: Uint8Array) => void>,357 ): PairingTransport {358 return {359 async send(msg: Uint8Array) {360 const r = outboxReaders.shift();361 if (r) r(msg);362 else outbox.push(msg);363 },364 receive() {365 const msg = inbox.shift();366 if (msg) return Promise.resolve(msg);367 return new Promise<Uint8Array>((resolve) => inboxReaders.push(resolve));368 },369 };370 }371 return [makeTransport(a, b, aReaders, bReaders), makeTransport(b, a, bReaders, aReaders)];372}