@reticulum/dacar 1.1.1 → 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 +307 -28
- package/src/cli/fileStore.js +166 -0
- package/src/cli/session.js +187 -20
- package/src/cli/store.js +451 -131
- 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,23 +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
|
-
*
|
|
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).
|
|
19
23
|
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
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.
|
|
22
34
|
*/
|
|
23
35
|
|
|
24
36
|
import { MsgPack, Identity, toHex } from "@reticulum/core";
|
|
@@ -32,16 +44,26 @@ import {
|
|
|
32
44
|
MAX_LEGACY_SALTS,
|
|
33
45
|
SALT_SIZE,
|
|
34
46
|
} from "../namespace.js";
|
|
35
|
-
import { Keyring
|
|
47
|
+
import { Keyring } from "../verifier.js";
|
|
36
48
|
|
|
37
49
|
/** The alias that always names the node's own signing identity. */
|
|
38
50
|
export const SELF_ALIAS = "self";
|
|
39
51
|
|
|
40
|
-
/**
|
|
41
|
-
const
|
|
52
|
+
/** Ed25519 public keys are 32 raw bytes (the verify half of a 64-byte RNS key). */
|
|
53
|
+
const ED25519_PUB_SIZE = 32;
|
|
42
54
|
|
|
43
55
|
const NS = "dacar";
|
|
44
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
|
+
|
|
45
67
|
/**
|
|
46
68
|
* @typedef {Object} StoreConfig
|
|
47
69
|
* @property {Uint8Array} primarySalt
|
|
@@ -60,6 +82,14 @@ const NS = "dacar";
|
|
|
60
82
|
* @property {string | null} [note]
|
|
61
83
|
*/
|
|
62
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
|
+
|
|
63
93
|
/**
|
|
64
94
|
* A dacar node store backed by a `StorageAdapter`. Each CLI invocation builds a
|
|
65
95
|
* store, loads what it needs, mutates in memory, and writes back — the
|
|
@@ -81,6 +111,11 @@ export class DacarStore {
|
|
|
81
111
|
|
|
82
112
|
/**
|
|
83
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).
|
|
84
119
|
* @param {import("@reticulum/core").StorageAdapter} adapter
|
|
85
120
|
* @param {Object} [opts]
|
|
86
121
|
* @param {Uint8Array} [opts.salt] 32-byte Privacy Salt (default: random).
|
|
@@ -90,7 +125,6 @@ export class DacarStore {
|
|
|
90
125
|
*/
|
|
91
126
|
static async init(adapter, opts = {}) {
|
|
92
127
|
const store = new DacarStore(adapter, opts);
|
|
93
|
-
// Identity: adopt the override, else generate + persist via saveKey.
|
|
94
128
|
let identity;
|
|
95
129
|
if (opts.identityBytes) {
|
|
96
130
|
identity = await Identity.fromBytes(opts.identityBytes);
|
|
@@ -117,34 +151,24 @@ export class DacarStore {
|
|
|
117
151
|
const aliases = new AliasRegistry();
|
|
118
152
|
aliases.add(SELF_ALIAS, identity.identityHash);
|
|
119
153
|
await store.saveAliases(aliases);
|
|
120
|
-
await store.saveKeyring(new Keyring());
|
|
121
154
|
return store;
|
|
122
155
|
}
|
|
123
156
|
|
|
124
157
|
/** @returns {Promise<boolean>} */
|
|
125
158
|
async exists() {
|
|
126
|
-
return (await this._adapter.get(NS,
|
|
159
|
+
return (await this._adapter.get(NS, CONFIG_RECORD)) !== null;
|
|
127
160
|
}
|
|
128
161
|
|
|
129
162
|
/** @returns {Promise<StoreConfig>} */
|
|
130
163
|
async loadConfig() {
|
|
131
|
-
const bytes = await this._adapter.get(NS,
|
|
164
|
+
const bytes = await this._adapter.get(NS, CONFIG_RECORD);
|
|
132
165
|
if (!bytes) throw new Error("store not initialized (run `dacar init`)");
|
|
133
|
-
return
|
|
166
|
+
return _decodeConfigIni(bytes);
|
|
134
167
|
}
|
|
135
168
|
|
|
136
169
|
/** @param {StoreConfig} config */
|
|
137
170
|
async saveConfig(config) {
|
|
138
|
-
|
|
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));
|
|
171
|
+
await this._adapter.set(NS, CONFIG_RECORD, _encodeConfigIni(config));
|
|
148
172
|
}
|
|
149
173
|
|
|
150
174
|
/**
|
|
@@ -193,12 +217,15 @@ export class DacarStore {
|
|
|
193
217
|
/** @returns {Promise<Clock>} */
|
|
194
218
|
async loadClock() {
|
|
195
219
|
const clock = new Clock();
|
|
196
|
-
const bytes = await this._adapter.get(NS,
|
|
220
|
+
const bytes = await this._adapter.get(NS, CLOCK_RECORD);
|
|
197
221
|
if (bytes) {
|
|
198
222
|
const obj = MsgPack.decode(bytes);
|
|
199
223
|
if (obj && typeof obj === "object" && !Array.isArray(obj)) {
|
|
200
|
-
|
|
201
|
-
|
|
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 });
|
|
202
229
|
}
|
|
203
230
|
}
|
|
204
231
|
}
|
|
@@ -209,8 +236,8 @@ export class DacarStore {
|
|
|
209
236
|
async saveClock(clock) {
|
|
210
237
|
await this._adapter.set(
|
|
211
238
|
NS,
|
|
212
|
-
|
|
213
|
-
MsgPack.encode(clock.
|
|
239
|
+
CLOCK_RECORD,
|
|
240
|
+
MsgPack.encode({ last_ms: clock.lastMs, logical: clock.logical }),
|
|
214
241
|
);
|
|
215
242
|
}
|
|
216
243
|
|
|
@@ -219,13 +246,12 @@ export class DacarStore {
|
|
|
219
246
|
/** @param {Config} [config] @returns {Promise<StateVector>} */
|
|
220
247
|
async loadState(config) {
|
|
221
248
|
const horizon = config?.deletionHorizonDays ?? (await this.loadConfig()).horizonDays;
|
|
222
|
-
const bytes = await this._adapter.get(NS,
|
|
249
|
+
const bytes = await this._adapter.get(NS, STATE_RECORD);
|
|
223
250
|
if (bytes && bytes.length) {
|
|
224
251
|
// `trusted: true` — these are this node's own persisted CRDT snapshot
|
|
225
252
|
// (written by `saveState()` → `toPayload()`), never network bytes.
|
|
226
253
|
// Network Operations arrive as signed Deltas through `DeltaReceiver`
|
|
227
|
-
// (the verify-on-ingest path), not here.
|
|
228
|
-
// the audible `fromPayload` footgun warning during normal CLI use.
|
|
254
|
+
// (the verify-on-ingest path), not here.
|
|
229
255
|
return StateVector.fromPayload(bytes, {
|
|
230
256
|
deletionHorizonDays: horizon,
|
|
231
257
|
trusted: true,
|
|
@@ -236,45 +262,51 @@ export class DacarStore {
|
|
|
236
262
|
|
|
237
263
|
/** @param {StateVector} state */
|
|
238
264
|
async saveState(state) {
|
|
239
|
-
await this._adapter.set(NS,
|
|
265
|
+
await this._adapter.set(NS, STATE_RECORD, state.toPayload());
|
|
240
266
|
}
|
|
241
267
|
|
|
242
268
|
// -- aliases -------------------------------------------------------------
|
|
243
269
|
|
|
244
270
|
/** @returns {Promise<AliasRegistry>} */
|
|
245
271
|
async loadAliases() {
|
|
246
|
-
const bytes = await this._adapter.get(NS,
|
|
272
|
+
const bytes = await this._adapter.get(NS, ALIASES_RECORD);
|
|
247
273
|
if (!bytes) return new AliasRegistry();
|
|
248
274
|
return AliasRegistry.decode(bytes);
|
|
249
275
|
}
|
|
250
276
|
|
|
251
277
|
/** @param {AliasRegistry} aliases */
|
|
252
278
|
async saveAliases(aliases) {
|
|
253
|
-
await this._adapter.set(NS,
|
|
279
|
+
await this._adapter.set(NS, ALIASES_RECORD, aliases.encode());
|
|
254
280
|
}
|
|
255
281
|
|
|
256
282
|
// -- ledger --------------------------------------------------------------
|
|
257
283
|
|
|
258
284
|
/**
|
|
259
|
-
*
|
|
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>>}
|
|
260
289
|
*/
|
|
261
290
|
async loadLedger() {
|
|
262
|
-
const bytes = await this._adapter.get(NS,
|
|
263
|
-
/** @type {Map<string,
|
|
291
|
+
const bytes = await this._adapter.get(NS, LEDGER_RECORD);
|
|
292
|
+
/** @type {Map<string, LedgerRow>} */
|
|
264
293
|
const ledger = new Map();
|
|
265
294
|
if (bytes) {
|
|
266
295
|
const obj = MsgPack.decode(bytes);
|
|
267
296
|
if (obj && typeof obj === "object" && !Array.isArray(obj)) {
|
|
268
|
-
for (const [k, v] of Object.entries(obj))
|
|
297
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
298
|
+
ledger.set(k, _decodeLedgerRow(v));
|
|
299
|
+
}
|
|
269
300
|
}
|
|
270
301
|
}
|
|
271
302
|
return ledger;
|
|
272
303
|
}
|
|
273
304
|
|
|
274
|
-
/** @param {Map<string,
|
|
305
|
+
/** @param {Map<string, LedgerRow>} ledger */
|
|
275
306
|
async saveLedger(ledger) {
|
|
276
|
-
const obj =
|
|
277
|
-
|
|
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));
|
|
278
310
|
}
|
|
279
311
|
|
|
280
312
|
// -- issuer identity cache (work doc #5) ---------------------------------
|
|
@@ -282,21 +314,26 @@ export class DacarStore {
|
|
|
282
314
|
/**
|
|
283
315
|
* Load the persisted issuer identity cache. Returns an empty {@link Keyring}
|
|
284
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.
|
|
285
321
|
* @returns {Promise<Keyring>}
|
|
286
322
|
*/
|
|
287
323
|
async loadKeyring() {
|
|
288
324
|
const keyring = new Keyring();
|
|
289
|
-
const bytes = await this._adapter.get(NS,
|
|
325
|
+
const bytes = await this._adapter.get(NS, IDENTITIES_RECORD);
|
|
290
326
|
if (bytes) {
|
|
291
327
|
const obj = MsgPack.decode(bytes);
|
|
292
328
|
if (obj && typeof obj === "object" && !Array.isArray(obj)) {
|
|
293
329
|
for (const [hashHex, pubKey] of Object.entries(obj)) {
|
|
294
|
-
if (pubKey instanceof Uint8Array
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
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
|
|
300
337
|
}
|
|
301
338
|
}
|
|
302
339
|
}
|
|
@@ -309,10 +346,11 @@ export class DacarStore {
|
|
|
309
346
|
const obj = {};
|
|
310
347
|
for (const [hashHex, keyset] of keyring.entries()) {
|
|
311
348
|
if (keyset.threshold === 1 && keyset.memberPublicKeys.length === 1) {
|
|
312
|
-
|
|
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);
|
|
313
351
|
}
|
|
314
352
|
}
|
|
315
|
-
await this._adapter.set(NS,
|
|
353
|
+
await this._adapter.set(NS, IDENTITIES_RECORD, MsgPack.encode(obj));
|
|
316
354
|
}
|
|
317
355
|
|
|
318
356
|
/**
|
|
@@ -325,11 +363,79 @@ export class DacarStore {
|
|
|
325
363
|
if (own) keyring.registerSingle(own.identityHash, await own.getPublicKey());
|
|
326
364
|
return keyring;
|
|
327
365
|
}
|
|
366
|
+
|
|
367
|
+
// -- outbox (work doc #8) -----------------------------------------------
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* Load the outbox of locally-issued, not-yet-published signed Delta
|
|
371
|
+
* payloads, in the order they were issued. Returns an empty array when no
|
|
372
|
+
* outbox record exists yet.
|
|
373
|
+
* @returns {Promise<Uint8Array[]>}
|
|
374
|
+
*/
|
|
375
|
+
async loadOutbox() {
|
|
376
|
+
const bytes = await this._adapter.get(NS, OUTBOX_RECORD);
|
|
377
|
+
if (!bytes) return [];
|
|
378
|
+
let obj;
|
|
379
|
+
try {
|
|
380
|
+
obj = MsgPack.decode(bytes);
|
|
381
|
+
} catch {
|
|
382
|
+
return []; // corrupted -> treat as empty (do not crash the CLI)
|
|
383
|
+
}
|
|
384
|
+
if (!Array.isArray(obj)) return [];
|
|
385
|
+
return obj.filter((p) => p instanceof Uint8Array).map((p) => new Uint8Array(p));
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* Persist the outbox as a MessagePack array of signed Delta payloads.
|
|
390
|
+
* @param {Uint8Array[]} payloads
|
|
391
|
+
*/
|
|
392
|
+
async saveOutbox(payloads) {
|
|
393
|
+
await this._adapter.set(
|
|
394
|
+
NS,
|
|
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,
|
|
430
|
+
MsgPack.encode(payloads.map((p) => new Uint8Array(p))),
|
|
431
|
+
);
|
|
432
|
+
}
|
|
328
433
|
}
|
|
329
434
|
|
|
330
435
|
/**
|
|
331
436
|
* In-memory alias registry: `hash → names[]` with an optional note. Mirrors
|
|
332
|
-
* 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.
|
|
333
439
|
*/
|
|
334
440
|
export class AliasRegistry {
|
|
335
441
|
/** @param {AliasEntry[]} [entries] */
|
|
@@ -337,118 +443,329 @@ export class AliasRegistry {
|
|
|
337
443
|
/** @type {AliasEntry[]} */ this.entries = entries;
|
|
338
444
|
}
|
|
339
445
|
|
|
340
|
-
/**
|
|
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
|
+
*/
|
|
341
452
|
static decode(bytes) {
|
|
342
|
-
const
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
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
|
+
}
|
|
351
487
|
}
|
|
352
|
-
return
|
|
488
|
+
return registry;
|
|
353
489
|
}
|
|
354
490
|
|
|
355
|
-
/** @returns {Uint8Array} */
|
|
491
|
+
/** @returns {Uint8Array} rnns text bytes (`hash name [# note]` per line). */
|
|
356
492
|
encode() {
|
|
357
|
-
|
|
493
|
+
if (this.entries.length === 0) return new Uint8Array(0);
|
|
494
|
+
const lines = [];
|
|
495
|
+
for (const e of this.entries) {
|
|
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);
|
|
500
|
+
}
|
|
501
|
+
return new TextEncoder().encode(lines.join("\n") + "\n");
|
|
358
502
|
}
|
|
359
503
|
|
|
360
|
-
/** @param {
|
|
361
|
-
|
|
504
|
+
/** @param {Uint8Array} hash @returns {AliasEntry | undefined} */
|
|
505
|
+
_entryFor(hash) {
|
|
506
|
+
return this.entries.find((e) => _bytesEqual(e.hash, hash));
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/**
|
|
510
|
+
* @param {string} name
|
|
511
|
+
* @param {Uint8Array} hash
|
|
512
|
+
* @param {string} [note]
|
|
513
|
+
*/
|
|
514
|
+
add(name, hash, note) {
|
|
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 });
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
|
|
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
|
+
*/
|
|
530
|
+
setSelf(hash) {
|
|
362
531
|
for (const e of this.entries) {
|
|
363
|
-
|
|
532
|
+
e.names = e.names.filter((n) => n !== SELF_ALIAS);
|
|
364
533
|
}
|
|
365
|
-
|
|
534
|
+
this.entries = this.entries.filter((e) => e.names.length > 0);
|
|
535
|
+
this.add(SELF_ALIAS, hash);
|
|
366
536
|
}
|
|
367
537
|
|
|
368
|
-
/**
|
|
369
|
-
|
|
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) {
|
|
370
545
|
for (const e of this.entries) {
|
|
371
|
-
|
|
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
|
+
}
|
|
372
554
|
}
|
|
373
|
-
return
|
|
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 ?? [];
|
|
374
567
|
}
|
|
375
568
|
|
|
376
569
|
/** @param {Uint8Array} hash @returns {string | null} */
|
|
377
570
|
primaryName(hash) {
|
|
378
571
|
const names = this.namesFor(hash);
|
|
379
|
-
return names[0]
|
|
572
|
+
return names.length > 0 ? names[0] : null;
|
|
380
573
|
}
|
|
574
|
+
}
|
|
381
575
|
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
576
|
+
// =========================================================================
|
|
577
|
+
// Helpers — record encode/decode (Python-canonical bytes) + small utilities
|
|
578
|
+
// =========================================================================
|
|
579
|
+
|
|
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.
|
|
616
|
+
* @param {Uint8Array} bytes
|
|
617
|
+
* @returns {StoreConfig}
|
|
618
|
+
*/
|
|
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));
|
|
392
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
|
+
}
|
|
393
646
|
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
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;
|
|
399
667
|
}
|
|
400
|
-
|
|
401
|
-
|
|
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);
|
|
402
674
|
}
|
|
675
|
+
return sections;
|
|
403
676
|
}
|
|
404
677
|
|
|
405
|
-
|
|
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
|
+
}
|
|
406
691
|
|
|
407
692
|
/**
|
|
408
|
-
* @param {
|
|
409
|
-
* @returns {
|
|
693
|
+
* @param {unknown} v
|
|
694
|
+
* @returns {LedgerRow}
|
|
410
695
|
*/
|
|
411
|
-
function
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
throw new Error("config record must be a 7-element MessagePack array");
|
|
696
|
+
function _decodeLedgerRow(v) {
|
|
697
|
+
if (!v || typeof v !== "object" || Array.isArray(v)) {
|
|
698
|
+
return { object: null, relation: null, wildcard: null, firstSeen: 0 };
|
|
415
699
|
}
|
|
416
|
-
const
|
|
700
|
+
const o = /** @type {Record<string, unknown>} */ (v);
|
|
417
701
|
return {
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
horizonDays: Number(horizonDays),
|
|
423
|
-
rfedTopic: String(rfedTopic),
|
|
424
|
-
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,
|
|
425
706
|
};
|
|
426
707
|
}
|
|
427
708
|
|
|
428
709
|
/**
|
|
429
|
-
* @param {
|
|
430
|
-
* @
|
|
431
|
-
|
|
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
|
|
432
737
|
* @returns {Uint8Array}
|
|
433
738
|
*/
|
|
434
|
-
function
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
return value;
|
|
739
|
+
function _padToRnsPub(ed25519Pub) {
|
|
740
|
+
const padded = new Uint8Array(64);
|
|
741
|
+
padded.set(ed25519Pub, 32);
|
|
742
|
+
return padded;
|
|
439
743
|
}
|
|
440
744
|
|
|
441
|
-
/**
|
|
745
|
+
/**
|
|
746
|
+
* @param {Uint8Array} a
|
|
747
|
+
* @param {Uint8Array} b
|
|
748
|
+
* @returns {boolean}
|
|
749
|
+
*/
|
|
442
750
|
function _bytesEqual(a, b) {
|
|
443
751
|
if (a.length !== b.length) return false;
|
|
444
|
-
let
|
|
445
|
-
|
|
446
|
-
|
|
752
|
+
for (let i = 0; i < a.length; i++) {
|
|
753
|
+
if (a[i] !== b[i]) return false;
|
|
754
|
+
}
|
|
755
|
+
return true;
|
|
447
756
|
}
|
|
448
757
|
|
|
449
|
-
/**
|
|
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
|
+
*/
|
|
450
764
|
function _hexToBytes(hex) {
|
|
451
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
|
+
}
|
|
452
769
|
const out = new Uint8Array(clean.length / 2);
|
|
453
770
|
for (let i = 0; i < out.length; i++) {
|
|
454
771
|
out[i] = parseInt(clean.slice(i * 2, i * 2 + 2), 16);
|
|
@@ -456,9 +773,12 @@ function _hexToBytes(hex) {
|
|
|
456
773
|
return out;
|
|
457
774
|
}
|
|
458
775
|
|
|
459
|
-
/**
|
|
460
|
-
|
|
461
|
-
|
|
776
|
+
/**
|
|
777
|
+
* @param {number} len
|
|
778
|
+
* @returns {Uint8Array}
|
|
779
|
+
*/
|
|
780
|
+
function _randomBytes(len) {
|
|
781
|
+
const out = new Uint8Array(len);
|
|
462
782
|
crypto.getRandomValues(out);
|
|
463
783
|
return out;
|
|
464
784
|
}
|