12345678910111213141516171819202122232425262728293031import { noiseNKInitiator, noiseNKResponder } from "./noise-js";32import type { DeviceType, PairedDevice } from "./sync-types";33import {34 bytesToHex,35 deriveRendezvousSecret,36 normalizeRelayUrl,37} from "./rendezvous";3839export interface PairingTransport {40 send(msg: Uint8Array): Promise<void>;41 receive(): Promise<Uint8Array>;42}43444546export interface PairingHandshakeResult {47 device: PairedDevice;48 sas: string;49 5051525354 peerLicense?: string;55}5657export interface LocalIdentity {58 deviceId: string;59 deviceName: string;60 deviceType: DeviceType;61 longTermPublicKeyHex: string;62 signingPublicKeyHex?: string;63 64 wsPort?: number;65 66676869707172 vaultId?: string;73 7475767778 license?: string;79 relayUrl?: string;80}8182interface PairingFrame {83 type: "PAIR_IDENTITY";84 device_id: string;85 device_name: string;86 device_type: DeviceType;87 long_term_public_key_hex: string;88 signing_public_key_hex?: string;89 ws_port?: number;90 91 92 vault_id?: string;93 94 95 license?: string;96 relay_url?: string;97}9899export interface PostNoiseFrame {100 sas: string;101 rendezvousSecretHex: string;102 frame: unknown;103 channel: PairingTransport;104}105106export interface PostNoiseChannel {107 sas: string;108 rendezvousSecretHex: string;109 channel: PairingTransport;110}111112function encodeFrame(frame: PairingFrame): Uint8Array {113 return new TextEncoder().encode(JSON.stringify(frame));114}115116function decodeParsedFrame(parsed: unknown): PairingFrame {117 const value = parsed as Record<string, unknown> | null;118 if (value?.type !== "PAIR_IDENTITY") throw new Error("expected PAIR_IDENTITY frame");119 if (typeof value.device_id !== "string" || !value.device_id) throw new Error("device_id missing");120 if (typeof value.device_name !== "string") throw new Error("device_name missing");121 if (typeof value.device_type !== "string") throw new Error("device_type missing");122 if (typeof value.long_term_public_key_hex !== "string") throw new Error("long_term_public_key_hex missing");123 124 if (!/^[0-9a-f]{64}$/.test(value.long_term_public_key_hex)) {125 throw new Error("long_term_public_key_hex is not 32-byte hex");126 }127 if (128 value.signing_public_key_hex !== undefined &&129 (typeof value.signing_public_key_hex !== "string" || !/^[0-9a-f]{64}$/.test(value.signing_public_key_hex))130 ) {131 throw new Error("signing_public_key_hex is not 32-byte hex");132 }133 if (value.relay_url !== undefined) {134 if (typeof value.relay_url !== "string") throw new Error("relay_url invalid");135 value.relay_url = normalizeRelayUrl(value.relay_url);136 }137 return value as unknown as PairingFrame;138}139140141142143144145146147export async function runPairingInitiator(148 remoteEphemeralPublicKey: Uint8Array,149 ourIdentity: LocalIdentity,150 transport: PairingTransport,151): Promise<PairingHandshakeResult> {152 const postNoise = await runPairingInitiatorOpenChannel(remoteEphemeralPublicKey, transport);153154 155 await postNoise.channel.send(encodeFrame({156 type: "PAIR_IDENTITY",157 device_id: ourIdentity.deviceId,158 device_name: ourIdentity.deviceName,159 device_type: ourIdentity.deviceType,160 long_term_public_key_hex: ourIdentity.longTermPublicKeyHex,161 signing_public_key_hex: ourIdentity.signingPublicKeyHex,162 ws_port: ourIdentity.wsPort,163 vault_id: ourIdentity.vaultId,164 license: ourIdentity.license,165 relay_url: ourIdentity.relayUrl,166 }));167168 const theirFrame = decodeParsedFrame(JSON.parse(new TextDecoder().decode(await postNoise.channel.receive())));169 170 171 return {172 device: {173 ...frameToPairedDevice(theirFrame),174 rendezvousSecretHex: postNoise.rendezvousSecretHex,175 },176 sas: postNoise.sas,177 peerLicense: peerLicenseOf(theirFrame),178 };179}180181export async function runPairingInitiatorOpenChannel(182 remoteEphemeralPublicKey: Uint8Array,183 transport: PairingTransport,184): Promise<PostNoiseChannel> {185 const session = await noiseNKInitiator(186 remoteEphemeralPublicKey,187 transport.send,188 transport.receive,189 );190191 const channel: PairingTransport = {192 async send(msg) {193 await transport.send(session.encrypt(msg));194 },195 async receive() {196 return session.decrypt(await transport.receive());197 },198 };199 return {200 sas: derivePairingSAS(session.handshakeHash),201 rendezvousSecretHex: bytesToHex(deriveRendezvousSecret(session.handshakeHash)),202 channel,203 };204}205206207208209210211212213214215216217218219220221222223224225226227228229export function derivePairingSAS(handshakeHash: Uint8Array): string {230 if (handshakeHash.length < 8) {231 throw new Error("handshake hash too short for SAS");232 }233 234 235 let value = 0n;236 for (const byte of handshakeHash.subarray(0, 8)) {237 value = (value << 8n) | BigInt(byte);238 }239 return String(value % 100_000_000n).padStart(8, "0");240}241242243244245246247248export async function runPairingResponder(249 ourEphemeralPrivateKey: Uint8Array,250 ourEphemeralPublicKey: Uint8Array,251 ourIdentity: LocalIdentity,252 transport: PairingTransport,253): Promise<PairingHandshakeResult> {254 const postNoise = await runPairingResponderOpenFrame(255 ourEphemeralPrivateKey,256 ourEphemeralPublicKey,257 transport,258 );259 return handlePairIdentityFrame(260 postNoise.frame,261 postNoise.channel,262 ourIdentity,263 postNoise.sas,264 postNoise.rendezvousSecretHex,265 );266}267268export async function runPairingResponderOpenFrame(269 ourEphemeralPrivateKey: Uint8Array,270 ourEphemeralPublicKey: Uint8Array,271 transport: PairingTransport,272): Promise<PostNoiseFrame> {273 const session = await noiseNKResponder(274 ourEphemeralPrivateKey,275 ourEphemeralPublicKey,276 transport.send,277 transport.receive,278 );279280 const channel: PairingTransport = {281 async send(msg) {282 await transport.send(session.encrypt(msg));283 },284 async receive() {285 return session.decrypt(await transport.receive());286 },287 };288 const theirFrame = JSON.parse(new TextDecoder().decode(await channel.receive()));289 return {290 sas: derivePairingSAS(session.handshakeHash),291 rendezvousSecretHex: bytesToHex(deriveRendezvousSecret(session.handshakeHash)),292 frame: theirFrame,293 channel,294 };295}296297export async function handlePairIdentityFrame(298 frame: unknown,299 channel: PairingTransport,300 ourIdentity: LocalIdentity,301 sas: string,302 rendezvousSecretHex: string,303): Promise<PairingHandshakeResult> {304 const theirFrame = decodeParsedFrame(frame);305 const ourFrame = encodeFrame({306 type: "PAIR_IDENTITY",307 device_id: ourIdentity.deviceId,308 device_name: ourIdentity.deviceName,309 device_type: ourIdentity.deviceType,310 long_term_public_key_hex: ourIdentity.longTermPublicKeyHex,311 signing_public_key_hex: ourIdentity.signingPublicKeyHex,312 ws_port: ourIdentity.wsPort,313 vault_id: ourIdentity.vaultId,314 license: ourIdentity.license,315 relay_url: ourIdentity.relayUrl,316 });317 await channel.send(ourFrame);318319 320 321 322 return {323 device: {324 ...frameToPairedDevice(theirFrame),325 rendezvousSecretHex,326 },327 sas,328 peerLicense: peerLicenseOf(theirFrame),329 };330}331332333334function peerLicenseOf(frame: PairingFrame): string | undefined {335 return typeof frame.license === "string" && frame.license.length > 0 ? frame.license : undefined;336}337338function frameToPairedDevice(frame: PairingFrame): PairedDevice {339 return {340 deviceId: frame.device_id,341 deviceName: frame.device_name,342 deviceType: frame.device_type,343 longTermPublicKeyHex: frame.long_term_public_key_hex,344 signingPublicKeyHex: frame.signing_public_key_hex,345 trustSource: "direct",346 wsPort: typeof frame.ws_port === "number" ? frame.ws_port : undefined,347 relayUrl: frame.relay_url,348 pairedAt: Date.now(),349 };350}351352353354355356export function memoryTransportPair(): [PairingTransport, PairingTransport] {357 const a: Uint8Array[] = [];358 const b: Uint8Array[] = [];359 const aReaders: Array<(b: Uint8Array) => void> = [];360 const bReaders: Array<(b: Uint8Array) => void> = [];361362 function makeTransport(363 inbox: Uint8Array[],364 outbox: Uint8Array[],365 inboxReaders: Array<(b: Uint8Array) => void>,366 outboxReaders: Array<(b: Uint8Array) => void>,367 ): PairingTransport {368 return {369 async send(msg: Uint8Array) {370 const r = outboxReaders.shift();371 if (r) r(msg);372 else outbox.push(msg);373 },374 receive() {375 const msg = inbox.shift();376 if (msg) return Promise.resolve(msg);377 return new Promise<Uint8Array>((resolve) => inboxReaders.push(resolve));378 },379 };380 }381 return [makeTransport(a, b, aReaders, bReaders), makeTransport(b, a, bReaders, aReaders)];382}