@reticulum/dacar 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +215 -0
- package/package.json +47 -0
- package/src/challenge.js +394 -0
- package/src/config.js +154 -0
- package/src/crdt.js +318 -0
- package/src/delta.js +108 -0
- package/src/engine.js +198 -0
- package/src/hlc.js +104 -0
- package/src/index.js +88 -0
- package/src/namespace.js +183 -0
- package/src/naming.js +39 -0
- package/src/operation.js +256 -0
- package/src/threshold.js +110 -0
- package/src/transport/index.js +37 -0
- package/src/transport/lxmfSync.js +221 -0
- package/src/transport/rfedSync.js +228 -0
- package/src/transport/rnsChallenge.js +243 -0
- package/src/transport/rnsIdentity.js +84 -0
- package/src/tuple.js +120 -0
- package/src/verifier.js +183 -0
package/src/operation.js
ADDED
|
@@ -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
|
+
|
package/src/threshold.js
ADDED
|
@@ -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
|
+
}
|