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}4142export type AdoptionFrame =43 | AdoptRequest44 | AdoptConfirm45 | AdoptGrant46 | AdoptIdentity;4748export interface AdoptionPairState {49 rendezvousSecretHex: string;50 relayUrl?: string;51}5253export interface AdoptionReceiverDeps {54 deviceName: string;55 generateDeviceId(): string;56 57 confirmSas(): Promise<boolean>;58 createVaultFromRootKey(rootKeyHex: string): Promise<{ vaultId: string }>;59 deriveLocalIdentity(): Promise<LocalIdentity>;60 upsertPairedDevice(device: PairedDevice): Promise<void>;61 applyLicense?(license: string): Promise<void>;62 63 pairState?: AdoptionPairState;64 onError(error: string): void;65}6667export interface AdoptionSourceDeps {68 sourceIdentity: LocalIdentity;69 rootKeyHex(): Promise<string>;70 vaultId(): Promise<string>;71 license?(): Promise<string | undefined>;72 onConsent(request: AdoptRequest): Promise<boolean>;73 upsertPairedDevice(device: PairedDevice): Promise<void>;74 75 pairState?: AdoptionPairState;76}7778export function isAdoptRequest(frame: unknown): frame is AdoptRequest {79 const value = frame as Partial<AdoptRequest> | null;80 return value?.type === "ADOPT_REQUEST" &&81 typeof value.device_id === "string" &&82 value.device_id.length > 0 &&83 typeof value.device_name === "string" &&84 isDeviceType(value.device_type);85}8687export function isAdoptGrant(frame: unknown): frame is AdoptGrant {88 const value = frame as Partial<AdoptGrant> | null;89 return value?.type === "ADOPT_GRANT" &&90 validateRootKeyHex(value.root_key_hex) &&91 typeof value.vault_id === "string" &&92 /^[0-9a-f]{64}$/.test(value.vault_id) &&93 typeof value.source === "object" &&94 value.source !== null &&95 typeof value.source.device_id === "string" &&96 typeof value.source.device_name === "string" &&97 isDeviceType(value.source.device_type) &&98 typeof value.source.long_term_public_key_hex === "string" &&99 /^[0-9a-f]{64}$/.test(value.source.long_term_public_key_hex) &&100 (value.source.relay_url === undefined ||101 (typeof value.source.relay_url === "string" &&102 isSecureRelayUrl(value.source.relay_url)));103}104105export function isAdoptConfirm(frame: unknown): frame is AdoptConfirm {106 const value = frame as Partial<AdoptConfirm> | null;107 return value?.type === "ADOPT_CONFIRM" &&108 typeof value.confirmed === "boolean";109}110111export function isAdoptIdentity(frame: unknown): frame is AdoptIdentity {112 const value = frame as Partial<AdoptIdentity> | null;113 return value?.type === "ADOPT_IDENTITY" &&114 typeof value.device_id === "string" &&115 value.device_id.length > 0 &&116 typeof value.device_name === "string" &&117 isDeviceType(value.device_type) &&118 typeof value.long_term_public_key_hex === "string" &&119 /^[0-9a-f]{64}$/.test(value.long_term_public_key_hex);120}121122export function validateRootKeyHex(rootKeyHex: unknown): rootKeyHex is string {123 return typeof rootKeyHex === "string" && /^[0-9a-f]{64}$/.test(rootKeyHex);124}125126export async function requestAdoption(127 channel: PairingTransport,128 deps: AdoptionReceiverDeps,129): Promise<PairedDevice | null> {130 await sendFrame(channel, {131 type: "ADOPT_REQUEST",132 device_id: deps.generateDeviceId(),133 device_name: deps.deviceName,134 device_type: "extension",135 });136137 const confirmed = await deps.confirmSas();138 await sendFrame(channel, {139 type: "ADOPT_CONFIRM",140 confirmed,141 });142 if (!confirmed) {143 deps.onError("ADOPT_SAS_REJECTED");144 return null;145 }146147 const frame = await receiveFrame(channel);148 if (!isAdoptGrant(frame)) {149 deps.onError("ADOPT_UNEXPECTED_FRAME");150 return null;151 }152153 const rootKeyBytes = hexToBytes(frame.root_key_hex);154 try {155 const computedVaultId = await computeVaultId(rootKeyBytes);156 if (computedVaultId !== frame.vault_id) {157 deps.onError("ADOPT_VAULT_ID_MISMATCH");158 return null;159 }160 } finally {161 rootKeyBytes.fill(0);162 }163164 const { vaultId } = await deps.createVaultFromRootKey(frame.root_key_hex);165 if (vaultId !== frame.vault_id) {166 deps.onError("ADOPT_VAULT_ID_MISMATCH");167 return null;168 }169170 if (frame.license) await deps.applyLicense?.(frame.license);171172 const identity = await deps.deriveLocalIdentity();173 await sendFrame(channel, {174 type: "ADOPT_IDENTITY",175 device_id: identity.deviceId,176 device_name: identity.deviceName,177 device_type: identity.deviceType,178 long_term_public_key_hex: identity.longTermPublicKeyHex,179 signing_public_key_hex: identity.signingPublicKeyHex,180 });181182 const sourceDevice: PairedDevice = {183 deviceId: frame.source.device_id,184 deviceName: frame.source.device_name,185 deviceType: frame.source.device_type,186 longTermPublicKeyHex: frame.source.long_term_public_key_hex,187 signingPublicKeyHex: frame.source.signing_public_key_hex,188 trustSource: "direct",189 wsPort: frame.source.ws_port,190 rendezvousSecretHex: deps.pairState?.rendezvousSecretHex,191 relayUrl: deps.pairState?.relayUrl ?? frame.source.relay_url,192 pairedAt: Date.now(),193 };194 await deps.upsertPairedDevice(sourceDevice);195 return sourceDevice;196}197198export async function grantAdoption(199 channel: PairingTransport,200 request: AdoptRequest,201 deps: AdoptionSourceDeps,202): Promise<PairedDevice | null> {203 const approved = await deps.onConsent(request);204 if (!approved) return null;205206 const confirmation = await receiveFrame(channel);207 if (!isAdoptConfirm(confirmation) || !confirmation.confirmed) return null;208209 const rootKeyHex = await deps.rootKeyHex();210 if (!validateRootKeyHex(rootKeyHex)) throw new Error("ADOPT_INVALID_ROOT_KEY");211212 await sendFrame(channel, {213 type: "ADOPT_GRANT",214 root_key_hex: rootKeyHex,215 vault_id: await deps.vaultId(),216 license: await deps.license?.(),217 source: {218 device_id: deps.sourceIdentity.deviceId,219 device_name: deps.sourceIdentity.deviceName,220 device_type: deps.sourceIdentity.deviceType,221 long_term_public_key_hex: deps.sourceIdentity.longTermPublicKeyHex,222 signing_public_key_hex: deps.sourceIdentity.signingPublicKeyHex,223 ws_port: deps.sourceIdentity.wsPort,224 relay_url: deps.pairState?.relayUrl,225 },226 });227228 const identity = await receiveFrame(channel);229 if (!isAdoptIdentity(identity)) return null;230231 const device: PairedDevice = {232 deviceId: identity.device_id,233 deviceName: identity.device_name,234 deviceType: identity.device_type,235 longTermPublicKeyHex: identity.long_term_public_key_hex,236 signingPublicKeyHex: identity.signing_public_key_hex,237 trustSource: "direct",238 rendezvousSecretHex: deps.pairState?.rendezvousSecretHex,239 relayUrl: deps.pairState?.relayUrl,240 pairedAt: Date.now(),241 };242 await deps.upsertPairedDevice(device);243 return device;244}245246export async function sendFrame(channel: PairingTransport, frame: AdoptionFrame): Promise<void> {247 await channel.send(new TextEncoder().encode(JSON.stringify(frame)));248}249250export async function receiveFrame(channel: PairingTransport): Promise<unknown> {251 return JSON.parse(new TextDecoder().decode(await channel.receive()));252}253254function isDeviceType(value: unknown): value is DeviceType {255 return value === "extension" || value === "android" || value === "ios";256}257258function isSecureRelayUrl(value: string): boolean {259 try {260 const url = new URL(value);261 return url.protocol === "wss:" && !url.username && !url.password &&262 !url.search && !url.hash;263 } catch {264 return false;265 }266}267268function hexToBytes(hex: string): Uint8Array {269 const out = new Uint8Array(hex.length / 2);270 for (let i = 0; i < out.length; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);271 return out;272}