@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,256 @@
1
+ /**
2
+ * Signed authorization Operations / Deltas (Dacar spec §5.2, §5.3).
3
+ *
4
+ * An Operation is a cryptographically signed instruction to Grant (Add) or
5
+ * Revoke (Remove) a Tuple. Ed25519 signing/verification is delegated to the
6
+ * `Identity` from `@reticulum/core` (Web Crypto), and transport serialization
7
+ * uses its MessagePack implementation.
8
+ *
9
+ * Single-identity issuers carry exactly one signature; Threshold Group issuers
10
+ * carry exactly `N` signatures from distinct members (§5.2).
11
+ */
12
+
13
+ import { Identity, MsgPack, toHex } from "@reticulum/core";
14
+ import { MAX_HLC } from "./hlc.js";
15
+ import { Tuple } from "./tuple.js";
16
+
17
+ /** Ed25519 signatures are always 64 bytes. */
18
+ export const SIGNATURE_SIZE = 64;
19
+ /** HLC timestamps travel as 64-bit big-endian unsigned integers. */
20
+ export const HLC_BYTES = 8;
21
+
22
+ /**
23
+ * The effect of an Operation on the CRDT.
24
+ * @readonly
25
+ * @enum {number}
26
+ */
27
+ export const Action = {
28
+ REVOKE: 0x00,
29
+ GRANT: 0x01,
30
+ };
31
+
32
+ /**
33
+ * @typedef {Object} OperationInit
34
+ * @property {Tuple} tuple
35
+ * @property {number} action One of {@link Action}.
36
+ * @property {bigint} hlc
37
+ * @property {Uint8Array[]} [signatures] 64-byte signatures (empty if unsigned).
38
+ */
39
+
40
+ export class Operation {
41
+ /** @param {OperationInit} init */
42
+ constructor({ tuple, action, hlc, signatures = [] }) {
43
+ if (action !== Action.GRANT && action !== Action.REVOKE) {
44
+ throw new TypeError("action must be Action.GRANT or Action.REVOKE");
45
+ }
46
+ if (typeof hlc !== "bigint" || hlc < 0n || hlc > MAX_HLC) {
47
+ throw new RangeError("hlc must be a bigint in [0, 2^64)");
48
+ }
49
+ if (!Array.isArray(signatures)) {
50
+ throw new TypeError("signatures must be an array");
51
+ }
52
+ for (const sig of signatures) {
53
+ if (!(sig instanceof Uint8Array) || sig.length !== SIGNATURE_SIZE) {
54
+ throw new RangeError(`each signature must be ${SIGNATURE_SIZE} bytes`);
55
+ }
56
+ }
57
+ this.tuple = tuple;
58
+ this.action = action;
59
+ this.hlc = hlc;
60
+ /** @type {Uint8Array[]} */
61
+ this.signatures = Object.freeze([...signatures]);
62
+ }
63
+
64
+ get issuer() {
65
+ return this.tuple.issuer;
66
+ }
67
+ get grantee() {
68
+ return this.tuple.grantee;
69
+ }
70
+ get relationHash() {
71
+ return this.tuple.relationHash;
72
+ }
73
+ get objectHashes() {
74
+ return this.tuple.objectHashes;
75
+ }
76
+ get wildcard() {
77
+ return this.tuple.wildcard;
78
+ }
79
+
80
+ /** §5.2 signature pre-image. @returns {Uint8Array} */
81
+ get preimage() {
82
+ let len = 16 + 16 + 1 + HLC_BYTES + 16 + 1 + 1;
83
+ for (const h of this.tuple.objectHashes) len += h.length;
84
+ const out = new Uint8Array(len);
85
+ let o = 0;
86
+ out.set(this.tuple.issuer, o); o += 16;
87
+ out.set(this.tuple.grantee, o); o += 16;
88
+ out[o++] = this.action;
89
+ new DataView(out.buffer).setBigUint64(o, this.hlc, false); // big-endian
90
+ o += HLC_BYTES;
91
+ out.set(this.tuple.relationHash, o); o += 16;
92
+ out[o++] = this.tuple.wildcard ? 0x01 : 0x00;
93
+ out[o++] = this.tuple.objectHashes.length;
94
+ for (const h of this.tuple.objectHashes) {
95
+ out.set(h, o);
96
+ o += h.length;
97
+ }
98
+ return out;
99
+ }
100
+
101
+ /**
102
+ * Return a copy signed with one or more `@reticulum/core` Identities holding
103
+ * private keys. Each identity produces one signature, in argument order.
104
+ * Pass one identity for a single-identity issuer, or `N` member identities for
105
+ * a Threshold Group issuer (§5.2).
106
+ * @param {...Identity} identities
107
+ * @returns {Promise<Operation>}
108
+ */
109
+ async sign(...identities) {
110
+ if (identities.length === 0) throw new Error("at least one signing identity is required");
111
+ const preimage = this.preimage;
112
+ const signatures = await Promise.all(identities.map((id) => id.sign(preimage)));
113
+ return new Operation({ tuple: this.tuple, action: this.action, hlc: this.hlc, signatures });
114
+ }
115
+
116
+ /**
117
+ * Coerce a public-key-like value into an `@reticulum/core` Identity.
118
+ * @param {Identity | Uint8Array} value
119
+ * @returns {Promise<Identity>}
120
+ */
121
+ static async _asIdentity(value) {
122
+ return value instanceof Identity ? value : await Identity.fromPublicKey(value);
123
+ }
124
+
125
+ /**
126
+ * Verify a single-identity Operation against one public key (§5.2).
127
+ * @param {Identity | Uint8Array} identityOrPublicKey
128
+ * @returns {Promise<boolean>}
129
+ */
130
+ async verify(identityOrPublicKey) {
131
+ if (this.signatures.length !== 1) return false;
132
+ return this.verifyThreshold([identityOrPublicKey], 1);
133
+ }
134
+
135
+ /**
136
+ * Verify a Threshold Group Operation (§5.2, §4.1). Requires exactly
137
+ * `threshold` signatures, each valid against a *distinct* member public key.
138
+ * Duplicate signatures, or signatures verifying against the same public key
139
+ * more than once, are rejected.
140
+ * @param {(Identity | Uint8Array)[]} memberPublicKeys
141
+ * @param {number} threshold
142
+ * @returns {Promise<boolean>}
143
+ */
144
+ async verifyThreshold(memberPublicKeys, threshold) {
145
+ if (!(Number.isInteger(threshold) && threshold >= 1) || this.signatures.length !== threshold) {
146
+ return false;
147
+ }
148
+ if (memberPublicKeys.length < threshold) return false;
149
+ const preimage = this.preimage;
150
+ /** @type {{ id: Identity, key: Uint8Array }[]} */
151
+ const members = [];
152
+ for (const v of memberPublicKeys) {
153
+ const id = await Operation._asIdentity(v);
154
+ members.push({ id, key: await id.getPublicKey() });
155
+ }
156
+ const used = new Set(); // hex of used public keys
157
+ for (const sig of this.signatures) {
158
+ if (sig.length !== SIGNATURE_SIZE) return false;
159
+ let matched = null;
160
+ for (const m of members) {
161
+ const hex = toHex(m.key);
162
+ if (used.has(hex)) continue;
163
+ if (await m.id.validate(sig, preimage)) {
164
+ matched = hex;
165
+ break;
166
+ }
167
+ }
168
+ if (matched === null) return false;
169
+ used.add(matched);
170
+ }
171
+ return used.size === threshold;
172
+ }
173
+
174
+ /**
175
+ * Verify against a resolved `IssuerKeyset` (§11.2.4 bridge). A resolver maps
176
+ * the Operation's 16-byte Issuer hash to a keyset; this confirms the
177
+ * threshold signature against it.
178
+ * @param {import("./verifier.js").IssuerKeyset} keyset
179
+ * @returns {Promise<boolean>}
180
+ */
181
+ async verifyKeyset(keyset) {
182
+ return this.verifyThreshold(keyset.memberPublicKeys, keyset.threshold);
183
+ }
184
+
185
+ /** §5.3 transport payload (the Operation must be signed first). @returns {Uint8Array} */
186
+ toPayload() {
187
+ if (this.signatures.length === 0) {
188
+ throw new Error("Operation must be signed before payload serialization");
189
+ }
190
+ return MsgPack.encode([
191
+ this.tuple.issuer,
192
+ this.tuple.grantee,
193
+ this.action,
194
+ this.hlc,
195
+ this.tuple.relationHash,
196
+ [...this.tuple.objectHashes],
197
+ this.tuple.wildcard,
198
+ [...this.signatures],
199
+ ]);
200
+ }
201
+
202
+ /**
203
+ * Deserialize a §5.3 transport payload.
204
+ * @param {Uint8Array} data
205
+ * @returns {Operation}
206
+ */
207
+ static fromPayload(data) {
208
+ const decoded = MsgPack.decode(data);
209
+ if (!Array.isArray(decoded) || decoded.length !== 8) {
210
+ throw new Error("payload must be an 8-element MessagePack array");
211
+ }
212
+ const [issuer, grantee, action, hlc, relationHash, objectHashes, wildcard, signatures] = decoded;
213
+ if (action !== Action.GRANT && action !== Action.REVOKE) {
214
+ throw new Error(`unknown action byte ${action}`);
215
+ }
216
+ if (!Array.isArray(signatures) || signatures.length === 0) {
217
+ throw new Error("signatures must be a non-empty array of 64-byte blobs");
218
+ }
219
+ return new Operation({
220
+ tuple: new Tuple({
221
+ relationHash: expectBytes(relationHash, 16, "relation_hash"),
222
+ objectHashes: objectHashes.map((h) => expectBytes(h, 16, "object_hash")),
223
+ wildcard: expectBool(wildcard, "wildcard"),
224
+ grantee: expectBytes(grantee, 16, "grantee"),
225
+ issuer: expectBytes(issuer, 16, "issuer"),
226
+ }),
227
+ action,
228
+ hlc: BigInt(hlc),
229
+ signatures: signatures.map((s) => expectBytes(s, SIGNATURE_SIZE, "signature")),
230
+ });
231
+ }
232
+ }
233
+
234
+ /**
235
+ * @param {unknown} value
236
+ * @param {number} len
237
+ * @param {string} name
238
+ * @returns {Uint8Array}
239
+ */
240
+ function expectBytes(value, len, name) {
241
+ if (!(value instanceof Uint8Array) || value.length !== len) {
242
+ throw new Error(`${name} must be a ${len}-byte Uint8Array`);
243
+ }
244
+ return value;
245
+ }
246
+
247
+ /**
248
+ * @param {unknown} value
249
+ * @param {string} name
250
+ * @returns {boolean}
251
+ */
252
+ function expectBool(value, name) {
253
+ if (typeof value !== "boolean") throw new Error(`${name} must be a boolean`);
254
+ return value;
255
+ }
256
+
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Threshold Trust Anchors: N-of-M identity groups (Dacar spec §4.1).
3
+ *
4
+ * A Threshold Group is a composite authority requiring consensus: an Operation
5
+ * issued *by* the group MUST carry exactly `N` valid signatures from `N`
6
+ * distinct members of the `M`-member set (§5.2).
7
+ *
8
+ * The **Group ID** is the SHA-256 hash of the alphabetically sorted member
9
+ * hashes concatenated with the threshold `N`, truncated to the first 16 bytes
10
+ * (§4.1). The Group ID is itself a 16-byte value usable wherever an Issuer hash
11
+ * is expected.
12
+ *
13
+ * > Scope (§4.1): in v1.0, Threshold Groups MAY ONLY act as Issuers.
14
+ *
15
+ * SHA-256 uses Web Crypto, so `groupId()` is asynchronous. Compute it once and
16
+ * cache the result (`group.id` after the first `await group.groupId()`).
17
+ */
18
+
19
+ import { HASH_SIZE } from "./namespace.js";
20
+
21
+ const encoder = new TextEncoder();
22
+
23
+ /** The threshold `N` is folded into the Group ID as an 8-byte big-endian int. */
24
+ const THRESHOLD_BYTES = 8;
25
+
26
+ /** Compare two 16-byte member hashes for ascending sort (byte-wise === hex). */
27
+ function compareHashes(a, b) {
28
+ for (let i = 0; i < a.length; i++) {
29
+ if (a[i] !== b[i]) return a[i] - b[i];
30
+ }
31
+ return 0;
32
+ }
33
+
34
+ /** Synchronously validate member hashes and threshold (shared by all paths). */
35
+ function validateMembers(members, threshold) {
36
+ if (!(Number.isInteger(threshold) && threshold >= 1 && threshold <= members.length)) {
37
+ throw new Error(
38
+ `threshold must satisfy 1 <= N <= M (got N=${threshold}, M=${members.length})`,
39
+ );
40
+ }
41
+ for (const m of members) {
42
+ if (!(m instanceof Uint8Array) || m.length !== HASH_SIZE) {
43
+ throw new RangeError(`member hash must be ${HASH_SIZE} bytes`);
44
+ }
45
+ }
46
+ if (members.length < 2) {
47
+ throw new Error("a threshold group needs at least 2 members (M)");
48
+ }
49
+ }
50
+
51
+ /**
52
+ * Compute the 16-byte Group ID for a member set and threshold (§4.1).
53
+ *
54
+ * Members are 16-byte identity hashes, sorted ascending by raw byte value
55
+ * (equivalent to hex-alphabetical order). The threshold `N` is appended as an
56
+ * 8-byte big-endian unsigned integer, then SHA-256 of the whole blob is
57
+ * truncated to 16 bytes.
58
+ * @param {Uint8Array[]} members
59
+ * @param {number} threshold
60
+ * @returns {Promise<Uint8Array>}
61
+ */
62
+ export async function groupId(members, threshold) {
63
+ const normalized = [...members].sort(compareHashes);
64
+ validateMembers(normalized, threshold);
65
+ const nBytes = new Uint8Array(THRESHOLD_BYTES);
66
+ new DataView(nBytes.buffer).setBigUint64(0, BigInt(threshold), false); // big-endian
67
+ const total = normalized.length * HASH_SIZE + THRESHOLD_BYTES;
68
+ const blob = new Uint8Array(total);
69
+ let o = 0;
70
+ for (const m of normalized) {
71
+ blob.set(m, o);
72
+ o += HASH_SIZE;
73
+ }
74
+ blob.set(nBytes, o);
75
+ const digest = await crypto.subtle.digest("SHA-256", blob);
76
+ return new Uint8Array(digest).slice(0, HASH_SIZE);
77
+ }
78
+
79
+ export class ThresholdGroup {
80
+ /**
81
+ * @param {Uint8Array[]} members M member identity hashes (16 bytes each).
82
+ * @param {number} threshold The consensus threshold N.
83
+ */
84
+ constructor(members, threshold) {
85
+ validateMembers([...members], threshold);
86
+ /** @readonly @type {Uint8Array[]} sorted ascending. */
87
+ this.members = [...members].sort(compareHashes);
88
+ /** @readonly */ this.threshold = threshold;
89
+ /** Cached Group ID once computed. @type {Uint8Array | null} */
90
+ this._id = null;
91
+ this._idPromise = null;
92
+ }
93
+
94
+ /** The number of members `M`. @returns {number} */
95
+ get size() {
96
+ return this.members.length;
97
+ }
98
+
99
+ /** The 16-byte Group ID (cached after the first call). @returns {Promise<Uint8Array>} */
100
+ groupId() {
101
+ if (this._id) return Promise.resolve(this._id);
102
+ if (!this._idPromise) {
103
+ this._idPromise = groupId(this.members, this.threshold).then((id) => {
104
+ this._id = id;
105
+ return id;
106
+ });
107
+ }
108
+ return this._idPromise;
109
+ }
110
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Optional RNS/RFed/LXMF-dependent transport adapters for Dacar (spec §8, §11).
3
+ *
4
+ * These wire the pure, transport-agnostic core — the §8 `Challenge`/
5
+ * `AuthoritativeServer`/`ChallengeClient` and the §11 `DeltaReceiver` — to the
6
+ * concrete Reticulum transports from `@reticulum/core`:
7
+ *
8
+ * - {@link RnsIdentityResolver} §3.1, §11.2.4 recall → verify key
9
+ * - {@link RnsChallengeServer} &c. §8 Challenge over an RNS Link
10
+ * - {@link LxmfDeltaDelivery} §11.2/§11.3 targeted LXMF + Paper Messages
11
+ * - {@link RfedDeltaSync} §11.1 RFed many-to-many convergence
12
+ *
13
+ * Importing the pure core (`@reticulum/dacar`) does **not** import this
14
+ * subpath: it is opt-in via `@reticulum/dacar/transport`. Every adapter depends
15
+ * only on `@reticulum/core`, which the core already depends on, so the
16
+ * transport layer adds no new dependency.
17
+ */
18
+
19
+ export { RnsIdentityResolver } from "./rnsIdentity.js";
20
+
21
+ export {
22
+ CHALLENGE_REQUEST_PATH,
23
+ DEFAULT_CHALLENGE_TIMEOUT_MS,
24
+ DEFAULT_ESTABLISH_TIMEOUT_MS,
25
+ challengeRequestHandler,
26
+ RnsChallengeServer,
27
+ RnsLinkTransport,
28
+ establishLink,
29
+ } from "./rnsChallenge.js";
30
+
31
+ export {
32
+ LxmfDeltaDelivery,
33
+ messageTitle,
34
+ messageContent,
35
+ } from "./lxmfSync.js";
36
+
37
+ export { RfedDeltaSync } from "./rfedSync.js";
@@ -0,0 +1,221 @@
1
+ /**
2
+ * §11.2 targeted Delta delivery + §11.3 Paper Messages over LXMF.
3
+ *
4
+ * LXMF gives Dacar forward-secret, store-and-forward, point-to-point delivery
5
+ * of Deltas to (possibly offline) nodes, alongside the public RFed broadcast
6
+ * (§11.1, served by `./rfedSync.js`). A Delta (the §5.3 MessagePack payload)
7
+ * is embedded as the *content* of an LXMF message whose title is the fixed
8
+ * discriminator `dacar/sync/delta`; on receipt, only messages with that title
9
+ * are fed to the shared {@link import("../delta.js").DeltaReceiver
10
+ * DeltaReceiver} (verify-on-ingest, §11.2.4).
11
+ *
12
+ * §11.3 reuses the very same LXMF messages in LXMF's Paper Message encoding
13
+ * (the `lxm://` URI, high-density QR), giving a fully air-gapped, optical
14
+ * channel: export produces the encrypted URI; import feeds it straight back
15
+ * through the router.
16
+ *
17
+ * This module is part of the optional transport layer: importing the pure core
18
+ * never pulls it in. It depends only on `@reticulum/core`, which the core
19
+ * already depends on, so no new dependency is added.
20
+ *
21
+ * Typical use (receiver, wired to a {@link import("@reticulum/core").LXMRouter
22
+ * LXMRouter}):
23
+ *
24
+ * ```js
25
+ * router.addEventListener("message", async (event) => {
26
+ * await delivery.handleMessage(event.detail.message);
27
+ * });
28
+ * ```
29
+ *
30
+ * Typical use (sender, to a known recipient `lxmf.delivery` hash):
31
+ *
32
+ * ```js
33
+ * await delivery.deliver(deltaPayload, recipientDeliveryHash);
34
+ * ```
35
+ *
36
+ * @example Receiver with a DeltaReceiver wired in
37
+ * // event.detail.message is the LXMF message the router just decrypted.
38
+ * await delivery.handleMessage(event.detail.message);
39
+ */
40
+
41
+ import { LXMessage as LXMFMessage } from "@reticulum/core";
42
+ import { LXMF_DELIVERY_TITLE } from "../naming.js";
43
+
44
+ /**
45
+ * Best-effort title of an LXMF message as text.
46
+ *
47
+ * Tolerates the title being a `Uint8Array` (some code paths leave it binary),
48
+ * decoding it leniently. Used to filter on the fixed `dacar/sync/delta`
49
+ * discriminator without ever touching the payload.
50
+ * @param {{ title?: string | Uint8Array } | null} message
51
+ * @returns {string}
52
+ */
53
+ export function messageTitle(message) {
54
+ const t = message ? message.title : undefined;
55
+ if (typeof t === "string") return t;
56
+ if (t instanceof Uint8Array) return new TextDecoder().decode(t);
57
+ return "";
58
+ }
59
+
60
+ /**
61
+ * Best-effort content of an LXMF message as raw bytes (the §5.3 Delta payload).
62
+ *
63
+ * `@reticulum/core`'s `Message.deserialize()` UTF-8-decodes the content element
64
+ * into `message.content`, which corrupts arbitrary binary Deltas. The raw bytes
65
+ * are preserved on `_decodedPayload[2]` (the same field the library uses
66
+ * internally for §5.6 signature re-verification), so this helper recovers the
67
+ * exact bytes a Python peer sent with `content=<delta bytes>`. For an
68
+ * in-process-constructed message whose `content` is already a `Uint8Array`, it
69
+ * returns that directly.
70
+ * @param {{ content?: string | Uint8Array, _decodedPayload?: any[] } | null} message
71
+ * @returns {Uint8Array}
72
+ */
73
+ export function messageContent(message) {
74
+ // Deserialized message: the raw content bytes survive on the decoded payload.
75
+ const raw = message ? message._decodedPayload : undefined;
76
+ if (Array.isArray(raw) && raw[2] instanceof Uint8Array) {
77
+ return new Uint8Array(raw[2]);
78
+ }
79
+ const c = message ? message.content : undefined;
80
+ if (c instanceof Uint8Array) return new Uint8Array(c);
81
+ if (typeof c === "string") return new TextEncoder().encode(c);
82
+ return new Uint8Array(0);
83
+ }
84
+
85
+ /**
86
+ * §11.2 targeted Delta delivery over LXMF; §11.3 Paper Message channel.
87
+ *
88
+ * The send paths (`deliver`, `makePaperUri`) are thin wrappers over a bound
89
+ * `LXMRouter` / outbound `Destination`; the receive path (`handleMessage`) is
90
+ * the title filter + verify-on-ingest seam and is fully testable without a
91
+ * live network (the LXMF codec is pure).
92
+ */
93
+ export class LxmfDeltaDelivery {
94
+ /** Fixed title discriminator (spec §11.2). Aliases `LXMF_DELIVERY_TITLE`. */
95
+ static TITLE = LXMF_DELIVERY_TITLE;
96
+
97
+ /**
98
+ * @param {Object} [opts]
99
+ * @param {import("../delta.js").DeltaReceiver | null} [opts.receiver]
100
+ * The shared DeltaReceiver (state + key resolver). May be omitted on a
101
+ * send-only node (then `handleMessage` throws if called).
102
+ * @param {import("@reticulum/core").LXMRouter | null} [opts.router]
103
+ * Optional bound `LXMRouter` for `deliver` / `ingestPaperUri`.
104
+ */
105
+ constructor({ receiver = null, router = null } = {}) {
106
+ /** @type {import("../delta.js").DeltaReceiver | null} */
107
+ this._receiver = receiver;
108
+ /** @type {import("@reticulum/core").LXMRouter | null} */
109
+ this._router = router;
110
+ }
111
+
112
+ // -- §11.2 send --------------------------------------------------------
113
+
114
+ /**
115
+ * Builds an LXMF message wrapping one §5.3 Delta payload (§11.2.2).
116
+ *
117
+ * The content is the raw Delta bytes (encoded `bin` on the wire, matching the
118
+ * Python reference's `content=<delta bytes>`), under the fixed
119
+ * `dacar/sync/delta` title. The returned message is *not yet sent*; pass it
120
+ * to `router.send()` (or call {@link deliver}) to queue it for the network.
121
+ * @param {Uint8Array} deltaPayload
122
+ * @param {Uint8Array} destinationHash The recipient `lxmf.delivery` hash.
123
+ * @param {Uint8Array} sourceHash The sender's `lxmf.delivery` hash.
124
+ * @returns {import("@reticulum/core").LXMessage}
125
+ */
126
+ makeMessage(deltaPayload, destinationHash, sourceHash) {
127
+ if (!(deltaPayload instanceof Uint8Array)) {
128
+ throw new TypeError("deltaPayload must be a Uint8Array");
129
+ }
130
+ return new LXMFMessage({
131
+ destinationHash,
132
+ sourceHash,
133
+ // bin on the wire — round-trips byte-identical with the Python reference.
134
+ content: new Uint8Array(deltaPayload),
135
+ title: LxmfDeltaDelivery.TITLE,
136
+ });
137
+ }
138
+
139
+ /**
140
+ * Builds and queues a Delta for LXMF delivery via the bound router (§11.2).
141
+ *
142
+ * Uses the router's identity as the LXMF sender (and source hash). Delivery
143
+ * method is chosen by the router (DIRECT link, falling back to opportunistic).
144
+ * @param {Uint8Array} deltaPayload
145
+ * @param {Uint8Array} destinationHash The recipient `lxmf.delivery` hash.
146
+ * @param {Object} [opts]
147
+ * @param {Uint8Array | null} [opts.linkId] Reuse an existing DIRECT link id.
148
+ * @returns {Promise<import("@reticulum/core").LXMessage>}
149
+ */
150
+ async deliver(deltaPayload, destinationHash, { linkId = null } = {}) {
151
+ if (!this._router) {
152
+ throw new Error("LxmfDeltaDelivery.deliver requires a router");
153
+ }
154
+ const sourceHash = this._router.identity.identityHash;
155
+ const message = this.makeMessage(deltaPayload, destinationHash, sourceHash);
156
+ await this._router.send(message, this._router.identity, linkId);
157
+ return message;
158
+ }
159
+
160
+ // -- §11.2 receive -----------------------------------------------------
161
+
162
+ /**
163
+ * LXMF `message` event handler: filter by title, then apply the Delta
164
+ * (§11.2.4).
165
+ *
166
+ * Returns `true` iff a Dacar Delta was applied to the CRDT, `false`
167
+ * otherwise (wrong title, or a malformed/forged payload — which
168
+ * `DeltaReceiver.applyPayload()` swallows so a bad message can never crash
169
+ * the transport). Non-Dacar messages are passed through untouched.
170
+ * @param {{ title?: string | Uint8Array, content?: string | Uint8Array, _decodedPayload?: any[] } | null} message
171
+ * @returns {Promise<boolean>}
172
+ */
173
+ async handleMessage(message) {
174
+ if (messageTitle(message) !== LxmfDeltaDelivery.TITLE) return false;
175
+ if (!this._receiver) {
176
+ throw new Error("LxmfDeltaDelivery.handleMessage requires a receiver");
177
+ }
178
+ return this._receiver.applyPayload(messageContent(message));
179
+ }
180
+
181
+ // -- §11.3 Paper Messages (air-gapped / optical) ----------------------
182
+
183
+ /**
184
+ * Builds a §11.3 Paper Message (`lxm://` URI, QR-encodable) wrapping one
185
+ * Delta.
186
+ *
187
+ * Same wrapping as {@link makeMessage} but encrypted to the recipient via
188
+ * the outbound `lxmf.delivery` destination (which holds the recipient public
189
+ * key), so the returned URI carries no plaintext Delta. `sourceIdentity`
190
+ * signs the message; `outboundDestination` is the recipient's OUT
191
+ * `lxmf.delivery` destination.
192
+ * @param {Uint8Array} deltaPayload
193
+ * @param {Uint8Array} destinationHash The recipient `lxmf.delivery` hash.
194
+ * @param {Object} opts
195
+ * @param {import("@reticulum/core").Identity} opts.sourceIdentity
196
+ * @param {import("@reticulum/core").Destination} opts.outboundDestination
197
+ * @returns {Promise<string>} The `lxm://` paper URI.
198
+ */
199
+ async makePaperUri(deltaPayload, destinationHash, { sourceIdentity, outboundDestination }) {
200
+ const message = this.makeMessage(deltaPayload, destinationHash, sourceIdentity.identityHash);
201
+ return message.toPaperUri(sourceIdentity, outboundDestination);
202
+ }
203
+
204
+ /**
205
+ * Feeds a scanned Paper Message URI back through the bound router (§11.3).
206
+ *
207
+ * The router decrypts it (it must own the delivery Identity) and dispatches
208
+ * the recovered LXMF message as a `message` event, which
209
+ * {@link handleMessage} then filters and applies. Returns the router's
210
+ * ingest result (the reconstructed message, or `null` if it was not for this
211
+ * node / already ingested).
212
+ * @param {string} uri
213
+ * @returns {Promise<import("@reticulum/core").LXMessage | null>}
214
+ */
215
+ async ingestPaperUri(uri) {
216
+ if (!this._router) {
217
+ throw new Error("LxmfDeltaDelivery.ingestPaperUri requires a router");
218
+ }
219
+ return this._router.ingestUri(uri);
220
+ }
221
+ }