import { SYNC_LIMITS } from "./sync-limits"; import { computeVaultId } from "./sync-identity"; import type { DeviceType, PairedDevice, SignedDeviceManifest } from "./sync-types"; import type { LocalIdentity, PairingTransport } from "./pairing-handshake"; export interface AdoptRequest { type: "ADOPT_REQUEST"; device_id: string; device_name: string; device_type: DeviceType; /** Signed Pro entitlement offered by the receiver, if it has one. */ license?: string; /** * Receiver advertises inline-snapshot support. A source that understands it * sends the vault snapshot over this same channel after ADOPT_APPLIED; * older sources ignore it and the receiver falls back to the sync pipeline. */ snapshot_capable?: boolean; } export interface AdoptGrant { type: "ADOPT_GRANT"; root_key_hex: string; vault_id: string; license?: string; source: { device_id: string; device_name: string; device_type: DeviceType; long_term_public_key_hex: string; signing_public_key_hex?: string; ws_port?: number; relay_url?: string; }; /** * ECHO: source reflects receiver's ADOPT_REQUEST.snapshot_capable here so * the receiver can skip the inline-snapshot wait when the source will not * send one (pre-C source, or explicit opt-out). Additive; absent = old source. */ snapshot_capable?: boolean; } export interface AdoptConfirm { type: "ADOPT_CONFIRM"; confirmed: boolean; } export interface AdoptIdentity { type: "ADOPT_IDENTITY"; device_id: string; device_name: string; device_type: DeviceType; long_term_public_key_hex: string; signing_public_key_hex?: string; } /** * Encrypted source-to-receiver acknowledgement sent only after the source has * durably recorded the receiver. Without it the receiver could report success * after sending ADOPT_IDENTITY even though the source never received it. */ export interface AdoptComplete { type: "ADOPT_COMPLETE"; } /** Receiver-to-source acknowledgement sent after the source record is durable. */ export interface AdoptApplied { type: "ADOPT_APPLIED"; } /** Neither peer could prove a Pro entitlement, so no vault state may commit. */ export interface AdoptReject { type: "ADOPT_REJECT"; reason: "ADOPT_PRO_REQUIRED"; } /** * One chunk of the inline vault snapshot sent by the source after * ADOPT_APPLIED, over the same authenticated Noise NK channel. Entries are * opaque base64 ciphertext envelopes; tombstones ride the final chunk only. */ export interface AdoptSnapshot { type: "ADOPT_SNAPSHOT"; chunk_index: number; chunk_count: number; entries: Record; /** entryId → deletion timestamp; populated on the final chunk only. */ tombstones: Record; /** * Sealed duress (slot B) StoredVault as JSON.stringify of the browser * shape — identical to the Codex vault_b field. Populated on the final * chunk only, and only when duress is actually configured; absent otherwise. */ duress_slot?: string; } /** Receiver acknowledgement sent after all snapshot chunks are applied. */ export interface AdoptSnapshotApplied { type: "ADOPT_SNAPSHOT_APPLIED"; entries_received: number; tombstones_received: number; } /** Optional signed device graph appended after the snapshot (manifest v3+). */ export interface AdoptDeviceManifestFrame { type: "ADOPT_DEVICE_MANIFEST"; manifest: SignedDeviceManifest; } /** Receiver acknowledgement for an applied device manifest. */ export interface AdoptDeviceManifestApplied { type: "ADOPT_DEVICE_MANIFEST_APPLIED"; } export type AdoptionFrame = | AdoptRequest | AdoptConfirm | AdoptGrant | AdoptIdentity | AdoptComplete | AdoptApplied | AdoptReject | AdoptSnapshot | AdoptSnapshotApplied | AdoptDeviceManifestFrame | AdoptDeviceManifestApplied; /** * Wire guardrails for the inline snapshot. A chunk's serialized entries map * stays under MAX_SNAPSHOT_CHUNK_BYTES; the whole transfer is capped at * MAX_SNAPSHOT_CHUNKS frames so a compromised source cannot exhaust receiver * memory (mirrors SYNC_LIMITS.MAX_FRAME_BYTES in the sync coordinator). */ export const MAX_SNAPSHOT_CHUNK_BYTES = 512 * 1024; // 512 KiB export const MAX_SNAPSHOT_CHUNKS = 200; // Headroom added to the per-chunk entries budget when validating an encoded // frame on the wire: JSON envelope plus the final chunk's tombstone map. export const SNAPSHOT_FRAME_WIRE_CEILING = MAX_SNAPSHOT_CHUNK_BYTES + 256 * 1024; /** * PB-26-206 aggregate budget shared by sender and receiver. The per-frame * guard above bounds one frame; this bounds the whole transfer — encoded * snapshot bytes (entries + tombstones + JSON framing + duress slot), * entry/tombstone counts, ID lengths, and per-envelope size — independently * of the 200-frame count, so many individually valid frames cannot OOM the * receiver. Aligned with the 12 MiB pairing-inbox transport backstop * (PB-26-205): an honest snapshot never approaches either ceiling. */ export const MAX_ADOPT_SNAPSHOT_BYTES = 10 * 1024 * 1024; // 10 MiB export const MAX_ADOPT_SNAPSHOT_ENTRIES = 25_000; export const MAX_ADOPT_SNAPSHOT_TOMBSTONES = 25_000; /** Matches the cross-client anti-abuse ID ceiling (Android SyncLimits.MAX_ID_LENGTH). */ export const MAX_ADOPT_SNAPSHOT_ID_CHARS = 512; /** * Maximum encrypted entry envelope: SYNC_LIMITS.MAX_ENTRY_PLAINTEXT_BYTES * plus the existing base64 overhead allowance (mirrors sync-vault-adapter). */ export const MAX_ADOPT_SNAPSHOT_ENVELOPE_BYTES = (SYNC_LIMITS.MAX_ENTRY_PLAINTEXT_BYTES * 4) / 3 + 64; /** * Running aggregate for one snapshot transfer. Counts are tracked alongside * the maps (not derived via Object.keys per frame) so the bound stays O(n). */ export interface AdoptSnapshotBudget { entries: Record; tombstones: Record; duressSlot: string | null; /** Sum of encoded wire-frame bytes consumed so far. */ bytes: number; entryCount: number; tombstoneCount: number; } export function createAdoptSnapshotBudget(): AdoptSnapshotBudget { return { entries: {}, tombstones: {}, duressSlot: null, bytes: 0, entryCount: 0, tombstoneCount: 0 }; } /** * PB-26-206 shared preflight/merge helper used by BOTH the sender * (preflight before the first frame goes out) and the receiver (per frame, * after the byte-budget check, before JSON.parse results are merged). * * One bounded pass per attacker-controlled map: each key/value is validated * and projected counts are enforced inside the same loop that merges, so the * maps are never iterated count→size→merge repeatedly. Throws before merging * the offending frame, leaving no partial adopted state. */ export function applyAdoptSnapshotFrame( acc: AdoptSnapshotBudget, entries: Record, tombstones: Record, duressSlot: string | null | undefined, frameBytes: number, ): void { if (frameBytes > MAX_ADOPT_SNAPSHOT_BYTES - acc.bytes) { throw new Error("ADOPT_SNAPSHOT_OVER_BUDGET"); } for (const [id, envelope] of Object.entries(entries)) { if (id.length > MAX_ADOPT_SNAPSHOT_ID_CHARS) throw new Error("ADOPT_SNAPSHOT_ID_TOO_LONG"); if (envelope.length > MAX_ADOPT_SNAPSHOT_ENVELOPE_BYTES) { throw new Error("ADOPT_SNAPSHOT_ENVELOPE_TOO_LARGE"); } if (acc.entryCount >= MAX_ADOPT_SNAPSHOT_ENTRIES) { throw new Error("ADOPT_SNAPSHOT_TOO_MANY_ENTRIES"); } acc.entryCount++; acc.entries[id] = envelope; } for (const [id, ts] of Object.entries(tombstones)) { if (id.length > MAX_ADOPT_SNAPSHOT_ID_CHARS) throw new Error("ADOPT_SNAPSHOT_ID_TOO_LONG"); if (typeof ts !== "number" || !Number.isFinite(ts)) { throw new Error("ADOPT_SNAPSHOT_INVALID_TOMBSTONE"); } if (acc.tombstoneCount >= MAX_ADOPT_SNAPSHOT_TOMBSTONES) { throw new Error("ADOPT_SNAPSHOT_TOO_MANY_TOMBSTONES"); } acc.tombstoneCount++; acc.tombstones[id] = ts; } if (typeof duressSlot === "string" && duressSlot.length > 0) { // The duress slot rides inside wire frames already bounded by the frame // ceiling, and its raw bytes count against the aggregate above. if (duressSlot.length > SNAPSHOT_FRAME_WIRE_CEILING) { throw new Error("ADOPT_SNAPSHOT_DURESS_FRAME_TOO_LARGE"); } acc.duressSlot = duressSlot; } acc.bytes += frameBytes; } export interface AdoptionPairState { rendezvousSecretHex: string; relayUrl?: string; } export interface AdoptionReceiverDeps { deviceName: string; generateDeviceId(): string; /** Root-key transfer cannot begin until this device confirms the SAS. */ confirmSas(): Promise; /** * Optional local safety policy evaluated after the authenticated grant is * validated but before any vault state changes. */ validateSource?(source: AdoptGrant["source"]): Promise; createVaultFromRootKey(rootKeyHex: string): Promise<{ vaultId: string }>; deriveLocalIdentity(): Promise; upsertPairedDevice(device: PairedDevice): Promise; applyLicense?(license: string): Promise; /** Return this receiver's locally validated Pro key, if present. */ license?(): Promise; /** Bound to the same completed Noise session, never taken from frame claims. */ pairState?: AdoptionPairState; onError(error: string): void; } export interface AdoptionSourceDeps { sourceIdentity: LocalIdentity; rootKeyHex(): Promise; vaultId(): Promise; license?(): Promise; /** Validate and durably store a Pro key offered by the receiver. */ applyLicense?(license: string): Promise; onConsent(request: AdoptRequest): Promise; upsertPairedDevice(device: PairedDevice): Promise; /** Bound to the same completed Noise session, never taken from frame claims. */ pairState?: AdoptionPairState; } export function isAdoptRequest(frame: unknown): frame is AdoptRequest { const value = frame as Partial | null; return value?.type === "ADOPT_REQUEST" && typeof value.device_id === "string" && value.device_id.length > 0 && typeof value.device_name === "string" && isDeviceType(value.device_type) && (value.license === undefined || typeof value.license === "string"); } export function isAdoptGrant(frame: unknown): frame is AdoptGrant { const value = frame as Partial | null; return value?.type === "ADOPT_GRANT" && validateRootKeyHex(value.root_key_hex) && typeof value.vault_id === "string" && /^[0-9a-f]{64}$/.test(value.vault_id) && typeof value.source === "object" && value.source !== null && typeof value.source.device_id === "string" && typeof value.source.device_name === "string" && isDeviceType(value.source.device_type) && typeof value.source.long_term_public_key_hex === "string" && /^[0-9a-f]{64}$/.test(value.source.long_term_public_key_hex) && (value.source.relay_url === undefined || (typeof value.source.relay_url === "string" && isSecureRelayUrl(value.source.relay_url))) && (value.snapshot_capable === undefined || typeof value.snapshot_capable === "boolean"); } export function isAdoptConfirm(frame: unknown): frame is AdoptConfirm { const value = frame as Partial | null; return value?.type === "ADOPT_CONFIRM" && typeof value.confirmed === "boolean"; } export function isAdoptIdentity(frame: unknown): frame is AdoptIdentity { const value = frame as Partial | null; return value?.type === "ADOPT_IDENTITY" && typeof value.device_id === "string" && value.device_id.length > 0 && typeof value.device_name === "string" && isDeviceType(value.device_type) && typeof value.long_term_public_key_hex === "string" && /^[0-9a-f]{64}$/.test(value.long_term_public_key_hex); } export function isAdoptComplete(frame: unknown): frame is AdoptComplete { return (frame as Partial | null)?.type === "ADOPT_COMPLETE"; } export function isAdoptApplied(frame: unknown): frame is AdoptApplied { return (frame as Partial | null)?.type === "ADOPT_APPLIED"; } export function isAdoptReject(frame: unknown): frame is AdoptReject { const value = frame as Partial | null; return value?.type === "ADOPT_REJECT" && value.reason === "ADOPT_PRO_REQUIRED"; } export function isAdoptSnapshot(frame: unknown): frame is AdoptSnapshot { const value = frame as Partial | null; if (value?.type !== "ADOPT_SNAPSHOT") return false; if ( typeof value.chunk_index !== "number" || !Number.isInteger(value.chunk_index) || value.chunk_index < 0 ) return false; if ( typeof value.chunk_count !== "number" || !Number.isInteger(value.chunk_count) || value.chunk_count < 1 ) return false; if (!isStringRecord(value.entries)) return false; if (!isNumberRecord(value.tombstones)) return false; if (value.duress_slot !== undefined && typeof value.duress_slot !== "string") return false; return true; } export function isAdoptSnapshotApplied(frame: unknown): frame is AdoptSnapshotApplied { const value = frame as Partial | null; return value?.type === "ADOPT_SNAPSHOT_APPLIED" && typeof value.entries_received === "number" && typeof value.tombstones_received === "number"; } export function isAdoptDeviceManifestFrame( frame: unknown, ): frame is AdoptDeviceManifestFrame { const value = frame as Partial | null; const manifest = value?.manifest as | { body?: unknown; signatureHex?: unknown } | null; return value?.type === "ADOPT_DEVICE_MANIFEST" && typeof manifest === "object" && manifest !== null && typeof manifest.body === "object" && manifest.body !== null && typeof manifest.signatureHex === "string"; } function isStringRecord(value: unknown): value is Record { if (typeof value !== "object" || value === null) return false; return Object.values(value).every((v) => typeof v === "string"); } function isNumberRecord(value: unknown): value is Record { if (typeof value !== "object" || value === null) return false; return Object.values(value).every((v) => typeof v === "number"); } /** * Source side: chunk the vault snapshot and send it over the pairing channel. * * Wire shape (v4.6.4+, duress on its own frame): * - N entries frames, each ≤ MAX_SNAPSHOT_CHUNK_BYTES serialized entries. * Tombstones ride the last entries frame when no duress follows. * - If duressSlot is set, one extra frame after the entries carrying only * the duress slot (entries: empty, tombstones: tombstone map, * duress_slot: duressSlot). This moves duress off the entries frame so a * sizeable sealed duress vault can no longer push the entries frame raw * bytes over SNAPSHOT_FRAME_WIRE_CEILING (768 KiB). * * Frame budget (MAX_SNAPSHOT_CHUNKS = 200): * - When duress is configured, ONE of the 200 frames is reserved for the * duress slot. The entries cap is MAX_SNAPSHOT_CHUNKS - 1 = 199 entries * chunks; excess entries are deferred to the sync fallback pipeline (sync * carries entries and tombstones, so this is a graceful truncation). * This reservation is REQUIRED: ordinary sync does NOT carry duress * (vault.test.ts:158-179 test-locks duress as non-syncing), so silently * dropping a duress frame at the cap would leave the adopted device * permanently without its duress vault. We prioritize duress delivery * over completeness of entries (the latter is recoverable via sync). * * Oversized duress: duress that itself exceeds SNAPSHOT_FRAME_WIRE_CEILING * (e.g. a ≥768 KiB sealed duress) is rejected with a thrown error BEFORE the * first duress frame is sent. This is a hard stop — the caller must split * the duress payload out of band. We do not silently truncate duress; sync * cannot restore it. * * Backward compat: pre-v4.6.4 senders put `duress_slot` on the entries * final chunk. Receivers tolerate both shapes because `receiveVaultSnapshot` * reads `duress_slot` off any chunk (a duress-only frame has empty * entries/tombstones which merge as a no-op). */ export async function sendVaultSnapshot( channel: PairingTransport, entries: Record, tombstones: Record, duressSlot: string | null = null, ): Promise { const frames = buildAdoptSnapshotFrames(entries, tombstones, duressSlot); // PB-26-206 preflight with the SAME helper the receiver enforces: nothing // is sent until every frame fits the aggregate budget, so an over-budget // snapshot is never partially sent or silently truncated. The caller falls // through to the regular sync pipeline (deny-only: no wire-field change, // invitation prefix unchanged). const acc = createAdoptSnapshotBudget(); const encoded: Uint8Array[] = frames.map((frame) => new TextEncoder().encode(JSON.stringify(frame)) ); for (let index = 0; index < frames.length; index++) { const frame = frames[index]; applyAdoptSnapshotFrame( acc, frame.entries, frame.tombstones, frame.duress_slot, encoded[index].length, ); } for (const raw of encoded) await channel.send(raw); } /** * Pure frame construction for sendVaultSnapshot: chunking, the duress-frame * reservation, and the oversized-duress hard stop. No I/O happens here, so * the preflight above can reject before the first byte goes out. */ function buildAdoptSnapshotFrames( entries: Record, tombstones: Record, duressSlot: string | null, ): AdoptSnapshot[] { const includeDuress = !!duressSlot; // Reserve one frame for duress when configured so duress is never silently // dropped at the cap; ordinary sync does NOT carry duress, so dropping a // duress frame would orphan the adopted device's duress vault. const entryCap = includeDuress ? MAX_SNAPSHOT_CHUNKS - 1 : MAX_SNAPSHOT_CHUNKS; const chunks: Array> = []; let current: Record = {}; let currentCount = 0; let currentBytes = 2; // "{}" for (const [id, envelope] of Object.entries(entries)) { // +1 for the separating comma; an overestimate only shrinks chunks. const entryBytes = JSON.stringify({ [id]: envelope }).length + 1; if (currentCount > 0 && currentBytes + entryBytes >= MAX_SNAPSHOT_CHUNK_BYTES) { chunks.push(current); current = {}; currentCount = 0; currentBytes = 2; } current[id] = envelope; currentCount++; currentBytes += entryBytes; } chunks.push(current); const entriesChunkCount = Math.min(chunks.length, entryCap); const sendDuress = includeDuress; const totalChunks = entriesChunkCount + (sendDuress ? 1 : 0); const duressFrame: AdoptSnapshot | null = sendDuress ? { type: "ADOPT_SNAPSHOT", chunk_index: entriesChunkCount, chunk_count: totalChunks, entries: {}, tombstones, duress_slot: duressSlot as string, } : null; if ( duressFrame && new TextEncoder().encode(JSON.stringify(duressFrame)).length > SNAPSHOT_FRAME_WIRE_CEILING ) { throw new Error("ADOPT_SNAPSHOT_DURESS_FRAME_TOO_LARGE"); } const frames: AdoptSnapshot[] = []; for (let index = 0; index < entriesChunkCount; index++) { const isFinalEntries = index === entriesChunkCount - 1; frames.push({ type: "ADOPT_SNAPSHOT", chunk_index: index, chunk_count: totalChunks, entries: chunks[index], // Tombstones ride the LAST wire frame: when duress follows the entries, // it goes on the duress frame; otherwise on the last entries chunk. tombstones: isFinalEntries && !sendDuress ? tombstones : {}, }); } if (duressFrame) frames.push(duressFrame); return frames; } /** * Receiver side: receive and reassemble every snapshot chunk. Adds raw frame * bytes to the aggregate counter before decoding (rejecting before * JSON.parse when the next frame exceeds the remaining budget), then enforces * the per-frame wire ceiling, sequential 0-based chunk_index ordering, a * stable chunk_count within MAX_SNAPSHOT_CHUNKS, and structural validity of * each frame. Merging goes through the shared aggregate helper: every * key/value and projected count is validated in one bounded pass before * anything lands in the accumulator, so a rejection leaves no partial * adopted state and the caller can fall back to the sync pipeline. */ export async function receiveVaultSnapshot( channel: PairingTransport, ): Promise<{ entries: Record; tombstones: Record; duressSlot: string | null }> { const acc = createAdoptSnapshotBudget(); let chunkCount = -1; let received = 0; while (chunkCount === -1 || received < chunkCount) { const raw = await channel.receive(); if (raw.length > MAX_ADOPT_SNAPSHOT_BYTES - acc.bytes) { throw new Error("ADOPT_SNAPSHOT_OVER_BUDGET"); } if (raw.length > SNAPSHOT_FRAME_WIRE_CEILING) { throw new Error("ADOPT_SNAPSHOT_CHUNK_TOO_LARGE"); } const frame: unknown = JSON.parse(new TextDecoder().decode(raw)); if (!isAdoptSnapshot(frame)) throw new Error("ADOPT_SNAPSHOT_INVALID_FRAME"); if (chunkCount === -1) { chunkCount = frame.chunk_count; if (chunkCount > MAX_SNAPSHOT_CHUNKS) { throw new Error("ADOPT_SNAPSHOT_TOO_MANY_CHUNKS"); } } else if (frame.chunk_count !== chunkCount) { throw new Error("ADOPT_SNAPSHOT_CHUNK_COUNT_MISMATCH"); } if (frame.chunk_index !== received) { throw new Error("ADOPT_SNAPSHOT_CHUNK_OUT_OF_ORDER"); } applyAdoptSnapshotFrame(acc, frame.entries, frame.tombstones, frame.duress_slot, raw.length); received++; } return { entries: acc.entries, tombstones: acc.tombstones, duressSlot: acc.duressSlot }; } export function validateRootKeyHex(rootKeyHex: unknown): rootKeyHex is string { return typeof rootKeyHex === "string" && /^[0-9a-f]{64}$/.test(rootKeyHex); } export interface AdoptionRequestResult { device: PairedDevice; /** True iff the source echoed snapshot_capable: true in its ADOPT_GRANT. */ snapshotCapable: boolean; } export async function requestAdoption( channel: PairingTransport, deps: AdoptionReceiverDeps, ): Promise { const receiverLicense = await deps.license?.(); await sendFrame(channel, { type: "ADOPT_REQUEST", device_id: deps.generateDeviceId(), device_name: deps.deviceName, device_type: "extension", license: receiverLicense, snapshot_capable: true, }); const confirmed = await deps.confirmSas(); await sendFrame(channel, { type: "ADOPT_CONFIRM", confirmed, }); if (!confirmed) { deps.onError("ADOPT_SAS_REJECTED"); return null; } const frame = await receiveFrame(channel); if (isAdoptReject(frame)) { deps.onError(frame.reason); return null; } if (!isAdoptGrant(frame)) { deps.onError("ADOPT_UNEXPECTED_FRAME"); return null; } const grantSnapshotCapable = frame.snapshot_capable === true; const rootKeyBytes = hexToBytes(frame.root_key_hex); try { const computedVaultId = await computeVaultId(rootKeyBytes); if (computedVaultId !== frame.vault_id) { deps.onError("ADOPT_VAULT_ID_MISMATCH"); return null; } } finally { rootKeyBytes.fill(0); } if (!receiverLicense && !frame.license) { deps.onError("ADOPT_PRO_REQUIRED"); return null; } await deps.validateSource?.(frame.source); const { vaultId } = await deps.createVaultFromRootKey(frame.root_key_hex); if (vaultId !== frame.vault_id) { deps.onError("ADOPT_VAULT_ID_MISMATCH"); return null; } if (!receiverLicense && frame.license) { await deps.applyLicense?.(frame.license); } const identity = await deps.deriveLocalIdentity(); await sendFrame(channel, { type: "ADOPT_IDENTITY", device_id: identity.deviceId, device_name: identity.deviceName, device_type: identity.deviceType, long_term_public_key_hex: identity.longTermPublicKeyHex, signing_public_key_hex: identity.signingPublicKeyHex, }); const completion = await receiveFrame(channel); if (!isAdoptComplete(completion)) { deps.onError("ADOPT_COMPLETION_MISSING"); return null; } const sourceDevice: PairedDevice = { deviceId: frame.source.device_id, deviceName: frame.source.device_name, deviceType: frame.source.device_type, longTermPublicKeyHex: frame.source.long_term_public_key_hex, signingPublicKeyHex: frame.source.signing_public_key_hex, trustSource: "direct", wsPort: frame.source.ws_port, rendezvousSecretHex: deps.pairState?.rendezvousSecretHex, relayUrl: deps.pairState?.relayUrl ?? frame.source.relay_url, pairedAt: Date.now(), }; await deps.upsertPairedDevice(sourceDevice); await sendFrame(channel, { type: "ADOPT_APPLIED" }); return { device: sourceDevice, snapshotCapable: grantSnapshotCapable }; } export async function grantAdoption( channel: PairingTransport, request: AdoptRequest, deps: AdoptionSourceDeps, ): Promise { const approved = await deps.onConsent(request); if (!approved) return null; const confirmation = await receiveFrame(channel); if (!isAdoptConfirm(confirmation) || !confirmation.confirmed) return null; let sourceLicense = await deps.license?.(); if (!sourceLicense && request.license) { try { if (!deps.applyLicense) throw new Error("license_transfer_unavailable"); await deps.applyLicense(request.license); sourceLicense = await deps.license?.(); } catch { sourceLicense = undefined; } } if (!sourceLicense) { await sendFrame(channel, { type: "ADOPT_REJECT", reason: "ADOPT_PRO_REQUIRED", }); throw new Error("ADOPT_PRO_REQUIRED"); } const rootKeyHex = await deps.rootKeyHex(); if (!validateRootKeyHex(rootKeyHex)) throw new Error("ADOPT_INVALID_ROOT_KEY"); await sendFrame(channel, { type: "ADOPT_GRANT", root_key_hex: rootKeyHex, vault_id: await deps.vaultId(), license: sourceLicense, source: { device_id: deps.sourceIdentity.deviceId, device_name: deps.sourceIdentity.deviceName, device_type: deps.sourceIdentity.deviceType, long_term_public_key_hex: deps.sourceIdentity.longTermPublicKeyHex, signing_public_key_hex: deps.sourceIdentity.signingPublicKeyHex, ws_port: deps.sourceIdentity.wsPort, relay_url: deps.pairState?.relayUrl, }, snapshot_capable: request.snapshot_capable === true, }); const identity = await receiveFrame(channel); if (!isAdoptIdentity(identity)) return null; const device: PairedDevice = { deviceId: identity.device_id, deviceName: identity.device_name, deviceType: identity.device_type, longTermPublicKeyHex: identity.long_term_public_key_hex, signingPublicKeyHex: identity.signing_public_key_hex, trustSource: "direct", rendezvousSecretHex: deps.pairState?.rendezvousSecretHex, relayUrl: deps.pairState?.relayUrl, pairedAt: Date.now(), }; await deps.upsertPairedDevice(device); await sendFrame(channel, { type: "ADOPT_COMPLETE" }); if (!isAdoptApplied(await receiveFrame(channel))) return null; return device; } export async function sendFrame(channel: PairingTransport, frame: AdoptionFrame): Promise { await channel.send(new TextEncoder().encode(JSON.stringify(frame))); } export async function receiveFrame(channel: PairingTransport): Promise { return JSON.parse(new TextDecoder().decode(await channel.receive())); } function isDeviceType(value: unknown): value is DeviceType { return value === "extension" || value === "android" || value === "ios"; } function isSecureRelayUrl(value: string): boolean { try { const url = new URL(value); return url.protocol === "wss:" && !url.username && !url.password && !url.search && !url.hash; } catch { return false; } } function hexToBytes(hex: string): Uint8Array { const out = new Uint8Array(hex.length / 2); for (let i = 0; i < out.length; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16); return out; }