@derec-alliance/nodejs 0.0.1-alpha.6 → 0.0.1-alpha.8

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 CHANGED
@@ -53,68 +53,210 @@ These represent **opaque wire-level protocol messages**.
53
53
  ## Quick Example
54
54
 
55
55
  ```ts
56
- import * as derec from "@derec-alliance/nodejs";
56
+ import { primitives } from "@derec-alliance/nodejs";
57
57
 
58
- const version = derec.derec_protocol_version();
58
+ const channelId = 1n; // u64 → bigint
59
+ const secretId = 42n; // u64 → bigint
60
+ const version = 1; // u32 → number
61
+ const sharedKey = new Uint8Array(32); // established during pairing
59
62
 
60
- console.log(version.major, version.minor);
63
+ const result = primitives.verification.request.produce(channelId, secretId, version, sharedKey);
64
+ // result carries the encoded DeRecMessage envelope, ready to send over transport.
61
65
  ```
62
66
 
63
67
  ---
64
68
 
65
69
  ## Pairing Flow
66
70
 
71
+ The `ContactMessage` is exchanged out-of-band (QR codes, existing messaging
72
+ channels, etc.). Two `ContactMode` values select how the public encryption
73
+ material is delivered:
74
+
75
+ | Mode | What the contact carries | Use when |
76
+ |---|---|---|
77
+ | `InlineKeys` (default) | Full ML-KEM encapsulation key + ECIES public key | Out-of-band channel can carry the keys (NFC, messaging). |
78
+ | `HashedKeys` | Only a SHA-384 commitment to the keys | Channel is size-constrained (QR codes). Scanner fetches the actual keys via a plaintext `PrePair` round-trip and verifies them against the hash. |
79
+
80
+ After the handshake completes, **both modes** rekey the channel id. The
81
+ responder derives `SHA-384(u64_be(originalId) || sharedKey)[..8]` as a
82
+ `bigint`, includes it in the encrypted `PairResponseMessage`, and both sides
83
+ switch their local state to the new id. The new id never appears in plaintext
84
+ on the wire, so a passive observer who only saw pre-rekey traffic cannot link
85
+ the long-running channel to its pairing-time id.
86
+
87
+ ### `InlineKeys` flow
88
+
67
89
  ```ts
68
- import * as derec from "@derec-alliance/nodejs";
90
+ import { ContactMode, primitives, SenderKind } from "@derec-alliance/nodejs";
69
91
 
70
- // Step 1: Owner creates contact message (out-of-band)
71
- const contact = derec.create_contact_message(
72
- 1n,
73
- new TextEncoder().encode("wss://owner.example.com")
92
+ const channelId = 1n;
93
+
94
+ // Step 1: Initiator creates the out-of-band ContactMessage.
95
+ const contact = primitives.pairing.request.create_contact(
96
+ channelId,
97
+ ContactMode.InlineKeys,
98
+ { protocol: 0, uri: "https://owner.example.com" },
74
99
  );
75
100
 
76
- // Step 2: Helper produces pairing request
77
- const request = derec.produce_pairing_request_message(
78
- 2, // SenderKind.Helper
79
- new TextEncoder().encode("wss://helper.example.com"),
80
- contact.wire_bytes
101
+ // Step 2: Responder produces a pairing request from the contact.
102
+ const request = primitives.pairing.request.produce(
103
+ SenderKind.Helper,
104
+ { protocol: 0, uri: "https://helper.example.com" },
105
+ contact.contact_message,
106
+ null, // optional CommunicationInfo
81
107
  );
82
108
 
83
- // Step 3: Owner produces pairing response
84
- const response = derec.produce_pairing_response_message(
85
- 0, // SenderKind.SharerNonRecovery
86
- request.wire_bytes,
87
- request.secret_key_material
109
+ // Step 3: Initiator extracts the request and produces the response.
110
+ const { request: pairRequest } =
111
+ primitives.pairing.request.extract(request.envelope, contact.secret_key);
112
+ const produced = primitives.pairing.response.produce(
113
+ channelId,
114
+ pairRequest,
115
+ contact.secret_key,
116
+ null,
88
117
  );
89
118
 
90
- // Step 4: Helper processes response and derives shared key
91
- const result = derec.process_pairing_response_message(
92
- contact.wire_bytes,
93
- response.wire_bytes,
94
- request.secret_key_material
119
+ // Step 4: Responder extracts and processes the response.
120
+ const { response: pairResponse } =
121
+ primitives.pairing.response.extract(produced.envelope, request.secret_key);
122
+ const processed = primitives.pairing.response.process(
123
+ request.initiator_contact_message,
124
+ pairResponse,
125
+ request.secret_key,
95
126
  );
96
127
 
97
- console.log("Shared key length:", result.shared_key.length);
128
+ // Both sides hold the same shared key and rekeyed channel id.
129
+ // produced.shared_key == processed.shared_key
130
+ // produced.channel_id == processed.channel_id !== channelId
131
+ //
132
+ // Rename local channel state from `channelId` to `produced.channel_id`
133
+ // before sending any further traffic.
98
134
  ```
