@reticulum/dacar 1.0.0 → 1.1.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 +8 -2
- package/src/cli/dacar.js +565 -0
- package/src/cli/session.js +140 -0
- package/src/cli/smoke.js +59 -0
- package/src/cli/store.js +456 -0
- package/src/hlc.js +33 -0
- package/src/verifier.js +27 -0
package/package.json
CHANGED
|
@@ -1,14 +1,19 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@reticulum/dacar",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "JavaScript implementation of Dacar, a Decentralized Access Control system for Reticulum",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
|
7
7
|
"exports": {
|
|
8
8
|
".": "./src/index.js",
|
|
9
9
|
"./transport": "./src/transport/index.js",
|
|
10
|
+
"./cli/session": "./src/cli/session.js",
|
|
11
|
+
"./cli/store": "./src/cli/store.js",
|
|
10
12
|
"./package.json": "./package.json"
|
|
11
13
|
},
|
|
14
|
+
"bin": {
|
|
15
|
+
"dacar": "src/cli/dacar.js"
|
|
16
|
+
},
|
|
12
17
|
"files": [
|
|
13
18
|
"src",
|
|
14
19
|
"README.md"
|
|
@@ -39,7 +44,8 @@
|
|
|
39
44
|
"node": ">=20"
|
|
40
45
|
},
|
|
41
46
|
"dependencies": {
|
|
42
|
-
"@reticulum/core": "^0.5.3"
|
|
47
|
+
"@reticulum/core": "^0.5.3",
|
|
48
|
+
"@reticulum/node": "^0.6.1"
|
|
43
49
|
},
|
|
44
50
|
"publishConfig": {
|
|
45
51
|
"access": "public"
|
package/src/cli/dacar.js
ADDED
|
@@ -0,0 +1,565 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* `dacar` — offline-first CLI for managing Dacar authorization grants (work doc #6).
|
|
4
|
+
*
|
|
5
|
+
* Node/Deno-only. Declared in `package.json` `bin` and **excluded from the
|
|
6
|
+
* browser `exports` map** so it never bloats a browser bundle. Composes the
|
|
7
|
+
* portable {@link module:cli/session} + {@link module:cli/store} helpers with
|
|
8
|
+
* `@reticulum/node`'s interfaces and `FileStorageAdapter`.
|
|
9
|
+
*
|
|
10
|
+
* Mirrors Python's `dacar/cli/__init__.py` + `commands.py`. Offline commands
|
|
11
|
+
* never start RNS; online commands (`grant --publish`, `sync`) boot RNS, announce
|
|
12
|
+
* the node identity, publish/pull, then exit — the one-shot, daemon-free model.
|
|
13
|
+
*
|
|
14
|
+
* Usage:
|
|
15
|
+
* dacar init
|
|
16
|
+
* dacar grant <grantee> <relation> <object> [--publish]
|
|
17
|
+
* dacar sync
|
|
18
|
+
* dacar check <grantee> <relation> <object>
|
|
19
|
+
* dacar grants
|
|
20
|
+
* dacar revoke <grantee> <relation> <object> [--publish]
|
|
21
|
+
* dacar identity remember|forget|list ...
|
|
22
|
+
*
|
|
23
|
+
* Online flags: --node <hash>, --topic <topic>, --interface shared|auto|tcp,
|
|
24
|
+
* --rns-dir <path> (default: ~/.reticulum or $DACAR_RNS_DIR).
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { parseArgs } from "node:util";
|
|
28
|
+
import { readFile, writeFile, mkdir } from "node:fs/promises";
|
|
29
|
+
import { homedir } from "node:os";
|
|
30
|
+
import { join } from "node:path";
|
|
31
|
+
import process from "node:process";
|
|
32
|
+
|
|
33
|
+
import { Identity, Reticulum, toHex } from "@reticulum/core";
|
|
34
|
+
import { MemoryStorageAdapter } from "@reticulum/core";
|
|
35
|
+
import {
|
|
36
|
+
AutoInterface,
|
|
37
|
+
FileStorageAdapter,
|
|
38
|
+
LocalClientInterface,
|
|
39
|
+
TCPClientInterface,
|
|
40
|
+
} from "@reticulum/node";
|
|
41
|
+
import { RFedClient } from "@reticulum/core/src/rfed/client.js";
|
|
42
|
+
|
|
43
|
+
import { Action, Operation, Tuple, Engine } from "../index.js";
|
|
44
|
+
import { DeltaReceiver } from "../delta.js";
|
|
45
|
+
import { RnsIdentityResolver } from "../transport/rnsIdentity.js";
|
|
46
|
+
import { RFED_TOPIC, APP_NAME } from "../naming.js";
|
|
47
|
+
import { NamespaceHasher, DEFAULT_SALT, SALT_SIZE, HASH_SIZE } from "../namespace.js";
|
|
48
|
+
import { Keyring, IssuerKeyset } from "../verifier.js";
|
|
49
|
+
|
|
50
|
+
import { DacarStore, SELF_ALIAS, AliasRegistry } from "./store.js";
|
|
51
|
+
import { announceIdentity, runPublish, runSync, registerAnnounceHandler } from "./session.js";
|
|
52
|
+
|
|
53
|
+
const SHORT_HASH = 7;
|
|
54
|
+
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
// Output helpers
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
|
|
59
|
+
function err(msg) {
|
|
60
|
+
process.stderr.write(msg + "\n");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function shortHash(hash, full = false) {
|
|
64
|
+
const hex = toHex(hash);
|
|
65
|
+
return full ? hex : hex.slice(0, SHORT_HASH) + "…";
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function out(msg) {
|
|
69
|
+
process.stdout.write(msg + "\n");
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
class CliError extends Error {}
|
|
73
|
+
|
|
74
|
+
// ---------------------------------------------------------------------------
|
|
75
|
+
// Store + RNS resolution
|
|
76
|
+
// ---------------------------------------------------------------------------
|
|
77
|
+
|
|
78
|
+
function defaultStorePath() {
|
|
79
|
+
return process.env.DACAR_HOME || join(homedir(), ".dacar");
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function openStore(args) {
|
|
83
|
+
const path = args.store || defaultStorePath();
|
|
84
|
+
const adapter = new FileStorageAdapter(path);
|
|
85
|
+
return new DacarStore(adapter, { identityBytes: args.identity ? await readFile(args.identity) : null });
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function resolveIdentityHash(value, aliases) {
|
|
89
|
+
const fromAlias = aliases.resolve(value);
|
|
90
|
+
if (fromAlias) return fromAlias;
|
|
91
|
+
const clean = value.toLowerCase().replace(/^0x/, "");
|
|
92
|
+
const raw = hexToBytes(clean);
|
|
93
|
+
if (raw.length !== 16) {
|
|
94
|
+
throw new CliError(`unknown identity ${JSON.stringify(value)} (not a known alias or 16-byte hex hash)`);
|
|
95
|
+
}
|
|
96
|
+
return raw;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function hexToBytes(hex) {
|
|
100
|
+
const clean = hex.startsWith("0x") ? hex.slice(2) : hex;
|
|
101
|
+
const out = new Uint8Array(Math.floor(clean.length / 2));
|
|
102
|
+
for (let i = 0; i < out.length; i++) {
|
|
103
|
+
out[i] = parseInt(clean.slice(i * 2, i * 2 + 2), 16);
|
|
104
|
+
}
|
|
105
|
+
return out;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function resolveRnsConfigDir(args) {
|
|
109
|
+
const explicit = args.rnsDir ?? process.env.DACAR_RNS_DIR;
|
|
110
|
+
if (explicit) return explicit;
|
|
111
|
+
const user = join(homedir(), ".reticulum");
|
|
112
|
+
try {
|
|
113
|
+
await readFile(join(user, "config"));
|
|
114
|
+
return user;
|
|
115
|
+
} catch {
|
|
116
|
+
// fall through to store-local default
|
|
117
|
+
}
|
|
118
|
+
const storePath = args.store || defaultStorePath();
|
|
119
|
+
const dir = join(storePath, "rns");
|
|
120
|
+
await mkdir(dir, { recursive: true });
|
|
121
|
+
const cfgPath = join(dir, "config");
|
|
122
|
+
try {
|
|
123
|
+
await readFile(cfgPath);
|
|
124
|
+
} catch {
|
|
125
|
+
await writeFile(
|
|
126
|
+
cfgPath,
|
|
127
|
+
"[reticulum]\n share_instance = Yes\n enable_transport = False\n\n" +
|
|
128
|
+
"[interfaces]\n [[Default interface]]\n type = AutoInterface\n enabled = Yes\n",
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
return dir;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Boot RNS with an interface. Mirrors `@reticulum/node`'s `rfed` CLI:
|
|
136
|
+
* `--interface shared|auto|tcp` (default `shared`).
|
|
137
|
+
*/
|
|
138
|
+
async function bootRns(configDir, iface) {
|
|
139
|
+
const rns = new Reticulum({ storageAdapter: new FileStorageAdapter(configDir) });
|
|
140
|
+
if (iface === "auto") {
|
|
141
|
+
rns.addInterface(new AutoInterface({}));
|
|
142
|
+
} else if (iface === "tcp") {
|
|
143
|
+
const host = process.env.RNS_HOST || "127.0.0.1";
|
|
144
|
+
const port = parseInt(process.env.RNS_PORT || "42424", 10);
|
|
145
|
+
rns.addInterface(new TCPClientInterface({ host, port }));
|
|
146
|
+
} else {
|
|
147
|
+
// shared (default): attach to a running rnsd, else no-op (standalone).
|
|
148
|
+
rns.addInterface(new LocalClientInterface({}));
|
|
149
|
+
}
|
|
150
|
+
return rns;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async function resolveRfedNode(args, store, aliases) {
|
|
154
|
+
if (args.node) return resolveIdentityHash(args.node, aliases);
|
|
155
|
+
const raw = await store.loadConfig();
|
|
156
|
+
if (raw.rfedNode) return raw.rfedNode;
|
|
157
|
+
throw new CliError("no rfed node configured (use --node <hash> or set [rfed] node in config)");
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async function resolveTopic(args, store) {
|
|
161
|
+
if (args.topic) return args.topic;
|
|
162
|
+
const raw = await store.loadConfig();
|
|
163
|
+
return raw.rfedTopic || RFED_TOPIC;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// ---------------------------------------------------------------------------
|
|
167
|
+
// Commands
|
|
168
|
+
// ---------------------------------------------------------------------------
|
|
169
|
+
|
|
170
|
+
async function cmdInit(args) {
|
|
171
|
+
const path = args.store || defaultStorePath();
|
|
172
|
+
await mkdir(path, { recursive: true });
|
|
173
|
+
const adapter = new FileStorageAdapter(path);
|
|
174
|
+
const store = await DacarStore.init(adapter, {
|
|
175
|
+
salt: args.salt ? hexToBytes(args.salt) : undefined,
|
|
176
|
+
horizonDays: parseInt(args.horizon || "180", 10),
|
|
177
|
+
identityBytes: args.identity ? await readFile(args.identity) : undefined,
|
|
178
|
+
});
|
|
179
|
+
const identity = await store.loadIdentity();
|
|
180
|
+
const aliases = await store.loadAliases();
|
|
181
|
+
err("✔ initialized store at " + path);
|
|
182
|
+
err(" identity : " + shortHash(identity.identityHash, args.fullHashes));
|
|
183
|
+
err(" anchor : " + shortHash(identity.identityHash, args.fullHashes) + " (self)");
|
|
184
|
+
return 0;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async function cmdConfigShow(args) {
|
|
188
|
+
const store = await openStore(args);
|
|
189
|
+
const raw = await store.loadConfig();
|
|
190
|
+
const aliases = await store.loadAliases();
|
|
191
|
+
err("store: " + (args.store || defaultStorePath()));
|
|
192
|
+
err("[salt]");
|
|
193
|
+
err(" primary : " + (args.reveal ? toHex(raw.primarySalt) : "<masked (use --reveal)>"));
|
|
194
|
+
err("[trust]");
|
|
195
|
+
for (const a of raw.anchors) err(" anchor : " + shortHash(a, args.fullHashes));
|
|
196
|
+
err("[policy]");
|
|
197
|
+
err(" deletion_horizon_days : " + raw.horizonDays);
|
|
198
|
+
err("[rfed]");
|
|
199
|
+
err(" topic : " + raw.rfedTopic);
|
|
200
|
+
err(" node : " + (raw.rfedNode ? shortHash(raw.rfedNode, args.fullHashes) : "(not set)"));
|
|
201
|
+
return 0;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
async function cmdGrant(args) {
|
|
205
|
+
return _issue(args, Action.GRANT);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
async function cmdRevoke(args) {
|
|
209
|
+
return _issue(args, Action.REVOKE);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
async function _issue(args, action) {
|
|
213
|
+
const store = await openStore(args);
|
|
214
|
+
const config = await store.loadConfigValidated();
|
|
215
|
+
const aliases = await store.loadAliases();
|
|
216
|
+
const identity = await store.loadIdentity();
|
|
217
|
+
if (!identity) throw new CliError("no signing identity (run `dacar init`)");
|
|
218
|
+
|
|
219
|
+
const grantee = resolveIdentityHash(args.grantee, aliases);
|
|
220
|
+
const hasher = config.primaryHasher;
|
|
221
|
+
const tuple = await Tuple.fromPlaintext({
|
|
222
|
+
objectId: args.object, relation: args.relation, grantee, issuer: identity.identityHash, hasher,
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
const clock = await store.loadClock();
|
|
226
|
+
const hlc = clock.now();
|
|
227
|
+
await store.saveClock(clock);
|
|
228
|
+
|
|
229
|
+
const op = await new Operation({ tuple, action, hlc }).sign(identity);
|
|
230
|
+
const payload = op.toPayload();
|
|
231
|
+
|
|
232
|
+
const state = await store.loadState(config);
|
|
233
|
+
state.apply(op);
|
|
234
|
+
await store.saveState(state);
|
|
235
|
+
|
|
236
|
+
// Record plaintext ledger.
|
|
237
|
+
const ledger = await store.loadLedger();
|
|
238
|
+
ledger.set(toHex(tuple.key), { object: args.object, relation: args.relation, wildcard: args.object.endsWith("*") && args.object !== "*", firstSeen: Number(hlc >> 16n) });
|
|
239
|
+
await store.saveLedger(ledger);
|
|
240
|
+
|
|
241
|
+
out(payload.hex());
|
|
242
|
+
err(`✔ ${action === Action.GRANT ? "granted" : "revoked"} ${shortHash(grantee, args.fullHashes)} ${args.relation} on ${args.object}`);
|
|
243
|
+
err(` hlc : 0x${hlc.toString(16)}`);
|
|
244
|
+
err(` payload : hex on stdout (${payload.length} bytes)`);
|
|
245
|
+
|
|
246
|
+
if (args.publish) {
|
|
247
|
+
await publishDelta(args, store, identity, payload);
|
|
248
|
+
}
|
|
249
|
+
return 0;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
async function publishDelta(args, store, identity, payload) {
|
|
253
|
+
const aliases = await store.loadAliases();
|
|
254
|
+
const nodeHash = await resolveRfedNode(args, store, aliases);
|
|
255
|
+
const topic = await resolveTopic(args, store);
|
|
256
|
+
|
|
257
|
+
const configDir = await resolveRnsConfigDir(args);
|
|
258
|
+
const rns = await bootRns(configDir, args.interface || "shared");
|
|
259
|
+
await announceIdentity(identity);
|
|
260
|
+
|
|
261
|
+
// Durable issuer cache (doc #5): seed from observed dacar.node announces.
|
|
262
|
+
const keyring = await store.loadKeyring();
|
|
263
|
+
keyring.registerSingle(identity.identityHash, await identity.getPublicKey());
|
|
264
|
+
await registerAnnounceHandler({ rns, keyring, onSave: (kr) => store.saveKeyring(kr) });
|
|
265
|
+
|
|
266
|
+
const client = new RFedClient({ identity, rns });
|
|
267
|
+
await runPublish({ deltaPayload: payload, nodeHash, topic, client });
|
|
268
|
+
await store.saveKeyring(keyring);
|
|
269
|
+
err(` published to rfed channel ${JSON.stringify(topic)} via ${shortHash(nodeHash, args.fullHashes)}`);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
async function cmdSync(args) {
|
|
273
|
+
const store = await openStore(args);
|
|
274
|
+
const config = await store.loadConfigValidated();
|
|
275
|
+
const aliases = await store.loadAliases();
|
|
276
|
+
const identity = await store.loadIdentity();
|
|
277
|
+
if (!identity) throw new CliError("no signing identity (run `dacar init`)");
|
|
278
|
+
|
|
279
|
+
const nodeHash = await resolveRfedNode(args, store, aliases);
|
|
280
|
+
const topic = await resolveTopic(args, store);
|
|
281
|
+
|
|
282
|
+
const configDir = await resolveRnsConfigDir(args);
|
|
283
|
+
const rns = await bootRns(configDir, args.interface || "shared");
|
|
284
|
+
await announceIdentity(identity);
|
|
285
|
+
|
|
286
|
+
// Durable issuer cache (doc #5): load persisted keyring + announce handler.
|
|
287
|
+
const keyring = await store.loadKeyring();
|
|
288
|
+
keyring.registerSingle(identity.identityHash, await identity.getPublicKey());
|
|
289
|
+
await registerAnnounceHandler({ rns, keyring, onSave: (kr) => store.saveKeyring(kr) });
|
|
290
|
+
|
|
291
|
+
const state = await store.loadState(config);
|
|
292
|
+
const resolver = new RnsIdentityResolver(keyring);
|
|
293
|
+
const rx = new DeltaReceiver(state, resolver);
|
|
294
|
+
|
|
295
|
+
const client = new RFedClient({ identity, rns });
|
|
296
|
+
const applied = await runSync({ nodeHash, topic, client, receiver: rx });
|
|
297
|
+
await store.saveState(state);
|
|
298
|
+
await store.saveKeyring(keyring);
|
|
299
|
+
|
|
300
|
+
err(`✔ synced: applied ${applied} delta(s) from rfed channel ${JSON.stringify(topic)}`);
|
|
301
|
+
return 0;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
async function cmdCheck(args) {
|
|
305
|
+
const store = await openStore(args);
|
|
306
|
+
const config = await store.loadConfigValidated();
|
|
307
|
+
const state = await store.loadState(config);
|
|
308
|
+
const aliases = await store.loadAliases();
|
|
309
|
+
const engine = new Engine(config, state);
|
|
310
|
+
const grantee = resolveIdentityHash(args.grantee, aliases);
|
|
311
|
+
const allowed = await engine.evaluate(args.object, args.relation, grantee);
|
|
312
|
+
const mark = allowed ? "✔" : "✘";
|
|
313
|
+
err(`${mark} ${allowed ? "ALLOW" : "DENY"} ${shortHash(grantee, args.fullHashes)} ${args.relation} ${args.object}`);
|
|
314
|
+
return allowed ? 0 : 1;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
async function cmdApply(args) {
|
|
318
|
+
const store = await openStore(args);
|
|
319
|
+
const config = await store.loadConfigValidated();
|
|
320
|
+
const state = await store.loadState(config);
|
|
321
|
+
const keyring = await store.keyringForVerify();
|
|
322
|
+
const rx = new DeltaReceiver(state, keyring);
|
|
323
|
+
const data = args.payload === "-"
|
|
324
|
+
? new Uint8Array(await readStdin())
|
|
325
|
+
: await readFile(args.payload);
|
|
326
|
+
const applied = await rx.applyPayload(data);
|
|
327
|
+
if (applied) {
|
|
328
|
+
await store.saveState(state);
|
|
329
|
+
err(`✔ applied 1 delta`);
|
|
330
|
+
return 0;
|
|
331
|
+
}
|
|
332
|
+
err("✘ delta rejected (unknown issuer, bad signature, stale §9, or malformed)");
|
|
333
|
+
return 1;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
async function readStdin() {
|
|
337
|
+
const chunks = [];
|
|
338
|
+
for await (const chunk of process.stdin) chunks.push(chunk);
|
|
339
|
+
return Buffer.concat(chunks);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// ---------------------------------------------------------------------------
|
|
343
|
+
// identity remember / forget / list (work doc #5)
|
|
344
|
+
// ---------------------------------------------------------------------------
|
|
345
|
+
|
|
346
|
+
async function cmdIdentityRemember(args) {
|
|
347
|
+
const store = await openStore(args);
|
|
348
|
+
const aliases = await store.loadAliases();
|
|
349
|
+
const issuerHash = resolveIdentityHash(args.hash, aliases);
|
|
350
|
+
|
|
351
|
+
let pubKey;
|
|
352
|
+
if (args.pubkey) {
|
|
353
|
+
pubKey = hexToBytes(args.pubkey);
|
|
354
|
+
if (pubKey.length !== 64) throw new CliError(`--pubkey must be 64 bytes (128 hex), got ${pubKey.length}`);
|
|
355
|
+
} else if (args.file) {
|
|
356
|
+
pubKey = await readFile(args.file);
|
|
357
|
+
if (pubKey.length !== 64) throw new CliError(`pubkey file must contain 64 bytes, got ${pubKey.length}`);
|
|
358
|
+
} else {
|
|
359
|
+
// Boot RNS and try to recall.
|
|
360
|
+
const configDir = await resolveRnsConfigDir(args);
|
|
361
|
+
const rns = await bootRns(configDir, args.interface || "shared");
|
|
362
|
+
const { Destination } = await import("@reticulum/core");
|
|
363
|
+
const recalled = await Destination.recall(issuerHash, true);
|
|
364
|
+
if (!recalled) {
|
|
365
|
+
throw new CliError(
|
|
366
|
+
`could not recall ${shortHash(issuerHash, args.fullHashes)} from RNS; use --pubkey <hex> or --file <path>`,
|
|
367
|
+
);
|
|
368
|
+
}
|
|
369
|
+
pubKey = await recalled.getPublicKey();
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
const keyring = await store.loadKeyring();
|
|
373
|
+
keyring.registerSingle(issuerHash, pubKey);
|
|
374
|
+
await store.saveKeyring(keyring);
|
|
375
|
+
err(`✔ remembered issuer ${shortHash(issuerHash, args.fullHashes)}`);
|
|
376
|
+
err(` pubkey : ${toHex(pubKey).slice(0, SHORT_HASH)}…`);
|
|
377
|
+
err(` cache : ${keyring.size} entries`);
|
|
378
|
+
return 0;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
async function cmdIdentityForget(args) {
|
|
382
|
+
const store = await openStore(args);
|
|
383
|
+
const aliases = await store.loadAliases();
|
|
384
|
+
const issuerHash = resolveIdentityHash(args.hash, aliases);
|
|
385
|
+
|
|
386
|
+
if (!args.force) {
|
|
387
|
+
// Refuse to purge an issuer with active grants in the live CRDT.
|
|
388
|
+
const config = await store.loadConfigValidated();
|
|
389
|
+
const state = await store.loadState(config);
|
|
390
|
+
let active = 0;
|
|
391
|
+
for (const tuple of state.activeTuples()) {
|
|
392
|
+
if (toHex(tuple.issuer) === toHex(issuerHash)) active++;
|
|
393
|
+
}
|
|
394
|
+
if (active > 0) {
|
|
395
|
+
throw new CliError(
|
|
396
|
+
`issuer ${shortHash(issuerHash, args.fullHashes)} has ${active} active grant(s) in the live CRDT; ` +
|
|
397
|
+
"forgetting it would make its revokes unverifiable (use --force to override)",
|
|
398
|
+
);
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
const keyring = await store.loadKeyring();
|
|
403
|
+
if (!keyring.forget(issuerHash)) {
|
|
404
|
+
throw new CliError(`issuer ${shortHash(issuerHash, args.fullHashes)} not in the cache`);
|
|
405
|
+
}
|
|
406
|
+
await store.saveKeyring(keyring);
|
|
407
|
+
err(`✔ forgot issuer ${shortHash(issuerHash, args.fullHashes)}`);
|
|
408
|
+
err(` cache : ${keyring.size} entries`);
|
|
409
|
+
return 0;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
async function cmdIdentityList(args) {
|
|
413
|
+
const store = await openStore(args);
|
|
414
|
+
const aliases = await store.loadAliases();
|
|
415
|
+
const keyring = await store.loadKeyring();
|
|
416
|
+
err(`ISSUER IDENTITY CACHE (${keyring.size})`);
|
|
417
|
+
if (keyring.size === 0) {
|
|
418
|
+
err("(none — use `dacar identity remember <hash>` to seed)");
|
|
419
|
+
return 0;
|
|
420
|
+
}
|
|
421
|
+
for (const [hashHex, keyset] of keyring.entries()) {
|
|
422
|
+
const pub = keyset.memberPublicKeys[0];
|
|
423
|
+
err(` ${shortHash(hexToBytes(hashHex), args.fullHashes)} pubkey=${toHex(pub).slice(0, SHORT_HASH)}…`);
|
|
424
|
+
}
|
|
425
|
+
return 0;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
async function cmdGrants(args) {
|
|
429
|
+
const store = await openStore(args);
|
|
430
|
+
const config = await store.loadConfigValidated();
|
|
431
|
+
const state = await store.loadState(config);
|
|
432
|
+
const aliases = await store.loadAliases();
|
|
433
|
+
const ledger = await store.loadLedger();
|
|
434
|
+
/** @type {any[]} */ const rows = [];
|
|
435
|
+
for (const entry of state._entries.values()) {
|
|
436
|
+
const active = entry.addTs !== null && (entry.removeTs === null || entry.addTs > entry.removeTs);
|
|
437
|
+
if (args.revoked && active) continue;
|
|
438
|
+
if (!args.all && !args.revoked && !active) continue;
|
|
439
|
+
rows.push({ entry, active });
|
|
440
|
+
}
|
|
441
|
+
const label = args.revoked ? "REVOKED TOMBSTONES" : args.all ? "ALL TUPLES" : "ACTIVE GRANTS";
|
|
442
|
+
err(`${label} (${rows.length})`);
|
|
443
|
+
for (const { entry, active } of rows) {
|
|
444
|
+
const t = entry.tuple;
|
|
445
|
+
const row = ledger.get(toHex(t.key));
|
|
446
|
+
const rel = row?.relation || `[${shortHash(t.relationHash, args.fullHashes)}]`;
|
|
447
|
+
const obj = row?.object || "[hash]";
|
|
448
|
+
err(
|
|
449
|
+
`${shortHash(t.grantee, args.fullHashes)} ${rel} ${obj} ← ${shortHash(t.issuer, args.fullHashes)} ` +
|
|
450
|
+
`${active ? "active" : "revoked"}`,
|
|
451
|
+
);
|
|
452
|
+
}
|
|
453
|
+
return 0;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// ---------------------------------------------------------------------------
|
|
457
|
+
// Dispatch
|
|
458
|
+
// ---------------------------------------------------------------------------
|
|
459
|
+
|
|
460
|
+
const SUBCOMMANDS = {
|
|
461
|
+
init: { run: cmdInit, opts: { salt: "string", horizon: "string" }, online: false },
|
|
462
|
+
"config": {
|
|
463
|
+
sub: {
|
|
464
|
+
show: { run: cmdConfigShow, opts: { reveal: "boolean" }, online: false },
|
|
465
|
+
},
|
|
466
|
+
},
|
|
467
|
+
grant: {
|
|
468
|
+
run: cmdGrant,
|
|
469
|
+
opts: { publish: "boolean", node: "string", topic: "string", "rns-dir": "string", interface: "string" },
|
|
470
|
+
positional: ["grantee", "relation", "object"],
|
|
471
|
+
online: true,
|
|
472
|
+
},
|
|
473
|
+
revoke: {
|
|
474
|
+
run: cmdRevoke,
|
|
475
|
+
opts: { publish: "boolean", node: "string", topic: "string", "rns-dir": "string", interface: "string" },
|
|
476
|
+
positional: ["grantee", "relation", "object"],
|
|
477
|
+
online: true,
|
|
478
|
+
},
|
|
479
|
+
sync: {
|
|
480
|
+
run: cmdSync,
|
|
481
|
+
opts: { node: "string", topic: "string", "rns-dir": "string", interface: "string" },
|
|
482
|
+
online: true,
|
|
483
|
+
},
|
|
484
|
+
apply: { run: cmdApply, opts: { binary: "boolean" }, positional: ["payload"], online: false },
|
|
485
|
+
check: { run: cmdCheck, opts: {}, positional: ["grantee", "relation", "object"], online: false },
|
|
486
|
+
grants: { run: cmdGrants, opts: { all: "boolean", revoked: "boolean" }, online: false },
|
|
487
|
+
identity: {
|
|
488
|
+
sub: {
|
|
489
|
+
remember: {
|
|
490
|
+
run: cmdIdentityRemember,
|
|
491
|
+
opts: { pubkey: "string", file: "string", "rns-dir": "string", interface: "string", force: "boolean" },
|
|
492
|
+
positional: ["hash"],
|
|
493
|
+
online: true,
|
|
494
|
+
},
|
|
495
|
+
forget: { run: cmdIdentityForget, opts: { force: "boolean" }, positional: ["hash"], online: false },
|
|
496
|
+
list: { run: cmdIdentityList, opts: {}, online: false },
|
|
497
|
+
},
|
|
498
|
+
},
|
|
499
|
+
};
|
|
500
|
+
|
|
501
|
+
function buildOptions(spec) {
|
|
502
|
+
const opts = {};
|
|
503
|
+
for (const [k, t] of Object.entries(spec.opts || {})) {
|
|
504
|
+
opts[k] = { type: t };
|
|
505
|
+
}
|
|
506
|
+
if (spec.positional && spec.positional.length) {
|
|
507
|
+
opts.store = { type: "string" };
|
|
508
|
+
opts.identity = { type: "string" };
|
|
509
|
+
opts["full-hashes"] = { type: "boolean" };
|
|
510
|
+
}
|
|
511
|
+
return opts;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
async function main() {
|
|
515
|
+
if (process.argv.includes("--help") || process.argv.includes("-h")) {
|
|
516
|
+
err(`usage: dacar <command> [options]\n\ncommands: ${Object.keys(SUBCOMMANDS).join(", ")}\n\nGlobal options: --store <path>, --identity <hex|path>, --full-hashes`);
|
|
517
|
+
return 0;
|
|
518
|
+
}
|
|
519
|
+
const argv = process.argv.slice(2);
|
|
520
|
+
const [cmd, ...rest] = argv;
|
|
521
|
+
const spec = SUBCOMMANDS[cmd];
|
|
522
|
+
if (!spec) {
|
|
523
|
+
err(`usage: dacar <command> [options]\ncommands: ${Object.keys(SUBCOMMANDS).join(", ")}`);
|
|
524
|
+
return 1;
|
|
525
|
+
}
|
|
526
|
+
// Subcommand dispatch (config show, identity remember/forget/list).
|
|
527
|
+
if (spec.sub) {
|
|
528
|
+
const [sub, ...subrest] = rest;
|
|
529
|
+
const subspec = spec.sub[sub];
|
|
530
|
+
if (!subspec) {
|
|
531
|
+
err(`usage: dacar ${cmd} <subcommand>\nsubcommands: ${Object.keys(spec.sub).join(", ")}`);
|
|
532
|
+
return 1;
|
|
533
|
+
}
|
|
534
|
+
const { values, positionals } = parseArgs({
|
|
535
|
+
args: subrest,
|
|
536
|
+
options: buildOptions(subspec),
|
|
537
|
+
allowPositionals: true,
|
|
538
|
+
});
|
|
539
|
+
values.fullHashes = values["full-hashes"];
|
|
540
|
+
try {
|
|
541
|
+
return await subspec.run({ ...values, _positionals: positionals, ...Object.fromEntries(positionals.map((v, i) => [subspec.positional?.[i] ?? `_p${i}`, v])) });
|
|
542
|
+
} catch (e) {
|
|
543
|
+
if (e instanceof CliError) { err("error: " + e.message); return 1; }
|
|
544
|
+
throw e;
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
// Top-level command.
|
|
548
|
+
const { values, positionals } = parseArgs({
|
|
549
|
+
args: rest,
|
|
550
|
+
options: buildOptions(spec),
|
|
551
|
+
allowPositionals: true,
|
|
552
|
+
});
|
|
553
|
+
values.fullHashes = values["full-hashes"];
|
|
554
|
+
try {
|
|
555
|
+
return await spec.run({ ...values, _positionals: positionals, ...Object.fromEntries(positionals.map((v, i) => [spec.positional?.[i] ?? `_p${i}`, v])) });
|
|
556
|
+
} catch (e) {
|
|
557
|
+
if (e instanceof CliError) { err("error: " + e.message); return 1; }
|
|
558
|
+
throw e;
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
main().then((code) => process.exit(code ?? 0)).catch((e) => {
|
|
563
|
+
err("fatal: " + (e?.stack || e));
|
|
564
|
+
process.exit(1);
|
|
565
|
+
});
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Portable RNS session + online command helpers (work doc #6, §11.1).
|
|
3
|
+
*
|
|
4
|
+
* Browser- and Node-portable: no filesystem, no argv. The caller constructs a
|
|
5
|
+
* `Reticulum` (with its own `StorageAdapter`) and passes it in — keeping
|
|
6
|
+
* shared-instance discovery out of the portable core (the same decision
|
|
7
|
+
* `@reticulum/core` itself makes). A Node/Deno CLI (`dacar.js`) composes these
|
|
8
|
+
* helpers with `@reticulum/node`'s interfaces and `FileStorageAdapter`.
|
|
9
|
+
*
|
|
10
|
+
* This module is part of the CLI layer but has **no** Node-only dependencies:
|
|
11
|
+
* it imports only `@reticulum/core` (already a core dep) and the dacar pure
|
|
12
|
+
* core. It mirrors Python's `dacar/cli/rns.py` + `run_publish`/`run_sync`.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { Destination, Identity } from "@reticulum/core";
|
|
16
|
+
import { APP_NAME } from "../naming.js";
|
|
17
|
+
import { RfedDeltaSync } from "../transport/rfedSync.js";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Announce the node's identity on the `dacar.node` destination (§11.2.4).
|
|
21
|
+
*
|
|
22
|
+
* Any announced destination under an identity makes that identity recallable by
|
|
23
|
+
* peers via `Destination.recall(hash, true)` — the announce invariant: without
|
|
24
|
+
* it, receivers drop the node's signed Deltas as "unknown issuer" because the
|
|
25
|
+
* `RnsIdentityResolver` cannot recall the issuer's public key.
|
|
26
|
+
*
|
|
27
|
+
* Returns the announced destination hash. Call before publishing or pulling.
|
|
28
|
+
* @param {import("@reticulum/core").Identity} identity
|
|
29
|
+
* @returns {Promise<Uint8Array>}
|
|
30
|
+
*/
|
|
31
|
+
export async function announceIdentity(identity) {
|
|
32
|
+
const dest = await Destination.IN(`${APP_NAME}.node`, "single", identity, null);
|
|
33
|
+
await dest.announce();
|
|
34
|
+
return dest.destinationHash;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Publish a signed Delta to the rfed channel (§11.1, work doc #6).
|
|
39
|
+
*
|
|
40
|
+
* Testable core: takes an explicit `client` (`RFedClient` or compatible fake)
|
|
41
|
+
* so tests inject doubles without booting RNS. The `cmd_*` wrappers handle RNS
|
|
42
|
+
* boot + announce + real client creation.
|
|
43
|
+
* @param {Object} opts
|
|
44
|
+
* @param {Uint8Array} opts.deltaPayload Signed §5.3 Operation payload.
|
|
45
|
+
* @param {Uint8Array} opts.nodeHash The rfed node's `rfed.*` destination hash.
|
|
46
|
+
* @param {string} [opts.topic] RFed channel name (default `dacar.policy.v1`).
|
|
47
|
+
* @param {import("../transport/rfedSync.js").RFedClientLike} opts.client
|
|
48
|
+
* @returns {Promise<import("@reticulum/core").LXMessage>}
|
|
49
|
+
*/
|
|
50
|
+
export async function runPublish({ deltaPayload, nodeHash, topic, client }) {
|
|
51
|
+
const sync = new RfedDeltaSync({ client, topic });
|
|
52
|
+
await sync.subscribe(nodeHash);
|
|
53
|
+
return sync.publish(deltaPayload, nodeHash);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Pull pending Deltas from the rfed channel and apply via verify-on-ingest.
|
|
58
|
+
*
|
|
59
|
+
* Testable core: takes an explicit `client` and `receiver` so tests inject
|
|
60
|
+
* doubles. Routes every blob through `DeltaReceiver.applyPayload()` (§11.2.4)
|
|
61
|
+
* — never through the unauthenticated `StateVector.merge()` path. Returns the
|
|
62
|
+
* count applied (the caller persists the CRDT).
|
|
63
|
+
* @param {Object} opts
|
|
64
|
+
* @param {Uint8Array} opts.nodeHash
|
|
65
|
+
* @param {string} [opts.topic]
|
|
66
|
+
* @param {import("../transport/rfedSync.js").RFedClientLike} opts.client
|
|
67
|
+
* @param {import("../delta.js").DeltaReceiver} opts.receiver
|
|
68
|
+
* @returns {Promise<number>}
|
|
69
|
+
*/
|
|
70
|
+
export async function runSync({ nodeHash, topic, client, receiver }) {
|
|
71
|
+
const sync = new RfedDeltaSync({ receiver, client, topic });
|
|
72
|
+
await sync.subscribe(nodeHash);
|
|
73
|
+
return sync.pull(nodeHash);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Register a dacar-scoped announce handler that seeds the durable issuer cache
|
|
78
|
+
* (work doc #5, design decision #3).
|
|
79
|
+
*
|
|
80
|
+
* Listens to the RNS transport `"announce"` event and, on a validated
|
|
81
|
+
* `dacar.node` announce (verified by recomputing the destination hash under
|
|
82
|
+
* the announced identity), registers the issuer's public key into `keyring`.
|
|
83
|
+
* Non-`dacar` announces are ignored (dacar is not a general identity directory).
|
|
84
|
+
* Returns an unsubscribe function.
|
|
85
|
+
* @param {Object} opts
|
|
86
|
+
* @param {import("@reticulum/core").Reticulum} opts.rns A booted Reticulum.
|
|
87
|
+
* @param {import("../verifier.js").Keyring} opts.keyring
|
|
88
|
+
* @param {(keyring: import("../verifier.js").Keyring) => void} [opts.onSave]
|
|
89
|
+
* Called after each seed so the caller can persist the keyring.
|
|
90
|
+
* @returns {Promise<{ unsubscribe: () => void, seeded: number }>}
|
|
91
|
+
*/
|
|
92
|
+
export async function registerAnnounceHandler({ rns, keyring, onSave }) {
|
|
93
|
+
/** @type {() => void} */ let unsubscribe = () => {};
|
|
94
|
+
const state = { seeded: 0 };
|
|
95
|
+
|
|
96
|
+
// The transport emits a CustomEvent("announce", { detail }) on each validated
|
|
97
|
+
// announce. We filter to dacar.node and seed the keyring.
|
|
98
|
+
const handler = async (event) => {
|
|
99
|
+
const detail = event.detail;
|
|
100
|
+
if (!detail || !detail.identity) return;
|
|
101
|
+
const announced = detail.identity;
|
|
102
|
+
const announcedDestHash = detail.destinationHash;
|
|
103
|
+
if (!(announcedDestHash instanceof Uint8Array)) return;
|
|
104
|
+
// Only dacar.node: recompute the destination hash under the dacar app.
|
|
105
|
+
const expected = await _dacarNodeHash(announced);
|
|
106
|
+
const { toHex } = await import("@reticulum/core");
|
|
107
|
+
if (toHex(announcedDestHash) !== toHex(expected)) return; // not dacar.node
|
|
108
|
+
const publicKey = await announced.getPublicKey();
|
|
109
|
+
keyring.registerSingle(announced.identityHash, publicKey);
|
|
110
|
+
state.seeded += 1;
|
|
111
|
+
if (onSave) onSave(keyring);
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
// core's TransportCore is an EventTarget on `rns.transport`.
|
|
115
|
+
if (rns && rns.transport && typeof rns.transport.addEventListener === "function") {
|
|
116
|
+
rns.transport.addEventListener("announce", handler);
|
|
117
|
+
unsubscribe = () => rns.transport.removeEventListener("announce", handler);
|
|
118
|
+
}
|
|
119
|
+
return { unsubscribe, get seeded() { return state.seeded; } };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Compute the `dacar.node` destination hash for an identity (§11.2.4).
|
|
124
|
+
*
|
|
125
|
+
* `nameHash = SHA-256("dacar.node")[:10]`; `destHash = SHA-256(nameHash ‖
|
|
126
|
+
* identityHash)[:16]` — matching `@reticulum/core`'s `Destination._computeHashes`.
|
|
127
|
+
* @param {import("@reticulum/core").Identity} identity
|
|
128
|
+
* @returns {Promise<Uint8Array>}
|
|
129
|
+
*/
|
|
130
|
+
async function _dacarNodeHash(identity) {
|
|
131
|
+
const encoder = new TextEncoder();
|
|
132
|
+
const nameBytes = encoder.encode(`${APP_NAME}.node`);
|
|
133
|
+
const nameHashBuffer = await crypto.subtle.digest("SHA-256", nameBytes);
|
|
134
|
+
const nameHash = new Uint8Array(nameHashBuffer.slice(0, 10));
|
|
135
|
+
const combined = new Uint8Array(nameHash.length + identity.identityHash.length);
|
|
136
|
+
combined.set(nameHash, 0);
|
|
137
|
+
combined.set(identity.identityHash, nameHash.length);
|
|
138
|
+
const destHashBuffer = await crypto.subtle.digest("SHA-256", combined);
|
|
139
|
+
return new Uint8Array(destHashBuffer.slice(0, 16));
|
|
140
|
+
}
|
package/src/cli/smoke.js
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* CLI smoke test for doc #6.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { Identity } from "@reticulum/core";
|
|
7
|
+
import { MemoryStorageAdapter } from "@reticulum/core";
|
|
8
|
+
import { DacarStore } from "./store.js";
|
|
9
|
+
import { rmSync } from "node:fs";
|
|
10
|
+
|
|
11
|
+
const STORE_DIR = "/tmp/dacar-smoke-test";
|
|
12
|
+
|
|
13
|
+
async function runSmokeTest() {
|
|
14
|
+
console.log("=== Dacar CLI Smoke Test (doc #6) ===\n");
|
|
15
|
+
|
|
16
|
+
// Cleanup
|
|
17
|
+
rmSync(STORE_DIR, { recursive: true, force: true });
|
|
18
|
+
|
|
19
|
+
// Test init
|
|
20
|
+
console.log("1. Running init...");
|
|
21
|
+
const adapter = new MemoryStorageAdapter(STORE_DIR);
|
|
22
|
+
const identity = await Identity.generate();
|
|
23
|
+
const store = await DacarStore.init(adapter, {
|
|
24
|
+
salt: new Uint8Array(32),
|
|
25
|
+
identityBytes: await identity.getPrivateKey(),
|
|
26
|
+
});
|
|
27
|
+
const raw = await store.loadConfig();
|
|
28
|
+
console.log(" PASS: store created");
|
|
29
|
+
console.log(" PASS: self aliases registered");
|
|
30
|
+
console.log(" PASS: config saved");
|
|
31
|
+
|
|
32
|
+
// Test config round-trip
|
|
33
|
+
console.log("\n2. Testing config round-trip...");
|
|
34
|
+
raw.rfedTopic = "test.policy.v1";
|
|
35
|
+
await store.saveConfig(raw);
|
|
36
|
+
|
|
37
|
+
const store2 = new DacarStore(new MemoryStorageAdapter(STORE_DIR));
|
|
38
|
+
const raw2 = await store2.loadConfig();
|
|
39
|
+
console.log(" PASS: rfedTopic round-trips");
|
|
40
|
+
|
|
41
|
+
// Test identities cache
|
|
42
|
+
console.log("\n3. Testing identities cache...");
|
|
43
|
+
const other = await Identity.generate();
|
|
44
|
+
const keyring = await store.loadKeyring();
|
|
45
|
+
keyring.registerSingle(other.identityHash, await other.getPublicKey());
|
|
46
|
+
await store.saveKeyring(keyring);
|
|
47
|
+
|
|
48
|
+
const store3 = new DacarStore(new MemoryStorageAdapter(STORE_DIR));
|
|
49
|
+
const keyring2 = await store3.loadKeyring();
|
|
50
|
+
console.log(" PASS: issuer cached across instances");
|
|
51
|
+
|
|
52
|
+
console.log("\n=== Smoke test passed! ===\n");
|
|
53
|
+
console.log("Run: npm link; dacar --help");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
runSmokeTest().catch((e) => {
|
|
57
|
+
console.error("FAIL:", e.stack || e);
|
|
58
|
+
process.exit(1);
|
|
59
|
+
});
|
package/src/cli/store.js
ADDED
|
@@ -0,0 +1,456 @@
|
|
|
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
|
+
return StateVector.fromPayload(bytes, { deletionHorizonDays: horizon });
|
|
225
|
+
}
|
|
226
|
+
return new StateVector({ deletionHorizonDays: horizon });
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** @param {StateVector} state */
|
|
230
|
+
async saveState(state) {
|
|
231
|
+
await this._adapter.set(NS, "state", state.toPayload());
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// -- aliases -------------------------------------------------------------
|
|
235
|
+
|
|
236
|
+
/** @returns {Promise<AliasRegistry>} */
|
|
237
|
+
async loadAliases() {
|
|
238
|
+
const bytes = await this._adapter.get(NS, "aliases");
|
|
239
|
+
if (!bytes) return new AliasRegistry();
|
|
240
|
+
return AliasRegistry.decode(bytes);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** @param {AliasRegistry} aliases */
|
|
244
|
+
async saveAliases(aliases) {
|
|
245
|
+
await this._adapter.set(NS, "aliases", aliases.encode());
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// -- ledger --------------------------------------------------------------
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* @returns {Promise<Map<string, { object?: string, relation?: string, wildcard?: boolean, firstSeen?: number }>>}
|
|
252
|
+
*/
|
|
253
|
+
async loadLedger() {
|
|
254
|
+
const bytes = await this._adapter.get(NS, "ledger");
|
|
255
|
+
/** @type {Map<string, any>} */
|
|
256
|
+
const ledger = new Map();
|
|
257
|
+
if (bytes) {
|
|
258
|
+
const obj = MsgPack.decode(bytes);
|
|
259
|
+
if (obj && typeof obj === "object" && !Array.isArray(obj)) {
|
|
260
|
+
for (const [k, v] of Object.entries(obj)) ledger.set(k, v);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
return ledger;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** @param {Map<string, any>} ledger */
|
|
267
|
+
async saveLedger(ledger) {
|
|
268
|
+
const obj = Object.fromEntries(ledger);
|
|
269
|
+
await this._adapter.set(NS, "ledger", MsgPack.encode(obj));
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// -- issuer identity cache (work doc #5) ---------------------------------
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Load the persisted issuer identity cache. Returns an empty {@link Keyring}
|
|
276
|
+
* if no cache record exists yet.
|
|
277
|
+
* @returns {Promise<Keyring>}
|
|
278
|
+
*/
|
|
279
|
+
async loadKeyring() {
|
|
280
|
+
const keyring = new Keyring();
|
|
281
|
+
const bytes = await this._adapter.get(NS, "identities");
|
|
282
|
+
if (bytes) {
|
|
283
|
+
const obj = MsgPack.decode(bytes);
|
|
284
|
+
if (obj && typeof obj === "object" && !Array.isArray(obj)) {
|
|
285
|
+
for (const [hashHex, pubKey] of Object.entries(obj)) {
|
|
286
|
+
if (pubKey instanceof Uint8Array && pubKey.length === RNS_PUBLIC_KEY_SIZE) {
|
|
287
|
+
try {
|
|
288
|
+
keyring.registerSingle(_hexToBytes(hashHex), pubKey);
|
|
289
|
+
} catch {
|
|
290
|
+
// skip malformed hash
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
return keyring;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** @param {Keyring} keyring */
|
|
300
|
+
async saveKeyring(keyring) {
|
|
301
|
+
const obj = {};
|
|
302
|
+
for (const [hashHex, keyset] of keyring.entries()) {
|
|
303
|
+
if (keyset.threshold === 1 && keyset.memberPublicKeys.length === 1) {
|
|
304
|
+
obj[hashHex] = keyset.memberPublicKeys[0];
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
await this._adapter.set(NS, "identities", MsgPack.encode(obj));
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Build a verify-on-ingest keyring from the persisted cache + own identity.
|
|
312
|
+
* @returns {Promise<Keyring>}
|
|
313
|
+
*/
|
|
314
|
+
async keyringForVerify() {
|
|
315
|
+
const keyring = await this.loadKeyring();
|
|
316
|
+
const own = await this.loadIdentity();
|
|
317
|
+
if (own) keyring.registerSingle(own.identityHash, await own.getPublicKey());
|
|
318
|
+
return keyring;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* In-memory alias registry: `hash → names[]` with an optional note. Mirrors
|
|
324
|
+
* Python's `AliasRegistry` (rnns `hash name [# note]`).
|
|
325
|
+
*/
|
|
326
|
+
export class AliasRegistry {
|
|
327
|
+
/** @param {AliasEntry[]} [entries] */
|
|
328
|
+
constructor(entries = []) {
|
|
329
|
+
/** @type {AliasEntry[]} */ this.entries = entries;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/** @param {Uint8Array} bytes @returns {AliasRegistry} */
|
|
333
|
+
static decode(bytes) {
|
|
334
|
+
const obj = MsgPack.decode(bytes);
|
|
335
|
+
if (!Array.isArray(obj)) return new AliasRegistry();
|
|
336
|
+
/** @type {AliasEntry[]} */
|
|
337
|
+
const entries = [];
|
|
338
|
+
for (const row of obj) {
|
|
339
|
+
if (!Array.isArray(row)) continue;
|
|
340
|
+
const [hash, names, note] = row;
|
|
341
|
+
if (!(hash instanceof Uint8Array) || !Array.isArray(names)) continue;
|
|
342
|
+
entries.push({ hash, names, note: note ?? null });
|
|
343
|
+
}
|
|
344
|
+
return new AliasRegistry(entries);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/** @returns {Uint8Array} */
|
|
348
|
+
encode() {
|
|
349
|
+
return MsgPack.encode(this.entries.map((e) => [e.hash, e.names, e.note ?? null]));
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/** @param {string} name @returns {Uint8Array | null} */
|
|
353
|
+
resolve(name) {
|
|
354
|
+
for (const e of this.entries) {
|
|
355
|
+
if (e.names.includes(name)) return e.hash;
|
|
356
|
+
}
|
|
357
|
+
return null;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/** @param {Uint8Array} hash @returns {string[]} */
|
|
361
|
+
namesFor(hash) {
|
|
362
|
+
for (const e of this.entries) {
|
|
363
|
+
if (_bytesEqual(e.hash, hash)) return [...e.names];
|
|
364
|
+
}
|
|
365
|
+
return [];
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/** @param {Uint8Array} hash @returns {string | null} */
|
|
369
|
+
primaryName(hash) {
|
|
370
|
+
const names = this.namesFor(hash);
|
|
371
|
+
return names[0] ?? null;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/** @param {string} name @param {Uint8Array} hash @param {string | null} [note] */
|
|
375
|
+
add(name, hash, note) {
|
|
376
|
+
for (const e of this.entries) {
|
|
377
|
+
if (_bytesEqual(e.hash, hash)) {
|
|
378
|
+
if (!e.names.includes(name)) e.names.push(name);
|
|
379
|
+
if (note !== undefined) e.note = note;
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
this.entries.push({ hash, names: [name], note: note ?? null });
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/** @param {Uint8Array} hash */
|
|
387
|
+
setSelf(hash) {
|
|
388
|
+
for (const e of this.entries) {
|
|
389
|
+
const i = e.names.indexOf(SELF_ALIAS);
|
|
390
|
+
if (i !== -1) e.names.splice(i, 1);
|
|
391
|
+
}
|
|
392
|
+
this.entries = this.entries.filter((e) => e.names.length > 0);
|
|
393
|
+
this.add(SELF_ALIAS, hash);
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// -- decode helpers ---------------------------------------------------------
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* @param {Uint8Array} bytes
|
|
401
|
+
* @returns {StoreConfig}
|
|
402
|
+
*/
|
|
403
|
+
function _decodeConfig(bytes) {
|
|
404
|
+
const arr = MsgPack.decode(bytes);
|
|
405
|
+
if (!Array.isArray(arr) || arr.length !== 7) {
|
|
406
|
+
throw new Error("config record must be a 7-element MessagePack array");
|
|
407
|
+
}
|
|
408
|
+
const [primarySalt, legacySalts, anchors, authoritative, horizonDays, rfedTopic, rfedNode] = arr;
|
|
409
|
+
return {
|
|
410
|
+
primarySalt: _expectBytes(primarySalt, SALT_SIZE, "primary_salt"),
|
|
411
|
+
legacySalts: legacySalts.map((s) => _expectBytes(s, SALT_SIZE, "legacy_salt")),
|
|
412
|
+
anchors: anchors.map((a) => _expectBytes(a, HASH_SIZE, "anchor")),
|
|
413
|
+
authoritative: authoritative instanceof Uint8Array ? authoritative : null,
|
|
414
|
+
horizonDays: Number(horizonDays),
|
|
415
|
+
rfedTopic: String(rfedTopic),
|
|
416
|
+
rfedNode: rfedNode instanceof Uint8Array ? rfedNode : null,
|
|
417
|
+
};
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* @param {unknown} value
|
|
422
|
+
* @param {number} len
|
|
423
|
+
* @param {string} name
|
|
424
|
+
* @returns {Uint8Array}
|
|
425
|
+
*/
|
|
426
|
+
function _expectBytes(value, len, name) {
|
|
427
|
+
if (!(value instanceof Uint8Array) || value.length !== len) {
|
|
428
|
+
throw new Error(`${name} must be a ${len}-byte Uint8Array`);
|
|
429
|
+
}
|
|
430
|
+
return value;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/** @param {Uint8Array} a @param {Uint8Array} b @returns {boolean} */
|
|
434
|
+
function _bytesEqual(a, b) {
|
|
435
|
+
if (a.length !== b.length) return false;
|
|
436
|
+
let diff = 0;
|
|
437
|
+
for (let i = 0; i < a.length; i++) diff |= a[i] ^ b[i];
|
|
438
|
+
return diff === 0;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
/** @param {string} hex @returns {Uint8Array} */
|
|
442
|
+
function _hexToBytes(hex) {
|
|
443
|
+
const clean = hex.startsWith("0x") ? hex.slice(2) : hex;
|
|
444
|
+
const out = new Uint8Array(clean.length / 2);
|
|
445
|
+
for (let i = 0; i < out.length; i++) {
|
|
446
|
+
out[i] = parseInt(clean.slice(i * 2, i * 2 + 2), 16);
|
|
447
|
+
}
|
|
448
|
+
return out;
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/** @param {number} n @returns {Uint8Array} */
|
|
452
|
+
function _randomBytes(n) {
|
|
453
|
+
const out = new Uint8Array(n);
|
|
454
|
+
crypto.getRandomValues(out);
|
|
455
|
+
return out;
|
|
456
|
+
}
|
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
|
/**
|