@reticulum/dacar 1.0.0 → 1.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +8 -2
- package/src/cli/dacar.js +574 -0
- package/src/cli/rns_boot.js +131 -0
- package/src/cli/session.js +277 -0
- package/src/cli/smoke.js +59 -0
- package/src/cli/store.js +464 -0
- package/src/crdt.js +9 -3
- 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.1",
|
|
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,574 @@
|
|
|
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, toHex } from "@reticulum/core";
|
|
34
|
+
import { MemoryStorageAdapter } from "@reticulum/core";
|
|
35
|
+
import { FileStorageAdapter } from "@reticulum/node";
|
|
36
|
+
import { RFedClient } from "@reticulum/core/src/rfed/client.js";
|
|
37
|
+
import { bootRns } from "./rns_boot.js";
|
|
38
|
+
|
|
39
|
+
import { Action, Operation, Tuple, Engine } from "../index.js";
|
|
40
|
+
import { DeltaReceiver } from "../delta.js";
|
|
41
|
+
import { RnsIdentityResolver } from "../transport/rnsIdentity.js";
|
|
42
|
+
import { RFED_TOPIC, APP_NAME } from "../naming.js";
|
|
43
|
+
import { NamespaceHasher, DEFAULT_SALT, SALT_SIZE, HASH_SIZE } from "../namespace.js";
|
|
44
|
+
import { Keyring, IssuerKeyset } from "../verifier.js";
|
|
45
|
+
|
|
46
|
+
import { DacarStore, SELF_ALIAS, AliasRegistry } from "./store.js";
|
|
47
|
+
import { announceIdentity, discoverRfedNode, ensureNodeIdentity, runPublish, runSync, registerAnnounceHandler } from "./session.js";
|
|
48
|
+
|
|
49
|
+
const SHORT_HASH = 7;
|
|
50
|
+
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
// Output helpers
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
|
|
55
|
+
function err(msg) {
|
|
56
|
+
process.stderr.write(msg + "\n");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function shortHash(hash, full = false) {
|
|
60
|
+
const hex = toHex(hash);
|
|
61
|
+
return full ? hex : hex.slice(0, SHORT_HASH) + "…";
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function out(msg) {
|
|
65
|
+
process.stdout.write(msg + "\n");
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
class CliError extends Error {}
|
|
69
|
+
|
|
70
|
+
// ---------------------------------------------------------------------------
|
|
71
|
+
// Store + RNS resolution
|
|
72
|
+
// ---------------------------------------------------------------------------
|
|
73
|
+
|
|
74
|
+
function defaultStorePath() {
|
|
75
|
+
return process.env.DACAR_HOME || join(homedir(), ".dacar");
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function openStore(args) {
|
|
79
|
+
const path = args.store || defaultStorePath();
|
|
80
|
+
const adapter = new FileStorageAdapter(path);
|
|
81
|
+
return new DacarStore(adapter, { identityBytes: args.identity ? await readFile(args.identity) : null });
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function resolveIdentityHash(value, aliases) {
|
|
85
|
+
const fromAlias = aliases.resolve(value);
|
|
86
|
+
if (fromAlias) return fromAlias;
|
|
87
|
+
const clean = value.toLowerCase().replace(/^0x/, "");
|
|
88
|
+
const raw = hexToBytes(clean);
|
|
89
|
+
if (raw.length !== 16) {
|
|
90
|
+
throw new CliError(`unknown identity ${JSON.stringify(value)} (not a known alias or 16-byte hex hash)`);
|
|
91
|
+
}
|
|
92
|
+
return raw;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function hexToBytes(hex) {
|
|
96
|
+
const clean = hex.startsWith("0x") ? hex.slice(2) : hex;
|
|
97
|
+
const out = new Uint8Array(Math.floor(clean.length / 2));
|
|
98
|
+
for (let i = 0; i < out.length; i++) {
|
|
99
|
+
out[i] = parseInt(clean.slice(i * 2, i * 2 + 2), 16);
|
|
100
|
+
}
|
|
101
|
+
return out;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async function resolveRnsConfigDir(args) {
|
|
105
|
+
const explicit = args.rnsDir ?? process.env.DACAR_RNS_DIR;
|
|
106
|
+
if (explicit) return explicit;
|
|
107
|
+
const user = join(homedir(), ".reticulum");
|
|
108
|
+
try {
|
|
109
|
+
await readFile(join(user, "config"));
|
|
110
|
+
return user;
|
|
111
|
+
} catch {
|
|
112
|
+
// fall through to store-local default
|
|
113
|
+
}
|
|
114
|
+
const storePath = args.store || defaultStorePath();
|
|
115
|
+
const dir = join(storePath, "rns");
|
|
116
|
+
await mkdir(dir, { recursive: true });
|
|
117
|
+
const cfgPath = join(dir, "config");
|
|
118
|
+
try {
|
|
119
|
+
await readFile(cfgPath);
|
|
120
|
+
} catch {
|
|
121
|
+
await writeFile(
|
|
122
|
+
cfgPath,
|
|
123
|
+
"[reticulum]\n share_instance = Yes\n enable_transport = False\n\n" +
|
|
124
|
+
"[interfaces]\n [[Default interface]]\n type = AutoInterface\n enabled = Yes\n",
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
return dir;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// `bootRns` lives in `./rns_boot.js` (Node-only): it constructs, **connects**,
|
|
131
|
+
// and default-attaches the chosen mesh interface, and optionally raises the
|
|
132
|
+
// Reticulum log threshold + logs each announce for `--verbose`. See
|
|
133
|
+
// `src/cli/rns_boot.js` for why `connect()` + `isDefault=true` are
|
|
134
|
+
// non-optional (the `--discover` silent-timeout symptom).
|
|
135
|
+
|
|
136
|
+
async function resolveRfedNode(args, store, aliases, rns) {
|
|
137
|
+
if (args.node) return resolveIdentityHash(args.node, aliases);
|
|
138
|
+
const raw = await store.loadConfig();
|
|
139
|
+
if (raw.rfedNode) return raw.rfedNode;
|
|
140
|
+
if (args.discover && rns) return discoverRfedNode({ rns, timeout: 30000 });
|
|
141
|
+
throw new CliError("no rfed node configured (use --node <hash>, --discover, or set [rfed] node in config)");
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
async function resolveTopic(args, store) {
|
|
145
|
+
if (args.topic) return args.topic;
|
|
146
|
+
const raw = await store.loadConfig();
|
|
147
|
+
return raw.rfedTopic || RFED_TOPIC;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// ---------------------------------------------------------------------------
|
|
151
|
+
// Commands
|
|
152
|
+
// ---------------------------------------------------------------------------
|
|
153
|
+
|
|
154
|
+
async function cmdInit(args) {
|
|
155
|
+
const path = args.store || defaultStorePath();
|
|
156
|
+
await mkdir(path, { recursive: true });
|
|
157
|
+
const adapter = new FileStorageAdapter(path);
|
|
158
|
+
const store = await DacarStore.init(adapter, {
|
|
159
|
+
salt: args.salt ? hexToBytes(args.salt) : undefined,
|
|
160
|
+
horizonDays: parseInt(args.horizon || "180", 10),
|
|
161
|
+
identityBytes: args.identity ? await readFile(args.identity) : undefined,
|
|
162
|
+
});
|
|
163
|
+
const identity = await store.loadIdentity();
|
|
164
|
+
const aliases = await store.loadAliases();
|
|
165
|
+
err("✔ initialized store at " + path);
|
|
166
|
+
err(" identity : " + shortHash(identity.identityHash, args.fullHashes));
|
|
167
|
+
err(" anchor : " + shortHash(identity.identityHash, args.fullHashes) + " (self)");
|
|
168
|
+
return 0;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
async function cmdConfigShow(args) {
|
|
172
|
+
const store = await openStore(args);
|
|
173
|
+
const raw = await store.loadConfig();
|
|
174
|
+
const aliases = await store.loadAliases();
|
|
175
|
+
err("store: " + (args.store || defaultStorePath()));
|
|
176
|
+
err("[salt]");
|
|
177
|
+
err(" primary : " + (args.reveal ? toHex(raw.primarySalt) : "<masked (use --reveal)>"));
|
|
178
|
+
err("[trust]");
|
|
179
|
+
for (const a of raw.anchors) err(" anchor : " + shortHash(a, args.fullHashes));
|
|
180
|
+
err("[policy]");
|
|
181
|
+
err(" deletion_horizon_days : " + raw.horizonDays);
|
|
182
|
+
err("[rfed]");
|
|
183
|
+
err(" topic : " + raw.rfedTopic);
|
|
184
|
+
err(" node : " + (raw.rfedNode ? shortHash(raw.rfedNode, args.fullHashes) : "(not set)"));
|
|
185
|
+
return 0;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async function cmdGrant(args) {
|
|
189
|
+
return _issue(args, Action.GRANT);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
async function cmdRevoke(args) {
|
|
193
|
+
return _issue(args, Action.REVOKE);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async function _issue(args, action) {
|
|
197
|
+
const store = await openStore(args);
|
|
198
|
+
const config = await store.loadConfigValidated();
|
|
199
|
+
const aliases = await store.loadAliases();
|
|
200
|
+
const identity = await store.loadIdentity();
|
|
201
|
+
if (!identity) throw new CliError("no signing identity (run `dacar init`)");
|
|
202
|
+
|
|
203
|
+
const grantee = resolveIdentityHash(args.grantee, aliases);
|
|
204
|
+
const hasher = config.primaryHasher;
|
|
205
|
+
const tuple = await Tuple.fromPlaintext({
|
|
206
|
+
objectId: args.object, relation: args.relation, grantee, issuer: identity.identityHash, hasher,
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
const clock = await store.loadClock();
|
|
210
|
+
const hlc = clock.now();
|
|
211
|
+
await store.saveClock(clock);
|
|
212
|
+
|
|
213
|
+
const op = await new Operation({ tuple, action, hlc }).sign(identity);
|
|
214
|
+
const payload = op.toPayload();
|
|
215
|
+
|
|
216
|
+
const state = await store.loadState(config);
|
|
217
|
+
state.apply(op);
|
|
218
|
+
await store.saveState(state);
|
|
219
|
+
|
|
220
|
+
// Record plaintext ledger.
|
|
221
|
+
const ledger = await store.loadLedger();
|
|
222
|
+
ledger.set(toHex(tuple.key), { object: args.object, relation: args.relation, wildcard: args.object.endsWith("*") && args.object !== "*", firstSeen: Number(hlc >> 16n) });
|
|
223
|
+
await store.saveLedger(ledger);
|
|
224
|
+
|
|
225
|
+
out(payload.hex());
|
|
226
|
+
err(`✔ ${action === Action.GRANT ? "granted" : "revoked"} ${shortHash(grantee, args.fullHashes)} ${args.relation} on ${args.object}`);
|
|
227
|
+
err(` hlc : 0x${hlc.toString(16)}`);
|
|
228
|
+
err(` payload : hex on stdout (${payload.length} bytes)`);
|
|
229
|
+
|
|
230
|
+
if (args.publish) {
|
|
231
|
+
await publishDelta(args, store, identity, payload);
|
|
232
|
+
}
|
|
233
|
+
return 0;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
async function publishDelta(args, store, identity, payload) {
|
|
237
|
+
const aliases = await store.loadAliases();
|
|
238
|
+
const topic = await resolveTopic(args, store);
|
|
239
|
+
const configDir = await resolveRnsConfigDir(args);
|
|
240
|
+
const rns = await bootRns(configDir, args.interface || "shared", {
|
|
241
|
+
verbose: !!args.verbose,
|
|
242
|
+
});
|
|
243
|
+
// RNS must be booted before resolveRfedNode: --discover listens for peer
|
|
244
|
+
// announces on the live transport (mirrors Python's _publish_delta).
|
|
245
|
+
const nodeHash = await resolveRfedNode(args, store, aliases, rns);
|
|
246
|
+
await announceIdentity(identity, rns);
|
|
247
|
+
// Proactively fetch the rfed node's identity: when --node is given (or
|
|
248
|
+
// --discover derived it), the destination's announce may not yet be in
|
|
249
|
+
// the recall store. Send a path? request and wait for the announce rather
|
|
250
|
+
// than failing with "wait for its announce" (work doc #6).
|
|
251
|
+
await ensureNodeIdentity(rns, nodeHash, {
|
|
252
|
+
onRequest: () => err(" requesting rfed node identity…"),
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
// Durable issuer cache (doc #5): seed from observed dacar.node announces.
|
|
256
|
+
const keyring = await store.loadKeyring();
|
|
257
|
+
keyring.registerSingle(identity.identityHash, await identity.getPublicKey());
|
|
258
|
+
await registerAnnounceHandler({ rns, keyring, onSave: (kr) => store.saveKeyring(kr) });
|
|
259
|
+
|
|
260
|
+
const client = new RFedClient({ identity, rns });
|
|
261
|
+
await runPublish({ deltaPayload: payload, nodeHash, topic, client });
|
|
262
|
+
await store.saveKeyring(keyring);
|
|
263
|
+
err(` published to rfed channel ${JSON.stringify(topic)} via ${shortHash(nodeHash, args.fullHashes)}`);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
async function cmdSync(args) {
|
|
267
|
+
const store = await openStore(args);
|
|
268
|
+
const config = await store.loadConfigValidated();
|
|
269
|
+
const aliases = await store.loadAliases();
|
|
270
|
+
const identity = await store.loadIdentity();
|
|
271
|
+
if (!identity) throw new CliError("no signing identity (run `dacar init`)");
|
|
272
|
+
|
|
273
|
+
// RNS must be booted before discover if we're autodiscovering
|
|
274
|
+
const configDir = await resolveRnsConfigDir(args);
|
|
275
|
+
const rns = await bootRns(configDir, args.interface || "shared", {
|
|
276
|
+
verbose: !!args.verbose,
|
|
277
|
+
});
|
|
278
|
+
await announceIdentity(identity, rns);
|
|
279
|
+
|
|
280
|
+
// Durable issuer cache (doc #5): load persisted keyring + announce handler.
|
|
281
|
+
const keyring = await store.loadKeyring();
|
|
282
|
+
keyring.registerSingle(identity.identityHash, await identity.getPublicKey());
|
|
283
|
+
await registerAnnounceHandler({ rns, keyring, onSave: (kr) => store.saveKeyring(kr) });
|
|
284
|
+
|
|
285
|
+
const nodeHash = await resolveRfedNode(args, store, aliases, rns);
|
|
286
|
+
// Proactively fetch the rfed node's identity: when --node is given (or
|
|
287
|
+
// --discover derived it), the destination's announce may not yet be in
|
|
288
|
+
// the recall store. Send a path? request and wait for the announce rather
|
|
289
|
+
// than failing with "wait for its announce" (work doc #6).
|
|
290
|
+
await ensureNodeIdentity(rns, nodeHash, {
|
|
291
|
+
onRequest: () => err(" requesting rfed node identity…"),
|
|
292
|
+
});
|
|
293
|
+
const topic = await resolveTopic(args, store);
|
|
294
|
+
const state = await store.loadState(config);
|
|
295
|
+
const resolver = new RnsIdentityResolver(keyring);
|
|
296
|
+
const rx = new DeltaReceiver(state, resolver);
|
|
297
|
+
|
|
298
|
+
const client = new RFedClient({ identity, rns });
|
|
299
|
+
const applied = await runSync({ nodeHash, topic, client, receiver: rx });
|
|
300
|
+
await store.saveState(state);
|
|
301
|
+
await store.saveKeyring(keyring);
|
|
302
|
+
|
|
303
|
+
err(`✔ synced: applied ${applied} delta(s) from rfed channel ${JSON.stringify(topic)}`);
|
|
304
|
+
return 0;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
async function cmdCheck(args) {
|
|
308
|
+
const store = await openStore(args);
|
|
309
|
+
const config = await store.loadConfigValidated();
|
|
310
|
+
const state = await store.loadState(config);
|
|
311
|
+
const aliases = await store.loadAliases();
|
|
312
|
+
const engine = new Engine(config, state);
|
|
313
|
+
const grantee = resolveIdentityHash(args.grantee, aliases);
|
|
314
|
+
const allowed = await engine.evaluate(args.object, args.relation, grantee);
|
|
315
|
+
const mark = allowed ? "✔" : "✘";
|
|
316
|
+
err(`${mark} ${allowed ? "ALLOW" : "DENY"} ${shortHash(grantee, args.fullHashes)} ${args.relation} ${args.object}`);
|
|
317
|
+
return allowed ? 0 : 1;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
async function cmdApply(args) {
|
|
321
|
+
const store = await openStore(args);
|
|
322
|
+
const config = await store.loadConfigValidated();
|
|
323
|
+
const state = await store.loadState(config);
|
|
324
|
+
const keyring = await store.keyringForVerify();
|
|
325
|
+
const rx = new DeltaReceiver(state, keyring);
|
|
326
|
+
const data = args.payload === "-"
|
|
327
|
+
? new Uint8Array(await readStdin())
|
|
328
|
+
: await readFile(args.payload);
|
|
329
|
+
const applied = await rx.applyPayload(data);
|
|
330
|
+
if (applied) {
|
|
331
|
+
await store.saveState(state);
|
|
332
|
+
err(`✔ applied 1 delta`);
|
|
333
|
+
return 0;
|
|
334
|
+
}
|
|
335
|
+
err("✘ delta rejected (unknown issuer, bad signature, stale §9, or malformed)");
|
|
336
|
+
return 1;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
async function readStdin() {
|
|
340
|
+
const chunks = [];
|
|
341
|
+
for await (const chunk of process.stdin) chunks.push(chunk);
|
|
342
|
+
return Buffer.concat(chunks);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// ---------------------------------------------------------------------------
|
|
346
|
+
// identity remember / forget / list (work doc #5)
|
|
347
|
+
// ---------------------------------------------------------------------------
|
|
348
|
+
|
|
349
|
+
async function cmdIdentityRemember(args) {
|
|
350
|
+
const store = await openStore(args);
|
|
351
|
+
const aliases = await store.loadAliases();
|
|
352
|
+
const issuerHash = resolveIdentityHash(args.hash, aliases);
|
|
353
|
+
|
|
354
|
+
let pubKey;
|
|
355
|
+
if (args.pubkey) {
|
|
356
|
+
pubKey = hexToBytes(args.pubkey);
|
|
357
|
+
if (pubKey.length !== 64) throw new CliError(`--pubkey must be 64 bytes (128 hex), got ${pubKey.length}`);
|
|
358
|
+
} else if (args.file) {
|
|
359
|
+
pubKey = await readFile(args.file);
|
|
360
|
+
if (pubKey.length !== 64) throw new CliError(`pubkey file must contain 64 bytes, got ${pubKey.length}`);
|
|
361
|
+
} else {
|
|
362
|
+
// Boot RNS and try to recall.
|
|
363
|
+
const configDir = await resolveRnsConfigDir(args);
|
|
364
|
+
const rns = await bootRns(configDir, args.interface || "shared", {
|
|
365
|
+
verbose: !!args.verbose,
|
|
366
|
+
});
|
|
367
|
+
const { Destination } = await import("@reticulum/core");
|
|
368
|
+
const recalled = await Destination.recall(issuerHash, true);
|
|
369
|
+
if (!recalled) {
|
|
370
|
+
throw new CliError(
|
|
371
|
+
`could not recall ${shortHash(issuerHash, args.fullHashes)} from RNS; use --pubkey <hex> or --file <path>`,
|
|
372
|
+
);
|
|
373
|
+
}
|
|
374
|
+
pubKey = await recalled.getPublicKey();
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
const keyring = await store.loadKeyring();
|
|
378
|
+
keyring.registerSingle(issuerHash, pubKey);
|
|
379
|
+
await store.saveKeyring(keyring);
|
|
380
|
+
err(`✔ remembered issuer ${shortHash(issuerHash, args.fullHashes)}`);
|
|
381
|
+
err(` pubkey : ${toHex(pubKey).slice(0, SHORT_HASH)}…`);
|
|
382
|
+
err(` cache : ${keyring.size} entries`);
|
|
383
|
+
return 0;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
async function cmdIdentityForget(args) {
|
|
387
|
+
const store = await openStore(args);
|
|
388
|
+
const aliases = await store.loadAliases();
|
|
389
|
+
const issuerHash = resolveIdentityHash(args.hash, aliases);
|
|
390
|
+
|
|
391
|
+
if (!args.force) {
|
|
392
|
+
// Refuse to purge an issuer with active grants in the live CRDT.
|
|
393
|
+
const config = await store.loadConfigValidated();
|
|
394
|
+
const state = await store.loadState(config);
|
|
395
|
+
let active = 0;
|
|
396
|
+
for (const tuple of state.activeTuples()) {
|
|
397
|
+
if (toHex(tuple.issuer) === toHex(issuerHash)) active++;
|
|
398
|
+
}
|
|
399
|
+
if (active > 0) {
|
|
400
|
+
throw new CliError(
|
|
401
|
+
`issuer ${shortHash(issuerHash, args.fullHashes)} has ${active} active grant(s) in the live CRDT; ` +
|
|
402
|
+
"forgetting it would make its revokes unverifiable (use --force to override)",
|
|
403
|
+
);
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
const keyring = await store.loadKeyring();
|
|
408
|
+
if (!keyring.forget(issuerHash)) {
|
|
409
|
+
throw new CliError(`issuer ${shortHash(issuerHash, args.fullHashes)} not in the cache`);
|
|
410
|
+
}
|
|
411
|
+
await store.saveKeyring(keyring);
|
|
412
|
+
err(`✔ forgot issuer ${shortHash(issuerHash, args.fullHashes)}`);
|
|
413
|
+
err(` cache : ${keyring.size} entries`);
|
|
414
|
+
return 0;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
async function cmdIdentityList(args) {
|
|
418
|
+
const store = await openStore(args);
|
|
419
|
+
const aliases = await store.loadAliases();
|
|
420
|
+
const keyring = await store.loadKeyring();
|
|
421
|
+
err(`ISSUER IDENTITY CACHE (${keyring.size})`);
|
|
422
|
+
if (keyring.size === 0) {
|
|
423
|
+
err("(none — use `dacar identity remember <hash>` to seed)");
|
|
424
|
+
return 0;
|
|
425
|
+
}
|
|
426
|
+
for (const [hashHex, keyset] of keyring.entries()) {
|
|
427
|
+
const pub = keyset.memberPublicKeys[0];
|
|
428
|
+
err(` ${shortHash(hexToBytes(hashHex), args.fullHashes)} pubkey=${toHex(pub).slice(0, SHORT_HASH)}…`);
|
|
429
|
+
}
|
|
430
|
+
return 0;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
async function cmdGrants(args) {
|
|
434
|
+
const store = await openStore(args);
|
|
435
|
+
const config = await store.loadConfigValidated();
|
|
436
|
+
const state = await store.loadState(config);
|
|
437
|
+
const aliases = await store.loadAliases();
|
|
438
|
+
const ledger = await store.loadLedger();
|
|
439
|
+
/** @type {any[]} */ const rows = [];
|
|
440
|
+
for (const entry of state._entries.values()) {
|
|
441
|
+
const active = entry.addTs !== null && (entry.removeTs === null || entry.addTs > entry.removeTs);
|
|
442
|
+
if (args.revoked && active) continue;
|
|
443
|
+
if (!args.all && !args.revoked && !active) continue;
|
|
444
|
+
rows.push({ entry, active });
|
|
445
|
+
}
|
|
446
|
+
const label = args.revoked ? "REVOKED TOMBSTONES" : args.all ? "ALL TUPLES" : "ACTIVE GRANTS";
|
|
447
|
+
err(`${label} (${rows.length})`);
|
|
448
|
+
for (const { entry, active } of rows) {
|
|
449
|
+
const t = entry.tuple;
|
|
450
|
+
const row = ledger.get(toHex(t.key));
|
|
451
|
+
const rel = row?.relation || `[${shortHash(t.relationHash, args.fullHashes)}]`;
|
|
452
|
+
const obj = row?.object || "[hash]";
|
|
453
|
+
err(
|
|
454
|
+
`${shortHash(t.grantee, args.fullHashes)} ${rel} ${obj} ← ${shortHash(t.issuer, args.fullHashes)} ` +
|
|
455
|
+
`${active ? "active" : "revoked"}`,
|
|
456
|
+
);
|
|
457
|
+
}
|
|
458
|
+
return 0;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// ---------------------------------------------------------------------------
|
|
462
|
+
// Dispatch
|
|
463
|
+
// ---------------------------------------------------------------------------
|
|
464
|
+
|
|
465
|
+
const SUBCOMMANDS = {
|
|
466
|
+
init: { run: cmdInit, opts: { salt: "string", horizon: "string" }, online: false },
|
|
467
|
+
"config": {
|
|
468
|
+
sub: {
|
|
469
|
+
show: { run: cmdConfigShow, opts: { reveal: "boolean" }, online: false },
|
|
470
|
+
},
|
|
471
|
+
},
|
|
472
|
+
grant: {
|
|
473
|
+
run: cmdGrant,
|
|
474
|
+
opts: { publish: "boolean", node: "string", discover: "boolean", topic: "string", "rns-dir": "string", interface: "string" },
|
|
475
|
+
positional: ["grantee", "relation", "object"],
|
|
476
|
+
online: true,
|
|
477
|
+
},
|
|
478
|
+
revoke: {
|
|
479
|
+
run: cmdRevoke,
|
|
480
|
+
opts: { publish: "boolean", node: "string", discover: "boolean", topic: "string", "rns-dir": "string", interface: "string" },
|
|
481
|
+
positional: ["grantee", "relation", "object"],
|
|
482
|
+
online: true,
|
|
483
|
+
},
|
|
484
|
+
sync: {
|
|
485
|
+
run: cmdSync,
|
|
486
|
+
opts: { node: "string", discover: "boolean", topic: "string", "rns-dir": "string", interface: "string" },
|
|
487
|
+
online: true,
|
|
488
|
+
},
|
|
489
|
+
apply: { run: cmdApply, opts: { binary: "boolean" }, positional: ["payload"], online: false },
|
|
490
|
+
check: { run: cmdCheck, opts: {}, positional: ["grantee", "relation", "object"], online: false },
|
|
491
|
+
grants: { run: cmdGrants, opts: { all: "boolean", revoked: "boolean" }, online: false },
|
|
492
|
+
identity: {
|
|
493
|
+
sub: {
|
|
494
|
+
remember: {
|
|
495
|
+
run: cmdIdentityRemember,
|
|
496
|
+
opts: { pubkey: "string", file: "string", "rns-dir": "string", interface: "string", force: "boolean" },
|
|
497
|
+
positional: ["hash"],
|
|
498
|
+
online: true,
|
|
499
|
+
},
|
|
500
|
+
forget: { run: cmdIdentityForget, opts: { force: "boolean" }, positional: ["hash"], online: false },
|
|
501
|
+
list: { run: cmdIdentityList, opts: {}, online: false },
|
|
502
|
+
},
|
|
503
|
+
},
|
|
504
|
+
};
|
|
505
|
+
|
|
506
|
+
function buildOptions(spec) {
|
|
507
|
+
const opts = {};
|
|
508
|
+
for (const [k, t] of Object.entries(spec.opts || {})) {
|
|
509
|
+
opts[k] = { type: t };
|
|
510
|
+
}
|
|
511
|
+
// --verbose / -v is global: accepted by every (sub)command so it never
|
|
512
|
+
// errors out, and threaded into bootRns to raise the Reticulum log
|
|
513
|
+
// threshold + log interface/announce diagnostics.
|
|
514
|
+
opts.verbose = { type: "boolean", short: "v" };
|
|
515
|
+
if (spec.positional && spec.positional.length) {
|
|
516
|
+
opts.store = { type: "string" };
|
|
517
|
+
opts.identity = { type: "string" };
|
|
518
|
+
opts["full-hashes"] = { type: "boolean" };
|
|
519
|
+
}
|
|
520
|
+
return opts;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
async function main() {
|
|
524
|
+
if (process.argv.includes("--help") || process.argv.includes("-h")) {
|
|
525
|
+
err(`usage: dacar <command> [options]\n\ncommands: ${Object.keys(SUBCOMMANDS).join(", ")}\n\nGlobal options: --store <path>, --identity <hex|path>, --full-hashes, -v/--verbose`);
|
|
526
|
+
return 0;
|
|
527
|
+
}
|
|
528
|
+
const argv = process.argv.slice(2);
|
|
529
|
+
const [cmd, ...rest] = argv;
|
|
530
|
+
const spec = SUBCOMMANDS[cmd];
|
|
531
|
+
if (!spec) {
|
|
532
|
+
err(`usage: dacar <command> [options]\ncommands: ${Object.keys(SUBCOMMANDS).join(", ")}`);
|
|
533
|
+
return 1;
|
|
534
|
+
}
|
|
535
|
+
// Subcommand dispatch (config show, identity remember/forget/list).
|
|
536
|
+
if (spec.sub) {
|
|
537
|
+
const [sub, ...subrest] = rest;
|
|
538
|
+
const subspec = spec.sub[sub];
|
|
539
|
+
if (!subspec) {
|
|
540
|
+
err(`usage: dacar ${cmd} <subcommand>\nsubcommands: ${Object.keys(spec.sub).join(", ")}`);
|
|
541
|
+
return 1;
|
|
542
|
+
}
|
|
543
|
+
const { values, positionals } = parseArgs({
|
|
544
|
+
args: subrest,
|
|
545
|
+
options: buildOptions(subspec),
|
|
546
|
+
allowPositionals: true,
|
|
547
|
+
});
|
|
548
|
+
values.fullHashes = values["full-hashes"];
|
|
549
|
+
try {
|
|
550
|
+
return await subspec.run({ ...values, _positionals: positionals, ...Object.fromEntries(positionals.map((v, i) => [subspec.positional?.[i] ?? `_p${i}`, v])) });
|
|
551
|
+
} catch (e) {
|
|
552
|
+
if (e instanceof CliError) { err("error: " + e.message); return 1; }
|
|
553
|
+
throw e;
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
// Top-level command.
|
|
557
|
+
const { values, positionals } = parseArgs({
|
|
558
|
+
args: rest,
|
|
559
|
+
options: buildOptions(spec),
|
|
560
|
+
allowPositionals: true,
|
|
561
|
+
});
|
|
562
|
+
values.fullHashes = values["full-hashes"];
|
|
563
|
+
try {
|
|
564
|
+
return await spec.run({ ...values, _positionals: positionals, ...Object.fromEntries(positionals.map((v, i) => [spec.positional?.[i] ?? `_p${i}`, v])) });
|
|
565
|
+
} catch (e) {
|
|
566
|
+
if (e instanceof CliError) { err("error: " + e.message); return 1; }
|
|
567
|
+
throw e;
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
main().then((code) => process.exit(code ?? 0)).catch((e) => {
|
|
572
|
+
err("fatal: " + (e?.stack || e));
|
|
573
|
+
process.exit(1);
|
|
574
|
+
});
|