@derec-alliance/nodejs 0.0.1-alpha.6 → 0.0.1-alpha.9
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 +408 -71
- package/derec_library.d.ts +369 -221
- package/derec_library.js +1376 -400
- package/derec_library_bg.wasm +0 -0
- package/derec_library_bg.wasm.d.ts +77 -11
- package/index.d.ts +1214 -0
- package/index.js +106 -0
- package/package.json +6 -4
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
|
|
56
|
+
import { primitives } from "@derec-alliance/nodejs";
|
|
57
57
|
|
|
58
|
-
const
|
|
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
|
-
|
|
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
|
|
90
|
+
import { ContactMode, primitives, SenderKind } from "@derec-alliance/nodejs";
|
|
91
|
+
|
|
92
|
+
const channelId = 1n;
|
|
69
93
|
|
|
70
|
-
// Step 1:
|
|
71
|
-
const contact =
|
|
72
|
-
|
|
73
|
-
|
|
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:
|
|
77
|
-
const request =
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
contact.
|
|
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:
|
|
84
|
-
const
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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:
|
|
91
|
-
const
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
request.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
106
|
-
|
|
107
|
-
const
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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,
|
|
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,
|
|
113
171
|
);
|
|
172
|
+
const { response: prePairResp } =
|
|
173
|
+
primitives.pairing.response.extract_pre_pair(prePairRespEnv.envelope);
|
|
114
174
|
|
|
115
|
-
|
|
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
|
+
);
|
|
116
180
|
|
|
117
|
-
|
|
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
|
+
---
|
|
232
|
+
|
|
233
|
+
## Share Distribution (Sharing Flow)
|
|
234
|
+
|
|
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,93 @@ console.log(shareMessages);
|
|
|
122
264
|
## Recovery Flow
|
|
123
265
|
|
|
124
266
|
```ts
|
|
125
|
-
import
|
|
267
|
+
import { primitives } from "@derec-alliance/nodejs";
|
|
126
268
|
|
|
127
|
-
|
|
128
|
-
const
|
|
129
|
-
|
|
130
|
-
|
|
269
|
+
const secretId = 42n; // u64
|
|
270
|
+
const version = 1; // u32
|
|
271
|
+
|
|
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
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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
|
|
140
|
-
const
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
+
```
|
|
145
301
|
|
|
146
|
-
|
|
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.
|
|
308
|
+
|
|
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
|
+
|
|
321
|
+
> **Secret format:** the recoverable secret (the bytes helpers store and
|
|
322
|
+
> recovery reconstructs) is `[version byte] · payload` — v1's payload is
|
|
323
|
+
> **gzip (RFC 1952)** compressed **JSON**, with byte fields as **standard
|
|
324
|
+
> base64 with padding (RFC 4648 §4)** and `u64` fields as decimal strings.
|
|
325
|
+
> See the `derec-library` `protocol::types::secret` reference documentation
|
|
326
|
+
> for the full field schema.
|
|
327
|
+
|
|
149
328
|
---
|
|
150
329
|
|
|
151
330
|
## Verification Flow
|
|
152
331
|
|
|
153
332
|
```ts
|
|
154
|
-
import
|
|
155
|
-
|
|
156
|
-
// Owner
|
|
157
|
-
const
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
);
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
333
|
+
import { primitives } from "@derec-alliance/nodejs";
|
|
334
|
+
|
|
335
|
+
// Owner side: produce the verification request.
|
|
336
|
+
const requestEnvelope = primitives.verification.request.produce(channelId, secretId, version, sharedKey);
|
|
337
|
+
|
|
338
|
+
// Helper side: decrypt and extract the challenge fields.
|
|
339
|
+
const req = primitives.verification.request.extract(requestEnvelope, sharedKey);
|
|
340
|
+
// req.channel_id, req.secret_id, req.version, req.nonce
|
|
341
|
+
|
|
342
|
+
// Helper side: produce the response.
|
|
343
|
+
const responseEnvelope = primitives.verification.response.produce(
|
|
344
|
+
channelId,
|
|
345
|
+
req.secret_id,
|
|
346
|
+
req.version,
|
|
347
|
+
req.nonce,
|
|
348
|
+
sharedKey,
|
|
349
|
+
storedShareEnvelope
|
|
166
350
|
);
|
|
167
351
|
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
request,
|
|
171
|
-
response
|
|
172
|
-
);
|
|
352
|
+
// Owner side: verify the response.
|
|
353
|
+
const isValid = primitives.verification.response.process(responseEnvelope, sharedKey, storedShareEnvelope);
|
|
173
354
|
|
|
174
355
|
console.log("Valid:", isValid);
|
|
175
356
|
```
|
|
@@ -185,23 +366,179 @@ console.log("Valid:", isValid);
|
|
|
185
366
|
|
|
186
367
|
---
|
|
187
368
|
|
|
369
|
+
## Replica flows
|
|
370
|
+
|
|
371
|
+
Replicas mirror an Owner's secret onto a second device so the same secrets
|
|
372
|
+
remain reachable after device loss. Pairings are **unidirectional** — one
|
|
373
|
+
side runs as `SenderKind.ReplicaSource` (owns the secret), the other as
|
|
374
|
+
`SenderKind.ReplicaDestination` (receives it). Both must be constructed
|
|
375
|
+
with a stable `replicaId`:
|
|
376
|
+
|
|
377
|
+
```ts
|
|
378
|
+
const owner = new DeRecProtocol(
|
|
379
|
+
channelStore, shareStore, secretStore, transport,
|
|
380
|
+
"https://owner.example.com", "https",
|
|
381
|
+
/* threshold */ 2, /* keepVersionsCount */ 3,
|
|
382
|
+
{ name: "Owner" },
|
|
383
|
+
null, null, null, null,
|
|
384
|
+
/* replicaId */ 0xAAAA_AAAA_AAAA_AAAAn,
|
|
385
|
+
);
|
|
386
|
+
```
|
|
387
|
+
|
|
388
|
+
A typical Source↔Destination handshake:
|
|
389
|
+
|
|
390
|
+
```ts
|
|
391
|
+
const contact = await owner.createContact(channelId, ContactMode.InlineKeys);
|
|
392
|
+
await destination.start(FlowKind.Pairing, {
|
|
393
|
+
kind: SenderKind.ReplicaDestination,
|
|
394
|
+
contact,
|
|
395
|
+
});
|
|
396
|
+
// pump messages between the two protocols (drain transport → process)
|
|
397
|
+
```
|
|
398
|
+
|
|
399
|
+
The channel ends up in `Pending` and is NOT eligible as a
|
|
400
|
+
`ProtectSecret` target until both sides confirm a deterministic
|
|
401
|
+
fingerprint derived from the shared key:
|
|
402
|
+
|
|
403
|
+
```ts
|
|
404
|
+
const localFp = await owner.getFingerprint(channelId);
|
|
405
|
+
const peerFp = await destination.getFingerprint(channelId); // out of band
|
|
406
|
+
|
|
407
|
+
await owner.verifyFingerprint(channelId, peerFp); // → true
|
|
408
|
+
await destination.verifyFingerprint(channelId, localFp); // → true
|
|
409
|
+
```
|
|
410
|
+
|
|
411
|
+
Once paired, the Source includes the Destination as a `ProtectSecret`
|
|
412
|
+
target alongside helpers. Helpers receive the usual VSS share via
|
|
413
|
+
`StoreShareRequest`; the Destination receives the full secret as a
|
|
414
|
+
typed `ReplicaSecretReceived` event:
|
|
415
|
+
|
|
416
|
+
```ts
|
|
417
|
+
{
|
|
418
|
+
type: "ReplicaSecretReceived",
|
|
419
|
+
channel_id, from_replica_id, secret_id, version,
|
|
420
|
+
secret: {
|
|
421
|
+
helpers: [...], // every paired helper (channel_id, transport_uri, shared_key, ...)
|
|
422
|
+
secrets: [{ id, name, data }],
|
|
423
|
+
replicas: [...], // every paired destination (replica_id, sender_kind, ...)
|
|
424
|
+
owner_replica_id, // the Source's replica_id
|
|
425
|
+
},
|
|
426
|
+
shares: [{ channel_id, committed_share }, ...], // helper channel_id → share bytes
|
|
427
|
+
}
|
|
428
|
+
```
|
|
429
|
+
|
|
430
|
+
`secret` + `shares` give the Destination everything it needs to act in the
|
|
431
|
+
Source's place during recovery.
|
|
432
|
+
|
|
433
|
+
End-to-end coverage lives in
|
|
434
|
+
[`runReplicaPairingAndSecretSyncFlow`](../../bindings/nodejs/protocol.ts).
|
|
435
|
+
|
|
436
|
+
---
|
|
437
|
+
|
|
438
|
+
## Correlation and routing
|
|
439
|
+
|
|
440
|
+
Two cross-cutting metadata fields appear on every channel-mode exchange:
|
|
441
|
+
|
|
442
|
+
- **`traceId`** — opaque `bigint` on the outer envelope, used to correlate
|
|
443
|
+
responses with requests. The `DeRecProtocol` orchestrator handles this
|
|
444
|
+
end-to-end (random token on every outbound request, echo on every
|
|
445
|
+
response). Primitive-only callers can manipulate it directly via
|
|
446
|
+
`envelope.apply_trace_id(bytes, traceId)` and `envelope.read_trace_id(bytes)`.
|
|
447
|
+
- **`replyTo`** — optional `TransportProtocol` on request bodies, telling
|
|
448
|
+
the responder to route this exchange's response to an alternate endpoint.
|
|
449
|
+
Set it per call (every `primitives.*.request.produce` takes a trailing
|
|
450
|
+
`reply_to` arg) or protocol-wide with the `autoReplyTo` constructor flag
|
|
451
|
+
on `DeRecProtocol` (stamps `replyTo = ownTransport` on every outbound
|
|
452
|
+
request). Excludes pairing and `UpdateChannelInfo`, which already carry
|
|
453
|
+
their own `transportProtocol` field.
|
|
454
|
+
|
|
455
|
+
The motivating case for `replyTo` is replicas: when Replica A sends a
|
|
456
|
+
request on a channel the helper paired with sibling Replica B, the
|
|
457
|
+
helper's stored peer endpoint points at B. `replyTo` lets A say "send the
|
|
458
|
+
response back to me," without rewriting channel state.
|
|
459
|
+
|
|
460
|
+
---
|
|
461
|
+
|
|
188
462
|
## Package Contents
|
|
189
463
|
|
|
190
464
|
```text
|
|
191
465
|
derec_library_bg.wasm
|
|
192
466
|
derec_library.js
|
|
193
467
|
derec_library.d.ts
|
|
468
|
+
index.js
|
|
469
|
+
index.d.ts
|
|
194
470
|
```
|
|
195
471
|
|
|
196
472
|
- `.wasm` — compiled Rust core
|
|
197
|
-
-
|
|
198
|
-
-
|
|
473
|
+
- `derec_library.js` / `derec_library.d.ts` — raw wasm-bindgen bindings
|
|
474
|
+
- `index.js` / `index.d.ts` — `primitives.*` namespace assembly and TypeScript declarations
|
|
475
|
+
|
|
476
|
+
---
|
|
477
|
+
|
|
478
|
+
## Security considerations
|
|
479
|
+
|
|
480
|
+
### Replica destinations inherit Source trust
|
|
481
|
+
|
|
482
|
+
`ReplicaSecretReceived.secret` carries the full secret, which
|
|
483
|
+
embeds every helper's `channel_id` and `shared_key`. Anyone holding the
|
|
484
|
+
secret can therefore authenticate as the Source toward every helper.
|
|
485
|
+
This is intentional — it is what makes Destination-driven recovery
|
|
486
|
+
work — but it means a compromised Destination can impersonate the
|
|
487
|
+
Source against every helper paired at the time the secret was sent.
|
|
488
|
+
Pick Destinations with at least the trust level of the Source device
|
|
489
|
+
itself; do not treat them as opaque backups.
|
|
490
|
+
|
|
491
|
+
All replicas of one `secret_id` also share a single **group channel
|
|
492
|
+
key**: every replica channel's `SharedKey` entry in the secret store
|
|
493
|
+
holds the same 32 bytes, established at the first replica pair and
|
|
494
|
+
handed to every subsequent joiner via the
|
|
495
|
+
`ReplicaSecretPayload.shared_key` field on its first sync round.
|
|
496
|
+
Compromise of any one Destination therefore exposes that single key;
|
|
497
|
+
the protocol does not provide per-pair forward secrecy across replicas.
|
|
498
|
+
|
|
499
|
+
### `ContactMode.HashedKeys` requires an ephemeral transport URI
|
|
500
|
+
|
|
501
|
+
`HashedKeys` ships only a SHA-384 binding hash in the contact and
|
|
502
|
+
serves the actual public keys through a plaintext PrePair round-trip
|
|
503
|
+
on the contact creator's own transport. Any party that can reach that
|
|
504
|
+
URI before the legitimate scanner gets the keys. Use `HashedKeys` only
|
|
505
|
+
with a transport endpoint that is freshly minted for the pairing and
|
|
506
|
+
that you can retire as soon as the PrePair leg completes.
|
|
507
|
+
`ContactMode.InlineKeys` has no such constraint.
|
|
508
|
+
|
|
509
|
+
The recommended pattern is: pair on the ephemeral URI, then — as soon
|
|
510
|
+
as the pairing completes on the contact creator side — call
|
|
511
|
+
`setOwnTransport` with the permanent endpoint and start an
|
|
512
|
+
`UpdateChannelInfo` flow against the peer to announce the swap. Once
|
|
513
|
+
the peer acknowledges, retire the ephemeral URI. This keeps the
|
|
514
|
+
plaintext PrePair window tight while letting subsequent traffic ride
|
|
515
|
+
on the long-lived endpoint.
|
|
516
|
+
|
|
517
|
+
### Replica fingerprint verification is mandatory
|
|
518
|
+
|
|
519
|
+
Replica channels are created with `status: "Pending"` and remain there
|
|
520
|
+
until both sides call `verifyFingerprint` with the value the peer
|
|
521
|
+
derived from the shared key — confirmed out of band. The orchestrator
|
|
522
|
+
enforces this: `start(FlowKind.ProtectSecret, ...)` throws when a
|
|
523
|
+
target is still `Pending`. Treat verification as a required step in
|
|
524
|
+
the pairing UX — a scanner that auto-pairs without it accepts a
|
|
525
|
+
MITM-vulnerable replica.
|
|
526
|
+
|
|
527
|
+
### The `derec.*` namespace in `communicationInfo` is library-owned
|
|
528
|
+
|
|
529
|
+
`communicationInfo` is otherwise an opaque app-defined map, but every
|
|
530
|
+
key under the `derec.` prefix is reserved for the protocol. Today the
|
|
531
|
+
library owns `derec.replica_id`; future protocol additions will use
|
|
532
|
+
the same namespace. Application code must not write any `derec.*`
|
|
533
|
+
entry — the orchestrator silently overwrites or strips library-owned
|
|
534
|
+
keys at the protocol boundary, and app-set values are lost without
|
|
535
|
+
warning.
|
|
199
536
|
|
|
200
537
|
---
|
|
201
538
|
|
|
202
539
|
## Documentation
|
|
203
540
|
|
|
204
|
-
- DeRec Alliance: https://
|
|
541
|
+
- DeRec Alliance: https://derec.org
|
|
205
542
|
- Protocol specification: https://derec-alliance.gitbook.io/docs/protocol-specification/protocol-overview
|
|
206
543
|
- Rust SDK: https://github.com/derecalliance/lib-derec
|
|
207
544
|
|