1import { computeVaultId } from "./sync-identity";2import type { DeviceType, PairedDevice, SignedDeviceManifest } from "./sync-types";3import type { LocalIdentity, PairingTransport } from "./pairing-handshake";45export interface AdoptRequest {6 type: "ADOPT_REQUEST";7 device_id: string;8 device_name: string;9 device_type: DeviceType;10 11 license?: string;12 1314151617 snapshot_capable?: boolean;18}1920export interface AdoptGrant {21 type: "ADOPT_GRANT";22 root_key_hex: string;23 vault_id: string;24 license?: string;25 source: {26 device_id: string;27 device_name: string;28 device_type: DeviceType;29 long_term_public_key_hex: string;30 signing_public_key_hex?: string;31 ws_port?: number;32 relay_url?: string;33 };34 3536373839 snapshot_capable?: boolean;40}4142export interface AdoptConfirm {43 type: "ADOPT_CONFIRM";44 confirmed: boolean;45}4647export interface AdoptIdentity {48 type: "ADOPT_IDENTITY";49 device_id: string;50 device_name: string;51 device_type: DeviceType;52 long_term_public_key_hex: string;53 signing_public_key_hex?: string;54}55565758596061export interface AdoptComplete {62 type: "ADOPT_COMPLETE";63}646566export interface AdoptApplied {67 type: "ADOPT_APPLIED";68}697071export interface AdoptReject {72 type: "ADOPT_REJECT";73 reason: "ADOPT_PRO_REQUIRED";74}75767778798081export interface AdoptSnapshot {82 type: "ADOPT_SNAPSHOT";83 chunk_index: number;84 chunk_count: number;85 entries: Record<string, string>;86 87 tombstones: Record<string, number>;88 8990919293 duress_slot?: string;94}959697export interface AdoptSnapshotApplied {98 type: "ADOPT_SNAPSHOT_APPLIED";99 entries_received: number;100 tombstones_received: number;101}102103104export interface AdoptDeviceManifestFrame {105 type: "ADOPT_DEVICE_MANIFEST";106 manifest: SignedDeviceManifest;107}108109110export interface AdoptDeviceManifestApplied {111 type: "ADOPT_DEVICE_MANIFEST_APPLIED";112}113114export type AdoptionFrame =115 | AdoptRequest116 | AdoptConfirm117 | AdoptGrant118 | AdoptIdentity119 | AdoptComplete120 | AdoptApplied121 | AdoptReject122 | AdoptSnapshot123 | AdoptSnapshotApplied124 | AdoptDeviceManifestFrame125 | AdoptDeviceManifestApplied;126127128129130131132133export const MAX_SNAPSHOT_CHUNK_BYTES = 512 * 1024; 134export const MAX_SNAPSHOT_CHUNKS = 200;135136137const SNAPSHOT_FRAME_WIRE_CEILING = MAX_SNAPSHOT_CHUNK_BYTES + 256 * 1024;138139export interface AdoptionPairState {140 rendezvousSecretHex: string;141 relayUrl?: string;142}143144export interface AdoptionReceiverDeps {145 deviceName: string;146 generateDeviceId(): string;147 148 confirmSas(): Promise<boolean>;149 150151152153 validateSource?(source: AdoptGrant["source"]): Promise<void>;154 createVaultFromRootKey(rootKeyHex: string): Promise<{ vaultId: string }>;155 deriveLocalIdentity(): Promise<LocalIdentity>;156 upsertPairedDevice(device: PairedDevice): Promise<void>;157 applyLicense?(license: string): Promise<void>;158 159 license?(): Promise<string | undefined>;160 161 pairState?: AdoptionPairState;162 onError(error: string): void;163}164165export interface AdoptionSourceDeps {166 sourceIdentity: LocalIdentity;167 rootKeyHex(): Promise<string>;168 vaultId(): Promise<string>;169 license?(): Promise<string | undefined>;170 171 applyLicense?(license: string): Promise<void>;172 onConsent(request: AdoptRequest): Promise<boolean>;173 upsertPairedDevice(device: PairedDevice): Promise<void>;174 175 pairState?: AdoptionPairState;176}177178export function isAdoptRequest(frame: unknown): frame is AdoptRequest {179 const value = frame as Partial<AdoptRequest> | null;180 return value?.type === "ADOPT_REQUEST" &&181 typeof value.device_id === "string" &&182 value.device_id.length > 0 &&183 typeof value.device_name === "string" &&184 isDeviceType(value.device_type) &&185 (value.license === undefined || typeof value.license === "string");186}187188export function isAdoptGrant(frame: unknown): frame is AdoptGrant {189 const value = frame as Partial<AdoptGrant> | null;190 return value?.type === "ADOPT_GRANT" &&191 validateRootKeyHex(value.root_key_hex) &&192 typeof value.vault_id === "string" &&193 /^[0-9a-f]{64}$/.test(value.vault_id) &&194 typeof value.source === "object" &&195 value.source !== null &&196 typeof value.source.device_id === "string" &&197 typeof value.source.device_name === "string" &&198 isDeviceType(value.source.device_type) &&199 typeof value.source.long_term_public_key_hex === "string" &&200 /^[0-9a-f]{64}$/.test(value.source.long_term_public_key_hex) &&201 (value.source.relay_url === undefined ||202 (typeof value.source.relay_url === "string" &&203 isSecureRelayUrl(value.source.relay_url))) &&204 (value.snapshot_capable === undefined ||205 typeof value.snapshot_capable === "boolean");206}207208export function isAdoptConfirm(frame: unknown): frame is AdoptConfirm {209 const value = frame as Partial<AdoptConfirm> | null;210 return value?.type === "ADOPT_CONFIRM" &&211 typeof value.confirmed === "boolean";212}213214export function isAdoptIdentity(frame: unknown): frame is AdoptIdentity {215 const value = frame as Partial<AdoptIdentity> | null;216 return value?.type === "ADOPT_IDENTITY" &&217 typeof value.device_id === "string" &&218 value.device_id.length > 0 &&219 typeof value.device_name === "string" &&220 isDeviceType(value.device_type) &&221 typeof value.long_term_public_key_hex === "string" &&222 /^[0-9a-f]{64}$/.test(value.long_term_public_key_hex);223}224225export function isAdoptComplete(frame: unknown): frame is AdoptComplete {226 return (frame as Partial<AdoptComplete> | null)?.type === "ADOPT_COMPLETE";227}228229export function isAdoptApplied(frame: unknown): frame is AdoptApplied {230 return (frame as Partial<AdoptApplied> | null)?.type === "ADOPT_APPLIED";231}232233export function isAdoptReject(frame: unknown): frame is AdoptReject {234 const value = frame as Partial<AdoptReject> | null;235 return value?.type === "ADOPT_REJECT" &&236 value.reason === "ADOPT_PRO_REQUIRED";237}238239export function isAdoptSnapshot(frame: unknown): frame is AdoptSnapshot {240 const value = frame as Partial<AdoptSnapshot> | null;241 if (value?.type !== "ADOPT_SNAPSHOT") return false;242 if (243 typeof value.chunk_index !== "number" ||244 !Number.isInteger(value.chunk_index) ||245 value.chunk_index < 0246 ) return false;247 if (248 typeof value.chunk_count !== "number" ||249 !Number.isInteger(value.chunk_count) ||250 value.chunk_count < 1251 ) return false;252 if (!isStringRecord(value.entries)) return false;253 if (!isNumberRecord(value.tombstones)) return false;254 if (value.duress_slot !== undefined && typeof value.duress_slot !== "string") return false;255 return true;256}257258export function isAdoptSnapshotApplied(frame: unknown): frame is AdoptSnapshotApplied {259 const value = frame as Partial<AdoptSnapshotApplied> | null;260 return value?.type === "ADOPT_SNAPSHOT_APPLIED" &&261 typeof value.entries_received === "number" &&262 typeof value.tombstones_received === "number";263}264265export function isAdoptDeviceManifestFrame(266 frame: unknown,267): frame is AdoptDeviceManifestFrame {268 const value = frame as Partial<AdoptDeviceManifestFrame> | null;269 const manifest = value?.manifest as270 | { body?: unknown; signatureHex?: unknown }271 | null;272 return value?.type === "ADOPT_DEVICE_MANIFEST" &&273 typeof manifest === "object" &&274 manifest !== null &&275 typeof manifest.body === "object" &&276 manifest.body !== null &&277 typeof manifest.signatureHex === "string";278}279280export function isAdoptDeviceManifestApplied(281 frame: unknown,282): frame is AdoptDeviceManifestApplied {283 return (frame as Partial<AdoptDeviceManifestApplied> | null)?.type ===284 "ADOPT_DEVICE_MANIFEST_APPLIED";285}286287function isStringRecord(value: unknown): value is Record<string, string> {288 if (typeof value !== "object" || value === null) return false;289 return Object.values(value).every((v) => typeof v === "string");290}291292function isNumberRecord(value: unknown): value is Record<string, number> {293 if (typeof value !== "object" || value === null) return false;294 return Object.values(value).every((v) => typeof v === "number");295}296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331export async function sendVaultSnapshot(332 channel: PairingTransport,333 entries: Record<string, string>,334 tombstones: Record<string, number>,335 duressSlot: string | null = null,336): Promise<void> {337 const includeDuress = !!duressSlot;338 339 340 341 const entryCap = includeDuress ? MAX_SNAPSHOT_CHUNKS - 1 : MAX_SNAPSHOT_CHUNKS;342343 const chunks: Array<Record<string, string>> = [];344 let current: Record<string, string> = {};345 let currentBytes = 2; 346 for (const [id, envelope] of Object.entries(entries)) {347 348 const entryBytes = JSON.stringify({ [id]: envelope }).length + 1;349 if (350 Object.keys(current).length > 0 &&351 currentBytes + entryBytes >= MAX_SNAPSHOT_CHUNK_BYTES352 ) {353 chunks.push(current);354 current = {};355 currentBytes = 2;356 }357 current[id] = envelope;358 currentBytes += entryBytes;359 }360 chunks.push(current);361362 const entriesChunkCount = Math.min(chunks.length, entryCap);363 const sendDuress = includeDuress;364 const totalChunks = entriesChunkCount + (sendDuress ? 1 : 0);365 const duressFrame: AdoptSnapshot | null = sendDuress366 ? {367 type: "ADOPT_SNAPSHOT",368 chunk_index: entriesChunkCount,369 chunk_count: totalChunks,370 entries: {},371 tombstones,372 duress_slot: duressSlot as string,373 }374 : null;375376 if (377 duressFrame &&378 new TextEncoder().encode(JSON.stringify(duressFrame)).length >379 SNAPSHOT_FRAME_WIRE_CEILING380 ) {381 throw new Error("ADOPT_SNAPSHOT_DURESS_FRAME_TOO_LARGE");382 }383384 for (let index = 0; index < entriesChunkCount; index++) {385 const isFinalEntries = index === entriesChunkCount - 1;386 const frame: AdoptSnapshot = {387 type: "ADOPT_SNAPSHOT",388 chunk_index: index,389 chunk_count: totalChunks,390 entries: chunks[index],391 392 393 tombstones: isFinalEntries && !sendDuress ? tombstones : {},394 };395 await sendFrame(channel, frame);396 }397398 if (duressFrame) await sendFrame(channel, duressFrame);399}400401402403404405406407408export async function receiveVaultSnapshot(409 channel: PairingTransport,410): Promise<{ entries: Record<string, string>; tombstones: Record<string, number>; duressSlot: string | null }> {411 const entries: Record<string, string> = {};412 const tombstones: Record<string, number> = {};413 let duressSlot: string | null = null;414 let chunkCount = -1;415 let received = 0;416 while (chunkCount === -1 || received < chunkCount) {417 const raw = await channel.receive();418 if (raw.length > SNAPSHOT_FRAME_WIRE_CEILING) {419 throw new Error("ADOPT_SNAPSHOT_CHUNK_TOO_LARGE");420 }421 const frame: unknown = JSON.parse(new TextDecoder().decode(raw));422 if (!isAdoptSnapshot(frame)) throw new Error("ADOPT_SNAPSHOT_INVALID_FRAME");423 if (chunkCount === -1) {424 chunkCount = frame.chunk_count;425 if (chunkCount > MAX_SNAPSHOT_CHUNKS) {426 throw new Error("ADOPT_SNAPSHOT_TOO_MANY_CHUNKS");427 }428 } else if (frame.chunk_count !== chunkCount) {429 throw new Error("ADOPT_SNAPSHOT_CHUNK_COUNT_MISMATCH");430 }431 if (frame.chunk_index !== received) {432 throw new Error("ADOPT_SNAPSHOT_CHUNK_OUT_OF_ORDER");433 }434 Object.assign(entries, frame.entries);435 Object.assign(tombstones, frame.tombstones);436 if (frame.duress_slot) duressSlot = frame.duress_slot;437 received++;438 }439 return { entries, tombstones, duressSlot };440}441442export function validateRootKeyHex(rootKeyHex: unknown): rootKeyHex is string {443 return typeof rootKeyHex === "string" && /^[0-9a-f]{64}$/.test(rootKeyHex);444}445446export interface AdoptionRequestResult {447 device: PairedDevice;448 449 snapshotCapable: boolean;450}451452export async function requestAdoption(453 channel: PairingTransport,454 deps: AdoptionReceiverDeps,455): Promise<AdoptionRequestResult | null> {456 const receiverLicense = await deps.license?.();457 await sendFrame(channel, {458 type: "ADOPT_REQUEST",459 device_id: deps.generateDeviceId(),460 device_name: deps.deviceName,461 device_type: "extension",462 license: receiverLicense,463 snapshot_capable: true,464 });465466 const confirmed = await deps.confirmSas();467 await sendFrame(channel, {468 type: "ADOPT_CONFIRM",469 confirmed,470 });471 if (!confirmed) {472 deps.onError("ADOPT_SAS_REJECTED");473 return null;474 }475476 const frame = await receiveFrame(channel);477 if (isAdoptReject(frame)) {478 deps.onError(frame.reason);479 return null;480 }481 if (!isAdoptGrant(frame)) {482 deps.onError("ADOPT_UNEXPECTED_FRAME");483 return null;484 }485 const grantSnapshotCapable = frame.snapshot_capable === true;486487 const rootKeyBytes = hexToBytes(frame.root_key_hex);488 try {489 const computedVaultId = await computeVaultId(rootKeyBytes);490 if (computedVaultId !== frame.vault_id) {491 deps.onError("ADOPT_VAULT_ID_MISMATCH");492 return null;493 }494 } finally {495 rootKeyBytes.fill(0);496 }497 if (!receiverLicense && !frame.license) {498 deps.onError("ADOPT_PRO_REQUIRED");499 return null;500 }501502 await deps.validateSource?.(frame.source);503 const { vaultId } = await deps.createVaultFromRootKey(frame.root_key_hex);504 if (vaultId !== frame.vault_id) {505 deps.onError("ADOPT_VAULT_ID_MISMATCH");506 return null;507 }508509 if (!receiverLicense && frame.license) {510 await deps.applyLicense?.(frame.license);511 }512513 const identity = await deps.deriveLocalIdentity();514 await sendFrame(channel, {515 type: "ADOPT_IDENTITY",516 device_id: identity.deviceId,517 device_name: identity.deviceName,518 device_type: identity.deviceType,519 long_term_public_key_hex: identity.longTermPublicKeyHex,520 signing_public_key_hex: identity.signingPublicKeyHex,521 });522523 const completion = await receiveFrame(channel);524 if (!isAdoptComplete(completion)) {525 deps.onError("ADOPT_COMPLETION_MISSING");526 return null;527 }528529 const sourceDevice: PairedDevice = {530 deviceId: frame.source.device_id,531 deviceName: frame.source.device_name,532 deviceType: frame.source.device_type,533 longTermPublicKeyHex: frame.source.long_term_public_key_hex,534 signingPublicKeyHex: frame.source.signing_public_key_hex,535 trustSource: "direct",536 wsPort: frame.source.ws_port,537 rendezvousSecretHex: deps.pairState?.rendezvousSecretHex,538 relayUrl: deps.pairState?.relayUrl ?? frame.source.relay_url,539 pairedAt: Date.now(),540 };541 await deps.upsertPairedDevice(sourceDevice);542 await sendFrame(channel, { type: "ADOPT_APPLIED" });543 return { device: sourceDevice, snapshotCapable: grantSnapshotCapable };544}545546export async function grantAdoption(547 channel: PairingTransport,548 request: AdoptRequest,549 deps: AdoptionSourceDeps,550): Promise<PairedDevice | null> {551 const approved = await deps.onConsent(request);552 if (!approved) return null;553554 const confirmation = await receiveFrame(channel);555 if (!isAdoptConfirm(confirmation) || !confirmation.confirmed) return null;556557 let sourceLicense = await deps.license?.();558 if (!sourceLicense && request.license) {559 try {560 if (!deps.applyLicense) throw new Error("license_transfer_unavailable");561 await deps.applyLicense(request.license);562 sourceLicense = await deps.license?.();563 } catch {564 sourceLicense = undefined;565 }566 }567 if (!sourceLicense) {568 await sendFrame(channel, {569 type: "ADOPT_REJECT",570 reason: "ADOPT_PRO_REQUIRED",571 });572 throw new Error("ADOPT_PRO_REQUIRED");573 }574575 const rootKeyHex = await deps.rootKeyHex();576 if (!validateRootKeyHex(rootKeyHex)) throw new Error("ADOPT_INVALID_ROOT_KEY");577578 await sendFrame(channel, {579 type: "ADOPT_GRANT",580 root_key_hex: rootKeyHex,581 vault_id: await deps.vaultId(),582 license: sourceLicense,583 source: {584 device_id: deps.sourceIdentity.deviceId,585 device_name: deps.sourceIdentity.deviceName,586 device_type: deps.sourceIdentity.deviceType,587 long_term_public_key_hex: deps.sourceIdentity.longTermPublicKeyHex,588 signing_public_key_hex: deps.sourceIdentity.signingPublicKeyHex,589 ws_port: deps.sourceIdentity.wsPort,590 relay_url: deps.pairState?.relayUrl,591 },592 snapshot_capable: request.snapshot_capable === true,593 });594595 const identity = await receiveFrame(channel);596 if (!isAdoptIdentity(identity)) return null;597598 const device: PairedDevice = {599 deviceId: identity.device_id,600 deviceName: identity.device_name,601 deviceType: identity.device_type,602 longTermPublicKeyHex: identity.long_term_public_key_hex,603 signingPublicKeyHex: identity.signing_public_key_hex,604 trustSource: "direct",605 rendezvousSecretHex: deps.pairState?.rendezvousSecretHex,606 relayUrl: deps.pairState?.relayUrl,607 pairedAt: Date.now(),608 };609 await deps.upsertPairedDevice(device);610 await sendFrame(channel, { type: "ADOPT_COMPLETE" });611 if (!isAdoptApplied(await receiveFrame(channel))) return null;612 return device;613}614615export async function sendFrame(channel: PairingTransport, frame: AdoptionFrame): Promise<void> {616 await channel.send(new TextEncoder().encode(JSON.stringify(frame)));617}618619export async function receiveFrame(channel: PairingTransport): Promise<unknown> {620 return JSON.parse(new TextDecoder().decode(await channel.receive()));621}622623function isDeviceType(value: unknown): value is DeviceType {624 return value === "extension" || value === "android" || value === "ios";625}626627function isSecureRelayUrl(value: string): boolean {628 try {629 const url = new URL(value);630 return url.protocol === "wss:" && !url.username && !url.password &&631 !url.search && !url.hash;632 } catch {633 return false;634 }635}636637function hexToBytes(hex: string): Uint8Array {638 const out = new Uint8Array(hex.length / 2);639 for (let i = 0; i < out.length; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);640 return out;641}