1import { computeVaultId } from "./sync-identity";2import type { DeviceType, PairedDevice } 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}1112export interface AdoptGrant {13 type: "ADOPT_GRANT";14 root_key_hex: string;15 vault_id: string;16 license?: string;17 source: {18 device_id: string;19 device_name: string;20 device_type: DeviceType;21 long_term_public_key_hex: string;22 signing_public_key_hex?: string;23 ws_port?: number;24 };25}2627export interface AdoptIdentity {28 type: "ADOPT_IDENTITY";29 device_id: string;30 device_name: string;31 device_type: DeviceType;32 long_term_public_key_hex: string;33 signing_public_key_hex?: string;34}3536export type AdoptionFrame = AdoptRequest | AdoptGrant | AdoptIdentity;3738export interface AdoptionReceiverDeps {39 deviceName: string;40 generateDeviceId(): string;41 createVaultFromRootKey(rootKeyHex: string): Promise<{ vaultId: string }>;42 deriveLocalIdentity(): Promise<LocalIdentity>;43 upsertPairedDevice(device: PairedDevice): Promise<void>;44 applyLicense?(license: string): Promise<void>;45 onError(error: string): void;46}4748export interface AdoptionSourceDeps {49 sourceIdentity: LocalIdentity;50 rootKeyHex(): Promise<string>;51 vaultId(): Promise<string>;52 license?(): Promise<string | undefined>;53 onConsent(request: AdoptRequest): Promise<boolean>;54 upsertPairedDevice(device: PairedDevice): Promise<void>;55}5657export function isAdoptRequest(frame: unknown): frame is AdoptRequest {58 const value = frame as Partial<AdoptRequest> | null;59 return value?.type === "ADOPT_REQUEST" &&60 typeof value.device_id === "string" &&61 value.device_id.length > 0 &&62 typeof value.device_name === "string" &&63 isDeviceType(value.device_type);64}6566export function isAdoptGrant(frame: unknown): frame is AdoptGrant {67 const value = frame as Partial<AdoptGrant> | null;68 return value?.type === "ADOPT_GRANT" &&69 validateRootKeyHex(value.root_key_hex) &&70 typeof value.vault_id === "string" &&71 /^[0-9a-f]{64}$/.test(value.vault_id) &&72 typeof value.source === "object" &&73 value.source !== null &&74 typeof value.source.device_id === "string" &&75 typeof value.source.device_name === "string" &&76 isDeviceType(value.source.device_type) &&77 typeof value.source.long_term_public_key_hex === "string" &&78 /^[0-9a-f]{64}$/.test(value.source.long_term_public_key_hex);79}8081export function isAdoptIdentity(frame: unknown): frame is AdoptIdentity {82 const value = frame as Partial<AdoptIdentity> | null;83 return value?.type === "ADOPT_IDENTITY" &&84 typeof value.device_id === "string" &&85 value.device_id.length > 0 &&86 typeof value.device_name === "string" &&87 isDeviceType(value.device_type) &&88 typeof value.long_term_public_key_hex === "string" &&89 /^[0-9a-f]{64}$/.test(value.long_term_public_key_hex);90}9192export function validateRootKeyHex(rootKeyHex: unknown): rootKeyHex is string {93 return typeof rootKeyHex === "string" && /^[0-9a-f]{64}$/.test(rootKeyHex);94}9596export async function requestAdoption(97 channel: PairingTransport,98 deps: AdoptionReceiverDeps,99): Promise<PairedDevice | null> {100 await sendFrame(channel, {101 type: "ADOPT_REQUEST",102 device_id: deps.generateDeviceId(),103 device_name: deps.deviceName,104 device_type: "extension",105 });106107 const frame = await receiveFrame(channel);108 if (!isAdoptGrant(frame)) {109 deps.onError("ADOPT_UNEXPECTED_FRAME");110 return null;111 }112113 const rootKeyBytes = hexToBytes(frame.root_key_hex);114 try {115 const computedVaultId = await computeVaultId(rootKeyBytes);116 if (computedVaultId !== frame.vault_id) {117 deps.onError("ADOPT_VAULT_ID_MISMATCH");118 return null;119 }120 } finally {121 rootKeyBytes.fill(0);122 }123124 const { vaultId } = await deps.createVaultFromRootKey(frame.root_key_hex);125 if (vaultId !== frame.vault_id) {126 deps.onError("ADOPT_VAULT_ID_MISMATCH");127 return null;128 }129130 if (frame.license) await deps.applyLicense?.(frame.license);131132 const identity = await deps.deriveLocalIdentity();133 await sendFrame(channel, {134 type: "ADOPT_IDENTITY",135 device_id: identity.deviceId,136 device_name: identity.deviceName,137 device_type: identity.deviceType,138 long_term_public_key_hex: identity.longTermPublicKeyHex,139 signing_public_key_hex: identity.signingPublicKeyHex,140 });141142 const sourceDevice: PairedDevice = {143 deviceId: frame.source.device_id,144 deviceName: frame.source.device_name,145 deviceType: frame.source.device_type,146 longTermPublicKeyHex: frame.source.long_term_public_key_hex,147 signingPublicKeyHex: frame.source.signing_public_key_hex,148 trustSource: "direct",149 wsPort: frame.source.ws_port,150 pairedAt: Date.now(),151 };152 await deps.upsertPairedDevice(sourceDevice);153 return sourceDevice;154}155156export async function grantAdoption(157 channel: PairingTransport,158 request: AdoptRequest,159 deps: AdoptionSourceDeps,160): Promise<PairedDevice | null> {161 const approved = await deps.onConsent(request);162 if (!approved) return null;163164 const rootKeyHex = await deps.rootKeyHex();165 if (!validateRootKeyHex(rootKeyHex)) throw new Error("ADOPT_INVALID_ROOT_KEY");166167 await sendFrame(channel, {168 type: "ADOPT_GRANT",169 root_key_hex: rootKeyHex,170 vault_id: await deps.vaultId(),171 license: await deps.license?.(),172 source: {173 device_id: deps.sourceIdentity.deviceId,174 device_name: deps.sourceIdentity.deviceName,175 device_type: deps.sourceIdentity.deviceType,176 long_term_public_key_hex: deps.sourceIdentity.longTermPublicKeyHex,177 signing_public_key_hex: deps.sourceIdentity.signingPublicKeyHex,178 ws_port: deps.sourceIdentity.wsPort,179 },180 });181182 const identity = await receiveFrame(channel);183 if (!isAdoptIdentity(identity)) return null;184185 const device: PairedDevice = {186 deviceId: identity.device_id,187 deviceName: identity.device_name,188 deviceType: identity.device_type,189 longTermPublicKeyHex: identity.long_term_public_key_hex,190 signingPublicKeyHex: identity.signing_public_key_hex,191 trustSource: "direct",192 pairedAt: Date.now(),193 };194 await deps.upsertPairedDevice(device);195 return device;196}197198export async function sendFrame(channel: PairingTransport, frame: AdoptionFrame): Promise<void> {199 await channel.send(new TextEncoder().encode(JSON.stringify(frame)));200}201202export async function receiveFrame(channel: PairingTransport): Promise<unknown> {203 return JSON.parse(new TextDecoder().decode(await channel.receive()));204}205206function isDeviceType(value: unknown): value is DeviceType {207 return value === "extension" || value === "android" || value === "ios" || value === "desktop";208}209210function hexToBytes(hex: string): Uint8Array {211 const out = new Uint8Array(hex.length / 2);212 for (let i = 0; i < out.length; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);213 return out;214}