@blamejs/core 0.13.2 → 0.13.4

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/CHANGELOG.md CHANGED
@@ -8,6 +8,10 @@ upgrading across more than a few patches at a time.
8
8
 
9
9
  ## v0.13.x
10
10
 
11
+ - v0.13.4 (2026-05-26) — **`b.crdt` — conflict-free replicated data types.** b.crdt adds state-based Conflict-free Replicated Data Types: data structures that independent replicas update without coordination and still converge to the same value once they have exchanged state. Each type's merge is a join over a semilattice — commutative, associative, and idempotent — so replicas can merge in any order, any number of times, and agree, which makes these the substrate for active/active cluster state, offline-first clients that reconcile on reconnect, and eventually-consistent counters, sets, and maps. The release ships the full state-based family: grow-only and positive-negative counters (gCounter / pnCounter), grow-only, two-phase, and observed-remove sets (gSet / twoPSet / orSet), a last-write-wins register (lwwRegister), and an observed-remove map (orMap). Every type exposes the same contract — local mutators, merge(other) that returns a converged instance without mutating either operand, value() for the materialized value, and state() / fromState() for a JSON-serializable form to snapshot via b.archive or b.backup or ship to a peer — and carries a replicaId so per-replica contributions stay distinct. **Added:** *`b.crdt` — state-based CvRDT counters, sets, register, and map* — `b.crdt.gCounter` / `pnCounter` (grow-only and increment/decrement counters), `b.crdt.gSet` / `twoPSet` / `orSet` (grow-only, two-phase, and observed-remove sets — `orSet` supports re-add and resolves a concurrent add-vs-remove as add-wins), `b.crdt.lwwRegister` (last-write-wins with a deterministic replicaId tie-break), and `b.crdt.orMap` (observed-remove keys with last-write-wins values). Each exposes `merge` / `value` / `state` / `fromState` and converges by the CvRDT laws. `orSet` and `orMap` accept `tombstoneRetention` to bound tombstone memory against a remove flood.
12
+
13
+ - v0.13.3 (2026-05-26) — **`b.crypto.xwing` — X-Wing hybrid post-quantum KEM.** b.crypto.xwing adds the X-Wing hybrid key-encapsulation mechanism (draft-connolly-cfrg-xwing-kem): it runs ML-KEM-768 and X25519 side by side and binds their shared secrets with SHA3-256, so an encapsulated key stays secure as long as either ML-KEM-768 or X25519 holds. That is the conservative shape for moving off classical ECDH today — a harvest-now-decrypt-later attacker must break the lattice KEM, and a hypothetical ML-KEM break still leaves X25519 standing. keygen() produces a 32-byte decapsulation seed and a 1216-byte public key; encapsulate(publicKey) returns a 1120-byte ciphertext and a 32-byte shared secret; decapsulate(secretKey, ciphertext) recovers it. The X-Wing combiner is frozen, but its specification is still an IETF Internet-Draft, so this primitive is marked experimental and sits beside the existing pre-RFC post-quantum HPKE drafts; it composes the framework's vendored ML-KEM-768 and X25519 with SHA3 and adds no new cryptographic core. The combiner is known-answer-tested byte-for-byte against the draft's definition. **Added:** *`b.crypto.xwing` — X-Wing hybrid PQ/T KEM (experimental)* — `keygen(seed?)` → `{ publicKey (1216 B), secretKey (32-byte seed) }`; `encapsulate(publicKey, eseed?)` → `{ ciphertext (1120 B), sharedSecret (32 B) }`; `decapsulate(secretKey, ciphertext)` → the 32-byte shared secret. Both `keygen` and `encapsulate` accept an optional seed for deterministic operation. The combiner — `SHA3-256(ssMLKEM ‖ ssX25519 ‖ ctX25519 ‖ pkX25519 ‖ label)` — is exposed as `combiner` for advanced use. Marked `experimental` while draft-connolly-cfrg-xwing-kem remains an Internet-Draft; the algorithm itself is frozen.
14
+
11
15
  - v0.13.2 (2026-05-26) — **`b.iabTcf.encode` — write TCF consent strings, and a TC-string timestamp fix.** b.iabTcf gains the encode half of its consent-string codec: b.iabTcf.encode(obj) serialises a parsed object back into an IAB TCF v2 TC string, and b.iabTcf.isValid(tcString) is a total never-throwing validity check. Vendor and purpose collections may be Sets, id arrays, or the parsed sections parseString returns; vendor sections are written with whichever of the bit-field and range forms is smaller, matching the reference CMP encoders, so a parsed string round-trips to an equivalent signal. parseString now fully decodes the Core publisher-restrictions list and the PublisherTC segment's publisher and custom purposes, where it previously reported only the segment's presence. The encoder is verified against the worked-example string in the IAB Tech Lab consent-string specification: it re-encodes that string's Core segment byte-for-byte. This release also fixes a TC-string parsing bug — the bit reader accumulated values with a 32-bit shift, so the 36-bit Created and LastUpdated timestamp fields were silently truncated for any real date; they now decode and round-trip exactly. **Added:** *`b.iabTcf.encode` / `b.iabTcf.isValid`* — `encode(obj)` serialises a TCF object (the shape `parseString` returns) into a TC string — Core plus optional DisclosedVendors, AllowedVendors, and PublisherTC segments — choosing the smaller of the bit-field and range vendor encodings. `isValid(tcString)` returns whether a string parses as a well-formed Core segment without throwing. `parseString` now fully decodes Core publisher restrictions and the PublisherTC purposes that were previously reported only as present. **Fixed:** *TC-string 36-bit timestamps were truncated on parse* — `b.iabTcf.parseString` read multi-bit fields with a 32-bit left-shift accumulation. The 36-bit Created and LastUpdated fields hold deciseconds-since-epoch, which exceeds 2^31 for any date after 1976, so those timestamps were silently corrupted. The reader now accumulates without the 32-bit truncation; timestamps decode correctly and round-trip through `encode`.
12
16
 
13
17
  - v0.13.1 (2026-05-26) — **`b.worm` — write-once-read-many retention.** Store records that cannot be altered or deleted before a retention period elapses — the immutable-storage discipline regulators require (SEC 17a-4(f), CFTC 1.31, FINRA 4511). b.worm.create(opts) returns a WORM store that enforces, on every mutating call, that a record is not overwritten or deleted while it is within its retainUntil window or under a legal hold. Two modes mirror cloud Object-Lock: compliance (the default — no one, including the operator, can delete before expiry) and governance (a privileged caller may override with an audited reason). Retention can only be extended, never shortened; every record carries a SHA3-512 digest that get verifies, so tampering with the underlying bytes is detected on read; every allow/refuse decision is audited. Storage is pluggable via a synchronous store adapter, so the policy layer sits over a sealed DB table, a filesystem, or any non-S3 backend — the store-agnostic, application-level companion to b.objectStore's S3 Object Lock, with content-integrity verification that native Object Lock does not provide. **Added:** *`b.worm.create` — write-once-read-many retention* — Returns a store with `put` / `get` / `delete` / `extendRetention` / `placeLegalHold` / `releaseLegalHold` / `list`. `put` is write-once (an overwrite of a retained or held record is refused); `delete` is gated by the retention window, legal holds, and the mode (`compliance` refuses any early delete; `governance` allows a privileged override with a required, audited reason); `extendRetention` is extend-only; `get` verifies the stored SHA3-512 digest and throws `worm/tampered` on a mismatch. Storage is a pluggable synchronous adapter (`get` / `set` / `delete` / `has` / `keys`), defaulting to in-memory for tests. Use it for SEC 17a-4 / CFTC / FINRA immutable records on backends without native Object Lock; `b.objectStore` remains the path for S3 Object Lock.
