@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/README.md +215 -0
- package/package.json +47 -0
- package/src/challenge.js +394 -0
- package/src/config.js +154 -0
- package/src/crdt.js +318 -0
- package/src/delta.js +108 -0
- package/src/engine.js +198 -0
- package/src/hlc.js +104 -0
- package/src/index.js +88 -0
- package/src/namespace.js +183 -0
- package/src/naming.js +39 -0
- package/src/operation.js +256 -0
- package/src/threshold.js +110 -0
- package/src/transport/index.js +37 -0
- package/src/transport/lxmfSync.js +221 -0
- package/src/transport/rfedSync.js +228 -0
- package/src/transport/rnsChallenge.js +243 -0
- package/src/transport/rnsIdentity.js +84 -0
- package/src/tuple.js +120 -0
- package/src/verifier.js +183 -0
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";
|
package/src/namespace.js
ADDED
|
@@ -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";
|