1import { padSyncFrame, syncFrameBucket, unpadSyncFrame } from "./sync-padding";2import { capDeviceName, SYNC_LIMITS } from "./sync-limits";3import { computeVaultId } from "./sync-identity";4import type { DeviceType, PairedDevice, SignedDeviceManifest } from "./sync-types";5import type { LocalIdentity, PairingTransport } from "./pairing-handshake";67export interface AdoptRequest {8 type: "ADOPT_REQUEST";9 device_id: string;10 device_name: string;11 device_type: DeviceType;12 13 license?: string;14 1516171819 snapshot_capable?: boolean;20}2122export interface AdoptGrant {23 type: "ADOPT_GRANT";24 root_key_hex: string;25 vault_id: string;26 license?: string;27 source: {28 device_id: string;29 device_name: string;30 device_type: DeviceType;31 long_term_public_key_hex: string;32 signing_public_key_hex?: string;33 ws_port?: number;34 relay_url?: string;35 };36 3738394041 snapshot_capable?: boolean;42}4344export interface AdoptConfirm {45 type: "ADOPT_CONFIRM";46 confirmed: boolean;47}4849export interface AdoptIdentity {50 type: "ADOPT_IDENTITY";51 device_id: string;52 device_name: string;53 device_type: DeviceType;54 long_term_public_key_hex: string;55 signing_public_key_hex?: string;56}57585960616263export interface AdoptComplete {64 type: "ADOPT_COMPLETE";65}666768export interface AdoptApplied {69 type: "ADOPT_APPLIED";70}717273export interface AdoptReject {74 type: "ADOPT_REJECT";75 reason: "ADOPT_PRO_REQUIRED";76}77787980818283export interface AdoptSnapshot {84 type: "ADOPT_SNAPSHOT";85 chunk_index: number;86 chunk_count: number;87 entries: Record<string, string>;88 89 tombstones: Record<string, number>;90 9192939495 duress_slot?: string;96}979899export interface AdoptSnapshotApplied {100 type: "ADOPT_SNAPSHOT_APPLIED";101 entries_received: number;102 tombstones_received: number;103}104105106export interface AdoptDeviceManifestFrame {107 type: "ADOPT_DEVICE_MANIFEST";108 manifest: SignedDeviceManifest;109}110111112export interface AdoptDeviceManifestApplied {113 type: "ADOPT_DEVICE_MANIFEST_APPLIED";114}115116export type AdoptionFrame =117 | AdoptRequest118 | AdoptConfirm119 | AdoptGrant120 | AdoptIdentity121 | AdoptComplete122 | AdoptApplied123 | AdoptReject124 | AdoptSnapshot125 | AdoptSnapshotApplied126 | AdoptDeviceManifestFrame127 | AdoptDeviceManifestApplied;128129130131132133134135export const MAX_SNAPSHOT_CHUNK_BYTES = 512 * 1024; 136export const MAX_SNAPSHOT_CHUNKS = 200;137138139export const SNAPSHOT_FRAME_WIRE_CEILING = MAX_SNAPSHOT_CHUNK_BYTES + 256 * 1024;140141142143144145export const MAX_PADDED_SNAPSHOT_FRAME_BYTES = syncFrameBucket(146 SNAPSHOT_FRAME_WIRE_CEILING + 4,147);148149150151152153154155156157158export const MAX_ADOPT_SNAPSHOT_BYTES = 10 * 1024 * 1024; 159export const MAX_ADOPT_SNAPSHOT_ENTRIES = 25_000;160export const MAX_ADOPT_SNAPSHOT_TOMBSTONES = 25_000;161162export const MAX_ADOPT_SNAPSHOT_ID_CHARS = 512;163164165166167export const MAX_ADOPT_SNAPSHOT_ENVELOPE_BYTES =168 (SYNC_LIMITS.MAX_ENTRY_PLAINTEXT_BYTES * 4) / 3 + 64;169170171172173174export interface AdoptSnapshotBudget {175 entries: Record<string, string>;176 tombstones: Record<string, number>;177 duressSlot: string | null;178 179 bytes: number;180 entryCount: number;181 tombstoneCount: number;182}183184export function createAdoptSnapshotBudget(): AdoptSnapshotBudget {185 return { entries: {}, tombstones: {}, duressSlot: null, bytes: 0, entryCount: 0, tombstoneCount: 0 };186}187188189190191192193194195196197198export function applyAdoptSnapshotFrame(199 acc: AdoptSnapshotBudget,200 entries: Record<string, string>,201 tombstones: Record<string, number>,202 duressSlot: string | null | undefined,203 frameBytes: number,204): void {205 if (frameBytes > MAX_ADOPT_SNAPSHOT_BYTES - acc.bytes) {206 throw new Error("ADOPT_SNAPSHOT_OVER_BUDGET");207 }208 for (const [id, envelope] of Object.entries(entries)) {209 if (id.length > MAX_ADOPT_SNAPSHOT_ID_CHARS) throw new Error("ADOPT_SNAPSHOT_ID_TOO_LONG");210 if (envelope.length > MAX_ADOPT_SNAPSHOT_ENVELOPE_BYTES) {211 throw new Error("ADOPT_SNAPSHOT_ENVELOPE_TOO_LARGE");212 }213 if (acc.entryCount >= MAX_ADOPT_SNAPSHOT_ENTRIES) {214 throw new Error("ADOPT_SNAPSHOT_TOO_MANY_ENTRIES");215 }216 acc.entryCount++;217 acc.entries[id] = envelope;218 }219 for (const [id, ts] of Object.entries(tombstones)) {220 if (id.length > MAX_ADOPT_SNAPSHOT_ID_CHARS) throw new Error("ADOPT_SNAPSHOT_ID_TOO_LONG");221 if (typeof ts !== "number" || !Number.isFinite(ts)) {222 throw new Error("ADOPT_SNAPSHOT_INVALID_TOMBSTONE");223 }224 if (acc.tombstoneCount >= MAX_ADOPT_SNAPSHOT_TOMBSTONES) {225 throw new Error("ADOPT_SNAPSHOT_TOO_MANY_TOMBSTONES");226 }227 acc.tombstoneCount++;228 acc.tombstones[id] = ts;229 }230 if (typeof duressSlot === "string" && duressSlot.length > 0) {231 232 233 if (duressSlot.length > SNAPSHOT_FRAME_WIRE_CEILING) {234 throw new Error("ADOPT_SNAPSHOT_DURESS_FRAME_TOO_LARGE");235 }236 acc.duressSlot = duressSlot;237 }238 acc.bytes += frameBytes;239}240241export interface AdoptionPairState {242 rendezvousSecretHex: string;243 relayUrl?: string;244}245246export interface AdoptionReceiverDeps {247 deviceName: string;248 generateDeviceId(): string;249 250 confirmSas(): Promise<boolean>;251 252253254255 validateSource?(source: AdoptGrant["source"]): Promise<void>;256 createVaultFromRootKey(rootKeyHex: string): Promise<{ vaultId: string }>;257 deriveLocalIdentity(): Promise<LocalIdentity>;258 upsertPairedDevice(device: PairedDevice): Promise<void>;259 applyLicense?(license: string): Promise<void>;260 261 license?(): Promise<string | undefined>;262 263 pairState?: AdoptionPairState;264 onError(error: string): void;265}266267export interface AdoptionSourceDeps {268 sourceIdentity: LocalIdentity;269 rootKeyHex(): Promise<string>;270 vaultId(): Promise<string>;271 license?(): Promise<string | undefined>;272 273 applyLicense?(license: string): Promise<void>;274 onConsent(request: AdoptRequest): Promise<boolean>;275 upsertPairedDevice(device: PairedDevice): Promise<void>;276 277 pairState?: AdoptionPairState;278}279280export function isAdoptRequest(frame: unknown): frame is AdoptRequest {281 const value = frame as Partial<AdoptRequest> | null;282 return value?.type === "ADOPT_REQUEST" &&283 typeof value.device_id === "string" &&284 value.device_id.length > 0 &&285 typeof value.device_name === "string" &&286 isDeviceType(value.device_type) &&287 (value.license === undefined || typeof value.license === "string");288}289290export function isAdoptGrant(frame: unknown): frame is AdoptGrant {291 const value = frame as Partial<AdoptGrant> | null;292 return value?.type === "ADOPT_GRANT" &&293 validateRootKeyHex(value.root_key_hex) &&294 typeof value.vault_id === "string" &&295 /^[0-9a-f]{64}$/.test(value.vault_id) &&296 typeof value.source === "object" &&297 value.source !== null &&298 typeof value.source.device_id === "string" &&299 typeof value.source.device_name === "string" &&300 isDeviceType(value.source.device_type) &&301 typeof value.source.long_term_public_key_hex === "string" &&302 /^[0-9a-f]{64}$/.test(value.source.long_term_public_key_hex) &&303 (value.source.relay_url === undefined ||304 (typeof value.source.relay_url === "string" &&305 isSecureRelayUrl(value.source.relay_url))) &&306 (value.snapshot_capable === undefined ||307 typeof value.snapshot_capable === "boolean");308}309310export function isAdoptConfirm(frame: unknown): frame is AdoptConfirm {311 const value = frame as Partial<AdoptConfirm> | null;312 return value?.type === "ADOPT_CONFIRM" &&313 typeof value.confirmed === "boolean";314}315316export function isAdoptIdentity(frame: unknown): frame is AdoptIdentity {317 const value = frame as Partial<AdoptIdentity> | null;318 return value?.type === "ADOPT_IDENTITY" &&319 typeof value.device_id === "string" &&320 value.device_id.length > 0 &&321 typeof value.device_name === "string" &&322 isDeviceType(value.device_type) &&323 typeof value.long_term_public_key_hex === "string" &&324 /^[0-9a-f]{64}$/.test(value.long_term_public_key_hex);325}326327export function isAdoptComplete(frame: unknown): frame is AdoptComplete {328 return (frame as Partial<AdoptComplete> | null)?.type === "ADOPT_COMPLETE";329}330331export function isAdoptApplied(frame: unknown): frame is AdoptApplied {332 return (frame as Partial<AdoptApplied> | null)?.type === "ADOPT_APPLIED";333}334335export function isAdoptReject(frame: unknown): frame is AdoptReject {336 const value = frame as Partial<AdoptReject> | null;337 return value?.type === "ADOPT_REJECT" &&338 value.reason === "ADOPT_PRO_REQUIRED";339}340341export function isAdoptSnapshot(frame: unknown): frame is AdoptSnapshot {342 const value = frame as Partial<AdoptSnapshot> | null;343 if (value?.type !== "ADOPT_SNAPSHOT") return false;344 if (345 typeof value.chunk_index !== "number" ||346 !Number.isInteger(value.chunk_index) ||347 value.chunk_index < 0348 ) return false;349 if (350 typeof value.chunk_count !== "number" ||351 !Number.isInteger(value.chunk_count) ||352 value.chunk_count < 1353 ) return false;354 if (!isStringRecord(value.entries)) return false;355 if (!isNumberRecord(value.tombstones)) return false;356 if (value.duress_slot !== undefined && typeof value.duress_slot !== "string") return false;357 return true;358}359360export function isAdoptSnapshotApplied(frame: unknown): frame is AdoptSnapshotApplied {361 const value = frame as Partial<AdoptSnapshotApplied> | null;362 return value?.type === "ADOPT_SNAPSHOT_APPLIED" &&363 typeof value.entries_received === "number" &&364 typeof value.tombstones_received === "number";365}366367export function isAdoptDeviceManifestFrame(368 frame: unknown,369): frame is AdoptDeviceManifestFrame {370 const value = frame as Partial<AdoptDeviceManifestFrame> | null;371 const manifest = value?.manifest as372 | { body?: unknown; signatureHex?: unknown }373 | null;374 return value?.type === "ADOPT_DEVICE_MANIFEST" &&375 typeof manifest === "object" &&376 manifest !== null &&377 typeof manifest.body === "object" &&378 manifest.body !== null &&379 typeof manifest.signatureHex === "string";380}381382function isStringRecord(value: unknown): value is Record<string, string> {383 if (typeof value !== "object" || value === null) return false;384 return Object.values(value).every((v) => typeof v === "string");385}386387function isNumberRecord(value: unknown): value is Record<string, number> {388 if (typeof value !== "object" || value === null) return false;389 return Object.values(value).every((v) => typeof v === "number");390}391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426export async function sendVaultSnapshot(427 channel: PairingTransport,428 entries: Record<string, string>,429 tombstones: Record<string, number>,430 duressSlot: string | null = null,431): Promise<void> {432 const frames = buildAdoptSnapshotFrames(entries, tombstones, duressSlot);433 434 435 436 437 438 const acc = createAdoptSnapshotBudget();439 const encoded: Uint8Array[] = frames.map((frame) =>440 new TextEncoder().encode(JSON.stringify(frame))441 );442 for (let index = 0; index < frames.length; index++) {443 const frame = frames[index];444 applyAdoptSnapshotFrame(445 acc,446 frame.entries,447 frame.tombstones,448 frame.duress_slot,449 encoded[index].length,450 );451 }452 453 454 455 for (const raw of encoded) await channel.send(padSyncFrame(raw));456}457458459460461462463function buildAdoptSnapshotFrames(464 entries: Record<string, string>,465 tombstones: Record<string, number>,466 duressSlot: string | null,467): AdoptSnapshot[] {468 const includeDuress = !!duressSlot;469 470 471 472 const entryCap = includeDuress ? MAX_SNAPSHOT_CHUNKS - 1 : MAX_SNAPSHOT_CHUNKS;473474 const chunks: Array<Record<string, string>> = [];475 let current: Record<string, string> = {};476 let currentCount = 0;477 let currentBytes = 2; 478 for (const [id, envelope] of Object.entries(entries)) {479 480 const entryBytes = JSON.stringify({ [id]: envelope }).length + 1;481 if (currentCount > 0 && currentBytes + entryBytes >= MAX_SNAPSHOT_CHUNK_BYTES) {482 chunks.push(current);483 current = {};484 currentCount = 0;485 currentBytes = 2;486 }487 current[id] = envelope;488 currentCount++;489 currentBytes += entryBytes;490 }491 chunks.push(current);492493 const entriesChunkCount = Math.min(chunks.length, entryCap);494 const sendDuress = includeDuress;495 const totalChunks = entriesChunkCount + (sendDuress ? 1 : 0);496 const duressFrame: AdoptSnapshot | null = sendDuress497 ? {498 type: "ADOPT_SNAPSHOT",499 chunk_index: entriesChunkCount,500 chunk_count: totalChunks,501 entries: {},502 tombstones,503 duress_slot: duressSlot as string,504 }505 : null;506507 if (508 duressFrame &&509 new TextEncoder().encode(JSON.stringify(duressFrame)).length >510 SNAPSHOT_FRAME_WIRE_CEILING511 ) {512 throw new Error("ADOPT_SNAPSHOT_DURESS_FRAME_TOO_LARGE");513 }514515 const frames: AdoptSnapshot[] = [];516 for (let index = 0; index < entriesChunkCount; index++) {517 const isFinalEntries = index === entriesChunkCount - 1;518 frames.push({519 type: "ADOPT_SNAPSHOT",520 chunk_index: index,521 chunk_count: totalChunks,522 entries: chunks[index],523 524 525 tombstones: isFinalEntries && !sendDuress ? tombstones : {},526 });527 }528529 if (duressFrame) frames.push(duressFrame);530 return frames;531}532533534535536537538539540541542543544export async function receiveVaultSnapshot(545 channel: PairingTransport,546): Promise<{ entries: Record<string, string>; tombstones: Record<string, number>; duressSlot: string | null }> {547 const acc = createAdoptSnapshotBudget();548 let chunkCount = -1;549 let received = 0;550 while (chunkCount === -1 || received < chunkCount) {551 const wire = await channel.receive();552 if (wire.length > MAX_PADDED_SNAPSHOT_FRAME_BYTES) {553 throw new Error("ADOPT_SNAPSHOT_CHUNK_TOO_LARGE");554 }555 556 557 const raw = unpadSyncFrame(wire);558 if (raw.length > MAX_ADOPT_SNAPSHOT_BYTES - acc.bytes) {559 throw new Error("ADOPT_SNAPSHOT_OVER_BUDGET");560 }561 if (raw.length > SNAPSHOT_FRAME_WIRE_CEILING) {562 throw new Error("ADOPT_SNAPSHOT_CHUNK_TOO_LARGE");563 }564 const frame: unknown = JSON.parse(new TextDecoder().decode(raw));565 if (!isAdoptSnapshot(frame)) throw new Error("ADOPT_SNAPSHOT_INVALID_FRAME");566 if (chunkCount === -1) {567 chunkCount = frame.chunk_count;568 if (chunkCount > MAX_SNAPSHOT_CHUNKS) {569 throw new Error("ADOPT_SNAPSHOT_TOO_MANY_CHUNKS");570 }571 } else if (frame.chunk_count !== chunkCount) {572 throw new Error("ADOPT_SNAPSHOT_CHUNK_COUNT_MISMATCH");573 }574 if (frame.chunk_index !== received) {575 throw new Error("ADOPT_SNAPSHOT_CHUNK_OUT_OF_ORDER");576 }577 applyAdoptSnapshotFrame(acc, frame.entries, frame.tombstones, frame.duress_slot, raw.length);578 received++;579 }580 return { entries: acc.entries, tombstones: acc.tombstones, duressSlot: acc.duressSlot };581}582583export function validateRootKeyHex(rootKeyHex: unknown): rootKeyHex is string {584 return typeof rootKeyHex === "string" && /^[0-9a-f]{64}$/.test(rootKeyHex);585}586587export interface AdoptionRequestResult {588 device: PairedDevice;589 590 snapshotCapable: boolean;591}592593export async function requestAdoption(594 channel: PairingTransport,595 deps: AdoptionReceiverDeps,596): Promise<AdoptionRequestResult | null> {597 const receiverLicense = await deps.license?.();598 await sendFrame(channel, {599 type: "ADOPT_REQUEST",600 device_id: deps.generateDeviceId(),601 device_name: deps.deviceName,602 device_type: "extension",603 license: receiverLicense,604 snapshot_capable: true,605 });606607 const confirmed = await deps.confirmSas();608 await sendFrame(channel, {609 type: "ADOPT_CONFIRM",610 confirmed,611 });612 if (!confirmed) {613 deps.onError("ADOPT_SAS_REJECTED");614 return null;615 }616617 const frame = await receiveFrame(channel);618 if (isAdoptReject(frame)) {619 deps.onError(frame.reason);620 return null;621 }622 if (!isAdoptGrant(frame)) {623 deps.onError("ADOPT_UNEXPECTED_FRAME");624 return null;625 }626 const grantSnapshotCapable = frame.snapshot_capable === true;627628 const rootKeyBytes = hexToBytes(frame.root_key_hex);629 try {630 const computedVaultId = await computeVaultId(rootKeyBytes);631 if (computedVaultId !== frame.vault_id) {632 deps.onError("ADOPT_VAULT_ID_MISMATCH");633 return null;634 }635 } finally {636 rootKeyBytes.fill(0);637 }638 if (!receiverLicense && !frame.license) {639 deps.onError("ADOPT_PRO_REQUIRED");640 return null;641 }642643 await deps.validateSource?.(frame.source);644 const { vaultId } = await deps.createVaultFromRootKey(frame.root_key_hex);645 if (vaultId !== frame.vault_id) {646 deps.onError("ADOPT_VAULT_ID_MISMATCH");647 return null;648 }649650 if (!receiverLicense && frame.license) {651 await deps.applyLicense?.(frame.license);652 }653654 const identity = await deps.deriveLocalIdentity();655 await sendFrame(channel, {656 type: "ADOPT_IDENTITY",657 device_id: identity.deviceId,658 device_name: identity.deviceName,659 device_type: identity.deviceType,660 long_term_public_key_hex: identity.longTermPublicKeyHex,661 signing_public_key_hex: identity.signingPublicKeyHex,662 });663664 const completion = await receiveFrame(channel);665 if (!isAdoptComplete(completion)) {666 deps.onError("ADOPT_COMPLETION_MISSING");667 return null;668 }669670 const sourceDevice: PairedDevice = {671 deviceId: frame.source.device_id,672 deviceName: capDeviceName(frame.source.device_name),673 deviceType: frame.source.device_type,674 longTermPublicKeyHex: frame.source.long_term_public_key_hex,675 signingPublicKeyHex: frame.source.signing_public_key_hex,676 trustSource: "direct",677 wsPort: frame.source.ws_port,678 rendezvousSecretHex: deps.pairState?.rendezvousSecretHex,679 relayUrl: deps.pairState?.relayUrl ?? frame.source.relay_url,680 pairedAt: Date.now(),681 };682 await deps.upsertPairedDevice(sourceDevice);683 await sendFrame(channel, { type: "ADOPT_APPLIED" });684 return { device: sourceDevice, snapshotCapable: grantSnapshotCapable };685}686687export async function grantAdoption(688 channel: PairingTransport,689 request: AdoptRequest,690 deps: AdoptionSourceDeps,691): Promise<PairedDevice | null> {692 const approved = await deps.onConsent(request);693 if (!approved) return null;694695 const confirmation = await receiveFrame(channel);696 if (!isAdoptConfirm(confirmation) || !confirmation.confirmed) return null;697698 let sourceLicense = await deps.license?.();699 if (!sourceLicense && request.license) {700 try {701 if (!deps.applyLicense) throw new Error("license_transfer_unavailable");702 await deps.applyLicense(request.license);703 sourceLicense = await deps.license?.();704 } catch {705 sourceLicense = undefined;706 }707 }708 if (!sourceLicense) {709 await sendFrame(channel, {710 type: "ADOPT_REJECT",711 reason: "ADOPT_PRO_REQUIRED",712 });713 throw new Error("ADOPT_PRO_REQUIRED");714 }715716 const rootKeyHex = await deps.rootKeyHex();717 if (!validateRootKeyHex(rootKeyHex)) throw new Error("ADOPT_INVALID_ROOT_KEY");718719 await sendFrame(channel, {720 type: "ADOPT_GRANT",721 root_key_hex: rootKeyHex,722 vault_id: await deps.vaultId(),723 license: sourceLicense,724 source: {725 device_id: deps.sourceIdentity.deviceId,726 device_name: deps.sourceIdentity.deviceName,727 device_type: deps.sourceIdentity.deviceType,728 long_term_public_key_hex: deps.sourceIdentity.longTermPublicKeyHex,729 signing_public_key_hex: deps.sourceIdentity.signingPublicKeyHex,730 ws_port: deps.sourceIdentity.wsPort,731 relay_url: deps.pairState?.relayUrl,732 },733 snapshot_capable: request.snapshot_capable === true,734 });735736 const identity = await receiveFrame(channel);737 if (!isAdoptIdentity(identity)) return null;738739 const device: PairedDevice = {740 deviceId: identity.device_id,741 deviceName: capDeviceName(identity.device_name),742 deviceType: identity.device_type,743 longTermPublicKeyHex: identity.long_term_public_key_hex,744 signingPublicKeyHex: identity.signing_public_key_hex,745 trustSource: "direct",746 rendezvousSecretHex: deps.pairState?.rendezvousSecretHex,747 relayUrl: deps.pairState?.relayUrl,748 pairedAt: Date.now(),749 };750 await deps.upsertPairedDevice(device);751 await sendFrame(channel, { type: "ADOPT_COMPLETE" });752 if (!isAdoptApplied(await receiveFrame(channel))) return null;753 return device;754}755756export async function sendFrame(channel: PairingTransport, frame: AdoptionFrame): Promise<void> {757 await channel.send(new TextEncoder().encode(JSON.stringify(frame)));758}759760export async function receiveFrame(channel: PairingTransport): Promise<unknown> {761 return JSON.parse(new TextDecoder().decode(await channel.receive()));762}763764function isDeviceType(value: unknown): value is DeviceType {765 return value === "extension" || value === "android" || value === "ios";766}767768function isSecureRelayUrl(value: string): boolean {769 try {770 const url = new URL(value);771 return url.protocol === "wss:" && !url.username && !url.password &&772 !url.search && !url.hash;773 } catch {774 return false;775 }776}777778function hexToBytes(hex: string): Uint8Array {779 const out = new Uint8Array(hex.length / 2);780 for (let i = 0; i < out.length; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);781 return out;782}