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 relay_url?: string;25 };26}2728export interface AdoptConfirm {29 type: "ADOPT_CONFIRM";30 confirmed: boolean;31}3233export interface AdoptIdentity {34 type: "ADOPT_IDENTITY";35 device_id: string;36 device_name: string;37 device_type: DeviceType;38 long_term_public_key_hex: string;39 signing_public_key_hex?: string;40}41424344454647export interface AdoptComplete {48 type: "ADOPT_COMPLETE";49}505152export interface AdoptApplied {53 type: "ADOPT_APPLIED";54}5556export type AdoptionFrame =57 | AdoptRequest58 | AdoptConfirm59 | AdoptGrant60 | AdoptIdentity61 | AdoptComplete62 | AdoptApplied;6364export interface AdoptionPairState {65 rendezvousSecretHex: string;66 relayUrl?: string;67}6869export interface AdoptionReceiverDeps {70 deviceName: string;71 generateDeviceId(): string;72 73 confirmSas(): Promise<boolean>;74 75767778 validateSource?(source: AdoptGrant["source"]): Promise<void>;79 createVaultFromRootKey(rootKeyHex: string): Promise<{ vaultId: string }>;80 deriveLocalIdentity(): Promise<LocalIdentity>;81 upsertPairedDevice(device: PairedDevice): Promise<void>;82 applyLicense?(license: string): Promise<void>;83 84 pairState?: AdoptionPairState;85 onError(error: string): void;86}8788export interface AdoptionSourceDeps {89 sourceIdentity: LocalIdentity;90 rootKeyHex(): Promise<string>;91 vaultId(): Promise<string>;92 license?(): Promise<string | undefined>;93 onConsent(request: AdoptRequest): Promise<boolean>;94 upsertPairedDevice(device: PairedDevice): Promise<void>;95 96 pairState?: AdoptionPairState;97}9899export function isAdoptRequest(frame: unknown): frame is AdoptRequest {100 const value = frame as Partial<AdoptRequest> | null;101 return value?.type === "ADOPT_REQUEST" &&102 typeof value.device_id === "string" &&103 value.device_id.length > 0 &&104 typeof value.device_name === "string" &&105 isDeviceType(value.device_type);106}107108export function isAdoptGrant(frame: unknown): frame is AdoptGrant {109 const value = frame as Partial<AdoptGrant> | null;110 return value?.type === "ADOPT_GRANT" &&111 validateRootKeyHex(value.root_key_hex) &&112 typeof value.vault_id === "string" &&113 /^[0-9a-f]{64}$/.test(value.vault_id) &&114 typeof value.source === "object" &&115 value.source !== null &&116 typeof value.source.device_id === "string" &&117 typeof value.source.device_name === "string" &&118 isDeviceType(value.source.device_type) &&119 typeof value.source.long_term_public_key_hex === "string" &&120 /^[0-9a-f]{64}$/.test(value.source.long_term_public_key_hex) &&121 (value.source.relay_url === undefined ||122 (typeof value.source.relay_url === "string" &&123 isSecureRelayUrl(value.source.relay_url)));124}125126export function isAdoptConfirm(frame: unknown): frame is AdoptConfirm {127 const value = frame as Partial<AdoptConfirm> | null;128 return value?.type === "ADOPT_CONFIRM" &&129 typeof value.confirmed === "boolean";130}131132export function isAdoptIdentity(frame: unknown): frame is AdoptIdentity {133 const value = frame as Partial<AdoptIdentity> | null;134 return value?.type === "ADOPT_IDENTITY" &&135 typeof value.device_id === "string" &&136 value.device_id.length > 0 &&137 typeof value.device_name === "string" &&138 isDeviceType(value.device_type) &&139 typeof value.long_term_public_key_hex === "string" &&140 /^[0-9a-f]{64}$/.test(value.long_term_public_key_hex);141}142143export function isAdoptComplete(frame: unknown): frame is AdoptComplete {144 return (frame as Partial<AdoptComplete> | null)?.type === "ADOPT_COMPLETE";145}146147export function isAdoptApplied(frame: unknown): frame is AdoptApplied {148 return (frame as Partial<AdoptApplied> | null)?.type === "ADOPT_APPLIED";149}150151export function validateRootKeyHex(rootKeyHex: unknown): rootKeyHex is string {152 return typeof rootKeyHex === "string" && /^[0-9a-f]{64}$/.test(rootKeyHex);153}154155export async function requestAdoption(156 channel: PairingTransport,157 deps: AdoptionReceiverDeps,158): Promise<PairedDevice | null> {159 await sendFrame(channel, {160 type: "ADOPT_REQUEST",161 device_id: deps.generateDeviceId(),162 device_name: deps.deviceName,163 device_type: "extension",164 });165166 const confirmed = await deps.confirmSas();167 await sendFrame(channel, {168 type: "ADOPT_CONFIRM",169 confirmed,170 });171 if (!confirmed) {172 deps.onError("ADOPT_SAS_REJECTED");173 return null;174 }175176 const frame = await receiveFrame(channel);177 if (!isAdoptGrant(frame)) {178 deps.onError("ADOPT_UNEXPECTED_FRAME");179 return null;180 }181182 const rootKeyBytes = hexToBytes(frame.root_key_hex);183 try {184 const computedVaultId = await computeVaultId(rootKeyBytes);185 if (computedVaultId !== frame.vault_id) {186 deps.onError("ADOPT_VAULT_ID_MISMATCH");187 return null;188 }189 } finally {190 rootKeyBytes.fill(0);191 }192193 await deps.validateSource?.(frame.source);194 const { vaultId } = await deps.createVaultFromRootKey(frame.root_key_hex);195 if (vaultId !== frame.vault_id) {196 deps.onError("ADOPT_VAULT_ID_MISMATCH");197 return null;198 }199200 if (frame.license) await deps.applyLicense?.(frame.license);201202 const identity = await deps.deriveLocalIdentity();203 await sendFrame(channel, {204 type: "ADOPT_IDENTITY",205 device_id: identity.deviceId,206 device_name: identity.deviceName,207 device_type: identity.deviceType,208 long_term_public_key_hex: identity.longTermPublicKeyHex,209 signing_public_key_hex: identity.signingPublicKeyHex,210 });211212 const completion = await receiveFrame(channel);213 if (!isAdoptComplete(completion)) {214 deps.onError("ADOPT_COMPLETION_MISSING");215 return null;216 }217218 const sourceDevice: PairedDevice = {219 deviceId: frame.source.device_id,220 deviceName: frame.source.device_name,221 deviceType: frame.source.device_type,222 longTermPublicKeyHex: frame.source.long_term_public_key_hex,223 signingPublicKeyHex: frame.source.signing_public_key_hex,224 trustSource: "direct",225 wsPort: frame.source.ws_port,226 rendezvousSecretHex: deps.pairState?.rendezvousSecretHex,227 relayUrl: deps.pairState?.relayUrl ?? frame.source.relay_url,228 pairedAt: Date.now(),229 };230 await deps.upsertPairedDevice(sourceDevice);231 await sendFrame(channel, { type: "ADOPT_APPLIED" });232 return sourceDevice;233}234235export async function grantAdoption(236 channel: PairingTransport,237 request: AdoptRequest,238 deps: AdoptionSourceDeps,239): Promise<PairedDevice | null> {240 const approved = await deps.onConsent(request);241 if (!approved) return null;242243 const confirmation = await receiveFrame(channel);244 if (!isAdoptConfirm(confirmation) || !confirmation.confirmed) return null;245246 const rootKeyHex = await deps.rootKeyHex();247 if (!validateRootKeyHex(rootKeyHex)) throw new Error("ADOPT_INVALID_ROOT_KEY");248249 await sendFrame(channel, {250 type: "ADOPT_GRANT",251 root_key_hex: rootKeyHex,252 vault_id: await deps.vaultId(),253 license: await deps.license?.(),254 source: {255 device_id: deps.sourceIdentity.deviceId,256 device_name: deps.sourceIdentity.deviceName,257 device_type: deps.sourceIdentity.deviceType,258 long_term_public_key_hex: deps.sourceIdentity.longTermPublicKeyHex,259 signing_public_key_hex: deps.sourceIdentity.signingPublicKeyHex,260 ws_port: deps.sourceIdentity.wsPort,261 relay_url: deps.pairState?.relayUrl,262 },263 });264265 const identity = await receiveFrame(channel);266 if (!isAdoptIdentity(identity)) return null;267268 const device: PairedDevice = {269 deviceId: identity.device_id,270 deviceName: identity.device_name,271 deviceType: identity.device_type,272 longTermPublicKeyHex: identity.long_term_public_key_hex,273 signingPublicKeyHex: identity.signing_public_key_hex,274 trustSource: "direct",275 rendezvousSecretHex: deps.pairState?.rendezvousSecretHex,276 relayUrl: deps.pairState?.relayUrl,277 pairedAt: Date.now(),278 };279 await deps.upsertPairedDevice(device);280 await sendFrame(channel, { type: "ADOPT_COMPLETE" });281 if (!isAdoptApplied(await receiveFrame(channel))) return null;282 return device;283}284285export async function sendFrame(channel: PairingTransport, frame: AdoptionFrame): Promise<void> {286 await channel.send(new TextEncoder().encode(JSON.stringify(frame)));287}288289export async function receiveFrame(channel: PairingTransport): Promise<unknown> {290 return JSON.parse(new TextDecoder().decode(await channel.receive()));291}292293function isDeviceType(value: unknown): value is DeviceType {294 return value === "extension" || value === "android" || value === "ios";295}296297function isSecureRelayUrl(value: string): boolean {298 try {299 const url = new URL(value);300 return url.protocol === "wss:" && !url.username && !url.password &&301 !url.search && !url.hash;302 } catch {303 return false;304 }305}306307function hexToBytes(hex: string): Uint8Array {308 const out = new Uint8Array(hex.length / 2);309 for (let i = 0; i < out.length; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);310 return out;311}