@agentcomms/events-daemon 0.0.0-stage → 0.16.0
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/LICENSE +21 -0
- package/README.md +146 -2
- package/THIRD_PARTY_LICENSES +499 -0
- package/dist/b3-contract-03AO67VB.mjs +2235 -0
- package/dist/cli.d.mts +1 -0
- package/dist/cli.mjs +18235 -0
- package/dist/index.d.mts +12701 -0
- package/dist/index.mjs +2 -0
- package/dist/mcp-DXXb3Vv3-B0sHZfig.mjs +22131 -0
- package/dist/records-3rGS8spn.mjs +1143 -0
- package/dist/records-DojbuTaG.mjs +2 -0
- package/dist/stdio-CD7QQBAh.mjs +102 -0
- package/dist/webhook-target-BkA_wHIu.mjs +662 -0
- package/package.json +71 -3
|
@@ -0,0 +1,1143 @@
|
|
|
1
|
+
import { t as canonicalSecretWebhookUrl } from "./webhook-target-BkA_wHIu.mjs";
|
|
2
|
+
import { createCipheriv, createDecipheriv, hkdfSync, randomBytes } from "node:crypto";
|
|
3
|
+
import "node:fs/promises";
|
|
4
|
+
import { FileSecretStore, KeychainSecretStore, canonicalJson, keychainNamespace, loadKeyringModule, sha256Hex, withFileLock } from "@agentcomms/core";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
7
|
+
//#region src/store/aad.ts
|
|
8
|
+
const AAD_PREFIX = Buffer.from("aec-v1", "ascii");
|
|
9
|
+
const PRIMARY_KEY_COMPONENT_COUNTS = {
|
|
10
|
+
"source_scan_state.encryptedRecord": 1,
|
|
11
|
+
"rule_activation_points.encryptedPosition": 5,
|
|
12
|
+
"activation_baselines.encryptedPosition": 4,
|
|
13
|
+
"ingest_rules.encryptedProjection": 3,
|
|
14
|
+
"decisions.encryptedRecord": 1,
|
|
15
|
+
"deliveries.encryptedRecord": 1,
|
|
16
|
+
"dryrun_log.encryptedRecord": 1,
|
|
17
|
+
"stream_log.encryptedRecord": 1,
|
|
18
|
+
"event_secret_generations.encryptedReference": 5,
|
|
19
|
+
"system_reset_outbox.encryptedRecord": 1
|
|
20
|
+
};
|
|
21
|
+
var AadError = class extends Error {
|
|
22
|
+
constructor(message) {
|
|
23
|
+
super(message);
|
|
24
|
+
this.name = "AadError";
|
|
25
|
+
}
|
|
26
|
+
};
|
|
27
|
+
function lengthPrefixed(value) {
|
|
28
|
+
if (value.byteLength > 4294967295) throw new AadError("AAD value is too large to encode");
|
|
29
|
+
const length = Buffer.allocUnsafe(4);
|
|
30
|
+
length.writeUInt32BE(value.byteLength);
|
|
31
|
+
return Buffer.concat([length, value]);
|
|
32
|
+
}
|
|
33
|
+
function integerBytes(value) {
|
|
34
|
+
const integer = typeof value === "bigint" ? value : BigInt(value);
|
|
35
|
+
if (typeof value === "number" && (!Number.isSafeInteger(value) || !Number.isInteger(value))) throw new AadError("AAD INTEGER must be a safe integer or bigint");
|
|
36
|
+
if (integer < -(1n << 63n) || integer > (1n << 63n) - 1n) throw new AadError("AAD INTEGER is outside SQLite’s signed 64-bit range");
|
|
37
|
+
const encoded = Buffer.allocUnsafe(8);
|
|
38
|
+
encoded.writeBigInt64BE(integer);
|
|
39
|
+
return encoded;
|
|
40
|
+
}
|
|
41
|
+
function componentBytes(component) {
|
|
42
|
+
switch (component.type) {
|
|
43
|
+
case "integer": return Buffer.concat([Buffer.from([1]), integerBytes(component.value)]);
|
|
44
|
+
case "text": return Buffer.concat([Buffer.from([2]), lengthPrefixed(Buffer.from(component.value, "utf8"))]);
|
|
45
|
+
case "blob": return Buffer.concat([Buffer.from([3]), lengthPrefixed(Buffer.from(component.value))]);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Encodes the one AAD form every encrypted event record uses.
|
|
50
|
+
*
|
|
51
|
+
* Components must arrive in the primary-key order declared by the migration. The table/column map intentionally
|
|
52
|
+
* lives here as well as in the schema, so a caller cannot silently encrypt an unsupported column or omit one part
|
|
53
|
+
* of its row identity.
|
|
54
|
+
*/
|
|
55
|
+
function encodeAad(table, column, components) {
|
|
56
|
+
const id = `${table}.${column}`;
|
|
57
|
+
const expected = PRIMARY_KEY_COMPONENT_COUNTS[id];
|
|
58
|
+
if (expected === void 0) throw new AadError(`no encrypted event column is named ${id}`);
|
|
59
|
+
if (components.length !== expected) throw new AadError(`${id} needs ${expected} primary-key components, got ${components.length}`);
|
|
60
|
+
return Buffer.concat([
|
|
61
|
+
AAD_PREFIX,
|
|
62
|
+
lengthPrefixed(Buffer.from(table, "utf8")),
|
|
63
|
+
lengthPrefixed(Buffer.from(column, "utf8")),
|
|
64
|
+
Buffer.from([components.length]),
|
|
65
|
+
...components.map(componentBytes)
|
|
66
|
+
]);
|
|
67
|
+
}
|
|
68
|
+
Object.freeze({ ...PRIMARY_KEY_COMPONENT_COUNTS });
|
|
69
|
+
//#endregion
|
|
70
|
+
//#region src/store/crypto.ts
|
|
71
|
+
const FORMAT_VERSION = 1;
|
|
72
|
+
const NONCE_BYTES = 12;
|
|
73
|
+
const TAG_BYTES = 16;
|
|
74
|
+
var PackedRecordError = class extends Error {
|
|
75
|
+
code;
|
|
76
|
+
constructor(code, message) {
|
|
77
|
+
super(message);
|
|
78
|
+
this.name = "PackedRecordError";
|
|
79
|
+
this.code = code;
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
function checkedKey(key) {
|
|
83
|
+
if (key.byteLength !== 32) throw new PackedRecordError("PACKED_RECORD_INVALID", "AES-256-GCM needs a 32-byte key");
|
|
84
|
+
return Buffer.from(key);
|
|
85
|
+
}
|
|
86
|
+
function checkedKeyId(keyId) {
|
|
87
|
+
const encoded = Buffer.from(keyId, "ascii");
|
|
88
|
+
if (keyId.length === 0 || keyId.length > 255 || encoded.toString("ascii") !== keyId) throw new PackedRecordError("PACKED_RECORD_INVALID", "packed record key identifiers are non-empty ASCII of at most 255 bytes");
|
|
89
|
+
return encoded;
|
|
90
|
+
}
|
|
91
|
+
function unpack(record) {
|
|
92
|
+
const bytes = Buffer.from(record);
|
|
93
|
+
if (bytes.byteLength < 31 || bytes[0] !== FORMAT_VERSION) throw new PackedRecordError("PACKED_RECORD_INVALID", "encrypted record is not packed record format v1");
|
|
94
|
+
const keyIdLength = bytes[1] ?? 0;
|
|
95
|
+
const keyStart = 2;
|
|
96
|
+
const nonceStart = keyStart + keyIdLength;
|
|
97
|
+
const ciphertextStart = nonceStart + NONCE_BYTES;
|
|
98
|
+
const tagStart = bytes.byteLength - TAG_BYTES;
|
|
99
|
+
if (keyIdLength === 0 || ciphertextStart > tagStart) throw new PackedRecordError("PACKED_RECORD_INVALID", "encrypted record has invalid packed boundaries");
|
|
100
|
+
const keyIdBytes = bytes.subarray(keyStart, nonceStart);
|
|
101
|
+
const keyId = keyIdBytes.toString("ascii");
|
|
102
|
+
if (keyIdBytes.toString("ascii") !== keyId || !/^[\x21-\x7e]+$/.test(keyId)) throw new PackedRecordError("PACKED_RECORD_INVALID", "encrypted record has a non-ASCII key identifier");
|
|
103
|
+
return {
|
|
104
|
+
keyId,
|
|
105
|
+
nonce: bytes.subarray(nonceStart, ciphertextStart),
|
|
106
|
+
ciphertext: bytes.subarray(ciphertextStart, tagStart),
|
|
107
|
+
tag: bytes.subarray(tagStart)
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
/** Packs AES-256-GCM ciphertext exactly as D8 record format v1. */
|
|
111
|
+
function encryptPackedRecord(input) {
|
|
112
|
+
const keyId = checkedKeyId(input.keyId);
|
|
113
|
+
const key = checkedKey(input.key);
|
|
114
|
+
const nonce = input.nonce === void 0 ? randomBytes(NONCE_BYTES) : Buffer.from(input.nonce);
|
|
115
|
+
if (nonce.byteLength !== NONCE_BYTES) throw new PackedRecordError("PACKED_RECORD_INVALID", "packed record nonces must be 96 bits");
|
|
116
|
+
const cipher = createCipheriv("aes-256-gcm", key, nonce, { authTagLength: TAG_BYTES });
|
|
117
|
+
cipher.setAAD(Buffer.from(input.aad));
|
|
118
|
+
const ciphertext = Buffer.concat([cipher.update(Buffer.from(input.plaintext)), cipher.final()]);
|
|
119
|
+
return Buffer.concat([
|
|
120
|
+
Buffer.from([FORMAT_VERSION, keyId.byteLength]),
|
|
121
|
+
keyId,
|
|
122
|
+
nonce,
|
|
123
|
+
ciphertext,
|
|
124
|
+
cipher.getAuthTag()
|
|
125
|
+
]);
|
|
126
|
+
}
|
|
127
|
+
/** Opens one packed record and refuses a missing key, malformed record, or an authentication mismatch. */
|
|
128
|
+
function decryptPackedRecord(input) {
|
|
129
|
+
const parsed = unpack(input.record);
|
|
130
|
+
const selected = input.keyForId(parsed.keyId);
|
|
131
|
+
if (selected === null) throw new PackedRecordError("UNKNOWN_KEY", "encrypted record names an unavailable master key");
|
|
132
|
+
try {
|
|
133
|
+
const decipher = createDecipheriv("aes-256-gcm", checkedKey(selected), parsed.nonce, { authTagLength: TAG_BYTES });
|
|
134
|
+
decipher.setAAD(Buffer.from(input.aad));
|
|
135
|
+
decipher.setAuthTag(parsed.tag);
|
|
136
|
+
return Buffer.concat([decipher.update(parsed.ciphertext), decipher.final()]);
|
|
137
|
+
} catch (error) {
|
|
138
|
+
if (error instanceof PackedRecordError) throw error;
|
|
139
|
+
throw new PackedRecordError("AUTHENTICATION_FAILED", "encrypted record did not authenticate for its declared row");
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
/** Reads only the identifier from a syntactically valid packed record, before its master is looked up asynchronously. */
|
|
143
|
+
function packedRecordKeyId(record) {
|
|
144
|
+
return unpack(record).keyId;
|
|
145
|
+
}
|
|
146
|
+
/** Derives the prescribed table subkey from one installation master without putting the key id in HKDF info. */
|
|
147
|
+
function deriveTableKey(master, table) {
|
|
148
|
+
return Buffer.from(hkdfSync("sha256", checkedKey(master), Buffer.alloc(0), `agentcomms-events/${table}/v1`, 32));
|
|
149
|
+
}
|
|
150
|
+
//#endregion
|
|
151
|
+
//#region src/store/event-secrets.ts
|
|
152
|
+
var EventSecretError = class extends Error {
|
|
153
|
+
code;
|
|
154
|
+
constructor(code, message) {
|
|
155
|
+
super(message);
|
|
156
|
+
this.name = "EventSecretError";
|
|
157
|
+
this.code = code;
|
|
158
|
+
}
|
|
159
|
+
};
|
|
160
|
+
const lockScope = new AsyncLocalStorage();
|
|
161
|
+
/** Shares the one process- and filesystem-wide lock across master, generation and migration work. */
|
|
162
|
+
async function withEventSecretsLock(paths, work) {
|
|
163
|
+
const lockPath = join(paths.root, "secrets.lock");
|
|
164
|
+
if (lockScope.getStore()?.has(lockPath)) return work();
|
|
165
|
+
return withFileLock(lockPath, async () => lockScope.run(/* @__PURE__ */ new Set([...lockScope.getStore() ?? [], lockPath]), work), {
|
|
166
|
+
timeoutMs: 5e3,
|
|
167
|
+
staleMs: 3e4
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
function generationLocation(owner, purpose, generation) {
|
|
171
|
+
return {
|
|
172
|
+
table: "event_secret_generations",
|
|
173
|
+
column: "encryptedReference",
|
|
174
|
+
key: [
|
|
175
|
+
{
|
|
176
|
+
type: "text",
|
|
177
|
+
value: owner.kind
|
|
178
|
+
},
|
|
179
|
+
{
|
|
180
|
+
type: "text",
|
|
181
|
+
value: owner.id
|
|
182
|
+
},
|
|
183
|
+
{
|
|
184
|
+
type: "integer",
|
|
185
|
+
value: owner.version
|
|
186
|
+
},
|
|
187
|
+
{
|
|
188
|
+
type: "text",
|
|
189
|
+
value: purpose
|
|
190
|
+
},
|
|
191
|
+
{
|
|
192
|
+
type: "integer",
|
|
193
|
+
value: generation
|
|
194
|
+
}
|
|
195
|
+
]
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
function assertPurpose(owner, purpose) {
|
|
199
|
+
if (!(owner.kind === "target" && (purpose === "secret-url" || purpose === "webhook-signing") || owner.kind === "subscriber" && purpose === "sse-bearer" || owner.kind === "judge" && purpose === "judge-key")) throw new EventSecretError("EVENT_SECRET_REFERENCE", "the secret purpose does not belong to the exact object version");
|
|
200
|
+
}
|
|
201
|
+
function asStoredGeneration(row) {
|
|
202
|
+
const ownerKind = row.owner_kind;
|
|
203
|
+
const purpose = row.purpose;
|
|
204
|
+
const lifecycle = row.lifecycle;
|
|
205
|
+
if (ownerKind !== "target" && ownerKind !== "subscriber" && ownerKind !== "judge" || purpose !== "secret-url" && purpose !== "webhook-signing" && purpose !== "sse-bearer" && purpose !== "judge-key" || lifecycle !== "current" && lifecycle !== "overlap" && lifecycle !== "retired" || typeof row.owner_id !== "string" || typeof row.owner_version !== "number" || typeof row.owner_digest !== "string" || typeof row.generation !== "number" || typeof row.secret_digest !== "string" || !(row.encrypted_ref instanceof Uint8Array)) throw new EventSecretError("EVENT_SECRET_REFERENCE", "event secret generation ledger has an invalid row");
|
|
206
|
+
return {
|
|
207
|
+
owner: {
|
|
208
|
+
kind: ownerKind,
|
|
209
|
+
id: row.owner_id,
|
|
210
|
+
version: row.owner_version,
|
|
211
|
+
digest: row.owner_digest
|
|
212
|
+
},
|
|
213
|
+
purpose,
|
|
214
|
+
generation: row.generation,
|
|
215
|
+
lifecycle,
|
|
216
|
+
expiresAt: typeof row.expires_at === "number" ? row.expires_at : null,
|
|
217
|
+
secretDigest: row.secret_digest,
|
|
218
|
+
encryptedRef: row.encrypted_ref
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
async function referencesFor(database, cipher) {
|
|
222
|
+
const masters = database.prepare("SELECT secret_ref FROM event_secret_masters").all().map((row) => row.secret_ref);
|
|
223
|
+
const rows = database.prepare(`SELECT owner_kind, owner_id, owner_version, purpose, generation, owner_digest, secret_digest,
|
|
224
|
+
encrypted_ref, lifecycle, expires_at
|
|
225
|
+
FROM event_secret_generations WHERE lifecycle IN ('current', 'overlap')`).all();
|
|
226
|
+
if (rows.length === 0) return [...new Set(masters)].sort();
|
|
227
|
+
if (cipher === void 0) throw new EventSecretError("EVENT_SECRET_REFERENCE", "event secret generations need the record cipher for reference reconciliation");
|
|
228
|
+
const references = [...masters];
|
|
229
|
+
for (const row of rows) {
|
|
230
|
+
const generation = asStoredGeneration(row);
|
|
231
|
+
const value = (await cipher.decrypt(generationLocation(generation.owner, generation.purpose, generation.generation), generation.encryptedRef)).toString("utf8");
|
|
232
|
+
if (!/^event-secret:[a-f0-9]{24}$/.test(value)) throw new EventSecretError("EVENT_SECRET_REFERENCE", "event secret generation has an invalid opaque reference");
|
|
233
|
+
references.push(value);
|
|
234
|
+
}
|
|
235
|
+
return [...new Set(references)].sort();
|
|
236
|
+
}
|
|
237
|
+
function selectedKind(database) {
|
|
238
|
+
const row = database.prepare("SELECT value FROM meta WHERE key = 'event_secret_store'").get();
|
|
239
|
+
if (row === void 0) return null;
|
|
240
|
+
if (row.value === "keychain" || row.value === "file") return row.value;
|
|
241
|
+
throw new EventSecretError("EVENT_SECRET_SELECTOR", "event secret selector is not a recognised backend");
|
|
242
|
+
}
|
|
243
|
+
function setSelector(database, kind) {
|
|
244
|
+
database.prepare("INSERT INTO meta (key, value) VALUES ('event_secret_store', ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value").run(kind);
|
|
245
|
+
}
|
|
246
|
+
/** A synchronous transaction-safe representation of every reference migration is allowed to move. */
|
|
247
|
+
function referenceSnapshot(database) {
|
|
248
|
+
const masters = database.prepare("SELECT key_id, secret_ref, retired_at FROM event_secret_masters ORDER BY key_id").all();
|
|
249
|
+
const generations = database.prepare(`SELECT owner_kind, owner_id, owner_version, purpose, generation, owner_digest, secret_digest,
|
|
250
|
+
hex(encrypted_ref) AS encrypted_ref, lifecycle, expires_at
|
|
251
|
+
FROM event_secret_generations ORDER BY owner_kind, owner_id, owner_version, purpose, generation`).all();
|
|
252
|
+
return canonicalJson({
|
|
253
|
+
masters,
|
|
254
|
+
generations
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
/** The core keychain namespace deliberately gains an events-only suffix. */
|
|
258
|
+
function eventKeychainNamespace(configDir) {
|
|
259
|
+
return `${keychainNamespace(configDir)}:events`;
|
|
260
|
+
}
|
|
261
|
+
async function storeFor(options, kind) {
|
|
262
|
+
const injected = options.stores?.[kind];
|
|
263
|
+
if (injected !== void 0) return injected;
|
|
264
|
+
if (kind === "file") return new FileSecretStore(options.paths.secretsDir);
|
|
265
|
+
const keyring = options.keyring === void 0 ? await loadKeyringModule() : options.keyring;
|
|
266
|
+
if (keyring === null) throw new EventSecretError("EVENT_SECRET_SELECTOR", "event secrets default to the system keychain, which is unavailable");
|
|
267
|
+
return new KeychainSecretStore(keyring, eventKeychainNamespace(options.configDir));
|
|
268
|
+
}
|
|
269
|
+
function parseMaster(ref, value) {
|
|
270
|
+
if (value === null) throw new EventSecretError("EVENT_MASTER_LOST", `event master ${ref} is unavailable`);
|
|
271
|
+
const key = Buffer.from(value, "base64");
|
|
272
|
+
if (key.byteLength !== 32 || key.toString("base64") !== value) throw new EventSecretError("EVENT_MASTER_LOST", `event master ${ref} is invalid`);
|
|
273
|
+
return key;
|
|
274
|
+
}
|
|
275
|
+
/**
|
|
276
|
+
* The daemon's independent master-key ledger. It uses core's hardened physical backends but owns selection,
|
|
277
|
+
* references, locking and migration boundaries itself.
|
|
278
|
+
*/
|
|
279
|
+
var EventSecretStore = class {
|
|
280
|
+
#database;
|
|
281
|
+
#paths;
|
|
282
|
+
#kind;
|
|
283
|
+
#store;
|
|
284
|
+
#selectorWasAbsent;
|
|
285
|
+
#openStore;
|
|
286
|
+
constructor(database, paths, kind, store, selectorWasAbsent, openStore) {
|
|
287
|
+
this.#database = database;
|
|
288
|
+
this.#paths = paths;
|
|
289
|
+
this.#kind = kind;
|
|
290
|
+
this.#store = store;
|
|
291
|
+
this.#selectorWasAbsent = selectorWasAbsent;
|
|
292
|
+
this.#openStore = openStore;
|
|
293
|
+
}
|
|
294
|
+
get kind() {
|
|
295
|
+
return this.#kind;
|
|
296
|
+
}
|
|
297
|
+
async references(cipher) {
|
|
298
|
+
return this.withLock(() => referencesFor(this.#database, cipher));
|
|
299
|
+
}
|
|
300
|
+
/** The owner alone opens the other events-only backend; no core selector or namespace participates. */
|
|
301
|
+
async migrateTo(to, referenceCipher) {
|
|
302
|
+
const target = to === this.#kind ? this.#store : await this.#openStore(to);
|
|
303
|
+
const result = await migrateEventSecrets({
|
|
304
|
+
database: this.#database,
|
|
305
|
+
paths: this.#paths,
|
|
306
|
+
from: this.#kind,
|
|
307
|
+
to,
|
|
308
|
+
source: this.#store,
|
|
309
|
+
target,
|
|
310
|
+
referenceCipher
|
|
311
|
+
});
|
|
312
|
+
if (selectedKind(this.#database) === to) {
|
|
313
|
+
this.#kind = to;
|
|
314
|
+
this.#store = target;
|
|
315
|
+
}
|
|
316
|
+
return result;
|
|
317
|
+
}
|
|
318
|
+
async currentMaster() {
|
|
319
|
+
return this.withLock(async () => {
|
|
320
|
+
this.assertSelectedBackend();
|
|
321
|
+
const current = this.#database.prepare("SELECT key_id, secret_ref FROM event_secret_masters WHERE retired_at IS NULL ORDER BY created_at DESC LIMIT 1").get();
|
|
322
|
+
if (current !== void 0) return {
|
|
323
|
+
keyId: current.key_id,
|
|
324
|
+
key: parseMaster(current.secret_ref, await readBackend(this.#store, current.secret_ref))
|
|
325
|
+
};
|
|
326
|
+
if (this.#selectorWasAbsent) await this.probeInitialBackend();
|
|
327
|
+
return this.createMaster(void 0);
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
async master(keyId) {
|
|
331
|
+
return this.withLock(async () => {
|
|
332
|
+
this.assertSelectedBackend();
|
|
333
|
+
const row = this.#database.prepare("SELECT secret_ref FROM event_secret_masters WHERE key_id = ?").get(keyId);
|
|
334
|
+
if (row === void 0) throw new EventSecretError("EVENT_MASTER_UNKNOWN", `event master ${keyId} is unknown`);
|
|
335
|
+
return parseMaster(row.secret_ref, await readBackend(this.#store, row.secret_ref));
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
async rotateMaster() {
|
|
339
|
+
return this.withLock(async () => {
|
|
340
|
+
this.assertSelectedBackend();
|
|
341
|
+
const current = this.#database.prepare("SELECT key_id FROM event_secret_masters WHERE retired_at IS NULL ORDER BY created_at DESC LIMIT 1").get();
|
|
342
|
+
return this.createMaster(current?.key_id);
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
/**
|
|
346
|
+
* A retired master may be physically deleted only once every packed record
|
|
347
|
+
* and encrypted opaque-reference column proves it no longer names that key.
|
|
348
|
+
* The caller owns the writer-quiescer exclusive lease; this method retains
|
|
349
|
+
* the secrets lock for the entire scan and deletion.
|
|
350
|
+
*/
|
|
351
|
+
async destroyRetiredMaster(input) {
|
|
352
|
+
input.quiescer.assertExclusive();
|
|
353
|
+
return this.withLock(async () => {
|
|
354
|
+
this.assertSelectedBackend();
|
|
355
|
+
const row = this.#database.prepare("SELECT secret_ref, retired_at FROM event_secret_masters WHERE key_id = ?").get(input.keyId);
|
|
356
|
+
if (row === void 0 || row.retired_at === null) throw new EventSecretError("EVENT_MASTER_UNKNOWN", "only a known retired event master may be destroyed");
|
|
357
|
+
const { RECORD_LAYOUTS } = await import("./records-DojbuTaG.mjs");
|
|
358
|
+
for (const layout of RECORD_LAYOUTS) {
|
|
359
|
+
const records = this.#database.prepare(`SELECT ${layout.sqlColumn} AS record FROM ${layout.table} WHERE ${layout.sqlColumn} IS NOT NULL`).all();
|
|
360
|
+
for (const record of records) {
|
|
361
|
+
if (!(record.record instanceof Uint8Array)) throw new EventSecretError("RETIRED_MASTER_REFERENCED", "a packed record column is not a BLOB");
|
|
362
|
+
let keyId;
|
|
363
|
+
try {
|
|
364
|
+
keyId = packedRecordKeyId(record.record);
|
|
365
|
+
} catch {
|
|
366
|
+
throw new EventSecretError("RETIRED_MASTER_REFERENCED", "a packed record cannot be proven independent");
|
|
367
|
+
}
|
|
368
|
+
if (keyId === input.keyId) throw new EventSecretError("RETIRED_MASTER_REFERENCED", "a packed record still names the retired event master");
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
await referencesFor(this.#database, input.cipher);
|
|
372
|
+
this.#database.exec("BEGIN IMMEDIATE");
|
|
373
|
+
try {
|
|
374
|
+
const current = this.#database.prepare("SELECT retired_at FROM event_secret_masters WHERE key_id = ?").get(input.keyId);
|
|
375
|
+
if (current === void 0 || current.retired_at === null) throw new EventSecretError("RETIRED_MASTER_REFERENCED", "the retired master changed during its destruction scan");
|
|
376
|
+
this.#database.prepare("DELETE FROM nonce_counters WHERE key_id = ?").run(input.keyId);
|
|
377
|
+
this.#database.prepare("DELETE FROM event_secret_masters WHERE key_id = ? AND retired_at IS NOT NULL").run(input.keyId);
|
|
378
|
+
this.#database.exec("COMMIT");
|
|
379
|
+
} catch (error) {
|
|
380
|
+
this.#database.exec("ROLLBACK");
|
|
381
|
+
throw error;
|
|
382
|
+
}
|
|
383
|
+
if (!await this.#store.delete(row.secret_ref)) throw new EventSecretError("RETIRED_MASTER_REFERENCED", "the retired event master could not be deleted");
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
/**
|
|
387
|
+
* Resolves the current and still-valid overlap generations for one exact immutable owner. The opaque backend
|
|
388
|
+
* reference stays inside this store; only the byte-boundary adapter receives material, and only in memory.
|
|
389
|
+
*/
|
|
390
|
+
async readLiveGenerations(input) {
|
|
391
|
+
assertPurpose(input.owner, input.purpose);
|
|
392
|
+
return this.withLock(async () => {
|
|
393
|
+
this.assertSelectedBackend();
|
|
394
|
+
this.assertLiveOwner(input.owner);
|
|
395
|
+
const now = input.now ?? Date.now();
|
|
396
|
+
const rows = this.#database.prepare(`SELECT owner_kind, owner_id, owner_version, purpose, generation, owner_digest, secret_digest,
|
|
397
|
+
encrypted_ref, lifecycle, expires_at
|
|
398
|
+
FROM event_secret_generations
|
|
399
|
+
WHERE owner_kind = ? AND owner_id = ? AND owner_version = ? AND purpose = ?
|
|
400
|
+
AND (lifecycle = 'current' OR (lifecycle = 'overlap' AND expires_at > ?))
|
|
401
|
+
ORDER BY generation DESC`).all(input.owner.kind, input.owner.id, input.owner.version, input.purpose, now);
|
|
402
|
+
const resolved = [];
|
|
403
|
+
for (const row of rows) {
|
|
404
|
+
const generation = asStoredGeneration(row);
|
|
405
|
+
if (generation.owner.digest !== input.owner.digest) throw new EventSecretError("EVENT_SECRET_OWNER_STALE", "the secret generation owner digest is no longer exact");
|
|
406
|
+
const ref = (await input.cipher.decrypt(generationLocation(generation.owner, generation.purpose, generation.generation), generation.encryptedRef)).toString("utf8");
|
|
407
|
+
if (!/^event-secret:[a-f0-9]{24}$/.test(ref)) throw new EventSecretError("EVENT_SECRET_REFERENCE", "event secret generation has an invalid opaque reference");
|
|
408
|
+
const material = await readBackend(this.#store, ref);
|
|
409
|
+
if (material === null || sha256Hex(material) !== generation.secretDigest) throw new EventSecretError("EVENT_SECRET_REFERENCE", "event secret generation material is unavailable or changed");
|
|
410
|
+
resolved.push({
|
|
411
|
+
owner: generation.owner,
|
|
412
|
+
purpose: generation.purpose,
|
|
413
|
+
generation: generation.generation,
|
|
414
|
+
lifecycle: generation.lifecycle,
|
|
415
|
+
expiresAt: generation.expiresAt,
|
|
416
|
+
secretDigest: generation.secretDigest,
|
|
417
|
+
material
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
return resolved;
|
|
421
|
+
});
|
|
422
|
+
}
|
|
423
|
+
/** Writes a concealed backend value, verifies it, then commits only the exact immutable owner/generation reference. */
|
|
424
|
+
async storeGeneration(options) {
|
|
425
|
+
const { owner, purpose, material, expectedPriorGeneration, overlapExpiresAt, retirePrevious = false, requireEmpty = false } = options;
|
|
426
|
+
assertPurpose(owner, purpose);
|
|
427
|
+
if (typeof material !== "string" || material.length === 0) throw new EventSecretError("EVENT_SECRET_REFERENCE", "event secret generation material is non-empty");
|
|
428
|
+
if (!Number.isSafeInteger(owner.version) || owner.version < 1 || !/^[a-f0-9]{64}$/.test(owner.digest) || expectedPriorGeneration !== null && (!Number.isSafeInteger(expectedPriorGeneration) || expectedPriorGeneration < 1)) throw new EventSecretError("EVENT_SECRET_REFERENCE", "event secret generation has an exact immutable owner");
|
|
429
|
+
return this.withLock(async () => {
|
|
430
|
+
this.assertSelectedBackend();
|
|
431
|
+
if (requireEmpty) {
|
|
432
|
+
if (this.#database.prepare(`SELECT 1 AS present FROM event_secret_generations
|
|
433
|
+
WHERE owner_kind = ? AND owner_id = ? AND owner_version = ? AND purpose = ? LIMIT 1`).get(owner.kind, owner.id, owner.version, purpose) !== void 0) throw new EventSecretError("EVENT_SECRET_OWNER_STALE", "the exact immutable secret slot is not empty");
|
|
434
|
+
}
|
|
435
|
+
if ((this.currentGeneration(owner, purpose)?.generation ?? null) !== expectedPriorGeneration) throw new EventSecretError("EVENT_SECRET_OWNER_STALE", "the expected prior secret generation is no longer current");
|
|
436
|
+
const generation = (this.#database.prepare(`SELECT MAX(generation) AS maximum FROM event_secret_generations
|
|
437
|
+
WHERE owner_kind = ? AND owner_id = ? AND owner_version = ? AND purpose = ?`).get(owner.kind, owner.id, owner.version, purpose)?.maximum ?? 0) + 1;
|
|
438
|
+
const ref = `event-secret:${randomBytes(12).toString("hex")}`;
|
|
439
|
+
const materialDigest = sha256Hex(material);
|
|
440
|
+
await this.#store.set(ref, material);
|
|
441
|
+
try {
|
|
442
|
+
if (await readBackend(this.#store, ref) !== material) throw new EventSecretError("EVENT_SECRET_REFERENCE", "event secret generation did not read back from the selected backend");
|
|
443
|
+
const encryptedRef = await options.cipher.encrypt(generationLocation(owner, purpose, generation), Buffer.from(ref, "utf8"));
|
|
444
|
+
this.#database.exec("BEGIN IMMEDIATE");
|
|
445
|
+
try {
|
|
446
|
+
this.assertSelectedBackend();
|
|
447
|
+
this.assertLiveOwner(owner);
|
|
448
|
+
if (requireEmpty) {
|
|
449
|
+
if (this.#database.prepare(`SELECT 1 AS present FROM event_secret_generations
|
|
450
|
+
WHERE owner_kind = ? AND owner_id = ? AND owner_version = ? AND purpose = ? LIMIT 1`).get(owner.kind, owner.id, owner.version, purpose) !== void 0) throw new EventSecretError("EVENT_SECRET_OWNER_STALE", "the exact immutable secret slot changed while writing");
|
|
451
|
+
}
|
|
452
|
+
const current = this.currentGeneration(owner, purpose);
|
|
453
|
+
if ((current?.generation ?? null) !== expectedPriorGeneration || current !== null && current.owner.digest !== owner.digest) throw new EventSecretError("EVENT_SECRET_OWNER_STALE", "the expected prior secret generation changed while writing");
|
|
454
|
+
const now = Date.now();
|
|
455
|
+
if (current !== null) {
|
|
456
|
+
if (retirePrevious) this.#database.prepare(`UPDATE event_secret_generations SET lifecycle = 'retired', expires_at = ?
|
|
457
|
+
WHERE owner_kind = ? AND owner_id = ? AND owner_version = ? AND purpose = ? AND generation = ?
|
|
458
|
+
AND lifecycle = 'current'`).run(now, owner.kind, owner.id, owner.version, purpose, current.generation);
|
|
459
|
+
else {
|
|
460
|
+
const overlap = overlapExpiresAt ?? now + 3e5;
|
|
461
|
+
if (!Number.isSafeInteger(overlap) || overlap <= now || overlap > now + 3e5) throw new EventSecretError("EVENT_SECRET_REFERENCE", "a rotating event secret generation has an overlap expiry within five minutes");
|
|
462
|
+
if ((this.#database.prepare(`SELECT COUNT(*) AS count FROM event_secret_generations
|
|
463
|
+
WHERE owner_kind = ? AND owner_id = ? AND owner_version = ? AND purpose = ? AND lifecycle = 'overlap'`).get(owner.kind, owner.id, owner.version, purpose)?.count ?? 0) !== 0) throw new EventSecretError("EVENT_SECRET_REFERENCE", "an event secret slot already has its bounded overlap generation");
|
|
464
|
+
this.#database.prepare(`UPDATE event_secret_generations SET lifecycle = 'overlap', expires_at = ?
|
|
465
|
+
WHERE owner_kind = ? AND owner_id = ? AND owner_version = ? AND purpose = ? AND generation = ?
|
|
466
|
+
AND lifecycle = 'current'`).run(overlap, owner.kind, owner.id, owner.version, purpose, current.generation);
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
this.#database.prepare(`INSERT INTO event_secret_generations
|
|
470
|
+
(owner_kind, owner_id, owner_version, purpose, generation, owner_digest, secret_digest, encrypted_ref, lifecycle, expires_at, created_at)
|
|
471
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'current', NULL, ?)`).run(owner.kind, owner.id, owner.version, purpose, generation, owner.digest, materialDigest, encryptedRef, now);
|
|
472
|
+
this.#database.exec("COMMIT");
|
|
473
|
+
} catch (error) {
|
|
474
|
+
this.#database.exec("ROLLBACK");
|
|
475
|
+
throw error;
|
|
476
|
+
}
|
|
477
|
+
} catch (error) {
|
|
478
|
+
await this.#store.delete(ref).catch(() => false);
|
|
479
|
+
throw error;
|
|
480
|
+
}
|
|
481
|
+
return {
|
|
482
|
+
owner,
|
|
483
|
+
purpose,
|
|
484
|
+
generation,
|
|
485
|
+
lifecycle: "current",
|
|
486
|
+
expiresAt: null,
|
|
487
|
+
secretDigest: materialDigest
|
|
488
|
+
};
|
|
489
|
+
});
|
|
490
|
+
}
|
|
491
|
+
/**
|
|
492
|
+
* The only factory that can turn hidden URL bytes into a new immutable target
|
|
493
|
+
* version. It writes and verifies physical material before one compensated
|
|
494
|
+
* SQLite commit; an existing version is never an update target.
|
|
495
|
+
*/
|
|
496
|
+
async createSecretUrlTargetVersion(options) {
|
|
497
|
+
if (options.document.url.kind !== "secret") throw new EventSecretError("EVENT_SECRET_REFERENCE", "a new secret URL target needs a secret URL descriptor");
|
|
498
|
+
const canonical = canonicalSecretWebhookUrl(options.completeUrl);
|
|
499
|
+
if (canonical.descriptor.scheme !== options.document.url.scheme || canonical.descriptor.host !== options.document.url.host || canonical.descriptor.port !== options.document.url.port || canonical.descriptor.sha256 !== options.document.url.sha256) throw new EventSecretError("EVENT_SECRET_REFERENCE", "the hidden URL does not match the new immutable descriptor");
|
|
500
|
+
const document = canonicalJson(options.document);
|
|
501
|
+
const owner = {
|
|
502
|
+
kind: "target",
|
|
503
|
+
id: options.document.targetId,
|
|
504
|
+
version: options.document.version,
|
|
505
|
+
digest: sha256Hex(document)
|
|
506
|
+
};
|
|
507
|
+
return this.withLock(async () => {
|
|
508
|
+
this.assertSelectedBackend();
|
|
509
|
+
if (this.#database.prepare("SELECT 1 AS present FROM target_versions WHERE target_id = ? AND version = ?").get(owner.id, owner.version) !== void 0) throw new EventSecretError("EVENT_SECRET_OWNER_STALE", "the requested new target version already exists");
|
|
510
|
+
const ref = `event-secret:${randomBytes(12).toString("hex")}`;
|
|
511
|
+
await this.#store.set(ref, canonical.value);
|
|
512
|
+
try {
|
|
513
|
+
if (await readBackend(this.#store, ref) !== canonical.value) throw new EventSecretError("EVENT_SECRET_REFERENCE", "the new target URL did not read back from the selected backend");
|
|
514
|
+
const encryptedRef = await options.cipher.encrypt(generationLocation(owner, "secret-url", 1), Buffer.from(ref, "utf8"));
|
|
515
|
+
this.#database.exec("BEGIN IMMEDIATE");
|
|
516
|
+
try {
|
|
517
|
+
this.assertSelectedBackend();
|
|
518
|
+
if (this.#database.prepare("SELECT 1 AS present FROM target_versions WHERE target_id = ? AND version = ?").get(owner.id, owner.version) !== void 0) throw new EventSecretError("EVENT_SECRET_OWNER_STALE", "the requested new target version already exists");
|
|
519
|
+
this.#database.prepare("INSERT INTO target_versions (id, target_id, version, document, digest) VALUES (?, ?, ?, ?, ?)").run(`${owner.id}@${owner.version}`, owner.id, owner.version, document, owner.digest);
|
|
520
|
+
this.#database.prepare(`INSERT INTO event_secret_generations
|
|
521
|
+
(owner_kind, owner_id, owner_version, purpose, generation, owner_digest, secret_digest, encrypted_ref, lifecycle, expires_at, created_at)
|
|
522
|
+
VALUES ('target', ?, ?, 'secret-url', 1, ?, ?, ?, 'current', NULL, ?)`).run(owner.id, owner.version, owner.digest, sha256Hex(canonical.value), encryptedRef, Date.now());
|
|
523
|
+
this.#database.exec("COMMIT");
|
|
524
|
+
} catch (error) {
|
|
525
|
+
this.#database.exec("ROLLBACK");
|
|
526
|
+
throw error;
|
|
527
|
+
}
|
|
528
|
+
} catch (error) {
|
|
529
|
+
await this.#store.delete(ref).catch(() => false);
|
|
530
|
+
throw error;
|
|
531
|
+
}
|
|
532
|
+
return {
|
|
533
|
+
owner,
|
|
534
|
+
purpose: "secret-url",
|
|
535
|
+
generation: 1,
|
|
536
|
+
lifecycle: "current",
|
|
537
|
+
expiresAt: null,
|
|
538
|
+
secretDigest: sha256Hex(canonical.value)
|
|
539
|
+
};
|
|
540
|
+
});
|
|
541
|
+
}
|
|
542
|
+
/** Reconciles only proven-retired opaque references after their database rows are no longer reachable. */
|
|
543
|
+
async reconcileRetiredGenerations(cipher, now = Date.now()) {
|
|
544
|
+
return this.withLock(async () => {
|
|
545
|
+
this.assertSelectedBackend();
|
|
546
|
+
this.#database.prepare("UPDATE event_secret_generations SET lifecycle = 'retired' WHERE lifecycle = 'overlap' AND expires_at <= ?").run(now);
|
|
547
|
+
const rows = this.#database.prepare(`SELECT owner_kind, owner_id, owner_version, purpose, generation, owner_digest, secret_digest,
|
|
548
|
+
encrypted_ref, lifecycle, expires_at
|
|
549
|
+
FROM event_secret_generations WHERE lifecycle = 'retired'`).all();
|
|
550
|
+
const stale = await Promise.all(rows.map(async (row) => {
|
|
551
|
+
const generation = asStoredGeneration(row);
|
|
552
|
+
const value = (await cipher.decrypt(generationLocation(generation.owner, generation.purpose, generation.generation), generation.encryptedRef)).toString("utf8");
|
|
553
|
+
if (!/^event-secret:[a-f0-9]{24}$/.test(value)) throw new EventSecretError("EVENT_SECRET_REFERENCE", "event secret generation has an invalid opaque reference");
|
|
554
|
+
return {
|
|
555
|
+
generation,
|
|
556
|
+
ref: value
|
|
557
|
+
};
|
|
558
|
+
}));
|
|
559
|
+
const liveReferences = new Set(await referencesFor(this.#database, cipher));
|
|
560
|
+
this.#database.exec("BEGIN IMMEDIATE");
|
|
561
|
+
try {
|
|
562
|
+
this.assertSelectedBackend();
|
|
563
|
+
for (const entry of stale) this.#database.prepare(`DELETE FROM event_secret_generations
|
|
564
|
+
WHERE owner_kind = ? AND owner_id = ? AND owner_version = ? AND purpose = ? AND generation = ?
|
|
565
|
+
AND lifecycle = 'retired'`).run(entry.generation.owner.kind, entry.generation.owner.id, entry.generation.owner.version, entry.generation.purpose, entry.generation.generation);
|
|
566
|
+
this.#database.exec("COMMIT");
|
|
567
|
+
} catch (error) {
|
|
568
|
+
this.#database.exec("ROLLBACK");
|
|
569
|
+
throw error;
|
|
570
|
+
}
|
|
571
|
+
const leftovers = [];
|
|
572
|
+
for (const entry of stale) {
|
|
573
|
+
if (liveReferences.has(entry.ref)) continue;
|
|
574
|
+
try {
|
|
575
|
+
if (!await this.#store.delete(entry.ref)) leftovers.push(entry.ref);
|
|
576
|
+
} catch {
|
|
577
|
+
leftovers.push(entry.ref);
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
return leftovers;
|
|
581
|
+
});
|
|
582
|
+
}
|
|
583
|
+
async probeInitialBackend() {
|
|
584
|
+
const ref = `event-master-probe:${randomBytes(12).toString("hex")}`;
|
|
585
|
+
const value = randomBytes(32).toString("base64");
|
|
586
|
+
await this.#store.set(ref, value);
|
|
587
|
+
try {
|
|
588
|
+
if (await readBackend(this.#store, ref) !== value) throw new EventSecretError("EVENT_SECRET_SELECTOR", "event keychain probe did not read back its value");
|
|
589
|
+
} finally {
|
|
590
|
+
await this.#store.delete(ref).catch(() => void 0);
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
async createMaster(retire) {
|
|
594
|
+
const keyId = `evt_${randomBytes(12).toString("hex")}`;
|
|
595
|
+
const ref = `event-master:${keyId}`;
|
|
596
|
+
const key = randomBytes(32);
|
|
597
|
+
const encoded = key.toString("base64");
|
|
598
|
+
await this.#store.set(ref, encoded);
|
|
599
|
+
try {
|
|
600
|
+
if (await readBackend(this.#store, ref) !== encoded) throw new EventSecretError("EVENT_SECRET_SELECTOR", "event master did not read back from its selected backend");
|
|
601
|
+
this.#database.exec("BEGIN IMMEDIATE");
|
|
602
|
+
try {
|
|
603
|
+
const now = Date.now();
|
|
604
|
+
this.#database.prepare("INSERT INTO event_secret_masters (key_id, secret_ref, created_at) VALUES (?, ?, ?)").run(keyId, ref, now);
|
|
605
|
+
if (retire !== void 0) this.#database.prepare("UPDATE event_secret_masters SET retired_at = ? WHERE key_id = ?").run(now, retire);
|
|
606
|
+
if (this.#selectorWasAbsent) setSelector(this.#database, this.#kind);
|
|
607
|
+
this.#database.exec("COMMIT");
|
|
608
|
+
} catch (error) {
|
|
609
|
+
this.#database.exec("ROLLBACK");
|
|
610
|
+
throw error;
|
|
611
|
+
}
|
|
612
|
+
} catch (error) {
|
|
613
|
+
await this.#store.delete(ref).catch(() => void 0);
|
|
614
|
+
throw error;
|
|
615
|
+
}
|
|
616
|
+
return {
|
|
617
|
+
keyId,
|
|
618
|
+
key
|
|
619
|
+
};
|
|
620
|
+
}
|
|
621
|
+
currentGeneration(owner, purpose) {
|
|
622
|
+
const row = this.#database.prepare(`SELECT owner_kind, owner_id, owner_version, purpose, generation, owner_digest, secret_digest,
|
|
623
|
+
encrypted_ref, lifecycle, expires_at
|
|
624
|
+
FROM event_secret_generations
|
|
625
|
+
WHERE owner_kind = ? AND owner_id = ? AND owner_version = ? AND purpose = ? AND lifecycle = 'current'`).get(owner.kind, owner.id, owner.version, purpose);
|
|
626
|
+
if (row === void 0) return null;
|
|
627
|
+
const generation = asStoredGeneration(row);
|
|
628
|
+
return {
|
|
629
|
+
owner: generation.owner,
|
|
630
|
+
purpose: generation.purpose,
|
|
631
|
+
generation: generation.generation,
|
|
632
|
+
lifecycle: generation.lifecycle,
|
|
633
|
+
expiresAt: generation.expiresAt,
|
|
634
|
+
secretDigest: generation.secretDigest
|
|
635
|
+
};
|
|
636
|
+
}
|
|
637
|
+
assertLiveOwner(owner) {
|
|
638
|
+
const table = owner.kind === "target" ? "target_versions" : owner.kind === "subscriber" ? "subscriber_versions" : "judge_versions";
|
|
639
|
+
const idColumn = owner.kind === "target" ? "target_id" : owner.kind === "subscriber" ? "subscriber_id" : "judge_id";
|
|
640
|
+
const row = this.#database.prepare(`SELECT digest, revoked_at FROM ${table} WHERE ${idColumn} = ? AND version = ?`).get(owner.id, owner.version);
|
|
641
|
+
if (row === void 0 || row.revoked_at !== null || row.digest !== owner.digest) throw new EventSecretError("EVENT_SECRET_OWNER_STALE", "the exact secret owner version is no longer live");
|
|
642
|
+
}
|
|
643
|
+
/** A queued stale store may never write/delete after migration selected the other independent backend. */
|
|
644
|
+
assertSelectedBackend() {
|
|
645
|
+
const selected = selectedKind(this.#database);
|
|
646
|
+
if (selected !== null && selected !== this.#kind) throw new EventSecretError("EVENT_SECRET_SELECTOR", "event secret backend changed; reopen the selected backend");
|
|
647
|
+
}
|
|
648
|
+
async withLock(work) {
|
|
649
|
+
return withEventSecretsLock(this.#paths, work);
|
|
650
|
+
}
|
|
651
|
+
};
|
|
652
|
+
/**
|
|
653
|
+
* Reads what the backend holds now. Core's keychain store answers `get` from a cache its own `set` fills, so a read-back
|
|
654
|
+
* or a migration check through it would otherwise see what this process last wrote rather than the keychain item, which
|
|
655
|
+
* another process, or a keychain reset, may have changed since.
|
|
656
|
+
*/
|
|
657
|
+
async function readBackend(store, ref) {
|
|
658
|
+
store.invalidate(ref);
|
|
659
|
+
return store.get(ref);
|
|
660
|
+
}
|
|
661
|
+
/** Opens only the backend selected by SQLite; an unselected installation tries keychain and never falls back. */
|
|
662
|
+
async function openEventSecretStore(options) {
|
|
663
|
+
const recorded = selectedKind(options.database);
|
|
664
|
+
const kind = recorded ?? "keychain";
|
|
665
|
+
return new EventSecretStore(options.database, options.paths, kind, await storeFor(options, kind), recorded === null, (requested) => storeFor(options, requested));
|
|
666
|
+
}
|
|
667
|
+
/**
|
|
668
|
+
* Moves every reference the event database names, under the events-only lock. No core selector, lock or reference
|
|
669
|
+
* discovery participates. Before the selector changes, every copy is verified and any attempted target value is
|
|
670
|
+
* removed again on failure; after it changes, failed source cleanup is returned as a retryable leftover.
|
|
671
|
+
*/
|
|
672
|
+
async function migrateEventSecrets(options) {
|
|
673
|
+
if (options.from === options.to) return {
|
|
674
|
+
moved: 0,
|
|
675
|
+
leftovers: []
|
|
676
|
+
};
|
|
677
|
+
if (options.source === options.target || options.source.kind !== options.from || options.target.kind !== options.to) throw new EventSecretError("EVENT_SECRET_SELECTOR", "event secret migration stores do not match its backends");
|
|
678
|
+
return withEventSecretsLock(options.paths, async () => {
|
|
679
|
+
const selected = selectedKind(options.database);
|
|
680
|
+
if (selected === options.to) {
|
|
681
|
+
const references = await referencesForSelectedTarget(options.database, options.target);
|
|
682
|
+
const snapshot = referenceSnapshot(options.database);
|
|
683
|
+
const present = [];
|
|
684
|
+
for (const ref of references) {
|
|
685
|
+
const selectedValue = await readBackend(options.target, ref);
|
|
686
|
+
if (selectedValue === null) throw new EventSecretError("EVENT_MASTER_LOST", "a selected event secret is unavailable; old migration leftovers are retained");
|
|
687
|
+
const leftover = await readBackend(options.source, ref);
|
|
688
|
+
if (leftover !== null && leftover !== selectedValue) throw new EventSecretError("EVENT_SECRET_SELECTOR", "a selected event secret no longer matches its migration source; old migration leftovers are retained");
|
|
689
|
+
if (leftover !== null) present.push(ref);
|
|
690
|
+
}
|
|
691
|
+
options.database.exec("BEGIN IMMEDIATE");
|
|
692
|
+
try {
|
|
693
|
+
if (referenceSnapshot(options.database) !== snapshot || selectedKind(options.database) !== options.to) throw new EventSecretError("EVENT_SECRET_SELECTOR", "event secret references changed before leftover cleanup");
|
|
694
|
+
options.database.exec("COMMIT");
|
|
695
|
+
} catch (error) {
|
|
696
|
+
options.database.exec("ROLLBACK");
|
|
697
|
+
throw error;
|
|
698
|
+
}
|
|
699
|
+
const leftovers = [];
|
|
700
|
+
for (const ref of present) try {
|
|
701
|
+
if (!await options.source.delete(ref)) leftovers.push(ref);
|
|
702
|
+
} catch {
|
|
703
|
+
leftovers.push(ref);
|
|
704
|
+
}
|
|
705
|
+
return {
|
|
706
|
+
moved: 0,
|
|
707
|
+
leftovers
|
|
708
|
+
};
|
|
709
|
+
}
|
|
710
|
+
if (selected !== options.from) throw new EventSecretError("EVENT_SECRET_SELECTOR", "event secret migration source is not the selected backend");
|
|
711
|
+
const references = await referencesFor(options.database, options.referenceCipher);
|
|
712
|
+
const snapshot = referenceSnapshot(options.database);
|
|
713
|
+
const copied = [];
|
|
714
|
+
try {
|
|
715
|
+
for (const ref of references) {
|
|
716
|
+
const value = await readBackend(options.source, ref);
|
|
717
|
+
if (value === null) throw new EventSecretError("EVENT_MASTER_LOST", "a referenced event secret is unavailable");
|
|
718
|
+
copied.push({
|
|
719
|
+
ref,
|
|
720
|
+
value
|
|
721
|
+
});
|
|
722
|
+
await options.target.set(ref, value);
|
|
723
|
+
if (await readBackend(options.target, ref) !== value) throw new EventSecretError("EVENT_SECRET_SELECTOR", `event secret ${ref} did not verify in the destination backend`);
|
|
724
|
+
}
|
|
725
|
+
options.database.exec("BEGIN IMMEDIATE");
|
|
726
|
+
try {
|
|
727
|
+
if (referenceSnapshot(options.database) !== snapshot) throw new EventSecretError("EVENT_SECRET_SELECTOR", "event secret references changed during migration");
|
|
728
|
+
if (selectedKind(options.database) !== options.from) throw new EventSecretError("EVENT_SECRET_SELECTOR", "event secret selector changed during migration");
|
|
729
|
+
setSelector(options.database, options.to);
|
|
730
|
+
options.database.exec("COMMIT");
|
|
731
|
+
} catch (error) {
|
|
732
|
+
options.database.exec("ROLLBACK");
|
|
733
|
+
throw error;
|
|
734
|
+
}
|
|
735
|
+
} catch (error) {
|
|
736
|
+
await Promise.all(copied.map(({ ref }) => options.target.delete(ref).catch(() => false)));
|
|
737
|
+
throw error;
|
|
738
|
+
}
|
|
739
|
+
if (!await copiedTargetValuesMatch(options.target, copied)) return {
|
|
740
|
+
moved: references.length,
|
|
741
|
+
leftovers: references,
|
|
742
|
+
code: "SELECTED_COPY_CHANGED_AFTER_MIGRATION"
|
|
743
|
+
};
|
|
744
|
+
const leftovers = [];
|
|
745
|
+
for (const ref of references) try {
|
|
746
|
+
if (!await options.source.delete(ref)) leftovers.push(ref);
|
|
747
|
+
} catch {
|
|
748
|
+
leftovers.push(ref);
|
|
749
|
+
}
|
|
750
|
+
return {
|
|
751
|
+
moved: references.length,
|
|
752
|
+
leftovers
|
|
753
|
+
};
|
|
754
|
+
});
|
|
755
|
+
}
|
|
756
|
+
/** A post-selector cleanup may delete a source only if its selected counterpart still has the verified exact bytes. */
|
|
757
|
+
async function copiedTargetValuesMatch(target, copied) {
|
|
758
|
+
try {
|
|
759
|
+
for (const { ref, value } of copied) if (await readBackend(target, ref) !== value) return false;
|
|
760
|
+
return true;
|
|
761
|
+
} catch {
|
|
762
|
+
return false;
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
/** Resolves opaque generation references from the selected migration target without retaining an old-store handle. */
|
|
766
|
+
async function referencesForSelectedTarget(database, target) {
|
|
767
|
+
const masters = database.prepare("SELECT key_id, secret_ref FROM event_secret_masters ORDER BY key_id").all();
|
|
768
|
+
const masterByKey = /* @__PURE__ */ new Map();
|
|
769
|
+
for (const master of masters) {
|
|
770
|
+
const encoded = await readBackend(target, master.secret_ref);
|
|
771
|
+
if (encoded === null) throw new EventSecretError("EVENT_MASTER_LOST", "a selected event master is unavailable during leftover cleanup");
|
|
772
|
+
const key = Buffer.from(encoded, "base64");
|
|
773
|
+
if (key.length !== 32) throw new EventSecretError("EVENT_MASTER_LOST", "a selected event master is malformed during leftover cleanup");
|
|
774
|
+
masterByKey.set(master.key_id, key);
|
|
775
|
+
}
|
|
776
|
+
const rows = database.prepare(`SELECT owner_kind, owner_id, owner_version, purpose, generation, owner_digest, secret_digest,
|
|
777
|
+
encrypted_ref, lifecycle, expires_at
|
|
778
|
+
FROM event_secret_generations
|
|
779
|
+
ORDER BY owner_kind, owner_id, owner_version, purpose, generation`).all();
|
|
780
|
+
const references = masters.map((master) => master.secret_ref);
|
|
781
|
+
for (const row of rows) {
|
|
782
|
+
const generation = asStoredGeneration(row);
|
|
783
|
+
const keyId = packedRecordKeyId(generation.encryptedRef);
|
|
784
|
+
const master = masterByKey.get(keyId);
|
|
785
|
+
if (master === void 0) throw new EventSecretError("EVENT_MASTER_LOST", "an event secret reference names an unavailable selected master");
|
|
786
|
+
let reference;
|
|
787
|
+
try {
|
|
788
|
+
reference = decryptPackedRecord({
|
|
789
|
+
record: generation.encryptedRef,
|
|
790
|
+
keyForId: (selectedKeyId) => selectedKeyId === keyId ? deriveTableKey(master, "event_secret_generations") : null,
|
|
791
|
+
aad: encodeAad("event_secret_generations", "encryptedReference", generationLocation(generation.owner, generation.purpose, generation.generation).key)
|
|
792
|
+
});
|
|
793
|
+
} catch {
|
|
794
|
+
throw new EventSecretError("EVENT_SECRET_REFERENCE", "an event secret reference is unreadable during leftover cleanup");
|
|
795
|
+
}
|
|
796
|
+
const value = reference.toString("utf8");
|
|
797
|
+
if (!/^event-secret:[a-f0-9]{24}$/.test(value)) throw new EventSecretError("EVENT_SECRET_REFERENCE", "an event secret reference is not an opaque event reference");
|
|
798
|
+
references.push(value);
|
|
799
|
+
}
|
|
800
|
+
return [...new Set(references)].sort();
|
|
801
|
+
}
|
|
802
|
+
//#endregion
|
|
803
|
+
//#region src/runtime/writer-quiescer.ts
|
|
804
|
+
const leaseContext = new AsyncLocalStorage();
|
|
805
|
+
/**
|
|
806
|
+
* Stops new encrypted-state writers while a rare owner-wide rekey drains the
|
|
807
|
+
* writers already between their cipher work and durable commit. It is purely
|
|
808
|
+
* in-memory: a failed operation never leaves persistent pause state behind.
|
|
809
|
+
*/
|
|
810
|
+
var WriterQuiescer = class {
|
|
811
|
+
#shared = 0;
|
|
812
|
+
#exclusive = false;
|
|
813
|
+
#exclusiveWaiting = 0;
|
|
814
|
+
#waiters = [];
|
|
815
|
+
async withShared(work) {
|
|
816
|
+
if (leaseContext.getStore()?.quiescer === this) return work();
|
|
817
|
+
await this.acquireShared();
|
|
818
|
+
try {
|
|
819
|
+
return await leaseContext.run({
|
|
820
|
+
quiescer: this,
|
|
821
|
+
kind: "shared"
|
|
822
|
+
}, work);
|
|
823
|
+
} finally {
|
|
824
|
+
this.#shared -= 1;
|
|
825
|
+
this.wake();
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
async withExclusive(work) {
|
|
829
|
+
const held = leaseContext.getStore();
|
|
830
|
+
if (held?.quiescer === this && held.kind === "exclusive") return work();
|
|
831
|
+
if (held?.quiescer === this) throw new Error("an event writer cannot upgrade its shared lease to an exclusive lease");
|
|
832
|
+
this.#exclusiveWaiting += 1;
|
|
833
|
+
try {
|
|
834
|
+
await this.waitFor(() => !this.#exclusive && this.#shared === 0);
|
|
835
|
+
this.#exclusive = true;
|
|
836
|
+
return await leaseContext.run({
|
|
837
|
+
quiescer: this,
|
|
838
|
+
kind: "exclusive"
|
|
839
|
+
}, work);
|
|
840
|
+
} finally {
|
|
841
|
+
if (this.#exclusive) this.#exclusive = false;
|
|
842
|
+
this.#exclusiveWaiting -= 1;
|
|
843
|
+
this.wake();
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
/** Fails closed when a destructive key operation escaped the owner-wide rekey lease. */
|
|
847
|
+
assertExclusive() {
|
|
848
|
+
const held = leaseContext.getStore();
|
|
849
|
+
if (held?.quiescer !== this || held.kind !== "exclusive") throw new Error("the event writer quiescer exclusive lease is required");
|
|
850
|
+
}
|
|
851
|
+
async acquireShared() {
|
|
852
|
+
await this.waitFor(() => !this.#exclusive && this.#exclusiveWaiting === 0);
|
|
853
|
+
this.#shared += 1;
|
|
854
|
+
}
|
|
855
|
+
async waitFor(predicate) {
|
|
856
|
+
while (!predicate()) await new Promise((resolve) => this.#waiters.push(resolve));
|
|
857
|
+
}
|
|
858
|
+
wake() {
|
|
859
|
+
const waiters = this.#waiters.splice(0);
|
|
860
|
+
for (const wake of waiters) wake();
|
|
861
|
+
}
|
|
862
|
+
};
|
|
863
|
+
/** Required shared lease wrapper for every B3 writer-inventory entrypoint. */
|
|
864
|
+
async function withEventWriterLease(quiescer, work) {
|
|
865
|
+
return quiescer.withShared(work);
|
|
866
|
+
}
|
|
867
|
+
//#endregion
|
|
868
|
+
//#region src/store/records.ts
|
|
869
|
+
const MAX_INVOCATIONS_PER_TABLE_KEY = 4294967295;
|
|
870
|
+
var RecordStorageError = class extends Error {
|
|
871
|
+
code;
|
|
872
|
+
constructor(code, message) {
|
|
873
|
+
super(message);
|
|
874
|
+
this.name = "RecordStorageError";
|
|
875
|
+
this.code = code;
|
|
876
|
+
}
|
|
877
|
+
};
|
|
878
|
+
/** Maps ciphertext/key failures to the reset path; callers must never substitute plaintext for these records. */
|
|
879
|
+
function unreadableRecordReason(error) {
|
|
880
|
+
if (error instanceof EventSecretError) {
|
|
881
|
+
if (error.code === "EVENT_MASTER_LOST") return "master-lost";
|
|
882
|
+
if (error.code === "EVENT_MASTER_UNKNOWN") return "unknown-key";
|
|
883
|
+
}
|
|
884
|
+
if (error instanceof PackedRecordError) {
|
|
885
|
+
if (error.code === "AUTHENTICATION_FAILED") return "authentication-failed";
|
|
886
|
+
if (error.code === "UNKNOWN_KEY") return "unknown-key";
|
|
887
|
+
}
|
|
888
|
+
return null;
|
|
889
|
+
}
|
|
890
|
+
/** Each encrypted column, with its table's primary key in the order the v1 migration declares it — the AAD order. */
|
|
891
|
+
const RECORD_LAYOUTS = [
|
|
892
|
+
{
|
|
893
|
+
table: "source_scan_state",
|
|
894
|
+
column: "encryptedRecord",
|
|
895
|
+
sqlColumn: "encrypted_record",
|
|
896
|
+
keyColumns: ["id"]
|
|
897
|
+
},
|
|
898
|
+
{
|
|
899
|
+
table: "rule_activation_points",
|
|
900
|
+
column: "encryptedPosition",
|
|
901
|
+
sqlColumn: "encrypted_position",
|
|
902
|
+
keyColumns: [
|
|
903
|
+
"activation_id",
|
|
904
|
+
"rule_id",
|
|
905
|
+
"rule_version",
|
|
906
|
+
"account_id",
|
|
907
|
+
"position_scope"
|
|
908
|
+
]
|
|
909
|
+
},
|
|
910
|
+
{
|
|
911
|
+
table: "activation_baselines",
|
|
912
|
+
column: "encryptedPosition",
|
|
913
|
+
sqlColumn: "encrypted_position",
|
|
914
|
+
keyColumns: [
|
|
915
|
+
"intent_id",
|
|
916
|
+
"source",
|
|
917
|
+
"account_id",
|
|
918
|
+
"position_scope"
|
|
919
|
+
]
|
|
920
|
+
},
|
|
921
|
+
{
|
|
922
|
+
table: "ingest_rules",
|
|
923
|
+
column: "encryptedProjection",
|
|
924
|
+
sqlColumn: "encrypted_projection",
|
|
925
|
+
keyColumns: [
|
|
926
|
+
"event_id",
|
|
927
|
+
"rule_id",
|
|
928
|
+
"rule_version"
|
|
929
|
+
]
|
|
930
|
+
},
|
|
931
|
+
{
|
|
932
|
+
table: "decisions",
|
|
933
|
+
column: "encryptedRecord",
|
|
934
|
+
sqlColumn: "encrypted_record",
|
|
935
|
+
keyColumns: ["id"]
|
|
936
|
+
},
|
|
937
|
+
{
|
|
938
|
+
table: "deliveries",
|
|
939
|
+
column: "encryptedRecord",
|
|
940
|
+
sqlColumn: "encrypted_record",
|
|
941
|
+
keyColumns: ["id"]
|
|
942
|
+
},
|
|
943
|
+
{
|
|
944
|
+
table: "dryrun_log",
|
|
945
|
+
column: "encryptedRecord",
|
|
946
|
+
sqlColumn: "encrypted_record",
|
|
947
|
+
keyColumns: ["delivery_id"]
|
|
948
|
+
},
|
|
949
|
+
{
|
|
950
|
+
table: "stream_log",
|
|
951
|
+
column: "encryptedRecord",
|
|
952
|
+
sqlColumn: "encrypted_record",
|
|
953
|
+
keyColumns: ["id"]
|
|
954
|
+
},
|
|
955
|
+
{
|
|
956
|
+
table: "event_secret_generations",
|
|
957
|
+
column: "encryptedReference",
|
|
958
|
+
sqlColumn: "encrypted_ref",
|
|
959
|
+
keyColumns: [
|
|
960
|
+
"owner_kind",
|
|
961
|
+
"owner_id",
|
|
962
|
+
"owner_version",
|
|
963
|
+
"purpose",
|
|
964
|
+
"generation"
|
|
965
|
+
]
|
|
966
|
+
},
|
|
967
|
+
{
|
|
968
|
+
table: "system_reset_outbox",
|
|
969
|
+
column: "encryptedRecord",
|
|
970
|
+
sqlColumn: "encrypted_record",
|
|
971
|
+
keyColumns: ["id"]
|
|
972
|
+
}
|
|
973
|
+
];
|
|
974
|
+
/** Encrypts and decrypts B1 table records while reserving the D8 nonce-invocation budget in SQLite. */
|
|
975
|
+
/** A rotation pass that still meets an older key this many times is refused, never looped. */
|
|
976
|
+
const MAX_ROTATION_PASSES = 32;
|
|
977
|
+
var EventRecordCipher = class {
|
|
978
|
+
#database;
|
|
979
|
+
#secrets;
|
|
980
|
+
#quiescer;
|
|
981
|
+
#tableKeys = /* @__PURE__ */ new Map();
|
|
982
|
+
constructor(database, secrets, quiescer) {
|
|
983
|
+
this.#database = database;
|
|
984
|
+
this.#secrets = secrets;
|
|
985
|
+
this.#quiescer = quiescer;
|
|
986
|
+
}
|
|
987
|
+
/** The B3 secret service must share this exact owner-wide lease, never a look-alike lock. */
|
|
988
|
+
get writerQuiescer() {
|
|
989
|
+
return this.#quiescer;
|
|
990
|
+
}
|
|
991
|
+
async encrypt(location, plaintext) {
|
|
992
|
+
return withEventWriterLease(this.#quiescer, () => this.encryptHeld(location, plaintext));
|
|
993
|
+
}
|
|
994
|
+
async encryptHeld(location, plaintext) {
|
|
995
|
+
const master = await this.#secrets.currentMaster();
|
|
996
|
+
this.reserveInvocation(master.keyId, location.table);
|
|
997
|
+
return encryptPackedRecord({
|
|
998
|
+
keyId: master.keyId,
|
|
999
|
+
key: this.tableKey(master.keyId, master.key, location.table),
|
|
1000
|
+
aad: encodeAad(location.table, location.column, location.key),
|
|
1001
|
+
plaintext
|
|
1002
|
+
});
|
|
1003
|
+
}
|
|
1004
|
+
async decrypt(location, stored) {
|
|
1005
|
+
return withEventWriterLease(this.#quiescer, () => this.decryptHeld(location, stored));
|
|
1006
|
+
}
|
|
1007
|
+
async decryptHeld(location, stored) {
|
|
1008
|
+
if (!(stored instanceof Uint8Array)) throw new RecordStorageError("RECORD_STORAGE_CLASS", "encrypted event records must be SQLite BLOB values");
|
|
1009
|
+
const record = Buffer.from(stored);
|
|
1010
|
+
try {
|
|
1011
|
+
const keyId = packedRecordKeyId(record);
|
|
1012
|
+
const master = await this.#secrets.master(keyId);
|
|
1013
|
+
return decryptPackedRecord({
|
|
1014
|
+
record,
|
|
1015
|
+
keyForId: (selected) => selected === keyId ? this.tableKey(keyId, master, location.table) : null,
|
|
1016
|
+
aad: encodeAad(location.table, location.column, location.key)
|
|
1017
|
+
});
|
|
1018
|
+
} catch (error) {
|
|
1019
|
+
const reason = unreadableRecordReason(error);
|
|
1020
|
+
if (reason === null) throw error;
|
|
1021
|
+
throw new RecordStorageError("RECORD_UNREADABLE_RESET_REQUIRED", `encrypted event record is unreadable (${reason}); installation reset is required`);
|
|
1022
|
+
}
|
|
1023
|
+
}
|
|
1024
|
+
/**
|
|
1025
|
+
* Re-encrypts every B1 packed column in small SQLite write batches under a newly generated master.
|
|
1026
|
+
*
|
|
1027
|
+
* Precondition, owed by its caller: no writer may hold ciphertext between `encrypt()` and its own write across the
|
|
1028
|
+
* rotation. A record a worker encrypted under the old master before `rotateMaster()` and wrote after the last pass
|
|
1029
|
+
* would stay under the retired key (still readable: retired masters are kept). B1 exposes no caller; the B3 operation
|
|
1030
|
+
* that does must stop the owner's writers and await their in-flight work first, and destroying a retired master must
|
|
1031
|
+
* refuse while any record still names it (plan, "PR #59 review (Blocks)" row).
|
|
1032
|
+
*/
|
|
1033
|
+
async rotateAndReencrypt(options = {}) {
|
|
1034
|
+
return this.#quiescer.withExclusive(() => this.rotateAndReencryptHeld(options));
|
|
1035
|
+
}
|
|
1036
|
+
async rotateAndReencryptHeld(options = {}) {
|
|
1037
|
+
const batchSize = options.batchSize ?? 100;
|
|
1038
|
+
if (!Number.isSafeInteger(batchSize) || batchSize < 1) throw new RecordStorageError("NONCE_LIMIT", "rotation batch size must be positive");
|
|
1039
|
+
const master = await this.#secrets.rotateMaster();
|
|
1040
|
+
let rewritten = 0;
|
|
1041
|
+
for (const layout of RECORD_LAYOUTS) rewritten += await this.reencryptLayout(layout, master, batchSize);
|
|
1042
|
+
return {
|
|
1043
|
+
keyId: master.keyId,
|
|
1044
|
+
rewritten
|
|
1045
|
+
};
|
|
1046
|
+
}
|
|
1047
|
+
reserveInvocation(keyId, table) {
|
|
1048
|
+
this.#database.exec("BEGIN IMMEDIATE");
|
|
1049
|
+
try {
|
|
1050
|
+
if ((this.#database.prepare("SELECT invocations FROM nonce_counters WHERE key_id = ? AND table_name = ?").get(keyId, table)?.invocations ?? 0) >= MAX_INVOCATIONS_PER_TABLE_KEY) throw new RecordStorageError("NONCE_LIMIT", "the event table key reached its nonce limit; rotate the master key before encrypting again");
|
|
1051
|
+
this.#database.prepare(`INSERT INTO nonce_counters (key_id, table_name, invocations) VALUES (?, ?, 1)
|
|
1052
|
+
ON CONFLICT(key_id, table_name) DO UPDATE SET invocations = nonce_counters.invocations + 1`).run(keyId, table);
|
|
1053
|
+
this.#database.exec("COMMIT");
|
|
1054
|
+
} catch (error) {
|
|
1055
|
+
this.#database.exec("ROLLBACK");
|
|
1056
|
+
throw error;
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
tableKey(keyId, master, table) {
|
|
1060
|
+
const cacheKey = `${keyId}\u0000${table}`;
|
|
1061
|
+
const cached = this.#tableKeys.get(cacheKey);
|
|
1062
|
+
if (cached !== void 0) return cached;
|
|
1063
|
+
const derived = deriveTableKey(master, table);
|
|
1064
|
+
this.#tableKeys.set(cacheKey, derived);
|
|
1065
|
+
return derived;
|
|
1066
|
+
}
|
|
1067
|
+
async reencryptLayout(layout, master, batchSize) {
|
|
1068
|
+
let rewritten = 0;
|
|
1069
|
+
const selectedColumns = [...layout.keyColumns, layout.sqlColumn].join(", ");
|
|
1070
|
+
const orderedColumns = layout.keyColumns.join(", ");
|
|
1071
|
+
const first = this.#database.prepare(`SELECT ${selectedColumns} FROM ${layout.table} WHERE ${layout.sqlColumn} IS NOT NULL ORDER BY ${orderedColumns} LIMIT ?`);
|
|
1072
|
+
const next = this.#database.prepare(`SELECT ${selectedColumns} FROM ${layout.table}
|
|
1073
|
+
WHERE ${layout.sqlColumn} IS NOT NULL AND (${orderedColumns}) > (${layout.keyColumns.map(() => "?").join(", ")})
|
|
1074
|
+
ORDER BY ${orderedColumns} LIMIT ?`);
|
|
1075
|
+
const where = layout.keyColumns.map((column) => `${column} = ?`).join(" AND ");
|
|
1076
|
+
const update = this.#database.prepare(`UPDATE ${layout.table} SET ${layout.sqlColumn} = ? WHERE ${where} AND ${layout.sqlColumn} = ?`);
|
|
1077
|
+
for (let pass = 0;; pass += 1) {
|
|
1078
|
+
if (pass === MAX_ROTATION_PASSES) throw new RecordStorageError("ROTATION_INCOMPLETE", `${layout.table}.${layout.sqlColumn} still held a record under an older key after ${MAX_ROTATION_PASSES} passes`);
|
|
1079
|
+
let sawOlder = false;
|
|
1080
|
+
let last;
|
|
1081
|
+
for (;;) {
|
|
1082
|
+
const rows = last === void 0 ? first.all(batchSize) : next.all(...last, batchSize);
|
|
1083
|
+
if (rows.length === 0) break;
|
|
1084
|
+
last = this.keyValues(layout, rows[rows.length - 1]);
|
|
1085
|
+
const updates = [];
|
|
1086
|
+
for (const row of rows) {
|
|
1087
|
+
const stored = row[layout.sqlColumn];
|
|
1088
|
+
if (!(stored instanceof Uint8Array)) throw new RecordStorageError("RECORD_STORAGE_CLASS", `${layout.table}.${layout.sqlColumn} is not a BLOB`);
|
|
1089
|
+
if (packedRecordKeyId(stored) === master.keyId) continue;
|
|
1090
|
+
sawOlder = true;
|
|
1091
|
+
const location = this.locationFor(layout, row);
|
|
1092
|
+
const plaintext = await this.decrypt(location, stored);
|
|
1093
|
+
updates.push({
|
|
1094
|
+
record: await this.encrypt(location, plaintext),
|
|
1095
|
+
read: stored,
|
|
1096
|
+
keys: this.keyValues(layout, row)
|
|
1097
|
+
});
|
|
1098
|
+
}
|
|
1099
|
+
if (updates.length === 0) continue;
|
|
1100
|
+
this.#database.exec("BEGIN IMMEDIATE");
|
|
1101
|
+
try {
|
|
1102
|
+
for (const item of updates) if (Number(update.run(item.record, ...item.keys, item.read).changes) === 1) rewritten += 1;
|
|
1103
|
+
this.#database.exec("COMMIT");
|
|
1104
|
+
} catch (error) {
|
|
1105
|
+
this.#database.exec("ROLLBACK");
|
|
1106
|
+
throw error;
|
|
1107
|
+
}
|
|
1108
|
+
}
|
|
1109
|
+
if (!sawOlder) return rewritten;
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
1112
|
+
locationFor(layout, row) {
|
|
1113
|
+
return {
|
|
1114
|
+
table: layout.table,
|
|
1115
|
+
column: layout.column,
|
|
1116
|
+
key: layout.keyColumns.map((column) => this.component(row[column], `${layout.table}.${column}`))
|
|
1117
|
+
};
|
|
1118
|
+
}
|
|
1119
|
+
keyValues(layout, row) {
|
|
1120
|
+
return layout.keyColumns.map((column) => this.sqlValue(row[column], `${layout.table}.${column}`));
|
|
1121
|
+
}
|
|
1122
|
+
component(value, name) {
|
|
1123
|
+
if (typeof value === "string") return {
|
|
1124
|
+
type: "text",
|
|
1125
|
+
value
|
|
1126
|
+
};
|
|
1127
|
+
if (typeof value === "number" || typeof value === "bigint") return {
|
|
1128
|
+
type: "integer",
|
|
1129
|
+
value
|
|
1130
|
+
};
|
|
1131
|
+
if (value instanceof Uint8Array) return {
|
|
1132
|
+
type: "blob",
|
|
1133
|
+
value
|
|
1134
|
+
};
|
|
1135
|
+
throw new RecordStorageError("RECORD_STORAGE_CLASS", `${name} is not a valid primary-key storage class`);
|
|
1136
|
+
}
|
|
1137
|
+
sqlValue(value, name) {
|
|
1138
|
+
if (typeof value === "string" || typeof value === "number" || typeof value === "bigint" || value instanceof Uint8Array || value === null) return value;
|
|
1139
|
+
throw new RecordStorageError("RECORD_STORAGE_CLASS", `${name} is not a SQLite value`);
|
|
1140
|
+
}
|
|
1141
|
+
};
|
|
1142
|
+
//#endregion
|
|
1143
|
+
export { WriterQuiescer as a, openEventSecretStore as c, unreadableRecordReason as i, RECORD_LAYOUTS as n, withEventWriterLease as o, RecordStorageError as r, migrateEventSecrets as s, EventRecordCipher as t };
|