99
135
 
100
- ---
136
+ To reject the request, build a `PairResponseMessage` with a non-OK status and
137
+ encrypt it against `request.ecies_public_key` using the WASM-exposed pairing
138
+ envelope helpers. The higher-level `DeRecProtocol` orchestrator's `reject`
139
+ method does this for you. Rejected responses do not carry a meaningful
140
+ `channel_id` — the rekey only takes effect on `Ok` responses.
101
141
 
102
- ## Share Distribution (Sharing Flow)
142
+ ### `HashedKeys` flow (PrePair)
143
+
144
+ `HashedKeys` adds one plaintext round-trip before the regular `InlineKeys`
145
+ handshake. The scanner fetches the actual keys via `PrePair`, verifies them
146
+ against `contact.contact_binding_hash`, and then runs the normal pairing
147
+ flow on a synthesized contact with the keys filled in.
103
148
 
104
149
  ```ts
105
- import * as derec from "@derec-alliance/nodejs";
106
-
107
- const result = derec.protect_secret(
108
- new Uint8Array([1, 2, 3]), // secret ID
109
- new TextEncoder().encode("super-secret"), // secret bytes
110
- [1n, 2n, 3n], // helper channel IDs
111
- 2, // threshold
112
- 1 // version
150
+ import { ContactMode, primitives, SenderKind, type ContactMessage } from "@derec-alliance/nodejs";
151
+
152
+ const channelId = 7n;
153
+
154
+ // Initiator: HASHED_KEYS contact (no inline keys, only the binding hash).
155
+ // Transport URI MUST be ephemeral — PrePair envelopes are plaintext.
156
+ const contact = primitives.pairing.request.create_contact(
157
+ channelId,
158
+ ContactMode.HashedKeys,
159
+ { protocol: 0, uri: "https://relay.example.com/ephemeral" },
160
+ );
161
+
162
+ // Scanner: fetch keys via PrePair.
163
+ const prePairReqEnv = primitives.pairing.request.produce_pre_pair(
164
+ { protocol: 0, uri: "https://scanner.example.com/ephemeral" },
165
+ contact.contact_message,
113
166
  );
167
+ const { request: prePairReq } =
168
+ primitives.pairing.request.extract_pre_pair(prePairReqEnv.envelope);
169
+ const prePairRespEnv = primitives.pairing.response.produce_pre_pair(
170
+ channelId, prePairReq, contact.secret_key,
171
+ );
172
+ const { response: prePairResp } =
173
+ primitives.pairing.response.extract_pre_pair(prePairRespEnv.envelope);
174
+
175
+ // Scanner validates the published keys against contact.contact_binding_hash.
176
+ // Throws on mismatch (returns the keys + echoed nonce on match).
177
+ const validated = primitives.pairing.response.process_pre_pair(
178
+ contact.contact_message, prePairResp,
179
+ );
180
+
181
+ // Synthesize a "filled-in" contact and run the regular pairing flow. The
182
+ // mode flip is required — `primitives.pairing.request.produce` enforces
183
+ // `InlineKeys` and rejects a contact that still advertises `HashedKeys`.
184
+ const { contact_binding_hash: _omitBindingHash, ...contactBase } =
185
+ contact.contact_message;
186
+ const filledInContact: ContactMessage = {
187
+ ...contactBase,
188
+ contact_mode: ContactMode.InlineKeys,
189
+ mlkem_encapsulation_key: validated.mlkem_encapsulation_key,
190
+ ecies_public_key: validated.ecies_public_key,
191
+ };
192
+ // ... continue with primitives.pairing.request.produce / extract /
193
+ // primitives.pairing.response.produce / process against `filledInContact`
194
+ // exactly as in the InlineKeys example.
195
+ ```
196
+
197
+ After the PrePair exchange the application **must** swap the transport
198
+ endpoint to a long-term one via `UpdateChannelInfo`. The ephemeral endpoint
199
+ advertised in the `HashedKeys` contact is intended to be retired immediately
200
+ after pairing.
201
+
202
+ #### Using `DeRecProtocol` instead
203
+
204
+ The orchestrator handles the whole chain automatically:
205
+
206
+ - **Contact creator** — `protocol.createContact(channelId, ContactMode.HashedKeys)`
207
+ returns the small contact (binding hash only). When the scanner's
208
+ `PrePairRequest` arrives, `protocol.process(bytes)` emits an
209
+ `ActionRequired` event with `action_kind: "PrePair"`. Call
210
+ `protocol.accept(action)` to publish the keys (the library builds the
211
+ response and routes it), or `protocol.reject(action, status, memo)` to
212
+ refuse.
213
+ - **Scanner** — `protocol.start(FlowKind.Pairing, { kind, contact })` kicks
214
+ off the plaintext PrePair leg. `start()` returns a `DeRecEvent[]`
215
+ containing one `PairingStarted { channel_id, kind }` event that
216
+ describes the dispatched handshake; the scanner auto-proceeds to
217
+ `PairRequest`, and the application sees `PairingCompleted` only when
218
+ the final response lands via `process()`. Failure modes:
219
+ - Contact creator rejected → `DeRecEvent` with
220
+ `type: "PrePairRejected"`, plus `status` / `memo`.
221
+ - Binding-hash mismatch → `protocol.process(...)` throws a
222
+ `DeRecException`-shape error whose `message` carries
223
+ `"contact binding hash mismatch"`. This is security-relevant — the
224
+ keys published by the peer do not match the commitment the scanner
225
+ originally accepted.
226
+
227
+ End-to-end orchestrator-level coverage is in
228
+ `bindings/nodejs/protocol.ts::runHashedKeysPairingFlow` (happy path +
229
+ tampered-hash assertion).
230
+
231
+ ---
114
232
 
