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 11 license?: string;12}1314export interface AdoptGrant {15 type: "ADOPT_GRANT";16 root_key_hex: string;17 vault_id: string;18 license?: string;19 source: {20 device_id: string;21 device_name: string;22 device_type: DeviceType;23 long_term_public_key_hex: string;24 signing_public_key_hex?: string;25 ws_port?: number;26 relay_url?: string;27 };28}2930export interface AdoptConfirm {31 type: "ADOPT_CONFIRM";32 confirmed: boolean;33}3435export interface AdoptIdentity {36 type: "ADOPT_IDENTITY";37 device_id: string;38 device_name: string;39 device_type: DeviceType;40 long_term_public_key_hex: string;41 signing_public_key_hex?: string;42}43444546474849export interface AdoptComplete {50 type: "ADOPT_COMPLETE";51}525354export interface AdoptApplied {55 type: "ADOPT_APPLIED";56}575859export interface AdoptReject {60 type: "ADOPT_REJECT";61 reason: "ADOPT_PRO_REQUIRED";62}6364export type AdoptionFrame =65 | AdoptRequest66 | AdoptConfirm67 | AdoptGrant68 | AdoptIdentity69 | AdoptComplete70 | AdoptApplied71 | AdoptReject;7273export interface AdoptionPairState {74 rendezvousSecretHex: string;75 relayUrl?: string;76}7778export interface AdoptionReceiverDeps {79 deviceName: string;80 generateDeviceId(): string;81 82 confirmSas(): Promise<boolean>;83 84858687 validateSource?(source: AdoptGrant["source"]): Promise<void>;88 createVaultFromRootKey(rootKeyHex: string): Promise<{ vaultId: string }>;89 deriveLocalIdentity(): Promise<LocalIdentity>;90 upsertPairedDevice(device: PairedDevice): Promise<void>;91 applyLicense?(license: string): Promise<void>;92 93 license?(): Promise<string | undefined>;94 95 pairState?: AdoptionPairState;96 onError(error: string): void;97}9899export interface AdoptionSourceDeps {100 sourceIdentity: LocalIdentity;101 rootKeyHex(): Promise<string>;102 vaultId(): Promise<string>;103 license?(): Promise<string | undefined>;104 105 applyLicense?(license: string): Promise<void>;106 onConsent(request: AdoptRequest): Promise<boolean>;107 upsertPairedDevice(device: PairedDevice): Promise<void>;108 109 pairState?: AdoptionPairState;110}111112export function isAdoptRequest(frame: unknown): frame is AdoptRequest {113 const value = frame as Partial<AdoptRequest> | null;114 return value?.type === "ADOPT_REQUEST" &&115 typeof value.device_id === "string" &&116 value.device_id.length > 0 &&117 typeof value.device_name === "string" &&118 isDeviceType(value.device_type) &&119 (value.license === undefined || typeof value.license === "string");120}121122export function isAdoptGrant(frame: unknown): frame is AdoptGrant {123 const value = frame as Partial<AdoptGrant> | null;124 return value?.type === "ADOPT_GRANT" &&125 validateRootKeyHex(value.root_key_hex) &&126 typeof value.vault_id === "string" &&127 /^[0-9a-f]{64}$/.test(value.vault_id) &&128 typeof value.source === "object" &&129 value.source !== null &&130 typeof value.source.device_id === "string" &&131 typeof value.source.device_name === "string" &&132 isDeviceType(value.source.device_type) &&133 typeof value.source.long_term_public_key_hex === "string" &&134 /^[0-9a-f]{64}$/.test(value.source.long_term_public_key_hex) &&135 (value.source.relay_url === undefined ||136 (typeof value.source.relay_url === "string" &&137 isSecureRelayUrl(value.source.relay_url)));138}139140export function isAdoptConfirm(frame: unknown): frame is AdoptConfirm {141 const value = frame as Partial<AdoptConfirm> | null;142 return value?.type === "ADOPT_CONFIRM" &&143 typeof value.confirmed === "boolean";144}145146export function isAdoptIdentity(frame: unknown): frame is AdoptIdentity {147 const value = frame as Partial<AdoptIdentity> | null;148 return value?.type === "ADOPT_IDENTITY" &&149 typeof value.device_id === "string" &&150 value.device_id.length > 0 &&151 typeof value.device_name === "string" &&152 isDeviceType(value.device_type) &&153 typeof value.long_term_public_key_hex === "string" &&154 /^[0-9a-f]{64}$/.test(value.long_term_public_key_hex);155}156157export function isAdoptComplete(frame: unknown): frame is AdoptComplete {158 return (frame as Partial<AdoptComplete> | null)?.type === "ADOPT_COMPLETE";159}160161export function isAdoptApplied(frame: unknown): frame is AdoptApplied {162 return (frame as Partial<AdoptApplied> | null)?.type === "ADOPT_APPLIED";163}164165export function isAdoptReject(frame: unknown): frame is AdoptReject {166 const value = frame as Partial<AdoptReject> | null;167 return value?.type === "ADOPT_REJECT" &&168 value.reason === "ADOPT_PRO_REQUIRED";169}170171export function validateRootKeyHex(rootKeyHex: unknown): rootKeyHex is string {172 return typeof rootKeyHex === "string" && /^[0-9a-f]{64}$/.test(rootKeyHex);173}174175export async function requestAdoption(176 channel: PairingTransport,177 deps: AdoptionReceiverDeps,178): Promise<PairedDevice | null> {179 const receiverLicense = await deps.license?.();180 await sendFrame(channel, {181 type: "ADOPT_REQUEST",182 device_id: deps.generateDeviceId(),183 device_name: deps.deviceName,184 device_type: "extension",185 license: receiverLicense,186 });187188 const confirmed = await deps.confirmSas();189 await sendFrame(channel, {190 type: "ADOPT_CONFIRM",191 confirmed,192 });193 if (!confirmed) {194 deps.onError("ADOPT_SAS_REJECTED");195 return null;196 }197198 const frame = await receiveFrame(channel);199 if (isAdoptReject(frame)) {200 deps.onError(frame.reason);201 return null;202 }203 if (!isAdoptGrant(frame)) {204 deps.onError("ADOPT_UNEXPECTED_FRAME");205 return null;206 }207208 const rootKeyBytes = hexToBytes(frame.root_key_hex);209 try {210 const computedVaultId = await computeVaultId(rootKeyBytes);211 if (computedVaultId !== frame.vault_id) {212 deps.onError("ADOPT_VAULT_ID_MISMATCH");213 return null;214 }215 } finally {216 rootKeyBytes.fill(0);217 }218 if (!receiverLicense && !frame.license) {219 deps.onError("ADOPT_PRO_REQUIRED");220 return null;221 }222223 await deps.validateSource?.(frame.source);224 const { vaultId } = await deps.createVaultFromRootKey(frame.root_key_hex);225 if (vaultId !== frame.vault_id) {226 deps.onError("ADOPT_VAULT_ID_MISMATCH");227 return null;228 }229230 if (!receiverLicense && frame.license) {231 await deps.applyLicense?.(frame.license);232 }233234 const identity = await deps.deriveLocalIdentity();235 await sendFrame(channel, {236 type: "ADOPT_IDENTITY",237 device_id: identity.deviceId,238 device_name: identity.deviceName,239 device_type: identity.deviceType,240 long_term_public_key_hex: identity.longTermPublicKeyHex,241 signing_public_key_hex: identity.signingPublicKeyHex,242 });243244 const completion = await receiveFrame(channel);245 if (!isAdoptComplete(completion)) {246 deps.onError("ADOPT_COMPLETION_MISSING");247 return null;248 }249250 const sourceDevice: PairedDevice = {251 deviceId: frame.source.device_id,252 deviceName: frame.source.device_name,253 deviceType: frame.source.device_type,254 longTermPublicKeyHex: frame.source.long_term_public_key_hex,255 signingPublicKeyHex: frame.source.signing_public_key_hex,256 trustSource: "direct",257 wsPort: frame.source.ws_port,258 rendezvousSecretHex: deps.pairState?.rendezvousSecretHex,259 relayUrl: deps.pairState?.relayUrl ?? frame.source.relay_url,260 pairedAt: Date.now(),261 };262 await deps.upsertPairedDevice(sourceDevice);263 await sendFrame(channel, { type: "ADOPT_APPLIED" });264 return sourceDevice;265}266267export async function grantAdoption(268 channel: PairingTransport,269 request: AdoptRequest,270 deps: AdoptionSourceDeps,271): Promise<PairedDevice | null> {272 const approved = await deps.onConsent(request);273 if (!approved) return null;274275 const confirmation = await receiveFrame(channel);276 if (!isAdoptConfirm(confirmation) || !confirmation.confirmed) return null;277278 let sourceLicense = await deps.license?.();279 if (!sourceLicense && request.license) {280 try {281 if (!deps.applyLicense) throw new Error("license_transfer_unavailable");282 await deps.applyLicense(request.license);283 sourceLicense = await deps.license?.();284 } catch {285 sourceLicense = undefined;286 }287 }288 if (!sourceLicense) {289 await sendFrame(channel, {290 type: "ADOPT_REJECT",291 reason: "ADOPT_PRO_REQUIRED",292 });293 throw new Error("ADOPT_PRO_REQUIRED");294 }295296 const rootKeyHex = await deps.rootKeyHex();297 if (!validateRootKeyHex(rootKeyHex)) throw new Error("ADOPT_INVALID_ROOT_KEY");298299 await sendFrame(channel, {300 type: "ADOPT_GRANT",301 root_key_hex: rootKeyHex,302 vault_id: await deps.vaultId(),303 license: sourceLicense,304 source: {305 device_id: deps.sourceIdentity.deviceId,306 device_name: deps.sourceIdentity.deviceName,307 device_type: deps.sourceIdentity.deviceType,308 long_term_public_key_hex: deps.sourceIdentity.longTermPublicKeyHex,309 signing_public_key_hex: deps.sourceIdentity.signingPublicKeyHex,310 ws_port: deps.sourceIdentity.wsPort,311 relay_url: deps.pairState?.relayUrl,312 },313 });314315 const identity = await receiveFrame(channel);316 if (!isAdoptIdentity(identity)) return null;317318 const device: PairedDevice = {319 deviceId: identity.device_id,320 deviceName: identity.device_name,321 deviceType: identity.device_type,322 longTermPublicKeyHex: identity.long_term_public_key_hex,323 signingPublicKeyHex: identity.signing_public_key_hex,324 trustSource: "direct",325 rendezvousSecretHex: deps.pairState?.rendezvousSecretHex,326 relayUrl: deps.pairState?.relayUrl,327 pairedAt: Date.now(),328 };329 await deps.upsertPairedDevice(device);330 await sendFrame(channel, { type: "ADOPT_COMPLETE" });331 if (!isAdoptApplied(await receiveFrame(channel))) return null;332 return device;333}334335export async function sendFrame(channel: PairingTransport, frame: AdoptionFrame): Promise<void> {336 await channel.send(new TextEncoder().encode(JSON.stringify(frame)));337}338339export async function receiveFrame(channel: PairingTransport): Promise<unknown> {340 return JSON.parse(new TextDecoder().decode(await channel.receive()));341}342343function isDeviceType(value: unknown): value is DeviceType {344 return value === "extension" || value === "android" || value === "ios";345}346347function isSecureRelayUrl(value: string): boolean {348 try {349 const url = new URL(value);350 return url.protocol === "wss:" && !url.username && !url.password &&351 !url.search && !url.hash;352 } catch {353 return false;354 }355}356357function hexToBytes(hex: string): Uint8Array {358 const out = new Uint8Array(hex.length / 2);359 for (let i = 0; i < out.length; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);360 return out;361}