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; /** Signed Pro entitlement offered by the receiver, if it has one. */ license?: string; } 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; } /** * Encrypted source-to-receiver acknowledgement sent only after the source has * durably recorded the receiver. Without it the receiver could report success * after sending ADOPT_IDENTITY even though the source never received it. */ export interface AdoptComplete { type: "ADOPT_COMPLETE"; } /** Receiver-to-source acknowledgement sent after the source record is durable. */ export interface AdoptApplied { type: "ADOPT_APPLIED"; } /** Neither peer could prove a Pro entitlement, so no vault state may commit. */ export interface AdoptReject { type: "ADOPT_REJECT"; reason: "ADOPT_PRO_REQUIRED"; } export type AdoptionFrame = | AdoptRequest | AdoptConfirm | AdoptGrant | AdoptIdentity | AdoptComplete | AdoptApplied | AdoptReject; 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; /** * Optional local safety policy evaluated after the authenticated grant is * validated but before any vault state changes. */ validateSource?(source: AdoptGrant["source"]): Promise; createVaultFromRootKey(rootKeyHex: string): Promise<{ vaultId: string }>; deriveLocalIdentity(): Promise; upsertPairedDevice(device: PairedDevice): Promise; applyLicense?(license: string): Promise; /** Return this receiver's locally validated Pro key, if present. */ license?(): 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; /** Validate and durably store a Pro key offered by the receiver. */ applyLicense?(license: string): 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) && (value.license === undefined || typeof value.license === "string"); } 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 isAdoptComplete(frame: unknown): frame is AdoptComplete { return (frame as Partial | null)?.type === "ADOPT_COMPLETE"; } export function isAdoptApplied(frame: unknown): frame is AdoptApplied { return (frame as Partial | null)?.type === "ADOPT_APPLIED"; } export function isAdoptReject(frame: unknown): frame is AdoptReject { const value = frame as Partial | null; return value?.type === "ADOPT_REJECT" && value.reason === "ADOPT_PRO_REQUIRED"; } 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 { const receiverLicense = await deps.license?.(); await sendFrame(channel, { type: "ADOPT_REQUEST", device_id: deps.generateDeviceId(), device_name: deps.deviceName, device_type: "extension", license: receiverLicense, }); 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 (isAdoptReject(frame)) { deps.onError(frame.reason); return null; } 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); } if (!receiverLicense && !frame.license) { deps.onError("ADOPT_PRO_REQUIRED"); return null; } await deps.validateSource?.(frame.source); const { vaultId } = await deps.createVaultFromRootKey(frame.root_key_hex); if (vaultId !== frame.vault_id) { deps.onError("ADOPT_VAULT_ID_MISMATCH"); return null; } if (!receiverLicense && 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 completion = await receiveFrame(channel); if (!isAdoptComplete(completion)) { deps.onError("ADOPT_COMPLETION_MISSING"); return null; } 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); await sendFrame(channel, { type: "ADOPT_APPLIED" }); 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; let sourceLicense = await deps.license?.(); if (!sourceLicense && request.license) { try { if (!deps.applyLicense) throw new Error("license_transfer_unavailable"); await deps.applyLicense(request.license); sourceLicense = await deps.license?.(); } catch { sourceLicense = undefined; } } if (!sourceLicense) { await sendFrame(channel, { type: "ADOPT_REJECT", reason: "ADOPT_PRO_REQUIRED", }); throw new Error("ADOPT_PRO_REQUIRED"); } 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: sourceLicense, 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); await sendFrame(channel, { type: "ADOPT_COMPLETE" }); if (!isAdoptApplied(await receiveFrame(channel))) return null; 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; }