@openlfcp/storage 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
@@ -12,9 +12,9 @@ ESM only; Node.js 24 or later (browsers and Electron for every package except `@
12
12
 
13
13
  Part of the OpenLFCP TypeScript SDK, which implements the OpenLFCP MVP 0.1
14
14
  subset of [LFCP-WIRE-01](https://github.com/openlfcp/spec/blob/main/wire/LFCP-WIRE-01.md)
15
- at `mvp-0.1-baseline.10`, not every deferred WIRE-01 feature. It does not
15
+ at `mvp-0.1-baseline.9`, not every deferred WIRE-01 feature. It does not
16
16
  claim full LFCP-WIRE-01 conformance. This is the MVP 0.1 release
17
- (`0.1.4`, npm dist-tag `latest`). Until 1.0, minor versions may change APIs.
17
+ (`0.1.3`, npm dist-tag `latest`). Until 1.0, minor versions may change APIs.
18
18
 
19
19
  ## Links
20
20
 
package/dist/index.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  /** Name of this package. */
2
2
  export declare const PACKAGE = "@openlfcp/storage";
3
+ export { LOCAL_STATE_SCHEME, type LocalStateCipher, type LocalStateDiagnostics, type LocalStateEvent, LocalStateKeyring, type LocalStateMeta, localStateAad, localStateKeyRef, type OpenedLocal, type ResealRow, reseal, } from "./local-state.js";
3
4
  export { InMemoryLfcpStorage } from "./memory.js";
4
5
  export { dekSecretRef, InMemorySecretStore, isSecretRef, principalKeySecretRef, type SecretKind, type SecretRef, type SecretStore, secretRef, } from "./secrets.js";
5
6
  export { type ActorSequenceReservation, InMemoryActorSequenceReservation, nextActorSequence, SequenceReuseGuard, } from "./sequence.js";
package/dist/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  /** Name of this package. */
2
2
  export const PACKAGE = "@openlfcp/storage";
3
+ export { LOCAL_STATE_SCHEME, LocalStateKeyring, localStateAad, localStateKeyRef, reseal, } from "./local-state.js";
3
4
  export { InMemoryLfcpStorage } from "./memory.js";
4
5
  export { dekSecretRef, InMemorySecretStore, isSecretRef, principalKeySecretRef, secretRef, } from "./secrets.js";
5
6
  export { InMemoryActorSequenceReservation, nextActorSequence, SequenceReuseGuard, } from "./sequence.js";
@@ -0,0 +1,145 @@
1
+ import { type SecretRef, type SecretStore } from "./secrets.js";
2
+ /**
3
+ * Local state encrypted at rest (LFCP-02-098; .github
4
+ * docs/devel/design/local-state-encryption.md).
5
+ *
6
+ * A storage adapter opened with a SecretStore seals the rows that hold
7
+ * profile text (ProfileCheckpoint.state) with the install's local state key
8
+ * before they reach the disk, as `lse1` envelopes. The
9
+ * key lives only in the SecretStore, under
10
+ * `lfcp-secret:local-state-key:<install id>.<generation>`; the adapter's
11
+ * metadata records the scheme, the install ID and the current generation,
12
+ * never key bytes.
13
+ *
14
+ * The keyring decides what to do when an adapter opens:
15
+ * - a new install, or a database from before the scheme: create the key
16
+ * (secret first, then the metadata), then seal every plaintext row
17
+ * (`migrating`);
18
+ * - a migration or rotation left half done: continue it;
19
+ * - a missing key: never a crash and never a silent deletion. The next
20
+ * generation's key is created, rows sealed under the lost one read as
21
+ * unreadable (a checkpoint as absent: the profile state is rebuilt from
22
+ * the stored units), and they are replaced as they are rewritten.
23
+ * Rotation is explicit: the next generation's key is written, every row is
24
+ * sealed again, then the old key is removed.
25
+ *
26
+ * This package does no cryptography (it may not depend on @openlfcp/crypto,
27
+ * LFCP-014): the application passes the cipher, `localStateCipher` of
28
+ * @openlfcp/crypto, which keeps the key bytes inside that package.
29
+ */
30
+ /**
31
+ * The envelope cipher: `localStateCipher` of @openlfcp/crypto
32
+ * (XChaCha20-Poly1305, `lse1` envelopes). Keys are opaque here.
33
+ */
34
+ export interface LocalStateCipher<K = unknown> {
35
+ generateKey(): K;
36
+ importKey(bytes: Uint8Array): K;
37
+ /** A copy of the key bytes, for the SecretStore only. */
38
+ exportKey(key: K): Uint8Array;
39
+ seal(key: K, generation: number, aad: Uint8Array, plaintext: Uint8Array): Uint8Array;
40
+ /** Throws on a wrong key, AAD or generation, or tampered bytes. */
41
+ open(key: K, aad: Uint8Array, envelope: Uint8Array): Uint8Array;
42
+ isSealed(bytes: Uint8Array): boolean;
43
+ generationOf(bytes: Uint8Array): number | undefined;
44
+ }
45
+ /** The scheme an adapter records in its metadata. */
46
+ export declare const LOCAL_STATE_SCHEME = "lse-v1";
47
+ /** Something that happened to the local state key, for diagnostics. */
48
+ export interface LocalStateEvent {
49
+ readonly kind: "created" | "migrated" | "key-lost" | "rotated";
50
+ /** RFC 3339. */
51
+ readonly at: string;
52
+ }
53
+ /** The adapter's record of local encryption. It never holds key bytes. */
54
+ export interface LocalStateMeta {
55
+ readonly scheme: typeof LOCAL_STATE_SCHEME;
56
+ /** A random public name of this install, in every row's AAD and key reference. */
57
+ readonly installId: string;
58
+ /** The generation rows are sealed with. */
59
+ readonly generation: number;
60
+ /** `ready`, or a migration or rotation to continue on the next open. */
61
+ readonly phase: "migrating" | "rotating" | "ready";
62
+ /** While rotating: the generation being replaced. */
63
+ readonly previous: number | null;
64
+ readonly lastEvent: LocalStateEvent | null;
65
+ }
66
+ /** What an adapter's `localStateDiagnostics` reports: never key bytes, envelopes or plaintext. */
67
+ export interface LocalStateDiagnostics {
68
+ readonly scheme: string;
69
+ readonly generation: number;
70
+ readonly keyPresent: boolean;
71
+ readonly phase: LocalStateMeta["phase"];
72
+ /** Rows sealed under a held key, in plaintext, and sealed under a lost key. */
73
+ readonly rows: {
74
+ readonly sealed: number;
75
+ readonly plaintext: number;
76
+ readonly unreadable: number;
77
+ };
78
+ readonly lastEvent: LocalStateEvent | null;
79
+ }
80
+ /** A row's bytes as read: plaintext from before the scheme, opened, or unreadable. */
81
+ export type OpenedLocal = {
82
+ readonly kind: "plain";
83
+ readonly bytes: Uint8Array;
84
+ } | {
85
+ readonly kind: "opened";
86
+ readonly bytes: Uint8Array;
87
+ readonly generation: number;
88
+ } | {
89
+ readonly kind: "unreadable";
90
+ readonly reason: "missing-key" | "authentication";
91
+ };
92
+ /** The SecretStore reference of an install's key of one generation. */
93
+ export declare const localStateKeyRef: (installId: string, generation: number) => SecretRef;
94
+ /**
95
+ * The AAD of a row: the label, the install ID, the store and the row's
96
+ * primary key, each length-prefixed, so a row cannot be swapped with
97
+ * another one or moved to another install.
98
+ */
99
+ export declare function localStateAad(installId: string, store: string, key: string): Uint8Array;
100
+ /** An install's local state keys and metadata, held by an open adapter. */
101
+ export declare class LocalStateKeyring {
102
+ #private;
103
+ readonly meta: LocalStateMeta;
104
+ private constructor();
105
+ /**
106
+ * The keyring for an adapter opening with `stored` metadata (undefined: a
107
+ * new install or a database from before the scheme). Writes any new key
108
+ * to `secrets` before it returns; the caller then records `meta` and runs
109
+ * `reseal` while `meta.phase` is not `ready`.
110
+ */
111
+ static prepare(cipher: LocalStateCipher, secrets: SecretStore, stored: LocalStateMeta | undefined, now: string): Promise<LocalStateKeyring>;
112
+ /**
113
+ * Begins a rotation: writes the next generation's key to `secrets` and
114
+ * returns the keyring to record (phase `rotating`) before `reseal` runs.
115
+ */
116
+ rotate(secrets: SecretStore, now: string): Promise<LocalStateKeyring>;
117
+ /** The keyring once `reseal` has finished: phase `ready`. */
118
+ finished(now: string): LocalStateKeyring;
119
+ /** Whether the current generation's key is held. */
120
+ get keyPresent(): boolean;
121
+ /** The envelope of a row's `plaintext`, under the current key. */
122
+ seal(store: string, key: string, plaintext: Uint8Array): Uint8Array;
123
+ /** A row's bytes as stored: plaintext, opened, or unreadable (never a throw). */
124
+ open(store: string, key: string, bytes: Uint8Array): OpenedLocal;
125
+ /** Whether a stored row needs sealing again: plaintext, or sealed under a readable older generation. */
126
+ needsReseal(bytes: Uint8Array): boolean;
127
+ /** The SecretStore reference of the key a finished rotation replaced, to remove. */
128
+ get retired(): SecretRef | null;
129
+ toJSON(): string;
130
+ }
131
+ /** One row to seal again, as an adapter scans it. */
132
+ export interface ResealRow {
133
+ readonly store: string;
134
+ readonly key: string;
135
+ readonly bytes: Uint8Array;
136
+ }
137
+ /**
138
+ * Seals every row that needs it, in batches: `scan` returns up to `limit`
139
+ * rows after `after` (null: from the start) in key order; `write` stores the
140
+ * sealed rows atomically. Resumable: a row already sealed under the current
141
+ * generation is skipped, so a crash between batches continues on the next
142
+ * open. A row whose key is lost is left as it is.
143
+ */
144
+ export declare function reseal(keyring: LocalStateKeyring, scan: (after: string | null, limit: number) => Promise<readonly ResealRow[]>, write: (rows: readonly ResealRow[]) => Promise<void>, limit?: number): Promise<number>;
145
+ //# sourceMappingURL=local-state.d.ts.map
@@ -0,0 +1,214 @@
1
+ import { LfcpError, toHex } from "@openlfcp/core";
2
+ import { secretRef } from "./secrets.js";
3
+ /** The scheme an adapter records in its metadata. */
4
+ export const LOCAL_STATE_SCHEME = "lse-v1";
5
+ /** The SecretStore reference of an install's key of one generation. */
6
+ export const localStateKeyRef = (installId, generation) => secretRef("local-state-key", `${installId}.${generation}`);
7
+ const LABEL = "openlfcp-local-v1";
8
+ // WHATWG TextEncoder and Web Crypto's getRandomValues, which browsers,
9
+ // Node.js and editors all provide; declared here so the package needs no DOM
10
+ // or Node type library.
11
+ const g = globalThis;
12
+ const utf8 = (text) => new g.TextEncoder().encode(text);
13
+ /**
14
+ * The AAD of a row: the label, the install ID, the store and the row's
15
+ * primary key, each length-prefixed, so a row cannot be swapped with
16
+ * another one or moved to another install.
17
+ */
18
+ export function localStateAad(installId, store, key) {
19
+ const parts = [utf8(LABEL), utf8(installId), utf8(store), utf8(key)];
20
+ const out = new Uint8Array(parts.reduce((n, p) => n + 4 + p.length, 0));
21
+ const view = new DataView(out.buffer);
22
+ let at = 0;
23
+ for (const p of parts) {
24
+ view.setUint32(at, p.length);
25
+ out.set(p, at + 4);
26
+ at += 4 + p.length;
27
+ }
28
+ return out;
29
+ }
30
+ const randomInstallId = () => toHex(g.crypto.getRandomValues(new Uint8Array(16)));
31
+ async function loadKey(cipher, secrets, installId, generation) {
32
+ const bytes = await secrets.get(localStateKeyRef(installId, generation));
33
+ // An empty value marks a deleted secret where the store has no delete.
34
+ if (bytes === undefined || bytes.length === 0)
35
+ return undefined;
36
+ try {
37
+ return cipher.importKey(bytes);
38
+ }
39
+ finally {
40
+ bytes.fill(0);
41
+ }
42
+ }
43
+ async function storeKey(cipher, secrets, installId, generation) {
44
+ const key = cipher.generateKey();
45
+ const bytes = cipher.exportKey(key);
46
+ try {
47
+ await secrets.put(localStateKeyRef(installId, generation), bytes);
48
+ }
49
+ finally {
50
+ bytes.fill(0);
51
+ }
52
+ return key;
53
+ }
54
+ /** An install's local state keys and metadata, held by an open adapter. */
55
+ export class LocalStateKeyring {
56
+ #cipher;
57
+ #keys;
58
+ meta;
59
+ constructor(cipher, meta, keys) {
60
+ this.#cipher = cipher;
61
+ this.meta = Object.freeze({ ...meta });
62
+ this.#keys = keys;
63
+ }
64
+ /**
65
+ * The keyring for an adapter opening with `stored` metadata (undefined: a
66
+ * new install or a database from before the scheme). Writes any new key
67
+ * to `secrets` before it returns; the caller then records `meta` and runs
68
+ * `reseal` while `meta.phase` is not `ready`.
69
+ */
70
+ static async prepare(cipher, secrets, stored, now) {
71
+ if (stored === undefined) {
72
+ const installId = randomInstallId();
73
+ const key = await storeKey(cipher, secrets, installId, 1);
74
+ return new LocalStateKeyring(cipher, {
75
+ scheme: LOCAL_STATE_SCHEME,
76
+ installId,
77
+ generation: 1,
78
+ phase: "migrating",
79
+ previous: null,
80
+ lastEvent: { kind: "created", at: now },
81
+ }, new Map([[1, key]]));
82
+ }
83
+ if (stored.scheme !== LOCAL_STATE_SCHEME)
84
+ throw new LfcpError("UNSUPPORTED_VALUE", `unknown local state scheme ${stored.scheme}`);
85
+ const keys = new Map();
86
+ const current = await loadKey(cipher, secrets, stored.installId, stored.generation);
87
+ if (stored.previous !== null) {
88
+ const previous = await loadKey(cipher, secrets, stored.installId, stored.previous);
89
+ if (previous !== undefined)
90
+ keys.set(stored.previous, previous);
91
+ }
92
+ if (current !== undefined) {
93
+ keys.set(stored.generation, current);
94
+ return new LocalStateKeyring(cipher, stored, keys);
95
+ }
96
+ // The current key is lost: start the next generation. Rows sealed under
97
+ // the lost key stay unreadable until they are rewritten.
98
+ const generation = stored.generation + 1;
99
+ keys.set(generation, await storeKey(cipher, secrets, stored.installId, generation));
100
+ return new LocalStateKeyring(cipher, {
101
+ ...stored,
102
+ generation,
103
+ phase: stored.phase === "rotating" ? "rotating" : stored.phase,
104
+ lastEvent: { kind: "key-lost", at: now },
105
+ }, keys);
106
+ }
107
+ /**
108
+ * Begins a rotation: writes the next generation's key to `secrets` and
109
+ * returns the keyring to record (phase `rotating`) before `reseal` runs.
110
+ */
111
+ async rotate(secrets, now) {
112
+ if (this.meta.phase !== "ready")
113
+ throw new LfcpError("UNSUPPORTED_VALUE", `the local state is ${this.meta.phase}`);
114
+ const generation = this.meta.generation + 1;
115
+ const keys = new Map(this.#keys);
116
+ keys.set(generation, await storeKey(this.#cipher, secrets, this.meta.installId, generation));
117
+ return new LocalStateKeyring(this.#cipher, {
118
+ ...this.meta,
119
+ generation,
120
+ phase: "rotating",
121
+ previous: this.meta.generation,
122
+ lastEvent: { kind: "rotated", at: now },
123
+ }, keys);
124
+ }
125
+ /** The keyring once `reseal` has finished: phase `ready`. */
126
+ finished(now) {
127
+ const migrated = this.meta.phase === "migrating";
128
+ const keys = new Map([...this.#keys].filter(([g]) => g === this.meta.generation));
129
+ return new LocalStateKeyring(this.#cipher, {
130
+ ...this.meta,
131
+ phase: "ready",
132
+ previous: null,
133
+ lastEvent: migrated ? { kind: "migrated", at: now } : this.meta.lastEvent,
134
+ }, keys);
135
+ }
136
+ /** Whether the current generation's key is held. */
137
+ get keyPresent() {
138
+ return this.#keys.has(this.meta.generation);
139
+ }
140
+ /** The envelope of a row's `plaintext`, under the current key. */
141
+ seal(store, key, plaintext) {
142
+ const k = this.#keys.get(this.meta.generation);
143
+ if (k === undefined)
144
+ throw new LfcpError("CRYPTO_FAILURE", "the local state key is missing");
145
+ return this.#cipher.seal(k, this.meta.generation, localStateAad(this.meta.installId, store, key), plaintext);
146
+ }
147
+ /** A row's bytes as stored: plaintext, opened, or unreadable (never a throw). */
148
+ open(store, key, bytes) {
149
+ if (!this.#cipher.isSealed(bytes))
150
+ return { kind: "plain", bytes };
151
+ const generation = this.#cipher.generationOf(bytes);
152
+ const k = this.#keys.get(generation);
153
+ if (k === undefined)
154
+ return { kind: "unreadable", reason: "missing-key" };
155
+ try {
156
+ return {
157
+ kind: "opened",
158
+ bytes: this.#cipher.open(k, localStateAad(this.meta.installId, store, key), bytes),
159
+ generation,
160
+ };
161
+ }
162
+ catch {
163
+ return { kind: "unreadable", reason: "authentication" };
164
+ }
165
+ }
166
+ /** Whether a stored row needs sealing again: plaintext, or sealed under a readable older generation. */
167
+ needsReseal(bytes) {
168
+ if (!this.#cipher.isSealed(bytes))
169
+ return true;
170
+ const generation = this.#cipher.generationOf(bytes);
171
+ return generation !== this.meta.generation && this.#keys.has(generation);
172
+ }
173
+ /** The SecretStore reference of the key a finished rotation replaced, to remove. */
174
+ get retired() {
175
+ return this.meta.previous === null
176
+ ? null
177
+ : localStateKeyRef(this.meta.installId, this.meta.previous);
178
+ }
179
+ toJSON() {
180
+ return "[LocalStateKeyring]";
181
+ }
182
+ }
183
+ /**
184
+ * Seals every row that needs it, in batches: `scan` returns up to `limit`
185
+ * rows after `after` (null: from the start) in key order; `write` stores the
186
+ * sealed rows atomically. Resumable: a row already sealed under the current
187
+ * generation is skipped, so a crash between batches continues on the next
188
+ * open. A row whose key is lost is left as it is.
189
+ */
190
+ export async function reseal(keyring, scan, write, limit = 64) {
191
+ let after = null;
192
+ let count = 0;
193
+ for (;;) {
194
+ const rows = await scan(after, limit);
195
+ if (rows.length === 0)
196
+ return count;
197
+ const sealed = [];
198
+ for (const row of rows) {
199
+ if (!keyring.needsReseal(row.bytes))
200
+ continue;
201
+ const opened = keyring.open(row.store, row.key, row.bytes);
202
+ if (opened.kind === "unreadable")
203
+ continue;
204
+ sealed.push({ ...row, bytes: keyring.seal(row.store, row.key, opened.bytes) });
205
+ }
206
+ if (sealed.length > 0)
207
+ await write(sealed);
208
+ count += sealed.length;
209
+ after = rows.at(-1).key;
210
+ if (rows.length < limit)
211
+ return count;
212
+ }
213
+ }
214
+ //# sourceMappingURL=local-state.js.map
package/dist/secrets.d.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  import { type DataEpoch, type PrincipalId, type ResourceId } from "@openlfcp/core";
2
2
  /**
3
- * Client secret material (LFCP-034): Principal private keys, Resource DEKs
4
- * and invitation secrets live only in a SecretStore. Public storage rows
3
+ * Client secret material (LFCP-034): Principal private keys, Resource DEKs,
4
+ * invitation secrets and local state keys (LFCP-02-098) live only in a
5
+ * SecretStore. Public storage rows
5
6
  * hold a SecretRef, a name that reveals nothing about the value.
6
7
  *
7
8
  * A SecretStore cannot be enumerated: there is no list operation, so no
@@ -14,7 +15,7 @@ import { type DataEpoch, type PrincipalId, type ResourceId } from "@openlfcp/cor
14
15
  * first, then commit the row that references it. A reference without a
15
16
  * value is a recoverable gap; a value without a reference is harmless.
16
17
  */
17
- export type SecretKind = "principal-signing-key" | "principal-agreement-key" | "resource-dek" | "invitation-secret";
18
+ export type SecretKind = "principal-signing-key" | "principal-agreement-key" | "resource-dek" | "invitation-secret" | "local-state-key";
18
19
  /** The name of a secret: "lfcp-secret:<kind>:<id>". It never contains secret material. */
19
20
  export type SecretRef = string & {
20
21
  readonly __secretRef: true;
package/dist/secrets.js CHANGED
@@ -4,6 +4,7 @@ const KINDS = new Set([
4
4
  "principal-agreement-key",
5
5
  "resource-dek",
6
6
  "invitation-secret",
7
+ "local-state-key",
7
8
  ]);
8
9
  const ID = /^[A-Za-z0-9._-]{1,200}$/;
9
10
  /** A secret reference; `id` is a public name such as hex IDs joined with ".". */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openlfcp/storage",
3
- "version": "0.1.4",
3
+ "version": "0.2.0-beta.1",
4
4
  "description": "Storage interfaces for LFCP clients (no adapters).",
5
5
  "keywords": [
6
6
  "openlfcp",
@@ -42,9 +42,9 @@
42
42
  ],
43
43
  "publishConfig": {
44
44
  "access": "public",
45
- "tag": "latest"
45
+ "tag": "beta"
46
46
  },
47
47
  "dependencies": {
48
- "@openlfcp/core": "^0.1.4"
48
+ "@openlfcp/core": "^0.2.0-beta.1"
49
49
  }
50
50
  }