@reticulum/dacar 1.0.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/src/engine.js ADDED
@@ -0,0 +1,198 @@
1
+ /**
2
+ * The evaluation engine (§7).
3
+ *
4
+ * Resolves a plaintext request `(Object, Relation, Grantee)` against the local
5
+ * CRDT state and the recursive delegation graph, terminating at a Root Trust
6
+ * Anchor.
7
+ *
8
+ * Resolution (§7.3): DENY if any valid active Deny Tuple exists; else ALLOW if
9
+ * any valid active Allow Tuple exists; else DENY. "Valid" means the granting
10
+ * Issuer's authority traces back to a Root Trust Anchor (directly, or recursively
11
+ * via the reserved `admin` relation).
12
+ *
13
+ * Namespace Label Privacy (§3.3) means the engine never compares plaintext
14
+ * labels: it hashes the request with every configured salt (§10.2) and matches
15
+ * the byte arrays against hashed Tuples. The total-work bound (§7.2) is enforced
16
+ * *per request across all salt tracks simultaneously* (§10.2).
17
+ *
18
+ * Hashing (Web Crypto) is asynchronous, so `evaluate()` is async. To keep the
19
+ * recursive core fast and synchronous, every per-salt hash needed during
20
+ * evaluation — including the `admin`/`-admin` relation hashes used by authority
21
+ * recursion — is precomputed up front into a hypothesis object. The challenge
22
+ * server (§8) builds the same hypothesis objects straight from the wire and
23
+ * calls the synchronous `evaluateHashes()`.
24
+ */
25
+
26
+ import { toHex } from "@reticulum/core";
27
+ import { covers } from "./namespace.js";
28
+
29
+ /** Maximum delegation hops in a single evaluation path (§7.2). */
30
+ export const DEFAULT_MAX_DEPTH = 10;
31
+ /** Maximum evaluation steps (visited nodes) per request (§7.2). */
32
+ export const DEFAULT_MAX_VISITED = 50;
33
+ /** The reserved relation that confers the authority to delegate (§3.2). */
34
+ export const ADMIN_RELATION = "admin";
35
+
36
+ /**
37
+ * @typedef {Object} Hypothesis
38
+ * @property {import("./namespace.js").NamespaceHasher} hasher
39
+ * @property {Uint8Array[]} objectHashes Exact request object hashes for this salt.
40
+ * @property {Uint8Array} allowRelationHash HMAC of the requested relation.
41
+ * @property {Uint8Array} denyRelationHash HMAC of "-"+requested relation.
42
+ * @property {Uint8Array} adminAllowHash HMAC of "admin".
43
+ * @property {Uint8Array} adminDenyHash HMAC of "-admin".
44
+ */
45
+
46
+ /**
47
+ * @typedef {Object} EngineOptions
48
+ * @property {number} [maxDepth]
49
+ * @property {number} [maxVisited]
50
+ */
51
+
52
+ export class Engine {
53
+ /**
54
+ * @param {import("./config.js").Config} config
55
+ * @param {import("./crdt.js").StateVector} state
56
+ * @param {EngineOptions} [options]
57
+ */
58
+ constructor(config, state, options = {}) {
59
+ this.config = config;
60
+ this.state = state;
61
+ this.maxDepth = options.maxDepth ?? DEFAULT_MAX_DEPTH;
62
+ this.maxVisited = options.maxVisited ?? DEFAULT_MAX_VISITED;
63
+ }
64
+
65
+ /**
66
+ * Hash the plaintext request with every configured salt (§7.1, §10.2) and
67
+ * resolve it. Returns true iff (object, relation, grantee) is ALLOWED.
68
+ * @param {string} objectId
69
+ * @param {string} relation
70
+ * @param {Uint8Array} grantee
71
+ * @returns {Promise<boolean>}
72
+ */
73
+ async evaluate(objectId, relation, grantee) {
74
+ const denyRelation = "-" + relation;
75
+ const hypotheses = await Promise.all(
76
+ this.config.hashers.map(async (hasher) => {
77
+ const [objectHashes, allowRelationHash, denyRelationHash, adminAllowHash, adminDenyHash] =
78
+ await Promise.all([
79
+ hasher.hashObject(objectId),
80
+ hasher.hashRelation(relation),
81
+ hasher.hashRelation(denyRelation),
82
+ hasher.hashRelation(ADMIN_RELATION),
83
+ hasher.hashRelation("-" + ADMIN_RELATION),
84
+ ]);
85
+ return {
86
+ hasher,
87
+ objectHashes: objectHashes.hashes,
88
+ allowRelationHash,
89
+ denyRelationHash,
90
+ adminAllowHash,
91
+ adminDenyHash,
92
+ };
93
+ }),
94
+ );
95
+ return this.evaluateHashes(grantee, hypotheses);
96
+ }
97
+
98
+ /**
99
+ * Evaluate pre-hashed per-salt hypotheses (§7.3, §10.2). Synchronous: all
100
+ * required hashes are already present in each hypothesis. The total-work bound
101
+ * is shared across all hypotheses. Used by the §8 challenge server.
102
+ * @param {Uint8Array} grantee
103
+ * @param {Hypothesis[]} hypotheses
104
+ * @returns {boolean}
105
+ */
106
+ evaluateHashes(grantee, hypotheses) {
107
+ // Index active tuples by grantee hex for this request.
108
+ /** @type {Map<string, import("./tuple.js").Tuple[]>} */
109
+ const index = new Map();
110
+ const granteeHex = toHex(grantee);
111
+ for (const t of this.state.activeTuples()) {
112
+ const g = toHex(t.grantee);
113
+ const arr = index.get(g);
114
+ if (arr) arr.push(t);
115
+ else index.set(g, [t]);
116
+ }
117
+ /** @type {Map<string, boolean>} memo of positive authority results */
118
+ const memo = new Map();
119
+ let counter = 0;
120
+ const { config, maxDepth, maxVisited } = this;
121
+ const hyps = [...hypotheses];
122
+
123
+ /** @param {Hypothesis[]} hs @returns {string} */
124
+ const objectKey = (hs) => hs.map((h) => toHex(h.hasher.salt) + "|" + h.objectHashes.map(toHex).join(".")).join(";");
125
+
126
+ /**
127
+ * @param {Uint8Array} issuer
128
+ * @param {Hypothesis[]} hs
129
+ * @param {number} depth
130
+ * @param {Set<string>} visited
131
+ * @returns {boolean}
132
+ */
133
+ function authority(issuer, hs, depth, visited) {
134
+ if (config.isRootAnchor(issuer)) return true; // §7.2 terminal trust anchor
135
+ const key = toHex(issuer) + "|" + objectKey(hs);
136
+ if (memo.has(key)) return /** @type {boolean} */ (memo.get(key));
137
+ if (depth >= maxDepth) return false; // §7.2 recursion depth bound
138
+ const issuerHex = toHex(issuer);
139
+ if (visited.has(issuerHex)) return false; // §7.2 cycle detection
140
+ const nextVisited = new Set(visited);
141
+ nextVisited.add(issuerHex);
142
+ // Build admin hypotheses reusing the same object hashes.
143
+ const adminHyps = hs.map((h) => ({
144
+ hasher: h.hasher,
145
+ objectHashes: h.objectHashes,
146
+ allowRelationHash: h.adminAllowHash,
147
+ denyRelationHash: h.adminDenyHash,
148
+ adminAllowHash: h.adminAllowHash,
149
+ adminDenyHash: h.adminDenyHash,
150
+ }));
151
+ const result = _resolve(adminHyps, issuer, depth + 1, nextVisited) === "allow";
152
+ if (result) memo.set(key, true);
153
+ return result;
154
+ }
155
+
156
+ /**
157
+ * @param {Hypothesis[]} hs
158
+ * @param {Uint8Array} granteeId
159
+ * @param {number} depth
160
+ * @param {Set<string>} visited
161
+ * @returns {"deny" | "allow" | "none"}
162
+ */
163
+ function _resolve(hs, granteeId, depth, visited) {
164
+ counter += 1;
165
+ if (counter > maxVisited) return "none"; // §7.2 / §10.2 shared total-work bound
166
+ const gid = toHex(granteeId);
167
+ const candidates = index.get(gid) ?? [];
168
+ let denyValid = false;
169
+ let allowValid = false;
170
+ for (const candidate of candidates) {
171
+ for (const h of hs) {
172
+ if (bytesEqualHash(candidate.relationHash, h.denyRelationHash)) {
173
+ if (covers(candidate.objectHashes, candidate.wildcard, h.objectHashes)) {
174
+ if (authority(candidate.issuer, hs, depth, visited)) denyValid = true;
175
+ }
176
+ } else if (bytesEqualHash(candidate.relationHash, h.allowRelationHash)) {
177
+ if (covers(candidate.objectHashes, candidate.wildcard, h.objectHashes)) {
178
+ if (authority(candidate.issuer, hs, depth, visited)) allowValid = true;
179
+ }
180
+ }
181
+ }
182
+ }
183
+ if (denyValid) return "deny";
184
+ if (allowValid) return "allow";
185
+ return "none";
186
+ }
187
+
188
+ return _resolve(hyps, grantee, 0, new Set()) === "allow";
189
+ }
190
+ }
191
+
192
+ /** Fast hex-free 16-byte equality for relation hashes. @param {Uint8Array} a @param {Uint8Array} b @returns {boolean} */
193
+ function bytesEqualHash(a, b) {
194
+ if (a.length !== b.length) return false;
195
+ let diff = 0;
196
+ for (let i = 0; i < a.length; i++) diff |= a[i] ^ b[i];
197
+ return diff === 0;
198
+ }
package/src/hlc.js ADDED
@@ -0,0 +1,104 @@
1
+ /**
2
+ * Hybrid Logical Clocks (Dacar spec §5.1).
3
+ *
4
+ * An HLC packs into a single 64-bit unsigned integer, transmitted big-endian:
5
+ * high 48 bits: physical time (Unix epoch, milliseconds)
6
+ * low 16 bits : logical counter
7
+ *
8
+ * Packed HLCs are represented as ECMAScript `bigint`, because
9
+ * `physical_ms << 16` exceeds `Number.MAX_SAFE_INTEGER` for any realistic
10
+ * timestamp.
11
+ */
12
+
13
+ export const PHYSICAL_BITS = 48n;
14
+ export const LOGICAL_BITS = 16n;
15
+ /** @type {bigint} 0xFFFF */
16
+ export const LOGICAL_MASK = (1n << LOGICAL_BITS) - 1n;
17
+ /** @type {bigint} 2^48 - 1 */
18
+ export const MAX_PHYSICAL = (1n << PHYSICAL_BITS) - 1n;
19
+ /** @type {bigint} 2^16 - 1 */
20
+ export const MAX_LOGICAL = LOGICAL_MASK;
21
+ /** @type {bigint} 2^64 - 1 */
22
+ export const MAX_HLC = (1n << 64n) - 1n;
23
+
24
+ /**
25
+ * Pack a physical timestamp (ms) and logical counter into a 64-bit HLC.
26
+ * @param {number} physicalMs
27
+ * @param {number} logical
28
+ * @returns {bigint}
29
+ */
30
+ export function packHlc(physicalMs, logical) {
31
+ if (!Number.isInteger(physicalMs) || physicalMs < 0 || BigInt(physicalMs) > MAX_PHYSICAL) {
32
+ throw new RangeError(`physicalMs must fit in 48 bits, got ${physicalMs}`);
33
+ }
34
+ if (!Number.isInteger(logical) || logical < 0 || BigInt(logical) > MAX_LOGICAL) {
35
+ throw new RangeError(`logical must fit in 16 bits, got ${logical}`);
36
+ }
37
+ return (BigInt(physicalMs) << LOGICAL_BITS) | BigInt(logical);
38
+ }
39
+
40
+ /**
41
+ * Unpack an HLC into its physical (ms) and logical parts (both safe Numbers).
42
+ * @param {bigint} hlc
43
+ * @returns {{ physicalMs: number, logical: number }}
44
+ */
45
+ export function unpackHlc(hlc) {
46
+ if (typeof hlc !== "bigint" || hlc < 0n || hlc > MAX_HLC) {
47
+ throw new RangeError(`hlc must fit in 64 bits, got ${hlc}`);
48
+ }
49
+ return {
50
+ physicalMs: Number(hlc >> LOGICAL_BITS),
51
+ logical: Number(hlc & LOGICAL_MASK),
52
+ };
53
+ }
54
+
55
+ /**
56
+ * Current wall-clock time in milliseconds since the Unix epoch.
57
+ * @returns {number}
58
+ */
59
+ export function physicalNowMs() {
60
+ return Date.now();
61
+ }
62
+
63
+ /**
64
+ * A process-local HLC generator producing monotonically non-decreasing
65
+ * timestamps, able to absorb remote HLCs observed during sync.
66
+ */
67
+ export class Clock {
68
+ #lastMs = 0;
69
+ #logical = 0;
70
+
71
+ /** Advance from a local event and return the new HLC. @returns {bigint} */
72
+ now() {
73
+ const phys = physicalNowMs();
74
+ if (phys > this.#lastMs) {
75
+ this.#lastMs = phys;
76
+ this.#logical = 0;
77
+ } else {
78
+ this.#logical += 1;
79
+ }
80
+ return packHlc(this.#lastMs, this.#logical);
81
+ }
82
+
83
+ /**
84
+ * Absorb a remote HLC observed during sync and return the new local HLC.
85
+ * @param {bigint} remoteHlc
86
+ * @returns {bigint}
87
+ */
88
+ observe(remoteHlc) {
89
+ const { physicalMs: rphys, logical: rlog } = unpackHlc(remoteHlc);
90
+ const phys = physicalNowMs();
91
+ if (phys > this.#lastMs && phys > rphys) {
92
+ this.#lastMs = phys;
93
+ this.#logical = 0;
94
+ } else if (rphys > this.#lastMs) {
95
+ this.#lastMs = rphys;
96
+ this.#logical = rlog + 1;
97
+ } else if (this.#lastMs > rphys) {
98
+ this.#logical += 1;
99
+ } else {
100
+ this.#logical = Math.max(this.#logical, rlog) + 1;
101
+ }
102
+ return packHlc(this.#lastMs, this.#logical);
103
+ }
104
+ }
package/src/index.js ADDED
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Dacar: Decentralized Access Control for Reticulum (JavaScript reference impl).
3
+ *
4
+ * A tuple-based, offline-first authorization policy plane built on an
5
+ * LWW-Element-Set CRDT, designed for delay-tolerant mesh networks.
6
+ *
7
+ * Object and relation labels are stored only as salted HMAC-SHA256 hashes
8
+ * (§3.3 Namespace Label Privacy), Threshold Groups may act as N-of-M Issuers
9
+ * (§4.1), and the state is bounded by Time-Horizon Tombstone Pruning (§9).
10
+ */
11
+
12
+ export const __version__ = "1.0.0";
13
+ export const __specVersion__ = "1.0-RC7";
14
+
15
+ // HLC (§5.1)
16
+ export {
17
+ PHYSICAL_BITS,
18
+ LOGICAL_BITS,
19
+ LOGICAL_MASK,
20
+ MAX_PHYSICAL,
21
+ MAX_LOGICAL,
22
+ MAX_HLC,
23
+ packHlc,
24
+ unpackHlc,
25
+ physicalNowMs,
26
+ Clock,
27
+ } from "./hlc.js";
28
+
29
+ // Namespace Label Privacy (§3.3)
30
+ export {
31
+ DELIMITER,
32
+ WILDCARD,
33
+ SALT_SIZE,
34
+ HASH_SIZE,
35
+ DEFAULT_SALT,
36
+ MAX_LEGACY_SALTS,
37
+ NamespaceHasher,
38
+ covers,
39
+ split,
40
+ parseObject,
41
+ bytesEqual,
42
+ } from "./namespace.js";
43
+
44
+ // Tuple (§3.1, §6.1)
45
+ export { MAX_SEGMENTS, Tuple } from "./tuple.js";
46
+
47
+ // Threshold Groups (§4.1)
48
+ export { ThresholdGroup, groupId } from "./threshold.js";
49
+
50
+ // Operations (§5.2, §5.3)
51
+ export { SIGNATURE_SIZE, HLC_BYTES, Action, Operation } from "./operation.js";
52
+
53
+ // Verify-on-ingest (§11.2.4)
54
+ export { IssuerKeyset, Keyring, verifyOperation } from "./verifier.js";
55
+
56
+ // Transport-agnostic Delta receive boundary (§11)
57
+ export { DeltaReceiver } from "./delta.js";
58
+
59
+ // RNS naming conventions (§8, §11)
60
+ export {
61
+ APP_NAME,
62
+ CHALLENGE_ASPECTS,
63
+ CHALLENGE_DESTINATION,
64
+ RFED_TOPIC,
65
+ LXMF_DELIVERY_TITLE,
66
+ } from "./naming.js";
67
+
68
+ // Config (§4, §10) + state (§6, §9)
69
+ export { Config, DEFAULT_DELETION_HORIZON_DAYS } from "./config.js";
70
+ export { StateVector } from "./crdt.js";
71
+
72
+ // Engine (§7)
73
+ export {
74
+ Engine,
75
+ ADMIN_RELATION,
76
+ DEFAULT_MAX_DEPTH,
77
+ DEFAULT_MAX_VISITED,
78
+ } from "./engine.js";
79
+
80
+ // Challenge (§8)
81
+ export {
82
+ NONCE_SIZE,
83
+ Verdict,
84
+ Challenge,
85
+ Receipt,
86
+ AuthoritativeServer,
87
+ ChallengeClient,
88
+ } from "./challenge.js";
@@ -0,0 +1,183 @@
1
+ /**
2
+ * Namespace Label Privacy (Dacar spec §3.3).
3
+ *
4
+ * To prevent label disclosure over public transports, Dacar never transmits or
5
+ * stores Object or Relation strings in plaintext. Every string label is hashed
6
+ * with **HMAC-SHA256**, keyed with the node's Privacy Salt, and strictly
7
+ * truncated to the first 16 bytes.
8
+ *
9
+ * Objects are split by `:` into segments, each hashed individually. The
10
+ * terminal suffix wildcard `*` is stripped *before* hashing and carried as a
11
+ * boolean flag on the Tuple (§3.3).
12
+ *
13
+ * > WARNING (§3.3): an unset Privacy Salt defaults to 32 null bytes, which is
14
+ * > *fail-open on privacy* — the hashes become trivially dictionary-attackable.
15
+ *
16
+ * Hashing uses the Web Crypto `HMAC`/`SHA-256` primitives, so all methods are
17
+ * asynchronous and runtime-portable (browsers, Node, Deno, Bun).
18
+ */
19
+
20
+ export const DELIMITER = ":";
21
+ export const WILDCARD = "*";
22
+
23
+ /** Privacy Salts are 32 bytes of cryptographically secure random data. */
24
+ export const SALT_SIZE = 32;
25
+ /** All label hashes (and RNS.Identity hashes) are 16 bytes. */
26
+ export const HASH_SIZE = 16;
27
+ /** The fail-open default salt when none is configured (§3.3 WARNING). */
28
+ export const DEFAULT_SALT = new Uint8Array(SALT_SIZE);
29
+ /** Maximum number of concurrently-configured Legacy Salts (§10.2). */
30
+ export const MAX_LEGACY_SALTS = 2;
31
+
32
+ /** Domain-separation tag used to derive a salt's identifying `id_tag` (§8.3). */
33
+ const SALT_ID_TAG = new TextEncoder().encode("dacar.salt.id");
34
+
35
+ const encoder = new TextEncoder();
36
+
37
+ /**
38
+ * Split an object string into its colon-delimited segments.
39
+ * @param {string} objectId
40
+ * @returns {string[]}
41
+ */
42
+ export function split(objectId) {
43
+ return objectId.split(DELIMITER);
44
+ }
45
+
46
+ /**
47
+ * Return `{ segments, wildcard }` for an object string.
48
+ *
49
+ * The terminal `*` is stripped and reported via the wildcard flag:
50
+ * - `"*"` -> `{ segments: [], wildcard: true }` (root wildcard)
51
+ * - `"sensor:*"` -> `{ segments: ["sensor"], wildcard: true }`
52
+ * - `"sensor:wind"`-> `{ segments: ["sensor","wind"], wildcard: false }`
53
+ *
54
+ * A non-terminal `*` is treated as a literal segment.
55
+ * @param {string} objectId
56
+ * @returns {{ segments: string[], wildcard: boolean }}
57
+ */
58
+ export function parseObject(objectId) {
59
+ if (objectId === WILDCARD) return { segments: [], wildcard: true };
60
+ const segments = split(objectId);
61
+ let wildcard = false;
62
+ if (segments.length > 0 && segments[segments.length - 1] === WILDCARD) {
63
+ wildcard = true;
64
+ segments.pop();
65
+ }
66
+ return { segments, wildcard };
67
+ }
68
+
69
+ /**
70
+ * Truncate a 32-byte HMAC-SHA256 digest to the first 16 bytes (§3.3).
71
+ * @param {Uint8Array} digest
72
+ * @returns {Uint8Array}
73
+ */
74
+ function truncate16(digest) {
75
+ return digest.slice(0, HASH_SIZE);
76
+ }
77
+
78
+ export class NamespaceHasher {
79
+ /**
80
+ * @param {Uint8Array} [salt] 32-byte Privacy Salt (defaults to fail-open nulls).
81
+ */
82
+ constructor(salt = DEFAULT_SALT) {
83
+ if (!(salt instanceof Uint8Array) || salt.length !== SALT_SIZE) {
84
+ throw new RangeError(`salt must be ${SALT_SIZE} bytes`);
85
+ }
86
+ /** @readonly @type {Uint8Array} */
87
+ this.salt = salt;
88
+ /** Lazily-imported HMAC CryptoKey, shared across all sign calls. */
89
+ this._keyPromise = null;
90
+ }
91
+
92
+ /** @returns {Promise<CryptoKey>} */
93
+ _key() {
94
+ if (!this._keyPromise) {
95
+ this._keyPromise = crypto.subtle.importKey(
96
+ "raw",
97
+ this.salt,
98
+ { name: "HMAC", hash: "SHA-256" },
99
+ false,
100
+ ["sign"],
101
+ );
102
+ }
103
+ return this._keyPromise;
104
+ }
105
+
106
+ /**
107
+ * HMAC-SHA256(salt, relation) truncated to 16 bytes (§3.3).
108
+ * @param {string} relation
109
+ * @returns {Promise<Uint8Array>}
110
+ */
111
+ async hashRelation(relation) {
112
+ const key = await this._key();
113
+ const mac = new Uint8Array(await crypto.subtle.sign("HMAC", key, encoder.encode(relation)));
114
+ return truncate16(mac);
115
+ }
116
+
117
+ /**
118
+ * Return `{ hashes, wildcard }` for an object string (§3.3).
119
+ * @param {string} objectId
120
+ * @returns {Promise<{ hashes: Uint8Array[], wildcard: boolean }>}
121
+ */
122
+ async hashObject(objectId) {
123
+ const { segments, wildcard } = parseObject(objectId);
124
+ const key = await this._key();
125
+ const hashes = await Promise.all(
126
+ segments.map(async (seg) => {
127
+ const mac = new Uint8Array(await crypto.subtle.sign("HMAC", key, encoder.encode(seg)));
128
+ return truncate16(mac);
129
+ }),
130
+ );
131
+ return { hashes, wildcard };
132
+ }
133
+
134
+ /**
135
+ * A 16-byte tag identifying this salt (§8.3 `salt_id_tag`):
136
+ * `HMAC-SHA256(salt, b"dacar.salt.id")` truncated to 16 bytes.
137
+ * @returns {Promise<Uint8Array>}
138
+ */
139
+ async idTag() {
140
+ const key = await this._key();
141
+ const mac = new Uint8Array(await crypto.subtle.sign("HMAC", key, SALT_ID_TAG));
142
+ return truncate16(mac);
143
+ }
144
+ }
145
+
146
+ /**
147
+ * Does a Tuple's hashed Object cover a request's exact hashed Object? (§3.3)
148
+ *
149
+ * A match succeeds if the Tuple is wildcarded and its hashes are a *prefix* of
150
+ * the request hashes, or if the two hash arrays are identical.
151
+ * @param {Uint8Array[]} tupleHashes
152
+ * @param {boolean} wildcard
153
+ * @param {Uint8Array[]} requestHashes
154
+ * @returns {boolean}
155
+ */
156
+ export function covers(tupleHashes, wildcard, requestHashes) {
157
+ if (wildcard) {
158
+ if (tupleHashes.length > requestHashes.length) return false;
159
+ for (let i = 0; i < tupleHashes.length; i++) {
160
+ if (!bytesEqual(tupleHashes[i], requestHashes[i])) return false;
161
+ }
162
+ return true;
163
+ }
164
+ if (tupleHashes.length !== requestHashes.length) return false;
165
+ for (let i = 0; i < tupleHashes.length; i++) {
166
+ if (!bytesEqual(tupleHashes[i], requestHashes[i])) return false;
167
+ }
168
+ return true;
169
+ }
170
+
171
+ /**
172
+ * Constant-time-ish byte comparison.
173
+ * @param {Uint8Array} a
174
+ * @param {Uint8Array} b
175
+ * @returns {boolean}
176
+ */
177
+ export function bytesEqual(a, b) {
178
+ if (!(a instanceof Uint8Array) || !(b instanceof Uint8Array)) return false;
179
+ if (a.length !== b.length) return false;
180
+ let diff = 0;
181
+ for (let i = 0; i < a.length; i++) diff |= a[i] ^ b[i];
182
+ return diff === 0;
183
+ }
package/src/naming.js ADDED
@@ -0,0 +1,39 @@
1
+ /**
2
+ * RNS naming conventions for the Dacar policy plane (spec §8, §11).
3
+ *
4
+ * Pure, dependency-free constants. Two scopes:
5
+ *
6
+ * - `RFED_TOPIC` is a *deployment-overridable default*. RFed is a broadcast
7
+ * (many-to-many) medium, so deployments sharing an RNS network SHOULD set a
8
+ * deployment-specific topic to isolate their policy feeds (verify-on-ingest
9
+ * limits cross-feed damage, but not the bandwidth cost or the risk of
10
+ * shared root anchors).
11
+ * - `CHALLENGE_DESTINATION` and `LXMF_DELIVERY_TITLE` are *fixed
12
+ * discriminators*. The §8 Challenge and §11.2 LXMF delivery are addressed
13
+ * point-to-point to a specific Identity, so RNS derives isolation from the
14
+ * destination *hash* (which embeds the target Identity), not from this name.
15
+ *
16
+ * Both the pure core and the (optional) transport adapters reference these, so
17
+ * the on-wire naming is defined in one place and stays consistent across
18
+ * language implementations. Transport adapters accept overrides (e.g.
19
+ * `topic = RFED_TOPIC`) for deployment-specific values.
20
+ */
21
+
22
+ /** The RNS App Name under which all Dacar services live (§8, §11). */
23
+ export const APP_NAME = "dacar";
24
+
25
+ /** Aspects of the §8 Authoritative Challenge destination (App `dacar`). */
26
+ export const CHALLENGE_ASPECTS = Object.freeze(["auth", "v1"]);
27
+
28
+ /** The full dotted name of the §8 Authoritative Challenge destination. */
29
+ export const CHALLENGE_DESTINATION = "dacar.auth.v1";
30
+
31
+ /**
32
+ * RFed topic for many-to-many CRDT convergence (§11.1). Deployment-overridable
33
+ * default — RFed is broadcast, so shared-network deployments SHOULD set a
34
+ * distinct topic to isolate their feeds.
35
+ */
36
+ export const RFED_TOPIC = "dacar.policy.v1";
37
+
38
+ /** LXMF message title for targeted Delta delivery (§11.2). */
39
+ export const LXMF_DELIVERY_TITLE = "dacar/sync/delta";