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}279280function isStringRecord(value: unknown): value is Record<string, string> {281 if (typeof value !== "object" || value === null) return false;282 return Object.values(value).every((v) => typeof v === "string");283}284285function isNumberRecord(value: unknown): value is Record<string, number> {286 if (typeof value !== "object" || value === null) return false;287 return Object.values(value).every((v) => typeof v === "number");288}289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324export async function sendVaultSnapshot(325 channel: PairingTransport,326 entries: Record<string, string>,327 tombstones: Record<string, number>,328 duressSlot: string | null = null,329): Promise<void> {330 const includeDuress = !!duressSlot;331 332 333 334 const entryCap = includeDuress ? MAX_SNAPSHOT_CHUNKS - 1 : MAX_SNAPSHOT_CHUNKS;335336 const chunks: Array<Record<string, string>> = [];337 let current: Record<string, string> = {};338 let currentBytes = 2; 339 for (const [id, envelope] of Object.entries(entries)) {340 341 const entryBytes = JSON.stringify({ [id]: envelope }).length + 1;342 if (343 Object.keys(current).length > 0 &&344 currentBytes + entryBytes >= MAX_SNAPSHOT_CHUNK_BYTES345 ) {346 chunks.push(current);347 current = {};348 currentBytes = 2;349 }350 current[id] = envelope;351 currentBytes += entryBytes;352 }353 chunks.push(current);354355 const entriesChunkCount = Math.min(chunks.length, entryCap);356 const sendDuress = includeDuress;357 const totalChunks = entriesChunkCount + (sendDuress ? 1 : 0);358 const duressFrame: AdoptSnapshot | null = sendDuress359 ? {360 type: "ADOPT_SNAPSHOT",361 chunk_index: entriesChunkCount,362 chunk_count: totalChunks,363 entries: {},364 tombstones,365 duress_slot: duressSlot as string,366 }367 : null;368369 if (370 duressFrame &&371 new TextEncoder().encode(JSON.stringify(duressFrame)).length >372 SNAPSHOT_FRAME_WIRE_CEILING373 ) {374 throw new Error("ADOPT_SNAPSHOT_DURESS_FRAME_TOO_LARGE");375 }376377 for (let index = 0; index < entriesChunkCount; index++) {378 const isFinalEntries = index === entriesChunkCount - 1;379 const frame: AdoptSnapshot = {380 type: "ADOPT_SNAPSHOT",381 chunk_index: index,382 chunk_count: totalChunks,383 entries: chunks[index],384 385 386 tombstones: isFinalEntries && !sendDuress ? tombstones : {},387 };388 await sendFrame(channel, frame);389 }390391 if (duressFrame) await sendFrame(channel, duressFrame);392}393394395396397398399400401export async function receiveVaultSnapshot(402 channel: PairingTransport,403): Promise<{ entries: Record<string, string>; tombstones: Record<string, number>; duressSlot: string | null }> {404 const entries: Record<string, string> = {};405 const tombstones: Record<string, number> = {};406 let duressSlot: string | null = null;407 let chunkCount = -1;408 let received = 0;409 while (chunkCount === -1 || received < chunkCount) {410 const raw = await channel.receive();411 if (raw.length > SNAPSHOT_FRAME_WIRE_CEILING) {412 throw new Error("ADOPT_SNAPSHOT_CHUNK_TOO_LARGE");413 }414 const frame: unknown = JSON.parse(new TextDecoder().decode(raw));415 if (!isAdoptSnapshot(frame)) throw new Error("ADOPT_SNAPSHOT_INVALID_FRAME");416 if (chunkCount === -1) {417 chunkCount = frame.chunk_count;418 if (chunkCount > MAX_SNAPSHOT_CHUNKS) {419 throw new Error("ADOPT_SNAPSHOT_TOO_MANY_CHUNKS");420 }421 } else if (frame.chunk_count !== chunkCount) {422 throw new Error("ADOPT_SNAPSHOT_CHUNK_COUNT_MISMATCH");423 }424 if (frame.chunk_index !== received) {425 throw new Error("ADOPT_SNAPSHOT_CHUNK_OUT_OF_ORDER");426 }427 Object.assign(entries, frame.entries);428 Object.assign(tombstones, frame.tombstones);429 if (frame.duress_slot) duressSlot = frame.duress_slot;430 received++;431 }432 return { entries, tombstones, duressSlot };433}434435export function validateRootKeyHex(rootKeyHex: unknown): rootKeyHex is string {436 return typeof rootKeyHex === "string" && /^[0-9a-f]{64}$/.test(rootKeyHex);437}438439export interface AdoptionRequestResult {440 device: PairedDevice;441 442 snapshotCapable: boolean;443}444445export async function requestAdoption(446 channel: PairingTransport,447 deps: AdoptionReceiverDeps,448): Promise<AdoptionRequestResult | null> {449 const receiverLicense = await deps.license?.();450 await sendFrame(channel, {451 type: "ADOPT_REQUEST",452 device_id: deps.generateDeviceId(),453 device_name: deps.deviceName,454 device_type: "extension",455 license: receiverLicense,456 snapshot_capable: true,457 });458459 const confirmed = await deps.confirmSas();460 await sendFrame(channel, {461 type: "ADOPT_CONFIRM",462 confirmed,463 });464 if (!confirmed) {465 deps.onError("ADOPT_SAS_REJECTED");466 return null;467 }468469 const frame = await receiveFrame(channel);470 if (isAdoptReject(frame)) {471 deps.onError(frame.reason);472 return null;473 }474 if (!isAdoptGrant(frame)) {475 deps.onError("ADOPT_UNEXPECTED_FRAME");476 return null;477 }478 const grantSnapshotCapable = frame.snapshot_capable === true;479480 const rootKeyBytes = hexToBytes(frame.root_key_hex);481 try {482 const computedVaultId = await computeVaultId(rootKeyBytes);483 if (computedVaultId !== frame.vault_id) {484 deps.onError("ADOPT_VAULT_ID_MISMATCH");485 return null;486 }487 } finally {488 rootKeyBytes.fill(0);489 }490 if (!receiverLicense && !frame.license) {491 deps.onError("ADOPT_PRO_REQUIRED");492 return null;493 }494495 await deps.validateSource?.(frame.source);496 const { vaultId } = await deps.createVaultFromRootKey(frame.root_key_hex);497 if (vaultId !== frame.vault_id) {498 deps.onError("ADOPT_VAULT_ID_MISMATCH");499 return null;500 }501502 if (!receiverLicense && frame.license) {503 await deps.applyLicense?.(frame.license);504 }505506 const identity = await deps.deriveLocalIdentity();507 await sendFrame(channel, {508 type: "ADOPT_IDENTITY",509 device_id: identity.deviceId,510 device_name: identity.deviceName,511 device_type: identity.deviceType,512 long_term_public_key_hex: identity.longTermPublicKeyHex,513 signing_public_key_hex: identity.signingPublicKeyHex,514 });515516 const completion = await receiveFrame(channel);517 if (!isAdoptComplete(completion)) {518 deps.onError("ADOPT_COMPLETION_MISSING");519 return null;520 }521522 const sourceDevice: PairedDevice = {523 deviceId: frame.source.device_id,524 deviceName: frame.source.device_name,525 deviceType: frame.source.device_type,526 longTermPublicKeyHex: frame.source.long_term_public_key_hex,527 signingPublicKeyHex: frame.source.signing_public_key_hex,528 trustSource: "direct",529 wsPort: frame.source.ws_port,530 rendezvousSecretHex: deps.pairState?.rendezvousSecretHex,531 relayUrl: deps.pairState?.relayUrl ?? frame.source.relay_url,532 pairedAt: Date.now(),533 };534 await deps.upsertPairedDevice(sourceDevice);535 await sendFrame(channel, { type: "ADOPT_APPLIED" });536 return { device: sourceDevice, snapshotCapable: grantSnapshotCapable };537}538539export async function grantAdoption(540 channel: PairingTransport,541 request: AdoptRequest,542 deps: AdoptionSourceDeps,543): Promise<PairedDevice | null> {544 const approved = await deps.onConsent(request);545 if (!approved) return null;546547 const confirmation = await receiveFrame(channel);548 if (!isAdoptConfirm(confirmation) || !confirmation.confirmed) return null;549550 let sourceLicense = await deps.license?.();551 if (!sourceLicense && request.license) {552 try {553 if (!deps.applyLicense) throw new Error("license_transfer_unavailable");554 await deps.applyLicense(request.license);555 sourceLicense = await deps.license?.();556 } catch {557 sourceLicense = undefined;558 }559 }560 if (!sourceLicense) {561 await sendFrame(channel, {562 type: "ADOPT_REJECT",563 reason: "ADOPT_PRO_REQUIRED",564 });565 throw new Error("ADOPT_PRO_REQUIRED");566 }567568 const rootKeyHex = await deps.rootKeyHex();569 if (!validateRootKeyHex(rootKeyHex)) throw new Error("ADOPT_INVALID_ROOT_KEY");570571 await sendFrame(channel, {572 type: "ADOPT_GRANT",573 root_key_hex: rootKeyHex,574 vault_id: await deps.vaultId(),575 license: sourceLicense,576 source: {577 device_id: deps.sourceIdentity.deviceId,578 device_name: deps.sourceIdentity.deviceName,579 device_type: deps.sourceIdentity.deviceType,580 long_term_public_key_hex: deps.sourceIdentity.longTermPublicKeyHex,581 signing_public_key_hex: deps.sourceIdentity.signingPublicKeyHex,582 ws_port: deps.sourceIdentity.wsPort,583 relay_url: deps.pairState?.relayUrl,584 },585 snapshot_capable: request.snapshot_capable === true,586 });587588 const identity = await receiveFrame(channel);589 if (!isAdoptIdentity(identity)) return null;590591 const device: PairedDevice = {592 deviceId: identity.device_id,593 deviceName: identity.device_name,594 deviceType: identity.device_type,595 longTermPublicKeyHex: identity.long_term_public_key_hex,596 signingPublicKeyHex: identity.signing_public_key_hex,597 trustSource: "direct",598 rendezvousSecretHex: deps.pairState?.rendezvousSecretHex,599 relayUrl: deps.pairState?.relayUrl,600 pairedAt: Date.now(),601 };602 await deps.upsertPairedDevice(device);603 await sendFrame(channel, { type: "ADOPT_COMPLETE" });604 if (!isAdoptApplied(await receiveFrame(channel))) return null;605 return device;606}607608export async function sendFrame(channel: PairingTransport, frame: AdoptionFrame): Promise<void> {609 await channel.send(new TextEncoder().encode(JSON.stringify(frame)));610}611612export async function receiveFrame(channel: PairingTransport): Promise<unknown> {613 return JSON.parse(new TextDecoder().decode(await channel.receive()));614}615616function isDeviceType(value: unknown): value is DeviceType {617 return value === "extension" || value === "android" || value === "ios";618}619620function isSecureRelayUrl(value: string): boolean {621 try {622 const url = new URL(value);623 return url.protocol === "wss:" && !url.username && !url.password &&624 !url.search && !url.hash;625 } catch {626 return false;627 }628}629630function hexToBytes(hex: string): Uint8Array {631 const out = new Uint8Array(hex.length / 2);632 for (let i = 0; i < out.length; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);633 return out;634}