package/README.md CHANGED
@@ -101,6 +101,7 @@ The framework bundles the surface a typical Node app reaches for. Every primitiv
101
101
  - **AAD-bound sealed columns** — AEAD tag tied to `(table, rowId, column, schemaVersion)`; copy-paste between rows or schema-version replay surfaces as refused decrypt (`b.vault.aad`)
102
102
  - **Signed webhooks + API encryption** — SLH-DSA-SHAKE-256f default; ML-DSA-65 opt-in; ECIES API encryption (`b.webhook`, `b.crypto`)
103
103
  - **HPKE / HTTP signatures** — RFC 9180 HPKE with ML-KEM-1024 + HKDF-SHA3-512 + ChaCha20-Poly1305 (`b.crypto.hpke`); RFC 9421 HTTP Message Signatures with derived components and ed25519 / ML-DSA-65 (`b.crypto.httpSig`); RFC 9530 Content-Digest / Repr-Digest body-integrity fields (SHA-256 / SHA-512, legacy algorithms refused — `b.contentDigest`) to sign the digest rather than the whole body
104
+ - **X-Wing hybrid KEM** — `b.crypto.xwing` (draft-connolly-cfrg-xwing-kem, experimental): ML-KEM-768 + X25519 bound by SHA3-256, secure if either component holds — the conservative key-encapsulation shape for migrating off classical ECDH. `keygen` / `encapsulate` / `decapsulate` with a 1216-byte public key, 1120-byte ciphertext, and 32-byte shared secret
104
105
  - **Link header** — RFC 8288 Web Linking codec (`b.linkHeader.parse` / `serialize`): parse and build `Link: <uri>; rel="next"` relations, the standard REST pagination mechanism; quote-aware (a comma inside a quoted parameter never splits the list)
105
106
  - **URI Templates** — RFC 6570 expansion (`b.uriTemplate.expand` / `compile`): full Level 4 — every operator, the `:N` prefix and `*` explode modifiers — turning `{/path}{?q*}` plus variables into a concrete URI; validated against the official uritemplate-test suite. The `{var}` syntax behind OpenAPI links and HAL `_links`
106
107
  - **JSON Type Definition** — RFC 8927 validation (`b.jtd.validate` / `isValid`): portable, cross-implementation schema validation (all eight forms — type / enum / elements / properties / values / discriminator / ref / empty), returning instancePath / schemaPath errors; validated against the official 316-case suite. Interop companion to the fluent `b.safeSchema` builder
@@ -231,6 +232,7 @@ The framework bundles the surface a typical Node app reaches for. Every primitiv
231
232
  ### Production
232
233
 
233
234
  - **Cluster + scheduling** — cluster leader election with fenced leases over Postgres/SQLite (`b.cluster`); cron + interval scheduler that runs exactly-once globally (`b.scheduler`)
235
+ - **CRDTs** — state-based conflict-free replicated data types (`b.crdt`): grow-only / PN counters, grow-only / two-phase / observed-remove sets, a last-write-wins register, and an observed-remove map; each `merge` is commutative / associative / idempotent so replicas converge with no coordination — the substrate for active/active and offline-first state, with `state()` / `fromState()` for snapshot via `b.archive` / `b.backup`
234
236
  - **Reliability** — retry with full-jitter backoff + circuit breaker (`b.retry`); graceful shutdown (`b.appShutdown`); NTP boot check (`b.ntpCheck`)
235
237
  - **Transactional integration** — outbox + dedupe-on-receive inbox; exactly-once semantics across Postgres / SQLite (`b.outbox`, `b.inbox`); Debezium-shape change-event envelope on the outbox (`b.outbox.create({ envelope: "debezium" })`)
236
238
  - **Backup + restore** — end-to-end-encrypted bundles with pre-flush fail-closed mode + ML-DSA-87 signed manifests + scheduled backup-restore drills (`b.backup`, `b.backup.scheduleTest`, `b.backupBundle.verifyManifestSignature`); restore with pulled-bundle footprint preflight (`b.restore`); disaster-recovery runbook generator (HIPAA / PCI-DSS / GDPR / SOC 2 / DORA postures) (`b.drRunbook`)
package/index.js CHANGED
@@ -58,6 +58,7 @@ var crypto = require("./lib/crypto");
58
58
  // the dedicated lib files; these are thin aliases.
59
59
  crypto.hpke = require("./lib/crypto-hpke");
60
60
  crypto.oprf = require("./lib/crypto-oprf");
61
+ crypto.xwing = require("./lib/crypto-xwing");
61
62
  // Both PQ-HPKE drafts behind one opt-in sub-namespace — see
62
63
  // lib/crypto-hpke-pq.js. Operators that need a draft-codepoint
63
64
  // shape reach for b.crypto.hpke.pq.connolly / .wg explicitly; the
@@ -373,6 +374,7 @@ var dualControl = require("./lib/dual-control");
373
374
  var retention = require("./lib/retention");
374
375
  var legalHold = require("./lib/legal-hold");
375
376
  var worm = require("./lib/worm");
377
+ var crdt = require("./lib/crdt");
376
378
  var network = require("./lib/network");
377
379
  var cloudEvents = require("./lib/cloud-events");
378
380
  var dsr = require("./lib/dsr");
