1import { computeVaultId } from "./sync-identity";2import { capDeviceName } from "./sync-limits";3import type { DeviceType, PairedDevice } from "./sync-types";4import type { LocalIdentity, PairingTransport } from "./pairing-handshake";5import { receiveFrame } from "./vault-adopt";67export const ADOPTION_VERSION = 2;8export type AdoptionRejectReason = "ADOPT_PRO_REQUIRED" | "ADOPT_DEVICE_ID_COLLISION";9export interface AdoptRequestV2 {10 type: "ADOPT_REQUEST_V2";11 adoptionVersion: 2;12 device_id: string;13 device_name: string;14 device_type: DeviceType;15 long_term_public_key_hex: string;16 signing_public_key_hex: string;17 license?: string;18 snapshot_capable?: boolean;19}20export interface AdoptConfirmRequired {21 type: "ADOPT_CONFIRM_REQUIRED";22}23export interface AdoptConfirm {24 type: "ADOPT_CONFIRM";25 confirmed: boolean;26 272829303132 license?: string;33}34export interface AdoptPrepare {35 type: "ADOPT_PREPARE";36 device_id: string;37 device_name: string;38 device_type: DeviceType;39 long_term_public_key_hex: string;40 signing_public_key_hex: string;41 vault_id: string;42 ws_port?: number;43 relay_url?: string;44 license?: string;45 snapshot_capable?: boolean;46}47export interface AdoptReady {48 type: "ADOPT_READY";49}50export interface AdoptGrantV2 {51 type: "ADOPT_GRANT";52 root_key_hex: string;53 vault_id: string;54 license?: string;55}56export interface AdoptApplied {57 type: "ADOPT_APPLIED";58}59export interface AdoptComplete {60 type: "ADOPT_COMPLETE";61}62export interface AdoptRejectV2 {63 type: "ADOPT_REJECT";64 reason: AdoptionRejectReason;65}6667const hex = (v: unknown): v is string => typeof v === "string" && /^[0-9a-f]{64}$/.test(v);68const device = (v: unknown): v is DeviceType => v === "extension" || v === "android" || v === "ios";69const relay = (v: unknown): v is string => {70 if (typeof v !== "string") return false;71 try {72 return new URL(v).protocol === "wss:";73 } catch {74 return false;75 }76};77export function isAdoptRequestV2(v: unknown): v is AdoptRequestV2 {78 const x = v as Partial<AdoptRequestV2> | null;79 return !!x && x.type === "ADOPT_REQUEST_V2" && x.adoptionVersion === 2 &&80 typeof x.device_id === "string" && x.device_id.length > 0 &&81 typeof x.device_name === "string" && device(x.device_type) &&82 hex(x.long_term_public_key_hex) && hex(x.signing_public_key_hex) &&83 (x.license === undefined || typeof x.license === "string") &&84 (x.snapshot_capable === undefined || typeof x.snapshot_capable === "boolean");85}86export function isAdoptPrepare(v: unknown): v is AdoptPrepare {87 const x = v as Partial<AdoptPrepare> | null;88 return !!x && x.type === "ADOPT_PREPARE" && typeof x.device_id === "string" &&89 x.device_id.length > 0 &&90 typeof x.device_name === "string" && device(x.device_type) && hex(x.long_term_public_key_hex) &&91 hex(x.signing_public_key_hex) && hex(x.vault_id) &&92 (x.ws_port === undefined ||93 (Number.isInteger(x.ws_port) && (x.ws_port as number) >= 1 && (x.ws_port as number) <= 65_535)) &&94 (x.relay_url === undefined || relay(x.relay_url)) &&95 (x.license === undefined || typeof x.license === "string") &&96 (x.snapshot_capable === undefined || typeof x.snapshot_capable === "boolean");97}98export function isAdoptRejectV2(v: unknown): v is AdoptRejectV2 {99 const x = v as Partial<AdoptRejectV2> | null;100 return !!x && x.type === "ADOPT_REJECT" &&101 (x.reason === "ADOPT_PRO_REQUIRED" || x.reason === "ADOPT_DEVICE_ID_COLLISION");102}103104export interface AdoptionV2ReceiverDeps {105 deviceName: string;106 localIdentity(): Promise<LocalIdentity>;107 rotateLocalIdentity(): Promise<LocalIdentity>;108 license(): Promise<string | undefined>;109 confirmSas(): Promise<boolean>;110 validateSource(prepare: AdoptPrepare): Promise<void>;111 hasSourceIdentityCollision(prepare: AdoptPrepare): Promise<boolean>;112 createVaultFromRootKey(rootKeyHex: string): Promise<{ vaultId: string }>;113 applyLicense(license: string): Promise<void>;114 upsertPairedDeviceAndClearRevocation(device: PairedDevice): Promise<void>;115 pairState?: { relayUrl?: string; rendezvousSecretHex?: string };116 onError(error: string): void;117}118export interface AdoptionV2SourceDeps {119 sourceIdentity: LocalIdentity;120 rootKeyHex(): Promise<string>;121 vaultId(): Promise<string>;122 license(): Promise<string | undefined>;123 applyLicense(license: string): Promise<void>;124 onConsent(request: AdoptRequestV2): Promise<boolean>;125 persistPendingIdentity(device: PairedDevice, nowMs: number): Promise<boolean>;126 completePendingIdentity(device: PairedDevice): Promise<void>;127 hasDeviceIdentityCollision(device: PairedDevice): Promise<boolean>;128 rotateSourceIdentity(): Promise<LocalIdentity>;129 upsertPairedDevice(device: PairedDevice): Promise<void>;130 snapshotCapable(): boolean;131 pairState?: { relayUrl?: string; rendezvousSecretHex?: string };132}133134function toDevice(135 i: {136 device_id: string;137 device_name: string;138 device_type: DeviceType;139 long_term_public_key_hex: string;140 signing_public_key_hex: string;141 },142 deps: { pairState?: { relayUrl?: string; rendezvousSecretHex?: string } },143): PairedDevice {144 return {145 deviceId: i.device_id,146 deviceName: capDeviceName(i.device_name),147 deviceType: i.device_type,148 longTermPublicKeyHex: i.long_term_public_key_hex,149 signingPublicKeyHex: i.signing_public_key_hex,150 trustSource: "direct",151 relayUrl: deps.pairState?.relayUrl,152 rendezvousSecretHex: deps.pairState?.rendezvousSecretHex,153 pairedAt: Date.now(),154 };155}156function requestFrame(i: LocalIdentity): AdoptRequestV2 {157 return {158 type: "ADOPT_REQUEST_V2",159 adoptionVersion: 2,160 device_id: i.deviceId,161 device_name: i.deviceName,162 device_type: i.deviceType,163 long_term_public_key_hex: i.longTermPublicKeyHex,164 signing_public_key_hex: i.signingPublicKeyHex ?? "",165 snapshot_capable: true,166 };167}168async function sendV2(channel: PairingTransport, frame: unknown): Promise<void> {169 await channel.send(new TextEncoder().encode(JSON.stringify(frame)));170}171function hexToBytes(hexValue: string): Uint8Array {172 const bytes = new Uint8Array(hexValue.length / 2);173 for (let i = 0; i < bytes.length; i++) {174 bytes[i] = Number.parseInt(hexValue.slice(i * 2, i * 2 + 2), 16);175 }176 return bytes;177}178179export async function requestAdoptionV2(180 channel: PairingTransport,181 deps: AdoptionV2ReceiverDeps,182): Promise<{ device: PairedDevice; snapshotCapable: boolean } | null> {183 let identity = await deps.localIdentity();184 const ownLicense = await deps.license();185 await sendV2(channel, requestFrame(identity));186 if ((await receiveFrame(channel) as Partial<AdoptConfirmRequired> | null)?.type !== "ADOPT_CONFIRM_REQUIRED") {187 deps.onError("ADOPT_UNEXPECTED_FRAME");188 return null;189 }190 const confirmed = await deps.confirmSas();191 192 await sendV2(channel, { type: "ADOPT_CONFIRM", confirmed, license: ownLicense });193 if (!confirmed) {194 deps.onError("ADOPT_SAS_REJECTED");195 return null;196 }197 let frame = await receiveFrame(channel);198 if (isAdoptRejectV2(frame) && frame.reason === "ADOPT_DEVICE_ID_COLLISION") {199 identity = await deps.rotateLocalIdentity();200 await sendV2(channel, requestFrame(identity));201 frame = await receiveFrame(channel);202 if (isAdoptRejectV2(frame)) {203 deps.onError(frame.reason);204 return null;205 }206 }207 if (isAdoptRejectV2(frame)) {208 deps.onError(frame.reason);209 return null;210 }211 if (!isAdoptPrepare(frame)) {212 deps.onError("ADOPT_UNEXPECTED_FRAME");213 return null;214 }215 await deps.validateSource(frame);216 if (await deps.hasSourceIdentityCollision(frame)) {217 await sendV2(channel, { type: "ADOPT_REJECT", reason: "ADOPT_DEVICE_ID_COLLISION" });218 const retry = await receiveFrame(channel);219 if (!isAdoptPrepare(retry) || await deps.hasSourceIdentityCollision(retry)) {220 await sendV2(channel, { type: "ADOPT_REJECT", reason: "ADOPT_DEVICE_ID_COLLISION" });221 deps.onError("ADOPT_SOURCE_COLLISION_NOT_RESOLVED");222 return null;223 }224 await deps.validateSource(retry);225 frame = retry;226 }227 if (!isAdoptPrepare(frame)) {228 deps.onError("ADOPT_UNEXPECTED_FRAME");229 return null;230 }231 if (!ownLicense && !frame.license) {232 deps.onError("ADOPT_PRO_REQUIRED");233 return null;234 }235 await sendV2(channel, { type: "ADOPT_READY" });236 const grant = await receiveFrame(channel);237 if (!grant || (grant as AdoptGrantV2).type !== "ADOPT_GRANT") {238 deps.onError("ADOPT_UNEXPECTED_FRAME");239 return null;240 }241 const g = grant as AdoptGrantV2;242 if (!hex(g.root_key_hex) || !hex(g.vault_id)) {243 deps.onError("ADOPT_UNEXPECTED_FRAME");244 return null;245 }246 const rootBytes = hexToBytes(g.root_key_hex);247 let expected: string;248 try {249 expected = await computeVaultId(rootBytes);250 } finally {251 rootBytes.fill(0);252 }253 if (expected !== g.vault_id || g.vault_id !== frame.vault_id) {254 deps.onError("ADOPT_VAULT_ID_MISMATCH");255 return null;256 }257 const created = await deps.createVaultFromRootKey(g.root_key_hex);258 if (created.vaultId !== g.vault_id) {259 deps.onError("ADOPT_VAULT_ID_MISMATCH");260 return null;261 }262 if (!ownLicense && g.license) await deps.applyLicense(g.license);263 const source = toDevice(frame, deps);264 await deps.upsertPairedDeviceAndClearRevocation(source);265 await sendV2(channel, { type: "ADOPT_APPLIED" });266 if (!((await receiveFrame(channel) as AdoptComplete).type === "ADOPT_COMPLETE")) {267 deps.onError("ADOPT_COMPLETION_MISSING");268 return null;269 }270 return { device: source, snapshotCapable: frame.snapshot_capable === true };271}272273export async function grantAdoptionV2(274 channel: PairingTransport,275 request: AdoptRequestV2,276 deps: AdoptionV2SourceDeps,277): Promise<PairedDevice | null> {278 let receiver = toDevice(request, deps);279 await sendV2(channel, { type: "ADOPT_CONFIRM_REQUIRED" });280 if (!await deps.onConsent(request)) return null;281 const confirmation = await receiveFrame(channel);282 if (283 !confirmation || (confirmation as AdoptConfirm).type !== "ADOPT_CONFIRM" ||284 !(confirmation as AdoptConfirm).confirmed285 ) return null;286 const confirmed = confirmation as AdoptConfirm;287 if (await deps.hasDeviceIdentityCollision(receiver)) {288 await sendV2(channel, { type: "ADOPT_REJECT", reason: "ADOPT_DEVICE_ID_COLLISION" });289 const retry = await receiveFrame(channel);290 if (!isAdoptRequestV2(retry)) return null;291 receiver = toDevice(retry, deps);292 if (await deps.hasDeviceIdentityCollision(receiver)) {293 await sendV2(channel, { type: "ADOPT_REJECT", reason: "ADOPT_DEVICE_ID_COLLISION" });294 return null;295 }296 return grantAfterIdentity(channel, retry, receiver, deps, confirmed);297 }298 return grantAfterIdentity(channel, request, receiver, deps, confirmed);299}300async function grantAfterIdentity(301 channel: PairingTransport,302 request: AdoptRequestV2,303 receiver: PairedDevice,304 deps: AdoptionV2SourceDeps,305 confirmation: AdoptConfirm,306): Promise<PairedDevice | null> {307 let license = await deps.license();308 309 310 const receiverLicense = confirmation.license ?? request.license;311 if (!license && receiverLicense) {312 try {313 await deps.applyLicense(receiverLicense);314 license = await deps.license();315 } catch {316 license = undefined;317 }318 }319 if (!license) {320 await sendV2(channel, { type: "ADOPT_REJECT", reason: "ADOPT_PRO_REQUIRED" });321 return null;322 }323 if (!await deps.persistPendingIdentity(receiver, Date.now())) return null;324 const root = await deps.rootKeyHex();325 if (!hex(root)) return null;326 let source = deps.sourceIdentity;327 for (let attempt = 0; attempt < 2; attempt++) {328 await sendV2(channel, {329 type: "ADOPT_PREPARE",330 device_id: source.deviceId,331 device_name: source.deviceName,332 device_type: source.deviceType,333 long_term_public_key_hex: source.longTermPublicKeyHex,334 signing_public_key_hex: source.signingPublicKeyHex ?? "",335 vault_id: await deps.vaultId(),336 relay_url: deps.pairState?.relayUrl,337 ws_port: source.wsPort,338 license,339 snapshot_capable: deps.snapshotCapable(),340 });341 const reply = await receiveFrame(channel);342 if (isAdoptRejectV2(reply) && reply.reason === "ADOPT_DEVICE_ID_COLLISION" && attempt === 0) {343 source = await deps.rotateSourceIdentity();344 continue;345 }346 if ((reply as Partial<AdoptReady> | null)?.type !== "ADOPT_READY") return null;347 break;348 }349 await sendV2(channel, {350 type: "ADOPT_GRANT",351 root_key_hex: root,352 vault_id: await deps.vaultId(),353 license,354 });355 if ((await receiveFrame(channel) as Partial<AdoptApplied> | null)?.type !== "ADOPT_APPLIED") {356 return null;357 }358 await deps.upsertPairedDevice(receiver);359 await deps.completePendingIdentity(receiver);360 await sendV2(channel, { type: "ADOPT_COMPLETE" });361 return receiver;362}