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}3536export interface AdoptConfirm {37 type: "ADOPT_CONFIRM";38 confirmed: boolean;39}4041export interface AdoptIdentity {42 type: "ADOPT_IDENTITY";43 device_id: string;44 device_name: string;45 device_type: DeviceType;46 long_term_public_key_hex: string;47 signing_public_key_hex?: string;48}49505152535455export interface AdoptComplete {56 type: "ADOPT_COMPLETE";57}585960export interface AdoptApplied {61 type: "ADOPT_APPLIED";62}636465export interface AdoptReject {66 type: "ADOPT_REJECT";67 reason: "ADOPT_PRO_REQUIRED";68}69707172737475export interface AdoptSnapshot {76 type: "ADOPT_SNAPSHOT";77 chunk_index: number;78 chunk_count: number;79 entries: Record<string, string>;80 81 tombstones: Record<string, number>;82 8384858687 duress_slot?: string;88}899091export interface AdoptSnapshotApplied {92 type: "ADOPT_SNAPSHOT_APPLIED";93 entries_received: number;94 tombstones_received: number;95}969798export interface AdoptDeviceManifestFrame {99 type: "ADOPT_DEVICE_MANIFEST";100 manifest: SignedDeviceManifest;101}102103104export interface AdoptDeviceManifestApplied {105 type: "ADOPT_DEVICE_MANIFEST_APPLIED";106}107108export type AdoptionFrame =109 | AdoptRequest110 | AdoptConfirm111 | AdoptGrant112 | AdoptIdentity113 | AdoptComplete114 | AdoptApplied115 | AdoptReject116 | AdoptSnapshot117 | AdoptSnapshotApplied118 | AdoptDeviceManifestFrame119 | AdoptDeviceManifestApplied;120121122123124125126127export const MAX_SNAPSHOT_CHUNK_BYTES = 512 * 1024; 128export const MAX_SNAPSHOT_CHUNKS = 200;129130131const SNAPSHOT_FRAME_WIRE_CEILING = MAX_SNAPSHOT_CHUNK_BYTES + 256 * 1024;132133export interface AdoptionPairState {134 rendezvousSecretHex: string;135 relayUrl?: string;136}137138export interface AdoptionReceiverDeps {139 deviceName: string;140 generateDeviceId(): string;141 142 confirmSas(): Promise<boolean>;143 144145146147 validateSource?(source: AdoptGrant["source"]): Promise<void>;148 createVaultFromRootKey(rootKeyHex: string): Promise<{ vaultId: string }>;149 deriveLocalIdentity(): Promise<LocalIdentity>;150 upsertPairedDevice(device: PairedDevice): Promise<void>;151 applyLicense?(license: string): Promise<void>;152 153 license?(): Promise<string | undefined>;154 155 pairState?: AdoptionPairState;156 onError(error: string): void;157}158159export interface AdoptionSourceDeps {160 sourceIdentity: LocalIdentity;161 rootKeyHex(): Promise<string>;162 vaultId(): Promise<string>;163 license?(): Promise<string | undefined>;164 165 applyLicense?(license: string): Promise<void>;166 onConsent(request: AdoptRequest): Promise<boolean>;167 upsertPairedDevice(device: PairedDevice): Promise<void>;168 169 pairState?: AdoptionPairState;170}171172export function isAdoptRequest(frame: unknown): frame is AdoptRequest {173 const value = frame as Partial<AdoptRequest> | null;174 return value?.type === "ADOPT_REQUEST" &&175 typeof value.device_id === "string" &&176 value.device_id.length > 0 &&177 typeof value.device_name === "string" &&178 isDeviceType(value.device_type) &&179 (value.license === undefined || typeof value.license === "string");180}181182export function isAdoptGrant(frame: unknown): frame is AdoptGrant {183 const value = frame as Partial<AdoptGrant> | null;184 return value?.type === "ADOPT_GRANT" &&185 validateRootKeyHex(value.root_key_hex) &&186 typeof value.vault_id === "string" &&187 /^[0-9a-f]{64}$/.test(value.vault_id) &&188 typeof value.source === "object" &&189 value.source !== null &&190 typeof value.source.device_id === "string" &&191 typeof value.source.device_name === "string" &&192 isDeviceType(value.source.device_type) &&193 typeof value.source.long_term_public_key_hex === "string" &&194 /^[0-9a-f]{64}$/.test(value.source.long_term_public_key_hex) &&195 (value.source.relay_url === undefined ||196 (typeof value.source.relay_url === "string" &&197 isSecureRelayUrl(value.source.relay_url)));198}199200export function isAdoptConfirm(frame: unknown): frame is AdoptConfirm {201 const value = frame as Partial<AdoptConfirm> | null;202 return value?.type === "ADOPT_CONFIRM" &&203 typeof value.confirmed === "boolean";204}205206export function isAdoptIdentity(frame: unknown): frame is AdoptIdentity {207 const value = frame as Partial<AdoptIdentity> | null;208 return value?.type === "ADOPT_IDENTITY" &&209 typeof value.device_id === "string" &&210 value.device_id.length > 0 &&211 typeof value.device_name === "string" &&212 isDeviceType(value.device_type) &&213 typeof value.long_term_public_key_hex === "string" &&214 /^[0-9a-f]{64}$/.test(value.long_term_public_key_hex);215}216217export function isAdoptComplete(frame: unknown): frame is AdoptComplete {218 return (frame as Partial<AdoptComplete> | null)?.type === "ADOPT_COMPLETE";219}220221export function isAdoptApplied(frame: unknown): frame is AdoptApplied {222 return (frame as Partial<AdoptApplied> | null)?.type === "ADOPT_APPLIED";223}224225export function isAdoptReject(frame: unknown): frame is AdoptReject {226 const value = frame as Partial<AdoptReject> | null;227 return value?.type === "ADOPT_REJECT" &&228 value.reason === "ADOPT_PRO_REQUIRED";229}230231export function isAdoptSnapshot(frame: unknown): frame is AdoptSnapshot {232 const value = frame as Partial<AdoptSnapshot> | null;233 if (value?.type !== "ADOPT_SNAPSHOT") return false;234 if (235 typeof value.chunk_index !== "number" ||236 !Number.isInteger(value.chunk_index) ||237 value.chunk_index < 0238 ) return false;239 if (240 typeof value.chunk_count !== "number" ||241 !Number.isInteger(value.chunk_count) ||242 value.chunk_count < 1243 ) return false;244 if (!isStringRecord(value.entries)) return false;245 if (!isNumberRecord(value.tombstones)) return false;246 if (value.duress_slot !== undefined && typeof value.duress_slot !== "string") return false;247 return true;248}249250export function isAdoptSnapshotApplied(frame: unknown): frame is AdoptSnapshotApplied {251 const value = frame as Partial<AdoptSnapshotApplied> | null;252 return value?.type === "ADOPT_SNAPSHOT_APPLIED" &&253 typeof value.entries_received === "number" &&254 typeof value.tombstones_received === "number";255}256257export function isAdoptDeviceManifestFrame(258 frame: unknown,259): frame is AdoptDeviceManifestFrame {260 const value = frame as Partial<AdoptDeviceManifestFrame> | null;261 const manifest = value?.manifest as262 | { body?: unknown; signatureHex?: unknown }263 | null;264 return value?.type === "ADOPT_DEVICE_MANIFEST" &&265 typeof manifest === "object" &&266 manifest !== null &&267 typeof manifest.body === "object" &&268 manifest.body !== null &&269 typeof manifest.signatureHex === "string";270}271272export function isAdoptDeviceManifestApplied(273 frame: unknown,274): frame is AdoptDeviceManifestApplied {275 return (frame as Partial<AdoptDeviceManifestApplied> | null)?.type ===276 "ADOPT_DEVICE_MANIFEST_APPLIED";277}278279function isStringRecord(value: unknown): value is Record<string, string> {280 if (typeof value !== "object" || value === null) return false;281 return Object.values(value).every((v) => typeof v === "string");282}283284function isNumberRecord(value: unknown): value is Record<string, number> {285 if (typeof value !== "object" || value === null) return false;286 return Object.values(value).every((v) => typeof v === "number");287}288289290291292293294295296export async function sendVaultSnapshot(297 channel: PairingTransport,298 entries: Record<string, string>,299 tombstones: Record<string, number>,300 duressSlot: string | null = null,301): Promise<void> {302 const chunks: Array<Record<string, string>> = [];303 let current: Record<string, string> = {};304 let currentBytes = 2; 305 for (const [id, envelope] of Object.entries(entries)) {306 307 const entryBytes = JSON.stringify({ [id]: envelope }).length + 1;308 if (309 Object.keys(current).length > 0 &&310 currentBytes + entryBytes >= MAX_SNAPSHOT_CHUNK_BYTES311 ) {312 chunks.push(current);313 current = {};314 currentBytes = 2;315 }316 current[id] = envelope;317 currentBytes += entryBytes;318 }319 chunks.push(current);320321 const chunkCount = Math.min(chunks.length, MAX_SNAPSHOT_CHUNKS);322 for (let index = 0; index < chunkCount; index++) {323 const isFinal = index === chunkCount - 1;324 const frame: AdoptSnapshot = {325 type: "ADOPT_SNAPSHOT",326 chunk_index: index,327 chunk_count: chunkCount,328 entries: chunks[index],329 tombstones: isFinal ? tombstones : {},330 };331 if (isFinal && duressSlot) {332 frame.duress_slot = duressSlot;333 }334 await sendFrame(channel, frame);335 }336}337338339340341342343344345export async function receiveVaultSnapshot(346 channel: PairingTransport,347): Promise<{ entries: Record<string, string>; tombstones: Record<string, number>; duressSlot: string | null }> {348 const entries: Record<string, string> = {};349 const tombstones: Record<string, number> = {};350 let duressSlot: string | null = null;351 let chunkCount = -1;352 let received = 0;353 while (chunkCount === -1 || received < chunkCount) {354 const raw = await channel.receive();355 if (raw.length > SNAPSHOT_FRAME_WIRE_CEILING) {356 throw new Error("ADOPT_SNAPSHOT_CHUNK_TOO_LARGE");357 }358 const frame: unknown = JSON.parse(new TextDecoder().decode(raw));359 if (!isAdoptSnapshot(frame)) throw new Error("ADOPT_SNAPSHOT_INVALID_FRAME");360 if (chunkCount === -1) {361 chunkCount = frame.chunk_count;362 if (chunkCount > MAX_SNAPSHOT_CHUNKS) {363 throw new Error("ADOPT_SNAPSHOT_TOO_MANY_CHUNKS");364 }365 } else if (frame.chunk_count !== chunkCount) {366 throw new Error("ADOPT_SNAPSHOT_CHUNK_COUNT_MISMATCH");367 }368 if (frame.chunk_index !== received) {369 throw new Error("ADOPT_SNAPSHOT_CHUNK_OUT_OF_ORDER");370 }371 Object.assign(entries, frame.entries);372 Object.assign(tombstones, frame.tombstones);373 if (frame.duress_slot) duressSlot = frame.duress_slot;374 received++;375 }376 return { entries, tombstones, duressSlot };377}378379export function validateRootKeyHex(rootKeyHex: unknown): rootKeyHex is string {380 return typeof rootKeyHex === "string" && /^[0-9a-f]{64}$/.test(rootKeyHex);381}382383export async function requestAdoption(384 channel: PairingTransport,385 deps: AdoptionReceiverDeps,386): Promise<PairedDevice | null> {387 const receiverLicense = await deps.license?.();388 await sendFrame(channel, {389 type: "ADOPT_REQUEST",390 device_id: deps.generateDeviceId(),391 device_name: deps.deviceName,392 device_type: "extension",393 license: receiverLicense,394 snapshot_capable: true,395 });396397 const confirmed = await deps.confirmSas();398 await sendFrame(channel, {399 type: "ADOPT_CONFIRM",400 confirmed,401 });402 if (!confirmed) {403 deps.onError("ADOPT_SAS_REJECTED");404 return null;405 }406407 const frame = await receiveFrame(channel);408 if (isAdoptReject(frame)) {409 deps.onError(frame.reason);410 return null;411 }412 if (!isAdoptGrant(frame)) {413 deps.onError("ADOPT_UNEXPECTED_FRAME");414 return null;415 }416417 const rootKeyBytes = hexToBytes(frame.root_key_hex);418 try {419 const computedVaultId = await computeVaultId(rootKeyBytes);420 if (computedVaultId !== frame.vault_id) {421 deps.onError("ADOPT_VAULT_ID_MISMATCH");422 return null;423 }424 } finally {425 rootKeyBytes.fill(0);426 }427 if (!receiverLicense && !frame.license) {428 deps.onError("ADOPT_PRO_REQUIRED");429 return null;430 }431432 await deps.validateSource?.(frame.source);433 const { vaultId } = await deps.createVaultFromRootKey(frame.root_key_hex);434 if (vaultId !== frame.vault_id) {435 deps.onError("ADOPT_VAULT_ID_MISMATCH");436 return null;437 }438439 if (!receiverLicense && frame.license) {440 await deps.applyLicense?.(frame.license);441 }442443 const identity = await deps.deriveLocalIdentity();444 await sendFrame(channel, {445 type: "ADOPT_IDENTITY",446 device_id: identity.deviceId,447 device_name: identity.deviceName,448 device_type: identity.deviceType,449 long_term_public_key_hex: identity.longTermPublicKeyHex,450 signing_public_key_hex: identity.signingPublicKeyHex,451 });452453 const completion = await receiveFrame(channel);454 if (!isAdoptComplete(completion)) {455 deps.onError("ADOPT_COMPLETION_MISSING");456 return null;457 }458459 const sourceDevice: PairedDevice = {460 deviceId: frame.source.device_id,461 deviceName: frame.source.device_name,462 deviceType: frame.source.device_type,463 longTermPublicKeyHex: frame.source.long_term_public_key_hex,464 signingPublicKeyHex: frame.source.signing_public_key_hex,465 trustSource: "direct",466 wsPort: frame.source.ws_port,467 rendezvousSecretHex: deps.pairState?.rendezvousSecretHex,468 relayUrl: deps.pairState?.relayUrl ?? frame.source.relay_url,469 pairedAt: Date.now(),470 };471 await deps.upsertPairedDevice(sourceDevice);472 await sendFrame(channel, { type: "ADOPT_APPLIED" });473 return sourceDevice;474}475476export async function grantAdoption(477 channel: PairingTransport,478 request: AdoptRequest,479 deps: AdoptionSourceDeps,480): Promise<PairedDevice | null> {481 const approved = await deps.onConsent(request);482 if (!approved) return null;483484 const confirmation = await receiveFrame(channel);485 if (!isAdoptConfirm(confirmation) || !confirmation.confirmed) return null;486487 let sourceLicense = await deps.license?.();488 if (!sourceLicense && request.license) {489 try {490 if (!deps.applyLicense) throw new Error("license_transfer_unavailable");491 await deps.applyLicense(request.license);492 sourceLicense = await deps.license?.();493 } catch {494 sourceLicense = undefined;495 }496 }497 if (!sourceLicense) {498 await sendFrame(channel, {499 type: "ADOPT_REJECT",500 reason: "ADOPT_PRO_REQUIRED",501 });502 throw new Error("ADOPT_PRO_REQUIRED");503 }504505 const rootKeyHex = await deps.rootKeyHex();506 if (!validateRootKeyHex(rootKeyHex)) throw new Error("ADOPT_INVALID_ROOT_KEY");507508 await sendFrame(channel, {509 type: "ADOPT_GRANT",510 root_key_hex: rootKeyHex,511 vault_id: await deps.vaultId(),512 license: sourceLicense,513 source: {514 device_id: deps.sourceIdentity.deviceId,515 device_name: deps.sourceIdentity.deviceName,516 device_type: deps.sourceIdentity.deviceType,517 long_term_public_key_hex: deps.sourceIdentity.longTermPublicKeyHex,518 signing_public_key_hex: deps.sourceIdentity.signingPublicKeyHex,519 ws_port: deps.sourceIdentity.wsPort,520 relay_url: deps.pairState?.relayUrl,521 },522 });523524 const identity = await receiveFrame(channel);525 if (!isAdoptIdentity(identity)) return null;526527 const device: PairedDevice = {528 deviceId: identity.device_id,529 deviceName: identity.device_name,530 deviceType: identity.device_type,531 longTermPublicKeyHex: identity.long_term_public_key_hex,532 signingPublicKeyHex: identity.signing_public_key_hex,533 trustSource: "direct",534 rendezvousSecretHex: deps.pairState?.rendezvousSecretHex,535 relayUrl: deps.pairState?.relayUrl,536 pairedAt: Date.now(),537 };538 await deps.upsertPairedDevice(device);539 await sendFrame(channel, { type: "ADOPT_COMPLETE" });540 if (!isAdoptApplied(await receiveFrame(channel))) return null;541 return device;542}543544export async function sendFrame(channel: PairingTransport, frame: AdoptionFrame): Promise<void> {545 await channel.send(new TextEncoder().encode(JSON.stringify(frame)));546}547548export async function receiveFrame(channel: PairingTransport): Promise<unknown> {549 return JSON.parse(new TextDecoder().decode(await channel.receive()));550}551552function isDeviceType(value: unknown): value is DeviceType {553 return value === "extension" || value === "android" || value === "ios";554}555556function isSecureRelayUrl(value: string): boolean {557 try {558 const url = new URL(value);559 return url.protocol === "wss:" && !url.username && !url.password &&560 !url.search && !url.hash;561 } catch {562 return false;563 }564}565566function hexToBytes(hex: string): Uint8Array {567 const out = new Uint8Array(hex.length / 2);568 for (let i = 0; i < out.length; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);569 return out;570}