@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.
@@ -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
+ }