115
- const shareMessages = result.share_message_wire_bytes_array;
233
+ ## Share Distribution (Sharing Flow)
116
234
 
117
- console.log(shareMessages);
235
+ ```ts
236
+ import { primitives } from "@derec-alliance/nodejs";
237
+
238
+ const secretId = 42n; // u64
239
+ const secretData = new TextEncoder().encode("super-secret");
240
+ const channelIds = [1n, 2n, 3n];
241
+ const threshold = 2; // must be 2 <= threshold <= channelIds.length
242
+ const version = 1;
243
+ // sharedKeys: Map<bigint, Uint8Array> with the 32-byte channel keys
244
+
245
+ const splitResult = primitives.sharing.request.split(
246
+ secretId,
247
+ secretData,
248
+ channelIds,
249
+ threshold,
250
+ version,
251
+ );
252
+ // splitResult.value: Map<bigint, Uint8Array> — one CommittedDeRecShare per helper.
253
+
254
+ // Wrap each share into an encrypted delivery envelope.
255
+ for (const [channelId, committedShare] of splitResult.value) {
256
+ const envelope = primitives.sharing.request.produce(
257
+ channelId, version, secretId, committedShare, [], "", sharedKeys.get(channelId)!,
258
+ );
259
+ }
118
260
  ```
119
261
 
120
262
  ---
@@ -122,54 +264,86 @@ console.log(shareMessages);
122
264
  ## Recovery Flow
123
265
 
