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);365366 for (let index = 0; index < entriesChunkCount; index++) {367 const isFinalEntries = index === entriesChunkCount - 1;368 const frame: AdoptSnapshot = {369 type: "ADOPT_SNAPSHOT",370 chunk_index: index,371 chunk_count: totalChunks,372 entries: chunks[index],373 374 375 tombstones: isFinalEntries && !sendDuress ? tombstones : {},376 };377 await sendFrame(channel, frame);378 }379380 if (sendDuress) {381 382 383 384 385 386 const duressEnvelope = JSON.stringify({387 type: "ADOPT_SNAPSHOT",388 chunk_index: entriesChunkCount,389 chunk_count: totalChunks,390 entries: {},391 tombstones,392 duress_slot: duressSlot,393 });394 if (new TextEncoder().encode(duressEnvelope).length > SNAPSHOT_FRAME_WIRE_CEILING) {395 throw new Error("ADOPT_SNAPSHOT_DURESS_FRAME_TOO_LARGE");396 }397 await sendFrame(channel, {398 type: "ADOPT_SNAPSHOT",399 chunk_index: entriesChunkCount,400 chunk_count: totalChunks,401 entries: {},402 tombstones,403 duress_slot: duressSlot as string,404 });405 }406}407408409410411412413414415export async function receiveVaultSnapshot(416 channel: PairingTransport,417): Promise<{ entries: Record<string, string>; tombstones: Record<string, number>; duressSlot: string | null }> {418 const entries: Record<string, string> = {};419 const tombstones: Record<string, number> = {};420 let duressSlot: string | null = null;421 let chunkCount = -1;422 let received = 0;423 while (chunkCount === -1 || received < chunkCount) {424 const raw = await channel.receive();425 if (raw.length > SNAPSHOT_FRAME_WIRE_CEILING) {426 throw new Error("ADOPT_SNAPSHOT_CHUNK_TOO_LARGE");427 }428 const frame: unknown = JSON.parse(new TextDecoder().decode(raw));429 if (!isAdoptSnapshot(frame)) throw new Error("ADOPT_SNAPSHOT_INVALID_FRAME");430 if (chunkCount === -1) {431 chunkCount = frame.chunk_count;432 if (chunkCount > MAX_SNAPSHOT_CHUNKS) {433 throw new Error("ADOPT_SNAPSHOT_TOO_MANY_CHUNKS");434 }435 } else if (frame.chunk_count !== chunkCount) {436 throw new Error("ADOPT_SNAPSHOT_CHUNK_COUNT_MISMATCH");437 }438 if (frame.chunk_index !== received) {439 throw new Error("ADOPT_SNAPSHOT_CHUNK_OUT_OF_ORDER");440 }441 Object.assign(entries, frame.entries);442 Object.assign(tombstones, frame.tombstones);443 if (frame.duress_slot) duressSlot = frame.duress_slot;444 received++;445 }446 return { entries, tombstones, duressSlot };447}448449export function validateRootKeyHex(rootKeyHex: unknown): rootKeyHex is string {450 return typeof rootKeyHex === "string" && /^[0-9a-f]{64}$/.test(rootKeyHex);451}452453export interface AdoptionRequestResult {454 device: PairedDevice;455 456 snapshotCapable: boolean;457}458459export async function requestAdoption(460 channel: PairingTransport,461 deps: AdoptionReceiverDeps,462): Promise<AdoptionRequestResult | null> {463 const receiverLicense = await deps.license?.();464 await sendFrame(channel, {465 type: "ADOPT_REQUEST",466 device_id: deps.generateDeviceId(),467 device_name: deps.deviceName,468 device_type: "extension",469 license: receiverLicense,470 snapshot_capable: true,471 });472473 const confirmed = await deps.confirmSas();474 await sendFrame(channel, {475 type: "ADOPT_CONFIRM",476 confirmed,477 });478 if (!confirmed) {479 deps.onError("ADOPT_SAS_REJECTED");480 return null;481 }482483 const frame = await receiveFrame(channel);484 if (isAdoptReject(frame)) {485 deps.onError(frame.reason);486 return null;487 }488 if (!isAdoptGrant(frame)) {489 deps.onError("ADOPT_UNEXPECTED_FRAME");490 return null;491 }492 const grantSnapshotCapable = frame.snapshot_capable === true;493494 const rootKeyBytes = hexToBytes(frame.root_key_hex);495 try {496 const computedVaultId = await computeVaultId(rootKeyBytes);497 if (computedVaultId !== frame.vault_id) {498 deps.onError("ADOPT_VAULT_ID_MISMATCH");499 return null;500 }501 } finally {502 rootKeyBytes.fill(0);503 }504 if (!receiverLicense && !frame.license) {505 deps.onError("ADOPT_PRO_REQUIRED");506 return null;507 }508509 await deps.validateSource?.(frame.source);510 const { vaultId } = await deps.createVaultFromRootKey(frame.root_key_hex);511 if (vaultId !== frame.vault_id) {512 deps.onError("ADOPT_VAULT_ID_MISMATCH");513 return null;514 }515516 if (!receiverLicense && frame.license) {517 await deps.applyLicense?.(frame.license);518 }519520 const identity = await deps.deriveLocalIdentity();521 await sendFrame(channel, {522 type: "ADOPT_IDENTITY",523 device_id: identity.deviceId,524 device_name: identity.deviceName,525 device_type: identity.deviceType,526 long_term_public_key_hex: identity.longTermPublicKeyHex,527 signing_public_key_hex: identity.signingPublicKeyHex,528 });529530 const completion = await receiveFrame(channel);531 if (!isAdoptComplete(completion)) {532 deps.onError("ADOPT_COMPLETION_MISSING");533 return null;534 }535536 const sourceDevice: PairedDevice = {537 deviceId: frame.source.device_id,538 deviceName: frame.source.device_name,539 deviceType: frame.source.device_type,540 longTermPublicKeyHex: frame.source.long_term_public_key_hex,541 signingPublicKeyHex: frame.source.signing_public_key_hex,542 trustSource: "direct",543 wsPort: frame.source.ws_port,544 rendezvousSecretHex: deps.pairState?.rendezvousSecretHex,545 relayUrl: deps.pairState?.relayUrl ?? frame.source.relay_url,546 pairedAt: Date.now(),547 };548 await deps.upsertPairedDevice(sourceDevice);549 await sendFrame(channel, { type: "ADOPT_APPLIED" });550 return { device: sourceDevice, snapshotCapable: grantSnapshotCapable };551}552553export async function grantAdoption(554 channel: PairingTransport,555 request: AdoptRequest,556 deps: AdoptionSourceDeps,557): Promise<PairedDevice | null> {558 const approved = await deps.onConsent(request);559 if (!approved) return null;560561 const confirmation = await receiveFrame(channel);562 if (!isAdoptConfirm(confirmation) || !confirmation.confirmed) return null;563564 let sourceLicense = await deps.license?.();565 if (!sourceLicense && request.license) {566 try {567 if (!deps.applyLicense) throw new Error("license_transfer_unavailable");568 await deps.applyLicense(request.license);569 sourceLicense = await deps.license?.();570 } catch {571 sourceLicense = undefined;572 }573 }574 if (!sourceLicense) {575 await sendFrame(channel, {576 type: "ADOPT_REJECT",577 reason: "ADOPT_PRO_REQUIRED",578 });579 throw new Error("ADOPT_PRO_REQUIRED");580 }581582 const rootKeyHex = await deps.rootKeyHex();583 if (!validateRootKeyHex(rootKeyHex)) throw new Error("ADOPT_INVALID_ROOT_KEY");584585 await sendFrame(channel, {586 type: "ADOPT_GRANT",587 root_key_hex: rootKeyHex,588 vault_id: await deps.vaultId(),589 license: sourceLicense,590 source: {591 device_id: deps.sourceIdentity.deviceId,592 device_name: deps.sourceIdentity.deviceName,593 device_type: deps.sourceIdentity.deviceType,594 long_term_public_key_hex: deps.sourceIdentity.longTermPublicKeyHex,595 signing_public_key_hex: deps.sourceIdentity.signingPublicKeyHex,596 ws_port: deps.sourceIdentity.wsPort,597 relay_url: deps.pairState?.relayUrl,598 },599 snapshot_capable: request.snapshot_capable === true,600 });601602 const identity = await receiveFrame(channel);603 if (!isAdoptIdentity(identity)) return null;604605 const device: PairedDevice = {606 deviceId: identity.device_id,607 deviceName: identity.device_name,608 deviceType: identity.device_type,609 longTermPublicKeyHex: identity.long_term_public_key_hex,610 signingPublicKeyHex: identity.signing_public_key_hex,611 trustSource: "direct",612 rendezvousSecretHex: deps.pairState?.rendezvousSecretHex,613 relayUrl: deps.pairState?.relayUrl,614 pairedAt: Date.now(),615 };616 await deps.upsertPairedDevice(device);617 await sendFrame(channel, { type: "ADOPT_COMPLETE" });618 if (!isAdoptApplied(await receiveFrame(channel))) return null;619 return device;620}621622export async function sendFrame(channel: PairingTransport, frame: AdoptionFrame): Promise<void> {623 await channel.send(new TextEncoder().encode(JSON.stringify(frame)));624}625626export async function receiveFrame(channel: PairingTransport): Promise<unknown> {627 return JSON.parse(new TextDecoder().decode(await channel.receive()));628}629630function isDeviceType(value: unknown): value is DeviceType {631 return value === "extension" || value === "android" || value === "ios";632}633634function isSecureRelayUrl(value: string): boolean {635 try {636 const url = new URL(value);637 return url.protocol === "wss:" && !url.username && !url.password &&638 !url.search && !url.hash;639 } catch {640 return false;641 }642}643644function hexToBytes(hex: string): Uint8Array {645 const out = new Uint8Array(hex.length / 2);646 for (let i = 0; i < out.length; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);647 return out;648}