123456789101112131415161718192021222324252627282930import { noiseNKInitiator, noiseNKResponder } from "./noise-js";31import type { DeviceType, PairedDevice } from "./sync-types";3233export interface PairingTransport {34 send(msg: Uint8Array): Promise<void>;35 receive(): Promise<Uint8Array>;36}37383940export interface PairingHandshakeResult {41 device: PairedDevice;42 sas: string;43 4445464748 peerLicense?: string;49}5051export interface LocalIdentity {52 deviceId: string;53 deviceName: string;54 deviceType: DeviceType;55 longTermPublicKeyHex: string;56 signingPublicKeyHex?: string;57 58 wsPort?: number;59 60616263646566 vaultId?: string;67 6869707172 license?: string;73}7475interface PairingFrame {76 type: "PAIR_IDENTITY";77 device_id: string;78 device_name: string;79 device_type: DeviceType;80 long_term_public_key_hex: string;81 signing_public_key_hex?: string;82 ws_port?: number;83 84 85 vault_id?: string;86 87 88 license?: string;89}9091export interface PostNoiseFrame {92 sas: string;93 frame: unknown;94 channel: PairingTransport;95}9697export interface PostNoiseChannel {98 sas: string;99 channel: PairingTransport;100}101102function encodeFrame(frame: PairingFrame): Uint8Array {103 return new TextEncoder().encode(JSON.stringify(frame));104}105106function decodeParsedFrame(parsed: unknown): PairingFrame {107 const value = parsed as Record<string, unknown> | null;108 if (value?.type !== "PAIR_IDENTITY") throw new Error("expected PAIR_IDENTITY frame");109 if (typeof value.device_id !== "string" || !value.device_id) throw new Error("device_id missing");110 if (typeof value.device_name !== "string") throw new Error("device_name missing");111 if (typeof value.device_type !== "string") throw new Error("device_type missing");112 if (typeof value.long_term_public_key_hex !== "string") throw new Error("long_term_public_key_hex missing");113 114 if (!/^[0-9a-f]{64}$/.test(value.long_term_public_key_hex)) {115 throw new Error("long_term_public_key_hex is not 32-byte hex");116 }117 if (118 value.signing_public_key_hex !== undefined &&119 (typeof value.signing_public_key_hex !== "string" || !/^[0-9a-f]{64}$/.test(value.signing_public_key_hex))120 ) {121 throw new Error("signing_public_key_hex is not 32-byte hex");122 }123 return value as unknown as PairingFrame;124}125126127128129130131132133export async function runPairingInitiator(134 remoteEphemeralPublicKey: Uint8Array,135 ourIdentity: LocalIdentity,136 transport: PairingTransport,137): Promise<PairingHandshakeResult> {138 const postNoise = await runPairingInitiatorOpenChannel(remoteEphemeralPublicKey, transport);139140 141 await postNoise.channel.send(encodeFrame({142 type: "PAIR_IDENTITY",143 device_id: ourIdentity.deviceId,144 device_name: ourIdentity.deviceName,145 device_type: ourIdentity.deviceType,146 long_term_public_key_hex: ourIdentity.longTermPublicKeyHex,147 signing_public_key_hex: ourIdentity.signingPublicKeyHex,148 ws_port: ourIdentity.wsPort,149 vault_id: ourIdentity.vaultId,150 license: ourIdentity.license,151 }));152153 const theirFrame = decodeParsedFrame(JSON.parse(new TextDecoder().decode(await postNoise.channel.receive())));154 155 156 return {157 device: frameToPairedDevice(theirFrame),158 sas: postNoise.sas,159 peerLicense: peerLicenseOf(theirFrame),160 };161}162163export async function runPairingInitiatorOpenChannel(164 remoteEphemeralPublicKey: Uint8Array,165 transport: PairingTransport,166): Promise<PostNoiseChannel> {167 const session = await noiseNKInitiator(168 remoteEphemeralPublicKey,169 transport.send,170 transport.receive,171 );172173 const channel: PairingTransport = {174 async send(msg) {175 await transport.send(session.encrypt(msg));176 },177 async receive() {178 return session.decrypt(await transport.receive());179 },180 };181 return {182 sas: derivePairingSAS(session.handshakeHash),183 channel,184 };185}186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233export function derivePairingSAS(handshakeHash: Uint8Array): string {234 235 236 237 if (handshakeHash.length < 4) return "000000";238 const n =239 ((handshakeHash[0] << 24) >>> 0) +240 (handshakeHash[1] << 16) +241 (handshakeHash[2] << 8) +242 handshakeHash[3];243 return String((n >>> 0) % 1_000_000).padStart(6, "0");244}245246247248249250251252export async function runPairingResponder(253 ourEphemeralPrivateKey: Uint8Array,254 ourEphemeralPublicKey: Uint8Array,255 ourIdentity: LocalIdentity,256 transport: PairingTransport,257): Promise<PairingHandshakeResult> {258 const postNoise = await runPairingResponderOpenFrame(259 ourEphemeralPrivateKey,260 ourEphemeralPublicKey,261 transport,262 );263 return handlePairIdentityFrame(postNoise.frame, postNoise.channel, ourIdentity, postNoise.sas);264}265266export async function runPairingResponderOpenFrame(267 ourEphemeralPrivateKey: Uint8Array,268 ourEphemeralPublicKey: Uint8Array,269 transport: PairingTransport,270): Promise<PostNoiseFrame> {271 const session = await noiseNKResponder(272 ourEphemeralPrivateKey,273 ourEphemeralPublicKey,274 transport.send,275 transport.receive,276 );277278 const channel: PairingTransport = {279 async send(msg) {280 await transport.send(session.encrypt(msg));281 },282 async receive() {283 return session.decrypt(await transport.receive());284 },285 };286 const theirFrame = JSON.parse(new TextDecoder().decode(await channel.receive()));287 return {288 sas: derivePairingSAS(session.handshakeHash),289 frame: theirFrame,290 channel,291 };292}293294export async function handlePairIdentityFrame(295 frame: unknown,296 channel: PairingTransport,297 ourIdentity: LocalIdentity,298 sas: string,299): Promise<PairingHandshakeResult> {300 const theirFrame = decodeParsedFrame(frame);301 const ourFrame = encodeFrame({302 type: "PAIR_IDENTITY",303 device_id: ourIdentity.deviceId,304 device_name: ourIdentity.deviceName,305 device_type: ourIdentity.deviceType,306 long_term_public_key_hex: ourIdentity.longTermPublicKeyHex,307 signing_public_key_hex: ourIdentity.signingPublicKeyHex,308 ws_port: ourIdentity.wsPort,309 vault_id: ourIdentity.vaultId,310 license: ourIdentity.license,311 });312 await channel.send(ourFrame);313314 315 316 317 return {318 device: frameToPairedDevice(theirFrame),319 sas,320 peerLicense: peerLicenseOf(theirFrame),321 };322}323324325326function peerLicenseOf(frame: PairingFrame): string | undefined {327 return typeof frame.license === "string" && frame.license.length > 0 ? frame.license : undefined;328}329330function frameToPairedDevice(frame: PairingFrame): PairedDevice {331 return {332 deviceId: frame.device_id,333 deviceName: frame.device_name,334 deviceType: frame.device_type,335 longTermPublicKeyHex: frame.long_term_public_key_hex,336 signingPublicKeyHex: frame.signing_public_key_hex,337 trustSource: "direct",338 wsPort: typeof frame.ws_port === "number" ? frame.ws_port : undefined,339 pairedAt: Date.now(),340 };341}342343344345346347export function memoryTransportPair(): [PairingTransport, PairingTransport] {348 const a: Uint8Array[] = [];349 const b: Uint8Array[] = [];350 const aReaders: Array<(b: Uint8Array) => void> = [];351 const bReaders: Array<(b: Uint8Array) => void> = [];352353 function makeTransport(354 inbox: Uint8Array[],355 outbox: Uint8Array[],356 inboxReaders: Array<(b: Uint8Array) => void>,357 outboxReaders: Array<(b: Uint8Array) => void>,358 ): PairingTransport {359 return {360 async send(msg: Uint8Array) {361 const r = outboxReaders.shift();362 if (r) r(msg);363 else outbox.push(msg);364 },365 receive() {366 const msg = inbox.shift();367 if (msg) return Promise.resolve(msg);368 return new Promise<Uint8Array>((resolve) => inboxReaders.push(resolve));369 },370 };371 }372 return [makeTransport(a, b, aReaders, bReaders), makeTransport(b, a, bReaders, aReaders)];373}