124
266
  ```ts
125
- import * as derec from "@derec-alliance/nodejs";
267
+ import { primitives } from "@derec-alliance/nodejs";
268
+
269
+ const secretId = 42n; // u64
270
+ const version = 1; // u32
126
271
 
127
- // Owner requests shares from helpers
128
- const request = derec.generate_share_request(
129
- new Uint8Array([1, 2, 3]), // secret ID
130
- 1 // version
272
+ // Owner side: produce the recovery request.
273
+ const shareRequest = primitives.recovery.request.produce(
274
+ 1n, // channel ID
275
+ secretId,
276
+ version,
277
+ sharedKey,
131
278
  );
132
279
 
133
- // Helper responds with its share
134
- const response = derec.generate_share_response(
135
- request,
136
- new Uint8Array() // share content
280
+ // Helper side: produce the response using the StoreShareRequest it persisted
281
+ // at sharing time.
282
+ const shareResponse = primitives.recovery.response.produce(
283
+ secretId,
284
+ 1n, // channel ID
285
+ storedShareEnvelope,
286
+ shareRequest,
287
+ sharedKey,
137
288
  );
138
289
 
139
- // Owner reconstructs secret from aggregated responses
140
- const secret = derec.recover_from_share_responses(
141
- new Uint8Array(), // aggregated responses
142
- new Uint8Array([1, 2, 3]),
143
- 1
290
+ // Owner side: collect at least `threshold` responses and reconstruct.
291
+ const recovered = primitives.recovery.response.recover(
292
+ [
293
+ { response: shareResponse, shared_key: sharedKey },
294
+ // …additional helper responses…
295
+ ],
296
+ secretId,
297
+ version,
144
298
  );
299
+ // `recovered` is a Uint8Array carrying the reconstructed secret payload.
300
+ ```
301
+
302
+ When driving the protocol layer instead of the primitives, the recovering
303
+ device receives a `SecretRecovered` event carrying the typed `secret`. Pass it
304
+ to `protocol.restore(secret, version)` on a fresh `DeRecProtocol` instance to
305
+ commit canonical helper / replica state and wipe the throwaway recovery-mode
306
+ channels — at that point the device resumes normal operation as if the secret
307
+ had been protected here originally.
145
308
 
146
- console.log(secret);
309
+ ```ts
310
+ const events = await protocol.process(responseBytes);
311
+ for (const ev of events) {
312
+ if (ev.type === "SecretRecovered") {
313
+ await freshProtocol.restore(ev.secret, recoveredVersion);
314
+ }
315
+ }
147
316
  ```
148
317
 
318
+ Errors surface as objects with a `code` field — `ALREADY_RESTORED`,
319
+ `CONFLICT` (with `channel_ids`), `INVARIANT`, or `STORAGE`.
320
+
149
321
  ---
150
322
 
151
323
  ## Verification Flow
152
324
 
153
325
  ```ts
154
- import * as derec from "@derec-alliance/nodejs";
155
-
156
- // Owner challenges helper
157
- const request = derec.generate_verification_request(
158
- new Uint8Array([1, 2, 3]),
159
- 1
160
- );
161
-
162
- // Helper proves it still holds the share
163
- const response = derec.generate_verification_response(
164
- new Uint8Array(), // share content
165
- request
326
+ import { primitives } from "@derec-alliance/nodejs";
327
+
328
+ // Owner side: produce the verification request.
329
+ const requestEnvelope = primitives.verification.request.produce(channelId, secretId, version, sharedKey);
330
+
331
+ // Helper side: decrypt and extract the challenge fields.
332
+ const req = primitives.verification.request.extract(requestEnvelope, sharedKey);
333
+ // req.channel_id, req.secret_id, req.version, req.nonce
334
+
335
+ // Helper side: produce the response.
336
+ const responseEnvelope = primitives.verification.response.produce(
337
+ channelId,
338
+ req.secret_id,
339
+ req.version,
340
+ req.nonce,
341
+ sharedKey,
342
+ storedShareEnvelope
166
343
  );
167
344
 
168
- const isValid = derec.verify_share_response(
169
- new Uint8Array(), // share content
170
- request,
171
- response
172
- );
345
+ // Owner side: verify the response.
346
+ const isValid = primitives.verification.response.process(responseEnvelope, sharedKey, storedShareEnvelope);
173
347
 
174
348
  console.log("Valid:", isValid);
175
349
  ```
@@ -185,23 +359,179 @@ console.log("Valid:", isValid);
185
359
 
186
360
  ---
187
361
 
