1234import { computeVaultId } from "./sync-identity";5import type { DeviceType, PairedDevice } from "./sync-types";6import type { LocalIdentity, PairingTransport } from "./chirp-handshake";78export interface AdoptRequest {9 type: "ADOPT_REQUEST";10 device_id: string;11 device_name: string;12 device_type: "extension" | "android";13}1415export interface AdoptGrant {16 type: "ADOPT_GRANT";17 root_key_hex: string;18 vault_id: string;19 license?: string;20 source: {21 device_id: string;22 device_name: string;23 device_type: DeviceType;24 long_term_public_key_hex: string;25 signing_public_key_hex?: string;26 ws_port?: number;27 };28}2930export interface AdoptIdentity {31 type: "ADOPT_IDENTITY";32 device_id: string;33 device_name: string;34 device_type: DeviceType;35 long_term_public_key_hex: string;36 signing_public_key_hex?: string;37}3839export type AdoptionFrame = AdoptRequest | AdoptGrant | AdoptIdentity;4041export interface AdoptionReceiverDeps {42 deviceName: string;43 generateDeviceId(): string;44 createVaultFromRootKey(rootKeyHex: string): Promise<{ vaultId: string }>;45 deriveLocalIdentity(): Promise<LocalIdentity>;46 upsertPairedDevice(device: PairedDevice): Promise<void>;47 applyLicense?(license: string): Promise<void>;48 onError(error: string): void;49}5051export interface AdoptionSourceDeps {52 sourceIdentity: LocalIdentity;53 rootKeyHex(): Promise<string>;54 vaultId(): Promise<string>;55 license?(): Promise<string | undefined>;56 onConsent(request: AdoptRequest): Promise<boolean>;57 upsertPairedDevice(device: PairedDevice): Promise<void>;58}5960export function isAdoptRequest(frame: unknown): frame is AdoptRequest {61 const value = frame as Partial<AdoptRequest> | null;62 return value?.type === "ADOPT_REQUEST" &&63 typeof value.device_id === "string" &&64 value.device_id.length > 0 &&65 typeof value.device_name === "string" &&66 (value.device_type === "extension" || value.device_type === "android");67}6869export function validateRootKeyHex(rootKeyHex: unknown): rootKeyHex is string {70 return typeof rootKeyHex === "string" && /^[0-9a-f]{64}$/.test(rootKeyHex);71}7273export async function requestAdoption(74 channel: PairingTransport,75 deps: AdoptionReceiverDeps,76): Promise<void> {77 await sendFrame(channel, {78 type: "ADOPT_REQUEST",79 device_id: deps.generateDeviceId(),80 device_name: deps.deviceName,81 device_type: "extension",82 });8384 const frame = await receiveFrame(channel);85 if (!isAdoptGrant(frame)) {86 deps.onError("ADOPT_UNEXPECTED_FRAME");87 return;88 }8990 if (!validateRootKeyHex(frame.root_key_hex)) {91 deps.onError("ADOPT_INVALID_ROOT_KEY");92 return;93 }9495 const rootKeyBytes = hexToBytes(frame.root_key_hex);96 try {97 const computedVaultId = await computeVaultId(rootKeyBytes);98 if (computedVaultId !== frame.vault_id) {99 deps.onError("ADOPT_VAULT_ID_MISMATCH");100 return;101 }102 } finally {103 rootKeyBytes.fill(0);104 }105106 const { vaultId } = await deps.createVaultFromRootKey(frame.root_key_hex);107 if (vaultId !== frame.vault_id) {108 deps.onError("ADOPT_VAULT_ID_MISMATCH");109 return;110 }111112 const identity = await deps.deriveLocalIdentity();113 await sendFrame(channel, {114 type: "ADOPT_IDENTITY",115 device_id: identity.deviceId,116 device_name: deps.deviceName,117 device_type: identity.deviceType,118 long_term_public_key_hex: identity.longTermPublicKeyHex,119 signing_public_key_hex: identity.signingPublicKeyHex,120 });121122 await deps.upsertPairedDevice({123 deviceId: frame.source.device_id,124 deviceName: frame.source.device_name,125 deviceType: frame.source.device_type,126 longTermPublicKeyHex: frame.source.long_term_public_key_hex,127 signingPublicKeyHex: frame.source.signing_public_key_hex,128 trustSource: "direct",129 wsPort: frame.source.ws_port,130 pairedAt: Date.now(),131 });132133 if (frame.license) await deps.applyLicense?.(frame.license);134}135136export async function grantAdoption(137 channel: PairingTransport,138 request: AdoptRequest,139 deps: AdoptionSourceDeps,140): Promise<void> {141 const approved = await deps.onConsent(request);142 if (!approved) return;143144 const rootKeyHex = await deps.rootKeyHex();145 if (!validateRootKeyHex(rootKeyHex)) throw new Error("ADOPT_INVALID_ROOT_KEY");146147 try {148 await sendFrame(channel, {149 type: "ADOPT_GRANT",150 root_key_hex: rootKeyHex,151 vault_id: await deps.vaultId(),152 license: await deps.license?.(),153 source: {154 device_id: deps.sourceIdentity.deviceId,155 device_name: deps.sourceIdentity.deviceName,156 device_type: deps.sourceIdentity.deviceType,157 long_term_public_key_hex: deps.sourceIdentity.longTermPublicKeyHex,158 signing_public_key_hex: deps.sourceIdentity.signingPublicKeyHex,159 ws_port: deps.sourceIdentity.wsPort,160 },161 });162 } finally {163 164 165 }166167 const identity = await receiveFrame(channel);168 if (!isAdoptIdentity(identity)) return;169170 await deps.upsertPairedDevice({171 deviceId: identity.device_id,172 deviceName: identity.device_name,173 deviceType: identity.device_type,174 longTermPublicKeyHex: identity.long_term_public_key_hex,175 signingPublicKeyHex: identity.signing_public_key_hex,176 trustSource: "direct",177 pairedAt: Date.now(),178 });179}180181export async function sendFrame(channel: PairingTransport, frame: AdoptionFrame): Promise<void> {182 await channel.send(new TextEncoder().encode(JSON.stringify(frame)));183}184185export async function receiveFrame(channel: PairingTransport): Promise<unknown> {186 return JSON.parse(new TextDecoder().decode(await channel.receive()));187}188189function isAdoptGrant(frame: unknown): frame is AdoptGrant {190 const value = frame as Partial<AdoptGrant> | null;191 return value?.type === "ADOPT_GRANT" &&192 validateRootKeyHex(value.root_key_hex) &&193 typeof value.vault_id === "string" &&194 /^[0-9a-f]{64}$/.test(value.vault_id) &&195 typeof value.source === "object" &&196 value.source !== null;197}198199function isAdoptIdentity(frame: unknown): frame is AdoptIdentity {200 const value = frame as Partial<AdoptIdentity> | null;201 return value?.type === "ADOPT_IDENTITY" &&202 typeof value.device_id === "string" &&203 typeof value.device_name === "string" &&204 typeof value.device_type === "string" &&205 typeof value.long_term_public_key_hex === "string" &&206 /^[0-9a-f]{64}$/.test(value.long_term_public_key_hex);207}208209function hexToBytes(hex: string): Uint8Array {210 const out = new Uint8Array(hex.length / 2);211 for (let i = 0; i < out.length; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);212 return out;213}214