import { computeVaultId } from "./sync-identity"; import type { DeviceType, PairedDevice } from "./sync-types"; import type { LocalIdentity, PairingTransport } from "./pairing-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; relay_url?: string; }; } export interface AdoptConfirm { type: "ADOPT_CONFIRM"; confirmed: boolean; } 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 | AdoptConfirm | AdoptGrant | AdoptIdentity; export interface AdoptionPairState { rendezvousSecretHex: string; relayUrl?: string; } export interface AdoptionReceiverDeps { deviceName: string; generateDeviceId(): string; /** Root-key transfer cannot begin until this device confirms the SAS. */ confirmSas(): Promise; createVaultFromRootKey(rootKeyHex: string): Promise<{ vaultId: string }>; deriveLocalIdentity(): Promise; upsertPairedDevice(device: PairedDevice): Promise; applyLicense?(license: string): Promise; /** Bound to the same completed Noise session, never taken from frame claims. */ pairState?: AdoptionPairState; onError(error: string): void; } export interface AdoptionSourceDeps { sourceIdentity: LocalIdentity; rootKeyHex(): Promise; vaultId(): Promise; license?(): Promise; onConsent(request: AdoptRequest): Promise; upsertPairedDevice(device: PairedDevice): Promise; /** Bound to the same completed Noise session, never taken from frame claims. */ pairState?: AdoptionPairState; } 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) && (value.source.relay_url === undefined || (typeof value.source.relay_url === "string" && isSecureRelayUrl(value.source.relay_url))); } export function isAdoptConfirm(frame: unknown): frame is AdoptConfirm { const value = frame as Partial | null; return value?.type === "ADOPT_CONFIRM" && typeof value.confirmed === "boolean"; } 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 confirmed = await deps.confirmSas(); await sendFrame(channel, { type: "ADOPT_CONFIRM", confirmed, }); if (!confirmed) { deps.onError("ADOPT_SAS_REJECTED"); return null; } const frame = await receiveFrame(channel); if (!isAdoptGrant(frame)) { deps.onError("ADOPT_UNEXPECTED_FRAME"); return null; } 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 null; } } 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 null; } 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: identity.deviceName, device_type: identity.deviceType, long_term_public_key_hex: identity.longTermPublicKeyHex, signing_public_key_hex: identity.signingPublicKeyHex, }); const sourceDevice: PairedDevice = { 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, rendezvousSecretHex: deps.pairState?.rendezvousSecretHex, relayUrl: deps.pairState?.relayUrl ?? frame.source.relay_url, pairedAt: Date.now(), }; await deps.upsertPairedDevice(sourceDevice); return sourceDevice; } export async function grantAdoption( channel: PairingTransport, request: AdoptRequest, deps: AdoptionSourceDeps, ): Promise { const approved = await deps.onConsent(request); if (!approved) return null; const confirmation = await receiveFrame(channel); if (!isAdoptConfirm(confirmation) || !confirmation.confirmed) 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, relay_url: deps.pairState?.relayUrl, }, }); 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", rendezvousSecretHex: deps.pairState?.rendezvousSecretHex, relayUrl: deps.pairState?.relayUrl, 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"; } function isSecureRelayUrl(value: string): boolean { try { const url = new URL(value); return url.protocol === "wss:" && !url.username && !url.password && !url.search && !url.hash; } catch { return false; } } 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; }