1import { SYNC_LIMITS } from "./sync-limits";2import { computeVaultId } from "./sync-identity";3import type { DeviceType, PairedDevice, SignedDeviceManifest } from "./sync-types";4import type { LocalIdentity, PairingTransport } from "./pairing-handshake";56export interface AdoptRequest {7 type: "ADOPT_REQUEST";8 device_id: string;9 device_name: string;10 device_type: DeviceType;11 12 license?: string;13 1415161718 snapshot_capable?: boolean;19}2021export interface AdoptGrant {22 type: "ADOPT_GRANT";23 root_key_hex: string;24 vault_id: string;25 license?: string;26 source: {27 device_id: string;28 device_name: string;29 device_type: DeviceType;30 long_term_public_key_hex: string;31 signing_public_key_hex?: string;32 ws_port?: number;33 relay_url?: string;34 };35 3637383940 snapshot_capable?: boolean;41}4243export interface AdoptConfirm {44 type: "ADOPT_CONFIRM";45 confirmed: boolean;46}4748export interface AdoptIdentity {49 type: "ADOPT_IDENTITY";50 device_id: string;51 device_name: string;52 device_type: DeviceType;53 long_term_public_key_hex: string;54 signing_public_key_hex?: string;55}56575859606162export interface AdoptComplete {63 type: "ADOPT_COMPLETE";64}656667export interface AdoptApplied {68 type: "ADOPT_APPLIED";69}707172export interface AdoptReject {73 type: "ADOPT_REJECT";74 reason: "ADOPT_PRO_REQUIRED";75}76777879808182export interface AdoptSnapshot {83 type: "ADOPT_SNAPSHOT";84 chunk_index: number;85 chunk_count: number;86 entries: Record<string, string>;87 88 tombstones: Record<string, number>;89 9091929394 duress_slot?: string;95}969798export interface AdoptSnapshotApplied {99 type: "ADOPT_SNAPSHOT_APPLIED";100 entries_received: number;101 tombstones_received: number;102}103104105export interface AdoptDeviceManifestFrame {106 type: "ADOPT_DEVICE_MANIFEST";107 manifest: SignedDeviceManifest;108}109110111export interface AdoptDeviceManifestApplied {112 type: "ADOPT_DEVICE_MANIFEST_APPLIED";113}114115export type AdoptionFrame =116 | AdoptRequest117 | AdoptConfirm118 | AdoptGrant119 | AdoptIdentity120 | AdoptComplete121 | AdoptApplied122 | AdoptReject123 | AdoptSnapshot124 | AdoptSnapshotApplied125 | AdoptDeviceManifestFrame126 | AdoptDeviceManifestApplied;127128129130131132133134export const MAX_SNAPSHOT_CHUNK_BYTES = 512 * 1024; 135export const MAX_SNAPSHOT_CHUNKS = 200;136137138export const SNAPSHOT_FRAME_WIRE_CEILING = MAX_SNAPSHOT_CHUNK_BYTES + 256 * 1024;139140141142143144145146147148149export const MAX_ADOPT_SNAPSHOT_BYTES = 10 * 1024 * 1024; 150export const MAX_ADOPT_SNAPSHOT_ENTRIES = 25_000;151export const MAX_ADOPT_SNAPSHOT_TOMBSTONES = 25_000;152153export const MAX_ADOPT_SNAPSHOT_ID_CHARS = 512;154155156157158export const MAX_ADOPT_SNAPSHOT_ENVELOPE_BYTES =159 (SYNC_LIMITS.MAX_ENTRY_PLAINTEXT_BYTES * 4) / 3 + 64;160161162163164165export interface AdoptSnapshotBudget {166 entries: Record<string, string>;167 tombstones: Record<string, number>;168 duressSlot: string | null;169 170 bytes: number;171 entryCount: number;172 tombstoneCount: number;173}174175export function createAdoptSnapshotBudget(): AdoptSnapshotBudget {176 return { entries: {}, tombstones: {}, duressSlot: null, bytes: 0, entryCount: 0, tombstoneCount: 0 };177}178179180181182183184185186187188189export function applyAdoptSnapshotFrame(190 acc: AdoptSnapshotBudget,191 entries: Record<string, string>,192 tombstones: Record<string, number>,193 duressSlot: string | null | undefined,194 frameBytes: number,195): void {196 if (frameBytes > MAX_ADOPT_SNAPSHOT_BYTES - acc.bytes) {197 throw new Error("ADOPT_SNAPSHOT_OVER_BUDGET");198 }199 for (const [id, envelope] of Object.entries(entries)) {200 if (id.length > MAX_ADOPT_SNAPSHOT_ID_CHARS) throw new Error("ADOPT_SNAPSHOT_ID_TOO_LONG");201 if (envelope.length > MAX_ADOPT_SNAPSHOT_ENVELOPE_BYTES) {202 throw new Error("ADOPT_SNAPSHOT_ENVELOPE_TOO_LARGE");203 }204 if (acc.entryCount >= MAX_ADOPT_SNAPSHOT_ENTRIES) {205 throw new Error("ADOPT_SNAPSHOT_TOO_MANY_ENTRIES");206 }207 acc.entryCount++;208 acc.entries[id] = envelope;209 }210 for (const [id, ts] of Object.entries(tombstones)) {211 if (id.length > MAX_ADOPT_SNAPSHOT_ID_CHARS) throw new Error("ADOPT_SNAPSHOT_ID_TOO_LONG");212 if (typeof ts !== "number" || !Number.isFinite(ts)) {213 throw new Error("ADOPT_SNAPSHOT_INVALID_TOMBSTONE");214 }215 if (acc.tombstoneCount >= MAX_ADOPT_SNAPSHOT_TOMBSTONES) {216 throw new Error("ADOPT_SNAPSHOT_TOO_MANY_TOMBSTONES");217 }218 acc.tombstoneCount++;219 acc.tombstones[id] = ts;220 }221 if (typeof duressSlot === "string" && duressSlot.length > 0) {222 223 224 if (duressSlot.length > SNAPSHOT_FRAME_WIRE_CEILING) {225 throw new Error("ADOPT_SNAPSHOT_DURESS_FRAME_TOO_LARGE");226 }227 acc.duressSlot = duressSlot;228 }229 acc.bytes += frameBytes;230}231232export interface AdoptionPairState {233 rendezvousSecretHex: string;234 relayUrl?: string;235}236237export interface AdoptionReceiverDeps {238 deviceName: string;239 generateDeviceId(): string;240 241 confirmSas(): Promise<boolean>;242 243244245246 validateSource?(source: AdoptGrant["source"]): Promise<void>;247 createVaultFromRootKey(rootKeyHex: string): Promise<{ vaultId: string }>;248 deriveLocalIdentity(): Promise<LocalIdentity>;249 upsertPairedDevice(device: PairedDevice): Promise<void>;250 applyLicense?(license: string): Promise<void>;251 252 license?(): Promise<string | undefined>;253 254 pairState?: AdoptionPairState;255 onError(error: string): void;256}257258export interface AdoptionSourceDeps {259 sourceIdentity: LocalIdentity;260 rootKeyHex(): Promise<string>;261 vaultId(): Promise<string>;262 license?(): Promise<string | undefined>;263 264 applyLicense?(license: string): Promise<void>;265 onConsent(request: AdoptRequest): Promise<boolean>;266 upsertPairedDevice(device: PairedDevice): Promise<void>;267 268 pairState?: AdoptionPairState;269}270271export function isAdoptRequest(frame: unknown): frame is AdoptRequest {272 const value = frame as Partial<AdoptRequest> | null;273 return value?.type === "ADOPT_REQUEST" &&274 typeof value.device_id === "string" &&275 value.device_id.length > 0 &&276 typeof value.device_name === "string" &&277 isDeviceType(value.device_type) &&278 (value.license === undefined || typeof value.license === "string");279}280281export function isAdoptGrant(frame: unknown): frame is AdoptGrant {282 const value = frame as Partial<AdoptGrant> | null;283 return value?.type === "ADOPT_GRANT" &&284 validateRootKeyHex(value.root_key_hex) &&285 typeof value.vault_id === "string" &&286 /^[0-9a-f]{64}$/.test(value.vault_id) &&287 typeof value.source === "object" &&288 value.source !== null &&289 typeof value.source.device_id === "string" &&290 typeof value.source.device_name === "string" &&291 isDeviceType(value.source.device_type) &&292 typeof value.source.long_term_public_key_hex === "string" &&293 /^[0-9a-f]{64}$/.test(value.source.long_term_public_key_hex) &&294 (value.source.relay_url === undefined ||295 (typeof value.source.relay_url === "string" &&296 isSecureRelayUrl(value.source.relay_url))) &&297 (value.snapshot_capable === undefined ||298 typeof value.snapshot_capable === "boolean");299}300301export function isAdoptConfirm(frame: unknown): frame is AdoptConfirm {302 const value = frame as Partial<AdoptConfirm> | null;303 return value?.type === "ADOPT_CONFIRM" &&304 typeof value.confirmed === "boolean";305}306307export function isAdoptIdentity(frame: unknown): frame is AdoptIdentity {308 const value = frame as Partial<AdoptIdentity> | null;309 return value?.type === "ADOPT_IDENTITY" &&310 typeof value.device_id === "string" &&311 value.device_id.length > 0 &&312 typeof value.device_name === "string" &&313 isDeviceType(value.device_type) &&314 typeof value.long_term_public_key_hex === "string" &&315 /^[0-9a-f]{64}$/.test(value.long_term_public_key_hex);316}317318export function isAdoptComplete(frame: unknown): frame is AdoptComplete {319 return (frame as Partial<AdoptComplete> | null)?.type === "ADOPT_COMPLETE";320}321322export function isAdoptApplied(frame: unknown): frame is AdoptApplied {323 return (frame as Partial<AdoptApplied> | null)?.type === "ADOPT_APPLIED";324}325326export function isAdoptReject(frame: unknown): frame is AdoptReject {327 const value = frame as Partial<AdoptReject> | null;328 return value?.type === "ADOPT_REJECT" &&329 value.reason === "ADOPT_PRO_REQUIRED";330}331332export function isAdoptSnapshot(frame: unknown): frame is AdoptSnapshot {333 const value = frame as Partial<AdoptSnapshot> | null;334 if (value?.type !== "ADOPT_SNAPSHOT") return false;335 if (336 typeof value.chunk_index !== "number" ||337 !Number.isInteger(value.chunk_index) ||338 value.chunk_index < 0339 ) return false;340 if (341 typeof value.chunk_count !== "number" ||342 !Number.isInteger(value.chunk_count) ||343 value.chunk_count < 1344 ) return false;345 if (!isStringRecord(value.entries)) return false;346 if (!isNumberRecord(value.tombstones)) return false;347 if (value.duress_slot !== undefined && typeof value.duress_slot !== "string") return false;348 return true;349}350351export function isAdoptSnapshotApplied(frame: unknown): frame is AdoptSnapshotApplied {352 const value = frame as Partial<AdoptSnapshotApplied> | null;353 return value?.type === "ADOPT_SNAPSHOT_APPLIED" &&354 typeof value.entries_received === "number" &&355 typeof value.tombstones_received === "number";356}357358export function isAdoptDeviceManifestFrame(359 frame: unknown,360): frame is AdoptDeviceManifestFrame {361 const value = frame as Partial<AdoptDeviceManifestFrame> | null;362 const manifest = value?.manifest as363 | { body?: unknown; signatureHex?: unknown }364 | null;365 return value?.type === "ADOPT_DEVICE_MANIFEST" &&366 typeof manifest === "object" &&367 manifest !== null &&368 typeof manifest.body === "object" &&369 manifest.body !== null &&370 typeof manifest.signatureHex === "string";371}372373function isStringRecord(value: unknown): value is Record<string, string> {374 if (typeof value !== "object" || value === null) return false;375 return Object.values(value).every((v) => typeof v === "string");376}377378function isNumberRecord(value: unknown): value is Record<string, number> {379 if (typeof value !== "object" || value === null) return false;380 return Object.values(value).every((v) => typeof v === "number");381}382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417export async function sendVaultSnapshot(418 channel: PairingTransport,419 entries: Record<string, string>,420 tombstones: Record<string, number>,421 duressSlot: string | null = null,422): Promise<void> {423 const frames = buildAdoptSnapshotFrames(entries, tombstones, duressSlot);424 425 426 427 428 429 const acc = createAdoptSnapshotBudget();430 const encoded: Uint8Array[] = frames.map((frame) =>431 new TextEncoder().encode(JSON.stringify(frame))432 );433 for (let index = 0; index < frames.length; index++) {434 const frame = frames[index];435 applyAdoptSnapshotFrame(436 acc,437 frame.entries,438 frame.tombstones,439 frame.duress_slot,440 encoded[index].length,441 );442 }443 for (const raw of encoded) await channel.send(raw);444}445446447448449450451function buildAdoptSnapshotFrames(452 entries: Record<string, string>,453 tombstones: Record<string, number>,454 duressSlot: string | null,455): AdoptSnapshot[] {456 const includeDuress = !!duressSlot;457 458 459 460 const entryCap = includeDuress ? MAX_SNAPSHOT_CHUNKS - 1 : MAX_SNAPSHOT_CHUNKS;461462 const chunks: Array<Record<string, string>> = [];463 let current: Record<string, string> = {};464 let currentCount = 0;465 let currentBytes = 2; 466 for (const [id, envelope] of Object.entries(entries)) {467 468 const entryBytes = JSON.stringify({ [id]: envelope }).length + 1;469 if (currentCount > 0 && currentBytes + entryBytes >= MAX_SNAPSHOT_CHUNK_BYTES) {470 chunks.push(current);471 current = {};472 currentCount = 0;473 currentBytes = 2;474 }475 current[id] = envelope;476 currentCount++;477 currentBytes += entryBytes;478 }479 chunks.push(current);480481 const entriesChunkCount = Math.min(chunks.length, entryCap);482 const sendDuress = includeDuress;483 const totalChunks = entriesChunkCount + (sendDuress ? 1 : 0);484 const duressFrame: AdoptSnapshot | null = sendDuress485 ? {486 type: "ADOPT_SNAPSHOT",487 chunk_index: entriesChunkCount,488 chunk_count: totalChunks,489 entries: {},490 tombstones,491 duress_slot: duressSlot as string,492 }493 : null;494495 if (496 duressFrame &&497 new TextEncoder().encode(JSON.stringify(duressFrame)).length >498 SNAPSHOT_FRAME_WIRE_CEILING499 ) {500 throw new Error("ADOPT_SNAPSHOT_DURESS_FRAME_TOO_LARGE");501 }502503 const frames: AdoptSnapshot[] = [];504 for (let index = 0; index < entriesChunkCount; index++) {505 const isFinalEntries = index === entriesChunkCount - 1;506 frames.push({507 type: "ADOPT_SNAPSHOT",508 chunk_index: index,509 chunk_count: totalChunks,510 entries: chunks[index],511 512 513 tombstones: isFinalEntries && !sendDuress ? tombstones : {},514 });515 }516517 if (duressFrame) frames.push(duressFrame);518 return frames;519}520521522523524525526527528529530531532export async function receiveVaultSnapshot(533 channel: PairingTransport,534): Promise<{ entries: Record<string, string>; tombstones: Record<string, number>; duressSlot: string | null }> {535 const acc = createAdoptSnapshotBudget();536 let chunkCount = -1;537 let received = 0;538 while (chunkCount === -1 || received < chunkCount) {539 const raw = await channel.receive();540 if (raw.length > MAX_ADOPT_SNAPSHOT_BYTES - acc.bytes) {541 throw new Error("ADOPT_SNAPSHOT_OVER_BUDGET");542 }543 if (raw.length > SNAPSHOT_FRAME_WIRE_CEILING) {544 throw new Error("ADOPT_SNAPSHOT_CHUNK_TOO_LARGE");545 }546 const frame: unknown = JSON.parse(new TextDecoder().decode(raw));547 if (!isAdoptSnapshot(frame)) throw new Error("ADOPT_SNAPSHOT_INVALID_FRAME");548 if (chunkCount === -1) {549 chunkCount = frame.chunk_count;550 if (chunkCount > MAX_SNAPSHOT_CHUNKS) {551 throw new Error("ADOPT_SNAPSHOT_TOO_MANY_CHUNKS");552 }553 } else if (frame.chunk_count !== chunkCount) {554 throw new Error("ADOPT_SNAPSHOT_CHUNK_COUNT_MISMATCH");555 }556 if (frame.chunk_index !== received) {557 throw new Error("ADOPT_SNAPSHOT_CHUNK_OUT_OF_ORDER");558 }559 applyAdoptSnapshotFrame(acc, frame.entries, frame.tombstones, frame.duress_slot, raw.length);560 received++;561 }562 return { entries: acc.entries, tombstones: acc.tombstones, duressSlot: acc.duressSlot };563}564565export function validateRootKeyHex(rootKeyHex: unknown): rootKeyHex is string {566 return typeof rootKeyHex === "string" && /^[0-9a-f]{64}$/.test(rootKeyHex);567}568569export interface AdoptionRequestResult {570 device: PairedDevice;571 572 snapshotCapable: boolean;573}574575export async function requestAdoption(576 channel: PairingTransport,577 deps: AdoptionReceiverDeps,578): Promise<AdoptionRequestResult | null> {579 const receiverLicense = await deps.license?.();580 await sendFrame(channel, {581 type: "ADOPT_REQUEST",582 device_id: deps.generateDeviceId(),583 device_name: deps.deviceName,584 device_type: "extension",585 license: receiverLicense,586 snapshot_capable: true,587 });588589 const confirmed = await deps.confirmSas();590 await sendFrame(channel, {591 type: "ADOPT_CONFIRM",592 confirmed,593 });594 if (!confirmed) {595 deps.onError("ADOPT_SAS_REJECTED");596 return null;597 }598599 const frame = await receiveFrame(channel);600 if (isAdoptReject(frame)) {601 deps.onError(frame.reason);602 return null;603 }604 if (!isAdoptGrant(frame)) {605 deps.onError("ADOPT_UNEXPECTED_FRAME");606 return null;607 }608 const grantSnapshotCapable = frame.snapshot_capable === true;609610 const rootKeyBytes = hexToBytes(frame.root_key_hex);611 try {612 const computedVaultId = await computeVaultId(rootKeyBytes);613 if (computedVaultId !== frame.vault_id) {614 deps.onError("ADOPT_VAULT_ID_MISMATCH");615 return null;616 }617 } finally {618 rootKeyBytes.fill(0);619 }620 if (!receiverLicense && !frame.license) {621 deps.onError("ADOPT_PRO_REQUIRED");622 return null;623 }624625 await deps.validateSource?.(frame.source);626 const { vaultId } = await deps.createVaultFromRootKey(frame.root_key_hex);627 if (vaultId !== frame.vault_id) {628 deps.onError("ADOPT_VAULT_ID_MISMATCH");629 return null;630 }631632 if (!receiverLicense && frame.license) {633 await deps.applyLicense?.(frame.license);634 }635636 const identity = await deps.deriveLocalIdentity();637 await sendFrame(channel, {638 type: "ADOPT_IDENTITY",639 device_id: identity.deviceId,640 device_name: identity.deviceName,641 device_type: identity.deviceType,642 long_term_public_key_hex: identity.longTermPublicKeyHex,643 signing_public_key_hex: identity.signingPublicKeyHex,644 });645646 const completion = await receiveFrame(channel);647 if (!isAdoptComplete(completion)) {648 deps.onError("ADOPT_COMPLETION_MISSING");649 return null;650 }651652 const sourceDevice: PairedDevice = {653 deviceId: frame.source.device_id,654 deviceName: frame.source.device_name,655 deviceType: frame.source.device_type,656 longTermPublicKeyHex: frame.source.long_term_public_key_hex,657 signingPublicKeyHex: frame.source.signing_public_key_hex,658 trustSource: "direct",659 wsPort: frame.source.ws_port,660 rendezvousSecretHex: deps.pairState?.rendezvousSecretHex,661 relayUrl: deps.pairState?.relayUrl ?? frame.source.relay_url,662 pairedAt: Date.now(),663 };664 await deps.upsertPairedDevice(sourceDevice);665 await sendFrame(channel, { type: "ADOPT_APPLIED" });666 return { device: sourceDevice, snapshotCapable: grantSnapshotCapable };667}668669export async function grantAdoption(670 channel: PairingTransport,671 request: AdoptRequest,672 deps: AdoptionSourceDeps,673): Promise<PairedDevice | null> {674 const approved = await deps.onConsent(request);675 if (!approved) return null;676677 const confirmation = await receiveFrame(channel);678 if (!isAdoptConfirm(confirmation) || !confirmation.confirmed) return null;679680 let sourceLicense = await deps.license?.();681 if (!sourceLicense && request.license) {682 try {683 if (!deps.applyLicense) throw new Error("license_transfer_unavailable");684 await deps.applyLicense(request.license);685 sourceLicense = await deps.license?.();686 } catch {687 sourceLicense = undefined;688 }689 }690 if (!sourceLicense) {691 await sendFrame(channel, {692 type: "ADOPT_REJECT",693 reason: "ADOPT_PRO_REQUIRED",694 });695 throw new Error("ADOPT_PRO_REQUIRED");696 }697698 const rootKeyHex = await deps.rootKeyHex();699 if (!validateRootKeyHex(rootKeyHex)) throw new Error("ADOPT_INVALID_ROOT_KEY");700701 await sendFrame(channel, {702 type: "ADOPT_GRANT",703 root_key_hex: rootKeyHex,704 vault_id: await deps.vaultId(),705 license: sourceLicense,706 source: {707 device_id: deps.sourceIdentity.deviceId,708 device_name: deps.sourceIdentity.deviceName,709 device_type: deps.sourceIdentity.deviceType,710 long_term_public_key_hex: deps.sourceIdentity.longTermPublicKeyHex,711 signing_public_key_hex: deps.sourceIdentity.signingPublicKeyHex,712 ws_port: deps.sourceIdentity.wsPort,713 relay_url: deps.pairState?.relayUrl,714 },715 snapshot_capable: request.snapshot_capable === true,716 });717718 const identity = await receiveFrame(channel);719 if (!isAdoptIdentity(identity)) return null;720721 const device: PairedDevice = {722 deviceId: identity.device_id,723 deviceName: identity.device_name,724 deviceType: identity.device_type,725 longTermPublicKeyHex: identity.long_term_public_key_hex,726 signingPublicKeyHex: identity.signing_public_key_hex,727 trustSource: "direct",728 rendezvousSecretHex: deps.pairState?.rendezvousSecretHex,729 relayUrl: deps.pairState?.relayUrl,730 pairedAt: Date.now(),731 };732 await deps.upsertPairedDevice(device);733 await sendFrame(channel, { type: "ADOPT_COMPLETE" });734 if (!isAdoptApplied(await receiveFrame(channel))) return null;735 return device;736}737738export async function sendFrame(channel: PairingTransport, frame: AdoptionFrame): Promise<void> {739 await channel.send(new TextEncoder().encode(JSON.stringify(frame)));740}741742export async function receiveFrame(channel: PairingTransport): Promise<unknown> {743 return JSON.parse(new TextDecoder().decode(await channel.receive()));744}745746function isDeviceType(value: unknown): value is DeviceType {747 return value === "extension" || value === "android" || value === "ios";748}749750function isSecureRelayUrl(value: string): boolean {751 try {752 const url = new URL(value);753 return url.protocol === "wss:" && !url.username && !url.password &&754 !url.search && !url.hash;755 } catch {756 return false;757 }758}759760function hexToBytes(hex: string): Uint8Array {761 const out = new Uint8Array(hex.length / 2);762 for (let i = 0; i < out.length; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);763 return out;764}