@openlfcp/storage-idb 0.1.4 → 0.2.0-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -22,9 +22,9 @@ installs must never share sequence counters.
22
22
 
23
23
  Part of the OpenLFCP TypeScript SDK, which implements the OpenLFCP MVP 0.1
24
24
  subset of [LFCP-WIRE-01](https://github.com/openlfcp/spec/blob/main/wire/LFCP-WIRE-01.md)
25
- at `mvp-0.1-baseline.10`, not every deferred WIRE-01 feature. It does not
25
+ at `mvp-0.1-baseline.9`, not every deferred WIRE-01 feature. It does not
26
26
  claim full LFCP-WIRE-01 conformance. This is the MVP 0.1 release
27
- (`0.1.4`, npm dist-tag `latest`). Until 1.0, minor versions may change APIs.
27
+ (`0.1.3`, npm dist-tag `latest`). Until 1.0, minor versions may change APIs.
28
28
 
29
29
  ## Guarantees
30
30
 
package/dist/idb.d.ts CHANGED
@@ -1,5 +1,33 @@
1
1
  import { type ActorSequence, type ControlRecordId, type DataEpoch, type DataUnitId, type Hash32, type PrincipalId, type ResourceId } from "@openlfcp/core";
2
- import { type ActorSequenceReservation, type CommitResult, type ControlConflictRow, type ControlHeadRow, type ControlRecordRow, type DataUnitRow, type DataUnitStatus, type EpochRow, type KeyPackageRow, type LfcpStorage, type OutboundItem, type ProfileCheckpoint, type ResourceRow, type RouteRow, type SeenRecord, type SnapshotRow, type SnapshotSequenceReservation, type StorageWrite, type StoredDataUnit, type SyncStateRow } from "@openlfcp/storage";
2
+ import { type ActorSequenceReservation, type CommitResult, type ControlConflictRow, type ControlHeadRow, type ControlRecordRow, type DataUnitRow, type DataUnitStatus, type EpochRow, type KeyPackageRow, type LfcpStorage, type LocalStateCipher, type LocalStateDiagnostics, type OutboundItem, type ProfileCheckpoint, type ResourceRow, type RouteRow, type SecretStore, type SeenRecord, type SnapshotRow, type SnapshotSequenceReservation, type StorageWrite, type StoredDataUnit, type SyncStateRow } from "@openlfcp/storage";
3
+ /**
4
+ * LfcpStorage on IndexedDB (LFCP-059): one database per client install,
5
+ * named by the caller. Portable: browsers, Electron, mobile WebViews.
6
+ *
7
+ * - Atomicity: one commit() is one readwrite transaction over every object
8
+ * store; the Control Head compare-and-set is read inside it, and any
9
+ * failure aborts the whole transaction.
10
+ * - Durability: every readwrite transaction asks for durability "strict"
11
+ * (Chromium's default is relaxed), and a promise resolves only on the
12
+ * transaction's complete event.
13
+ * - Sequences: a reservation reads the counter, checks it against this
14
+ * Principal's own stored objects (fail closed, SEQUENCE_REUSE) and writes
15
+ * it in one strict transaction, then calls onReserved before returning.
16
+ * - Exact bytes: signed objects are stored as their exact bytes; other
17
+ * fields are indexes. Values are structured clones, so nothing returned
18
+ * aliases a stored buffer or a caller's.
19
+ *
20
+ * A transaction only awaits IndexedDB requests (never other promises), so it
21
+ * cannot auto-commit half way.
22
+ */
23
+ /**
24
+ * The database version (LFCP-02-029). 1: the LFCP-059 stores (0.1.x).
25
+ * 2: the same stores; checkpoints may be sealed (LFCP-02-098) and 0.2
26
+ * records live in local marks. Nothing is converted from 1 to 2: the
27
+ * version only makes a 0.1.x client, which opens version 1, refuse the
28
+ * database (VersionError) instead of reading sealed rows as plaintext.
29
+ */
30
+ export declare const IDB_VERSION = 2;
3
31
  /** A reservation that is durable and about to be returned. */
4
32
  export interface ReservedSequence {
5
33
  readonly kind: "actor" | "snapshot";
@@ -16,12 +44,34 @@ export interface IdbStorageOptions {
16
44
  * fails and its value is abandoned, never reissued.
17
45
  */
18
46
  readonly onReserved?: (reserved: ReservedSequence) => void;
47
+ /**
48
+ * Local state encrypted at rest (LFCP-02-098): the SecretStore that holds
49
+ * the install's local state key, and the cipher (`localStateCipher` of
50
+ * @openlfcp/crypto). Profile checkpoints are then stored as `lse1`
51
+ * envelopes. A database that seals its local state cannot be opened
52
+ * without it.
53
+ */
54
+ readonly localState?: IdbLocalState;
55
+ }
56
+ export interface IdbLocalState {
57
+ readonly secrets: SecretStore;
58
+ readonly cipher: LocalStateCipher;
59
+ /** The clock of the diagnostics' events (default: the system clock). */
60
+ readonly now?: () => Date;
19
61
  }
20
62
  export declare class IdbLfcpStorage implements LfcpStorage {
21
63
  #private;
22
64
  private constructor();
23
65
  /** Opens (creating or upgrading) the database `name`. */
24
66
  static open(name: string, options?: IdbStorageOptions): Promise<IdbLfcpStorage>;
67
+ /**
68
+ * Rotates the local state key (LFCP-02-098 §8): writes the next
69
+ * generation's key, seals every checkpoint again, then removes the old
70
+ * key. Resumed on the next open after a crash.
71
+ */
72
+ rotateLocalStateKey(): Promise<void>;
73
+ /** The local state encryption status, or null when the database was opened without it. */
74
+ localStateDiagnostics(): Promise<LocalStateDiagnostics | null>;
25
75
  /** Closes the connection; later calls fail. */
26
76
  close(): void;
27
77
  commit(writes: readonly StorageWrite[]): Promise<CommitResult>;
package/dist/idb.js CHANGED
@@ -1,6 +1,6 @@
1
1
  /// <reference lib="dom" />
2
2
  import { bytesEqual, LfcpError, toHex, UINT64_MAX, } from "@openlfcp/core";
3
- import { nextActorSequence, } from "@openlfcp/storage";
3
+ import { LocalStateKeyring, nextActorSequence, reseal, } from "@openlfcp/storage";
4
4
  /**
5
5
  * LfcpStorage on IndexedDB (LFCP-059): one database per client install,
6
6
  * named by the caller. Portable: browsers, Electron, mobile WebViews.
@@ -21,7 +21,14 @@ import { nextActorSequence, } from "@openlfcp/storage";
21
21
  * A transaction only awaits IndexedDB requests (never other promises), so it
22
22
  * cannot auto-commit half way.
23
23
  */
24
- const VERSION = 1;
24
+ /**
25
+ * The database version (LFCP-02-029). 1: the LFCP-059 stores (0.1.x).
26
+ * 2: the same stores; checkpoints may be sealed (LFCP-02-098) and 0.2
27
+ * records live in local marks. Nothing is converted from 1 to 2: the
28
+ * version only makes a 0.1.x client, which opens version 1, refuse the
29
+ * database (VersionError) instead of reading sealed rows as plaintext.
30
+ */
31
+ export const IDB_VERSION = 2;
25
32
  const STORES = [
26
33
  "records",
27
34
  "heads",
@@ -38,6 +45,9 @@ const STORES = [
38
45
  "counters",
39
46
  "meta",
40
47
  ];
48
+ /** The meta key of the local state encryption metadata; the store name of checkpoints in their AAD. */
49
+ const LOCAL_STATE = "local-state";
50
+ const CHECKPOINTS = "checkpoints";
41
51
  const hex = toHex;
42
52
  /** uint64 as a fixed-width decimal string, so key order is numeric order. */
43
53
  const pad = (n) => n.toString().padStart(20, "0");
@@ -94,7 +104,11 @@ class HeadMismatch {
94
104
  this.result = result;
95
105
  }
96
106
  }
97
- function upgrade(db) {
107
+ /** Brings a database from version `from` to IDB_VERSION, in the version change transaction. */
108
+ function upgrade(db, from) {
109
+ // 1 → 2 adds no store: the stores of version 1 are kept as they are.
110
+ if (from >= 1)
111
+ return;
98
112
  const plain = (name) => db.createObjectStore(name);
99
113
  db.createObjectStore("records").createIndex("r", "r");
100
114
  plain("heads");
@@ -118,23 +132,134 @@ function upgrade(db) {
118
132
  export class IdbLfcpStorage {
119
133
  #db;
120
134
  #onReserved;
135
+ #localState;
136
+ #keyring = null;
121
137
  constructor(db, options) {
122
138
  this.#db = db;
123
139
  this.#onReserved = options.onReserved;
140
+ this.#localState = options.localState;
124
141
  }
125
142
  /** Opens (creating or upgrading) the database `name`. */
126
143
  static async open(name, options = {}) {
127
144
  const factory = options.indexedDB ?? globalThis.indexedDB;
128
145
  if (factory === undefined)
129
146
  throw new LfcpError("UNSUPPORTED_VALUE", "IndexedDB is not available in this runtime");
130
- const open = factory.open(name, VERSION);
131
- open.onupgradeneeded = () => upgrade(open.result);
147
+ const open = factory.open(name, IDB_VERSION);
148
+ open.onupgradeneeded = (event) => upgrade(open.result, event.oldVersion);
132
149
  const db = await new Promise((resolve, reject) => {
133
150
  open.onsuccess = () => resolve(open.result);
134
- open.onerror = () => reject(open.error);
151
+ open.onerror = () => reject(open.error?.name === "VersionError"
152
+ ? new LfcpError("UNSUPPORTED_VALUE", `IndexedDB ${name} was written by a newer client than this one (version ${IDB_VERSION}); it is left as it is`)
153
+ : open.error);
135
154
  open.onblocked = () => reject(new LfcpError("UNSUPPORTED_VALUE", `IndexedDB ${name} is blocked by another connection`));
136
155
  });
137
- return new IdbLfcpStorage(db, options);
156
+ const storage = new IdbLfcpStorage(db, options);
157
+ try {
158
+ await storage.#startLocalState();
159
+ }
160
+ catch (e) {
161
+ db.close();
162
+ throw e;
163
+ }
164
+ return storage;
165
+ }
166
+ #now() {
167
+ return (this.#localState?.now?.() ?? new Date()).toISOString();
168
+ }
169
+ /**
170
+ * LFCP-02-098: with `localState`, prepares the keyring (the key goes to the
171
+ * SecretStore before the metadata that names it), then migrates, or
172
+ * continues a half-done migration or rotation. Without it, a database that
173
+ * seals its local state is refused.
174
+ */
175
+ async #startLocalState() {
176
+ const stored = (await this.meta.get(LOCAL_STATE));
177
+ const local = this.#localState;
178
+ if (local === undefined) {
179
+ if (stored !== undefined)
180
+ throw new LfcpError("UNSUPPORTED_VALUE", "this database seals its local state: open it with the localState option");
181
+ return;
182
+ }
183
+ const keyring = await LocalStateKeyring.prepare(local.cipher, local.secrets, stored, this.#now());
184
+ if (JSON.stringify(keyring.meta) !== JSON.stringify(stored))
185
+ await this.meta.put(LOCAL_STATE, keyring.meta);
186
+ this.#keyring = keyring;
187
+ if (keyring.meta.phase !== "ready")
188
+ await this.#finishPhase();
189
+ }
190
+ /**
191
+ * Seals every checkpoint the current phase needs, in resumable batches,
192
+ * then records the phase as ready and removes a key a rotation retired. A
193
+ * batch writes a row only if it still holds the bytes it read, so a
194
+ * checkpoint committed meanwhile is never overwritten.
195
+ */
196
+ async #finishPhase() {
197
+ const keyring = this.#keyring;
198
+ const read = new Map();
199
+ const scan = (after, limit) => this.#tx(["checkpoints"], "readonly", async (tx) => {
200
+ const range = after === null ? undefined : IDBKeyRange.lowerBound(after, true);
201
+ const values = (await req(tx.objectStore("checkpoints").getAll(range, limit)));
202
+ return values.map((c) => {
203
+ const key = hex(c.resourceId);
204
+ read.set(key, c.state);
205
+ return { store: CHECKPOINTS, key, bytes: Uint8Array.from(c.state) };
206
+ });
207
+ });
208
+ const write = (rows) => this.#tx(["checkpoints"], "readwrite", async (tx) => {
209
+ const store = tx.objectStore("checkpoints");
210
+ for (const r of rows) {
211
+ const now = (await req(store.get(r.key)));
212
+ const before = read.get(r.key);
213
+ if (now === undefined || before === undefined || !bytesEqual(now.state, before))
214
+ continue;
215
+ await req(store.put({ ...now, state: r.bytes }, r.key));
216
+ }
217
+ });
218
+ await reseal(keyring, scan, write);
219
+ const retired = keyring.retired;
220
+ const finished = keyring.finished(this.#now());
221
+ await this.meta.put(LOCAL_STATE, finished.meta);
222
+ this.#keyring = finished;
223
+ // The old key goes last: a crash before this line leaves it, harmlessly.
224
+ if (retired !== null)
225
+ await this.#localState.secrets.delete(retired);
226
+ }
227
+ /**
228
+ * Rotates the local state key (LFCP-02-098 §8): writes the next
229
+ * generation's key, seals every checkpoint again, then removes the old
230
+ * key. Resumed on the next open after a crash.
231
+ */
232
+ async rotateLocalStateKey() {
233
+ if (this.#keyring === null || this.#localState === undefined)
234
+ throw new LfcpError("UNSUPPORTED_VALUE", "this database was not opened with localState");
235
+ this.#keyring = await this.#keyring.rotate(this.#localState.secrets, this.#now());
236
+ await this.meta.put(LOCAL_STATE, this.#keyring.meta);
237
+ await this.#finishPhase();
238
+ }
239
+ /** The local state encryption status, or null when the database was opened without it. */
240
+ async localStateDiagnostics() {
241
+ const keyring = this.#keyring;
242
+ if (keyring === null)
243
+ return null;
244
+ const values = await this.#tx(["checkpoints"], "readonly", async (tx) => (await req(tx.objectStore("checkpoints").getAll())));
245
+ const rows = { sealed: 0, plaintext: 0, unreadable: 0 };
246
+ for (const c of values) {
247
+ const opened = keyring.open(CHECKPOINTS, hex(c.resourceId), c.state);
248
+ if (opened.kind === "plain")
249
+ rows.plaintext++;
250
+ else if (opened.kind === "opened")
251
+ rows.sealed++;
252
+ else
253
+ rows.unreadable++;
254
+ }
255
+ return Object.freeze({
256
+ scheme: keyring.meta.scheme,
257
+ generation: keyring.meta.generation,
258
+ keyPresent: keyring.keyPresent,
259
+ phase: keyring.meta.phase,
260
+ rows: Object.freeze(rows),
261
+ lastEvent: keyring.meta.lastEvent,
262
+ });
138
263
  }
139
264
  /** Closes the connection; later calls fail. */
140
265
  close() {
@@ -342,9 +467,14 @@ export class IdbLfcpStorage {
342
467
  case "dequeue":
343
468
  await req(s("outbound").delete(hex(w.itemId)));
344
469
  return;
345
- case "put-profile-checkpoint":
346
- await req(s("checkpoints").put(w.checkpoint, hex(w.checkpoint.resourceId)));
470
+ case "put-profile-checkpoint": {
471
+ const key = hex(w.checkpoint.resourceId);
472
+ const state = this.#keyring === null
473
+ ? w.checkpoint.state
474
+ : this.#keyring.seal(CHECKPOINTS, key, w.checkpoint.state);
475
+ await req(s("checkpoints").put({ ...w.checkpoint, state }, key));
347
476
  return;
477
+ }
348
478
  case "put-sync-state":
349
479
  await req(s("syncStates").put(w.row, hex(w.row.resourceId)));
350
480
  return;
@@ -517,7 +647,16 @@ export class IdbLfcpStorage {
517
647
  get: (id) => this.#get("outbound", hex(id)).then((v) => v?.row),
518
648
  };
519
649
  profileState = {
520
- checkpoint: (resource) => this.#get("checkpoints", hex(resource)),
650
+ checkpoint: async (resource) => {
651
+ const row = await this.#get("checkpoints", hex(resource));
652
+ if (row === undefined || this.#keyring === null)
653
+ return row;
654
+ const opened = this.#keyring.open(CHECKPOINTS, hex(resource), row.state);
655
+ // Sealed under a lost key: absent, rebuilt from the stored units.
656
+ if (opened.kind === "unreadable")
657
+ return undefined;
658
+ return Object.freeze({ ...row, state: opened.bytes });
659
+ },
521
660
  };
522
661
  syncState = {
523
662
  get: (resource) => this.#get("syncStates", hex(resource)),
package/dist/index.d.ts CHANGED
@@ -5,5 +5,5 @@
5
5
  * SecretStore.
6
6
  */
7
7
  export declare const PACKAGE = "@openlfcp/storage-idb";
8
- export { IdbLfcpStorage, type IdbStorageOptions, type ReservedSequence } from "./idb.js";
8
+ export { IDB_VERSION, IdbLfcpStorage, type IdbLocalState, type IdbStorageOptions, type ReservedSequence, } from "./idb.js";
9
9
  //# sourceMappingURL=index.d.ts.map
package/dist/index.js CHANGED
@@ -5,5 +5,5 @@
5
5
  * SecretStore.
6
6
  */
7
7
  export const PACKAGE = "@openlfcp/storage-idb";
8
- export { IdbLfcpStorage } from "./idb.js";
8
+ export { IDB_VERSION, IdbLfcpStorage, } from "./idb.js";
9
9
  //# sourceMappingURL=index.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openlfcp/storage-idb",
3
- "version": "0.1.4",
3
+ "version": "0.2.0-beta.1",
4
4
  "description": "Durable portable storage for LFCP clients on IndexedDB (browsers, Electron, mobile WebViews).",
5
5
  "keywords": [
6
6
  "openlfcp",
@@ -39,11 +39,11 @@
39
39
  ],
40
40
  "publishConfig": {
41
41
  "access": "public",
42
- "tag": "latest"
42
+ "tag": "beta"
43
43
  },
44
44
  "dependencies": {
45
- "@openlfcp/core": "^0.1.4",
46
- "@openlfcp/storage": "^0.1.4"
45
+ "@openlfcp/core": "^0.2.0-beta.1",
46
+ "@openlfcp/storage": "^0.2.0-beta.1"
47
47
  },
48
48
  "devDependencies": {
49
49
  "fake-indexeddb": "6.2.5"