@reticulum/dacar 1.1.2 → 1.2.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/package.json +4 -3
- package/src/challenge.js +1 -1
- package/src/cli/dacar.js +204 -62
- package/src/cli/fileStore.js +166 -0
- package/src/cli/session.js +187 -20
- package/src/cli/store.js +421 -137
- package/src/crdt.js +2 -2
- package/src/operation.js +2 -2
- package/src/transport/lxmfSync.js +7 -7
- package/src/transport/rfedSync.js +93 -83
- package/src/tuple.js +1 -1
package/src/cli/store.js
CHANGED
|
@@ -2,26 +2,35 @@
|
|
|
2
2
|
* DacarStore: persistent node store over a `StorageAdapter` (work doc #6).
|
|
3
3
|
*
|
|
4
4
|
* Backend-neutral: built on `@reticulum/core`'s `StorageAdapter` KV contract
|
|
5
|
-
* (`get`/`set`/`delete`/`keys`, namespaced).
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* the idiomatic, portable choice (Node `FileStorageAdapter`, in-memory for
|
|
9
|
-
* tests, IndexedDB for browsers).
|
|
5
|
+
* (`get`/`set`/`delete`/`keys`, namespaced). The on-disk **record bytes are
|
|
6
|
+
* byte-for-byte identical to the canonical Python `Store`** (work doc #9), so a
|
|
7
|
+
* store directory written by one CLI is readable — and writable — by the other:
|
|
10
8
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
* - `
|
|
15
|
-
* - `
|
|
16
|
-
* - `
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
* - `
|
|
20
|
-
*
|
|
21
|
-
*
|
|
9
|
+
* - `config` — INI (`configparser` layout: `[salt]`/`[trust]`/
|
|
10
|
+
* `[policy]`/`[rfed]`, `key = value`, `legacy{i}`).
|
|
11
|
+
* - `clock.msgpack` — msgpack `{ last_ms, logical }` (snake_case).
|
|
12
|
+
* - `state.msgpack` — `StateVector.toPayload()` (the CRDT, trusted-local).
|
|
13
|
+
* - `aliases` — rnns text `hash name [# note]` (NOT msgpack).
|
|
14
|
+
* - `ledger.msgpack` — msgpack `{ tuple_hash_hex: { object, relation,
|
|
15
|
+
* wildcard, first_seen } }` (snake_case; the tuple
|
|
16
|
+
* hash key is `sha256(preimage).hex()`).
|
|
17
|
+
* - `identities.msgpack`— msgpack `{ hash_hex: 32-byte Ed25519 pub }` (the
|
|
18
|
+
* Ed25519 half of the 64-byte RNS pub key).
|
|
19
|
+
* - `outbox.msgpack` — msgpack `[payload_bytes, ...]` of locally-issued,
|
|
20
|
+
* not-yet-published signed Deltas (doc #8).
|
|
21
|
+
* - `sent.msgpack` — msgpack `[payload_bytes, ...]` durable replay log
|
|
22
|
+
* of published Deltas (doc #11).
|
|
22
23
|
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
24
|
+
* Record names are the exact Python filenames (with `.msgpack` where Python
|
|
25
|
+
* uses it); a Node `DacarFileAdapter` (`./fileStore.js`) writes them as loose
|
|
26
|
+
* files in the store root with Python-matching modes. `MemoryStorageAdapter`
|
|
27
|
+
* (tests) just stores the same bytes keyed by name.
|
|
28
|
+
*
|
|
29
|
+
* The node's own signing **identity private key** is the ONE intentional
|
|
30
|
+
* divergence: it stays library-native (Python RNS → 64-byte priv-only
|
|
31
|
+
* `identity`; `@reticulum/core` → 128-byte priv+pub `identity.key` via
|
|
32
|
+
* `adapter.loadKey`/`saveKey`). The two files coexist under different names; a
|
|
33
|
+
* store carries the identity of whichever CLI initialized it.
|
|
25
34
|
*/
|
|
26
35
|
|
|
27
36
|
import { MsgPack, Identity, toHex } from "@reticulum/core";
|
|
@@ -35,16 +44,26 @@ import {
|
|
|
35
44
|
MAX_LEGACY_SALTS,
|
|
36
45
|
SALT_SIZE,
|
|
37
46
|
} from "../namespace.js";
|
|
38
|
-
import { Keyring
|
|
47
|
+
import { Keyring } from "../verifier.js";
|
|
39
48
|
|
|
40
49
|
/** The alias that always names the node's own signing identity. */
|
|
41
50
|
export const SELF_ALIAS = "self";
|
|
42
51
|
|
|
43
|
-
/**
|
|
44
|
-
const
|
|
52
|
+
/** Ed25519 public keys are 32 raw bytes (the verify half of a 64-byte RNS key). */
|
|
53
|
+
const ED25519_PUB_SIZE = 32;
|
|
45
54
|
|
|
46
55
|
const NS = "dacar";
|
|
47
56
|
|
|
57
|
+
// Record names mirror the canonical Python `Store` filenames exactly.
|
|
58
|
+
const CONFIG_RECORD = "config";
|
|
59
|
+
const CLOCK_RECORD = "clock.msgpack";
|
|
60
|
+
const STATE_RECORD = "state.msgpack";
|
|
61
|
+
const ALIASES_RECORD = "aliases";
|
|
62
|
+
const LEDGER_RECORD = "ledger.msgpack";
|
|
63
|
+
const IDENTITIES_RECORD = "identities.msgpack";
|
|
64
|
+
const OUTBOX_RECORD = "outbox.msgpack";
|
|
65
|
+
const SENT_RECORD = "sent.msgpack";
|
|
66
|
+
|
|
48
67
|
/**
|
|
49
68
|
* @typedef {Object} StoreConfig
|
|
50
69
|
* @property {Uint8Array} primarySalt
|
|
@@ -63,6 +82,14 @@ const NS = "dacar";
|
|
|
63
82
|
* @property {string | null} [note]
|
|
64
83
|
*/
|
|
65
84
|
|
|
85
|
+
/**
|
|
86
|
+
* @typedef {Object} LedgerRow
|
|
87
|
+
* @property {string | null} [object]
|
|
88
|
+
* @property {string | null} [relation]
|
|
89
|
+
* @property {boolean | null} [wildcard]
|
|
90
|
+
* @property {number} [firstSeen]
|
|
91
|
+
*/
|
|
92
|
+
|
|
66
93
|
/**
|
|
67
94
|
* A dacar node store backed by a `StorageAdapter`. Each CLI invocation builds a
|
|
68
95
|
* store, loads what it needs, mutates in memory, and writes back — the
|
|
@@ -84,6 +111,11 @@ export class DacarStore {
|
|
|
84
111
|
|
|
85
112
|
/**
|
|
86
113
|
* Bootstrap a fresh node store (work doc #6 `init`).
|
|
114
|
+
*
|
|
115
|
+
* Produces the same file set as Python `Store.init`: `config` (INI),
|
|
116
|
+
* `state.msgpack`, `clock.msgpack`, `ledger.msgpack`, `aliases` (with the
|
|
117
|
+
* `self` alias). `identities.msgpack` / `outbox.msgpack` / `sent.msgpack`
|
|
118
|
+
* are NOT pre-written (Python creates them lazily on first save).
|
|
87
119
|
* @param {import("@reticulum/core").StorageAdapter} adapter
|
|
88
120
|
* @param {Object} [opts]
|
|
89
121
|
* @param {Uint8Array} [opts.salt] 32-byte Privacy Salt (default: random).
|
|
@@ -93,7 +125,6 @@ export class DacarStore {
|
|
|
93
125
|
*/
|
|
94
126
|
static async init(adapter, opts = {}) {
|
|
95
127
|
const store = new DacarStore(adapter, opts);
|
|
96
|
-
// Identity: adopt the override, else generate + persist via saveKey.
|
|
97
128
|
let identity;
|
|
98
129
|
if (opts.identityBytes) {
|
|
99
130
|
identity = await Identity.fromBytes(opts.identityBytes);
|
|
@@ -120,34 +151,24 @@ export class DacarStore {
|
|
|
120
151
|
const aliases = new AliasRegistry();
|
|
121
152
|
aliases.add(SELF_ALIAS, identity.identityHash);
|
|
122
153
|
await store.saveAliases(aliases);
|
|
123
|
-
await store.saveKeyring(new Keyring());
|
|
124
154
|
return store;
|
|
125
155
|
}
|
|
126
156
|
|
|
127
157
|
/** @returns {Promise<boolean>} */
|
|
128
158
|
async exists() {
|
|
129
|
-
return (await this._adapter.get(NS,
|
|
159
|
+
return (await this._adapter.get(NS, CONFIG_RECORD)) !== null;
|
|
130
160
|
}
|
|
131
161
|
|
|
132
162
|
/** @returns {Promise<StoreConfig>} */
|
|
133
163
|
async loadConfig() {
|
|
134
|
-
const bytes = await this._adapter.get(NS,
|
|
164
|
+
const bytes = await this._adapter.get(NS, CONFIG_RECORD);
|
|
135
165
|
if (!bytes) throw new Error("store not initialized (run `dacar init`)");
|
|
136
|
-
return
|
|
166
|
+
return _decodeConfigIni(bytes);
|
|
137
167
|
}
|
|
138
168
|
|
|
139
169
|
/** @param {StoreConfig} config */
|
|
140
170
|
async saveConfig(config) {
|
|
141
|
-
|
|
142
|
-
config.primarySalt,
|
|
143
|
-
config.legacySalts,
|
|
144
|
-
config.anchors,
|
|
145
|
-
config.authoritative ?? null,
|
|
146
|
-
config.horizonDays,
|
|
147
|
-
config.rfedTopic,
|
|
148
|
-
config.rfedNode ?? null,
|
|
149
|
-
];
|
|
150
|
-
await this._adapter.set(NS, "config", MsgPack.encode(obj));
|
|
171
|
+
await this._adapter.set(NS, CONFIG_RECORD, _encodeConfigIni(config));
|
|
151
172
|
}
|
|
152
173
|
|
|
153
174
|
/**
|
|
@@ -196,12 +217,15 @@ export class DacarStore {
|
|
|
196
217
|
/** @returns {Promise<Clock>} */
|
|
197
218
|
async loadClock() {
|
|
198
219
|
const clock = new Clock();
|
|
199
|
-
const bytes = await this._adapter.get(NS,
|
|
220
|
+
const bytes = await this._adapter.get(NS, CLOCK_RECORD);
|
|
200
221
|
if (bytes) {
|
|
201
222
|
const obj = MsgPack.decode(bytes);
|
|
202
223
|
if (obj && typeof obj === "object" && !Array.isArray(obj)) {
|
|
203
|
-
|
|
204
|
-
|
|
224
|
+
// snake_case on disk (Python parity); Clock API is camelCase.
|
|
225
|
+
const lastMs = obj.last_ms ?? obj.lastMs;
|
|
226
|
+
const logical = obj.logical;
|
|
227
|
+
if (typeof lastMs === "number" && typeof logical === "number") {
|
|
228
|
+
clock.restore({ lastMs, logical });
|
|
205
229
|
}
|
|
206
230
|
}
|
|
207
231
|
}
|
|
@@ -212,8 +236,8 @@ export class DacarStore {
|
|
|
212
236
|
async saveClock(clock) {
|
|
213
237
|
await this._adapter.set(
|
|
214
238
|
NS,
|
|
215
|
-
|
|
216
|
-
MsgPack.encode(clock.
|
|
239
|
+
CLOCK_RECORD,
|
|
240
|
+
MsgPack.encode({ last_ms: clock.lastMs, logical: clock.logical }),
|
|
217
241
|
);
|
|
218
242
|
}
|
|
219
243
|
|
|
@@ -222,13 +246,12 @@ export class DacarStore {
|
|
|
222
246
|
/** @param {Config} [config] @returns {Promise<StateVector>} */
|
|
223
247
|
async loadState(config) {
|
|
224
248
|
const horizon = config?.deletionHorizonDays ?? (await this.loadConfig()).horizonDays;
|
|
225
|
-
const bytes = await this._adapter.get(NS,
|
|
249
|
+
const bytes = await this._adapter.get(NS, STATE_RECORD);
|
|
226
250
|
if (bytes && bytes.length) {
|
|
227
251
|
// `trusted: true` — these are this node's own persisted CRDT snapshot
|
|
228
252
|
// (written by `saveState()` → `toPayload()`), never network bytes.
|
|
229
253
|
// Network Operations arrive as signed Deltas through `DeltaReceiver`
|
|
230
|
-
// (the verify-on-ingest path), not here.
|
|
231
|
-
// the audible `fromPayload` footgun warning during normal CLI use.
|
|
254
|
+
// (the verify-on-ingest path), not here.
|
|
232
255
|
return StateVector.fromPayload(bytes, {
|
|
233
256
|
deletionHorizonDays: horizon,
|
|
234
257
|
trusted: true,
|
|
@@ -239,45 +262,51 @@ export class DacarStore {
|
|
|
239
262
|
|
|
240
263
|
/** @param {StateVector} state */
|
|
241
264
|
async saveState(state) {
|
|
242
|
-
await this._adapter.set(NS,
|
|
265
|
+
await this._adapter.set(NS, STATE_RECORD, state.toPayload());
|
|
243
266
|
}
|
|
244
267
|
|
|
245
268
|
// -- aliases -------------------------------------------------------------
|
|
246
269
|
|
|
247
270
|
/** @returns {Promise<AliasRegistry>} */
|
|
248
271
|
async loadAliases() {
|
|
249
|
-
const bytes = await this._adapter.get(NS,
|
|
272
|
+
const bytes = await this._adapter.get(NS, ALIASES_RECORD);
|
|
250
273
|
if (!bytes) return new AliasRegistry();
|
|
251
274
|
return AliasRegistry.decode(bytes);
|
|
252
275
|
}
|
|
253
276
|
|
|
254
277
|
/** @param {AliasRegistry} aliases */
|
|
255
278
|
async saveAliases(aliases) {
|
|
256
|
-
await this._adapter.set(NS,
|
|
279
|
+
await this._adapter.set(NS, ALIASES_RECORD, aliases.encode());
|
|
257
280
|
}
|
|
258
281
|
|
|
259
282
|
// -- ledger --------------------------------------------------------------
|
|
260
283
|
|
|
261
284
|
/**
|
|
262
|
-
*
|
|
285
|
+
* Plaintext ledger: `Map<tuple_hash_hex, row>`. The on-disk key is
|
|
286
|
+
* `sha256(preimage).hex()` (Python `Tuple.key`); callers MUST set/lookup with
|
|
287
|
+
* that key (e.g. `toHex(await tuple.hash())`) for cross-CLI parity.
|
|
288
|
+
* @returns {Promise<Map<string, LedgerRow>>}
|
|
263
289
|
*/
|
|
264
290
|
async loadLedger() {
|
|
265
|
-
const bytes = await this._adapter.get(NS,
|
|
266
|
-
/** @type {Map<string,
|
|
291
|
+
const bytes = await this._adapter.get(NS, LEDGER_RECORD);
|
|
292
|
+
/** @type {Map<string, LedgerRow>} */
|
|
267
293
|
const ledger = new Map();
|
|
268
294
|
if (bytes) {
|
|
269
295
|
const obj = MsgPack.decode(bytes);
|
|
270
296
|
if (obj && typeof obj === "object" && !Array.isArray(obj)) {
|
|
271
|
-
for (const [k, v] of Object.entries(obj))
|
|
297
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
298
|
+
ledger.set(k, _decodeLedgerRow(v));
|
|
299
|
+
}
|
|
272
300
|
}
|
|
273
301
|
}
|
|
274
302
|
return ledger;
|
|
275
303
|
}
|
|
276
304
|
|
|
277
|
-
/** @param {Map<string,
|
|
305
|
+
/** @param {Map<string, LedgerRow>} ledger */
|
|
278
306
|
async saveLedger(ledger) {
|
|
279
|
-
const obj =
|
|
280
|
-
|
|
307
|
+
const obj = {};
|
|
308
|
+
for (const [k, row] of ledger) obj[k] = _encodeLedgerRow(row);
|
|
309
|
+
await this._adapter.set(NS, LEDGER_RECORD, MsgPack.encode(obj));
|
|
281
310
|
}
|
|
282
311
|
|
|
283
312
|
// -- issuer identity cache (work doc #5) ---------------------------------
|
|
@@ -285,21 +314,26 @@ export class DacarStore {
|
|
|
285
314
|
/**
|
|
286
315
|
* Load the persisted issuer identity cache. Returns an empty {@link Keyring}
|
|
287
316
|
* if no cache record exists yet.
|
|
317
|
+
*
|
|
318
|
+
* On disk each value is the 32-byte Ed25519 public key (Python canonical);
|
|
319
|
+
* it is padded back to a 64-byte RNS public key (zeros ‖ Ed25519) for the
|
|
320
|
+
* in-memory `IssuerKeyset`, whose verify path only uses the Ed25519 half.
|
|
288
321
|
* @returns {Promise<Keyring>}
|
|
289
322
|
*/
|
|
290
323
|
async loadKeyring() {
|
|
291
324
|
const keyring = new Keyring();
|
|
292
|
-
const bytes = await this._adapter.get(NS,
|
|
325
|
+
const bytes = await this._adapter.get(NS, IDENTITIES_RECORD);
|
|
293
326
|
if (bytes) {
|
|
294
327
|
const obj = MsgPack.decode(bytes);
|
|
295
328
|
if (obj && typeof obj === "object" && !Array.isArray(obj)) {
|
|
296
329
|
for (const [hashHex, pubKey] of Object.entries(obj)) {
|
|
297
|
-
if (pubKey instanceof Uint8Array
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
330
|
+
if (!(pubKey instanceof Uint8Array) || pubKey.length !== ED25519_PUB_SIZE) {
|
|
331
|
+
continue; // skip malformed (wrong-length) entries
|
|
332
|
+
}
|
|
333
|
+
try {
|
|
334
|
+
keyring.registerSingle(_hexToBytes(hashHex), _padToRnsPub(pubKey));
|
|
335
|
+
} catch {
|
|
336
|
+
// skip malformed hash
|
|
303
337
|
}
|
|
304
338
|
}
|
|
305
339
|
}
|
|
@@ -312,10 +346,11 @@ export class DacarStore {
|
|
|
312
346
|
const obj = {};
|
|
313
347
|
for (const [hashHex, keyset] of keyring.entries()) {
|
|
314
348
|
if (keyset.threshold === 1 && keyset.memberPublicKeys.length === 1) {
|
|
315
|
-
|
|
349
|
+
// Store the 32-byte Ed25519 half (last 32 bytes of the 64-byte RNS key).
|
|
350
|
+
obj[hashHex] = keyset.memberPublicKeys[0].slice(32, 64);
|
|
316
351
|
}
|
|
317
352
|
}
|
|
318
|
-
await this._adapter.set(NS,
|
|
353
|
+
await this._adapter.set(NS, IDENTITIES_RECORD, MsgPack.encode(obj));
|
|
319
354
|
}
|
|
320
355
|
|
|
321
356
|
/**
|
|
@@ -338,7 +373,7 @@ export class DacarStore {
|
|
|
338
373
|
* @returns {Promise<Uint8Array[]>}
|
|
339
374
|
*/
|
|
340
375
|
async loadOutbox() {
|
|
341
|
-
const bytes = await this._adapter.get(NS,
|
|
376
|
+
const bytes = await this._adapter.get(NS, OUTBOX_RECORD);
|
|
342
377
|
if (!bytes) return [];
|
|
343
378
|
let obj;
|
|
344
379
|
try {
|
|
@@ -357,7 +392,41 @@ export class DacarStore {
|
|
|
357
392
|
async saveOutbox(payloads) {
|
|
358
393
|
await this._adapter.set(
|
|
359
394
|
NS,
|
|
360
|
-
|
|
395
|
+
OUTBOX_RECORD,
|
|
396
|
+
MsgPack.encode(payloads.map((p) => new Uint8Array(p))),
|
|
397
|
+
);
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// -- sent box (work doc #11) -------------------------------------------
|
|
401
|
+
|
|
402
|
+
/**
|
|
403
|
+
* Load the sent box: the durable replay log of published Deltas (own
|
|
404
|
+
* issuance), as exact signed bytes, in publication order. Re-publishing them
|
|
405
|
+
* (`publish --sent`) is a no-op on peers (CRDT merge is idempotent). Returns
|
|
406
|
+
* an empty array when no sent record exists yet.
|
|
407
|
+
* @returns {Promise<Uint8Array[]>}
|
|
408
|
+
*/
|
|
409
|
+
async loadSent() {
|
|
410
|
+
const bytes = await this._adapter.get(NS, SENT_RECORD);
|
|
411
|
+
if (!bytes) return [];
|
|
412
|
+
let obj;
|
|
413
|
+
try {
|
|
414
|
+
obj = MsgPack.decode(bytes);
|
|
415
|
+
} catch {
|
|
416
|
+
return []; // corrupted -> treat as empty (do not crash the CLI)
|
|
417
|
+
}
|
|
418
|
+
if (!Array.isArray(obj)) return [];
|
|
419
|
+
return obj.filter((p) => p instanceof Uint8Array).map((p) => new Uint8Array(p));
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/**
|
|
423
|
+
* Persist the sent box as a MessagePack array of signed Delta payloads.
|
|
424
|
+
* @param {Uint8Array[]} payloads
|
|
425
|
+
*/
|
|
426
|
+
async saveSent(payloads) {
|
|
427
|
+
await this._adapter.set(
|
|
428
|
+
NS,
|
|
429
|
+
SENT_RECORD,
|
|
361
430
|
MsgPack.encode(payloads.map((p) => new Uint8Array(p))),
|
|
362
431
|
);
|
|
363
432
|
}
|
|
@@ -365,7 +434,8 @@ export class DacarStore {
|
|
|
365
434
|
|
|
366
435
|
/**
|
|
367
436
|
* In-memory alias registry: `hash → names[]` with an optional note. Mirrors
|
|
368
|
-
* Python's `AliasRegistry` (rnns `hash name [# note]`)
|
|
437
|
+
* Python's `AliasRegistry` (rnns `hash name [# note]`); `encode()`/`decode()`
|
|
438
|
+
* produce the exact rnns text bytes Python does.
|
|
369
439
|
*/
|
|
370
440
|
export class AliasRegistry {
|
|
371
441
|
/** @param {AliasEntry[]} [entries] */
|
|
@@ -373,118 +443,329 @@ export class AliasRegistry {
|
|
|
373
443
|
/** @type {AliasEntry[]} */ this.entries = entries;
|
|
374
444
|
}
|
|
375
445
|
|
|
376
|
-
/**
|
|
446
|
+
/**
|
|
447
|
+
* Parse rnns `hash name [# note]` lines (mirrors Python `AliasRegistry.parse`).
|
|
448
|
+
* Blank lines and lines whose first token is not a 32-hex hash are skipped.
|
|
449
|
+
* @param {Uint8Array} bytes
|
|
450
|
+
* @returns {AliasRegistry}
|
|
451
|
+
*/
|
|
377
452
|
static decode(bytes) {
|
|
378
|
-
const
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
453
|
+
const registry = new AliasRegistry();
|
|
454
|
+
const text = new TextDecoder().decode(bytes);
|
|
455
|
+
for (const raw of text.split(/\r?\n/)) {
|
|
456
|
+
const line = raw.trim();
|
|
457
|
+
if (!line) continue;
|
|
458
|
+
let head = line;
|
|
459
|
+
let note = null;
|
|
460
|
+
const hashIdx = line.indexOf("#");
|
|
461
|
+
if (hashIdx !== -1) {
|
|
462
|
+
head = line.slice(0, hashIdx);
|
|
463
|
+
note = line.slice(hashIdx + 1).trim() || null;
|
|
464
|
+
}
|
|
465
|
+
const tokens = head.trim().split(/\s+/).filter(Boolean);
|
|
466
|
+
if (tokens.length === 0) continue;
|
|
467
|
+
const hashHex = tokens[0];
|
|
468
|
+
const names = tokens.slice(1);
|
|
469
|
+
if (hashHex.length !== HASH_SIZE * 2) continue;
|
|
470
|
+
let hashBytes;
|
|
471
|
+
try {
|
|
472
|
+
hashBytes = _hexToBytes(hashHex);
|
|
473
|
+
} catch {
|
|
474
|
+
continue;
|
|
475
|
+
}
|
|
476
|
+
if (hashBytes.length !== HASH_SIZE) continue;
|
|
477
|
+
if (names.length === 0) continue;
|
|
478
|
+
const existing = registry._entryFor(hashBytes);
|
|
479
|
+
if (existing) {
|
|
480
|
+
for (const n of names) {
|
|
481
|
+
if (!existing.names.includes(n)) existing.names.push(n);
|
|
482
|
+
}
|
|
483
|
+
if (note !== null) existing.note = note;
|
|
484
|
+
} else {
|
|
485
|
+
registry.entries.push({ hash: hashBytes, names: [...names], note });
|
|
486
|
+
}
|
|
387
487
|
}
|
|
388
|
-
return
|
|
488
|
+
return registry;
|
|
389
489
|
}
|
|
390
490
|
|
|
391
|
-
/** @returns {Uint8Array} */
|
|
491
|
+
/** @returns {Uint8Array} rnns text bytes (`hash name [# note]` per line). */
|
|
392
492
|
encode() {
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
/** @param {string} name @returns {Uint8Array | null} */
|
|
397
|
-
resolve(name) {
|
|
493
|
+
if (this.entries.length === 0) return new Uint8Array(0);
|
|
494
|
+
const lines = [];
|
|
398
495
|
for (const e of this.entries) {
|
|
399
|
-
|
|
496
|
+
const hashHex = toHex(e.hash);
|
|
497
|
+
let field = e.names.length ? `${hashHex} ${e.names.join(" ")}` : hashHex;
|
|
498
|
+
if (e.note) field += ` # ${e.note}`;
|
|
499
|
+
lines.push(field);
|
|
400
500
|
}
|
|
401
|
-
return
|
|
501
|
+
return new TextEncoder().encode(lines.join("\n") + "\n");
|
|
402
502
|
}
|
|
403
503
|
|
|
404
|
-
/** @param {Uint8Array} hash @returns {
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
if (_bytesEqual(e.hash, hash)) return [...e.names];
|
|
408
|
-
}
|
|
409
|
-
return [];
|
|
504
|
+
/** @param {Uint8Array} hash @returns {AliasEntry | undefined} */
|
|
505
|
+
_entryFor(hash) {
|
|
506
|
+
return this.entries.find((e) => _bytesEqual(e.hash, hash));
|
|
410
507
|
}
|
|
411
508
|
|
|
412
|
-
/**
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
/** @param {string} name @param {Uint8Array} hash @param {string | null} [note] */
|
|
509
|
+
/**
|
|
510
|
+
* @param {string} name
|
|
511
|
+
* @param {Uint8Array} hash
|
|
512
|
+
* @param {string} [note]
|
|
513
|
+
*/
|
|
419
514
|
add(name, hash, note) {
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
}
|
|
515
|
+
const existing = this._entryFor(hash);
|
|
516
|
+
if (existing) {
|
|
517
|
+
if (!existing.names.includes(name)) existing.names.push(name);
|
|
518
|
+
if (note != null) existing.note = note;
|
|
519
|
+
} else {
|
|
520
|
+
this.entries.push({ hash, names: [name], note: note ?? null });
|
|
426
521
|
}
|
|
427
|
-
this.entries.push({ hash, names: [name], note: note ?? null });
|
|
428
522
|
}
|
|
429
523
|
|
|
430
|
-
/**
|
|
524
|
+
/**
|
|
525
|
+
* Point the `self` alias at `hash` (replacing any prior), mirroring Python
|
|
526
|
+
* `set_self`: strip `SELF_ALIAS` from every entry, prune now-empty entries,
|
|
527
|
+
* then add `SELF_ALIAS` to the new hash.
|
|
528
|
+
* @param {Uint8Array} hash
|
|
529
|
+
*/
|
|
431
530
|
setSelf(hash) {
|
|
432
531
|
for (const e of this.entries) {
|
|
433
|
-
|
|
434
|
-
if (i !== -1) e.names.splice(i, 1);
|
|
532
|
+
e.names = e.names.filter((n) => n !== SELF_ALIAS);
|
|
435
533
|
}
|
|
436
534
|
this.entries = this.entries.filter((e) => e.names.length > 0);
|
|
437
535
|
this.add(SELF_ALIAS, hash);
|
|
438
536
|
}
|
|
537
|
+
|
|
538
|
+
/**
|
|
539
|
+
* Remove `name` from its entry (mirrors Python `remove`). Returns `true` if
|
|
540
|
+
* the name existed; the entry is dropped when it has no names left.
|
|
541
|
+
* @param {string} name
|
|
542
|
+
* @returns {boolean}
|
|
543
|
+
*/
|
|
544
|
+
remove(name) {
|
|
545
|
+
for (const e of this.entries) {
|
|
546
|
+
const idx = e.names.indexOf(name);
|
|
547
|
+
if (idx !== -1) {
|
|
548
|
+
e.names.splice(idx, 1);
|
|
549
|
+
if (e.names.length === 0) {
|
|
550
|
+
this.entries = this.entries.filter((en) => en !== e);
|
|
551
|
+
}
|
|
552
|
+
return true;
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
return false;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
/** @param {string} name @returns {Uint8Array | undefined} */
|
|
559
|
+
resolve(name) {
|
|
560
|
+
const e = this.entries.find((en) => en.names.includes(name));
|
|
561
|
+
return e?.hash;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
/** @param {Uint8Array} hash @returns {string[]} */
|
|
565
|
+
namesFor(hash) {
|
|
566
|
+
return this._entryFor(hash)?.names ?? [];
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
/** @param {Uint8Array} hash @returns {string | null} */
|
|
570
|
+
primaryName(hash) {
|
|
571
|
+
const names = this.namesFor(hash);
|
|
572
|
+
return names.length > 0 ? names[0] : null;
|
|
573
|
+
}
|
|
439
574
|
}
|
|
440
575
|
|
|
441
|
-
//
|
|
576
|
+
// =========================================================================
|
|
577
|
+
// Helpers — record encode/decode (Python-canonical bytes) + small utilities
|
|
578
|
+
// =========================================================================
|
|
442
579
|
|
|
443
580
|
/**
|
|
581
|
+
* Encode a {@link StoreConfig} as the INI text `configparser` would write
|
|
582
|
+
* (sections `[salt]`/`[trust]`/`[policy]`/`[rfed]`, `key = value`, a blank
|
|
583
|
+
* line after each section, trailing blank line). Byte-identical to Python's
|
|
584
|
+
* `Store.save_config` output.
|
|
585
|
+
* @param {StoreConfig} config
|
|
586
|
+
* @returns {Uint8Array}
|
|
587
|
+
*/
|
|
588
|
+
function _encodeConfigIni(config) {
|
|
589
|
+
let out = "";
|
|
590
|
+
out += "[salt]\n";
|
|
591
|
+
out += `primary = ${toHex(config.primarySalt)}\n`;
|
|
592
|
+
for (let i = 0; i < config.legacySalts.length && i < MAX_LEGACY_SALTS; i++) {
|
|
593
|
+
out += `legacy${i} = ${toHex(config.legacySalts[i])}\n`;
|
|
594
|
+
}
|
|
595
|
+
out += "\n";
|
|
596
|
+
out += "[trust]\n";
|
|
597
|
+
out += `anchors = ${config.anchors.map(toHex).join(", ")}\n`;
|
|
598
|
+
if (config.authoritative) out += `authoritative = ${toHex(config.authoritative)}\n`;
|
|
599
|
+
out += "\n";
|
|
600
|
+
out += "[policy]\n";
|
|
601
|
+
out += `deletion_horizon_days = ${config.horizonDays}\n`;
|
|
602
|
+
out += "\n";
|
|
603
|
+
out += "[rfed]\n";
|
|
604
|
+
out += `topic = ${config.rfedTopic}\n`;
|
|
605
|
+
if (config.rfedNode) out += `node = ${toHex(config.rfedNode)}\n`;
|
|
606
|
+
out += "\n";
|
|
607
|
+
return new TextEncoder().encode(out);
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
/**
|
|
611
|
+
* Decode an INI `config` blob (as written by Python `configparser`) into a
|
|
612
|
+
* {@link StoreConfig}. Mirrors Python `Store.load_config_raw`: option keys are
|
|
613
|
+
* case-insensitive (configparser lowercases them); missing fields fall back to
|
|
614
|
+
* defaults; optional sections (`authoritative`, `node`, `legacy{i}`) are
|
|
615
|
+
* omitted when absent.
|
|
444
616
|
* @param {Uint8Array} bytes
|
|
445
617
|
* @returns {StoreConfig}
|
|
446
618
|
*/
|
|
447
|
-
function
|
|
448
|
-
const
|
|
449
|
-
|
|
450
|
-
|
|
619
|
+
function _decodeConfigIni(bytes) {
|
|
620
|
+
const sections = _parseIni(bytes);
|
|
621
|
+
const salt = sections.get("salt") ?? new Map();
|
|
622
|
+
const primaryHex = salt.get("primary") ?? toHex(DEFAULT_SALT);
|
|
623
|
+
const primarySalt = _expectHex("primary", primaryHex, SALT_SIZE);
|
|
624
|
+
const legacySalts = [];
|
|
625
|
+
for (let i = 0; i < MAX_LEGACY_SALTS; i++) {
|
|
626
|
+
const v = salt.get(`legacy${i}`);
|
|
627
|
+
if (v) legacySalts.push(_expectHex(`legacy${i}`, v, SALT_SIZE));
|
|
628
|
+
}
|
|
629
|
+
const trust = sections.get("trust") ?? new Map();
|
|
630
|
+
const anchorsRaw = trust.get("anchors") ?? "";
|
|
631
|
+
const anchors = anchorsRaw
|
|
632
|
+
.split(",")
|
|
633
|
+
.map((s) => s.trim())
|
|
634
|
+
.filter(Boolean)
|
|
635
|
+
.map((h, i) => _expectHex(`anchors[${i}]`, h, HASH_SIZE));
|
|
636
|
+
const authoritative = trust.has("authoritative")
|
|
637
|
+
? _expectHex("authoritative", trust.get("authoritative"), HASH_SIZE)
|
|
638
|
+
: null;
|
|
639
|
+
const policy = sections.get("policy") ?? new Map();
|
|
640
|
+
const horizonDays = parseInt(policy.get("deletion_horizon_days") ?? String(DEFAULT_DELETION_HORIZON_DAYS), 10);
|
|
641
|
+
const rfed = sections.get("rfed") ?? new Map();
|
|
642
|
+
const rfedTopic = rfed.get("topic") ?? RFED_TOPIC;
|
|
643
|
+
const rfedNode = rfed.has("node") ? _expectHex("node", rfed.get("node"), HASH_SIZE) : null;
|
|
644
|
+
return { primarySalt, legacySalts, anchors, authoritative, horizonDays, rfedTopic, rfedNode };
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
/**
|
|
648
|
+
* Minimal INI parser compatible with Python `configparser` output: sections in
|
|
649
|
+
* `[brackets]`, `key = value` (or `key: value`) lines, `#`/`;` comments and
|
|
650
|
+
* blank lines ignored. Option keys are lowercased (configparser `optionxform`).
|
|
651
|
+
* @param {Uint8Array} bytes
|
|
652
|
+
* @returns {Map<string, Map<string, string>>}
|
|
653
|
+
*/
|
|
654
|
+
function _parseIni(bytes) {
|
|
655
|
+
const text = new TextDecoder().decode(bytes);
|
|
656
|
+
/** @type {Map<string, Map<string, string>>} */
|
|
657
|
+
const sections = new Map();
|
|
658
|
+
let cur = null;
|
|
659
|
+
for (const rawLine of text.split(/\r?\n/)) {
|
|
660
|
+
const line = rawLine.trim();
|
|
661
|
+
if (!line || line.startsWith("#") || line.startsWith(";")) continue;
|
|
662
|
+
const sec = line.match(/^\[(.+)\]$/);
|
|
663
|
+
if (sec) {
|
|
664
|
+
cur = sec[1];
|
|
665
|
+
sections.set(cur, new Map());
|
|
666
|
+
continue;
|
|
667
|
+
}
|
|
668
|
+
if (!cur) continue;
|
|
669
|
+
const idx = line.search(/[=:]/);
|
|
670
|
+
if (idx < 0) continue;
|
|
671
|
+
const key = line.slice(0, idx).trim().toLowerCase();
|
|
672
|
+
const val = line.slice(idx + 1).trim();
|
|
673
|
+
sections.get(cur).set(key, val);
|
|
674
|
+
}
|
|
675
|
+
return sections;
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
/**
|
|
679
|
+
* @param {string} name
|
|
680
|
+
* @param {string} hex
|
|
681
|
+
* @param {number} len
|
|
682
|
+
* @returns {Uint8Array}
|
|
683
|
+
*/
|
|
684
|
+
function _expectHex(name, hex, len) {
|
|
685
|
+
const bytes = _hexToBytes(hex);
|
|
686
|
+
if (bytes.length !== len) {
|
|
687
|
+
throw new Error(`${name} must be ${len} bytes (${len * 2} hex), got ${bytes.length}`);
|
|
688
|
+
}
|
|
689
|
+
return bytes;
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
/**
|
|
693
|
+
* @param {unknown} v
|
|
694
|
+
* @returns {LedgerRow}
|
|
695
|
+
*/
|
|
696
|
+
function _decodeLedgerRow(v) {
|
|
697
|
+
if (!v || typeof v !== "object" || Array.isArray(v)) {
|
|
698
|
+
return { object: null, relation: null, wildcard: null, firstSeen: 0 };
|
|
451
699
|
}
|
|
452
|
-
const
|
|
700
|
+
const o = /** @type {Record<string, unknown>} */ (v);
|
|
453
701
|
return {
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
horizonDays: Number(horizonDays),
|
|
459
|
-
rfedTopic: String(rfedTopic),
|
|
460
|
-
rfedNode: rfedNode instanceof Uint8Array ? rfedNode : null,
|
|
702
|
+
object: _optStr(o.object),
|
|
703
|
+
relation: _optStr(o.relation),
|
|
704
|
+
wildcard: _optBool(o.wildcard),
|
|
705
|
+
firstSeen: typeof o.first_seen === "number" ? o.first_seen : 0,
|
|
461
706
|
};
|
|
462
707
|
}
|
|
463
708
|
|
|
464
709
|
/**
|
|
465
|
-
* @param {
|
|
466
|
-
* @
|
|
467
|
-
|
|
710
|
+
* @param {LedgerRow} row
|
|
711
|
+
* @returns {Record<string, unknown>}
|
|
712
|
+
*/
|
|
713
|
+
function _encodeLedgerRow(row) {
|
|
714
|
+
return {
|
|
715
|
+
object: row.object ?? null,
|
|
716
|
+
relation: row.relation ?? null,
|
|
717
|
+
wildcard: row.wildcard ?? null,
|
|
718
|
+
first_seen: row.firstSeen ?? 0,
|
|
719
|
+
};
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
/** @param {unknown} v @returns {string | null} */
|
|
723
|
+
function _optStr(v) {
|
|
724
|
+
return typeof v === "string" ? v : null;
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
/** @param {unknown} v @returns {boolean | null} */
|
|
728
|
+
function _optBool(v) {
|
|
729
|
+
return typeof v === "boolean" ? v : null;
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
/**
|
|
733
|
+
* Pad a 32-byte Ed25519 public key to a 64-byte RNS public key
|
|
734
|
+
* (`X25519(pub=zeros) ‖ Ed25519(pub)`). The verifier's `IssuerKeyset` holds
|
|
735
|
+
* 64-byte RNS keys; the X25519 half is unused for signature verification.
|
|
736
|
+
* @param {Uint8Array} ed25519Pub
|
|
468
737
|
* @returns {Uint8Array}
|
|
469
738
|
*/
|
|
470
|
-
function
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
return value;
|
|
739
|
+
function _padToRnsPub(ed25519Pub) {
|
|
740
|
+
const padded = new Uint8Array(64);
|
|
741
|
+
padded.set(ed25519Pub, 32);
|
|
742
|
+
return padded;
|
|
475
743
|
}
|
|
476
744
|
|
|
477
|
-
/**
|
|
745
|
+
/**
|
|
746
|
+
* @param {Uint8Array} a
|
|
747
|
+
* @param {Uint8Array} b
|
|
748
|
+
* @returns {boolean}
|
|
749
|
+
*/
|
|
478
750
|
function _bytesEqual(a, b) {
|
|
479
751
|
if (a.length !== b.length) return false;
|
|
480
|
-
let
|
|
481
|
-
|
|
482
|
-
|
|
752
|
+
for (let i = 0; i < a.length; i++) {
|
|
753
|
+
if (a[i] !== b[i]) return false;
|
|
754
|
+
}
|
|
755
|
+
return true;
|
|
483
756
|
}
|
|
484
757
|
|
|
485
|
-
/**
|
|
758
|
+
/**
|
|
759
|
+
* Parse a hex string (with or without `0x`) into bytes. Lower/upper-case
|
|
760
|
+
* tolerant; matches Python's `bytes.fromhex`.
|
|
761
|
+
* @param {string} hex
|
|
762
|
+
* @returns {Uint8Array}
|
|
763
|
+
*/
|
|
486
764
|
function _hexToBytes(hex) {
|
|
487
765
|
const clean = hex.startsWith("0x") ? hex.slice(2) : hex;
|
|
766
|
+
if (clean.length % 2 !== 0 || !/^[0-9a-fA-F]*$/.test(clean)) {
|
|
767
|
+
throw new Error(`invalid hex string (length ${clean.length})`);
|
|
768
|
+
}
|
|
488
769
|
const out = new Uint8Array(clean.length / 2);
|
|
489
770
|
for (let i = 0; i < out.length; i++) {
|
|
490
771
|
out[i] = parseInt(clean.slice(i * 2, i * 2 + 2), 16);
|
|
@@ -492,9 +773,12 @@ function _hexToBytes(hex) {
|
|
|
492
773
|
return out;
|
|
493
774
|
}
|
|
494
775
|
|
|
495
|
-
/**
|
|
496
|
-
|
|
497
|
-
|
|
776
|
+
/**
|
|
777
|
+
* @param {number} len
|
|
778
|
+
* @returns {Uint8Array}
|
|
779
|
+
*/
|
|
780
|
+
function _randomBytes(len) {
|
|
781
|
+
const out = new Uint8Array(len);
|
|
498
782
|
crypto.getRandomValues(out);
|
|
499
783
|
return out;
|
|
500
784
|
}
|