362
+ ## Replica flows
363
+
364
+ Replicas mirror an Owner's secret onto a second device so the same secrets
365
+ remain reachable after device loss. Pairings are **unidirectional** — one
366
+ side runs as `SenderKind.ReplicaSource` (owns the secret), the other as
367
+ `SenderKind.ReplicaDestination` (receives it). Both must be constructed
368
+ with a stable `replicaId`:
369
+
370
+ ```ts
371
+ const owner = new DeRecProtocol(
372
+ channelStore, shareStore, secretStore, transport,
373
+ "https://owner.example.com", "https",
374
+ /* threshold */ 2, /* keepVersionsCount */ 3,
375
+ { name: "Owner" },
376
+ null, null, null, null,
377
+ /* replicaId */ 0xAAAA_AAAA_AAAA_AAAAn,
378
+ );
379
+ ```
380
+
381
+ A typical Source↔Destination handshake:
382
+
383
+ ```ts
384
+ const contact = await owner.createContact(channelId, ContactMode.InlineKeys);
385
+ await destination.start(FlowKind.Pairing, {
386
+ kind: SenderKind.ReplicaDestination,
387
+ contact,
388
+ });
389
+ // pump messages between the two protocols (drain transport → process)
390
+ ```
391
+
392
+ The channel ends up in `Pending` and is NOT eligible as a
393
+ `ProtectSecret` target until both sides confirm a deterministic
394
+ fingerprint derived from the shared key:
395
+
396
+ ```ts
397
+ const localFp = await owner.getFingerprint(channelId);
398
+ const peerFp = await destination.getFingerprint(channelId); // out of band
399
+
400
+ await owner.verifyFingerprint(channelId, peerFp); // → true
401
+ await destination.verifyFingerprint(channelId, localFp); // → true
402
+ ```
403
+
404
+ Once paired, the Source includes the Destination as a `ProtectSecret`
405
+ target alongside helpers. Helpers receive the usual VSS share via
406
+ `StoreShareRequest`; the Destination receives the full secret as a
407
+ typed `ReplicaSecretReceived` event:
408
+
409
+ ```ts
410
+ {
411
+ type: "ReplicaSecretReceived",
412
+ channel_id, from_replica_id, secret_id, version,
413
+ secret: {
414
+ helpers: [...], // every paired helper (channel_id, transport_uri, shared_key, ...)
415
+ secrets: [{ id, name, data }],
416
+ replicas: [...], // every paired destination (replica_id, sender_kind, ...)
417
+ owner_replica_id, // the Source's replica_id
418
+ },
419
+ shares: [{ channel_id, committed_share }, ...], // helper channel_id → share bytes
420
+ }
421
+ ```
422
+
423
+ `secret` + `shares` give the Destination everything it needs to act in the
424
+ Source's place during recovery.
425
+
426
+ End-to-end coverage lives in
427
+ [`runReplicaPairingAndSecretSyncFlow`](../../bindings/nodejs/protocol.ts).
428
+
429
+ ---
430
+
431
+ ## Correlation and routing
432
+
433
+ Two cross-cutting metadata fields appear on every channel-mode exchange:
434
+
435
+ - **`traceId`** — opaque `bigint` on the outer envelope, used to correlate
436
+ responses with requests. The `DeRecProtocol` orchestrator handles this
437
+ end-to-end (random token on every outbound request, echo on every
438
+ response). Primitive-only callers can manipulate it directly via
439
+ `envelope.apply_trace_id(bytes, traceId)` and `envelope.read_trace_id(bytes)`.
440
+ - **`replyTo`** — optional `TransportProtocol` on request bodies, telling
441
+ the responder to route this exchange's response to an alternate endpoint.
442
+ Set it per call (every `primitives.*.request.produce` takes a trailing
443
+ `reply_to` arg) or protocol-wide with the `autoReplyTo` constructor flag
444
+ on `DeRecProtocol` (stamps `replyTo = ownTransport` on every outbound
445
+ request). Excludes pairing and `UpdateChannelInfo`, which already carry
446
+ their own `transportProtocol` field.
447
+
448
+ The motivating case for `replyTo` is replicas: when Replica A sends a
449
+ request on a channel the helper paired with sibling Replica B, the
450
+ helper's stored peer endpoint points at B. `replyTo` lets A say "send the
451
+ response back to me," without rewriting channel state.
452
+
453
+ ---
454
+
188
455
  ## Package Contents
