@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/README.md
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
# `@reticulum/dacar` — JavaScript implementation
|
|
2
|
+
|
|
3
|
+
A JavaScript implementation of the [Dacar 1.0-RC6](../README.md) specification:
|
|
4
|
+
decentralized, offline-first access control for Reticulum mesh networks.
|
|
5
|
+
|
|
6
|
+
Dacar is a tuple-based authorization system (inspired by Google Zanzibar) built
|
|
7
|
+
on an LWW-Element-Set CRDT. Each node evaluates permissions locally against a
|
|
8
|
+
replicated, eventually-consistent authorization state, with delegation chains
|
|
9
|
+
that terminate at configured Root Trust Anchors.
|
|
10
|
+
|
|
11
|
+
This is a modern ES-module package with JSDoc type annotations that runs on
|
|
12
|
+
**browsers, Node.js, Deno, and Bun** with no build step.
|
|
13
|
+
|
|
14
|
+
## Dependencies
|
|
15
|
+
|
|
16
|
+
| Need | Choice | Why |
|
|
17
|
+
| ---- | ------ | --- |
|
|
18
|
+
| Ed25519 sign/verify (§5.2) | `@reticulum/core` `Identity` (Web Crypto) | The canonical Reticulum JS stack |
|
|
19
|
+
| HMAC-SHA256 / SHA-256 (§3.3, §6.1) | Web Crypto `crypto.subtle` | Standard, runtime-portable |
|
|
20
|
+
| MessagePack (§5.3) | `@reticulum/core` `MsgPack` | Reuses the canonical stack's encoder |
|
|
21
|
+
|
|
22
|
+
`@reticulum/core` is the only dependency (for both the core and the optional
|
|
23
|
+
transport adapters — which additionally use its `Destination`, `Link`,
|
|
24
|
+
`LXMRouter`, and `RFedClient`).
|
|
25
|
+
|
|
26
|
+
> **Note on MessagePack + 64-bit HLCs:** a packed HLC (`physical_ms << 16`)
|
|
27
|
+
> exceeds `Number.MAX_SAFE_INTEGER`, so it is represented as a `bigint`. The HLC
|
|
28
|
+
> is always encoded as a `bigint` (uint64) and normalized with `BigInt()` on
|
|
29
|
+
> decode, so it round-trips losslessly.
|
|
30
|
+
|
|
31
|
+
## Install
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
cd javascript
|
|
35
|
+
npm install
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Layout
|
|
39
|
+
|
|
40
|
+
```
|
|
41
|
+
src/
|
|
42
|
+
hlc.js §5.1 Hybrid Logical Clocks (bigint, 64-bit packed, big-endian)
|
|
43
|
+
namespace.js §3.3 Namespace Label Privacy: salted HMAC-SHA256 hashing,
|
|
44
|
+
object segmenting, wildcard flag, hashed-object matching
|
|
45
|
+
tuple.js §3.1, §6.1 hashed authorization Tuple + SHA-256 Tuple Hash
|
|
46
|
+
threshold.js §4.1 N-of-M Threshold Groups + 16-byte Group ID
|
|
47
|
+
operation.js §5.2, §5.3 signed Operation (single + multi-sig), pre-image,
|
|
48
|
+
MessagePack transport payload
|
|
49
|
+
crdt.js §6, §9 LWW-Element-Set state, merge (Remove wins ties),
|
|
50
|
+
Time-Horizon Tombstone Pruning, intake rejection
|
|
51
|
+
config.js §4, §10 Root Trust Anchors, Privacy Salts (Primary + Legacy),
|
|
52
|
+
Authoritative Identity, deletion horizon
|
|
53
|
+
engine.js §7 recursive delegation evaluation, hashed hypotheses,
|
|
54
|
+
multi-salt shared work bound
|
|
55
|
+
challenge.js §8 Strict Consistency Challenge (hashed, multi-salt) +
|
|
56
|
+
signed Freshness Receipts
|
|
57
|
+
verifier.js §5.2, §11.2.4 verify-on-ingest: IssuerKeyset, KeyResolver,
|
|
58
|
+
Keyring, verifyOperation()
|
|
59
|
+
delta.js §11.2.4 DeltaReceiver — transport-agnostic receive boundary
|
|
60
|
+
(decode → verify → apply)
|
|
61
|
+
naming.js §8, §11 RNS naming constants (fixed discriminators +
|
|
62
|
+
configurable RFed topic)
|
|
63
|
+
index.js public API
|
|
64
|
+
transport/ §8, §11.2, §11.3 optional RNS/RFed/LXMF adapters (opt-in):
|
|
65
|
+
rnsIdentity.js §3.1, §11.2.4 RnsIdentityResolver (recall → verify key)
|
|
66
|
+
rnsChallenge.js §8 Challenge over an RNS Link (server endpoint +
|
|
67
|
+
client transport + establishLink)
|
|
68
|
+
lxmfSync.js §11.2/§11.3 targeted LXMF Delta delivery + Paper Messages
|
|
69
|
+
rfedSync.js §11.1 RFed many-to-many convergence
|
|
70
|
+
index.js transport barrel
|
|
71
|
+
test/ node:test smoketests for every module (incl. transport-*)
|
|
72
|
+
scripts/test.sh cross-runtime runner (node / deno / bun)
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## Quick start
|
|
76
|
+
|
|
77
|
+
```js
|
|
78
|
+
import {
|
|
79
|
+
Action,
|
|
80
|
+
Clock,
|
|
81
|
+
Config,
|
|
82
|
+
Engine,
|
|
83
|
+
NamespaceHasher,
|
|
84
|
+
Operation,
|
|
85
|
+
StateVector,
|
|
86
|
+
Tuple,
|
|
87
|
+
} from "@reticulum/dacar";
|
|
88
|
+
import { Identity } from "@reticulum/core";
|
|
89
|
+
|
|
90
|
+
const anchor = await Identity.generate(); // your Root Trust Anchor
|
|
91
|
+
const ROOT = anchor.identityHash; // 16-byte hash
|
|
92
|
+
|
|
93
|
+
const hasher = new NamespaceHasher(); // default salt is FAIL-OPEN; supply a real 32-byte salt!
|
|
94
|
+
const state = new StateVector();
|
|
95
|
+
const clock = new Clock();
|
|
96
|
+
|
|
97
|
+
// Bootstrap: the root anchor grants "read" on "sensor:wind".
|
|
98
|
+
const op = await new Operation({
|
|
99
|
+
tuple: await Tuple.fromPlaintext({
|
|
100
|
+
objectId: "sensor:wind", relation: "read", grantee: ROOT, issuer: ROOT, hasher,
|
|
101
|
+
}),
|
|
102
|
+
action: Action.GRANT,
|
|
103
|
+
hlc: clock.now(),
|
|
104
|
+
}).sign(anchor);
|
|
105
|
+
state.apply(op);
|
|
106
|
+
|
|
107
|
+
const engine = new Engine(new Config({ rootTrustAnchors: [ROOT] }), state);
|
|
108
|
+
console.log(await engine.evaluate("sensor:wind", "read", ROOT)); // true
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
## Transport layer
|
|
112
|
+
|
|
113
|
+
The pure core has no transport: it is wired to the Reticulum transports by the
|
|
114
|
+
optional `@reticulum/dacar/transport` subpath. Importing the core
|
|
115
|
+
(`@reticulum/dacar`) never pulls it in, and every adapter depends only on
|
|
116
|
+
`@reticulum/core` (already a core dependency), so it adds **no new dependency**.
|
|
117
|
+
|
|
118
|
+
Every transport funnels received bytes through the same verify-on-ingest seam
|
|
119
|
+
(`DeltaReceiver.applyPayload()`, §11.2.4): a Delta is decoded, authenticated by
|
|
120
|
+
Ed25519 signature, and only then merged — regardless of whether it arrived over
|
|
121
|
+
RFed, LXMF, or a scanned QR code.
|
|
122
|
+
|
|
123
|
+
For **bulk / full-state convergence** (e.g. RFed catch-up), pack many signed
|
|
124
|
+
Deltas into one message and ingest them as a batch with `DeltaReceiver.applyPayloads()`
|
|
125
|
+
— each element is still independently verified-on-ingest, so a forged element
|
|
126
|
+
is dropped without aborting the rest. This is the secure alternative to
|
|
127
|
+
`StateVector.merge()`, which is trusted-local-only (snapshot/restore of a
|
|
128
|
+
node's own state) and must never be fed network bytes:
|
|
129
|
+
|
|
130
|
+
```js
|
|
131
|
+
const batch = DeltaReceiver.packPayloads([opA.toPayload(), opB.toPayload()]);
|
|
132
|
+
const applied = await rx.applyPayloads(batch); // → # of Deltas verified + applied
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
```js
|
|
136
|
+
import {
|
|
137
|
+
RnsIdentityResolver,
|
|
138
|
+
LxmfDeltaDelivery,
|
|
139
|
+
RfedDeltaSync,
|
|
140
|
+
RnsChallengeServer,
|
|
141
|
+
RnsLinkTransport,
|
|
142
|
+
establishLink,
|
|
143
|
+
} from "@reticulum/dacar/transport";
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
| Adapter | Spec | Role |
|
|
147
|
+
| ------- | ---- | ---- |
|
|
148
|
+
| `RnsIdentityResolver` | §3.1, §11.2.4 | Resolves a single-identity Issuer hash to its 64-byte public key via `Destination.recall` (the announce store); Threshold Groups & out-of-band identities fall through to an optional fallback resolver. Usable directly as a `KeyResolver`. |
|
|
149
|
+
| `LxmfDeltaDelivery` | §11.2, §11.3 | Targeted, forward-secret Delta delivery over LXMF (store-and-forward to offline nodes), plus LXMF Paper Message export/import for air-gapped/optical (QR) transport. |
|
|
150
|
+
| `RfedDeltaSync` | §11.1 | Many-to-many CRDT convergence via RFed (`RFedClient`): publish + receive signed Deltas on a shared, deployment-overridable channel, routed through verify-on-ingest — never the unauthenticated `merge()`. |
|
|
151
|
+
| `RnsChallengeServer` / `RnsLinkTransport` / `establishLink` | §8 | The Strict Consistency Challenge over a real RNS Link: an authoritative endpoint answering `dacar.auth.v1` requests, and the client-side `Transport` callable (+ helper to open a Link) for `ChallengeClient`. |
|
|
152
|
+
|
|
153
|
+
> **The Issuer Hash must be the canonical RNS identity hash.** RNS defines an
|
|
154
|
+
> identity hash as `SHA-256(P)[:16]` where `P` is the 64-byte public key
|
|
155
|
+
> (`X25519_pub ‖ Ed25519_pub`). `RnsIdentityResolver` recalls exactly that hash;
|
|
156
|
+
> any other value cannot be authenticated and is dropped. (Threshold Group IDs
|
|
157
|
+
> are exempt — they are resolved by explicit keyset registration, not recall.)
|
|
158
|
+
|
|
159
|
+
### RFed convergence
|
|
160
|
+
|
|
161
|
+
```js
|
|
162
|
+
import { RFedClient } from "@reticulum/core";
|
|
163
|
+
import { DeltaReceiver, StateVector } from "@reticulum/dacar";
|
|
164
|
+
import { RfedDeltaSync } from "@reticulum/dacar/transport";
|
|
165
|
+
|
|
166
|
+
const client = new RFedClient({ identity, rns });
|
|
167
|
+
const sync = new RfedDeltaSync({
|
|
168
|
+
receiver: new DeltaReceiver(state, resolver),
|
|
169
|
+
client,
|
|
170
|
+
// topic: "dacar.policy.v1", // deployment-overridable default
|
|
171
|
+
});
|
|
172
|
+
await sync.subscribe(nodeHash); // cache the channel's stamp cost
|
|
173
|
+
await sync.listen(); // receive live fanout Deltas
|
|
174
|
+
await sync.publish(deltaPayload, nodeHash);
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
### Strict Consistency Challenge
|
|
178
|
+
|
|
179
|
+
```js
|
|
180
|
+
import { ChallengeClient } from "@reticulum/dacar";
|
|
181
|
+
import { RnsChallengeServer, RnsLinkTransport, establishLink } from "@reticulum/dacar/transport";
|
|
182
|
+
|
|
183
|
+
// Server side: expose the Authoritative Identity on dacar.auth.v1.
|
|
184
|
+
await RnsChallengeServer.create({ identity: authority, server, rns });
|
|
185
|
+
|
|
186
|
+
// Client side: open a Link and challenge it (partition → DENY).
|
|
187
|
+
const dest = await Destination.OUT("dacar.auth.v1", DestType.SINGLE, authority, rns);
|
|
188
|
+
const link = await establishLink(dest);
|
|
189
|
+
const client = new ChallengeClient(config, state, authorityPublicKey, new RnsLinkTransport(link));
|
|
190
|
+
await client.authorize("sensor:wind", "calibrate", granteeHash);
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
## Tests
|
|
194
|
+
|
|
195
|
+
Tests use [`node:test`](https://nodejs.org/api/test.html) and run under every
|
|
196
|
+
installed runtime (node, deno, bun). The runner fails if **none** is installed:
|
|
197
|
+
|
|
198
|
+
```bash
|
|
199
|
+
npm test # runs under each installed runtime
|
|
200
|
+
npm run test:node # node only
|
|
201
|
+
npm run test:deno # deno only
|
|
202
|
+
npm run test:bun # bun only
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
## Publishing
|
|
206
|
+
|
|
207
|
+
Published to both registries as **`@reticulum/dacar`**:
|
|
208
|
+
|
|
209
|
+
```bash
|
|
210
|
+
# JSR
|
|
211
|
+
cd javascript && deno publish
|
|
212
|
+
|
|
213
|
+
# npm
|
|
214
|
+
cd javascript && npm publish
|
|
215
|
+
```
|
package/package.json
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@reticulum/dacar",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "JavaScript implementation of Dacar, a Decentralized Access Control system for Reticulum",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "src/index.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./src/index.js",
|
|
9
|
+
"./transport": "./src/transport/index.js",
|
|
10
|
+
"./package.json": "./package.json"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"src",
|
|
14
|
+
"README.md"
|
|
15
|
+
],
|
|
16
|
+
"scripts": {
|
|
17
|
+
"test": "sh scripts/test.sh",
|
|
18
|
+
"test:node": "node --test --test-force-exit test/*.test.js",
|
|
19
|
+
"test:deno": "deno test --allow-read=test,src --allow-env --no-check test/",
|
|
20
|
+
"test:bun": "bun test test/"
|
|
21
|
+
},
|
|
22
|
+
"keywords": [
|
|
23
|
+
"reticulum",
|
|
24
|
+
"rns",
|
|
25
|
+
"access-control",
|
|
26
|
+
"authorization",
|
|
27
|
+
"crdt",
|
|
28
|
+
"mesh",
|
|
29
|
+
"offline"
|
|
30
|
+
],
|
|
31
|
+
"license": "EUPL-1.2",
|
|
32
|
+
"repository": {
|
|
33
|
+
"type": "git",
|
|
34
|
+
"url": "git+https://github.com/bergie/dacar.git",
|
|
35
|
+
"directory": "javascript"
|
|
36
|
+
},
|
|
37
|
+
"homepage": "https://github.com/bergie/dacar#readme",
|
|
38
|
+
"engines": {
|
|
39
|
+
"node": ">=20"
|
|
40
|
+
},
|
|
41
|
+
"dependencies": {
|
|
42
|
+
"@reticulum/core": "^0.5.3"
|
|
43
|
+
},
|
|
44
|
+
"publishConfig": {
|
|
45
|
+
"access": "public"
|
|
46
|
+
}
|
|
47
|
+
}
|
package/src/challenge.js
ADDED
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Strict Consistency Challenge / Freshness Receipts (§8).
|
|
3
|
+
*
|
|
4
|
+
* For destructive operations eventual consistency is dangerous. The node
|
|
5
|
+
* performs a local pre-check, then challenges a configured Authoritative
|
|
6
|
+
* Identity over an RNS link (App Name `dacar`, Aspects `auth`, `v1`) for a
|
|
7
|
+
* signed verdict evaluated against the server's absolute-latest CRDT state.
|
|
8
|
+
*
|
|
9
|
+
* To preserve Namespace Label Privacy (§3.3), the Challenge payload carries
|
|
10
|
+
* only *hashed* hypotheses — never plaintext. The client hashes the request
|
|
11
|
+
* across its Primary Salt and all Legacy Salts (§10); the server matches each by
|
|
12
|
+
* its `salt_id_tag` and evaluates directly in hash space.
|
|
13
|
+
*
|
|
14
|
+
* Canonical challenge wire format (§8.3):
|
|
15
|
+
*
|
|
16
|
+
* [ nonce(32),
|
|
17
|
+
* [ [ salt_id_tag(16), grantee_hash(16), allow_relation_hash(16),
|
|
18
|
+
* deny_relation_hash(16), [object_segment_hashes] ],
|
|
19
|
+
* ... ] ]
|
|
20
|
+
*
|
|
21
|
+
* Each entry is fully self-contained for one salt and carries *both* the allow
|
|
22
|
+
* and deny relation hashes so the Authority can apply the deny-beats-allow rule
|
|
23
|
+
* (§7.3) without recovering plaintext.
|
|
24
|
+
*
|
|
25
|
+
* The RNS transport is abstracted behind an async `transport` callable
|
|
26
|
+
* (`challengePayload -> receiptPayload | null`), so the cryptographic and
|
|
27
|
+
* verdict logic is testable without a live network. A transport that returns
|
|
28
|
+
* null or throws is a partition -> immediately DENIED (§8).
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import { Identity, MsgPack, toHex } from "@reticulum/core";
|
|
32
|
+
import { Config } from "./config.js";
|
|
33
|
+
import { Engine } from "./engine.js";
|
|
34
|
+
import { Clock, MAX_HLC } from "./hlc.js";
|
|
35
|
+
import { HASH_SIZE, bytesEqual } from "./namespace.js";
|
|
36
|
+
|
|
37
|
+
/** Cryptographically secure challenge nonces are 32 bytes. */
|
|
38
|
+
export const NONCE_SIZE = 32;
|
|
39
|
+
/** Ed25519 signatures are 64 bytes. */
|
|
40
|
+
const SIGNATURE_SIZE = 64;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The binary verdict carried by a Freshness Receipt.
|
|
44
|
+
* @readonly
|
|
45
|
+
* @enum {number}
|
|
46
|
+
*/
|
|
47
|
+
export const Verdict = {
|
|
48
|
+
DENY: 0x00,
|
|
49
|
+
ALLOW: 0x01,
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* @typedef {Uint8Array | Identity} PublicKeyLike
|
|
54
|
+
*/
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* @param {PublicKeyLike} value
|
|
58
|
+
* @returns {Promise<Identity>}
|
|
59
|
+
*/
|
|
60
|
+
async function asIdentity(value) {
|
|
61
|
+
return value instanceof Identity ? value : await Identity.fromPublicKey(value);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** @param {unknown} value @param {number} len @param {string} name @returns {Uint8Array} */
|
|
65
|
+
function expectBytes(value, len, name) {
|
|
66
|
+
if (!(value instanceof Uint8Array) || value.length !== len) {
|
|
67
|
+
throw new Error(`${name} must be a ${len}-byte Uint8Array`);
|
|
68
|
+
}
|
|
69
|
+
return value;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* @typedef {Object} DecodedEntry
|
|
74
|
+
* @property {Uint8Array} saltIdTag
|
|
75
|
+
* @property {Uint8Array} granteeHash
|
|
76
|
+
* @property {Uint8Array} allowRelationHash
|
|
77
|
+
* @property {Uint8Array} denyRelationHash
|
|
78
|
+
* @property {Uint8Array[]} objectHashes
|
|
79
|
+
*/
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* @typedef {Object} DecodedChallenge
|
|
83
|
+
* @property {Uint8Array} nonce
|
|
84
|
+
* @property {Uint8Array} grantee
|
|
85
|
+
* @property {DecodedEntry[]} entries
|
|
86
|
+
*/
|
|
87
|
+
|
|
88
|
+
export class Challenge {
|
|
89
|
+
/**
|
|
90
|
+
* @param {Object} init
|
|
91
|
+
* @param {string} init.object Plaintext (held only on the client).
|
|
92
|
+
* @param {string} init.relation Plaintext (held only on the client).
|
|
93
|
+
* @param {Uint8Array} init.grantee 16-byte holder identity hash.
|
|
94
|
+
* @param {Uint8Array} init.nonce 32-byte nonce.
|
|
95
|
+
* @param {import("./namespace.js").NamespaceHasher[]} init.hashers Salts to hypothesize over.
|
|
96
|
+
*/
|
|
97
|
+
constructor({ object, relation, grantee, nonce, hashers }) {
|
|
98
|
+
if (!(grantee instanceof Uint8Array) || grantee.length !== HASH_SIZE) {
|
|
99
|
+
throw new TypeError(`grantee must be ${HASH_SIZE} bytes`);
|
|
100
|
+
}
|
|
101
|
+
if (!(nonce instanceof Uint8Array) || nonce.length !== NONCE_SIZE) {
|
|
102
|
+
throw new TypeError(`nonce must be ${NONCE_SIZE} bytes`);
|
|
103
|
+
}
|
|
104
|
+
if (!Array.isArray(hashers) || hashers.length === 0) {
|
|
105
|
+
throw new TypeError("at least one salt hasher is required");
|
|
106
|
+
}
|
|
107
|
+
this.object = object;
|
|
108
|
+
this.relation = relation;
|
|
109
|
+
this.grantee = grantee;
|
|
110
|
+
this.nonce = nonce;
|
|
111
|
+
this.hashers = [...hashers];
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Build a Challenge with a fresh (or supplied) cryptographically secure nonce.
|
|
116
|
+
* @param {string} object
|
|
117
|
+
* @param {string} relation
|
|
118
|
+
* @param {Uint8Array} grantee
|
|
119
|
+
* @param {import("./namespace.js").NamespaceHasher[]} hashers
|
|
120
|
+
* @param {Object} [opts]
|
|
121
|
+
* @param {Uint8Array} [opts.nonce]
|
|
122
|
+
* @returns {Challenge}
|
|
123
|
+
*/
|
|
124
|
+
static generate(object, relation, grantee, hashers, { nonce } = {}) {
|
|
125
|
+
return new Challenge({
|
|
126
|
+
object,
|
|
127
|
+
relation,
|
|
128
|
+
grantee,
|
|
129
|
+
nonce: nonce ?? crypto.getRandomValues(new Uint8Array(NONCE_SIZE)),
|
|
130
|
+
hashers,
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Serialize the hashed multi-salt challenge (§8.3). @returns {Promise<Uint8Array>} */
|
|
135
|
+
async toPayload() {
|
|
136
|
+
const denyRelation = "-" + this.relation;
|
|
137
|
+
const entries = await Promise.all(
|
|
138
|
+
this.hashers.map(async (hasher) => {
|
|
139
|
+
const [saltIdTag, allowRelationHash, denyRelationHash, { hashes }] = await Promise.all([
|
|
140
|
+
hasher.idTag(),
|
|
141
|
+
hasher.hashRelation(this.relation),
|
|
142
|
+
hasher.hashRelation(denyRelation),
|
|
143
|
+
hasher.hashObject(this.object),
|
|
144
|
+
]);
|
|
145
|
+
return [
|
|
146
|
+
saltIdTag,
|
|
147
|
+
this.grantee,
|
|
148
|
+
allowRelationHash,
|
|
149
|
+
denyRelationHash,
|
|
150
|
+
[...hashes],
|
|
151
|
+
];
|
|
152
|
+
}),
|
|
153
|
+
);
|
|
154
|
+
return MsgPack.encode([this.nonce, entries]);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Decode a challenge payload (§8.4). Plaintext is intentionally unrecoverable.
|
|
159
|
+
* @param {Uint8Array} data
|
|
160
|
+
* @returns {DecodedChallenge}
|
|
161
|
+
*/
|
|
162
|
+
static fromPayload(data) {
|
|
163
|
+
const decoded = MsgPack.decode(data);
|
|
164
|
+
if (!Array.isArray(decoded) || decoded.length !== 2) {
|
|
165
|
+
throw new Error("challenge payload must be a 2-element MessagePack array");
|
|
166
|
+
}
|
|
167
|
+
const [nonce, entries] = decoded;
|
|
168
|
+
const nonceBytes = expectBytes(nonce, NONCE_SIZE, "nonce");
|
|
169
|
+
if (!Array.isArray(entries)) {
|
|
170
|
+
throw new Error("challenge entries must be an array");
|
|
171
|
+
}
|
|
172
|
+
/** @type {DecodedEntry[]} */
|
|
173
|
+
const result = [];
|
|
174
|
+
/** @type {Uint8Array | null} */
|
|
175
|
+
let grantee = null;
|
|
176
|
+
for (const entry of entries) {
|
|
177
|
+
if (!Array.isArray(entry) || entry.length !== 5) {
|
|
178
|
+
throw new Error("each challenge entry must be a 5-element array");
|
|
179
|
+
}
|
|
180
|
+
const [saltIdTag, granteeHash, allowRh, denyRh, objectHashes] = entry;
|
|
181
|
+
if (!Array.isArray(objectHashes)) throw new Error("object_segment_hashes must be an array");
|
|
182
|
+
const gh = expectBytes(granteeHash, HASH_SIZE, "grantee_hash");
|
|
183
|
+
if (grantee === null) grantee = gh;
|
|
184
|
+
else if (!bytesEqual(grantee, gh)) throw new Error("all challenge entries must share one grantee");
|
|
185
|
+
result.push({
|
|
186
|
+
saltIdTag: expectBytes(saltIdTag, HASH_SIZE, "salt_id_tag"),
|
|
187
|
+
granteeHash: gh,
|
|
188
|
+
allowRelationHash: expectBytes(allowRh, HASH_SIZE, "allow_relation_hash"),
|
|
189
|
+
denyRelationHash: expectBytes(denyRh, HASH_SIZE, "deny_relation_hash"),
|
|
190
|
+
objectHashes: objectHashes.map((h) => expectBytes(h, HASH_SIZE, "object_hash")),
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
if (grantee === null) throw new Error("challenge must carry at least one entry");
|
|
194
|
+
return { nonce: nonceBytes, grantee, entries: result };
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* @typedef {Object} ReceiptInit
|
|
200
|
+
* @property {number} verdict One of {@link Verdict}.
|
|
201
|
+
* @property {bigint} serverHlc
|
|
202
|
+
* @property {Uint8Array} nonce
|
|
203
|
+
* @property {Uint8Array} [signature]
|
|
204
|
+
*/
|
|
205
|
+
|
|
206
|
+
export class Receipt {
|
|
207
|
+
/** @param {ReceiptInit} init */
|
|
208
|
+
constructor({ verdict, serverHlc, nonce, signature = new Uint8Array(0) }) {
|
|
209
|
+
if (verdict !== Verdict.ALLOW && verdict !== Verdict.DENY) {
|
|
210
|
+
throw new TypeError("verdict must be Verdict.ALLOW or Verdict.DENY");
|
|
211
|
+
}
|
|
212
|
+
if (typeof serverHlc !== "bigint" || serverHlc < 0n || serverHlc > MAX_HLC) {
|
|
213
|
+
throw new RangeError("serverHlc must be a bigint in [0, 2^64)");
|
|
214
|
+
}
|
|
215
|
+
if (!(nonce instanceof Uint8Array) || nonce.length !== NONCE_SIZE) {
|
|
216
|
+
throw new TypeError(`nonce must be ${NONCE_SIZE} bytes`);
|
|
217
|
+
}
|
|
218
|
+
if (!(signature instanceof Uint8Array) || (signature.length !== 0 && signature.length !== SIGNATURE_SIZE)) {
|
|
219
|
+
throw new RangeError(`signature must be 0 or ${SIGNATURE_SIZE} bytes`);
|
|
220
|
+
}
|
|
221
|
+
this.verdict = verdict;
|
|
222
|
+
this.serverHlc = serverHlc;
|
|
223
|
+
this.nonce = nonce;
|
|
224
|
+
this.signature = signature;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** Unpadded concatenation of the fields preceding the signature (41 bytes). @returns {Uint8Array} */
|
|
228
|
+
get preimage() {
|
|
229
|
+
const hlcBytes = new Uint8Array(8);
|
|
230
|
+
new DataView(hlcBytes.buffer).setBigUint64(0, this.serverHlc, false); // big-endian
|
|
231
|
+
const out = new Uint8Array(1 + 8 + NONCE_SIZE);
|
|
232
|
+
out[0] = this.verdict;
|
|
233
|
+
out.set(hlcBytes, 1);
|
|
234
|
+
out.set(this.nonce, 9);
|
|
235
|
+
return out;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** @param {Identity} identity @returns {Promise<Receipt>} */
|
|
239
|
+
async sign(identity) {
|
|
240
|
+
const signature = await identity.sign(this.preimage);
|
|
241
|
+
return new Receipt({ verdict: this.verdict, serverHlc: this.serverHlc, nonce: this.nonce, signature });
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** @param {PublicKeyLike} identityOrPublicKey @returns {Promise<boolean>} */
|
|
245
|
+
async verify(identityOrPublicKey) {
|
|
246
|
+
if (this.signature.length !== SIGNATURE_SIZE) return false;
|
|
247
|
+
const identity = await asIdentity(identityOrPublicKey);
|
|
248
|
+
return identity.validate(this.signature, this.preimage);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** @returns {Uint8Array} */
|
|
252
|
+
toPayload() {
|
|
253
|
+
if (this.signature.length !== SIGNATURE_SIZE) {
|
|
254
|
+
throw new Error("Receipt must be signed before payload serialization");
|
|
255
|
+
}
|
|
256
|
+
return MsgPack.encode([this.verdict, this.serverHlc, this.nonce, this.signature]);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** @param {Uint8Array} data @returns {Receipt} */
|
|
260
|
+
static fromPayload(data) {
|
|
261
|
+
const decoded = MsgPack.decode(data);
|
|
262
|
+
if (!Array.isArray(decoded) || decoded.length !== 4) {
|
|
263
|
+
throw new Error("receipt payload must be a 4-element MessagePack array");
|
|
264
|
+
}
|
|
265
|
+
const [verdict, serverHlc, nonce, signature] = decoded;
|
|
266
|
+
if (verdict !== Verdict.ALLOW && verdict !== Verdict.DENY) {
|
|
267
|
+
throw new Error(`unknown verdict byte ${verdict}`);
|
|
268
|
+
}
|
|
269
|
+
return new Receipt({
|
|
270
|
+
verdict,
|
|
271
|
+
serverHlc: BigInt(serverHlc),
|
|
272
|
+
nonce: expectBytes(nonce, NONCE_SIZE, "nonce"),
|
|
273
|
+
signature: expectBytes(signature, SIGNATURE_SIZE, "signature"),
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Bind each decoded entry to a configured salt via its salt_id_tag (§8.4) and
|
|
280
|
+
* build the synchronous hypothesis objects the engine consumes.
|
|
281
|
+
* @param {import("./config.js").Config} config
|
|
282
|
+
* @param {DecodedChallenge} decoded
|
|
283
|
+
* @returns {Promise<import("./engine.js").Hypothesis[]>}
|
|
284
|
+
*/
|
|
285
|
+
async function buildHypotheses(config, decoded) {
|
|
286
|
+
/** @type {Map<string, import("./namespace.js").NamespaceHasher>} */
|
|
287
|
+
const byTag = new Map();
|
|
288
|
+
for (const hasher of config.hashers) {
|
|
289
|
+
byTag.set(toHex(await hasher.idTag()), hasher);
|
|
290
|
+
}
|
|
291
|
+
/** @type {import("./engine.js").Hypothesis[]} */
|
|
292
|
+
const hyps = [];
|
|
293
|
+
for (const entry of decoded.entries) {
|
|
294
|
+
const hasher = byTag.get(toHex(entry.saltIdTag));
|
|
295
|
+
if (!hasher) continue; // unknown salt -> hypothesis unusable, skip
|
|
296
|
+
const [adminAllowHash, adminDenyHash] = await Promise.all([
|
|
297
|
+
hasher.hashRelation("admin"),
|
|
298
|
+
hasher.hashRelation("-admin"),
|
|
299
|
+
]);
|
|
300
|
+
hyps.push({
|
|
301
|
+
hasher,
|
|
302
|
+
objectHashes: entry.objectHashes,
|
|
303
|
+
allowRelationHash: entry.allowRelationHash,
|
|
304
|
+
denyRelationHash: entry.denyRelationHash,
|
|
305
|
+
adminAllowHash,
|
|
306
|
+
adminDenyHash,
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
return hyps;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/** The Authoritative Identity: evaluates requests and signs Freshness Receipts. */
|
|
313
|
+
export class AuthoritativeServer {
|
|
314
|
+
/**
|
|
315
|
+
* @param {Config} config
|
|
316
|
+
* @param {import("./crdt.js").StateVector} state
|
|
317
|
+
* @param {Identity} privateKey Identity holding the signing key.
|
|
318
|
+
* @param {Object} [opts]
|
|
319
|
+
* @param {Clock} [opts.clock]
|
|
320
|
+
*/
|
|
321
|
+
constructor(config, state, privateKey, { clock } = {}) {
|
|
322
|
+
this._engine = new Engine(config, state);
|
|
323
|
+
this._state = state;
|
|
324
|
+
this._config = config;
|
|
325
|
+
this._privateKey = privateKey;
|
|
326
|
+
this._clock = clock ?? new Clock();
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** @param {Uint8Array} challengePayload @returns {Promise<Uint8Array>} */
|
|
330
|
+
async handle(challengePayload) {
|
|
331
|
+
const decoded = Challenge.fromPayload(challengePayload);
|
|
332
|
+
const hypotheses = await buildHypotheses(this._config, decoded);
|
|
333
|
+
const allowed = hypotheses.length > 0 && this._engine.evaluateHashes(decoded.grantee, hypotheses);
|
|
334
|
+
const verdict = allowed ? Verdict.ALLOW : Verdict.DENY;
|
|
335
|
+
const receipt = await new Receipt({
|
|
336
|
+
verdict,
|
|
337
|
+
serverHlc: this._clock.now(),
|
|
338
|
+
nonce: decoded.nonce,
|
|
339
|
+
}).sign(this._privateKey);
|
|
340
|
+
return receipt.toPayload();
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* @callback Transport
|
|
346
|
+
* @param {Uint8Array} challengePayload
|
|
347
|
+
* @returns {Promise<Uint8Array | null> | Uint8Array | null}
|
|
348
|
+
*/
|
|
349
|
+
|
|
350
|
+
/** The requesting node: performs the local pre-check and the challenge exchange. */
|
|
351
|
+
export class ChallengeClient {
|
|
352
|
+
/**
|
|
353
|
+
* @param {Config} config
|
|
354
|
+
* @param {import("./crdt.js").StateVector} state
|
|
355
|
+
* @param {PublicKeyLike} authoritativePublicKey
|
|
356
|
+
* @param {Transport} transport
|
|
357
|
+
*/
|
|
358
|
+
constructor(config, state, authoritativePublicKey, transport) {
|
|
359
|
+
if (config.authoritativeIdentity === undefined) {
|
|
360
|
+
throw new Error("Strict Consistency requires an Authoritative Identity (§8)");
|
|
361
|
+
}
|
|
362
|
+
this._engine = new Engine(config, state);
|
|
363
|
+
this._state = state;
|
|
364
|
+
this._config = config;
|
|
365
|
+
this._publicKey = authoritativePublicKey;
|
|
366
|
+
this._transport = transport;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/**
|
|
370
|
+
* Run the full §8 flow. Resolves true only on a verified server ALLOW.
|
|
371
|
+
* @param {string} objectId
|
|
372
|
+
* @param {string} relation
|
|
373
|
+
* @param {Uint8Array} grantee
|
|
374
|
+
* @returns {Promise<boolean>}
|
|
375
|
+
*/
|
|
376
|
+
async authorize(objectId, relation, grantee) {
|
|
377
|
+
// §8.1 Local pre-check.
|
|
378
|
+
if (!(await this._engine.evaluate(objectId, relation, grantee))) return false;
|
|
379
|
+
// §8.2/§8.3 Challenge across Primary + Legacy salts.
|
|
380
|
+
const challenge = Challenge.generate(objectId, relation, grantee, this._config.hashers);
|
|
381
|
+
let receiptPayload;
|
|
382
|
+
try {
|
|
383
|
+
receiptPayload = await Promise.resolve(this._transport(await challenge.toPayload()));
|
|
384
|
+
} catch {
|
|
385
|
+
return false; // partition penalty (§8)
|
|
386
|
+
}
|
|
387
|
+
if (receiptPayload === null || receiptPayload === undefined) return false; // partition (§8)
|
|
388
|
+
const receipt = Receipt.fromPayload(receiptPayload);
|
|
389
|
+
// §8.5 Verify nonce match and signature.
|
|
390
|
+
if (!bytesEqual(receipt.nonce, challenge.nonce)) return false;
|
|
391
|
+
if (!(await receipt.verify(this._publicKey))) return false;
|
|
392
|
+
return receipt.verdict === Verdict.ALLOW;
|
|
393
|
+
}
|
|
394
|
+
}
|