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}296297298299300301302303304305306307308309310311312313314315316317318319export async function sendVaultSnapshot(320 channel: PairingTransport,321 entries: Record<string, string>,322 tombstones: Record<string, number>,323 duressSlot: string | null = null,324): Promise<void> {325 const chunks: Array<Record<string, string>> = [];326 let current: Record<string, string> = {};327 let currentBytes = 2; 328 for (const [id, envelope] of Object.entries(entries)) {329 330 const entryBytes = JSON.stringify({ [id]: envelope }).length + 1;331 if (332 Object.keys(current).length > 0 &&333 currentBytes + entryBytes >= MAX_SNAPSHOT_CHUNK_BYTES334 ) {335 chunks.push(current);336 current = {};337 currentBytes = 2;338 }339 current[id] = envelope;340 currentBytes += entryBytes;341 }342 chunks.push(current);343344 const entriesChunkCount = Math.min(chunks.length, MAX_SNAPSHOT_CHUNKS);345 const includeDuress = !!duressSlot;346 347 348 const duressFits = entriesChunkCount < MAX_SNAPSHOT_CHUNKS;349 const sendDuress = includeDuress && duressFits;350 const totalChunks = entriesChunkCount + (sendDuress ? 1 : 0);351352 for (let index = 0; index < entriesChunkCount; index++) {353 const isFinalEntries = index === entriesChunkCount - 1;354 const frame: AdoptSnapshot = {355 type: "ADOPT_SNAPSHOT",356 chunk_index: index,357 chunk_count: totalChunks,358 entries: chunks[index],359 360 361 tombstones: isFinalEntries && !sendDuress ? tombstones : {},362 };363 await sendFrame(channel, frame);364 }365366 if (sendDuress) {367 await sendFrame(channel, {368 type: "ADOPT_SNAPSHOT",369 chunk_index: entriesChunkCount,370 chunk_count: totalChunks,371 entries: {},372 tombstones,373 duress_slot: duressSlot as string,374 });375 }376}377378379380381382383384385export async function receiveVaultSnapshot(386 channel: PairingTransport,387): Promise<{ entries: Record<string, string>; tombstones: Record<string, number>; duressSlot: string | null }> {388 const entries: Record<string, string> = {};389 const tombstones: Record<string, number> = {};390 let duressSlot: string | null = null;391 let chunkCount = -1;392 let received = 0;393 while (chunkCount === -1 || received < chunkCount) {394 const raw = await channel.receive();395 if (raw.length > SNAPSHOT_FRAME_WIRE_CEILING) {396 throw new Error("ADOPT_SNAPSHOT_CHUNK_TOO_LARGE");397 }398 const frame: unknown = JSON.parse(new TextDecoder().decode(raw));399 if (!isAdoptSnapshot(frame)) throw new Error("ADOPT_SNAPSHOT_INVALID_FRAME");400 if (chunkCount === -1) {401 chunkCount = frame.chunk_count;402 if (chunkCount > MAX_SNAPSHOT_CHUNKS) {403 throw new Error("ADOPT_SNAPSHOT_TOO_MANY_CHUNKS");404 }405 } else if (frame.chunk_count !== chunkCount) {406 throw new Error("ADOPT_SNAPSHOT_CHUNK_COUNT_MISMATCH");407 }408 if (frame.chunk_index !== received) {409 throw new Error("ADOPT_SNAPSHOT_CHUNK_OUT_OF_ORDER");410 }411 Object.assign(entries, frame.entries);412 Object.assign(tombstones, frame.tombstones);413 if (frame.duress_slot) duressSlot = frame.duress_slot;414 received++;415 }416 return { entries, tombstones, duressSlot };417}418419export function validateRootKeyHex(rootKeyHex: unknown): rootKeyHex is string {420 return typeof rootKeyHex === "string" && /^[0-9a-f]{64}$/.test(rootKeyHex);421}422423export interface AdoptionRequestResult {424 device: PairedDevice;425 426 snapshotCapable: boolean;427}428429export async function requestAdoption(430 channel: PairingTransport,431 deps: AdoptionReceiverDeps,432): Promise<AdoptionRequestResult | null> {433 const receiverLicense = await deps.license?.();434 await sendFrame(channel, {435 type: "ADOPT_REQUEST",436 device_id: deps.generateDeviceId(),437 device_name: deps.deviceName,438 device_type: "extension",439 license: receiverLicense,440 snapshot_capable: true,441 });442443 const confirmed = await deps.confirmSas();444 await sendFrame(channel, {445 type: "ADOPT_CONFIRM",446 confirmed,447 });448 if (!confirmed) {449 deps.onError("ADOPT_SAS_REJECTED");450 return null;451 }452453 const frame = await receiveFrame(channel);454 if (isAdoptReject(frame)) {455 deps.onError(frame.reason);456 return null;457 }458 if (!isAdoptGrant(frame)) {459 deps.onError("ADOPT_UNEXPECTED_FRAME");460 return null;461 }462 const grantSnapshotCapable = frame.snapshot_capable === true;463464 const rootKeyBytes = hexToBytes(frame.root_key_hex);465 try {466 const computedVaultId = await computeVaultId(rootKeyBytes);467 if (computedVaultId !== frame.vault_id) {468 deps.onError("ADOPT_VAULT_ID_MISMATCH");469 return null;470 }471 } finally {472 rootKeyBytes.fill(0);473 }474 if (!receiverLicense && !frame.license) {475 deps.onError("ADOPT_PRO_REQUIRED");476 return null;477 }478479 await deps.validateSource?.(frame.source);480 const { vaultId } = await deps.createVaultFromRootKey(frame.root_key_hex);481 if (vaultId !== frame.vault_id) {482 deps.onError("ADOPT_VAULT_ID_MISMATCH");483 return null;484 }485486 if (!receiverLicense && frame.license) {487 await deps.applyLicense?.(frame.license);488 }489490 const identity = await deps.deriveLocalIdentity();491 await sendFrame(channel, {492 type: "ADOPT_IDENTITY",493 device_id: identity.deviceId,494 device_name: identity.deviceName,495 device_type: identity.deviceType,496 long_term_public_key_hex: identity.longTermPublicKeyHex,497 signing_public_key_hex: identity.signingPublicKeyHex,498 });499500 const completion = await receiveFrame(channel);501 if (!isAdoptComplete(completion)) {502 deps.onError("ADOPT_COMPLETION_MISSING");503 return null;504 }505506 const sourceDevice: PairedDevice = {507 deviceId: frame.source.device_id,508 deviceName: frame.source.device_name,509 deviceType: frame.source.device_type,510 longTermPublicKeyHex: frame.source.long_term_public_key_hex,511 signingPublicKeyHex: frame.source.signing_public_key_hex,512 trustSource: "direct",513 wsPort: frame.source.ws_port,514 rendezvousSecretHex: deps.pairState?.rendezvousSecretHex,515 relayUrl: deps.pairState?.relayUrl ?? frame.source.relay_url,516 pairedAt: Date.now(),517 };518 await deps.upsertPairedDevice(sourceDevice);519 await sendFrame(channel, { type: "ADOPT_APPLIED" });520 return { device: sourceDevice, snapshotCapable: grantSnapshotCapable };521}522523export async function grantAdoption(524 channel: PairingTransport,525 request: AdoptRequest,526 deps: AdoptionSourceDeps,527): Promise<PairedDevice | null> {528 const approved = await deps.onConsent(request);529 if (!approved) return null;530531 const confirmation = await receiveFrame(channel);532 if (!isAdoptConfirm(confirmation) || !confirmation.confirmed) return null;533534 let sourceLicense = await deps.license?.();535 if (!sourceLicense && request.license) {536 try {537 if (!deps.applyLicense) throw new Error("license_transfer_unavailable");538 await deps.applyLicense(request.license);539 sourceLicense = await deps.license?.();540 } catch {541 sourceLicense = undefined;542 }543 }544 if (!sourceLicense) {545 await sendFrame(channel, {546 type: "ADOPT_REJECT",547 reason: "ADOPT_PRO_REQUIRED",548 });549 throw new Error("ADOPT_PRO_REQUIRED");550 }551552 const rootKeyHex = await deps.rootKeyHex();553 if (!validateRootKeyHex(rootKeyHex)) throw new Error("ADOPT_INVALID_ROOT_KEY");554555 await sendFrame(channel, {556 type: "ADOPT_GRANT",557 root_key_hex: rootKeyHex,558 vault_id: await deps.vaultId(),559 license: sourceLicense,560 source: {561 device_id: deps.sourceIdentity.deviceId,562 device_name: deps.sourceIdentity.deviceName,563 device_type: deps.sourceIdentity.deviceType,564 long_term_public_key_hex: deps.sourceIdentity.longTermPublicKeyHex,565 signing_public_key_hex: deps.sourceIdentity.signingPublicKeyHex,566 ws_port: deps.sourceIdentity.wsPort,567 relay_url: deps.pairState?.relayUrl,568 },569 snapshot_capable: request.snapshot_capable === true,570 });571572 const identity = await receiveFrame(channel);573 if (!isAdoptIdentity(identity)) return null;574575 const device: PairedDevice = {576 deviceId: identity.device_id,577 deviceName: identity.device_name,578 deviceType: identity.device_type,579 longTermPublicKeyHex: identity.long_term_public_key_hex,580 signingPublicKeyHex: identity.signing_public_key_hex,581 trustSource: "direct",582 rendezvousSecretHex: deps.pairState?.rendezvousSecretHex,583 relayUrl: deps.pairState?.relayUrl,584 pairedAt: Date.now(),585 };586 await deps.upsertPairedDevice(device);587 await sendFrame(channel, { type: "ADOPT_COMPLETE" });588 if (!isAdoptApplied(await receiveFrame(channel))) return null;589 return device;590}591592export async function sendFrame(channel: PairingTransport, frame: AdoptionFrame): Promise<void> {593 await channel.send(new TextEncoder().encode(JSON.stringify(frame)));594}595596export async function receiveFrame(channel: PairingTransport): Promise<unknown> {597 return JSON.parse(new TextDecoder().decode(await channel.receive()));598}599600function isDeviceType(value: unknown): value is DeviceType {601 return value === "extension" || value === "android" || value === "ios";602}603604function isSecureRelayUrl(value: string): boolean {605 try {606 const url = new URL(value);607 return url.protocol === "wss:" && !url.username && !url.password &&608 !url.search && !url.hash;609 } catch {610 return false;611 }612}613614function hexToBytes(hex: string): Uint8Array {615 const out = new Uint8Array(hex.length / 2);616 for (let i = 0; i < out.length; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);617 return out;618}