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}838485export interface AdoptSnapshotApplied {86 type: "ADOPT_SNAPSHOT_APPLIED";87 entries_received: number;88 tombstones_received: number;89}909192export interface AdoptDeviceManifestFrame {93 type: "ADOPT_DEVICE_MANIFEST";94 manifest: SignedDeviceManifest;95}969798export interface AdoptDeviceManifestApplied {99 type: "ADOPT_DEVICE_MANIFEST_APPLIED";100}101102export type AdoptionFrame =103 | AdoptRequest104 | AdoptConfirm105 | AdoptGrant106 | AdoptIdentity107 | AdoptComplete108 | AdoptApplied109 | AdoptReject110 | AdoptSnapshot111 | AdoptSnapshotApplied112 | AdoptDeviceManifestFrame113 | AdoptDeviceManifestApplied;114115116117118119120121export const MAX_SNAPSHOT_CHUNK_BYTES = 512 * 1024; 122export const MAX_SNAPSHOT_CHUNKS = 200;123124125const SNAPSHOT_FRAME_WIRE_CEILING = MAX_SNAPSHOT_CHUNK_BYTES + 256 * 1024;126127export interface AdoptionPairState {128 rendezvousSecretHex: string;129 relayUrl?: string;130}131132export interface AdoptionReceiverDeps {133 deviceName: string;134 generateDeviceId(): string;135 136 confirmSas(): Promise<boolean>;137 138139140141 validateSource?(source: AdoptGrant["source"]): Promise<void>;142 createVaultFromRootKey(rootKeyHex: string): Promise<{ vaultId: string }>;143 deriveLocalIdentity(): Promise<LocalIdentity>;144 upsertPairedDevice(device: PairedDevice): Promise<void>;145 applyLicense?(license: string): Promise<void>;146 147 license?(): Promise<string | undefined>;148 149 pairState?: AdoptionPairState;150 onError(error: string): void;151}152153export interface AdoptionSourceDeps {154 sourceIdentity: LocalIdentity;155 rootKeyHex(): Promise<string>;156 vaultId(): Promise<string>;157 license?(): Promise<string | undefined>;158 159 applyLicense?(license: string): Promise<void>;160 onConsent(request: AdoptRequest): Promise<boolean>;161 upsertPairedDevice(device: PairedDevice): Promise<void>;162 163 pairState?: AdoptionPairState;164}165166export function isAdoptRequest(frame: unknown): frame is AdoptRequest {167 const value = frame as Partial<AdoptRequest> | null;168 return value?.type === "ADOPT_REQUEST" &&169 typeof value.device_id === "string" &&170 value.device_id.length > 0 &&171 typeof value.device_name === "string" &&172 isDeviceType(value.device_type) &&173 (value.license === undefined || typeof value.license === "string");174}175176export function isAdoptGrant(frame: unknown): frame is AdoptGrant {177 const value = frame as Partial<AdoptGrant> | null;178 return value?.type === "ADOPT_GRANT" &&179 validateRootKeyHex(value.root_key_hex) &&180 typeof value.vault_id === "string" &&181 /^[0-9a-f]{64}$/.test(value.vault_id) &&182 typeof value.source === "object" &&183 value.source !== null &&184 typeof value.source.device_id === "string" &&185 typeof value.source.device_name === "string" &&186 isDeviceType(value.source.device_type) &&187 typeof value.source.long_term_public_key_hex === "string" &&188 /^[0-9a-f]{64}$/.test(value.source.long_term_public_key_hex) &&189 (value.source.relay_url === undefined ||190 (typeof value.source.relay_url === "string" &&191 isSecureRelayUrl(value.source.relay_url)));192}193194export function isAdoptConfirm(frame: unknown): frame is AdoptConfirm {195 const value = frame as Partial<AdoptConfirm> | null;196 return value?.type === "ADOPT_CONFIRM" &&197 typeof value.confirmed === "boolean";198}199200export function isAdoptIdentity(frame: unknown): frame is AdoptIdentity {201 const value = frame as Partial<AdoptIdentity> | null;202 return value?.type === "ADOPT_IDENTITY" &&203 typeof value.device_id === "string" &&204 value.device_id.length > 0 &&205 typeof value.device_name === "string" &&206 isDeviceType(value.device_type) &&207 typeof value.long_term_public_key_hex === "string" &&208 /^[0-9a-f]{64}$/.test(value.long_term_public_key_hex);209}210211export function isAdoptComplete(frame: unknown): frame is AdoptComplete {212 return (frame as Partial<AdoptComplete> | null)?.type === "ADOPT_COMPLETE";213}214215export function isAdoptApplied(frame: unknown): frame is AdoptApplied {216 return (frame as Partial<AdoptApplied> | null)?.type === "ADOPT_APPLIED";217}218219export function isAdoptReject(frame: unknown): frame is AdoptReject {220 const value = frame as Partial<AdoptReject> | null;221 return value?.type === "ADOPT_REJECT" &&222 value.reason === "ADOPT_PRO_REQUIRED";223}224225export function isAdoptSnapshot(frame: unknown): frame is AdoptSnapshot {226 const value = frame as Partial<AdoptSnapshot> | null;227 if (value?.type !== "ADOPT_SNAPSHOT") return false;228 if (229 typeof value.chunk_index !== "number" ||230 !Number.isInteger(value.chunk_index) ||231 value.chunk_index < 0232 ) return false;233 if (234 typeof value.chunk_count !== "number" ||235 !Number.isInteger(value.chunk_count) ||236 value.chunk_count < 1237 ) return false;238 if (!isStringRecord(value.entries)) return false;239 if (!isNumberRecord(value.tombstones)) return false;240 return true;241}242243export function isAdoptSnapshotApplied(frame: unknown): frame is AdoptSnapshotApplied {244 const value = frame as Partial<AdoptSnapshotApplied> | null;245 return value?.type === "ADOPT_SNAPSHOT_APPLIED" &&246 typeof value.entries_received === "number" &&247 typeof value.tombstones_received === "number";248}249250export function isAdoptDeviceManifestFrame(251 frame: unknown,252): frame is AdoptDeviceManifestFrame {253 const value = frame as Partial<AdoptDeviceManifestFrame> | null;254 const manifest = value?.manifest as255 | { body?: unknown; signatureHex?: unknown }256 | null;257 return value?.type === "ADOPT_DEVICE_MANIFEST" &&258 typeof manifest === "object" &&259 manifest !== null &&260 typeof manifest.body === "object" &&261 manifest.body !== null &&262 typeof manifest.signatureHex === "string";263}264265export function isAdoptDeviceManifestApplied(266 frame: unknown,267): frame is AdoptDeviceManifestApplied {268 return (frame as Partial<AdoptDeviceManifestApplied> | null)?.type ===269 "ADOPT_DEVICE_MANIFEST_APPLIED";270}271272function isStringRecord(value: unknown): value is Record<string, string> {273 if (typeof value !== "object" || value === null) return false;274 return Object.values(value).every((v) => typeof v === "string");275}276277function isNumberRecord(value: unknown): value is Record<string, number> {278 if (typeof value !== "object" || value === null) return false;279 return Object.values(value).every((v) => typeof v === "number");280}281282283284285286287288289export async function sendVaultSnapshot(290 channel: PairingTransport,291 entries: Record<string, string>,292 tombstones: Record<string, number>,293): Promise<void> {294 const chunks: Array<Record<string, string>> = [];295 let current: Record<string, string> = {};296 let currentBytes = 2; 297 for (const [id, envelope] of Object.entries(entries)) {298 299 const entryBytes = JSON.stringify({ [id]: envelope }).length + 1;300 if (301 Object.keys(current).length > 0 &&302 currentBytes + entryBytes >= MAX_SNAPSHOT_CHUNK_BYTES303 ) {304 chunks.push(current);305 current = {};306 currentBytes = 2;307 }308 current[id] = envelope;309 currentBytes += entryBytes;310 }311 chunks.push(current);312313 const chunkCount = Math.min(chunks.length, MAX_SNAPSHOT_CHUNKS);314 for (let index = 0; index < chunkCount; index++) {315 const isFinal = index === chunkCount - 1;316 await sendFrame(channel, {317 type: "ADOPT_SNAPSHOT",318 chunk_index: index,319 chunk_count: chunkCount,320 entries: chunks[index],321 tombstones: isFinal ? tombstones : {},322 });323 }324}325326327328329330331332333export async function receiveVaultSnapshot(334 channel: PairingTransport,335): Promise<{ entries: Record<string, string>; tombstones: Record<string, number> }> {336 const entries: Record<string, string> = {};337 const tombstones: Record<string, number> = {};338 let chunkCount = -1;339 let received = 0;340 while (chunkCount === -1 || received < chunkCount) {341 const raw = await channel.receive();342 if (raw.length > SNAPSHOT_FRAME_WIRE_CEILING) {343 throw new Error("ADOPT_SNAPSHOT_CHUNK_TOO_LARGE");344 }345 const frame: unknown = JSON.parse(new TextDecoder().decode(raw));346 if (!isAdoptSnapshot(frame)) throw new Error("ADOPT_SNAPSHOT_INVALID_FRAME");347 if (chunkCount === -1) {348 chunkCount = frame.chunk_count;349 if (chunkCount > MAX_SNAPSHOT_CHUNKS) {350 throw new Error("ADOPT_SNAPSHOT_TOO_MANY_CHUNKS");351 }352 } else if (frame.chunk_count !== chunkCount) {353 throw new Error("ADOPT_SNAPSHOT_CHUNK_COUNT_MISMATCH");354 }355 if (frame.chunk_index !== received) {356 throw new Error("ADOPT_SNAPSHOT_CHUNK_OUT_OF_ORDER");357 }358 Object.assign(entries, frame.entries);359 Object.assign(tombstones, frame.tombstones);360 received++;361 }362 return { entries, tombstones };363}364365export function validateRootKeyHex(rootKeyHex: unknown): rootKeyHex is string {366 return typeof rootKeyHex === "string" && /^[0-9a-f]{64}$/.test(rootKeyHex);367}368369export async function requestAdoption(370 channel: PairingTransport,371 deps: AdoptionReceiverDeps,372): Promise<PairedDevice | null> {373 const receiverLicense = await deps.license?.();374 await sendFrame(channel, {375 type: "ADOPT_REQUEST",376 device_id: deps.generateDeviceId(),377 device_name: deps.deviceName,378 device_type: "extension",379 license: receiverLicense,380 snapshot_capable: true,381 });382383 const confirmed = await deps.confirmSas();384 await sendFrame(channel, {385 type: "ADOPT_CONFIRM",386 confirmed,387 });388 if (!confirmed) {389 deps.onError("ADOPT_SAS_REJECTED");390 return null;391 }392393 const frame = await receiveFrame(channel);394 if (isAdoptReject(frame)) {395 deps.onError(frame.reason);396 return null;397 }398 if (!isAdoptGrant(frame)) {399 deps.onError("ADOPT_UNEXPECTED_FRAME");400 return null;401 }402403 const rootKeyBytes = hexToBytes(frame.root_key_hex);404 try {405 const computedVaultId = await computeVaultId(rootKeyBytes);406 if (computedVaultId !== frame.vault_id) {407 deps.onError("ADOPT_VAULT_ID_MISMATCH");408 return null;409 }410 } finally {411 rootKeyBytes.fill(0);412 }413 if (!receiverLicense && !frame.license) {414 deps.onError("ADOPT_PRO_REQUIRED");415 return null;416 }417418 await deps.validateSource?.(frame.source);419 const { vaultId } = await deps.createVaultFromRootKey(frame.root_key_hex);420 if (vaultId !== frame.vault_id) {421 deps.onError("ADOPT_VAULT_ID_MISMATCH");422 return null;423 }424425 if (!receiverLicense && frame.license) {426 await deps.applyLicense?.(frame.license);427 }428429 const identity = await deps.deriveLocalIdentity();430 await sendFrame(channel, {431 type: "ADOPT_IDENTITY",432 device_id: identity.deviceId,433 device_name: identity.deviceName,434 device_type: identity.deviceType,435 long_term_public_key_hex: identity.longTermPublicKeyHex,436 signing_public_key_hex: identity.signingPublicKeyHex,437 });438439 const completion = await receiveFrame(channel);440 if (!isAdoptComplete(completion)) {441 deps.onError("ADOPT_COMPLETION_MISSING");442 return null;443 }444445 const sourceDevice: PairedDevice = {446 deviceId: frame.source.device_id,447 deviceName: frame.source.device_name,448 deviceType: frame.source.device_type,449 longTermPublicKeyHex: frame.source.long_term_public_key_hex,450 signingPublicKeyHex: frame.source.signing_public_key_hex,451 trustSource: "direct",452 wsPort: frame.source.ws_port,453 rendezvousSecretHex: deps.pairState?.rendezvousSecretHex,454 relayUrl: deps.pairState?.relayUrl ?? frame.source.relay_url,455 pairedAt: Date.now(),456 };457 await deps.upsertPairedDevice(sourceDevice);458 await sendFrame(channel, { type: "ADOPT_APPLIED" });459 return sourceDevice;460}461462export async function grantAdoption(463 channel: PairingTransport,464 request: AdoptRequest,465 deps: AdoptionSourceDeps,466): Promise<PairedDevice | null> {467 const approved = await deps.onConsent(request);468 if (!approved) return null;469470 const confirmation = await receiveFrame(channel);471 if (!isAdoptConfirm(confirmation) || !confirmation.confirmed) return null;472473 let sourceLicense = await deps.license?.();474 if (!sourceLicense && request.license) {475 try {476 if (!deps.applyLicense) throw new Error("license_transfer_unavailable");477 await deps.applyLicense(request.license);478 sourceLicense = await deps.license?.();479 } catch {480 sourceLicense = undefined;481 }482 }483 if (!sourceLicense) {484 await sendFrame(channel, {485 type: "ADOPT_REJECT",486 reason: "ADOPT_PRO_REQUIRED",487 });488 throw new Error("ADOPT_PRO_REQUIRED");489 }490491 const rootKeyHex = await deps.rootKeyHex();492 if (!validateRootKeyHex(rootKeyHex)) throw new Error("ADOPT_INVALID_ROOT_KEY");493494 await sendFrame(channel, {495 type: "ADOPT_GRANT",496 root_key_hex: rootKeyHex,497 vault_id: await deps.vaultId(),498 license: sourceLicense,499 source: {500 device_id: deps.sourceIdentity.deviceId,501 device_name: deps.sourceIdentity.deviceName,502 device_type: deps.sourceIdentity.deviceType,503 long_term_public_key_hex: deps.sourceIdentity.longTermPublicKeyHex,504 signing_public_key_hex: deps.sourceIdentity.signingPublicKeyHex,505 ws_port: deps.sourceIdentity.wsPort,506 relay_url: deps.pairState?.relayUrl,507 },508 });509510 const identity = await receiveFrame(channel);511 if (!isAdoptIdentity(identity)) return null;512513 const device: PairedDevice = {514 deviceId: identity.device_id,515 deviceName: identity.device_name,516 deviceType: identity.device_type,517 longTermPublicKeyHex: identity.long_term_public_key_hex,518 signingPublicKeyHex: identity.signing_public_key_hex,519 trustSource: "direct",520 rendezvousSecretHex: deps.pairState?.rendezvousSecretHex,521 relayUrl: deps.pairState?.relayUrl,522 pairedAt: Date.now(),523 };524 await deps.upsertPairedDevice(device);525 await sendFrame(channel, { type: "ADOPT_COMPLETE" });526 if (!isAdoptApplied(await receiveFrame(channel))) return null;527 return device;528}529530export async function sendFrame(channel: PairingTransport, frame: AdoptionFrame): Promise<void> {531 await channel.send(new TextEncoder().encode(JSON.stringify(frame)));532}533534export async function receiveFrame(channel: PairingTransport): Promise<unknown> {535 return JSON.parse(new TextDecoder().decode(await channel.receive()));536}537538function isDeviceType(value: unknown): value is DeviceType {539 return value === "extension" || value === "android" || value === "ios";540}541542function isSecureRelayUrl(value: string): boolean {543 try {544 const url = new URL(value);545 return url.protocol === "wss:" && !url.username && !url.password &&546 !url.search && !url.hash;547 } catch {548 return false;549 }550}551552function hexToBytes(hex: string): Uint8Array {553 const out = new Uint8Array(hex.length / 2);554 for (let i = 0; i < out.length; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);555 return out;556}