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}296297298299300301302303304export async function sendVaultSnapshot(305 channel: PairingTransport,306 entries: Record<string, string>,307 tombstones: Record<string, number>,308 duressSlot: string | null = null,309): Promise<void> {310 const chunks: Array<Record<string, string>> = [];311 let current: Record<string, string> = {};312 let currentBytes = 2; 313 for (const [id, envelope] of Object.entries(entries)) {314 315 const entryBytes = JSON.stringify({ [id]: envelope }).length + 1;316 if (317 Object.keys(current).length > 0 &&318 currentBytes + entryBytes >= MAX_SNAPSHOT_CHUNK_BYTES319 ) {320 chunks.push(current);321 current = {};322 currentBytes = 2;323 }324 current[id] = envelope;325 currentBytes += entryBytes;326 }327 chunks.push(current);328329 const chunkCount = Math.min(chunks.length, MAX_SNAPSHOT_CHUNKS);330 for (let index = 0; index < chunkCount; index++) {331 const isFinal = index === chunkCount - 1;332 const frame: AdoptSnapshot = {333 type: "ADOPT_SNAPSHOT",334 chunk_index: index,335 chunk_count: chunkCount,336 entries: chunks[index],337 tombstones: isFinal ? tombstones : {},338 };339 if (isFinal && duressSlot) {340 frame.duress_slot = duressSlot;341 }342 await sendFrame(channel, frame);343 }344}345346347348349350351352353export async function receiveVaultSnapshot(354 channel: PairingTransport,355): Promise<{ entries: Record<string, string>; tombstones: Record<string, number>; duressSlot: string | null }> {356 const entries: Record<string, string> = {};357 const tombstones: Record<string, number> = {};358 let duressSlot: string | null = null;359 let chunkCount = -1;360 let received = 0;361 while (chunkCount === -1 || received < chunkCount) {362 const raw = await channel.receive();363 if (raw.length > SNAPSHOT_FRAME_WIRE_CEILING) {364 throw new Error("ADOPT_SNAPSHOT_CHUNK_TOO_LARGE");365 }366 const frame: unknown = JSON.parse(new TextDecoder().decode(raw));367 if (!isAdoptSnapshot(frame)) throw new Error("ADOPT_SNAPSHOT_INVALID_FRAME");368 if (chunkCount === -1) {369 chunkCount = frame.chunk_count;370 if (chunkCount > MAX_SNAPSHOT_CHUNKS) {371 throw new Error("ADOPT_SNAPSHOT_TOO_MANY_CHUNKS");372 }373 } else if (frame.chunk_count !== chunkCount) {374 throw new Error("ADOPT_SNAPSHOT_CHUNK_COUNT_MISMATCH");375 }376 if (frame.chunk_index !== received) {377 throw new Error("ADOPT_SNAPSHOT_CHUNK_OUT_OF_ORDER");378 }379 Object.assign(entries, frame.entries);380 Object.assign(tombstones, frame.tombstones);381 if (frame.duress_slot) duressSlot = frame.duress_slot;382 received++;383 }384 return { entries, tombstones, duressSlot };385}386387export function validateRootKeyHex(rootKeyHex: unknown): rootKeyHex is string {388 return typeof rootKeyHex === "string" && /^[0-9a-f]{64}$/.test(rootKeyHex);389}390391export interface AdoptionRequestResult {392 device: PairedDevice;393 394 snapshotCapable: boolean;395}396397export async function requestAdoption(398 channel: PairingTransport,399 deps: AdoptionReceiverDeps,400): Promise<AdoptionRequestResult | null> {401 const receiverLicense = await deps.license?.();402 await sendFrame(channel, {403 type: "ADOPT_REQUEST",404 device_id: deps.generateDeviceId(),405 device_name: deps.deviceName,406 device_type: "extension",407 license: receiverLicense,408 snapshot_capable: true,409 });410411 const confirmed = await deps.confirmSas();412 await sendFrame(channel, {413 type: "ADOPT_CONFIRM",414 confirmed,415 });416 if (!confirmed) {417 deps.onError("ADOPT_SAS_REJECTED");418 return null;419 }420421 const frame = await receiveFrame(channel);422 if (isAdoptReject(frame)) {423 deps.onError(frame.reason);424 return null;425 }426 if (!isAdoptGrant(frame)) {427 deps.onError("ADOPT_UNEXPECTED_FRAME");428 return null;429 }430 const grantSnapshotCapable = frame.snapshot_capable === true;431432 const rootKeyBytes = hexToBytes(frame.root_key_hex);433 try {434 const computedVaultId = await computeVaultId(rootKeyBytes);435 if (computedVaultId !== frame.vault_id) {436 deps.onError("ADOPT_VAULT_ID_MISMATCH");437 return null;438 }439 } finally {440 rootKeyBytes.fill(0);441 }442 if (!receiverLicense && !frame.license) {443 deps.onError("ADOPT_PRO_REQUIRED");444 return null;445 }446447 await deps.validateSource?.(frame.source);448 const { vaultId } = await deps.createVaultFromRootKey(frame.root_key_hex);449 if (vaultId !== frame.vault_id) {450 deps.onError("ADOPT_VAULT_ID_MISMATCH");451 return null;452 }453454 if (!receiverLicense && frame.license) {455 await deps.applyLicense?.(frame.license);456 }457458 const identity = await deps.deriveLocalIdentity();459 await sendFrame(channel, {460 type: "ADOPT_IDENTITY",461 device_id: identity.deviceId,462 device_name: identity.deviceName,463 device_type: identity.deviceType,464 long_term_public_key_hex: identity.longTermPublicKeyHex,465 signing_public_key_hex: identity.signingPublicKeyHex,466 });467468 const completion = await receiveFrame(channel);469 if (!isAdoptComplete(completion)) {470 deps.onError("ADOPT_COMPLETION_MISSING");471 return null;472 }473474 const sourceDevice: PairedDevice = {475 deviceId: frame.source.device_id,476 deviceName: frame.source.device_name,477 deviceType: frame.source.device_type,478 longTermPublicKeyHex: frame.source.long_term_public_key_hex,479 signingPublicKeyHex: frame.source.signing_public_key_hex,480 trustSource: "direct",481 wsPort: frame.source.ws_port,482 rendezvousSecretHex: deps.pairState?.rendezvousSecretHex,483 relayUrl: deps.pairState?.relayUrl ?? frame.source.relay_url,484 pairedAt: Date.now(),485 };486 await deps.upsertPairedDevice(sourceDevice);487 await sendFrame(channel, { type: "ADOPT_APPLIED" });488 return { device: sourceDevice, snapshotCapable: grantSnapshotCapable };489}490491export async function grantAdoption(492 channel: PairingTransport,493 request: AdoptRequest,494 deps: AdoptionSourceDeps,495): Promise<PairedDevice | null> {496 const approved = await deps.onConsent(request);497 if (!approved) return null;498499 const confirmation = await receiveFrame(channel);500 if (!isAdoptConfirm(confirmation) || !confirmation.confirmed) return null;501502 let sourceLicense = await deps.license?.();503 if (!sourceLicense && request.license) {504 try {505 if (!deps.applyLicense) throw new Error("license_transfer_unavailable");506 await deps.applyLicense(request.license);507 sourceLicense = await deps.license?.();508 } catch {509 sourceLicense = undefined;510 }511 }512 if (!sourceLicense) {513 await sendFrame(channel, {514 type: "ADOPT_REJECT",515 reason: "ADOPT_PRO_REQUIRED",516 });517 throw new Error("ADOPT_PRO_REQUIRED");518 }519520 const rootKeyHex = await deps.rootKeyHex();521 if (!validateRootKeyHex(rootKeyHex)) throw new Error("ADOPT_INVALID_ROOT_KEY");522523 await sendFrame(channel, {524 type: "ADOPT_GRANT",525 root_key_hex: rootKeyHex,526 vault_id: await deps.vaultId(),527 license: sourceLicense,528 source: {529 device_id: deps.sourceIdentity.deviceId,530 device_name: deps.sourceIdentity.deviceName,531 device_type: deps.sourceIdentity.deviceType,532 long_term_public_key_hex: deps.sourceIdentity.longTermPublicKeyHex,533 signing_public_key_hex: deps.sourceIdentity.signingPublicKeyHex,534 ws_port: deps.sourceIdentity.wsPort,535 relay_url: deps.pairState?.relayUrl,536 },537 snapshot_capable: request.snapshot_capable === true,538 });539540 const identity = await receiveFrame(channel);541 if (!isAdoptIdentity(identity)) return null;542543 const device: PairedDevice = {544 deviceId: identity.device_id,545 deviceName: identity.device_name,546 deviceType: identity.device_type,547 longTermPublicKeyHex: identity.long_term_public_key_hex,548 signingPublicKeyHex: identity.signing_public_key_hex,549 trustSource: "direct",550 rendezvousSecretHex: deps.pairState?.rendezvousSecretHex,551 relayUrl: deps.pairState?.relayUrl,552 pairedAt: Date.now(),553 };554 await deps.upsertPairedDevice(device);555 await sendFrame(channel, { type: "ADOPT_COMPLETE" });556 if (!isAdoptApplied(await receiveFrame(channel))) return null;557 return device;558}559560export async function sendFrame(channel: PairingTransport, frame: AdoptionFrame): Promise<void> {561 await channel.send(new TextEncoder().encode(JSON.stringify(frame)));562}563564export async function receiveFrame(channel: PairingTransport): Promise<unknown> {565 return JSON.parse(new TextDecoder().decode(await channel.receive()));566}567568function isDeviceType(value: unknown): value is DeviceType {569 return value === "extension" || value === "android" || value === "ios";570}571572function isSecureRelayUrl(value: string): boolean {573 try {574 const url = new URL(value);575 return url.protocol === "wss:" && !url.username && !url.password &&576 !url.search && !url.hash;577 } catch {578 return false;579 }580}581582function hexToBytes(hex: string): Uint8Array {583 const out = new Uint8Array(hex.length / 2);584 for (let i = 0; i < out.length; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);585 return out;586}