@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/config.js
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Node configuration: trust anchors, salts, and thresholds (§4, §10).
|
|
3
|
+
*
|
|
4
|
+
* Every Dacar node is bootstrapped out-of-band with one or more Root Trust
|
|
5
|
+
* Anchors (single identities or Threshold Groups), a Privacy Salt (plus up to
|
|
6
|
+
* two Legacy Salts for rotation, §10), and optionally an Authoritative Identity
|
|
7
|
+
* for Strict Consistency (§8).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { toHex } from "@reticulum/core";
|
|
11
|
+
import {
|
|
12
|
+
DEFAULT_SALT,
|
|
13
|
+
HASH_SIZE,
|
|
14
|
+
MAX_LEGACY_SALTS,
|
|
15
|
+
SALT_SIZE,
|
|
16
|
+
NamespaceHasher,
|
|
17
|
+
bytesEqual,
|
|
18
|
+
} from "./namespace.js";
|
|
19
|
+
|
|
20
|
+
/** Default deletion horizon H (days), see §9. */
|
|
21
|
+
export const DEFAULT_DELETION_HORIZON_DAYS = 180;
|
|
22
|
+
const MS_PER_DAY = 24 * 60 * 60 * 1000;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Guard ensuring the fail-open null-salt notice is logged at most once per
|
|
26
|
+
* process, so test suites and tooling that build many Configs are not flooded
|
|
27
|
+
* while still flagging the footgun at first startup.
|
|
28
|
+
*/
|
|
29
|
+
let __nullSaltWarned = false;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* @typedef {Object} ConfigInit
|
|
33
|
+
* @property {Iterable<Uint8Array>} rootTrustAnchors One or more 16-byte hashes.
|
|
34
|
+
* @property {Uint8Array} [primarySalt] 32-byte Primary Privacy Salt.
|
|
35
|
+
* @property {Uint8Array[]} [legacySalts] Ordered Legacy Salts (≤ MAX_LEGACY_SALTS).
|
|
36
|
+
* @property {import("./threshold.js").ThresholdGroup[]} [thresholdGroups]
|
|
37
|
+
* @property {Uint8Array} [authoritativeIdentity] One identity for §8, or omit.
|
|
38
|
+
* @property {number} [deletionHorizonDays] Deletion horizon H (§9).
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
export class Config {
|
|
42
|
+
/** @param {ConfigInit} init */
|
|
43
|
+
constructor({
|
|
44
|
+
rootTrustAnchors,
|
|
45
|
+
primarySalt = DEFAULT_SALT,
|
|
46
|
+
legacySalts = [],
|
|
47
|
+
thresholdGroups = [],
|
|
48
|
+
authoritativeIdentity,
|
|
49
|
+
deletionHorizonDays = DEFAULT_DELETION_HORIZON_DAYS,
|
|
50
|
+
}) {
|
|
51
|
+
const anchors = new Set();
|
|
52
|
+
for (const anchor of rootTrustAnchors) {
|
|
53
|
+
if (!(anchor instanceof Uint8Array) || anchor.length !== HASH_SIZE) {
|
|
54
|
+
throw new TypeError(`trust anchor must be ${HASH_SIZE} bytes`);
|
|
55
|
+
}
|
|
56
|
+
anchors.add(toHex(anchor));
|
|
57
|
+
}
|
|
58
|
+
if (anchors.size === 0) {
|
|
59
|
+
throw new Error("at least one Root Trust Anchor is required (§4.1)");
|
|
60
|
+
}
|
|
61
|
+
/** @type {Set<string>} hex of each Root Trust Anchor. */
|
|
62
|
+
this.rootTrustAnchors = anchors;
|
|
63
|
+
|
|
64
|
+
if (!(primarySalt instanceof Uint8Array) || primarySalt.length !== SALT_SIZE) {
|
|
65
|
+
throw new TypeError(`primarySalt must be ${SALT_SIZE} bytes`);
|
|
66
|
+
}
|
|
67
|
+
/** @type {Uint8Array} */
|
|
68
|
+
this.primarySalt = primarySalt;
|
|
69
|
+
|
|
70
|
+
if (legacySalts.length > MAX_LEGACY_SALTS) {
|
|
71
|
+
throw new Error(
|
|
72
|
+
`at most ${MAX_LEGACY_SALTS} Legacy Salts are allowed (§10.2), got ${legacySalts.length}`,
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
/** @type {Uint8Array[]} */
|
|
76
|
+
this.legacySalts = legacySalts.map((s) => {
|
|
77
|
+
if (!(s instanceof Uint8Array) || s.length !== SALT_SIZE) {
|
|
78
|
+
throw new TypeError(`each legacy salt must be ${SALT_SIZE} bytes`);
|
|
79
|
+
}
|
|
80
|
+
return s;
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
/** @type {import("./threshold.js").ThresholdGroup[]} */
|
|
84
|
+
this.thresholdGroups = [...thresholdGroups];
|
|
85
|
+
|
|
86
|
+
if (authoritativeIdentity !== undefined) {
|
|
87
|
+
if (!(authoritativeIdentity instanceof Uint8Array) || authoritativeIdentity.length !== HASH_SIZE) {
|
|
88
|
+
throw new TypeError(`authoritativeIdentity must be ${HASH_SIZE} bytes`);
|
|
89
|
+
}
|
|
90
|
+
this.authoritativeIdentity = authoritativeIdentity;
|
|
91
|
+
} else {
|
|
92
|
+
this.authoritativeIdentity = undefined;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (!(Number.isInteger(deletionHorizonDays) && deletionHorizonDays >= 1)) {
|
|
96
|
+
throw new Error("deletionHorizonDays must be >= 1");
|
|
97
|
+
}
|
|
98
|
+
/** @type {number} */
|
|
99
|
+
this.deletionHorizonDays = deletionHorizonDays;
|
|
100
|
+
|
|
101
|
+
// §3.3 fail-open guard: only warn once the Config is fully valid, so a
|
|
102
|
+
// misconfiguration that already throws is not also noisily warned about.
|
|
103
|
+
if (_isDefaultSalt(primarySalt) && !__nullSaltWarned) {
|
|
104
|
+
__nullSaltWarned = true;
|
|
105
|
+
console.warn(
|
|
106
|
+
"Config started with the default null Privacy Salt: label hashes are " +
|
|
107
|
+
"fail-open (trivially dictionary-attackable, §3.3). Set a strong " +
|
|
108
|
+
"random primarySalt for any real deployment.",
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Primary hasher, then Legacy hashers in order (§10.2). @returns {NamespaceHasher[]} */
|
|
114
|
+
get hashers() {
|
|
115
|
+
return [new NamespaceHasher(this.primarySalt), ...this.legacySalts.map((s) => new NamespaceHasher(s))];
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** @returns {NamespaceHasher} */
|
|
119
|
+
get primaryHasher() {
|
|
120
|
+
return new NamespaceHasher(this.primarySalt);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** @param {Uint8Array} identityHash @returns {boolean} */
|
|
124
|
+
isRootAnchor(identityHash) {
|
|
125
|
+
return this.rootTrustAnchors.has(toHex(identityHash));
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* The Threshold Group with the given Group ID, or undefined (§4.1). Async
|
|
130
|
+
* because Group IDs are SHA-256 hashes (Web Crypto).
|
|
131
|
+
* @param {Uint8Array} groupIdBytes
|
|
132
|
+
* @returns {Promise<import("./threshold.js").ThresholdGroup | undefined>}
|
|
133
|
+
*/
|
|
134
|
+
async groupFor(groupIdBytes) {
|
|
135
|
+
const target = toHex(groupIdBytes);
|
|
136
|
+
for (const group of this.thresholdGroups) {
|
|
137
|
+
if (toHex(await group.groupId()) === target) return group;
|
|
138
|
+
}
|
|
139
|
+
return undefined;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Deletion horizon in milliseconds. @returns {number} */
|
|
143
|
+
get deletionHorizonMs() {
|
|
144
|
+
return this.deletionHorizonDays * MS_PER_DAY;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* @param {Uint8Array} salt
|
|
150
|
+
* @returns {boolean} `true` iff `salt` is the all-zero fail-open default.
|
|
151
|
+
*/
|
|
152
|
+
function _isDefaultSalt(salt) {
|
|
153
|
+
return bytesEqual(salt, DEFAULT_SALT);
|
|
154
|
+
}
|
package/src/crdt.js
ADDED
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The authorization state: an LWW-Element-Set CRDT (§6).
|
|
3
|
+
*
|
|
4
|
+
* The global state maps a Tuple identity to an HLC timestamp, split into an Add
|
|
5
|
+
* set and a Remove set. A Tuple is active iff its Add timestamp is strictly
|
|
6
|
+
* greater than its Remove timestamp; ties resolve to removed (Remove wins).
|
|
7
|
+
*
|
|
8
|
+
* Storage is bounded by **Time-Horizon Tombstone Pruning** (§9): once a tuple
|
|
9
|
+
* resolves inactive *and* both its Add and Remove timestamps are older than the
|
|
10
|
+
* deletion horizon, both entries are silently deleted. Incoming Operations
|
|
11
|
+
* older than the horizon are rejected outright (intake rejection, §9).
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { MsgPack } from "@reticulum/core";
|
|
15
|
+
import { Action } from "./operation.js";
|
|
16
|
+
import { MAX_HLC, physicalNowMs, unpackHlc } from "./hlc.js";
|
|
17
|
+
import { Tuple } from "./tuple.js";
|
|
18
|
+
import { verifyOperation } from "./verifier.js";
|
|
19
|
+
|
|
20
|
+
const MS_PER_DAY = 24 * 60 * 60 * 1000;
|
|
21
|
+
/** Operations more than this far in the future are rejected (§12). */
|
|
22
|
+
const DEFAULT_MAX_FUTURE_MS = MS_PER_DAY;
|
|
23
|
+
/** Default deletion horizon H for Time-Horizon Tombstone Pruning (§9). */
|
|
24
|
+
export const DEFAULT_DELETION_HORIZON_DAYS = 180;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* @typedef {Object} Entry
|
|
28
|
+
* @property {Tuple} tuple
|
|
29
|
+
* @property {bigint | null} addTs
|
|
30
|
+
* @property {bigint | null} removeTs
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
/** @param {bigint | null} existing @param {bigint} incoming @returns {bigint} */
|
|
34
|
+
function maxTs(existing, incoming) {
|
|
35
|
+
return existing === null ? incoming : existing > incoming ? existing : incoming;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** @param {bigint | null} a @param {bigint | null} b @returns {bigint | null} */
|
|
39
|
+
function maxBoth(a, b) {
|
|
40
|
+
if (a === null) return b;
|
|
41
|
+
if (b === null) return a;
|
|
42
|
+
return a > b ? a : b;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Guard ensuring the trusted-local-only notice for `StateVector.fromPayload()`
|
|
47
|
+
* is logged at most once per process, so legitimate snapshot/restore does not
|
|
48
|
+
* flood logs while still flagging the footgun the first time.
|
|
49
|
+
*/
|
|
50
|
+
let __trustedLocalWarned = false;
|
|
51
|
+
|
|
52
|
+
/** @param {number | bigint | null} value @returns {bigint | null} */
|
|
53
|
+
function normalizeTs(value) {
|
|
54
|
+
return value === null ? null : BigInt(value);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export class StateVector {
|
|
58
|
+
/**
|
|
59
|
+
* @param {Object} [opts]
|
|
60
|
+
* @param {number} [opts.deletionHorizonDays] Deletion horizon H (§9).
|
|
61
|
+
*/
|
|
62
|
+
constructor({ deletionHorizonDays = DEFAULT_DELETION_HORIZON_DAYS } = {}) {
|
|
63
|
+
if (!(Number.isInteger(deletionHorizonDays) && deletionHorizonDays >= 1)) {
|
|
64
|
+
throw new Error("deletionHorizonDays must be >= 1");
|
|
65
|
+
}
|
|
66
|
+
/** @type {Map<string, Entry>} */
|
|
67
|
+
this._entries = new Map();
|
|
68
|
+
this.deletionHorizonDays = deletionHorizonDays;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Deletion horizon in milliseconds. @returns {number} */
|
|
72
|
+
get deletionHorizonMs() {
|
|
73
|
+
return this.deletionHorizonDays * MS_PER_DAY;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Number of distinct tuples known (active or revoked). @returns {number} */
|
|
77
|
+
get size() {
|
|
78
|
+
return this._entries.size;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** @param {string} key @returns {boolean} */
|
|
82
|
+
has(key) {
|
|
83
|
+
return this._entries.has(key);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** @param {string} key @returns {Entry | undefined} */
|
|
87
|
+
get(key) {
|
|
88
|
+
return this._entries.get(key);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Authenticate then apply a network-received Delta (§11.2.4, §5.2).
|
|
93
|
+
*
|
|
94
|
+
* This is the secure entry point for Operations received over any transport
|
|
95
|
+
* (RFed, LXMF, optical sneakernet). The Operation's Ed25519 signature(s)
|
|
96
|
+
* MUST verify against the public key(s) resolved for its claimed Issuer
|
|
97
|
+
* before the pure CRDT update (`apply()`) is allowed to mutate state. Any
|
|
98
|
+
* authentication failure — unknown Issuer, bad signature, wrong threshold —
|
|
99
|
+
* drops the Operation (returns `false`).
|
|
100
|
+
*
|
|
101
|
+
* Returns `true` iff authenticated *and* applied. Distinct from `apply()`,
|
|
102
|
+
* which trusts its caller and performs no cryptography.
|
|
103
|
+
* @param {import("./operation.js").Operation} operation
|
|
104
|
+
* @param {import("./verifier.js").KeyResolver | import("./verifier.js").Keyring} keyResolver
|
|
105
|
+
* @param {Object} [options]
|
|
106
|
+
* @param {number} [options.nowMs]
|
|
107
|
+
* @param {number | null} [options.maxFutureMs]
|
|
108
|
+
* @returns {Promise<boolean>}
|
|
109
|
+
*/
|
|
110
|
+
async ingest(operation, keyResolver, options = {}) {
|
|
111
|
+
if (!(await verifyOperation(operation, keyResolver))) return false;
|
|
112
|
+
return this.apply(operation, options);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Apply one Operation (Delta) to the appropriate set (§6.1, §9, §12).
|
|
117
|
+
* @param {import("./operation.js").Operation} operation
|
|
118
|
+
* @param {Object} [options]
|
|
119
|
+
* @param {number} [options.nowMs] Override the wall clock for testing.
|
|
120
|
+
* @param {number | null} [options.maxFutureMs] Max clock skew; null disables.
|
|
121
|
+
* @returns {boolean} true if applied, false if rejected.
|
|
122
|
+
*/
|
|
123
|
+
apply(operation, { nowMs, maxFutureMs = DEFAULT_MAX_FUTURE_MS } = {}) {
|
|
124
|
+
const { physicalMs } = unpackHlc(operation.hlc);
|
|
125
|
+
const now = nowMs ?? physicalNowMs();
|
|
126
|
+
if (maxFutureMs !== null && physicalMs > now + maxFutureMs) return false; // §12
|
|
127
|
+
if (physicalMs < now - this.deletionHorizonMs) return false; // §9 intake rejection
|
|
128
|
+
const key = operation.tuple.key;
|
|
129
|
+
let entry = this._entries.get(key);
|
|
130
|
+
if (!entry) {
|
|
131
|
+
entry = { tuple: operation.tuple, addTs: null, removeTs: null };
|
|
132
|
+
this._entries.set(key, entry);
|
|
133
|
+
}
|
|
134
|
+
if (operation.action === Action.GRANT) {
|
|
135
|
+
entry.addTs = maxTs(entry.addTs, operation.hlc);
|
|
136
|
+
} else {
|
|
137
|
+
entry.removeTs = maxTs(entry.removeTs, operation.hlc);
|
|
138
|
+
}
|
|
139
|
+
return true;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Merge another StateVector by taking the max HLC per set per tuple (§6.1).
|
|
144
|
+
*
|
|
145
|
+
* > **Warning: Trusted-local-only — never feed network bytes.**
|
|
146
|
+
* > `merge()` trusts its argument completely and performs **no** signature
|
|
147
|
+
* > verification, so it can inject or alter authorization state (including
|
|
148
|
+
* > Root Trust Anchor grants) for any tuple. It also skips the §9
|
|
149
|
+
* > stale-horizon and §12 future-skew intake checks that `apply()`/`ingest()`
|
|
150
|
+
* > enforce per-delta, so even a trusted source can silently reintroduce
|
|
151
|
+
* > operations per-delta ingestion would have rejected.
|
|
152
|
+
* >
|
|
153
|
+
* > Legitimate uses are confined to a node's own trusted state: CRDT unit
|
|
154
|
+
* > testing and restoring a snapshot previously produced by `toPayload()` on
|
|
155
|
+
* > the *same* node. For network convergence use `DeltaReceiver.applyPayloads()`
|
|
156
|
+
* > (a batch of signed Deltas) instead.
|
|
157
|
+
* @param {StateVector} other
|
|
158
|
+
*/
|
|
159
|
+
merge(other) {
|
|
160
|
+
for (const [key, otherEntry] of other._entries) {
|
|
161
|
+
let entry = this._entries.get(key);
|
|
162
|
+
if (!entry) {
|
|
163
|
+
entry = { tuple: otherEntry.tuple, addTs: null, removeTs: null };
|
|
164
|
+
this._entries.set(key, entry);
|
|
165
|
+
}
|
|
166
|
+
entry.addTs = maxBoth(entry.addTs, otherEntry.addTs);
|
|
167
|
+
entry.removeTs = maxBoth(entry.removeTs, otherEntry.removeTs);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Run Time-Horizon Tombstone Pruning (§9). Deletes both the Add and Remove
|
|
173
|
+
* entries for any tuple that resolves inactive *and* whose Add and Remove
|
|
174
|
+
* timestamps are both older than the horizon. Returns the count pruned.
|
|
175
|
+
* @param {Object} [opts]
|
|
176
|
+
* @param {number} [opts.nowMs]
|
|
177
|
+
* @returns {number}
|
|
178
|
+
*/
|
|
179
|
+
prune({ nowMs } = {}) {
|
|
180
|
+
const now = nowMs ?? physicalNowMs();
|
|
181
|
+
const cutoff = now - this.deletionHorizonMs;
|
|
182
|
+
let pruned = 0;
|
|
183
|
+
for (const [key, entry] of this._entries) {
|
|
184
|
+
if (this._isActiveEntry(entry)) continue;
|
|
185
|
+
const { addTs, removeTs } = entry;
|
|
186
|
+
if (addTs === null || removeTs === null) continue;
|
|
187
|
+
if (unpackHlc(addTs).physicalMs < cutoff && unpackHlc(removeTs).physicalMs < cutoff) {
|
|
188
|
+
this._entries.delete(key);
|
|
189
|
+
pruned += 1;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
return pruned;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** @param {string} key @returns {boolean} */
|
|
196
|
+
isActive(key) {
|
|
197
|
+
const entry = this._entries.get(key);
|
|
198
|
+
return entry !== undefined && this._isActiveEntry(entry);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** @param {Entry} entry @returns {boolean} */
|
|
202
|
+
_isActiveEntry(entry) {
|
|
203
|
+
return (
|
|
204
|
+
entry.addTs !== null &&
|
|
205
|
+
(entry.removeTs === null || entry.addTs > entry.removeTs)
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Generator yielding every currently active Tuple. @returns {Generator<Tuple>} */
|
|
210
|
+
*activeTuples() {
|
|
211
|
+
for (const entry of this._entries.values()) {
|
|
212
|
+
if (this._isActiveEntry(entry)) yield entry.tuple;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Serialize the full state vector as a MessagePack array of entries. Each
|
|
218
|
+
* entry is `[relationHash(16), [objectHashes], wildcard_bool, grantee(16),
|
|
219
|
+
* issuer(16), addTs | null, removeTs | null]`.
|
|
220
|
+
*
|
|
221
|
+
* > **Warning: Trusted-local-only.** The payload is an unauthenticated dump
|
|
222
|
+
* > of this node's CRDT and carries **no** Ed25519 signature material; it
|
|
223
|
+
* > exists for a node to snapshot *its own* state (e.g. a local backup or
|
|
224
|
+
* > CRDT unit test). It MUST NOT be accepted from the network — deserialize
|
|
225
|
+
* > such bytes only via your own trusted store, and if it ever crosses a
|
|
226
|
+
* > trust boundary use `DeltaReceiver.applyPayloads()` (a batch of signed
|
|
227
|
+
* > §5.3 Operations) instead.
|
|
228
|
+
* @returns {Uint8Array}
|
|
229
|
+
*/
|
|
230
|
+
toPayload() {
|
|
231
|
+
const rows = [];
|
|
232
|
+
for (const entry of this._entries.values()) {
|
|
233
|
+
rows.push([
|
|
234
|
+
entry.tuple.relationHash,
|
|
235
|
+
[...entry.tuple.objectHashes],
|
|
236
|
+
entry.tuple.wildcard,
|
|
237
|
+
entry.tuple.grantee,
|
|
238
|
+
entry.tuple.issuer,
|
|
239
|
+
entry.addTs,
|
|
240
|
+
entry.removeTs,
|
|
241
|
+
]);
|
|
242
|
+
}
|
|
243
|
+
return MsgPack.encode(rows);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Deserialize a state vector produced by `toPayload()`.
|
|
248
|
+
*
|
|
249
|
+
* > **Warning: Trusted-local-only — never feed network bytes.** The payload
|
|
250
|
+
* > carries **no** signature material, so deserializing attacker bytes and
|
|
251
|
+
* > then `merge()`-ing it lets a peer forge arbitrary authorization state
|
|
252
|
+
* > (including Root Trust Anchor grants) and silently bypass the §9
|
|
253
|
+
* > stale-horizon and §12 future-skew intake checks. Only deserialize bytes
|
|
254
|
+
* > from your own trusted store (a snapshot you previously produced with
|
|
255
|
+
* > `toPayload()` on this node). For network convergence use
|
|
256
|
+
* > `DeltaReceiver.applyPayloads()` (a batch of signed §5.3 Operations)
|
|
257
|
+
* > instead.
|
|
258
|
+
* >
|
|
259
|
+
* > A one-time `console.warn` is emitted to make this contract audible.
|
|
260
|
+
* @param {Uint8Array} data
|
|
261
|
+
* @param {Object} [opts]
|
|
262
|
+
* @param {number} [opts.deletionHorizonDays]
|
|
263
|
+
* @returns {StateVector}
|
|
264
|
+
*/
|
|
265
|
+
static fromPayload(data, { deletionHorizonDays = DEFAULT_DELETION_HORIZON_DAYS } = {}) {
|
|
266
|
+
if (!__trustedLocalWarned) {
|
|
267
|
+
__trustedLocalWarned = true;
|
|
268
|
+
console.warn(
|
|
269
|
+
"StateVector.fromPayload() is trusted-local-only: it performs no " +
|
|
270
|
+
"signature verification and must not be fed network bytes. For " +
|
|
271
|
+
"network convergence use DeltaReceiver.applyPayloads() instead.",
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
const rows = MsgPack.decode(data);
|
|
275
|
+
if (!Array.isArray(rows)) {
|
|
276
|
+
throw new Error("state vector payload must be a MessagePack array");
|
|
277
|
+
}
|
|
278
|
+
const state = new StateVector({ deletionHorizonDays });
|
|
279
|
+
for (const row of rows) {
|
|
280
|
+
if (!Array.isArray(row) || row.length !== 7) {
|
|
281
|
+
throw new Error("each state entry must be a 7-element array");
|
|
282
|
+
}
|
|
283
|
+
const [relationHash, objectHashes, wildcard, grantee, issuer, addTs, removeTs] = row;
|
|
284
|
+
const tuple = new Tuple({
|
|
285
|
+
relationHash: expectBytes(relationHash, 16, "relation_hash"),
|
|
286
|
+
objectHashes: objectHashes.map((h) => expectBytes(h, 16, "object_hash")),
|
|
287
|
+
wildcard: expectBool(wildcard, "wildcard"),
|
|
288
|
+
grantee: expectBytes(grantee, 16, "grantee"),
|
|
289
|
+
issuer: expectBytes(issuer, 16, "issuer"),
|
|
290
|
+
});
|
|
291
|
+
state._entries.set(tuple.key, {
|
|
292
|
+
tuple,
|
|
293
|
+
addTs: normalizeTs(addTs),
|
|
294
|
+
removeTs: normalizeTs(removeTs),
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
return state;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* @param {unknown} value
|
|
303
|
+
* @param {number} len
|
|
304
|
+
* @param {string} name
|
|
305
|
+
* @returns {Uint8Array}
|
|
306
|
+
*/
|
|
307
|
+
function expectBytes(value, len, name) {
|
|
308
|
+
if (!(value instanceof Uint8Array) || value.length !== len) {
|
|
309
|
+
throw new Error(`${name} must be a ${len}-byte Uint8Array`);
|
|
310
|
+
}
|
|
311
|
+
return value;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/** @param {unknown} value @param {string} name @returns {boolean} */
|
|
315
|
+
function expectBool(value, name) {
|
|
316
|
+
if (typeof value !== "boolean") throw new Error(`${name} must be a boolean`);
|
|
317
|
+
return value;
|
|
318
|
+
}
|
package/src/delta.js
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Transport-agnostic Delta receive boundary (spec §11.2.4).
|
|
3
|
+
*
|
|
4
|
+
* Every transport — RFed (§11.1), LXMF store-and-forward (§11.2), and optical
|
|
5
|
+
* Paper Messages (§11.3) — funnels incoming bytes through one identical path:
|
|
6
|
+
* decode the §5.3 Operation payload, authenticate it via verify-on-ingest
|
|
7
|
+
* (§5.2 / §11.2.4), and merge it into the CRDT. `DeltaReceiver` is that shared
|
|
8
|
+
* boundary. Malformed or unauthenticated Deltas are dropped silently rather
|
|
9
|
+
* than propagated into state or crashing a transport callback.
|
|
10
|
+
*
|
|
11
|
+
* This keeps the (optional) transport adapters thin: an adapter only has to
|
|
12
|
+
* hand received bytes to `DeltaReceiver.applyPayload()`, regardless of whether
|
|
13
|
+
* they arrived over RFed, LXMF, or a scanned QR code.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { MsgPack } from "@reticulum/core";
|
|
17
|
+
import { Operation } from "./operation.js";
|
|
18
|
+
|
|
19
|
+
export class DeltaReceiver {
|
|
20
|
+
/**
|
|
21
|
+
* Decode -> verify -> apply incoming Delta payloads (§11.2.4).
|
|
22
|
+
* @param {import("./crdt.js").StateVector} state
|
|
23
|
+
* @param {import("./verifier.js").KeyResolver | import("./verifier.js").Keyring} keyResolver
|
|
24
|
+
*/
|
|
25
|
+
constructor(state, keyResolver) {
|
|
26
|
+
this._state = state;
|
|
27
|
+
this._resolver = keyResolver;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Apply one wire-format Delta.
|
|
32
|
+
*
|
|
33
|
+
* Returns `true` iff the payload decoded, authenticated, and was applied to
|
|
34
|
+
* the CRDT. Malformed payloads are swallowed (return `false`) — a transport
|
|
35
|
+
* callback must never crash on arbitrary bytes. Signature and CRDT-level
|
|
36
|
+
* rejection (unknown Issuer, bad sig, stale/future) is delegated to
|
|
37
|
+
* `StateVector.ingest()`.
|
|
38
|
+
* @param {Uint8Array} payload
|
|
39
|
+
* @param {Object} [options]
|
|
40
|
+
* @param {number} [options.nowMs]
|
|
41
|
+
* @param {number | null} [options.maxFutureMs]
|
|
42
|
+
* @returns {Promise<boolean>}
|
|
43
|
+
*/
|
|
44
|
+
async applyPayload(payload, options = {}) {
|
|
45
|
+
let operation;
|
|
46
|
+
try {
|
|
47
|
+
operation = Operation.fromPayload(payload);
|
|
48
|
+
} catch {
|
|
49
|
+
return false; // malformed -> drop silently
|
|
50
|
+
}
|
|
51
|
+
return this._state.ingest(operation, this._resolver, options);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Authenticate and apply a *batch* of Deltas (§11.1, §11.2.4).
|
|
56
|
+
*
|
|
57
|
+
* The secure alternative to `StateVector.merge()` for network sync.
|
|
58
|
+
* `payload` is a MessagePack array of §5.3 Operation payloads
|
|
59
|
+
* (`MsgPack.encode([opA.toPayload(), opB.toPayload(), ...])`); each element
|
|
60
|
+
* is decoded and run through `applyPayload()`, i.e. it is independently
|
|
61
|
+
* Ed25519/threshold-authenticated before it may touch state. A single
|
|
62
|
+
* forged, stale (§9), or future-skewed (§12) element is dropped without
|
|
63
|
+
* affecting the rest of the batch.
|
|
64
|
+
*
|
|
65
|
+
* Returns the number of Deltas authenticated *and* applied. A malformed
|
|
66
|
+
* outer payload (not a MessagePack array, undecodable) yields `0` and is
|
|
67
|
+
* swallowed, so a transport callback can never crash on arbitrary bytes —
|
|
68
|
+
* exactly like `applyPayload()`.
|
|
69
|
+
*
|
|
70
|
+
* > **Warning:** This is the *only* safe entry point for full-state / bulk
|
|
71
|
+
* > convergence received over the network. `StateVector.merge()` /
|
|
72
|
+
* > `StateVector.fromPayload()` are trusted-local snapshot primitives that
|
|
73
|
+
* > perform **no** signature verification and **must not** be fed network
|
|
74
|
+
* > bytes.
|
|
75
|
+
*
|
|
76
|
+
* @param {Uint8Array} payload A MessagePack array of Operation payloads.
|
|
77
|
+
* @param {Object} [options]
|
|
78
|
+
* @param {number} [options.nowMs]
|
|
79
|
+
* @param {number | null} [options.maxFutureMs]
|
|
80
|
+
* @returns {Promise<number>}
|
|
81
|
+
*/
|
|
82
|
+
async applyPayloads(payload, options = {}) {
|
|
83
|
+
let items;
|
|
84
|
+
try {
|
|
85
|
+
items = MsgPack.decode(payload);
|
|
86
|
+
} catch {
|
|
87
|
+
return 0; // malformed outer payload -> drop silently
|
|
88
|
+
}
|
|
89
|
+
if (!Array.isArray(items)) return 0;
|
|
90
|
+
let applied = 0;
|
|
91
|
+
for (const item of items) {
|
|
92
|
+
if (!(item instanceof Uint8Array)) continue; // skip non-bin elements
|
|
93
|
+
if (await this.applyPayload(item, options)) applied += 1;
|
|
94
|
+
}
|
|
95
|
+
return applied;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Encode a list of §5.3 Operation payloads as a batch (§11.1). Inverse of
|
|
100
|
+
* `applyPayloads()`: `MsgPack.encode([...])` of already-signed Operation
|
|
101
|
+
* payload byte-strings, suitable for publishing as one bulk sync message.
|
|
102
|
+
* @param {Uint8Array[]} operationPayloads
|
|
103
|
+
* @returns {Uint8Array}
|
|
104
|
+
*/
|
|
105
|
+
static packPayloads(operationPayloads) {
|
|
106
|
+
return MsgPack.encode(operationPayloads.map((p) => new Uint8Array(p)));
|
|
107
|
+
}
|
|
108
|
+
}
|