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 AdoptConfirm {28 type: "ADOPT_CONFIRM";29 confirmed: boolean;30}3132export interface AdoptIdentity {33 type: "ADOPT_IDENTITY";34 device_id: string;35 device_name: string;36 device_type: DeviceType;37 long_term_public_key_hex: string;38 signing_public_key_hex?: string;39}4041export type AdoptionFrame =42 | AdoptRequest43 | AdoptConfirm44 | AdoptGrant45 | AdoptIdentity;4647export interface AdoptionReceiverDeps {48 deviceName: string;49 generateDeviceId(): string;50 51 confirmSas(): Promise<boolean>;52 createVaultFromRootKey(rootKeyHex: string): Promise<{ vaultId: string }>;53 deriveLocalIdentity(): Promise<LocalIdentity>;54 upsertPairedDevice(device: PairedDevice): Promise<void>;55 applyLicense?(license: string): Promise<void>;56 onError(error: string): void;57}5859export interface AdoptionSourceDeps {60 sourceIdentity: LocalIdentity;61 rootKeyHex(): Promise<string>;62 vaultId(): Promise<string>;63 license?(): Promise<string | undefined>;64 onConsent(request: AdoptRequest): Promise<boolean>;65 upsertPairedDevice(device: PairedDevice): Promise<void>;66}6768export function isAdoptRequest(frame: unknown): frame is AdoptRequest {69 const value = frame as Partial<AdoptRequest> | null;70 return value?.type === "ADOPT_REQUEST" &&71 typeof value.device_id === "string" &&72 value.device_id.length > 0 &&73 typeof value.device_name === "string" &&74 isDeviceType(value.device_type);75}7677export function isAdoptGrant(frame: unknown): frame is AdoptGrant {78 const value = frame as Partial<AdoptGrant> | null;79 return value?.type === "ADOPT_GRANT" &&80 validateRootKeyHex(value.root_key_hex) &&81 typeof value.vault_id === "string" &&82 /^[0-9a-f]{64}$/.test(value.vault_id) &&83 typeof value.source === "object" &&84 value.source !== null &&85 typeof value.source.device_id === "string" &&86 typeof value.source.device_name === "string" &&87 isDeviceType(value.source.device_type) &&88 typeof value.source.long_term_public_key_hex === "string" &&89 /^[0-9a-f]{64}$/.test(value.source.long_term_public_key_hex);90}9192export function isAdoptConfirm(frame: unknown): frame is AdoptConfirm {93 const value = frame as Partial<AdoptConfirm> | null;94 return value?.type === "ADOPT_CONFIRM" &&95 typeof value.confirmed === "boolean";96}9798export function isAdoptIdentity(frame: unknown): frame is AdoptIdentity {99 const value = frame as Partial<AdoptIdentity> | null;100 return value?.type === "ADOPT_IDENTITY" &&101 typeof value.device_id === "string" &&102 value.device_id.length > 0 &&103 typeof value.device_name === "string" &&104 isDeviceType(value.device_type) &&105 typeof value.long_term_public_key_hex === "string" &&106 /^[0-9a-f]{64}$/.test(value.long_term_public_key_hex);107}108109export function validateRootKeyHex(rootKeyHex: unknown): rootKeyHex is string {110 return typeof rootKeyHex === "string" && /^[0-9a-f]{64}$/.test(rootKeyHex);111}112113export async function requestAdoption(114 channel: PairingTransport,115 deps: AdoptionReceiverDeps,116): Promise<PairedDevice | null> {117 await sendFrame(channel, {118 type: "ADOPT_REQUEST",119 device_id: deps.generateDeviceId(),120 device_name: deps.deviceName,121 device_type: "extension",122 });123124 const confirmed = await deps.confirmSas();125 await sendFrame(channel, {126 type: "ADOPT_CONFIRM",127 confirmed,128 });129 if (!confirmed) {130 deps.onError("ADOPT_SAS_REJECTED");131 return null;132 }133134 const frame = await receiveFrame(channel);135 if (!isAdoptGrant(frame)) {136 deps.onError("ADOPT_UNEXPECTED_FRAME");137 return null;138 }139140 const rootKeyBytes = hexToBytes(frame.root_key_hex);141 try {142 const computedVaultId = await computeVaultId(rootKeyBytes);143 if (computedVaultId !== frame.vault_id) {144 deps.onError("ADOPT_VAULT_ID_MISMATCH");145 return null;146 }147 } finally {148 rootKeyBytes.fill(0);149 }150151 const { vaultId } = await deps.createVaultFromRootKey(frame.root_key_hex);152 if (vaultId !== frame.vault_id) {153 deps.onError("ADOPT_VAULT_ID_MISMATCH");154 return null;155 }156157 if (frame.license) await deps.applyLicense?.(frame.license);158159 const identity = await deps.deriveLocalIdentity();160 await sendFrame(channel, {161 type: "ADOPT_IDENTITY",162 device_id: identity.deviceId,163 device_name: identity.deviceName,164 device_type: identity.deviceType,165 long_term_public_key_hex: identity.longTermPublicKeyHex,166 signing_public_key_hex: identity.signingPublicKeyHex,167 });168169 const sourceDevice: PairedDevice = {170 deviceId: frame.source.device_id,171 deviceName: frame.source.device_name,172 deviceType: frame.source.device_type,173 longTermPublicKeyHex: frame.source.long_term_public_key_hex,174 signingPublicKeyHex: frame.source.signing_public_key_hex,175 trustSource: "direct",176 wsPort: frame.source.ws_port,177 pairedAt: Date.now(),178 };179 await deps.upsertPairedDevice(sourceDevice);180 return sourceDevice;181}182183export async function grantAdoption(184 channel: PairingTransport,185 request: AdoptRequest,186 deps: AdoptionSourceDeps,187): Promise<PairedDevice | null> {188 const approved = await deps.onConsent(request);189 if (!approved) return null;190191 const confirmation = await receiveFrame(channel);192 if (!isAdoptConfirm(confirmation) || !confirmation.confirmed) return null;193194 const rootKeyHex = await deps.rootKeyHex();195 if (!validateRootKeyHex(rootKeyHex)) throw new Error("ADOPT_INVALID_ROOT_KEY");196197 await sendFrame(channel, {198 type: "ADOPT_GRANT",199 root_key_hex: rootKeyHex,200 vault_id: await deps.vaultId(),201 license: await deps.license?.(),202 source: {203 device_id: deps.sourceIdentity.deviceId,204 device_name: deps.sourceIdentity.deviceName,205 device_type: deps.sourceIdentity.deviceType,206 long_term_public_key_hex: deps.sourceIdentity.longTermPublicKeyHex,207 signing_public_key_hex: deps.sourceIdentity.signingPublicKeyHex,208 ws_port: deps.sourceIdentity.wsPort,209 },210 });211212 const identity = await receiveFrame(channel);213 if (!isAdoptIdentity(identity)) return null;214215 const device: PairedDevice = {216 deviceId: identity.device_id,217 deviceName: identity.device_name,218 deviceType: identity.device_type,219 longTermPublicKeyHex: identity.long_term_public_key_hex,220 signingPublicKeyHex: identity.signing_public_key_hex,221 trustSource: "direct",222 pairedAt: Date.now(),223 };224 await deps.upsertPairedDevice(device);225 return device;226}227228export async function sendFrame(channel: PairingTransport, frame: AdoptionFrame): Promise<void> {229 await channel.send(new TextEncoder().encode(JSON.stringify(frame)));230}231232export async function receiveFrame(channel: PairingTransport): Promise<unknown> {233 return JSON.parse(new TextDecoder().decode(await channel.receive()));234}235236function isDeviceType(value: unknown): value is DeviceType {237 return value === "extension" || value === "android" || value === "ios";238}239240function hexToBytes(hex: string): Uint8Array {241 const out = new Uint8Array(hex.length / 2);242 for (let i = 0; i < out.length; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);243 return out;244}