@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
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* §11.1 eventual consistency via RFed (Reticulum Federation).
|
|
3
|
+
*
|
|
4
|
+
* Global convergence of the CRDT is handled by RFed's many-to-many broadcast:
|
|
5
|
+
* each node publishes its signed Operations (§5.3 Deltas) to a shared channel
|
|
6
|
+
* (default `dacar.policy.v1`, deployment-overridable), and receives peers'
|
|
7
|
+
* Deltas via RFed fanout. Because every Delta is individually Ed25519-signed,
|
|
8
|
+
* RFed need not be trusted: received bytes flow through the *same* verify-on-
|
|
9
|
+
* ingest seam (`DeltaReceiver.applyPayload()`, §11.2.4) as LXMF and optical
|
|
10
|
+
* delivery — **never** through the unauthenticated `StateVector.merge()` path.
|
|
11
|
+
* A forged or stale Delta is simply dropped before it can mutate state.
|
|
12
|
+
*
|
|
13
|
+
* `RfedDeltaSync` wraps a `@reticulum/core` `RFedClient`. A Delta is wrapped as
|
|
14
|
+
* the LXMF *content* of a channel message under the fixed `dacar/sync/delta`
|
|
15
|
+
* title; on receipt the channel is the feed discriminator (every message on it
|
|
16
|
+
* is a Dacar Delta), and the content bytes are fed to `DeltaReceiver`.
|
|
17
|
+
*
|
|
18
|
+
* §11.3 air-gapped/optical transport is served by `./lxmfSync.js` (Paper
|
|
19
|
+
* Messages); RFed is the online many-to-many path.
|
|
20
|
+
*
|
|
21
|
+
* This module is part of the optional transport layer: importing the pure core
|
|
22
|
+
* never pulls it in. It depends only on `@reticulum/core`, which the core
|
|
23
|
+
* already depends on, so no new dependency is added.
|
|
24
|
+
*
|
|
25
|
+
* Typical use:
|
|
26
|
+
*
|
|
27
|
+
* ```js
|
|
28
|
+
* const client = new RFedClient({ identity, rns });
|
|
29
|
+
* const sync = new RfedDeltaSync({ receiver: new DeltaReceiver(state, resolver), client });
|
|
30
|
+
* await sync.subscribe(nodeHash); // cache the channel's stamp cost
|
|
31
|
+
* await sync.listen(); // receive live fanout Deltas
|
|
32
|
+
* await sync.publish(deltaPayload, nodeHash);
|
|
33
|
+
* ```
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
import {
|
|
37
|
+
LXMessage,
|
|
38
|
+
deliveryHashFor,
|
|
39
|
+
deriveChannel,
|
|
40
|
+
unwrapChannelMessage,
|
|
41
|
+
} from "@reticulum/core";
|
|
42
|
+
import { LXMF_DELIVERY_TITLE, RFED_TOPIC } from "../naming.js";
|
|
43
|
+
import { messageContent } from "./lxmfSync.js";
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The shape of the decoded fanout callback argument from `RFedClient.listen`.
|
|
47
|
+
* Only `message` is consumed here.
|
|
48
|
+
* @typedef {Object} RfedDecoded
|
|
49
|
+
* @property {import("@reticulum/core").LXMessage} message
|
|
50
|
+
* @property {unknown} [senderIdentity]
|
|
51
|
+
* @property {Uint8Array} [senderPub]
|
|
52
|
+
* @property {Uint8Array} [sourceHash]
|
|
53
|
+
* @property {boolean} [signatureValid]
|
|
54
|
+
* @property {Uint8Array} [channelHash]
|
|
55
|
+
* @property {string | null} [channelName]
|
|
56
|
+
*/
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* The minimal `RFedClient` surface this adapter relies on. The real client from
|
|
60
|
+
* `@reticulum/core` satisfies it; tests inject a fake.
|
|
61
|
+
* @typedef {Object} RFedClientLike
|
|
62
|
+
* @property {(nodeHash: Uint8Array, channelName: string) => Promise<unknown>} subscribe
|
|
63
|
+
* @property {(nodeHash: Uint8Array, channelName: string) => Promise<unknown>} [unsubscribe]
|
|
64
|
+
* @property {(nodeHash: Uint8Array, channelName: string, lxmMessage: import("@reticulum/core").LXMessage) => Promise<unknown>} publish
|
|
65
|
+
* @property {(nodeHash: Uint8Array, channelName: string) => Promise<{ items: Array<{ channelHash: Uint8Array, blob: Uint8Array }>, morePending: boolean }>} pull
|
|
66
|
+
* @property {(onMessage: (decoded: RfedDecoded) => void) => Promise<Uint8Array>} listen
|
|
67
|
+
*/
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* §11.1 RFed Delta broadcast + receive, routed through verify-on-ingest.
|
|
71
|
+
*/
|
|
72
|
+
export class RfedDeltaSync {
|
|
73
|
+
/** Default RFed channel (deployment-overridable, spec §11.1). */
|
|
74
|
+
static DEFAULT_TOPIC = RFED_TOPIC;
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* @param {Object} opts
|
|
78
|
+
* @param {import("../delta.js").DeltaReceiver | null} [opts.receiver]
|
|
79
|
+
* The shared DeltaReceiver (state + key resolver). May be omitted on a
|
|
80
|
+
* publish-only node (then `listen`/`pull` throw if called).
|
|
81
|
+
* @param {RFedClientLike} opts.client A `@reticulum/core` `RFedClient`.
|
|
82
|
+
* @param {string} [opts.topic] RFed channel name (default `dacar.policy.v1`).
|
|
83
|
+
*/
|
|
84
|
+
constructor({ receiver = null, client, topic = RFED_TOPIC }) {
|
|
85
|
+
if (!client) throw new TypeError("RfedDeltaSync requires an RFedClient");
|
|
86
|
+
/** @type {import("../delta.js").DeltaReceiver | null} */
|
|
87
|
+
this._receiver = receiver;
|
|
88
|
+
/** @type {RFedClientLike} */
|
|
89
|
+
this._client = client;
|
|
90
|
+
/** @type {string} */
|
|
91
|
+
this._topic = topic;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** @returns {string} The configured RFed channel name. */
|
|
95
|
+
get topic() {
|
|
96
|
+
return this._topic;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Builds the LXMF channel message wrapping one §5.3 Delta payload.
|
|
101
|
+
*
|
|
102
|
+
* The message's `sourceHash`/`destinationHash` are placeholders: the rfed
|
|
103
|
+
* Phase-0 codec (`wrapChannelMessage`) overwrites them with the channel's
|
|
104
|
+
* `lxmf.delivery` hashes before serialization, so the classic "source_hash
|
|
105
|
+
* is the bare identity hash" bug cannot occur.
|
|
106
|
+
* @param {Uint8Array} deltaPayload
|
|
107
|
+
* @returns {import("@reticulum/core").LXMessage}
|
|
108
|
+
*/
|
|
109
|
+
makeMessage(deltaPayload) {
|
|
110
|
+
if (!(deltaPayload instanceof Uint8Array)) {
|
|
111
|
+
throw new TypeError("deltaPayload must be a Uint8Array");
|
|
112
|
+
}
|
|
113
|
+
return new LXMessage({
|
|
114
|
+
// Overwritten by the rfed codec before going on the wire.
|
|
115
|
+
destinationHash: new Uint8Array(16),
|
|
116
|
+
sourceHash: new Uint8Array(16),
|
|
117
|
+
content: new Uint8Array(deltaPayload),
|
|
118
|
+
title: LXMF_DELIVERY_TITLE,
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Subscribes to the channel on a node, caching its advertised stamp cost.
|
|
124
|
+
* Call at least once per session and after any publish seems dropped.
|
|
125
|
+
* @param {Uint8Array} nodeHash Any `rfed.*` destination hash of the node.
|
|
126
|
+
* @returns {Promise<unknown>} The client's `{ ok, stampCost }` result.
|
|
127
|
+
*/
|
|
128
|
+
async subscribe(nodeHash) {
|
|
129
|
+
return this._client.subscribe(nodeHash, this._topic);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Removes the subscription.
|
|
134
|
+
* @param {Uint8Array} nodeHash
|
|
135
|
+
* @returns {Promise<unknown>}
|
|
136
|
+
*/
|
|
137
|
+
async unsubscribe(nodeHash) {
|
|
138
|
+
if (!this._client.unsubscribe) {
|
|
139
|
+
throw new Error("RFedClient has no unsubscribe");
|
|
140
|
+
}
|
|
141
|
+
return this._client.unsubscribe(nodeHash, this._topic);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Publishes a Delta to the channel (fire-and-forget, §11.1).
|
|
146
|
+
*
|
|
147
|
+
* Call {@link subscribe} first so the channel's stamp cost is cached; an
|
|
148
|
+
* unstamped publish may be silently dropped by a cost-enforcing node.
|
|
149
|
+
* @param {Uint8Array} deltaPayload
|
|
150
|
+
* @param {Uint8Array} nodeHash
|
|
151
|
+
* @returns {Promise<import("@reticulum/core").LXMessage>} The published message.
|
|
152
|
+
*/
|
|
153
|
+
async publish(deltaPayload, nodeHash) {
|
|
154
|
+
const message = this.makeMessage(deltaPayload);
|
|
155
|
+
await this._client.publish(nodeHash, this._topic, message);
|
|
156
|
+
return message;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Starts listening for live fanout Deltas and routes each through
|
|
161
|
+
* verify-on-ingest (§11.1, §11.2.4).
|
|
162
|
+
*
|
|
163
|
+
* The channel is the feed discriminator, so every received message is a
|
|
164
|
+
* Dacar Delta; `DeltaReceiver.applyPayload()` authenticates it by signature
|
|
165
|
+
* and swallows any malformed/forged payload so a bad message can never crash
|
|
166
|
+
* the transport or mutate state.
|
|
167
|
+
* @returns {Promise<Uint8Array>} The local `rfed.delivery` destination hash.
|
|
168
|
+
*/
|
|
169
|
+
async listen() {
|
|
170
|
+
if (!this._receiver) {
|
|
171
|
+
throw new Error("RfedDeltaSync.listen requires a receiver");
|
|
172
|
+
}
|
|
173
|
+
const receiver = this._receiver;
|
|
174
|
+
return this._client.listen((decoded) => {
|
|
175
|
+
// RFedClient.invoke does not await the callback; run applyPayload without
|
|
176
|
+
// leaving an unhandled rejection (it swallows malformed payloads itself).
|
|
177
|
+
Promise.resolve(receiver.applyPayload(messageContent(decoded.message))).catch(
|
|
178
|
+
() => {},
|
|
179
|
+
);
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Drains the node's deferred queue (offline catch-up) and routes each blob
|
|
185
|
+
* through verify-on-ingest (§11.1).
|
|
186
|
+
*
|
|
187
|
+
* Each blob is EC-decrypted with the derived channel identity and the
|
|
188
|
+
* recovered LXMF message's content is applied. Foreign/undecryptable blobs
|
|
189
|
+
* are dropped, not fatal. Repeats until the node reports no more pending
|
|
190
|
+
* pages. Returns the count of Deltas newly applied to the CRDT.
|
|
191
|
+
*
|
|
192
|
+
* > **Assumption:** the node serves each deferred entry's `blob` as the rfed
|
|
193
|
+
* > `inner_blob` (the EC-encrypted channel message), matching the fanout
|
|
194
|
+
* > payload's inner half. Verify against a live rfed node on first deploy.
|
|
195
|
+
*
|
|
196
|
+
* @param {Uint8Array} nodeHash
|
|
197
|
+
* @returns {Promise<number>}
|
|
198
|
+
*/
|
|
199
|
+
async pull(nodeHash) {
|
|
200
|
+
if (!this._receiver) {
|
|
201
|
+
throw new Error("RfedDeltaSync.pull requires a receiver");
|
|
202
|
+
}
|
|
203
|
+
const { identity: channelIdentity } = await deriveChannel(this._topic);
|
|
204
|
+
const channelDeliveryHash = await deliveryHashFor(channelIdentity);
|
|
205
|
+
const receiver = this._receiver;
|
|
206
|
+
let applied = 0;
|
|
207
|
+
let morePending = true;
|
|
208
|
+
while (morePending) {
|
|
209
|
+
const page = await this._client.pull(nodeHash, this._topic);
|
|
210
|
+
for (const item of page.items) {
|
|
211
|
+
try {
|
|
212
|
+
const decoded = await unwrapChannelMessage({
|
|
213
|
+
innerBlob: item.blob,
|
|
214
|
+
channelIdentity,
|
|
215
|
+
channelDeliveryHash,
|
|
216
|
+
});
|
|
217
|
+
if (await receiver.applyPayload(messageContent(decoded.message))) {
|
|
218
|
+
applied++;
|
|
219
|
+
}
|
|
220
|
+
} catch {
|
|
221
|
+
// a foreign/undecryptable blob is dropped, never fatal
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
morePending = page.morePending;
|
|
225
|
+
}
|
|
226
|
+
return applied;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* §8 Strict Consistency Challenge over a real RNS Link.
|
|
3
|
+
*
|
|
4
|
+
* Optional transport wiring around the already pure-and-tested §8 logic
|
|
5
|
+
* (`Challenge`, `AuthoritativeServer`, `Receipt`, `ChallengeClient` from
|
|
6
|
+
* `../challenge.js`). Three pieces:
|
|
7
|
+
*
|
|
8
|
+
* - `challengeRequestHandler(server)` builds the response_generator a server
|
|
9
|
+
* registers on the `dacar.auth.v1` destination: it feeds each incoming
|
|
10
|
+
* challenge payload to `AuthoritativeServer.handle()` and returns the
|
|
11
|
+
* signed Freshness Receipt bytes (or nothing on a malformed challenge).
|
|
12
|
+
* - `RnsChallengeServer` exposes an Authoritative Identity on
|
|
13
|
+
* `dacar.auth.v1`, accepts Links, and registers that handler.
|
|
14
|
+
* - `RnsLinkTransport` is the client-side
|
|
15
|
+
* {@link import("../challenge.js").Transport Transport} callable: it sends
|
|
16
|
+
* a challenge payload over an established Link and awaits the signed
|
|
17
|
+
* receipt, returning `null` on timeout/partition (which §8 treats as DENY).
|
|
18
|
+
* - `establishLink()` opens a Link and awaits ACTIVE.
|
|
19
|
+
*
|
|
20
|
+
* The Dacar-specific glue (handler wrapping, partition → DENY) is covered by
|
|
21
|
+
* injected-fake unit tests; the pure §8 protocol is covered by
|
|
22
|
+
* `test/challenge.test.js`.
|
|
23
|
+
*
|
|
24
|
+
* This module is part of the optional transport layer: importing the pure core
|
|
25
|
+
* never pulls it in. It depends only on `@reticulum/core` (Destination, Link),
|
|
26
|
+
* which the core already depends on.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { Destination, DestType, Link } from "@reticulum/core";
|
|
30
|
+
import { APP_NAME, CHALLENGE_ASPECTS } from "../naming.js";
|
|
31
|
+
|
|
32
|
+
/** The RNS request path used for the Challenge exchange (§8). */
|
|
33
|
+
export const CHALLENGE_REQUEST_PATH = "challenge";
|
|
34
|
+
|
|
35
|
+
/** Default Challenge round-trip timeout in milliseconds. Partition → §8 DENY. */
|
|
36
|
+
export const DEFAULT_CHALLENGE_TIMEOUT_MS = 15_000;
|
|
37
|
+
|
|
38
|
+
/** Default Link establishment timeout in milliseconds (§8.2). */
|
|
39
|
+
export const DEFAULT_ESTABLISH_TIMEOUT_MS = 15_000;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* @typedef {import("../challenge.js").AuthoritativeServer} AuthoritativeServer
|
|
43
|
+
* @typedef {import("@reticulum/core").Destination} DestinationType
|
|
44
|
+
* @typedef {import("@reticulum/core").Link} LinkType
|
|
45
|
+
* @typedef {import("@reticulum/core").Identity} IdentityType
|
|
46
|
+
* @typedef {import("../challenge.js").Transport} Transport
|
|
47
|
+
*/
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* @callback ResponseGenerator
|
|
51
|
+
* @param {string} path
|
|
52
|
+
* @param {any} data The §8.3 challenge payload (a `Uint8Array`).
|
|
53
|
+
* @param {Uint8Array} requestId
|
|
54
|
+
* @param {IdentityType | null} remoteIdentity
|
|
55
|
+
* @param {number} requestTime
|
|
56
|
+
* @returns {Promise<Uint8Array | null>}
|
|
57
|
+
*/
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Builds the response_generator answering Challenge requests (§8.4).
|
|
61
|
+
*
|
|
62
|
+
* The returned callable matches the `@reticulum/core` `responseGenerator`
|
|
63
|
+
* contract (PROTOCOL-SPEC.md §11.2): it feeds `data` (the §8.3 challenge
|
|
64
|
+
* payload) to `AuthoritativeServer.handle()` and returns the signed Receipt
|
|
65
|
+
* payload. Malformed or unprocessable challenges yield `null` (no response),
|
|
66
|
+
* which the client treats as a partition → DENY (§8).
|
|
67
|
+
* @param {AuthoritativeServer} server
|
|
68
|
+
* @returns {ResponseGenerator}
|
|
69
|
+
*/
|
|
70
|
+
export function challengeRequestHandler(server) {
|
|
71
|
+
return async (_path, data) => {
|
|
72
|
+
try {
|
|
73
|
+
return await server.handle(data);
|
|
74
|
+
} catch {
|
|
75
|
+
return null; // malformed/unprocessable → no response → partition → DENY (§8)
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Authoritative endpoint: answers Challenge requests over RNS Links (§8).
|
|
82
|
+
*
|
|
83
|
+
* Because destination creation is asynchronous, construct via the static
|
|
84
|
+
* {@link RnsChallengeServer.create} factory. The server creates the
|
|
85
|
+
* `dacar.auth.v1` destination for `identity`, accepts Links, registers the
|
|
86
|
+
* Challenge request handler, and (by default) announces so clients can find
|
|
87
|
+
* it. A running `Reticulum` instance is assumed.
|
|
88
|
+
*/
|
|
89
|
+
export class RnsChallengeServer {
|
|
90
|
+
/** The request path Challenge requests are served on (§8). */
|
|
91
|
+
static REQUEST_PATH = CHALLENGE_REQUEST_PATH;
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* @param {Object} opts
|
|
95
|
+
* @param {IdentityType} opts.identity The Authoritative Identity (signs receipts).
|
|
96
|
+
* @param {AuthoritativeServer} opts.server The pure §8 authoritative evaluator.
|
|
97
|
+
* @param {import("@reticulum/core").Reticulum} opts.rns A running Reticulum instance.
|
|
98
|
+
* @param {string} [opts.appName] Override the `dacar` app name.
|
|
99
|
+
* @param {readonly string[]} [opts.aspects] Override the `auth.v1` aspects.
|
|
100
|
+
* @param {boolean} [opts.announce] Whether to announce immediately (default true).
|
|
101
|
+
* @returns {Promise<RnsChallengeServer>}
|
|
102
|
+
*/
|
|
103
|
+
static async create({
|
|
104
|
+
identity,
|
|
105
|
+
server,
|
|
106
|
+
rns,
|
|
107
|
+
appName = APP_NAME,
|
|
108
|
+
aspects = CHALLENGE_ASPECTS,
|
|
109
|
+
announce = true,
|
|
110
|
+
}) {
|
|
111
|
+
const self = new RnsChallengeServer(server);
|
|
112
|
+
const name = [appName, ...aspects].join(".");
|
|
113
|
+
|
|
114
|
+
// Build the `dacar.auth.v1` IN SINGLE destination and bind it to the
|
|
115
|
+
// transport so routed packets reach it (mirrors LXMRouter.init).
|
|
116
|
+
const dest = await Destination.IN(name, DestType.SINGLE, identity, rns);
|
|
117
|
+
rns.transport.bindLocalDestination(dest);
|
|
118
|
+
rns.registerDestination(dest);
|
|
119
|
+
|
|
120
|
+
// Accept Links so clients can issue Challenge requests over them.
|
|
121
|
+
dest.addEventListener("link_request", async (/** @type {any} */ event) => {
|
|
122
|
+
try {
|
|
123
|
+
await dest.acceptLink(event.detail.packet);
|
|
124
|
+
} catch {
|
|
125
|
+
// A failed handshake tears itself down; never fatal to the server.
|
|
126
|
+
}
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
await dest.registerRequestHandler(RnsChallengeServer.REQUEST_PATH, {
|
|
130
|
+
responseGenerator: challengeRequestHandler(server),
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
if (announce) await dest.announce();
|
|
134
|
+
self._destination = dest;
|
|
135
|
+
return self;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** @param {AuthoritativeServer} server */
|
|
139
|
+
constructor(server) {
|
|
140
|
+
/** @type {AuthoritativeServer} */
|
|
141
|
+
this._server = server;
|
|
142
|
+
/** @type {DestinationType | null} */
|
|
143
|
+
this._destination = null;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** @returns {AuthoritativeServer} */
|
|
147
|
+
get server() {
|
|
148
|
+
return this._server;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** @returns {DestinationType | null} */
|
|
152
|
+
get destination() {
|
|
153
|
+
return this._destination;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** @returns {Uint8Array | null} The 16-byte destination hash. */
|
|
157
|
+
get destinationHash() {
|
|
158
|
+
return this._destination ? this._destination.destinationHash : null;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* (Re)announce the destination so clients can resolve a path to it.
|
|
163
|
+
* @returns {Promise<void>}
|
|
164
|
+
*/
|
|
165
|
+
async announce() {
|
|
166
|
+
if (!this._destination) throw new Error("Server not created");
|
|
167
|
+
await this._destination.announce();
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Client-side {@link Transport} over an established Link.
|
|
173
|
+
*
|
|
174
|
+
* Call it with the §8.3 challenge payload: it issues an RNS request on the
|
|
175
|
+
* link and resolves with the signed Receipt bytes, or `null` on any failure —
|
|
176
|
+
* a non-ACTIVE link, a send failure, a timeout, or a partition — which
|
|
177
|
+
* {@link import("../challenge.js").ChallengeClient ChallengeClient} treats as
|
|
178
|
+
* a partition → DENY (§8).
|
|
179
|
+
*
|
|
180
|
+
* `@reticulum/core`'s `Link.request()` throws when the link is not ACTIVE and
|
|
181
|
+
* rejects on timeout/failure, so a single try/catch maps every failure mode to
|
|
182
|
+
* the §8 partition penalty without issuing a request on a link that cannot
|
|
183
|
+
* carry one.
|
|
184
|
+
*/
|
|
185
|
+
export class RnsLinkTransport {
|
|
186
|
+
/** The request path Challenge requests are sent on (§8). */
|
|
187
|
+
static REQUEST_PATH = CHALLENGE_REQUEST_PATH;
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* @param {LinkType} link An established (or establishable) RNS Link.
|
|
191
|
+
* @param {Object} [opts]
|
|
192
|
+
* @param {string} [opts.requestPath] Override the request path.
|
|
193
|
+
* @param {number} [opts.timeoutMs] Round-trip timeout in milliseconds.
|
|
194
|
+
*/
|
|
195
|
+
constructor(link, { requestPath = CHALLENGE_REQUEST_PATH, timeoutMs = DEFAULT_CHALLENGE_TIMEOUT_MS } = {}) {
|
|
196
|
+
this._link = link;
|
|
197
|
+
this._path = requestPath;
|
|
198
|
+
this._timeoutMs = timeoutMs;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* @param {Uint8Array} challengePayload
|
|
203
|
+
* @returns {Promise<Uint8Array | null>}
|
|
204
|
+
*/
|
|
205
|
+
async call(challengePayload) {
|
|
206
|
+
try {
|
|
207
|
+
const response = await this._link.request(this._path, challengePayload, {
|
|
208
|
+
timeout: this._timeoutMs,
|
|
209
|
+
});
|
|
210
|
+
if (!(response instanceof Uint8Array)) return null;
|
|
211
|
+
return response;
|
|
212
|
+
} catch {
|
|
213
|
+
return null; // inactive link / send failure / timeout → partition → DENY (§8)
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Opens an RNS Link to `destination` and awaits ACTIVE (§8.2).
|
|
220
|
+
*
|
|
221
|
+
* @param {DestinationType} destination An OUT destination whose identity is the
|
|
222
|
+
* authoritative responder.
|
|
223
|
+
* @param {Object} [opts]
|
|
224
|
+
* @param {number} [opts.timeoutMs] Establishment timeout in milliseconds.
|
|
225
|
+
* @returns {Promise<LinkType | null>} The active Link, or `null` if it could
|
|
226
|
+
* not be established within `timeoutMs` (partition → §8 DENY).
|
|
227
|
+
*/
|
|
228
|
+
export async function establishLink(destination, { timeoutMs = DEFAULT_ESTABLISH_TIMEOUT_MS } = {}) {
|
|
229
|
+
let link;
|
|
230
|
+
try {
|
|
231
|
+
link = await Link.initiate(destination, destination.interfaceLayer.transport);
|
|
232
|
+
return await link.whenActive(timeoutMs);
|
|
233
|
+
} catch {
|
|
234
|
+
if (link) {
|
|
235
|
+
try {
|
|
236
|
+
await link.teardown();
|
|
237
|
+
} catch {
|
|
238
|
+
// teardown is best-effort on a failed handshake
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
return null;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RNS Identity-backed KeyResolver (spec §3.1, §11.2.4).
|
|
3
|
+
*
|
|
4
|
+
* By spec a Dacar single-identity Issuer Hash **is** a standard 16-byte
|
|
5
|
+
* `RNS.Identity` hash — `SHA-256(P)[:16]` where `P` is the 64-byte RNS public
|
|
6
|
+
* key (`X25519_pub ‖ Ed25519_pub`). This resolver turns such a hash into the
|
|
7
|
+
* full 64-byte public key needed to verify an Operation's signature, by
|
|
8
|
+
* querying RNS's Identity *recall* store — the same store a live Reticulum
|
|
9
|
+
* populates from announce interception.
|
|
10
|
+
*
|
|
11
|
+
* Dacar's verify-on-ingest (§11.2.4) therefore works on real network Deltas
|
|
12
|
+
* with no out-of-band key exchange: an announced Identity is recalled by its
|
|
13
|
+
* hash, and the Operation it claims to be from is signature-checked against
|
|
14
|
+
* that Identity's signing key.
|
|
15
|
+
*
|
|
16
|
+
* Threshold Groups (§4.1) cannot be resolved this way — their Group ID is a
|
|
17
|
+
* composite hash, not an RNS identity, so RNS has nothing to recall. They —
|
|
18
|
+
* and any out-of-band single identities — fall through to an optional
|
|
19
|
+
* *fallback* resolver (e.g. a {@link Keyring} of pre-registered group
|
|
20
|
+
* keysets). RNS is consulted first, then the fallback, so announced
|
|
21
|
+
* identities always win.
|
|
22
|
+
*
|
|
23
|
+
* This module is part of the optional transport layer: importing the pure
|
|
24
|
+
* core (`@reticulum/dacar`) never pulls it in. It depends only on
|
|
25
|
+
* `@reticulum/core`'s `Destination.recall`, which the core already depends on
|
|
26
|
+
* for `Identity` / `MsgPack`, so it adds no new dependency.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { Destination } from "@reticulum/core";
|
|
30
|
+
import { IssuerKeyset } from "../verifier.js";
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Invokes a resolver (`KeyResolver` function or a {@link Keyring}) and awaits
|
|
34
|
+
* its result, tolerating either shape. Mirrors the dispatch in verifier.js.
|
|
35
|
+
* @param {import("../verifier.js").KeyResolver | import("../verifier.js").Keyring | null} resolver
|
|
36
|
+
* @param {Uint8Array} hash
|
|
37
|
+
* @returns {Promise<import("../verifier.js").IssuerKeyset | null>}
|
|
38
|
+
*/
|
|
39
|
+
async function resolveWith(resolver, hash) {
|
|
40
|
+
if (typeof resolver === "function") return await resolver(hash);
|
|
41
|
+
if (resolver && typeof resolver.resolve === "function") {
|
|
42
|
+
return await resolver.resolve(hash);
|
|
43
|
+
}
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Resolves single-identity Issuer hashes via the RNS Identity recall store.
|
|
49
|
+
*
|
|
50
|
+
* Usable directly as a {@link import("../verifier.js").KeyResolver KeyResolver}
|
|
51
|
+
* — both `resolve()` and the callable form (via `resolve` being the only
|
|
52
|
+
* method) are accepted by `DeltaReceiver` / `verifyOperation`, which dispatch
|
|
53
|
+
* on `typeof resolver === "function"`. Pass the resolver itself (or its
|
|
54
|
+
* `.resolve` method) where a `KeyResolver` function is expected.
|
|
55
|
+
*/
|
|
56
|
+
export class RnsIdentityResolver {
|
|
57
|
+
/**
|
|
58
|
+
* @param {import("../verifier.js").KeyResolver | import("../verifier.js").Keyring | null} [fallback]
|
|
59
|
+
* Consulted when RNS has no Identity for a hash — e.g. for Threshold Group
|
|
60
|
+
* IDs and out-of-band identities. RNS is consulted first, then the fallback.
|
|
61
|
+
*/
|
|
62
|
+
constructor(fallback = null) {
|
|
63
|
+
this._fallback = fallback;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Resolve a 16-byte Issuer hash to an {@link IssuerKeyset}, or `null` when
|
|
68
|
+
* the Issuer is unknown to both RNS and the fallback (the Operation is then
|
|
69
|
+
* rejected as unverifiable).
|
|
70
|
+
* @param {Uint8Array} issuerHash
|
|
71
|
+
* @returns {Promise<import("../verifier.js").IssuerKeyset | null>}
|
|
72
|
+
*/
|
|
73
|
+
async resolve(issuerHash) {
|
|
74
|
+
// `fromIdentityHash = true` scans the recall store matching by the
|
|
75
|
+
// identity hash (SHA-256(P)[:16]) rather than by destination hash.
|
|
76
|
+
const identity = await Destination.recall(issuerHash, true);
|
|
77
|
+
if (identity) {
|
|
78
|
+
// IssuerKeyset carries the full 64-byte RNS public key (X25519 ‖ Ed25519).
|
|
79
|
+
return IssuerKeyset.single(await identity.getPublicKey());
|
|
80
|
+
}
|
|
81
|
+
if (this._fallback) return resolveWith(this._fallback, issuerHash);
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
}
|
package/src/tuple.js
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The authorization Tuple and its canonical hash (Dacar spec §3.1, §6.1).
|
|
3
|
+
*
|
|
4
|
+
* A Tuple asserts that a Grantee holds a Relation over an Object, authorized by
|
|
5
|
+
* an Issuer: `(Object, Relation, Grantee, Issuer)`.
|
|
6
|
+
*
|
|
7
|
+
* For Namespace Label Privacy (§3.3), the Relation and Object are stored *only*
|
|
8
|
+
* as their 16-byte salted hashes. The **Tuple Hash** (§6.1) is SHA-256 over:
|
|
9
|
+
*
|
|
10
|
+
* [16-byte Issuer] + [16-byte Grantee] + [16-byte Relation Hash]
|
|
11
|
+
* + [1-byte Wildcard Flag] + [1-byte Segment Count] + [Object Hashes]
|
|
12
|
+
*
|
|
13
|
+
* Action and HLC are deliberately excluded, so a Grant and its Revoke for the
|
|
14
|
+
* same permission resolve to the *same* Tuple Hash. The pre-image is built
|
|
15
|
+
* synchronously and uniquely identifies a Tuple, so `toHex(preimage)` doubles as
|
|
16
|
+
* the CRDT's internal map key; the full async SHA-256 is available via `hash()`.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { toHex } from "@reticulum/core";
|
|
20
|
+
import { HASH_SIZE, bytesEqual } from "./namespace.js";
|
|
21
|
+
|
|
22
|
+
/** Maximum number of Object segments (the Segment Count field is one byte). */
|
|
23
|
+
export const MAX_SEGMENTS = 0xff;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* @typedef {Object} HashedTupleInit
|
|
27
|
+
* @property {Uint8Array} relationHash 16-byte HMAC of the relation string.
|
|
28
|
+
* @property {Uint8Array[]} objectHashes 16-byte HMAC per non-wildcard segment.
|
|
29
|
+
* @property {boolean} wildcard True iff the Object ended in the suffix `*`.
|
|
30
|
+
* @property {Uint8Array} grantee 16-byte holder identity hash.
|
|
31
|
+
* @property {Uint8Array} issuer 16-byte issuer identity hash or Group ID.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
export class Tuple {
|
|
35
|
+
/** @param {HashedTupleInit} init */
|
|
36
|
+
constructor({ relationHash, objectHashes, wildcard, grantee, issuer }) {
|
|
37
|
+
if (!(relationHash instanceof Uint8Array) || relationHash.length !== HASH_SIZE) {
|
|
38
|
+
throw new RangeError(`relationHash must be ${HASH_SIZE} bytes`);
|
|
39
|
+
}
|
|
40
|
+
if (!(grantee instanceof Uint8Array) || grantee.length !== HASH_SIZE) {
|
|
41
|
+
throw new RangeError(`grantee must be ${HASH_SIZE} bytes`);
|
|
42
|
+
}
|
|
43
|
+
if (!(issuer instanceof Uint8Array) || issuer.length !== HASH_SIZE) {
|
|
44
|
+
throw new RangeError(`issuer must be ${HASH_SIZE} bytes`);
|
|
45
|
+
}
|
|
46
|
+
if (objectHashes.length > MAX_SEGMENTS) {
|
|
47
|
+
throw new RangeError(`too many object segments (${objectHashes.length} > ${MAX_SEGMENTS})`);
|
|
48
|
+
}
|
|
49
|
+
for (const h of objectHashes) {
|
|
50
|
+
if (!(h instanceof Uint8Array) || h.length !== HASH_SIZE) {
|
|
51
|
+
throw new RangeError(`object segment hash must be ${HASH_SIZE} bytes`);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
/** @readonly */ this.relationHash = relationHash;
|
|
55
|
+
/** @readonly */ this.objectHashes = Object.freeze([...objectHashes]);
|
|
56
|
+
/** @readonly */ this.wildcard = wildcard;
|
|
57
|
+
/** @readonly */ this.grantee = grantee;
|
|
58
|
+
/** @readonly */ this.issuer = issuer;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Build a Tuple by hashing plaintext labels with `hasher` (§3.3).
|
|
63
|
+
* @param {Object} opts
|
|
64
|
+
* @param {string} opts.objectId
|
|
65
|
+
* @param {string} opts.relation
|
|
66
|
+
* @param {Uint8Array} opts.grantee
|
|
67
|
+
* @param {Uint8Array} opts.issuer
|
|
68
|
+
* @param {import("./namespace.js").NamespaceHasher} opts.hasher
|
|
69
|
+
* @returns {Promise<Tuple>}
|
|
70
|
+
*/
|
|
71
|
+
static async fromPlaintext({ objectId, relation, grantee, issuer, hasher }) {
|
|
72
|
+
const [relationHash, { hashes, wildcard }] = await Promise.all([
|
|
73
|
+
hasher.hashRelation(relation),
|
|
74
|
+
hasher.hashObject(objectId),
|
|
75
|
+
]);
|
|
76
|
+
return new Tuple({ relationHash, objectHashes: hashes, wildcard, grantee, issuer });
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** §6.1 hash pre-image (excludes Action + HLC). @returns {Uint8Array} */
|
|
80
|
+
get preimage() {
|
|
81
|
+
let len = HASH_SIZE * 3 + 2; // issuer + grantee + relationHash + flags
|
|
82
|
+
for (const h of this.objectHashes) len += h.length;
|
|
83
|
+
const out = new Uint8Array(len);
|
|
84
|
+
let o = 0;
|
|
85
|
+
out.set(this.issuer, o); o += HASH_SIZE;
|
|
86
|
+
out.set(this.grantee, o); o += HASH_SIZE;
|
|
87
|
+
out.set(this.relationHash, o); o += HASH_SIZE;
|
|
88
|
+
out[o++] = this.wildcard ? 0x01 : 0x00;
|
|
89
|
+
out[o++] = this.objectHashes.length;
|
|
90
|
+
for (const h of this.objectHashes) {
|
|
91
|
+
out.set(h, o);
|
|
92
|
+
o += h.length;
|
|
93
|
+
}
|
|
94
|
+
return out;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Canonical 32-byte SHA-256 Tuple Hash (§6.1). @returns {Promise<Uint8Array>} */
|
|
98
|
+
async hash() {
|
|
99
|
+
const digest = await crypto.subtle.digest("SHA-256", this.preimage);
|
|
100
|
+
return new Uint8Array(digest);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Stable unique key derived from the §6.1 pre-image (sync). @returns {string} */
|
|
104
|
+
get key() {
|
|
105
|
+
return toHex(this.preimage);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Structural equality with another Tuple. @param {unknown} other @returns {boolean} */
|
|
109
|
+
equals(other) {
|
|
110
|
+
if (!(other instanceof Tuple)) return false;
|
|
111
|
+
return (
|
|
112
|
+
bytesEqual(this.relationHash, other.relationHash) &&
|
|
113
|
+
this.objectHashes.length === other.objectHashes.length &&
|
|
114
|
+
this.objectHashes.every((h, i) => bytesEqual(h, other.objectHashes[i])) &&
|
|
115
|
+
this.wildcard === other.wildcard &&
|
|
116
|
+
bytesEqual(this.grantee, other.grantee) &&
|
|
117
|
+
bytesEqual(this.issuer, other.issuer)
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
}
|