@@ -712,6 +714,7 @@ module.exports = {
712
714
  retention: retention,
713
715
  legalHold: legalHold,
714
716
  worm: worm,
717
+ crdt: crdt,
715
718
  network: network,
716
719
  cloudEvents: cloudEvents,
717
720
  dsr: dsr,
package/lib/crdt.js ADDED
@@ -0,0 +1,453 @@
1
+ "use strict";
2
+ /**
3
+ * @module b.crdt
4
+ * @nav Data
5
+ * @title CRDTs
6
+ *
7
+ * @intro
8
+ * Conflict-free Replicated Data Types — data structures that several
9
+ * replicas can update independently, with no coordination, and still
10
+ * converge to the same value once they have all seen each other's state.
11
+ * These are the state-based CvRDTs: each type's <code>merge</code> is a
12
+ * join over a semilattice, so it is commutative, associative, and
13
+ * idempotent — replicas can merge in any order, any number of times, and
14
+ * land on the same result. That makes them the substrate for eventually-
15
+ * consistent state across an active/active cluster, offline-first clients
16
+ * that reconcile on reconnect, or any "last writer need not win, but
17
+ * everyone agrees" counter / set / register / map.
18
+ *
19
+ * Every type exposes the same contract: local mutators (e.g.
20
+ * <code>inc</code>, <code>add</code>, <code>set</code>),
21
+ * <code>merge(other)</code> which returns a new converged instance without
22
+ * mutating either operand, <code>value()</code> for the materialized value,
23
+ * and <code>state()</code> / <code>fromState()</code> for a JSON-
24
+ * serializable form to snapshot (via <code>b.archive</code> /
25
+ * <code>b.backup</code>) or ship to a peer. Each replica carries a
26
+ * <code>replicaId</code> so per-replica contributions stay distinct.
27
+ *
28
+ * This release covers the state-based family — grow-only and PN counters,
29
+ * grow-only / two-phase / observed-remove sets, a last-write-wins register,
30
+ * and an observed-remove map. Operation-based sequence CRDTs (RGA), delta-
31
+ * state mutators, and a live event-bus replicator are not included; the
32
+ * state-based types merge correctly without a causal channel, which is the
33
+ * whole point.
34
+ *
35
+ * @card
36
+ * Conflict-free Replicated Data Types (`b.crdt`) — state-based CvRDT
37
+ * counters, sets, a last-write-wins register, and an observed-remove map
38
+ * whose `merge` is commutative, associative, and idempotent, so replicas
39
+ * converge with no coordination.
40
+ */
41
+
42
+ var bCrypto = require("./crypto");
43
+ var safeJson = require("./safe-json");
44
+ var { defineClass } = require("./framework-error");
45
+
46
+ var CrdtError = defineClass("CrdtError", { alwaysPermanent: true });
47
+
48
+ function _replicaId(opts) {
49
+ var id = opts && opts.replicaId;
50
+ if (id == null) return bCrypto.generateToken(8); // allow:raw-byte-literal — random replica-id token length
51
+ if (typeof id !== "string" || id.length === 0) throw new CrdtError("crdt/bad-replica-id", "crdt: replicaId must be a non-empty string");
52
+ return id;
53
+ }
54
+ function _posInt(n, label) {
55
+ if (typeof n !== "number" || !isFinite(n) || n < 0 || Math.floor(n) !== n) throw new CrdtError("crdt/bad-value", "crdt: " + label + " must be a non-negative integer");
56
+ return n;
57
+ }
58
+ function _maxMerge(a, b) {
59
+ var out = {};
60
+ var k;
61
+ for (k in a) if (Object.prototype.hasOwnProperty.call(a, k)) out[k] = a[k];
62
+ for (k in b) if (Object.prototype.hasOwnProperty.call(b, k)) out[k] = (out[k] === undefined || b[k] > out[k]) ? b[k] : out[k];
63
+ return out;
64
+ }
65
+
66
+ /**
67
+ * @primitive b.crdt.gCounter
68
+ * @signature b.crdt.gCounter(opts?)
69
+ * @since 0.13.4
70
+ * @status stable
71
+ * @compliance soc2
72
+ * @related b.crdt.pnCounter, b.crdt.gSet
73
+ *
74
+ * A grow-only counter: each replica tracks its own increment-only tally, and
75
+ * the value is their sum. <code>merge</code> takes the per-replica maximum, so
76
+ * it converges no matter the order. Increments only — use
77
+ * <code>pnCounter</code> when you also need to decrement.
78
+ *
79
+ * @opts
80
+ * replicaId: string, // this replica's id (default: random)
81
+ *
82
+ * @example
83
+ * var a = b.crdt.gCounter({ replicaId: "a" }).inc(3);
84
+ * var c = b.crdt.gCounter({ replicaId: "c" }).inc(5);
85
+ * a.merge(c).value(); // → 8
86
+ */
87
+ function gCounter(opts) {
88
+ opts = opts || {};
89
+ var replicaId = _replicaId(opts);
90
+ var counts = {};
91
+ if (opts._counts) { for (var k in opts._counts) if (Object.prototype.hasOwnProperty.call(opts._counts, k)) counts[k] = opts._counts[k]; }
92
+
93
+ return {
94
+ type: "gCounter",
95
+ replicaId: replicaId,
96
+ inc: function (n) { n = n == null ? 1 : _posInt(n, "inc"); counts[replicaId] = (counts[replicaId] || 0) + n; return this; },
97
+ value: function () { var s = 0; for (var k in counts) if (Object.prototype.hasOwnProperty.call(counts, k)) s += counts[k]; return s; },
98
+ state: function () { return { type: "gCounter", counts: Object.assign({}, counts) }; },
99
+ merge: function (other) { return gCounter({ replicaId: replicaId, _counts: _maxMerge(counts, _otherState(other, "gCounter").counts) }); },
100
+ };
101
+ }
102
+ gCounter.fromState = function (s, opts) { _assertState(s, "gCounter"); return gCounter(Object.assign({}, opts, { _counts: s.counts })); };
103
+
104
+ /**
105
+ * @primitive b.crdt.pnCounter
106
+ * @signature b.crdt.pnCounter(opts?)
107
+ * @since 0.13.4
108
+ * @status stable
109
+ * @compliance soc2
110
+ * @related b.crdt.gCounter, b.crdt.lwwRegister
111
+ *
112
+ * A positive-negative counter: two grow-only counters (increments and
113
+ * decrements) whose difference is the value, so it supports both
114
+ * <code>inc</code> and <code>dec</code> and still converges.
115
+ *
116
+ * @opts
117
+ * replicaId: string, // this replica's id (default: random)
118
+ *
119
+ * @example
120
+ * var a = b.crdt.pnCounter({ replicaId: "a" }).inc(5).dec(2);
121
+ * var c = b.crdt.pnCounter({ replicaId: "c" }).inc(1);
122
+ * a.merge(c).value(); // → 4
123
+ */
124
+ function pnCounter(opts) {
125
+ opts = opts || {};
126
+ var replicaId = _replicaId(opts);
127
+ var p = gCounter({ replicaId: replicaId, _counts: opts._p });
128
+ var n = gCounter({ replicaId: replicaId, _counts: opts._n });
129
+
130
+ return {
131
+ type: "pnCounter",
132
+ replicaId: replicaId,
133
+ inc: function (by) { p.inc(by); return this; },
134
+ dec: function (by) { n.inc(by); return this; },
135
+ value: function () { return p.value() - n.value(); },
136
+ state: function () { return { type: "pnCounter", p: p.state().counts, n: n.state().counts }; },
137
+ merge: function (other) {
138
+ var o = _otherState(other, "pnCounter");
139
+ return pnCounter({ replicaId: replicaId, _p: _maxMerge(p.state().counts, o.p), _n: _maxMerge(n.state().counts, o.n) });
140
+ },
141
+ };
142
+ }
143
+ pnCounter.fromState = function (s, opts) { _assertState(s, "pnCounter"); return pnCounter(Object.assign({}, opts, { _p: s.p, _n: s.n })); };
144
+
145
+ /**
146
+ * @primitive b.crdt.gSet
147
+ * @signature b.crdt.gSet(opts?)
148
+ * @since 0.13.4
149
+ * @status stable
150
+ * @compliance soc2
151
+ * @related b.crdt.twoPSet, b.crdt.orSet
152
+ *
153
+ * A grow-only set: elements can be added but never removed; <code>merge</code>
154
+ * is set union. The simplest convergent set — reach for <code>orSet</code>
155
+ * when removal is needed. Elements may be strings or JSON-serializable values.
156
+ *
157
+ * @opts
158
+ * replicaId: string, // this replica's id (default: random)
159
+ *
160
+ * @example
161
+ * var a = b.crdt.gSet().add("x");
162
+ * var c = b.crdt.gSet().add("y");
163
+ * a.merge(c).value(); // → ["x", "y"]
164
+ */
165
+ function gSet(opts) {
166
+ opts = opts || {};
167
+ var replicaId = _replicaId(opts);
168
+ var els = new Set(opts._els || []);
169
+
170
+ return {
171
+ type: "gSet",
172
+ replicaId: replicaId,
173
+ add: function (x) { els.add(_key(x)); return this; },
174
+ has: function (x) { return els.has(_key(x)); },
175
+ value: function () { return _decodeKeys(els); },
176
+ state: function () { return { type: "gSet", els: Array.from(els) }; },
177
+ merge: function (other) { var o = _otherState(other, "gSet"); var u = new Set(els); o.els.forEach(function (e) { u.add(e); }); return gSet({ replicaId: replicaId, _els: u }); },
178
+ };
179
+ }
180
+ gSet.fromState = function (s, opts) { _assertState(s, "gSet"); return gSet(Object.assign({}, opts, { _els: s.els })); };
181
+
182
+ /**
183
+ * @primitive b.crdt.twoPSet
184
+ * @signature b.crdt.twoPSet(opts?)
185
+ * @since 0.13.4
186
+ * @status stable
187
+ * @compliance soc2
188
+ * @related b.crdt.gSet, b.crdt.orSet
189
+ *
190
+ * A two-phase set: an add-set and a remove-set (tombstones). An element can be
191
+ * added and removed, but once removed it can never be re-added — remove wins
192
+ * permanently. When re-adding must work, use <code>orSet</code>.
193
+ *
194
+ * @opts
195
+ * replicaId: string, // this replica's id (default: random)
196
+ *
197
+ * @example
198
+ * var s = b.crdt.twoPSet().add("a").add("b").remove("a");
199
+ * s.value(); // → ["b"]
200
+ */
201
+ function twoPSet(opts) {
202
+ opts = opts || {};
203
+ var replicaId = _replicaId(opts);
204
+ var adds = new Set(opts._adds || []);
205
+ var removes = new Set(opts._removes || []);
206
+
207
+ return {
208
+ type: "twoPSet",
209
+ replicaId: replicaId,
210
+ add: function (x) { adds.add(_key(x)); return this; },
211
+ remove: function (x) { var k = _key(x); if (adds.has(k)) removes.add(k); return this; },
212
+ has: function (x) { var k = _key(x); return adds.has(k) && !removes.has(k); },
213
+ value: function () { var live = new Set(); adds.forEach(function (k) { if (!removes.has(k)) live.add(k); }); return _decodeKeys(live); },
214
+ state: function () { return { type: "twoPSet", adds: Array.from(adds), removes: Array.from(removes) }; },
215
+ merge: function (other) {
216
+ var o = _otherState(other, "twoPSet");
217
+ var a = new Set(adds), r = new Set(removes);
218
+ o.adds.forEach(function (e) { a.add(e); });
219
+ o.removes.forEach(function (e) { r.add(e); });
220
+ return twoPSet({ replicaId: replicaId, _adds: a, _removes: r });
221
+ },
222
+ };
223
+ }
224
+ twoPSet.fromState = function (s, opts) { _assertState(s, "twoPSet"); return twoPSet(Object.assign({}, opts, { _adds: s.adds, _removes: s.removes })); };
225
+
226
+ /**
227
+ * @primitive b.crdt.orSet
228
+ * @signature b.crdt.orSet(opts?)
229
+ * @since 0.13.4
230
+ * @status stable
231
+ * @compliance soc2
232
+ * @related b.crdt.gSet, b.crdt.twoPSet, b.crdt.orMap
233
+ *
234
+ * An observed-remove set: each add stamps a unique tag, and remove tombstones
235
+ * the tags it has observed for that element, so an element survives if any
236
+ * concurrent add was not seen by the remove — re-adding works, and a
237
+ * concurrent add-vs-remove resolves add-wins. <code>tombstoneRetention</code>
238
+ * optionally caps the tombstone set to bound memory against a remove flood; it
239
+ * drops the oldest tombstones, which can resurrect a concurrently-removed
240
+ * element, so leave it unset unless that trade-off is acceptable.
241
+ *
242
+ * Each add stamps a unique tag; remove tombstones the tags currently observed
243
+ * for that element. An element is present if it has a live (un-tombstoned) tag.
244
+ *
245
+ * @opts
246
+ * replicaId: string, // this replica's id (default: random)
247
+ * tombstoneRetention: number, // optional cap on retained tombstones (default: unbounded)
248
+ *
249
+ * @example
250
+ * var a = b.crdt.orSet().add("x");
251
+ * var c = b.crdt.orSet.fromState(a.state()).add("x"); // re-add elsewhere
252
+ * a.remove("x");
253
+ * a.merge(c).value(); // → ["x"] (concurrent re-add survives)
254
+ */
255
+ function orSet(opts) {
256
+ opts = opts || {};
257
+ var replicaId = _replicaId(opts);
258
+ var tombstoneRetention = opts.tombstoneRetention;
259
+ if (tombstoneRetention != null) _posInt(tombstoneRetention, "tombstoneRetention");
260
+ // elems: key -> array of tags ; tombstones: Set of removed tags
261
+ var elems = {};
262
+ if (opts._elems) { for (var k in opts._elems) if (Object.prototype.hasOwnProperty.call(opts._elems, k)) elems[k] = opts._elems[k].slice(); }
263
+ var tombstones = new Set(opts._tombstones || []);
264
+ var seq = 0;
265
+
266
+ function _tag() { return replicaId + ":" + (++seq) + ":" + bCrypto.generateToken(4); }
267
+ function _liveTags(key) { return (elems[key] || []).filter(function (t) { return !tombstones.has(t); }); }
268
+ function _gcTombstones() {
269
+ if (tombstoneRetention == null || tombstones.size <= tombstoneRetention) return;
270
+ // Bounded-memory tradeoff (opt-in): drop oldest tombstones. Documented to
271
+ // possibly resurrect a concurrently-removed element — full causal GC ships
272
+ // with the replicator slice.
273
+ var arr = Array.from(tombstones);
274
+ tombstones = new Set(arr.slice(arr.length - tombstoneRetention));
275
+ }
276
+
277
+ return {
278
+ type: "orSet",
279
+ replicaId: replicaId,
280
+ add: function (x) { var key = _key(x); (elems[key] = elems[key] || []).push(_tag()); return this; },
281
+ remove: function (x) { var key = _key(x); _liveTags(key).forEach(function (t) { tombstones.add(t); }); _gcTombstones(); return this; },
282
+ has: function (x) { return _liveTags(_key(x)).length > 0; },
283
+ value: function () { var live = []; for (var key in elems) if (Object.prototype.hasOwnProperty.call(elems, key) && _liveTags(key).length > 0) live.push(key); return _decodeKeys(new Set(live)); },
284
+ state: function () { var e = {}; for (var key in elems) if (Object.prototype.hasOwnProperty.call(elems, key)) e[key] = elems[key].slice(); return { type: "orSet", elems: e, tombstones: Array.from(tombstones) }; },
285
+ merge: function (other) {
286
+ var o = _otherState(other, "orSet");
287
+ var e = {};
288
+ var key;
289
+ for (key in elems) if (Object.prototype.hasOwnProperty.call(elems, key)) e[key] = elems[key].slice();
290
+ for (key in o.elems) if (Object.prototype.hasOwnProperty.call(o.elems, key)) {
291
+ var merged = (e[key] || []).concat(o.elems[key]);
292
+ e[key] = Array.from(new Set(merged)); // union of tags, dedup
293
+ }
294
+ var ts = new Set(tombstones);
295
+ o.tombstones.forEach(function (t) { ts.add(t); });
296
+ return orSet({ replicaId: replicaId, tombstoneRetention: tombstoneRetention, _elems: e, _tombstones: ts });
297
+ },
298
+ };
299
+ }
300
+ orSet.fromState = function (s, opts) { _assertState(s, "orSet"); return orSet(Object.assign({}, opts, { _elems: s.elems, _tombstones: s.tombstones })); };
301
+
302
+ /**
303
+ * @primitive b.crdt.lwwRegister
304
+ * @signature b.crdt.lwwRegister(opts?)
305
+ * @since 0.13.4
306
+ * @status stable
307
+ * @compliance soc2
308
+ * @related b.crdt.pnCounter, b.crdt.orMap
309
+ *
310
+ * A last-write-wins register: holds a single value with a timestamp;
311
+ * <code>merge</code> keeps the higher-timestamped write, breaking ties by the
312
+ * higher <code>replicaId</code> so the outcome is deterministic. Pass an
313
+ * explicit timestamp to <code>set</code> for a logical clock, or omit it to
314
+ * use wall-clock milliseconds.
315
+ *
316
+ * @opts
317
+ * replicaId: string, // this replica's id (default: random)
318
+ *
319
+ * @example
320
+ * var a = b.crdt.lwwRegister({ replicaId: "a" }).set("first", 1);
321
+ * var c = b.crdt.lwwRegister({ replicaId: "c" }).set("second", 2);
322
+ * a.merge(c).value(); // → "second"
323
+ */
324
+ function lwwRegister(opts) {
325
+ opts = opts || {};
326
+ var replicaId = _replicaId(opts);
327
+ var current = opts._current || { value: null, ts: -1, replicaId: "" };
328
+
329
+ function _beats(a, b) { return a.ts > b.ts || (a.ts === b.ts && a.replicaId > b.replicaId); }
330
+ return {
331
+ type: "lwwRegister",
332
+ replicaId: replicaId,
333
+ set: function (v, ts) { ts = ts == null ? Date.now() : _posInt(ts, "ts"); var cand = { value: v, ts: ts, replicaId: replicaId }; if (_beats(cand, current)) current = cand; return this; },
334
+ value: function () { return current.value; },
335
+ timestamp: function () { return current.ts; },
336
+ state: function () { return { type: "lwwRegister", current: { value: current.value, ts: current.ts, replicaId: current.replicaId } }; },
337
+ merge: function (other) { var o = _otherState(other, "lwwRegister"); var win = _beats(o.current, current) ? o.current : current; return lwwRegister({ replicaId: replicaId, _current: { value: win.value, ts: win.ts, replicaId: win.replicaId } }); },
338
+ };
339
+ }
340
+ lwwRegister.fromState = function (s, opts) { _assertState(s, "lwwRegister"); return lwwRegister(Object.assign({}, opts, { _current: s.current })); };
341
+
342
+ /**
343
+ * @primitive b.crdt.orMap
344
+ * @signature b.crdt.orMap(opts?)
345
+ * @since 0.13.4
346
+ * @status stable
347
+ * @compliance soc2
348
+ * @related b.crdt.orSet, b.crdt.lwwRegister
349
+ *
350
+ * An observed-remove map: key presence follows observed-remove-set semantics
351
+ * (a key can be set, removed, and set again), and each key's value is a
352
+ * last-write-wins register, so concurrent writes to a live key converge by
353
+ * timestamp (higher wins, ties by replicaId). Removing a key clears its value
354
+ * register locally, so a re-add on the same replica starts clean; across
355
+ * replicas the value is strictly last-write-wins by timestamp — supply
356
+ * monotonic timestamps (the default wall-clock does) for re-add to win. Keys
357
+ * are non-empty strings.
358
+ *
359
+ * Keys follow OR-Set add/remove semantics; each key's value is an LWW register,
360
+ * so concurrent writes to the same key converge by last-write-wins.
361
+ *
362
+ * @opts
363
+ * replicaId: string, // this replica's id (default: random)
364
+ *
365
+ * @example
366
+ * var a = b.crdt.orMap({ replicaId: "a" }).set("k", "v1", 1);
367
+ * var c = b.crdt.orMap({ replicaId: "c" }).set("k", "v2", 2);
368
+ * a.merge(c).value(); // → { k: "v2" }
369
+ */
370
+ function orMap(opts) {
371
+ opts = opts || {};
372
+ var replicaId = _replicaId(opts);
373
+ var keys = orSet({ replicaId: replicaId, _elems: opts._keyElems, _tombstones: opts._keyTombstones });
374
+ var vals = {}; // key -> lwwRegister state
375
+ if (opts._vals) { for (var k in opts._vals) if (Object.prototype.hasOwnProperty.call(opts._vals, k)) vals[k] = opts._vals[k]; }
376
+
377
+ function _reg(key) { return lwwRegister.fromState(vals[key] || { type: "lwwRegister", current: { value: null, ts: -1, replicaId: "" } }, { replicaId: replicaId }); }
378
+ // Keys pass to the OR-Set raw (it encodes internally) and index `vals`
379
+ // directly, so the materialized value is keyed by the plain string.
380
+ return {
381
+ type: "orMap",
382
+ replicaId: replicaId,
383
+ set: function (key, v, ts) { key = _mapKey(key); keys.add(key); vals[key] = _reg(key).set(v, ts).state(); return this; },
384
+ // Removing a key clears its value register, so re-adding the key on this
385
+ // replica starts from a clean last-write-wins state rather than reusing the
386
+ // pre-remove value. (Across replicas the value still follows last-write-wins
387
+ // by timestamp — a concurrent, un-removed higher-timestamped write wins;
388
+ // making a re-add causally supersede needs vector clocks, which ship with
389
+ // the replicator slice.)
390
+ remove: function (key) { key = _mapKey(key); keys.remove(key); delete vals[key]; return this; },
391
+ has: function (key) { return keys.has(_mapKey(key)); },
392
+ get: function (key) { key = _mapKey(key); return keys.has(key) ? _reg(key).value() : undefined; },
393
+ value: function () { var out = {}; keys.value().forEach(function (key) { out[key] = _reg(key).value(); }); return out; },
394
+ state: function () { var ks = keys.state(); return { type: "orMap", keyElems: ks.elems, keyTombstones: ks.tombstones, vals: Object.assign({}, vals) }; },
395
+ merge: function (other) {
396
+ var o = _otherState(other, "orMap");
397
+ var mergedKeys = keys.merge({ state: function () { return { type: "orSet", elems: o.keyElems, tombstones: o.keyTombstones }; } }).state();
398
+ var v = {};
399
+ var key;
400
+ for (key in vals) if (Object.prototype.hasOwnProperty.call(vals, key)) v[key] = vals[key];
401
+ for (key in o.vals) if (Object.prototype.hasOwnProperty.call(o.vals, key)) {
402
+ if (!v[key]) { v[key] = o.vals[key]; }
403
+ else {
404
+ var merged = lwwRegister.fromState(v[key], { replicaId: replicaId }).merge({ state: function () { return o.vals[key]; } });
405
+ v[key] = merged.state();
406
+ }
407
+ }
408
+ return orMap({ replicaId: replicaId, _keyElems: mergedKeys.elems, _keyTombstones: mergedKeys.tombstones, _vals: v });
409
+ },
410
+ };
411
+ }
412
+ orMap.fromState = function (s, opts) { _assertState(s, "orMap"); return orMap(Object.assign({}, opts, { _keyElems: s.keyElems, _keyTombstones: s.keyTombstones, _vals: s.vals })); };
413
+
414
+ // ---- shared helpers --------------------------------------------------------
415
+
416
+ // Set/map elements are keyed by a reversible string so structured values work.
417
+ function _key(x) {
418
+ if (typeof x === "string") return "s:" + x;
419
+ return "j:" + JSON.stringify(x);
420
+ }
421
+ function _mapKey(k) {
422
+ if (typeof k !== "string" || k.length === 0) throw new CrdtError("crdt/bad-key", "crdt.orMap: keys must be non-empty strings");
423
+ return k;
424
+ }
425
+ function _decodeKeys(set) {
426
+ // Sort by the encoded key (a deterministic, unique string for every element,
427
+ // including structured ones) BEFORE decoding, so the materialized array order
428
+ // is identical regardless of merge order — structured elements would all
429
+ // collapse to "[object Object]" if sorted by their decoded value.
430
+ return Array.from(set).sort().map(function (k) {
431
+ return k.charAt(0) === "s" ? k.slice(2) : safeJson.parse(k.slice(2));
432
+ });
433
+ }
434
+ function _otherState(other, expected) {
435
+ var s = other && typeof other.state === "function" ? other.state() : other;
436
+ _assertState(s, expected);
437
+ return s;
438
+ }
439
+ function _assertState(s, expected) {
440
+ if (!s || typeof s !== "object") throw new CrdtError("crdt/bad-state", "crdt: expected a " + expected + " state object");
441
+ if (s.type !== expected) throw new CrdtError("crdt/type-mismatch", "crdt: expected a " + expected + " state, got '" + s.type + "'");
442
+ }
443
+
444
+ module.exports = {
445
+ gCounter: gCounter,
446
+ pnCounter: pnCounter,
447
+ gSet: gSet,
448
+ twoPSet: twoPSet,
449
+ orSet: orSet,
450
+ lwwRegister: lwwRegister,
451
+ orMap: orMap,
452
+ CrdtError: CrdtError,
453
+ };
@@ -0,0 +1,213 @@
1
+ "use strict";
2
+ /**
3
+ * @module b.crypto.xwing
4
+ * @nav Crypto
5
+ * @title X-Wing KEM
6
+ *
7
+ * @intro
8
+ * X-Wing is a general-purpose hybrid post-quantum / traditional key
9
+ * encapsulation mechanism: it runs ML-KEM-768 and X25519 side by side and
10
+ * binds their shared secrets with SHA3-256, so the resulting key stays
11
+ * secure as long as <em>either</em> ML-KEM-768 or X25519 holds. That is the
12
+ * conservative shape for migrating off classical ECDH today — a harvest-now-
13
+ * decrypt-later attacker must break the lattice KEM, and a hypothetical
14
+ * ML-KEM break still leaves X25519 standing.
15
+ *
16
+ * The construction follows
17
+ * <code>draft-connolly-cfrg-xwing-kem</code>. The combiner is frozen — it
18
+ * hashes the ML-KEM shared secret, the X25519 shared secret, the X25519
19
+ * ephemeral public key, the recipient's X25519 public key, and a fixed
20
+ * six-byte label — but the document is still an IETF Internet-Draft, so this
21
+ * primitive is marked <code>experimental</code> and sits beside the other
22
+ * pre-RFC post-quantum drafts (<code>b.crypto.hpke.pq</code>). The wire
23
+ * sizes are fixed: a 1216-byte public key (ML-KEM-768 1184 ‖ X25519 32), a
24
+ * 1120-byte ciphertext (ML-KEM-768 1088 ‖ X25519 32), a 32-byte decapsulation
25
+ * seed, and a 32-byte shared secret.
26
+ *
27
+ * X-Wing composes the framework's vendored ML-KEM-768 and X25519 plus
28
+ * SHA3 — it adds no new cryptographic core, only the standard combiner and
29
+ * wire framing.
30
+ *
31
+ * @card
32
+ * X-Wing hybrid PQ/T KEM (`b.crypto.xwing`) — ML-KEM-768 + X25519 bound by
33
+ * SHA3-256 per draft-connolly-cfrg-xwing-kem, secure if either component
34
+ * holds. 1216-byte key, 1120-byte ciphertext, 32-byte shared secret.
35
+ */
36
+
37
+ var nodeCrypto = require("node:crypto");
38
+ var pqc = require("./vendor/noble-post-quantum.cjs");
39
+ var { defineClass } = require("./framework-error");
40
+
41
+ var XWingError = defineClass("XWingError", { alwaysPermanent: true });
42
+
43
+ var mlkem = pqc.ml_kem768;
44
+
45
+ // draft-connolly-cfrg-xwing-kem: the combiner label, ASCII "\./" + "/^\".
46
+ var XWING_LABEL = Buffer.from("5c2e2f2f5e5c", "hex");
47
+
48
+ // Component + composite sizes (bytes), fixed by the draft — protocol wire
49
+ // widths, not buffer-capacity tunables.
50
+ var ML_KEM_PK = 1184; // allow:raw-byte-literal — ML-KEM-768 public key
51
+ var ML_KEM_CT = 1088; // allow:raw-byte-literal — ML-KEM-768 ciphertext
52
+ var X25519_LEN = 32; // allow:raw-byte-literal — X25519 key/share length
53
+ var SEED_LEN = 32; // allow:raw-byte-literal — X-Wing seed length
54
+ var SS_LEN = 32; // allow:raw-byte-literal — shared-secret length
55
+ var PK_LEN = ML_KEM_PK + X25519_LEN; // 1216
56
+ var CT_LEN = ML_KEM_CT + X25519_LEN; // 1120
57
+ var MLKEM_SEED = 64; // allow:raw-byte-literal — d ‖ z for ML-KEM KeyGen_internal
58
+ var EXPAND_LEN = 96; // allow:raw-byte-literal — SHAKE256(seed) → d ‖ z ‖ sk_X
59
+
60
+ // X25519 raw-scalar helpers via fixed PKCS8 / SPKI DER prefixes (OID
61
+ // 1.3.101.110). Node clamps the scalar per RFC 7748 on use, matching X-Wing.
62
+ var X25519_PKCS8_PREFIX = Buffer.from("302e020100300506032b656e04220420", "hex");
63
+ var X25519_SPKI_PREFIX = Buffer.from("302a300506032b656e032100", "hex");
64
+
65
+ function _x25519Public(sk) {
66
+ var key = nodeCrypto.createPrivateKey({ key: Buffer.concat([X25519_PKCS8_PREFIX, sk]), format: "der", type: "pkcs8" });
67
+ var spki = nodeCrypto.createPublicKey(key).export({ format: "der", type: "spki" });
68
+ return spki.subarray(spki.length - X25519_LEN);
69
+ }
70
+ function _x25519Shared(sk, pk) {
71
+ return nodeCrypto.diffieHellman({
72
+ privateKey: nodeCrypto.createPrivateKey({ key: Buffer.concat([X25519_PKCS8_PREFIX, sk]), format: "der", type: "pkcs8" }),
73
+ publicKey: nodeCrypto.createPublicKey({ key: Buffer.concat([X25519_SPKI_PREFIX, pk]), format: "der", type: "spki" }),
74
+ });
75
+ }
76
+
77
+ function _shake256(buf, outLen) { return nodeCrypto.createHash("shake256", { outputLength: outLen }).update(buf).digest(); }
78
+
79
+ /**
80
+ * @primitive b.crypto.xwing.combiner
81
+ * @signature b.crypto.xwing.combiner(ssM, ssX, ctX, pkX)
82
+ * @since 0.13.3
83
+ * @status experimental
84
+ * @compliance soc2
85
+ * @related b.crypto.xwing.encapsulate, b.crypto.xwing.decapsulate
86
+ *
87
+ * The X-Wing combiner: <code>SHA3-256(ssM ‖ ssX ‖ ctX ‖ pkX ‖ label)</code>,
88
+ * where the label is the fixed six bytes the draft defines. Exposed for
89
+ * advanced use and known-answer testing; <code>encapsulate</code> and
90
+ * <code>decapsulate</code> call it internally. Each input must be 32 bytes.
91
+ *
92
+ * @example
93
+ * var ss = b.crypto.xwing.combiner(ssMlkem, ssX25519, ephPub, recipientPub);
94
+ * // → 32-byte shared secret
95
+ */
96
+ function combiner(ssM, ssX, ctX, pkX) {
97
+ [["ssM", ssM, SS_LEN], ["ssX", ssX, X25519_LEN], ["ctX", ctX, X25519_LEN], ["pkX", pkX, X25519_LEN]].forEach(function (t) {
98
+ // ML-KEM outputs are Uint8Array; X25519 outputs are Buffer — accept both.
99
+ if (!(Buffer.isBuffer(t[1]) || t[1] instanceof Uint8Array) || t[1].length !== t[2]) throw new XWingError("xwing/bad-input", "xwing.combiner: " + t[0] + " must be a " + t[2] + "-byte byte array");
100
+ });
101
+ return nodeCrypto.createHash("sha3-256").update(Buffer.concat([ssM, ssX, ctX, pkX, XWING_LABEL])).digest();
102
+ }
103
+
104
+ // Expand a 32-byte seed into ML-KEM key material + the X25519 scalar.
105
+ function _expand(seed) {
106
+ var e = _shake256(seed, EXPAND_LEN);
107
+ var kp = mlkem.keygen(e.subarray(0, MLKEM_SEED)); // KeyGen_internal(d, z)
108
+ var skX = e.subarray(MLKEM_SEED, EXPAND_LEN);
109
+ return { skM: kp.secretKey, pkM: kp.publicKey, skX: skX, pkX: _x25519Public(skX) };
110
+ }
111
+
112
+ /**
113
+ * @primitive b.crypto.xwing.keygen
114
+ * @signature b.crypto.xwing.keygen(seed?)
115
+ * @since 0.13.3
116
+ * @status experimental
117
+ * @compliance soc2
118
+ * @related b.crypto.xwing.encapsulate, b.crypto.xwing.decapsulate
119
+ *
120
+ * Generate an X-Wing keypair. The decapsulation key is a 32-byte seed (store
121
+ * this); the encapsulation key is the 1216-byte public key to publish. Pass a
122
+ * 32-byte <code>seed</code> for deterministic generation, or omit it for a
123
+ * random key.
124
+ *
125
+ * @example
126
+ * var kp = b.crypto.xwing.keygen();
127
+ * kp.publicKey.length; // → 1216
128
+ * kp.secretKey.length; // → 32 (the seed — keep it secret)
129
+ */
130
+ function keygen(seed) {
131
+ if (seed == null) seed = nodeCrypto.randomBytes(SEED_LEN);
132
+ if (!Buffer.isBuffer(seed) || seed.length !== SEED_LEN) throw new XWingError("xwing/bad-seed", "xwing.keygen: seed must be a " + SEED_LEN + "-byte Buffer");
133
+ var k = _expand(seed);
134
+ return { publicKey: Buffer.concat([k.pkM, k.pkX]), secretKey: Buffer.from(seed) };
135
+ }
136
+
137
+ /**
138
+ * @primitive b.crypto.xwing.encapsulate
139
+ * @signature b.crypto.xwing.encapsulate(publicKey, eseed?)
140
+ * @since 0.13.3
141
+ * @status experimental
142
+ * @compliance soc2
143
+ * @related b.crypto.xwing.decapsulate, b.crypto.xwing.keygen
144
+ *
145
+ * Encapsulate to a 1216-byte X-Wing public key. Returns the 1120-byte
146
+ * <code>ciphertext</code> to send and the 32-byte <code>sharedSecret</code> to
147
+ * key a symmetric cipher with. Pass a 64-byte <code>eseed</code>
148
+ * (X25519 ephemeral scalar ‖ ML-KEM coins) for deterministic encapsulation, or
149
+ * omit it for fresh randomness.
150
+ *
151
+ * @example
152
+ * var enc = b.crypto.xwing.encapsulate(recipientPublicKey);
153
+ * enc.ciphertext.length; // → 1120
154
+ * enc.sharedSecret.length; // → 32
155
+ */
156
+ function encapsulate(publicKey, eseed) {
157
+ if (!Buffer.isBuffer(publicKey) || publicKey.length !== PK_LEN) throw new XWingError("xwing/bad-public-key", "xwing.encapsulate: publicKey must be a " + PK_LEN + "-byte Buffer");
158
+ var pkM = publicKey.subarray(0, ML_KEM_PK);
159
+ var pkX = publicKey.subarray(ML_KEM_PK, PK_LEN);
160
+ var ekX, mlkemCoins = null;
161
+ if (eseed == null) {
162
+ ekX = nodeCrypto.randomBytes(X25519_LEN);
163
+ } else {
164
+ if (!Buffer.isBuffer(eseed) || eseed.length !== 2 * X25519_LEN) throw new XWingError("xwing/bad-eseed", "xwing.encapsulate: eseed must be a " + (2 * X25519_LEN) + "-byte Buffer");
165
+ // draft EncapsulateDerand: eseed[0:32] = ML-KEM coins, eseed[32:64] = X25519
166
+ // ephemeral scalar. This order matches the draft's test vectors.
167
+ mlkemCoins = eseed.subarray(0, X25519_LEN);
168
+ ekX = eseed.subarray(X25519_LEN, 2 * X25519_LEN);
169
+ }
170
+ var ctX = _x25519Public(ekX);
171
+ var ssX = _x25519Shared(ekX, pkX);
172
+ var kem = mlkemCoins ? mlkem.encapsulate(pkM, mlkemCoins) : mlkem.encapsulate(pkM);
173
+ var ss = combiner(kem.sharedSecret, ssX, ctX, pkX);
174
+ return { ciphertext: Buffer.concat([kem.cipherText, ctX]), sharedSecret: ss };
175
+ }
176
+
177
+ /**
178
+ * @primitive b.crypto.xwing.decapsulate
179
+ * @signature b.crypto.xwing.decapsulate(secretKey, ciphertext)
180
+ * @since 0.13.3
181
+ * @status experimental
182
+ * @compliance soc2
183
+ * @related b.crypto.xwing.encapsulate, b.crypto.xwing.keygen
184
+ *
185
+ * Recover the 32-byte shared secret from a 1120-byte X-Wing ciphertext using
186
+ * the 32-byte decapsulation seed. ML-KEM-768's implicit-rejection means a
187
+ * tampered ciphertext yields a different (still 32-byte) secret rather than an
188
+ * error, so never branch on success — derive keys and let the AEAD tag fail.
189
+ *
190
+ * @example
191
+ * var ss = b.crypto.xwing.decapsulate(kp.secretKey, enc.ciphertext);
192
+ * ss.equals(enc.sharedSecret); // → true
193
+ */
194
+ function decapsulate(secretKey, ciphertext) {
195
+ if (!Buffer.isBuffer(secretKey) || secretKey.length !== SEED_LEN) throw new XWingError("xwing/bad-seed", "xwing.decapsulate: secretKey must be a " + SEED_LEN + "-byte Buffer");
196
+ if (!Buffer.isBuffer(ciphertext) || ciphertext.length !== CT_LEN) throw new XWingError("xwing/bad-ciphertext", "xwing.decapsulate: ciphertext must be a " + CT_LEN + "-byte Buffer");
197
+ var k = _expand(secretKey);
198
+ var ctM = ciphertext.subarray(0, ML_KEM_CT);
199
+ var ctX = ciphertext.subarray(ML_KEM_CT, CT_LEN);
200
+ var ssM = mlkem.decapsulate(ctM, k.skM);
201
+ var ssX = _x25519Shared(k.skX, ctX);
202
+ return combiner(ssM, ssX, ctX, k.pkX);
203
+ }
204
+
205
+ module.exports = {
206
+ NAME: "X-Wing",
207
+ keygen: keygen,
208
+ encapsulate: encapsulate,
209
+ decapsulate: decapsulate,
210
+ combiner: combiner,
211
+ SIZES: { publicKey: PK_LEN, ciphertext: CT_LEN, secretKey: SEED_LEN, sharedSecret: SS_LEN },
212
+ XWingError: XWingError,
213
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@blamejs/core",
3
- "version": "0.13.2",
3
+ "version": "0.13.4",
4
4
  "description": "The Node framework that owns its stack.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "blamejs contributors",
package/sbom.cdx.json CHANGED
@@ -2,10 +2,10 @@
2
2
  "$schema": "http://cyclonedx.org/schema/bom-1.5.schema.json",
3
3
  "bomFormat": "CycloneDX",
4
4
  "specVersion": "1.5",
5
- "serialNumber": "urn:uuid:45c82292-580f-4bfa-aa46-23d8f36d6b83",
5
+ "serialNumber": "urn:uuid:832111fb-8b91-4fb8-bb5f-c491bae476c5",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-05-26T23:17:31.244Z",
8
+ "timestamp": "2026-05-27T01:19:38.243Z",
9
9
  "lifecycles": [
10
10
  {
11
11
  "phase": "build"
@@ -19,14 +19,14 @@
19
19
  }
20
20
  ],
21
21
  "component": {
22
- "bom-ref": "@blamejs/core@0.13.2",
22
+ "bom-ref": "@blamejs/core@0.13.4",
23
23
  "type": "application",
24
24
  "name": "blamejs",
25
- "version": "0.13.2",
25
+ "version": "0.13.4",
26
26
  "scope": "required",
27
27
  "author": "blamejs contributors",
28
28
  "description": "The Node framework that owns its stack.",
29
- "purl": "pkg:npm/%40blamejs/core@0.13.2",
29
+ "purl": "pkg:npm/%40blamejs/core@0.13.4",
30
30
  "properties": [],
31
31
  "externalReferences": [
32
32
  {
@@ -54,7 +54,7 @@
54
54
  "components": [],
55
55
  "dependencies": [
56
56
  {
57
- "ref": "@blamejs/core@0.13.2",
57
+ "ref": "@blamejs/core@0.13.4",
58
58
  "dependsOn": []
59
59
  }
60
60
  ]