import { computeVaultId } from "./sync-identity"; import type { DeviceType, PairedDevice } from "./sync-types"; import type { LocalIdentity, PairingTransport } from "./chirp-handshake"; export interface AdoptRequest { type: "ADOPT_REQUEST"; device_id: string; device_name: string; device_type: DeviceType; } 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; }; } 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; } export type AdoptionFrame = AdoptRequest | AdoptGrant | AdoptIdentity; export interface AdoptionReceiverDeps { deviceName: string; generateDeviceId(): string; createVaultFromRootKey(rootKeyHex: string): Promise<{ vaultId: string }>; deriveLocalIdentity(): Promise; upsertPairedDevice(device: PairedDevice): Promise; applyLicense?(license: string): Promise; onError(error: string): void; } export interface AdoptionSourceDeps { sourceIdentity: LocalIdentity; rootKeyHex(): Promise; vaultId(): Promise; license?(): Promise; onConsent(request: AdoptRequest): Promise; upsertPairedDevice(device: PairedDevice): Promise; } 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); } 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); } 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 validateRootKeyHex(rootKeyHex: unknown): rootKeyHex is string { return typeof rootKeyHex === "string" && /^[0-9a-f]{64}$/.test(rootKeyHex); } export async function requestAdoption( channel: PairingTransport, deps: AdoptionReceiverDeps, ): Promise { await sendFrame(channel, { type: "ADOPT_REQUEST", device_id: deps.generateDeviceId(), device_name: deps.deviceName, device_type: "extension", }); const frame = await receiveFrame(channel); if (!isAdoptGrant(frame)) { deps.onError("ADOPT_UNEXPECTED_FRAME"); return; } 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; } } finally { rootKeyBytes.fill(0); } const { vaultId } = await deps.createVaultFromRootKey(frame.root_key_hex); if (vaultId !== frame.vault_id) { deps.onError("ADOPT_VAULT_ID_MISMATCH"); return; } if (frame.license) await deps.applyLicense?.(frame.license); const identity = await deps.deriveLocalIdentity(); await sendFrame(channel, { type: "ADOPT_IDENTITY", device_id: identity.deviceId, device_name: deps.deviceName, device_type: identity.deviceType, long_term_public_key_hex: identity.longTermPublicKeyHex, signing_public_key_hex: identity.signingPublicKeyHex, }); await deps.upsertPairedDevice({ 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, pairedAt: Date.now(), }); } export async function grantAdoption( channel: PairingTransport, request: AdoptRequest, deps: AdoptionSourceDeps, ): Promise { const approved = await deps.onConsent(request); if (!approved) return null; 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: await deps.license?.(), 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, }, }); 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", pairedAt: Date.now(), }; await deps.upsertPairedDevice(device); 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" || value === "desktop"; } 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; }