@totemsdk/wots-lease 1.0.0 → 1.0.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/dist/journal.js CHANGED
@@ -1,13 +1,37 @@
1
1
  "use strict";
2
2
  /**
3
- * LeaseJournal — append-only audit log for WOTS key-use events.
4
- * Preserves the full history: reserved → committed | burned | reserved-expired.
3
+ * LeaseJournal — physically append-only audit log for WOTS key-use events.
4
+ *
5
+ * Layout v1 stores one immutable record per sequence. The legacy whole-array
6
+ * key is retained as migration input/forensic history but is never rewritten.
7
+ * The SHA-256 chain format is intentionally unchanged (RFC-007 OQ3).
5
8
  */
6
9
  Object.defineProperty(exports, "__esModule", { value: true });
7
10
  exports.LeaseJournal = void 0;
8
11
  const crypto_1 = require("crypto");
9
12
  const core_1 = require("@totemsdk/core");
10
- const STORAGE_KEY = 'totem_wots_journal';
13
+ const errors_1 = require("@totemsdk/storage/errors");
14
+ const LEGACY_STORAGE_KEY = 'totem_wots_journal';
15
+ const ENTRY_PREFIX = 'totem_wots_journal:v1:entry:';
16
+ const RECORD_VERSION = 1;
17
+ const SEQUENCE_WIDTH = 16;
18
+ const MAX_INDEX_COMPONENT = 63;
19
+ const HASH_PATTERN = /^[0-9a-f]{64}$/;
20
+ const KEY_PATTERN = /^(\d{16}):([0-9a-f]{64})$/;
21
+ const STATUSES = new Set([
22
+ 'reserved',
23
+ 'committed',
24
+ 'burned',
25
+ 'reserved-expired',
26
+ ]);
27
+ const storageTails = new WeakMap();
28
+ function withStorageLock(storage, fn) {
29
+ const prior = storageTails.get(storage) ?? Promise.resolve();
30
+ const run = prior.then(fn, fn);
31
+ storageTails.set(storage, run.then(() => undefined, () => undefined));
32
+ return run;
33
+ }
34
+ /** Existing commitment format; property names/order and SHA-256 stay unchanged. */
11
35
  function hashEntry(entry) {
12
36
  const encoder = new TextEncoder();
13
37
  const data = {
@@ -23,65 +47,322 @@ function hashEntry(entry) {
23
47
  const bytes = encoder.encode(JSON.stringify(data));
24
48
  return (0, crypto_1.createHash)('sha256').update(bytes).digest('hex');
25
49
  }
50
+ function cloneEntry(entry) {
51
+ return {
52
+ treeId: entry.treeId,
53
+ branchId: entry.branchId,
54
+ wotsIndex: entry.wotsIndex,
55
+ indices: { ...entry.indices },
56
+ status: entry.status,
57
+ ...(entry.reservationId !== undefined ? { reservationId: entry.reservationId } : {}),
58
+ ...(entry.payloadHash !== undefined ? { payloadHash: entry.payloadHash } : {}),
59
+ ...(entry.txId !== undefined ? { txId: entry.txId } : {}),
60
+ timestamp: entry.timestamp,
61
+ deviceId: entry.deviceId,
62
+ ...(entry.previousHash !== undefined ? { previousHash: entry.previousHash } : {}),
63
+ ...(entry.hash !== undefined ? { hash: entry.hash } : {}),
64
+ };
65
+ }
66
+ function asObject(value, key) {
67
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) {
68
+ throw new errors_1.StorageError(`LeaseJournal: invalid object at ${key}`, 'corrupt', { key });
69
+ }
70
+ return value;
71
+ }
72
+ function requiredString(object, field, key) {
73
+ const value = object[field];
74
+ if (typeof value !== 'string' || value.length === 0) {
75
+ throw new errors_1.StorageError(`LeaseJournal: invalid ${field} at ${key}`, 'corrupt', { key });
76
+ }
77
+ return value;
78
+ }
79
+ function optionalString(object, field, key) {
80
+ const value = object[field];
81
+ if (value === undefined)
82
+ return undefined;
83
+ if (typeof value !== 'string') {
84
+ throw new errors_1.StorageError(`LeaseJournal: invalid ${field} at ${key}`, 'corrupt', { key });
85
+ }
86
+ return value;
87
+ }
88
+ function requiredInteger(object, field, key) {
89
+ const value = object[field];
90
+ if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0) {
91
+ throw new errors_1.StorageError(`LeaseJournal: invalid ${field} at ${key}`, 'corrupt', { key });
92
+ }
93
+ return value;
94
+ }
95
+ function validateEntry(value, key) {
96
+ const object = asObject(value, key);
97
+ const indices = asObject(object.indices, `${key}.indices`);
98
+ const status = requiredString(object, 'status', key);
99
+ if (!STATUSES.has(status)) {
100
+ throw new errors_1.StorageError(`LeaseJournal: invalid status at ${key}`, 'corrupt', { key });
101
+ }
102
+ const timestamp = object.timestamp;
103
+ if (typeof timestamp !== 'number' || !Number.isFinite(timestamp) || timestamp < 0) {
104
+ throw new errors_1.StorageError(`LeaseJournal: invalid timestamp at ${key}`, 'corrupt', { key });
105
+ }
106
+ const validated = cloneEntry({
107
+ treeId: requiredString(object, 'treeId', key),
108
+ branchId: requiredString(object, 'branchId', key),
109
+ wotsIndex: requiredInteger(object, 'wotsIndex', key),
110
+ indices: {
111
+ addressIndex: requiredInteger(indices, 'addressIndex', `${key}.indices`),
112
+ l1: requiredInteger(indices, 'l1', `${key}.indices`),
113
+ l2: requiredInteger(indices, 'l2', `${key}.indices`),
114
+ },
115
+ status,
116
+ reservationId: optionalString(object, 'reservationId', key),
117
+ payloadHash: optionalString(object, 'payloadHash', key),
118
+ txId: optionalString(object, 'txId', key),
119
+ timestamp,
120
+ deviceId: requiredString(object, 'deviceId', key),
121
+ previousHash: optionalString(object, 'previousHash', key),
122
+ hash: optionalString(object, 'hash', key),
123
+ });
124
+ const { addressIndex, l1, l2 } = validated.indices;
125
+ if (addressIndex > MAX_INDEX_COMPONENT || l1 > MAX_INDEX_COMPONENT || l2 > MAX_INDEX_COMPONENT) {
126
+ throw new errors_1.StorageError(`LeaseJournal: indices out of range at ${key}`, 'corrupt', { key });
127
+ }
128
+ const expectedIndex = addressIndex * 64 * 64 + l1 * 64 + l2;
129
+ if (validated.wotsIndex !== expectedIndex) {
130
+ throw new errors_1.StorageError(`LeaseJournal: wotsIndex/indices mismatch at ${key}`, 'corrupt', { key });
131
+ }
132
+ return validated;
133
+ }
134
+ function verifyChain(entries, source) {
135
+ for (let i = 0; i < entries.length; i += 1) {
136
+ const entry = entries[i];
137
+ if (entry.hash === undefined || !HASH_PATTERN.test(entry.hash)) {
138
+ throw new errors_1.StorageError(`LeaseJournal: invalid hash at ${source}[${i}]`, 'corrupt');
139
+ }
140
+ const expectedHash = hashEntry(entry);
141
+ if (entry.hash !== expectedHash) {
142
+ throw new errors_1.StorageError(`LeaseJournal: hash mismatch at ${source}[${i}]`, 'corrupt');
143
+ }
144
+ if (i === 0) {
145
+ if (entry.previousHash !== undefined) {
146
+ throw new errors_1.StorageError(`LeaseJournal: first entry has previousHash at ${source}[0]`, 'corrupt');
147
+ }
148
+ }
149
+ else if (entry.previousHash !== entries[i - 1].hash) {
150
+ throw new errors_1.StorageError(`LeaseJournal: previousHash mismatch at ${source}[${i}]`, 'corrupt');
151
+ }
152
+ }
153
+ }
154
+ function entriesEqual(a, b) {
155
+ return a.treeId === b.treeId
156
+ && a.branchId === b.branchId
157
+ && a.wotsIndex === b.wotsIndex
158
+ && a.indices.addressIndex === b.indices.addressIndex
159
+ && a.indices.l1 === b.indices.l1
160
+ && a.indices.l2 === b.indices.l2
161
+ && a.status === b.status
162
+ && a.reservationId === b.reservationId
163
+ && a.payloadHash === b.payloadHash
164
+ && a.txId === b.txId
165
+ && a.timestamp === b.timestamp
166
+ && a.deviceId === b.deviceId
167
+ && a.previousHash === b.previousHash
168
+ && a.hash === b.hash;
169
+ }
170
+ function sequenceText(sequence) {
171
+ return sequence.toString(10).padStart(SEQUENCE_WIDTH, '0');
172
+ }
173
+ function recordKey(sequence, hash) {
174
+ return `${ENTRY_PREFIX}${sequenceText(sequence)}:${hash}`;
175
+ }
176
+ function parseRecordKey(key) {
177
+ const relative = key.slice(ENTRY_PREFIX.length);
178
+ const match = KEY_PATTERN.exec(relative);
179
+ if (!match) {
180
+ throw new errors_1.StorageError(`LeaseJournal: malformed record key ${key}`, 'corrupt', { key });
181
+ }
182
+ const sequence = Number(match[1]);
183
+ if (!Number.isSafeInteger(sequence)) {
184
+ throw new errors_1.StorageError(`LeaseJournal: invalid sequence in ${key}`, 'corrupt', { key });
185
+ }
186
+ return { key, sequence, hash: match[2] };
187
+ }
26
188
  class LeaseJournal {
27
189
  constructor(storage, logger = new core_1.NoopLogger()) {
28
190
  this.storage = storage;
29
191
  this.logger = logger;
30
192
  this.entries = [];
31
- this._initialized = false;
193
+ this.initialized = false;
194
+ this.initializePromise = null;
195
+ this.appendTail = Promise.resolve();
32
196
  }
33
197
  async initialize() {
34
- if (this._initialized)
198
+ if (this.initialized)
35
199
  return;
36
- const stored = await this.storage.get(STORAGE_KEY);
37
- this.entries = stored ?? [];
38
- if (this.entries.length > 0) {
39
- const ok = this._verifyChain();
40
- if (!ok) {
41
- this.logger.error('[LeaseJournal] hash chain integrity check FAILED — journal may be tampered');
42
- throw new Error('Journal hash chain integrity check failed');
200
+ if (!this.initializePromise) {
201
+ this.initializePromise = withStorageLock(this.storage, () => this.initializeOnce()).catch((error) => {
202
+ this.initializePromise = null;
203
+ throw error;
204
+ });
205
+ }
206
+ await this.initializePromise;
207
+ }
208
+ async initializeOnce() {
209
+ const physical = await this.readPhysicalRecords();
210
+ const legacy = await this.readLegacyEntries();
211
+ if (legacy !== null) {
212
+ const commonLength = Math.min(physical.length, legacy.length);
213
+ for (let i = 0; i < commonLength; i += 1) {
214
+ if (!entriesEqual(physical[i], legacy[i])) {
215
+ throw new errors_1.StorageError(`LeaseJournal: legacy/v1 divergence at sequence ${i}`, 'corrupt');
216
+ }
217
+ }
218
+ for (let i = physical.length; i < legacy.length; i += 1) {
219
+ await this.writeRecord(i, legacy[i]);
220
+ }
221
+ }
222
+ const replayed = legacy !== null && legacy.length > physical.length
223
+ ? await this.readPhysicalRecords()
224
+ : physical;
225
+ this.entries = replayed.map(cloneEntry);
226
+ this.initialized = true;
227
+ }
228
+ async readLegacyEntries() {
229
+ const raw = await this.storage.get(LEGACY_STORAGE_KEY);
230
+ if (raw === null)
231
+ return null;
232
+ if (!Array.isArray(raw)) {
233
+ throw new errors_1.StorageError('LeaseJournal: legacy journal is not an array', 'corrupt', { key: LEGACY_STORAGE_KEY });
234
+ }
235
+ const entries = raw.map((entry, index) => validateEntry(entry, `${LEGACY_STORAGE_KEY}[${index}]`));
236
+ verifyChain(entries, LEGACY_STORAGE_KEY);
237
+ return entries;
238
+ }
239
+ async readPhysicalRecords() {
240
+ const parsed = (await this.storage.keys())
241
+ .filter((key) => key.startsWith(ENTRY_PREFIX))
242
+ .map(parseRecordKey)
243
+ .sort((a, b) => a.sequence - b.sequence || a.key.localeCompare(b.key));
244
+ for (let i = 0; i < parsed.length; i += 1) {
245
+ if (i > 0 && parsed[i - 1].sequence === parsed[i].sequence) {
246
+ throw new errors_1.StorageError(`LeaseJournal: fork at sequence ${parsed[i].sequence}`, 'corrupt');
247
+ }
248
+ if (parsed[i].sequence !== i) {
249
+ throw new errors_1.StorageError(`LeaseJournal: sequence gap at ${i}`, 'corrupt');
43
250
  }
44
251
  }
45
- this._initialized = true;
46
- }
47
- async append(entry) {
48
- const prev = this.entries[this.entries.length - 1];
49
- entry.previousHash = prev?.hash;
50
- entry.hash = hashEntry(entry);
51
- this.entries.push(entry);
52
- await this.storage.set(STORAGE_KEY, this.entries);
53
- this.logger.debug(`[LeaseJournal] ${entry.status} treeId=${entry.treeId} idx=${entry.wotsIndex} txId=${entry.txId ?? '-'} hash=${entry.hash.slice(0, 8)}`);
54
- }
55
- _verifyChain() {
56
- for (let i = 0; i < this.entries.length; i++) {
57
- const e = this.entries[i];
58
- const expectedHash = hashEntry(e);
59
- if (e.hash !== expectedHash) {
60
- this.logger.error(`[LeaseJournal] hash mismatch at entry ${i}: expected ${expectedHash}, stored ${e.hash}`);
61
- return false;
252
+ const entries = [];
253
+ for (const descriptor of parsed) {
254
+ const raw = await this.storage.get(descriptor.key);
255
+ if (raw === null) {
256
+ throw new errors_1.StorageError(`LeaseJournal: listed record is missing`, 'corrupt', { key: descriptor.key });
62
257
  }
63
- if (i > 0) {
64
- const prevHash = this.entries[i - 1].hash;
65
- if (e.previousHash !== prevHash) {
66
- this.logger.error(`[LeaseJournal] previousHash mismatch at entry ${i}: expected ${prevHash}, stored ${e.previousHash}`);
67
- return false;
258
+ const record = asObject(raw, descriptor.key);
259
+ const formatVersion = record.formatVersion;
260
+ if (formatVersion !== RECORD_VERSION) {
261
+ throw new errors_1.StorageError('LeaseJournal: unsupported record version', 'corrupt', {
262
+ key: descriptor.key,
263
+ detectedVersion: typeof formatVersion === 'number' ? formatVersion : undefined,
264
+ unsupportedVersion: typeof formatVersion === 'number' ? formatVersion : undefined,
265
+ });
266
+ }
267
+ if (record.sequence !== descriptor.sequence) {
268
+ throw new errors_1.StorageError('LeaseJournal: sequence/key mismatch', 'corrupt', { key: descriptor.key });
269
+ }
270
+ const entry = validateEntry(record.entry, `${descriptor.key}.entry`);
271
+ if (entry.hash !== descriptor.hash) {
272
+ throw new errors_1.StorageError('LeaseJournal: hash/key mismatch', 'corrupt', { key: descriptor.key });
273
+ }
274
+ entries.push(entry);
275
+ }
276
+ verifyChain(entries, ENTRY_PREFIX);
277
+ return entries;
278
+ }
279
+ async writeRecord(sequence, entry) {
280
+ if (entry.hash === undefined) {
281
+ throw new errors_1.StorageError(`LeaseJournal: entry ${sequence} has no hash`, 'corrupt');
282
+ }
283
+ const key = recordKey(sequence, entry.hash);
284
+ const record = {
285
+ formatVersion: RECORD_VERSION,
286
+ sequence,
287
+ entry: cloneEntry(entry),
288
+ };
289
+ await this.storage.set(key, record);
290
+ }
291
+ append(entry) {
292
+ const run = this.appendTail.then(async () => this.appendOne(entry));
293
+ this.appendTail = run.catch(() => undefined);
294
+ return run;
295
+ }
296
+ async appendOne(input) {
297
+ await this.initialize();
298
+ await withStorageLock(this.storage, async () => {
299
+ // Refresh under the shared lock so multiple journal instances that share
300
+ // the same adapter object cannot allocate the same sequence or slot.
301
+ this.entries = (await this.readPhysicalRecords()).map(cloneEntry);
302
+ const entry = validateEntry(cloneEntry(input), 'append');
303
+ if (entry.status === 'reserved' && this.entries.some((existing) => existing.treeId === entry.treeId && existing.wotsIndex === entry.wotsIndex)) {
304
+ throw new errors_1.StorageError(`LeaseJournal: WOTS slot ${entry.treeId}/${entry.wotsIndex} is already recorded`, 'write-failed');
305
+ }
306
+ if (entry.status !== 'reserved' && entry.reservationId) {
307
+ const latest = [...this.entries].reverse().find((existing) => existing.reservationId === entry.reservationId);
308
+ if (latest?.status === entry.status) {
309
+ if (latest.treeId !== entry.treeId || latest.wotsIndex !== entry.wotsIndex
310
+ || (entry.status === 'committed' && latest.txId !== entry.txId)) {
311
+ throw new errors_1.StorageError(`LeaseJournal: reservation ${entry.reservationId} is already ${latest.status} for a different use`, 'write-failed');
312
+ }
313
+ if (latest.previousHash === undefined)
314
+ delete input.previousHash;
315
+ else
316
+ input.previousHash = latest.previousHash;
317
+ input.hash = latest.hash;
318
+ return;
319
+ }
320
+ if (latest && latest.status !== 'reserved') {
321
+ throw new errors_1.StorageError(`LeaseJournal: reservation ${entry.reservationId} is already ${latest.status}`, 'write-failed');
68
322
  }
69
323
  }
324
+ const previous = this.entries[this.entries.length - 1];
325
+ if (previous?.hash !== undefined)
326
+ entry.previousHash = previous.hash;
327
+ else
328
+ delete entry.previousHash;
329
+ entry.hash = hashEntry(entry);
330
+ const sequence = this.entries.length;
331
+ const sequencePrefix = `${ENTRY_PREFIX}${sequenceText(sequence)}:`;
332
+ if ((await this.storage.keys()).some((key) => key.startsWith(sequencePrefix))) {
333
+ throw new errors_1.StorageError(`LeaseJournal: sequence ${sequence} already exists`, 'write-failed');
334
+ }
335
+ await this.writeRecord(sequence, entry);
336
+ this.entries.push(entry);
337
+ if (entry.previousHash === undefined)
338
+ delete input.previousHash;
339
+ else
340
+ input.previousHash = entry.previousHash;
341
+ input.hash = entry.hash;
342
+ this.logger.debug(`[LeaseJournal] ${entry.status} treeId=${entry.treeId} idx=${entry.wotsIndex} `
343
+ + `txId=${entry.txId ?? '-'} hash=${entry.hash.slice(0, 8)}`);
344
+ });
345
+ }
346
+ ensureInitialized() {
347
+ if (!this.initialized) {
348
+ throw new Error('LeaseJournal not initialized — call initialize() first');
70
349
  }
71
- return true;
72
350
  }
73
351
  getAll() {
74
- return [...this.entries];
352
+ this.ensureInitialized();
353
+ return this.entries.map(cloneEntry);
75
354
  }
76
355
  getByTree(treeId) {
77
- return this.entries.filter((e) => e.treeId === treeId);
356
+ this.ensureInitialized();
357
+ return this.entries.filter((entry) => entry.treeId === treeId).map(cloneEntry);
78
358
  }
79
359
  getByReservation(reservationId) {
80
- return [...this.entries].reverse().find((e) => e.reservationId === reservationId);
360
+ this.ensureInitialized();
361
+ const entry = [...this.entries].reverse().find((candidate) => candidate.reservationId === reservationId);
362
+ return entry ? cloneEntry(entry) : undefined;
81
363
  }
82
364
  async clear() {
83
- this.entries = [];
84
- await this.storage.remove(STORAGE_KEY);
365
+ throw new errors_1.StorageError('LeaseJournal is append-only and cannot be cleared', 'write-failed');
85
366
  }
86
367
  }
87
368
  exports.LeaseJournal = LeaseJournal;
package/dist/local.d.ts CHANGED
@@ -5,7 +5,7 @@
5
5
  * All WOTS safety guaranteed by the local WotsWatermarkStore + LeaseJournal.
6
6
  */
7
7
  import type { StorageAdapter, LoggerAdapter } from '@totemsdk/core';
8
- import type { WotsLeaseProvider, ReserveParams, LeaseReservation, LeaseCertificate, LocalWatermark, SyncResult, SigningIndices } from './types.js';
8
+ import type { WotsLeaseProvider, ReserveParams, LeaseReservation, LeaseCertificate, LocalWatermark, SyncResult, SigningIndices, WatermarkSeed, SeedWatermarkOptions } from './types.js';
9
9
  import { LeaseJournal } from './journal.js';
10
10
  export declare class LocalLeaseProvider implements WotsLeaseProvider {
11
11
  private readonly logger;
@@ -13,9 +13,26 @@ export declare class LocalLeaseProvider implements WotsLeaseProvider {
13
13
  private readonly leaseStore;
14
14
  private readonly journal;
15
15
  private _initialized;
16
+ private initializePromise;
17
+ private mutationTail;
16
18
  private readonly deviceId;
19
+ /**
20
+ * Optional CAS primitive (AUD-004). When the adapter exposes
21
+ * `conditionalUpdate`, a per-slot claim key is written atomically before the
22
+ * reservation so two providers over distinct adapter objects on the same
23
+ * durable medium cannot both claim one slot. Adapters without CAS fall back
24
+ * to the existing refresh() + check behavior.
25
+ */
26
+ private readonly cas?;
17
27
  constructor(storage: StorageAdapter, logger?: LoggerAdapter, deviceId?: string);
28
+ private static detectCas;
18
29
  initialize(): Promise<void>;
30
+ private initializeOnce;
31
+ /**
32
+ * Rebuild the no-reuse watermark from immutable journal history and leases.
33
+ * The journal is audit authority; the watermark remains allocation authority.
34
+ */
35
+ private reconcileDurableSafetyState;
19
36
  /** Burn all active (non-expired) leases that have no matching treeId.
20
37
  * On restart, leases with missing treeId (from before the treeId field was added)
21
38
  * must be burned to prevent index reuse. */
@@ -28,9 +45,22 @@ export declare class LocalLeaseProvider implements WotsLeaseProvider {
28
45
  * slot is already taken.
29
46
  */
30
47
  reserveSpecificKeyUse(params: ReserveParams, indices: SigningIndices): Promise<LeaseReservation>;
48
+ private reserveSpecificKeyUseUnlocked;
49
+ private withMutationLock;
31
50
  commitKeyUse(reservationId: string, txId: string): Promise<void>;
51
+ private commitKeyUseUnlocked;
32
52
  burnReservation(reservationId: string, reason: string): Promise<void>;
53
+ private burnReservationUnlocked;
33
54
  getLocalWatermark(treeId: string): Promise<LocalWatermark>;
55
+ /**
56
+ * Export a portable watermark seed for restore (RFC-013 §9).
57
+ */
58
+ exportWatermark(treeId: string): Promise<WatermarkSeed>;
59
+ /**
60
+ * Seed the local watermark from an exported/operator/on-chain snapshot.
61
+ * Forward-only by default; refuses to start below the true high-water mark.
62
+ */
63
+ seedWatermark(seed: WatermarkSeed, options?: SeedWatermarkOptions): Promise<void>;
34
64
  /** List all tree IDs known to the local watermark store. */
35
65
  listTrees(): string[];
36
66
  /** Expose the journal for quorum/on-chain providers to merge remote entries. */