80ab8c3ad841
feat: inline vault snapshot over pairing channel (v4.5.0)
- time
- 2026-07-29 23:48 UTC · 2 months ago
- machine
- koh2
- parent
- 7416b66af497
↓ peach-browser-80ab8c3a.tar.gzatlas at this save
files changed7 files
manifest.firefox.json+1 −1manifest.json+1 −1package.json+1 −1src/background/relay-adoption-source-bg.test.ts+13 −4src/background/relay-adoption-source-bg.ts+113 −3src/shared/vault-adopt-snapshot.test.ts+148 −0src/shared/vault-adopt.ts+197 −2manifest.firefox.json+1 −1
@@ -1,7 +1,7 @@11 {22 "manifest_version": 3,33 "name": "Peach Password Manager",4- "version": "4.4.7",4+ "version": "4.5.0",55 "description": "Zero-knowledge password manager",66 "browser_specific_settings": {77 "gecko": {manifest.json+1 −1
@@ -2,7 +2,7 @@22 "manifest_version": 3,33 "minimum_chrome_version": "116",44 "name": "Peach Password Manager",5- "version": "4.4.7",5+ "version": "4.5.0",66 "description": "Zero-knowledge password manager",77 "action": {88 "default_popup": "src/popup/index.html",package.json+1 −1
@@ -1,6 +1,6 @@11 {22 "name": "peach",3- "version": "4.4.7",3+ "version": "4.5.0",44 "description": "Zero-knowledge password manager",55 "license": "GPL-3.0-only",66 "type": "module",src/background/relay-adoption-source-bg.test.ts+13 −4
@@ -101,13 +101,22 @@101101 const encode = (value: unknown) =>102102 new TextEncoder().encode(JSON.stringify(value));103103 104-function socketWithFrames(frames: unknown[]) {104+function socketWithFrames(105+ frames: unknown[],106+ options: { closeOnEmpty?: boolean } = {},107+) {105108 const queue = frames.map(encode);106109 return {107110 send: vi.fn(),108111 receive: vi.fn(async () => {109112 const next = queue.shift();110- if (!next) return new Promise<Uint8Array>(() => undefined);113+ if (!next) {114+ // A real peer closes the channel once it runs out of frames; model115+ // that so the receiver's post-ADOPT_APPLIED snapshot read rejects fast116+ // and falls back to the sync pipeline instead of hanging on a timeout.117+ if (options.closeOnEmpty) throw new Error("socket_closed");118+ return new Promise<Uint8Array>(() => undefined);119+ }111120 return next;112121 }),113122 close: vi.fn(),@@ -505,7 +514,7 @@505514 },506515 },507516 { type: "ADOPT_COMPLETE" },508- ]);517+ ], { closeOnEmpty: true });509518 mocks.openSocket.mockResolvedValue(socket);510519 mocks.createVault.mockResolvedValue({ vaultId });511520 @@ -571,7 +580,7 @@571580 { type: "ADOPT_COMPLETE" },572581 ];573582 mocks.createVault.mockResolvedValue({ vaultId });574- mocks.openSocket.mockResolvedValue(socketWithFrames(frames));583+ mocks.openSocket.mockResolvedValue(socketWithFrames(frames, { closeOnEmpty: true }));575584 576585 // Replace the chrome.runtime mock for this one test. The receiver577586 // auto-accepts the SAS, so no decision needs to be simulated.src/background/relay-adoption-source-bg.ts+113 −3
@@ -14,9 +14,14 @@1414 } from "../shared/noise-js";1515 import {1616 grantAdoption,17+ isAdoptDeviceManifestFrame,1718 isAdoptRequest,19+ isAdoptSnapshotApplied,1820 receiveFrame,21+ receiveVaultSnapshot,1922 requestAdoption,23+ sendFrame,24+ sendVaultSnapshot,2025 type AdoptRequest,2126 } from "../shared/vault-adopt";2227 import {@@ -34,8 +39,9 @@3439 import { createVaultFromRootKey, getRootKeyHex, isUnlocked, rollbackAdoptedVault } from "./vault";3540 import { computeVaultId } from "../shared/sync-identity";3641 import { INITIAL_SYNC_PENDING_DEVICE_KEY } from "../shared/initial-sync";37-import { storageSet } from "../shared/storage";38-import { upsertPairedDevice } from "./paired-devices";42+import { storageRemove, storageSet } from "../shared/storage";43+import { markLastSyncedAt, upsertPairedDevice } from "./paired-devices";44+import { createSyncVaultAdapter } from "./sync-vault-adapter";3945 import { triggerSyncIfReachable } from "./sync-trigger";4046 4147 const SOURCE_WINDOW_MS = 5 * 60 * 1000;@@ -51,6 +57,23 @@5157 }5258 }5359 60+/** Race a promise against a wall-clock deadline; rejects with "timeout". */61+function withTimeout<T>(promise: Promise<T>, ms: number): Promise<T> {62+ return new Promise<T>((resolve, reject) => {63+ const timer = setTimeout(() => reject(new Error("timeout")), ms);64+ promise.then(65+ (value) => {66+ clearTimeout(timer);67+ resolve(value);68+ },69+ (error) => {70+ clearTimeout(timer);71+ reject(error);72+ },73+ );74+ });75+}76+5477 let inFlight = false;5578 let activeSocket: PairingSocket | null = null;5679 // Outstanding source-side SAS confirmations, keyed by id. A map (vs the@@ -249,6 +272,40 @@249272 });250273 return;251274 }275+ // If the receiver advertised inline-snapshot support, send the vault now276+ // over the same authenticated channel. Best-effort: any failure leaves the277+ // receiver to fall back to the sync pipeline triggered below.278+ if (request.snapshot_capable === true) {279+ try {280+ const adapter = createSyncVaultAdapter();281+ const entryManifest = await adapter.buildEntryManifest();282+ const entryIds = Object.keys(entryManifest);283+ const entries =284+ entryIds.length > 0 ? await adapter.getEncryptedEntries(entryIds) : {};285+ const tombstones = await adapter.buildTombstoneManifest();286+ await sendVaultSnapshot(channel, entries, tombstones);287+288+ const applied = await withTimeout(receiveFrame(channel), 15_000);289+ if (isAdoptSnapshotApplied(applied)) {290+ console.info(291+ `[Peach] snapshot applied by receiver: ${applied.entries_received} entries, ${applied.tombstones_received} tombstones`,292+ );293+ }294+295+ if (adapter.buildOutgoingDeviceManifest) {296+ const manifest = await adapter.buildOutgoingDeviceManifest();297+ if (manifest) {298+ await sendFrame(channel, { type: "ADOPT_DEVICE_MANIFEST", manifest });299+ await withTimeout(receiveFrame(channel), 5_000).catch(() => undefined);300+ }301+ }302+ } catch (snapshotError) {303+ console.info(304+ "[Peach] inline snapshot send failed (receiver will use sync pipeline):",305+ snapshotError,306+ );307+ }308+ }252309 push("ADOPTION_SOURCE_RESULT", { status: "complete", device });253310 // The Android receiver starts its relay listener as soon as its source254311 // record is durable. Try once now and once after service startup settles so@@ -386,6 +443,51 @@386443 });387444 return;388445 }446+ // The pairing channel is still open and authenticated — try to pull the447+ // vault snapshot inline so the popup transitions immediately instead of448+ // waiting on a second sync round. Any failure (older source, timeout,449+ // corrupt chunk) falls through to the existing sync pipeline below.450+ let snapshotApplied = false;451+ try {452+ const snapshot = await withTimeout(receiveVaultSnapshot(channel), 15_000);453+ const adapter = createSyncVaultAdapter();454+ await adapter.applyEntries(snapshot.entries);455+ await adapter.applyTombstones(snapshot.tombstones);456+ await sendFrame(channel, {457+ type: "ADOPT_SNAPSHOT_APPLIED",458+ entries_received: Object.keys(snapshot.entries).length,459+ tombstones_received: Object.keys(snapshot.tombstones).length,460+ });461+462+ // Optional device graph. Non-fatal and isolated so a manifest timeout or463+ // unsupported source cannot demote an otherwise-successful entry snapshot.464+ try {465+ const manifestFrame = await withTimeout(receiveFrame(channel), 5_000);466+ if (467+ isAdoptDeviceManifestFrame(manifestFrame) &&468+ adapter.applyIncomingDeviceManifest &&469+ typeof device.longTermPublicKeyHex === "string" &&470+ /^[0-9a-f]{64}$/.test(device.longTermPublicKeyHex)471+ ) {472+ await adapter.applyIncomingDeviceManifest(473+ manifestFrame.manifest,474+ hexToBytes(device.longTermPublicKeyHex),475+ );476+ await sendFrame(channel, { type: "ADOPT_DEVICE_MANIFEST_APPLIED" });477+ }478+ } catch {479+ // The graph arrives via incremental sync instead.480+ }481+482+ await markLastSyncedAt(device.deviceId, Date.now());483+ snapshotApplied = true;484+ } catch (snapshotError) {485+ console.info(486+ "[Peach] inline snapshot failed, falling back to sync pipeline:",487+ snapshotError,488+ );489+ }490+389491 // Adoption has committed identity and vault-key material, but entries have390492 // not necessarily crossed yet. Persist the exact source row before telling391493 // the popup pairing is complete so a popup close/reopen cannot reveal the@@ -396,7 +498,15 @@396498 );397499 push("ADOPTION_SOURCE_RESULT", { status: "complete", device });398500 createdVault = false;399- void triggerSyncIfReachable({ reason: "manual", force: true });501+502+ if (snapshotApplied) {503+ // The snapshot populated the vault and marked the source synced, so the504+ // pending marker's job is done — clear it instead of making the popup wait.505+ await storageRemove(INITIAL_SYNC_PENDING_DEVICE_KEY).catch(() => undefined);506+ } else {507+ // Fallback: the existing sync pipeline fills the vault.508+ void triggerSyncIfReachable({ reason: "manual", force: true });509+ }400510 } catch (error) {401511 if (createdVault) {402512 try {src/shared/vault-adopt-snapshot.test.ts+148 −0
@@ -0,0 +1,148 @@1+import { describe, expect, it } from "vitest";2+import {3+ MAX_SNAPSHOT_CHUNKS,4+ isAdoptSnapshot,5+ receiveFrame,6+ receiveVaultSnapshot,7+ requestAdoption,8+ sendFrame,9+ sendVaultSnapshot,10+ type AdoptionReceiverDeps,11+} from "./vault-adopt";12+import { memoryTransportPair, type PairingTransport } from "./pairing-handshake";13+14+describe("inline vault snapshot", () => {15+ it("round-trips an empty vault (0 entries)", async () => {16+ const [source, receiver] = memoryTransportPair();17+ const [result] = await Promise.all([18+ receiveVaultSnapshot(receiver),19+ sendVaultSnapshot(source, {}, {}),20+ ]);21+ expect(result.entries).toEqual({});22+ expect(result.tombstones).toEqual({});23+ });24+25+ it("round-trips 50 entries + 5 tombstones in a single chunk", async () => {26+ const [source, receiver] = memoryTransportPair();27+ const entries: Record<string, string> = {};28+ for (let i = 0; i < 50; i++) entries[`id-${i}`] = `cipher-${i}`;29+ const tombstones: Record<string, number> = {};30+ for (let i = 0; i < 5; i++) tombstones[`dead-${i}`] = 1000 + i;31+ const [result] = await Promise.all([32+ receiveVaultSnapshot(receiver),33+ sendVaultSnapshot(source, entries, tombstones),34+ ]);35+ expect(result.entries).toEqual(entries);36+ expect(result.tombstones).toEqual(tombstones);37+ });38+39+ it("splits a large vault into 3+ chunks and reassembles it", async () => {40+ const [source, receiver] = memoryTransportPair();41+ let frames = 0;42+ const countingSource: PairingTransport = {43+ send: (msg) => {44+ frames++;45+ return source.send(msg);46+ },47+ receive: () => source.receive(),48+ };49+ const big = "x".repeat(200 * 1024); // 200 KiB per entry forces chunking50+ const entries: Record<string, string> = {};51+ for (let i = 0; i < 5; i++) entries[`id-${i}`] = big;52+ const tombstones: Record<string, number> = { "dead-0": 42 };53+ const [result] = await Promise.all([54+ receiveVaultSnapshot(receiver),55+ sendVaultSnapshot(countingSource, entries, tombstones),56+ ]);57+ expect(frames).toBeGreaterThanOrEqual(3);58+ expect(result.entries).toEqual(entries);59+ expect(result.tombstones).toEqual(tombstones);60+ });61+62+ it("rejects out-of-order chunks", async () => {63+ const [source, receiver] = memoryTransportPair();64+ const send = sendFrame(source, {65+ type: "ADOPT_SNAPSHOT",66+ chunk_index: 1,67+ chunk_count: 2,68+ entries: { a: "x" },69+ tombstones: {},70+ });71+ await expect(receiveVaultSnapshot(receiver)).rejects.toThrow(/OUT_OF_ORDER/);72+ await send;73+ });74+75+ it("rejects a chunk_count exceeding MAX_SNAPSHOT_CHUNKS", async () => {76+ const [source, receiver] = memoryTransportPair();77+ const send = sendFrame(source, {78+ type: "ADOPT_SNAPSHOT",79+ chunk_index: 0,80+ chunk_count: MAX_SNAPSHOT_CHUNKS + 1,81+ entries: {},82+ tombstones: {},83+ });84+ await expect(receiveVaultSnapshot(receiver)).rejects.toThrow(/TOO_MANY_CHUNKS/);85+ await send;86+ });87+88+ it("validates ADOPT_SNAPSHOT frames", () => {89+ expect(90+ isAdoptSnapshot({91+ type: "ADOPT_SNAPSHOT",92+ chunk_index: 0,93+ chunk_count: 1,94+ entries: { a: "x" },95+ tombstones: {},96+ }),97+ ).toBe(true);98+ // Missing tombstones / chunk_index.99+ expect(100+ isAdoptSnapshot({ type: "ADOPT_SNAPSHOT", chunk_index: 0, chunk_count: 1, entries: {} }),101+ ).toBe(false);102+ expect(103+ isAdoptSnapshot({ type: "ADOPT_SNAPSHOT", chunk_count: 1, entries: {}, tombstones: {} }),104+ ).toBe(false);105+ // Wrong type tag.106+ expect(107+ isAdoptSnapshot({ type: "ADOPT_GRANT", chunk_index: 0, chunk_count: 1, entries: {}, tombstones: {} }),108+ ).toBe(false);109+ // Invalid indices and value types.110+ expect(111+ isAdoptSnapshot({ type: "ADOPT_SNAPSHOT", chunk_index: -1, chunk_count: 1, entries: {}, tombstones: {} }),112+ ).toBe(false);113+ expect(114+ isAdoptSnapshot({ type: "ADOPT_SNAPSHOT", chunk_index: 0, chunk_count: 0, entries: {}, tombstones: {} }),115+ ).toBe(false);116+ expect(117+ isAdoptSnapshot({ type: "ADOPT_SNAPSHOT", chunk_index: 0, chunk_count: 1, entries: { a: 5 }, tombstones: {} }),118+ ).toBe(false);119+ expect(isAdoptSnapshot(null)).toBe(false);120+ });121+122+ it("advertises snapshot_capable in the ADOPT_REQUEST frame", async () => {123+ const [receiverChannel, sourceChannel] = memoryTransportPair();124+ const deps: AdoptionReceiverDeps = {125+ deviceName: "recv",126+ generateDeviceId: () => "recv-id",127+ async confirmSas() {128+ return true;129+ },130+ async createVaultFromRootKey() {131+ return { vaultId: "v" };132+ },133+ async deriveLocalIdentity() {134+ return {135+ deviceId: "recv-id",136+ deviceName: "recv",137+ deviceType: "extension",138+ longTermPublicKeyHex: "ab".repeat(32),139+ };140+ },141+ async upsertPairedDevice() {},142+ onError() {},143+ };144+ void requestAdoption(receiverChannel, deps);145+ const first = await receiveFrame(sourceChannel);146+ expect(first).toMatchObject({ type: "ADOPT_REQUEST", snapshot_capable: true });147+ });148+});src/shared/vault-adopt.ts+197 −2
@@ -1,5 +1,5 @@11 import { computeVaultId } from "./sync-identity";2-import type { DeviceType, PairedDevice } from "./sync-types";2+import type { DeviceType, PairedDevice, SignedDeviceManifest } from "./sync-types";33 import type { LocalIdentity, PairingTransport } from "./pairing-handshake";44 55 export interface AdoptRequest {@@ -9,6 +9,12 @@99 device_type: DeviceType;1010 /** Signed Pro entitlement offered by the receiver, if it has one. */1111 license?: string;12+ /**13+ * Receiver advertises inline-snapshot support. A source that understands it14+ * sends the vault snapshot over this same channel after ADOPT_APPLIED;15+ * older sources ignore it and the receiver falls back to the sync pipeline.16+ */17+ snapshot_capable?: boolean;1218 }1319 1420 export interface AdoptGrant {@@ -61,6 +67,38 @@6167 reason: "ADOPT_PRO_REQUIRED";6268 }6369 70+/**71+ * One chunk of the inline vault snapshot sent by the source after72+ * ADOPT_APPLIED, over the same authenticated Noise NK channel. Entries are73+ * opaque base64 ciphertext envelopes; tombstones ride the final chunk only.74+ */75+export interface AdoptSnapshot {76+ type: "ADOPT_SNAPSHOT";77+ chunk_index: number;78+ chunk_count: number;79+ entries: Record<string, string>;80+ /** entryId → deletion timestamp; populated on the final chunk only. */81+ tombstones: Record<string, number>;82+}83+84+/** Receiver acknowledgement sent after all snapshot chunks are applied. */85+export interface AdoptSnapshotApplied {86+ type: "ADOPT_SNAPSHOT_APPLIED";87+ entries_received: number;88+ tombstones_received: number;89+}90+91+/** Optional signed device graph appended after the snapshot (manifest v3+). */92+export interface AdoptDeviceManifestFrame {93+ type: "ADOPT_DEVICE_MANIFEST";94+ manifest: SignedDeviceManifest;95+}96+97+/** Receiver acknowledgement for an applied device manifest. */98+export interface AdoptDeviceManifestApplied {99+ type: "ADOPT_DEVICE_MANIFEST_APPLIED";100+}101+64102 export type AdoptionFrame =65103 | AdoptRequest66104 | AdoptConfirm@@ -68,8 +106,24 @@68106 | AdoptIdentity69107 | AdoptComplete70108 | AdoptApplied71- | AdoptReject;109+ | AdoptReject110+ | AdoptSnapshot111+ | AdoptSnapshotApplied112+ | AdoptDeviceManifestFrame113+ | AdoptDeviceManifestApplied;72114 115+/**116+ * Wire guardrails for the inline snapshot. A chunk's serialized entries map117+ * stays under MAX_SNAPSHOT_CHUNK_BYTES; the whole transfer is capped at118+ * MAX_SNAPSHOT_CHUNKS frames so a compromised source cannot exhaust receiver119+ * memory (mirrors SYNC_LIMITS.MAX_FRAME_BYTES in the sync coordinator).120+ */121+export const MAX_SNAPSHOT_CHUNK_BYTES = 512 * 1024; // 512 KiB122+export const MAX_SNAPSHOT_CHUNKS = 200;123+// Headroom added to the per-chunk entries budget when validating an encoded124+// frame on the wire: JSON envelope plus the final chunk's tombstone map.125+const SNAPSHOT_FRAME_WIRE_CEILING = MAX_SNAPSHOT_CHUNK_BYTES + 256 * 1024;126+73127 export interface AdoptionPairState {74128 rendezvousSecretHex: string;75129 relayUrl?: string;@@ -168,6 +222,146 @@168222 value.reason === "ADOPT_PRO_REQUIRED";169223 }170224 225+export function isAdoptSnapshot(frame: unknown): frame is AdoptSnapshot {226+ const value = frame as Partial<AdoptSnapshot> | null;227+ if (value?.type !== "ADOPT_SNAPSHOT") return false;228+ if (229+ typeof value.chunk_index !== "number" ||230+ !Number.isInteger(value.chunk_index) ||231+ value.chunk_index < 0232+ ) return false;233+ if (234+ typeof value.chunk_count !== "number" ||235+ !Number.isInteger(value.chunk_count) ||236+ value.chunk_count < 1237+ ) return false;238+ if (!isStringRecord(value.entries)) return false;239+ if (!isNumberRecord(value.tombstones)) return false;240+ return true;241+}242+243+export function isAdoptSnapshotApplied(frame: unknown): frame is AdoptSnapshotApplied {244+ const value = frame as Partial<AdoptSnapshotApplied> | null;245+ return value?.type === "ADOPT_SNAPSHOT_APPLIED" &&246+ typeof value.entries_received === "number" &&247+ typeof value.tombstones_received === "number";248+}249+250+export function isAdoptDeviceManifestFrame(251+ frame: unknown,252+): frame is AdoptDeviceManifestFrame {253+ const value = frame as Partial<AdoptDeviceManifestFrame> | null;254+ const manifest = value?.manifest as255+ | { body?: unknown; signatureHex?: unknown }256+ | null;257+ return value?.type === "ADOPT_DEVICE_MANIFEST" &&258+ typeof manifest === "object" &&259+ manifest !== null &&260+ typeof manifest.body === "object" &&261+ manifest.body !== null &&262+ typeof manifest.signatureHex === "string";263+}264+265+export function isAdoptDeviceManifestApplied(266+ frame: unknown,267+): frame is AdoptDeviceManifestApplied {268+ return (frame as Partial<AdoptDeviceManifestApplied> | null)?.type ===269+ "ADOPT_DEVICE_MANIFEST_APPLIED";270+}271+272+function isStringRecord(value: unknown): value is Record<string, string> {273+ if (typeof value !== "object" || value === null) return false;274+ return Object.values(value).every((v) => typeof v === "string");275+}276+277+function isNumberRecord(value: unknown): value is Record<string, number> {278+ if (typeof value !== "object" || value === null) return false;279+ return Object.values(value).every((v) => typeof v === "number");280+}281+282+/**283+ * Source side: chunk the vault snapshot and send it over the pairing channel.284+ * Entries are split so each chunk's serialized entries map stays under285+ * MAX_SNAPSHOT_CHUNK_BYTES; tombstones ride the final chunk. If the vault286+ * needs more than MAX_SNAPSHOT_CHUNKS chunks, only the first that many are287+ * sent and the fallback sync pipeline fills the remainder.288+ */289+export async function sendVaultSnapshot(290+ channel: PairingTransport,291+ entries: Record<string, string>,292+ tombstones: Record<string, number>,293+): Promise<void> {294+ const chunks: Array<Record<string, string>> = [];295+ let current: Record<string, string> = {};296+ let currentBytes = 2; // "{}"297+ for (const [id, envelope] of Object.entries(entries)) {298+ // +1 for the separating comma; an overestimate only shrinks chunks.299+ const entryBytes = JSON.stringify({ [id]: envelope }).length + 1;300+ if (301+ Object.keys(current).length > 0 &&302+ currentBytes + entryBytes >= MAX_SNAPSHOT_CHUNK_BYTES303+ ) {304+ chunks.push(current);305+ current = {};306+ currentBytes = 2;307+ }308+ current[id] = envelope;309+ currentBytes += entryBytes;310+ }311+ chunks.push(current);312+313+ const chunkCount = Math.min(chunks.length, MAX_SNAPSHOT_CHUNKS);314+ for (let index = 0; index < chunkCount; index++) {315+ const isFinal = index === chunkCount - 1;316+ await sendFrame(channel, {317+ type: "ADOPT_SNAPSHOT",318+ chunk_index: index,319+ chunk_count: chunkCount,320+ entries: chunks[index],321+ tombstones: isFinal ? tombstones : {},322+ });323+ }324+}325+326+/**327+ * Receiver side: receive and reassemble every snapshot chunk. Validates the328+ * encoded frame size before parsing, then enforces sequential 0-based329+ * chunk_index ordering, a stable chunk_count within MAX_SNAPSHOT_CHUNKS, and330+ * structural validity of each frame. Throws on any validation failure so the331+ * caller can fall back to the sync pipeline.332+ */333+export async function receiveVaultSnapshot(334+ channel: PairingTransport,335+): Promise<{ entries: Record<string, string>; tombstones: Record<string, number> }> {336+ const entries: Record<string, string> = {};337+ const tombstones: Record<string, number> = {};338+ let chunkCount = -1;339+ let received = 0;340+ while (chunkCount === -1 || received < chunkCount) {341+ const raw = await channel.receive();342+ if (raw.length > SNAPSHOT_FRAME_WIRE_CEILING) {343+ throw new Error("ADOPT_SNAPSHOT_CHUNK_TOO_LARGE");344+ }345+ const frame: unknown = JSON.parse(new TextDecoder().decode(raw));346+ if (!isAdoptSnapshot(frame)) throw new Error("ADOPT_SNAPSHOT_INVALID_FRAME");347+ if (chunkCount === -1) {348+ chunkCount = frame.chunk_count;349+ if (chunkCount > MAX_SNAPSHOT_CHUNKS) {350+ throw new Error("ADOPT_SNAPSHOT_TOO_MANY_CHUNKS");351+ }352+ } else if (frame.chunk_count !== chunkCount) {353+ throw new Error("ADOPT_SNAPSHOT_CHUNK_COUNT_MISMATCH");354+ }355+ if (frame.chunk_index !== received) {356+ throw new Error("ADOPT_SNAPSHOT_CHUNK_OUT_OF_ORDER");357+ }358+ Object.assign(entries, frame.entries);359+ Object.assign(tombstones, frame.tombstones);360+ received++;361+ }362+ return { entries, tombstones };363+}364+171365 export function validateRootKeyHex(rootKeyHex: unknown): rootKeyHex is string {172366 return typeof rootKeyHex === "string" && /^[0-9a-f]{64}$/.test(rootKeyHex);173367 }@@ -183,6 +377,7 @@183377 device_name: deps.deviceName,184378 device_type: "extension",185379 license: receiverLicense,380+ snapshot_capable: true,186381 });187382 188383 const confirmed = await deps.confirmSas();