189
456
 
190
457
  ```text
191
458
  derec_library_bg.wasm
192
459
  derec_library.js
193
460
  derec_library.d.ts
461
+ index.js
462
+ index.d.ts
194
463
  ```
195
464
 
196
465
  - `.wasm` — compiled Rust core
197
- - `.js` — bindings
198
- - `.d.ts` — TypeScript definitions
466
+ - `derec_library.js` / `derec_library.d.ts` — raw wasm-bindgen bindings
467
+ - `index.js` / `index.d.ts` — `primitives.*` namespace assembly and TypeScript declarations
468
+
469
+ ---
470
+
471
+ ## Security considerations
472
+
473
+ ### Replica destinations inherit Source trust
474
+
475
+ `ReplicaSecretReceived.secret` carries the full secret, which
476
+ embeds every helper's `channel_id` and `shared_key`. Anyone holding the
477
+ secret can therefore authenticate as the Source toward every helper.
478
+ This is intentional — it is what makes Destination-driven recovery
479
+ work — but it means a compromised Destination can impersonate the
480
+ Source against every helper paired at the time the secret was sent.
481
+ Pick Destinations with at least the trust level of the Source device
482
+ itself; do not treat them as opaque backups.
483
+
484
+ All replicas of one `secret_id` also share a single **group channel
485
+ key**: every replica channel's `SharedKey` entry in the secret store
486
+ holds the same 32 bytes, established at the first replica pair and
487
+ handed to every subsequent joiner via the
488
+ `ReplicaSecretPayload.shared_key` field on its first sync round.
489
+ Compromise of any one Destination therefore exposes that single key;
490
+ the protocol does not provide per-pair forward secrecy across replicas.
491
+
492
+ ### `ContactMode.HashedKeys` requires an ephemeral transport URI
493
+
494
+ `HashedKeys` ships only a SHA-384 binding hash in the contact and
495
+ serves the actual public keys through a plaintext PrePair round-trip
496
+ on the contact creator's own transport. Any party that can reach that
497
+ URI before the legitimate scanner gets the keys. Use `HashedKeys` only
498
+ with a transport endpoint that is freshly minted for the pairing and
499
+ that you can retire as soon as the PrePair leg completes.
500
+ `ContactMode.InlineKeys` has no such constraint.
501
+
502
+ The recommended pattern is: pair on the ephemeral URI, then — as soon
503
+ as the pairing completes on the contact creator side — call
504
+ `setOwnTransport` with the permanent endpoint and start an
505
+ `UpdateChannelInfo` flow against the peer to announce the swap. Once
506
+ the peer acknowledges, retire the ephemeral URI. This keeps the
507
+ plaintext PrePair window tight while letting subsequent traffic ride
508
+ on the long-lived endpoint.
509
+
510
+ ### Replica fingerprint verification is mandatory
511
+
512
+ Replica channels are created with `status: "Pending"` and remain there
513
+ until both sides call `verifyFingerprint` with the value the peer
514
+ derived from the shared key — confirmed out of band. The orchestrator
515
+ enforces this: `start(FlowKind.ProtectSecret, ...)` throws when a
516
+ target is still `Pending`. Treat verification as a required step in
517
+ the pairing UX — a scanner that auto-pairs without it accepts a
518
+ MITM-vulnerable replica.
519
+
520
+ ### The `derec.*` namespace in `communicationInfo` is library-owned
521
+
522
+ `communicationInfo` is otherwise an opaque app-defined map, but every
523
+ key under the `derec.` prefix is reserved for the protocol. Today the
524
+ library owns `derec.replica_id`; future protocol additions will use
525
+ the same namespace. Application code must not write any `derec.*`
526
+ entry — the orchestrator silently overwrites or strips library-owned
527
+ keys at the protocol boundary, and app-set values are lost without
528
+ warning.
199
529
 
200
530
  ---
201
531
 
202
532
  ## Documentation
203
533
 
204
- - DeRec Alliance: https://derecalliance.org
534
+ - DeRec Alliance: https://derec.org
205
535
  - Protocol specification: https://derec-alliance.gitbook.io/docs/protocol-specification/protocol-overview
206
536
  - Rust SDK: https://github.com/derecalliance/lib-derec
207
537