@reticulum/dacar 1.0.0 → 1.1.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.
@@ -0,0 +1,464 @@
1
+ /**
2
+ * DacarStore: persistent node store over a `StorageAdapter` (work doc #6).
3
+ *
4
+ * Backend-neutral: built on `@reticulum/core`'s `StorageAdapter` KV contract
5
+ * (`get`/`set`/`delete`/`keys`, namespaced). Mirrors Python's `Store` fields
6
+ * logically, but stores each as a namespaced KV record rather than an INI +
7
+ * loose files — JS has no `0600`-mode INI convention, and the KV contract is
8
+ * the idiomatic, portable choice (Node `FileStorageAdapter`, in-memory for
9
+ * tests, IndexedDB for browsers).
10
+ *
11
+ * Records:
12
+ * - `config` — msgpack `{ primarySalt, legacySalts[], anchors[],
13
+ * authoritative?, horizonDays, rfedTopic, rfedNode? }`
14
+ * - `clock` — msgpack `{ lastMs, logical }`
15
+ * - `state` — `StateVector.toPayload()` (the CRDT, trusted-local)
16
+ * - `aliases` — msgpack `[{ hash, names[], note? }]`
17
+ * - `ledger` — msgpack `{ tupleHashHex: { object?, relation?, wildcard?, firstSeen } }`
18
+ * - `identities` — msgpack `{ hashHex: pubKeyBytes }` (durable issuer cache, doc #5)
19
+ *
20
+ * Secret material (the node's own identity private key) uses the adapter's
21
+ * dedicated `loadKey`/`saveKey` slot, matching `@reticulum/core`.
22
+ */
23
+
24
+ import { MsgPack, Identity, toHex } from "@reticulum/core";
25
+ import { Config, DEFAULT_DELETION_HORIZON_DAYS } from "../config.js";
26
+ import { StateVector } from "../crdt.js";
27
+ import { Clock } from "../hlc.js";
28
+ import { RFED_TOPIC } from "../naming.js";
29
+ import {
30
+ DEFAULT_SALT,
31
+ HASH_SIZE,
32
+ MAX_LEGACY_SALTS,
33
+ SALT_SIZE,
34
+ } from "../namespace.js";
35
+ import { Keyring, IssuerKeyset } from "../verifier.js";
36
+
37
+ /** The alias that always names the node's own signing identity. */
38
+ export const SELF_ALIAS = "self";
39
+
40
+ /** The 64-byte RNS public key (X25519 ‖ Ed25519). */
41
+ const RNS_PUBLIC_KEY_SIZE = 64;
42
+
43
+ const NS = "dacar";
44
+
45
+ /**
46
+ * @typedef {Object} StoreConfig
47
+ * @property {Uint8Array} primarySalt
48
+ * @property {Uint8Array[]} legacySalts
49
+ * @property {Uint8Array[]} anchors
50
+ * @property {Uint8Array | null} [authoritative]
51
+ * @property {number} horizonDays
52
+ * @property {string} rfedTopic
53
+ * @property {Uint8Array | null} [rfedNode]
54
+ */
55
+
56
+ /**
57
+ * @typedef {Object} AliasEntry
58
+ * @property {Uint8Array} hash
59
+ * @property {string[]} names
60
+ * @property {string | null} [note]
61
+ */
62
+
63
+ /**
64
+ * A dacar node store backed by a `StorageAdapter`. Each CLI invocation builds a
65
+ * store, loads what it needs, mutates in memory, and writes back — the
66
+ * offline-first, daemon-free model.
67
+ */
68
+ export class DacarStore {
69
+ /**
70
+ * @param {import("@reticulum/core").StorageAdapter} adapter
71
+ * @param {Object} [opts]
72
+ * @param {Uint8Array | string} [opts.identityBytes] A 128-byte private-key blob
73
+ * overriding the store's own identity (mirrors Python's `--identity PATH`).
74
+ */
75
+ constructor(adapter, opts = {}) {
76
+ this._adapter = adapter;
77
+ this._identityOverride = opts.identityBytes ?? null;
78
+ }
79
+
80
+ // -- config --------------------------------------------------------------
81
+
82
+ /**
83
+ * Bootstrap a fresh node store (work doc #6 `init`).
84
+ * @param {import("@reticulum/core").StorageAdapter} adapter
85
+ * @param {Object} [opts]
86
+ * @param {Uint8Array} [opts.salt] 32-byte Privacy Salt (default: random).
87
+ * @param {number} [opts.horizonDays]
88
+ * @param {Uint8Array} [opts.identityBytes] 128-byte private-key blob to adopt.
89
+ * @returns {Promise<DacarStore>}
90
+ */
91
+ static async init(adapter, opts = {}) {
92
+ const store = new DacarStore(adapter, opts);
93
+ // Identity: adopt the override, else generate + persist via saveKey.
94
+ let identity;
95
+ if (opts.identityBytes) {
96
+ identity = await Identity.fromBytes(opts.identityBytes);
97
+ if (!identity) throw new Error("could not load identity from provided bytes");
98
+ await adapter.saveKey(opts.identityBytes);
99
+ } else {
100
+ identity = await Identity.loadOrGenerate(adapter);
101
+ }
102
+ const salt = opts.salt ?? _randomBytes(SALT_SIZE);
103
+ /** @type {StoreConfig} */
104
+ const config = {
105
+ primarySalt: salt,
106
+ legacySalts: [],
107
+ anchors: [identity.identityHash],
108
+ authoritative: null,
109
+ horizonDays: opts.horizonDays ?? DEFAULT_DELETION_HORIZON_DAYS,
110
+ rfedTopic: RFED_TOPIC,
111
+ rfedNode: null,
112
+ };
113
+ await store.saveConfig(config);
114
+ await store.saveState(new StateVector({ deletionHorizonDays: config.horizonDays }));
115
+ await store.saveClock(new Clock());
116
+ await store.saveLedger(new Map());
117
+ const aliases = new AliasRegistry();
118
+ aliases.add(SELF_ALIAS, identity.identityHash);
119
+ await store.saveAliases(aliases);
120
+ await store.saveKeyring(new Keyring());
121
+ return store;
122
+ }
123
+
124
+ /** @returns {Promise<boolean>} */
125
+ async exists() {
126
+ return (await this._adapter.get(NS, "config")) !== null;
127
+ }
128
+
129
+ /** @returns {Promise<StoreConfig>} */
130
+ async loadConfig() {
131
+ const bytes = await this._adapter.get(NS, "config");
132
+ if (!bytes) throw new Error("store not initialized (run `dacar init`)");
133
+ return _decodeConfig(bytes);
134
+ }
135
+
136
+ /** @param {StoreConfig} config */
137
+ async saveConfig(config) {
138
+ const obj = [
139
+ config.primarySalt,
140
+ config.legacySalts,
141
+ config.anchors,
142
+ config.authoritative ?? null,
143
+ config.horizonDays,
144
+ config.rfedTopic,
145
+ config.rfedNode ?? null,
146
+ ];
147
+ await this._adapter.set(NS, "config", MsgPack.encode(obj));
148
+ }
149
+
150
+ /**
151
+ * Build a validated {@link Config} from the stored config.
152
+ * @returns {Promise<Config>}
153
+ */
154
+ async loadConfigValidated() {
155
+ const raw = await this.loadConfig();
156
+ return new Config({
157
+ rootTrustAnchors: raw.anchors,
158
+ primarySalt: raw.primarySalt,
159
+ legacySalts: raw.legacySalts,
160
+ authoritativeIdentity: raw.authoritative ?? undefined,
161
+ deletionHorizonDays: raw.horizonDays,
162
+ });
163
+ }
164
+
165
+ // -- identity ------------------------------------------------------------
166
+
167
+ /** @returns {Promise<Identity | null>} */
168
+ async loadIdentity() {
169
+ if (this._identityOverride) {
170
+ const bytes = typeof this._identityOverride === "string"
171
+ ? _hexToBytes(this._identityOverride)
172
+ : this._identityOverride;
173
+ const id = await Identity.fromBytes(bytes);
174
+ if (!id) throw new Error("could not load identity from override");
175
+ return id;
176
+ }
177
+ const keyBytes = await this._adapter.loadKey();
178
+ if (!keyBytes) return null;
179
+ const id = await Identity.fromBytes(keyBytes);
180
+ if (!id) throw new Error("could not load stored identity (corrupt?)");
181
+ return id;
182
+ }
183
+
184
+ /** @returns {Promise<Uint8Array>} */
185
+ async identityHash() {
186
+ const id = await this.loadIdentity();
187
+ if (!id) throw new Error("no signing identity (run `dacar init`)");
188
+ return id.identityHash;
189
+ }
190
+
191
+ // -- clock (HLC) ---------------------------------------------------------
192
+
193
+ /** @returns {Promise<Clock>} */
194
+ async loadClock() {
195
+ const clock = new Clock();
196
+ const bytes = await this._adapter.get(NS, "clock");
197
+ if (bytes) {
198
+ const obj = MsgPack.decode(bytes);
199
+ if (obj && typeof obj === "object" && !Array.isArray(obj)) {
200
+ if (typeof obj.lastMs === "number" && typeof obj.logical === "number") {
201
+ clock.restore(obj);
202
+ }
203
+ }
204
+ }
205
+ return clock;
206
+ }
207
+
208
+ /** @param {Clock} clock */
209
+ async saveClock(clock) {
210
+ await this._adapter.set(
211
+ NS,
212
+ "clock",
213
+ MsgPack.encode(clock.snapshot()),
214
+ );
215
+ }
216
+
217
+ // -- state (CRDT) --------------------------------------------------------
218
+
219
+ /** @param {Config} [config] @returns {Promise<StateVector>} */
220
+ async loadState(config) {
221
+ const horizon = config?.deletionHorizonDays ?? (await this.loadConfig()).horizonDays;
222
+ const bytes = await this._adapter.get(NS, "state");
223
+ if (bytes && bytes.length) {
224
+ // `trusted: true` — these are this node's own persisted CRDT snapshot
225
+ // (written by `saveState()` → `toPayload()`), never network bytes.
226
+ // Network Operations arrive as signed Deltas through `DeltaReceiver`
227
+ // (the verify-on-ingest path), not here. Asserting `trusted` silences
228
+ // the audible `fromPayload` footgun warning during normal CLI use.
229
+ return StateVector.fromPayload(bytes, {
230
+ deletionHorizonDays: horizon,
231
+ trusted: true,
232
+ });
233
+ }
234
+ return new StateVector({ deletionHorizonDays: horizon });
235
+ }
236
+
237
+ /** @param {StateVector} state */
238
+ async saveState(state) {
239
+ await this._adapter.set(NS, "state", state.toPayload());
240
+ }
241
+
242
+ // -- aliases -------------------------------------------------------------
243
+
244
+ /** @returns {Promise<AliasRegistry>} */
245
+ async loadAliases() {
246
+ const bytes = await this._adapter.get(NS, "aliases");
247
+ if (!bytes) return new AliasRegistry();
248
+ return AliasRegistry.decode(bytes);
249
+ }
250
+
251
+ /** @param {AliasRegistry} aliases */
252
+ async saveAliases(aliases) {
253
+ await this._adapter.set(NS, "aliases", aliases.encode());
254
+ }
255
+
256
+ // -- ledger --------------------------------------------------------------
257
+
258
+ /**
259
+ * @returns {Promise<Map<string, { object?: string, relation?: string, wildcard?: boolean, firstSeen?: number }>>}
260
+ */
261
+ async loadLedger() {
262
+ const bytes = await this._adapter.get(NS, "ledger");
263
+ /** @type {Map<string, any>} */
264
+ const ledger = new Map();
265
+ if (bytes) {
266
+ const obj = MsgPack.decode(bytes);
267
+ if (obj && typeof obj === "object" && !Array.isArray(obj)) {
268
+ for (const [k, v] of Object.entries(obj)) ledger.set(k, v);
269
+ }
270
+ }
271
+ return ledger;
272
+ }
273
+
274
+ /** @param {Map<string, any>} ledger */
275
+ async saveLedger(ledger) {
276
+ const obj = Object.fromEntries(ledger);
277
+ await this._adapter.set(NS, "ledger", MsgPack.encode(obj));
278
+ }
279
+
280
+ // -- issuer identity cache (work doc #5) ---------------------------------
281
+
282
+ /**
283
+ * Load the persisted issuer identity cache. Returns an empty {@link Keyring}
284
+ * if no cache record exists yet.
285
+ * @returns {Promise<Keyring>}
286
+ */
287
+ async loadKeyring() {
288
+ const keyring = new Keyring();
289
+ const bytes = await this._adapter.get(NS, "identities");
290
+ if (bytes) {
291
+ const obj = MsgPack.decode(bytes);
292
+ if (obj && typeof obj === "object" && !Array.isArray(obj)) {
293
+ for (const [hashHex, pubKey] of Object.entries(obj)) {
294
+ if (pubKey instanceof Uint8Array && pubKey.length === RNS_PUBLIC_KEY_SIZE) {
295
+ try {
296
+ keyring.registerSingle(_hexToBytes(hashHex), pubKey);
297
+ } catch {
298
+ // skip malformed hash
299
+ }
300
+ }
301
+ }
302
+ }
303
+ }
304
+ return keyring;
305
+ }
306
+
307
+ /** @param {Keyring} keyring */
308
+ async saveKeyring(keyring) {
309
+ const obj = {};
310
+ for (const [hashHex, keyset] of keyring.entries()) {
311
+ if (keyset.threshold === 1 && keyset.memberPublicKeys.length === 1) {
312
+ obj[hashHex] = keyset.memberPublicKeys[0];
313
+ }
314
+ }
315
+ await this._adapter.set(NS, "identities", MsgPack.encode(obj));
316
+ }
317
+
318
+ /**
319
+ * Build a verify-on-ingest keyring from the persisted cache + own identity.
320
+ * @returns {Promise<Keyring>}
321
+ */
322
+ async keyringForVerify() {
323
+ const keyring = await this.loadKeyring();
324
+ const own = await this.loadIdentity();
325
+ if (own) keyring.registerSingle(own.identityHash, await own.getPublicKey());
326
+ return keyring;
327
+ }
328
+ }
329
+
330
+ /**
331
+ * In-memory alias registry: `hash → names[]` with an optional note. Mirrors
332
+ * Python's `AliasRegistry` (rnns `hash name [# note]`).
333
+ */
334
+ export class AliasRegistry {
335
+ /** @param {AliasEntry[]} [entries] */
336
+ constructor(entries = []) {
337
+ /** @type {AliasEntry[]} */ this.entries = entries;
338
+ }
339
+
340
+ /** @param {Uint8Array} bytes @returns {AliasRegistry} */
341
+ static decode(bytes) {
342
+ const obj = MsgPack.decode(bytes);
343
+ if (!Array.isArray(obj)) return new AliasRegistry();
344
+ /** @type {AliasEntry[]} */
345
+ const entries = [];
346
+ for (const row of obj) {
347
+ if (!Array.isArray(row)) continue;
348
+ const [hash, names, note] = row;
349
+ if (!(hash instanceof Uint8Array) || !Array.isArray(names)) continue;
350
+ entries.push({ hash, names, note: note ?? null });
351
+ }
352
+ return new AliasRegistry(entries);
353
+ }
354
+
355
+ /** @returns {Uint8Array} */
356
+ encode() {
357
+ return MsgPack.encode(this.entries.map((e) => [e.hash, e.names, e.note ?? null]));
358
+ }
359
+
360
+ /** @param {string} name @returns {Uint8Array | null} */
361
+ resolve(name) {
362
+ for (const e of this.entries) {
363
+ if (e.names.includes(name)) return e.hash;
364
+ }
365
+ return null;
366
+ }
367
+
368
+ /** @param {Uint8Array} hash @returns {string[]} */
369
+ namesFor(hash) {
370
+ for (const e of this.entries) {
371
+ if (_bytesEqual(e.hash, hash)) return [...e.names];
372
+ }
373
+ return [];
374
+ }
375
+
376
+ /** @param {Uint8Array} hash @returns {string | null} */
377
+ primaryName(hash) {
378
+ const names = this.namesFor(hash);
379
+ return names[0] ?? null;
380
+ }
381
+
382
+ /** @param {string} name @param {Uint8Array} hash @param {string | null} [note] */
383
+ add(name, hash, note) {
384
+ for (const e of this.entries) {
385
+ if (_bytesEqual(e.hash, hash)) {
386
+ if (!e.names.includes(name)) e.names.push(name);
387
+ if (note !== undefined) e.note = note;
388
+ return;
389
+ }
390
+ }
391
+ this.entries.push({ hash, names: [name], note: note ?? null });
392
+ }
393
+
394
+ /** @param {Uint8Array} hash */
395
+ setSelf(hash) {
396
+ for (const e of this.entries) {
397
+ const i = e.names.indexOf(SELF_ALIAS);
398
+ if (i !== -1) e.names.splice(i, 1);
399
+ }
400
+ this.entries = this.entries.filter((e) => e.names.length > 0);
401
+ this.add(SELF_ALIAS, hash);
402
+ }
403
+ }
404
+
405
+ // -- decode helpers ---------------------------------------------------------
406
+
407
+ /**
408
+ * @param {Uint8Array} bytes
409
+ * @returns {StoreConfig}
410
+ */
411
+ function _decodeConfig(bytes) {
412
+ const arr = MsgPack.decode(bytes);
413
+ if (!Array.isArray(arr) || arr.length !== 7) {
414
+ throw new Error("config record must be a 7-element MessagePack array");
415
+ }
416
+ const [primarySalt, legacySalts, anchors, authoritative, horizonDays, rfedTopic, rfedNode] = arr;
417
+ return {
418
+ primarySalt: _expectBytes(primarySalt, SALT_SIZE, "primary_salt"),
419
+ legacySalts: legacySalts.map((s) => _expectBytes(s, SALT_SIZE, "legacy_salt")),
420
+ anchors: anchors.map((a) => _expectBytes(a, HASH_SIZE, "anchor")),
421
+ authoritative: authoritative instanceof Uint8Array ? authoritative : null,
422
+ horizonDays: Number(horizonDays),
423
+ rfedTopic: String(rfedTopic),
424
+ rfedNode: rfedNode instanceof Uint8Array ? rfedNode : null,
425
+ };
426
+ }
427
+
428
+ /**
429
+ * @param {unknown} value
430
+ * @param {number} len
431
+ * @param {string} name
432
+ * @returns {Uint8Array}
433
+ */
434
+ function _expectBytes(value, len, name) {
435
+ if (!(value instanceof Uint8Array) || value.length !== len) {
436
+ throw new Error(`${name} must be a ${len}-byte Uint8Array`);
437
+ }
438
+ return value;
439
+ }
440
+
441
+ /** @param {Uint8Array} a @param {Uint8Array} b @returns {boolean} */
442
+ function _bytesEqual(a, b) {
443
+ if (a.length !== b.length) return false;
444
+ let diff = 0;
445
+ for (let i = 0; i < a.length; i++) diff |= a[i] ^ b[i];
446
+ return diff === 0;
447
+ }
448
+
449
+ /** @param {string} hex @returns {Uint8Array} */
450
+ function _hexToBytes(hex) {
451
+ const clean = hex.startsWith("0x") ? hex.slice(2) : hex;
452
+ const out = new Uint8Array(clean.length / 2);
453
+ for (let i = 0; i < out.length; i++) {
454
+ out[i] = parseInt(clean.slice(i * 2, i * 2 + 2), 16);
455
+ }
456
+ return out;
457
+ }
458
+
459
+ /** @param {number} n @returns {Uint8Array} */
460
+ function _randomBytes(n) {
461
+ const out = new Uint8Array(n);
462
+ crypto.getRandomValues(out);
463
+ return out;
464
+ }
package/src/crdt.js CHANGED
@@ -256,14 +256,20 @@ export class StateVector {
256
256
  * > `DeltaReceiver.applyPayloads()` (a batch of signed §5.3 Operations)
257
257
  * > instead.
258
258
  * >
259
- * > A one-time `console.warn` is emitted to make this contract audible.
259
+ * > A one-time `console.warn` is emitted to make this contract audible
260
+ * > unless `opts.trusted` is set, which a caller that has already asserted
261
+ * > it is loading its own persisted snapshot (e.g. `DacarStore.loadState`)
262
+ * > passes to keep normal CLI output free of developer-footgun noise.
260
263
  * @param {Uint8Array} data
261
264
  * @param {Object} [opts]
262
265
  * @param {number} [opts.deletionHorizonDays]
266
+ * @param {boolean} [opts.trusted=false] Suppress the audible warning when the
267
+ * caller has asserted the bytes are a trusted-local snapshot (its own
268
+ * store). The JSDoc contract above still applies regardless.
263
269
  * @returns {StateVector}
264
270
  */
265
- static fromPayload(data, { deletionHorizonDays = DEFAULT_DELETION_HORIZON_DAYS } = {}) {
266
- if (!__trustedLocalWarned) {
271
+ static fromPayload(data, { deletionHorizonDays = DEFAULT_DELETION_HORIZON_DAYS, trusted = false } = {}) {
272
+ if (!trusted && !__trustedLocalWarned) {
267
273
  __trustedLocalWarned = true;
268
274
  console.warn(
269
275
  "StateVector.fromPayload() is trusted-local-only: it performs no " +
package/src/hlc.js CHANGED
@@ -68,6 +68,39 @@ export class Clock {
68
68
  #lastMs = 0;
69
69
  #logical = 0;
70
70
 
71
+ /**
72
+ * Get the last physical timestamp (ms).
73
+ * @returns {number}
74
+ */
75
+ get lastMs() {
76
+ return this.#lastMs;
77
+ }
78
+
79
+ /**
80
+ * Get the current logical counter.
81
+ * @returns {number}
82
+ */
83
+ get logical() {
84
+ return this.#logical;
85
+ }
86
+
87
+ /**
88
+ * Restore the clock from a snapshot (for store persistence).
89
+ * @param {{ lastMs: number, logical: number }} snap
90
+ */
91
+ restore(snap) {
92
+ if (!snap || typeof snap.lastMs !== "number" || typeof snap.logical !== "number") {
93
+ throw new Error("restore requires an object with lastMs and logical");
94
+ }
95
+ this.#lastMs = snap.lastMs;
96
+ this.#logical = snap.logical;
97
+ }
98
+
99
+ /** Obtain a snapshot for persistence. @returns {{ lastMs: number, logical: number }} */
100
+ snapshot() {
101
+ return { lastMs: this.#lastMs, logical: this.#logical };
102
+ }
103
+
71
104
  /** Advance from a local event and return the new HLC. @returns {bigint} */
72
105
  now() {
73
106
  const phys = physicalNowMs();
package/src/verifier.js CHANGED
@@ -144,6 +144,33 @@ export class Keyring {
144
144
  resolve(issuerHash) {
145
145
  return this._map.get(toHex(_asHash(issuerHash))) ?? null;
146
146
  }
147
+
148
+ /**
149
+ * Remove an Issuer from the keyring.
150
+ * @param {Uint8Array} issuerHash
151
+ * @returns {boolean} `true` if the Issuer was present (and is now removed).
152
+ */
153
+ forget(issuerHash) {
154
+ return this._map.delete(toHex(_asHash(issuerHash)));
155
+ }
156
+
157
+ /**
158
+ * Return `[issuerHashHex, keyset]` pairs for all registered Issuers.
159
+ * @returns {[string, IssuerKeyset][]}
160
+ */
161
+ entries() {
162
+ return [...this._map.entries()];
163
+ }
164
+
165
+ /** Number of registered Issuers. @returns {number} */
166
+ get size() {
167
+ return this._map.size;
168
+ }
169
+
170
+ /** @param {Uint8Array} issuerHash @returns {boolean} */
171
+ has(issuerHash) {
172
+ return this._map.has(toHex(_asHash(issuerHash)));
173
+ }
147
174
  }
148
175
 
149
176
  /**