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