import { computeVaultId } from "./sync-identity"; import { capDeviceName } from "./sync-limits"; import type { DeviceType, PairedDevice } from "./sync-types"; import type { LocalIdentity, PairingTransport } from "./pairing-handshake"; import { receiveFrame } from "./vault-adopt"; export const ADOPTION_VERSION = 2; export type AdoptionRejectReason = "ADOPT_PRO_REQUIRED" | "ADOPT_DEVICE_ID_COLLISION"; export interface AdoptRequestV2 { type: "ADOPT_REQUEST_V2"; adoptionVersion: 2; device_id: string; device_name: string; device_type: DeviceType; long_term_public_key_hex: string; signing_public_key_hex: string; license?: string; snapshot_capable?: boolean; } export interface AdoptConfirmRequired { type: "ADOPT_CONFIRM_REQUIRED"; } export interface AdoptConfirm { type: "ADOPT_CONFIRM"; confirmed: boolean; } export interface AdoptPrepare { type: "ADOPT_PREPARE"; device_id: string; device_name: string; device_type: DeviceType; long_term_public_key_hex: string; signing_public_key_hex: string; vault_id: string; ws_port?: number; relay_url?: string; license?: string; snapshot_capable?: boolean; } export interface AdoptReady { type: "ADOPT_READY"; } export interface AdoptGrantV2 { type: "ADOPT_GRANT"; root_key_hex: string; vault_id: string; license?: string; } export interface AdoptApplied { type: "ADOPT_APPLIED"; } export interface AdoptComplete { type: "ADOPT_COMPLETE"; } export interface AdoptRejectV2 { type: "ADOPT_REJECT"; reason: AdoptionRejectReason; } const hex = (v: unknown): v is string => typeof v === "string" && /^[0-9a-f]{64}$/.test(v); const device = (v: unknown): v is DeviceType => v === "extension" || v === "android" || v === "ios"; const relay = (v: unknown): v is string => { if (typeof v !== "string") return false; try { return new URL(v).protocol === "wss:"; } catch { return false; } }; export function isAdoptRequestV2(v: unknown): v is AdoptRequestV2 { const x = v as Partial | null; return !!x && x.type === "ADOPT_REQUEST_V2" && x.adoptionVersion === 2 && typeof x.device_id === "string" && x.device_id.length > 0 && typeof x.device_name === "string" && device(x.device_type) && hex(x.long_term_public_key_hex) && hex(x.signing_public_key_hex) && (x.license === undefined || typeof x.license === "string") && (x.snapshot_capable === undefined || typeof x.snapshot_capable === "boolean"); } export function isAdoptPrepare(v: unknown): v is AdoptPrepare { const x = v as Partial | null; return !!x && x.type === "ADOPT_PREPARE" && typeof x.device_id === "string" && x.device_id.length > 0 && typeof x.device_name === "string" && device(x.device_type) && hex(x.long_term_public_key_hex) && hex(x.signing_public_key_hex) && hex(x.vault_id) && (x.ws_port === undefined || (Number.isInteger(x.ws_port) && (x.ws_port as number) >= 1 && (x.ws_port as number) <= 65_535)) && (x.relay_url === undefined || relay(x.relay_url)) && (x.license === undefined || typeof x.license === "string") && (x.snapshot_capable === undefined || typeof x.snapshot_capable === "boolean"); } export function isAdoptRejectV2(v: unknown): v is AdoptRejectV2 { const x = v as Partial | null; return !!x && x.type === "ADOPT_REJECT" && (x.reason === "ADOPT_PRO_REQUIRED" || x.reason === "ADOPT_DEVICE_ID_COLLISION"); } export interface AdoptionV2ReceiverDeps { deviceName: string; localIdentity(): Promise; rotateLocalIdentity(): Promise; license(): Promise; confirmSas(): Promise; validateSource(prepare: AdoptPrepare): Promise; hasSourceIdentityCollision(prepare: AdoptPrepare): Promise; createVaultFromRootKey(rootKeyHex: string): Promise<{ vaultId: string }>; applyLicense(license: string): Promise; upsertPairedDeviceAndClearRevocation(device: PairedDevice): Promise; pairState?: { relayUrl?: string; rendezvousSecretHex?: string }; onError(error: string): void; } export interface AdoptionV2SourceDeps { sourceIdentity: LocalIdentity; rootKeyHex(): Promise; vaultId(): Promise; license(): Promise; applyLicense(license: string): Promise; onConsent(request: AdoptRequestV2): Promise; persistPendingIdentity(device: PairedDevice, nowMs: number): Promise; completePendingIdentity(device: PairedDevice): Promise; hasDeviceIdentityCollision(device: PairedDevice): Promise; rotateSourceIdentity(): Promise; upsertPairedDevice(device: PairedDevice): Promise; snapshotCapable(): boolean; pairState?: { relayUrl?: string; rendezvousSecretHex?: string }; } function toDevice( i: { device_id: string; device_name: string; device_type: DeviceType; long_term_public_key_hex: string; signing_public_key_hex: string; }, deps: { pairState?: { relayUrl?: string; rendezvousSecretHex?: string } }, ): PairedDevice { return { deviceId: i.device_id, deviceName: capDeviceName(i.device_name), deviceType: i.device_type, longTermPublicKeyHex: i.long_term_public_key_hex, signingPublicKeyHex: i.signing_public_key_hex, trustSource: "direct", relayUrl: deps.pairState?.relayUrl, rendezvousSecretHex: deps.pairState?.rendezvousSecretHex, pairedAt: Date.now(), }; } function requestFrame(i: LocalIdentity, license?: string): AdoptRequestV2 { return { type: "ADOPT_REQUEST_V2", adoptionVersion: 2, device_id: i.deviceId, device_name: i.deviceName, device_type: i.deviceType, long_term_public_key_hex: i.longTermPublicKeyHex, signing_public_key_hex: i.signingPublicKeyHex ?? "", license, snapshot_capable: true, }; } async function sendV2(channel: PairingTransport, frame: unknown): Promise { await channel.send(new TextEncoder().encode(JSON.stringify(frame))); } function hexToBytes(hexValue: string): Uint8Array { const bytes = new Uint8Array(hexValue.length / 2); for (let i = 0; i < bytes.length; i++) { bytes[i] = Number.parseInt(hexValue.slice(i * 2, i * 2 + 2), 16); } return bytes; } export async function requestAdoptionV2( channel: PairingTransport, deps: AdoptionV2ReceiverDeps, ): Promise<{ device: PairedDevice; snapshotCapable: boolean } | null> { let identity = await deps.localIdentity(); const ownLicense = await deps.license(); await sendV2(channel, requestFrame(identity, ownLicense)); if ((await receiveFrame(channel) as Partial | null)?.type !== "ADOPT_CONFIRM_REQUIRED") { deps.onError("ADOPT_UNEXPECTED_FRAME"); return null; } const confirmed = await deps.confirmSas(); await sendV2(channel, { type: "ADOPT_CONFIRM", confirmed }); if (!confirmed) { deps.onError("ADOPT_SAS_REJECTED"); return null; } let frame = await receiveFrame(channel); if (isAdoptRejectV2(frame) && frame.reason === "ADOPT_DEVICE_ID_COLLISION") { identity = await deps.rotateLocalIdentity(); await sendV2(channel, requestFrame(identity, ownLicense)); frame = await receiveFrame(channel); if (isAdoptRejectV2(frame)) { deps.onError(frame.reason); return null; } } if (isAdoptRejectV2(frame)) { deps.onError(frame.reason); return null; } if (!isAdoptPrepare(frame)) { deps.onError("ADOPT_UNEXPECTED_FRAME"); return null; } await deps.validateSource(frame); if (await deps.hasSourceIdentityCollision(frame)) { await sendV2(channel, { type: "ADOPT_REJECT", reason: "ADOPT_DEVICE_ID_COLLISION" }); const retry = await receiveFrame(channel); if (!isAdoptPrepare(retry) || await deps.hasSourceIdentityCollision(retry)) { await sendV2(channel, { type: "ADOPT_REJECT", reason: "ADOPT_DEVICE_ID_COLLISION" }); deps.onError("ADOPT_SOURCE_COLLISION_NOT_RESOLVED"); return null; } await deps.validateSource(retry); frame = retry; } if (!isAdoptPrepare(frame)) { deps.onError("ADOPT_UNEXPECTED_FRAME"); return null; } if (!ownLicense && !frame.license) { deps.onError("ADOPT_PRO_REQUIRED"); return null; } await sendV2(channel, { type: "ADOPT_READY" }); const grant = await receiveFrame(channel); if (!grant || (grant as AdoptGrantV2).type !== "ADOPT_GRANT") { deps.onError("ADOPT_UNEXPECTED_FRAME"); return null; } const g = grant as AdoptGrantV2; if (!hex(g.root_key_hex) || !hex(g.vault_id)) { deps.onError("ADOPT_UNEXPECTED_FRAME"); return null; } const rootBytes = hexToBytes(g.root_key_hex); let expected: string; try { expected = await computeVaultId(rootBytes); } finally { rootBytes.fill(0); } if (expected !== g.vault_id || g.vault_id !== frame.vault_id) { deps.onError("ADOPT_VAULT_ID_MISMATCH"); return null; } const created = await deps.createVaultFromRootKey(g.root_key_hex); if (created.vaultId !== g.vault_id) { deps.onError("ADOPT_VAULT_ID_MISMATCH"); return null; } if (!ownLicense && g.license) await deps.applyLicense(g.license); const source = toDevice(frame, deps); await deps.upsertPairedDeviceAndClearRevocation(source); await sendV2(channel, { type: "ADOPT_APPLIED" }); if (!((await receiveFrame(channel) as AdoptComplete).type === "ADOPT_COMPLETE")) { deps.onError("ADOPT_COMPLETION_MISSING"); return null; } return { device: source, snapshotCapable: frame.snapshot_capable === true }; } export async function grantAdoptionV2( channel: PairingTransport, request: AdoptRequestV2, deps: AdoptionV2SourceDeps, ): Promise { let receiver = toDevice(request, deps); await sendV2(channel, { type: "ADOPT_CONFIRM_REQUIRED" }); if (!await deps.onConsent(request)) return null; const confirmation = await receiveFrame(channel); if ( !confirmation || (confirmation as AdoptConfirm).type !== "ADOPT_CONFIRM" || !(confirmation as AdoptConfirm).confirmed ) return null; if (await deps.hasDeviceIdentityCollision(receiver)) { await sendV2(channel, { type: "ADOPT_REJECT", reason: "ADOPT_DEVICE_ID_COLLISION" }); const retry = await receiveFrame(channel); if (!isAdoptRequestV2(retry)) return null; receiver = toDevice(retry, deps); if (await deps.hasDeviceIdentityCollision(receiver)) { await sendV2(channel, { type: "ADOPT_REJECT", reason: "ADOPT_DEVICE_ID_COLLISION" }); return null; } return grantAfterIdentity(channel, retry, receiver, deps); } return grantAfterIdentity(channel, request, receiver, deps); } async function grantAfterIdentity( channel: PairingTransport, request: AdoptRequestV2, receiver: PairedDevice, deps: AdoptionV2SourceDeps, ): Promise { let license = await deps.license(); if (!license && request.license) { try { await deps.applyLicense(request.license); license = await deps.license(); } catch { license = undefined; } } if (!license) { await sendV2(channel, { type: "ADOPT_REJECT", reason: "ADOPT_PRO_REQUIRED" }); return null; } if (!await deps.persistPendingIdentity(receiver, Date.now())) return null; const root = await deps.rootKeyHex(); if (!hex(root)) return null; let source = deps.sourceIdentity; for (let attempt = 0; attempt < 2; attempt++) { await sendV2(channel, { type: "ADOPT_PREPARE", device_id: source.deviceId, device_name: source.deviceName, device_type: source.deviceType, long_term_public_key_hex: source.longTermPublicKeyHex, signing_public_key_hex: source.signingPublicKeyHex ?? "", vault_id: await deps.vaultId(), relay_url: deps.pairState?.relayUrl, ws_port: source.wsPort, license, snapshot_capable: deps.snapshotCapable(), }); const reply = await receiveFrame(channel); if (isAdoptRejectV2(reply) && reply.reason === "ADOPT_DEVICE_ID_COLLISION" && attempt === 0) { source = await deps.rotateSourceIdentity(); continue; } if ((reply as Partial | null)?.type !== "ADOPT_READY") return null; break; } await sendV2(channel, { type: "ADOPT_GRANT", root_key_hex: root, vault_id: await deps.vaultId(), license, }); if ((await receiveFrame(channel) as Partial | null)?.type !== "ADOPT_APPLIED") { return null; } await deps.upsertPairedDevice(receiver); await deps.completePendingIdentity(receiver); await sendV2(channel, { type: "ADOPT_COMPLETE" }); return receiver; }