@meshwhisper/sdk 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (163) hide show
  1. package/README.md +138 -0
  2. package/dist/browser/index.d.ts +4 -0
  3. package/dist/browser/index.d.ts.map +1 -0
  4. package/dist/browser/index.js +19 -0
  5. package/dist/browser/index.js.map +1 -0
  6. package/dist/chaff/index.d.ts +91 -0
  7. package/dist/chaff/index.d.ts.map +1 -0
  8. package/dist/chaff/index.js +268 -0
  9. package/dist/chaff/index.js.map +1 -0
  10. package/dist/cluster/index.d.ts +159 -0
  11. package/dist/cluster/index.d.ts.map +1 -0
  12. package/dist/cluster/index.js +393 -0
  13. package/dist/cluster/index.js.map +1 -0
  14. package/dist/compliance/index.d.ts +129 -0
  15. package/dist/compliance/index.d.ts.map +1 -0
  16. package/dist/compliance/index.js +315 -0
  17. package/dist/compliance/index.js.map +1 -0
  18. package/dist/crypto/index.d.ts +65 -0
  19. package/dist/crypto/index.d.ts.map +1 -0
  20. package/dist/crypto/index.js +146 -0
  21. package/dist/crypto/index.js.map +1 -0
  22. package/dist/group/index.d.ts +155 -0
  23. package/dist/group/index.d.ts.map +1 -0
  24. package/dist/group/index.js +560 -0
  25. package/dist/group/index.js.map +1 -0
  26. package/dist/index.d.ts +7 -0
  27. package/dist/index.d.ts.map +1 -0
  28. package/dist/index.js +11 -0
  29. package/dist/index.js.map +1 -0
  30. package/dist/namespace/index.d.ts +155 -0
  31. package/dist/namespace/index.d.ts.map +1 -0
  32. package/dist/namespace/index.js +278 -0
  33. package/dist/namespace/index.js.map +1 -0
  34. package/dist/node/index.d.ts +4 -0
  35. package/dist/node/index.d.ts.map +1 -0
  36. package/dist/node/index.js +19 -0
  37. package/dist/node/index.js.map +1 -0
  38. package/dist/packet/index.d.ts +63 -0
  39. package/dist/packet/index.d.ts.map +1 -0
  40. package/dist/packet/index.js +244 -0
  41. package/dist/packet/index.js.map +1 -0
  42. package/dist/permissions/index.d.ts +107 -0
  43. package/dist/permissions/index.d.ts.map +1 -0
  44. package/dist/permissions/index.js +282 -0
  45. package/dist/permissions/index.js.map +1 -0
  46. package/dist/persistence/idb-storage.d.ts +27 -0
  47. package/dist/persistence/idb-storage.d.ts.map +1 -0
  48. package/dist/persistence/idb-storage.js +75 -0
  49. package/dist/persistence/idb-storage.js.map +1 -0
  50. package/dist/persistence/index.d.ts +4 -0
  51. package/dist/persistence/index.d.ts.map +1 -0
  52. package/dist/persistence/index.js +3 -0
  53. package/dist/persistence/index.js.map +1 -0
  54. package/dist/persistence/node-storage.d.ts +33 -0
  55. package/dist/persistence/node-storage.d.ts.map +1 -0
  56. package/dist/persistence/node-storage.js +90 -0
  57. package/dist/persistence/node-storage.js.map +1 -0
  58. package/dist/persistence/serialization.d.ts +4 -0
  59. package/dist/persistence/serialization.d.ts.map +1 -0
  60. package/dist/persistence/serialization.js +49 -0
  61. package/dist/persistence/serialization.js.map +1 -0
  62. package/dist/persistence/types.d.ts +29 -0
  63. package/dist/persistence/types.d.ts.map +1 -0
  64. package/dist/persistence/types.js +5 -0
  65. package/dist/persistence/types.js.map +1 -0
  66. package/dist/ratchet/index.d.ts +80 -0
  67. package/dist/ratchet/index.d.ts.map +1 -0
  68. package/dist/ratchet/index.js +259 -0
  69. package/dist/ratchet/index.js.map +1 -0
  70. package/dist/reciprocity/index.d.ts +109 -0
  71. package/dist/reciprocity/index.d.ts.map +1 -0
  72. package/dist/reciprocity/index.js +311 -0
  73. package/dist/reciprocity/index.js.map +1 -0
  74. package/dist/relay/index.d.ts +87 -0
  75. package/dist/relay/index.d.ts.map +1 -0
  76. package/dist/relay/index.js +286 -0
  77. package/dist/relay/index.js.map +1 -0
  78. package/dist/routing/index.d.ts +136 -0
  79. package/dist/routing/index.d.ts.map +1 -0
  80. package/dist/routing/index.js +478 -0
  81. package/dist/routing/index.js.map +1 -0
  82. package/dist/sdk/index.d.ts +322 -0
  83. package/dist/sdk/index.d.ts.map +1 -0
  84. package/dist/sdk/index.js +1530 -0
  85. package/dist/sdk/index.js.map +1 -0
  86. package/dist/sybil/index.d.ts +123 -0
  87. package/dist/sybil/index.d.ts.map +1 -0
  88. package/dist/sybil/index.js +491 -0
  89. package/dist/sybil/index.js.map +1 -0
  90. package/dist/transport/browser/index.d.ts +34 -0
  91. package/dist/transport/browser/index.d.ts.map +1 -0
  92. package/dist/transport/browser/index.js +176 -0
  93. package/dist/transport/browser/index.js.map +1 -0
  94. package/dist/transport/local/index.d.ts +57 -0
  95. package/dist/transport/local/index.d.ts.map +1 -0
  96. package/dist/transport/local/index.js +442 -0
  97. package/dist/transport/local/index.js.map +1 -0
  98. package/dist/transport/negotiator/index.d.ts +79 -0
  99. package/dist/transport/negotiator/index.d.ts.map +1 -0
  100. package/dist/transport/negotiator/index.js +289 -0
  101. package/dist/transport/negotiator/index.js.map +1 -0
  102. package/dist/transport/node/index.d.ts +56 -0
  103. package/dist/transport/node/index.d.ts.map +1 -0
  104. package/dist/transport/node/index.js +209 -0
  105. package/dist/transport/node/index.js.map +1 -0
  106. package/dist/transport/noop/index.d.ts +11 -0
  107. package/dist/transport/noop/index.d.ts.map +1 -0
  108. package/dist/transport/noop/index.js +20 -0
  109. package/dist/transport/noop/index.js.map +1 -0
  110. package/dist/transport/p2p/index.d.ts +109 -0
  111. package/dist/transport/p2p/index.d.ts.map +1 -0
  112. package/dist/transport/p2p/index.js +237 -0
  113. package/dist/transport/p2p/index.js.map +1 -0
  114. package/dist/transport/websocket/index.d.ts +89 -0
  115. package/dist/transport/websocket/index.d.ts.map +1 -0
  116. package/dist/transport/websocket/index.js +498 -0
  117. package/dist/transport/websocket/index.js.map +1 -0
  118. package/dist/transport/websocket/serialize.d.ts +5 -0
  119. package/dist/transport/websocket/serialize.d.ts.map +1 -0
  120. package/dist/transport/websocket/serialize.js +55 -0
  121. package/dist/transport/websocket/serialize.js.map +1 -0
  122. package/dist/types.d.ts +215 -0
  123. package/dist/types.d.ts.map +1 -0
  124. package/dist/types.js +15 -0
  125. package/dist/types.js.map +1 -0
  126. package/dist/x3dh/index.d.ts +120 -0
  127. package/dist/x3dh/index.d.ts.map +1 -0
  128. package/dist/x3dh/index.js +290 -0
  129. package/dist/x3dh/index.js.map +1 -0
  130. package/package.json +59 -0
  131. package/src/browser/index.ts +19 -0
  132. package/src/chaff/index.ts +340 -0
  133. package/src/cluster/index.ts +482 -0
  134. package/src/compliance/index.ts +407 -0
  135. package/src/crypto/index.ts +193 -0
  136. package/src/group/index.ts +719 -0
  137. package/src/index.ts +87 -0
  138. package/src/lz4js.d.ts +58 -0
  139. package/src/namespace/index.ts +336 -0
  140. package/src/node/index.ts +19 -0
  141. package/src/packet/index.ts +326 -0
  142. package/src/permissions/index.ts +405 -0
  143. package/src/persistence/idb-storage.ts +83 -0
  144. package/src/persistence/index.ts +3 -0
  145. package/src/persistence/node-storage.ts +96 -0
  146. package/src/persistence/serialization.ts +75 -0
  147. package/src/persistence/types.ts +33 -0
  148. package/src/ratchet/index.ts +363 -0
  149. package/src/reciprocity/index.ts +371 -0
  150. package/src/relay/index.ts +382 -0
  151. package/src/routing/index.ts +577 -0
  152. package/src/sdk/index.ts +1994 -0
  153. package/src/sybil/index.ts +661 -0
  154. package/src/transport/browser/index.ts +201 -0
  155. package/src/transport/local/index.ts +540 -0
  156. package/src/transport/negotiator/index.ts +397 -0
  157. package/src/transport/node/index.ts +234 -0
  158. package/src/transport/noop/index.ts +22 -0
  159. package/src/transport/p2p/index.ts +345 -0
  160. package/src/transport/websocket/index.ts +660 -0
  161. package/src/transport/websocket/serialize.ts +68 -0
  162. package/src/types.ts +275 -0
  163. package/src/x3dh/index.ts +388 -0
@@ -0,0 +1,661 @@
1
+ // ============================================================
2
+ // MeshWhisper SDK — Sybil Resistance Module
3
+ // Proof of Physical Device (entropy challenges) and
4
+ // Zero-Knowledge Relay Reputation (hash-commitment proofs).
5
+ // ============================================================
6
+
7
+ import { blake3 } from '@noble/hashes/blake3';
8
+ import { randomBytes } from '@noble/hashes/utils';
9
+ import { ed25519 } from '@noble/curves/ed25519';
10
+ import type { EntropySensorType, EntropyChallenge, EntropyResponse } from '../types.js';
11
+
12
+ // ---- Exported Interfaces ----
13
+
14
+ export interface VerificationResult {
15
+ valid: boolean;
16
+ confidence: number; // 0.0–1.0
17
+ reasons: string[]; // explanation of pass/fail
18
+ }
19
+
20
+ export interface ReputationProof {
21
+ peerId: string; // can be pseudonymous
22
+ commitment: Uint8Array; // hash commitment to claims
23
+ proof: Uint8Array; // signed proof data
24
+ claims: {
25
+ minRelayCount: number;
26
+ periodDays: number;
27
+ minReciprocityScore: number;
28
+ };
29
+ timestamp: number;
30
+ }
31
+
32
+ // ---- Constants ----
33
+
34
+ /** Default challenge duration in milliseconds (3 seconds). */
35
+ const DEFAULT_CHALLENGE_DURATION_MS = 3_000;
36
+
37
+ /** Default minimum entropy samples expected for a valid response. */
38
+ const DEFAULT_MIN_ENTROPY_SAMPLES = 30;
39
+
40
+ /** Challenge ID length in bytes. */
41
+ const CHALLENGE_ID_LENGTH = 16;
42
+
43
+ /** Physical accelerometer range in m/s² (±20). */
44
+ const ACCEL_MAX = 20.0;
45
+
46
+ /** Expected gravity magnitude in m/s². */
47
+ const GRAVITY = 9.81;
48
+
49
+ /** Minimum acceptable standard deviation for accelerometer data. */
50
+ const MIN_STD_DEV = 0.01;
51
+
52
+ /** Maximum acceptable standard deviation for accelerometer data. */
53
+ const MAX_STD_DEV = 15.0;
54
+
55
+ /** Autocorrelation lag-1 lower bound for physical data. */
56
+ const MIN_AUTOCORRELATION = 0.05;
57
+
58
+ /** Maximum allowed autocorrelation — nearly perfect correlation indicates replay. */
59
+ const MAX_AUTOCORRELATION = 0.998;
60
+
61
+ /** Maximum allowed fraction of perfectly repeating consecutive blocks. */
62
+ const MAX_REPEAT_FRACTION = 0.5;
63
+
64
+ /** Reputation proof validity window in milliseconds (30 days). */
65
+ const PROOF_VALIDITY_MS = 30 * 24 * 60 * 60 * 1_000;
66
+
67
+ /** Nonce length for reputation commitments. */
68
+ const COMMITMENT_NONCE_LENGTH = 16;
69
+
70
+ // ---- Helpers ----
71
+
72
+ /** Concatenates multiple Uint8Arrays into one. */
73
+ function concat(...arrays: Uint8Array[]): Uint8Array {
74
+ let totalLength = 0;
75
+ for (const arr of arrays) totalLength += arr.length;
76
+ const result = new Uint8Array(totalLength);
77
+ let offset = 0;
78
+ for (const arr of arrays) {
79
+ result.set(arr, offset);
80
+ offset += arr.length;
81
+ }
82
+ return result;
83
+ }
84
+
85
+ /** Encodes a 64-bit number as 8-byte big-endian. */
86
+ function encodeUint64BE(value: number): Uint8Array {
87
+ const buf = new Uint8Array(8);
88
+ const view = new DataView(buf.buffer);
89
+ view.setUint32(0, Math.floor(value / 0x100000000), false);
90
+ view.setUint32(4, value >>> 0, false);
91
+ return buf;
92
+ }
93
+
94
+ /** Encodes a 32-bit number as 4-byte big-endian. */
95
+ function encodeUint32BE(value: number): Uint8Array {
96
+ const buf = new Uint8Array(4);
97
+ const view = new DataView(buf.buffer);
98
+ view.setUint32(0, value, false);
99
+ return buf;
100
+ }
101
+
102
+ /** Encodes a Float64 as 8-byte IEEE 754. */
103
+ function encodeFloat64(value: number): Uint8Array {
104
+ const buf = new Uint8Array(8);
105
+ const view = new DataView(buf.buffer);
106
+ view.setFloat64(0, value, false);
107
+ return buf;
108
+ }
109
+
110
+ /** Encodes a UTF-8 string to bytes. */
111
+ const textEncoder = new TextEncoder();
112
+ const textDecoder = new TextDecoder();
113
+
114
+ function encodeString(s: string): Uint8Array {
115
+ return textEncoder.encode(s);
116
+ }
117
+
118
+ // ---- Serialization helpers for challenges/responses ----
119
+
120
+ /**
121
+ * Sensor type to single-byte identifier.
122
+ */
123
+ function sensorTypeToByte(t: EntropySensorType): number {
124
+ switch (t) {
125
+ case 'accelerometer': return 0x01;
126
+ case 'gyroscope': return 0x02;
127
+ case 'magnetometer': return 0x03;
128
+ default: throw new Error(`Unknown sensor type: ${t}`);
129
+ }
130
+ }
131
+
132
+ function byteToSensorType(b: number): EntropySensorType {
133
+ switch (b) {
134
+ case 0x01: return 'accelerometer';
135
+ case 0x02: return 'gyroscope';
136
+ case 0x03: return 'magnetometer';
137
+ default: throw new Error(`Unknown sensor type byte: 0x${b.toString(16)}`);
138
+ }
139
+ }
140
+
141
+ // ============================================================
142
+ // EntropyChallenger — Proof of Physical Device
143
+ // ============================================================
144
+
145
+ export class EntropyChallenger {
146
+ private readonly challengeDurationMs: number;
147
+ private readonly minEntropySamples: number;
148
+
149
+ constructor(options?: {
150
+ challengeDurationMs?: number;
151
+ minEntropySamples?: number;
152
+ }) {
153
+ this.challengeDurationMs = options?.challengeDurationMs ?? DEFAULT_CHALLENGE_DURATION_MS;
154
+ this.minEntropySamples = options?.minEntropySamples ?? DEFAULT_MIN_ENTROPY_SAMPLES;
155
+ }
156
+
157
+ // ---- Challenge generation ----
158
+
159
+ /**
160
+ * Creates an entropy challenge with a random ID.
161
+ * @param sensorType - Type of sensor to challenge (default: accelerometer).
162
+ */
163
+ createChallenge(sensorType: EntropySensorType = 'accelerometer'): EntropyChallenge {
164
+ return {
165
+ challengeId: randomBytes(CHALLENGE_ID_LENGTH),
166
+ sensorType,
167
+ durationMs: this.challengeDurationMs,
168
+ timestamp: Date.now(),
169
+ };
170
+ }
171
+
172
+ /**
173
+ * Serializes an EntropyChallenge to a compact binary format.
174
+ *
175
+ * Layout:
176
+ * [16 bytes challengeId] [1 byte sensorType] [4 bytes durationMs] [8 bytes timestamp]
177
+ */
178
+ serializeChallenge(challenge: EntropyChallenge): Uint8Array {
179
+ return concat(
180
+ challenge.challengeId,
181
+ new Uint8Array([sensorTypeToByte(challenge.sensorType)]),
182
+ encodeUint32BE(challenge.durationMs),
183
+ encodeUint64BE(challenge.timestamp),
184
+ );
185
+ }
186
+
187
+ /**
188
+ * Deserializes a binary-encoded EntropyChallenge.
189
+ */
190
+ deserializeChallenge(data: Uint8Array): EntropyChallenge {
191
+ if (data.length < 29) {
192
+ throw new RangeError('Challenge data too short: expected at least 29 bytes');
193
+ }
194
+ const challengeId = data.slice(0, 16);
195
+ const sensorType = byteToSensorType(data[16]);
196
+ const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
197
+ const durationMs = view.getUint32(17, false);
198
+ const timestampHigh = view.getUint32(21, false);
199
+ const timestampLow = view.getUint32(25, false);
200
+ const timestamp = timestampHigh * 0x100000000 + timestampLow;
201
+
202
+ return { challengeId, sensorType, durationMs, timestamp };
203
+ }
204
+
205
+ // ---- Response handling ----
206
+
207
+ /**
208
+ * Creates an entropy response by signing the challenge ID concatenated
209
+ * with the raw sensor data.
210
+ *
211
+ * @param challenge - The challenge being responded to.
212
+ * @param sensorData - Raw sensor readings collected during the challenge window.
213
+ * @param signingKey - Ed25519 private key (32 bytes).
214
+ */
215
+ createResponse(
216
+ challenge: EntropyChallenge,
217
+ sensorData: Float64Array,
218
+ signingKey: Uint8Array,
219
+ ): EntropyResponse {
220
+ const dataBytes = new Uint8Array(sensorData.buffer, sensorData.byteOffset, sensorData.byteLength);
221
+ const payload = concat(challenge.challengeId, dataBytes);
222
+ const signature = ed25519.sign(payload, signingKey);
223
+
224
+ return {
225
+ challengeId: challenge.challengeId,
226
+ sensorData,
227
+ deviceSignature: signature,
228
+ };
229
+ }
230
+
231
+ /**
232
+ * Serializes an EntropyResponse to binary.
233
+ *
234
+ * Layout:
235
+ * [16 bytes challengeId] [4 bytes sampleCount] [sampleCount * 8 bytes float64 data] [64 bytes signature]
236
+ */
237
+ serializeResponse(response: EntropyResponse): Uint8Array {
238
+ const sampleCount = response.sensorData.length;
239
+ const dataBytes = new Uint8Array(
240
+ response.sensorData.buffer,
241
+ response.sensorData.byteOffset,
242
+ response.sensorData.byteLength,
243
+ );
244
+ return concat(
245
+ response.challengeId,
246
+ encodeUint32BE(sampleCount),
247
+ dataBytes,
248
+ response.deviceSignature,
249
+ );
250
+ }
251
+
252
+ /**
253
+ * Deserializes a binary-encoded EntropyResponse.
254
+ */
255
+ deserializeResponse(data: Uint8Array): EntropyResponse {
256
+ if (data.length < 84) {
257
+ throw new RangeError('Response data too short: expected at least 84 bytes');
258
+ }
259
+ const challengeId = data.slice(0, 16);
260
+ const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
261
+ const sampleCount = view.getUint32(16, false);
262
+
263
+ const expectedLength = 20 + sampleCount * 8 + 64;
264
+ if (data.length < expectedLength) {
265
+ throw new RangeError(
266
+ `Response data too short: expected ${expectedLength} bytes, got ${data.length}`,
267
+ );
268
+ }
269
+
270
+ const sensorDataBytes = data.slice(20, 20 + sampleCount * 8);
271
+ const sensorData = new Float64Array(
272
+ sensorDataBytes.buffer,
273
+ sensorDataBytes.byteOffset,
274
+ sampleCount,
275
+ );
276
+ const deviceSignature = data.slice(20 + sampleCount * 8, 20 + sampleCount * 8 + 64);
277
+
278
+ return { challengeId, sensorData, deviceSignature };
279
+ }
280
+
281
+ // ---- Verification ----
282
+
283
+ /**
284
+ * Verifies an entropy response against its challenge.
285
+ *
286
+ * Checks performed:
287
+ * 1. Ed25519 signature validity
288
+ * 2. Sufficient data points for the duration
289
+ * 3. Statistical analysis of sensor data
290
+ *
291
+ * @param challenge - The original challenge that was issued.
292
+ * @param response - The response to verify.
293
+ * @param publicKey - Ed25519 public key of the responding peer.
294
+ */
295
+ verifyResponse(
296
+ challenge: EntropyChallenge,
297
+ response: EntropyResponse,
298
+ publicKey: Uint8Array,
299
+ ): VerificationResult {
300
+ const reasons: string[] = [];
301
+ let confidence = 1.0;
302
+
303
+ // 1. Verify challenge ID matches
304
+ if (!uint8ArrayEqual(challenge.challengeId, response.challengeId)) {
305
+ return { valid: false, confidence: 0, reasons: ['Challenge ID mismatch'] };
306
+ }
307
+
308
+ // 2. Verify signature
309
+ const dataBytes = new Uint8Array(
310
+ response.sensorData.buffer,
311
+ response.sensorData.byteOffset,
312
+ response.sensorData.byteLength,
313
+ );
314
+ const payload = concat(challenge.challengeId, dataBytes);
315
+
316
+ let signatureValid: boolean;
317
+ try {
318
+ signatureValid = ed25519.verify(response.deviceSignature, payload, publicKey);
319
+ } catch {
320
+ signatureValid = false;
321
+ }
322
+
323
+ if (!signatureValid) {
324
+ return { valid: false, confidence: 0, reasons: ['Invalid Ed25519 signature'] };
325
+ }
326
+ reasons.push('Signature valid');
327
+
328
+ // 3. Sufficient data points
329
+ const samples = response.sensorData;
330
+ if (samples.length < this.minEntropySamples) {
331
+ return {
332
+ valid: false,
333
+ confidence: 0,
334
+ reasons: [
335
+ `Insufficient samples: got ${samples.length}, need >= ${this.minEntropySamples}`,
336
+ ],
337
+ };
338
+ }
339
+ reasons.push(`Sample count sufficient (${samples.length})`);
340
+
341
+ // 4. Statistical analysis
342
+ const stats = this.analyzeSensorData(samples, challenge.sensorType);
343
+
344
+ // 4a. Non-zero variance
345
+ if (stats.variance === 0) {
346
+ return {
347
+ valid: false,
348
+ confidence: 0,
349
+ reasons: ['Zero variance — constant values indicate synthetic data'],
350
+ };
351
+ }
352
+
353
+ // 4b. Values within physical sensor range
354
+ if (challenge.sensorType === 'accelerometer') {
355
+ if (stats.min < -ACCEL_MAX || stats.max > ACCEL_MAX) {
356
+ confidence -= 0.3;
357
+ reasons.push(
358
+ `Values outside physical range: [${stats.min.toFixed(2)}, ${stats.max.toFixed(2)}] vs ±${ACCEL_MAX} m/s²`,
359
+ );
360
+ } else {
361
+ reasons.push('Values within physical accelerometer range');
362
+ }
363
+ }
364
+
365
+ // 4c. Standard deviation check
366
+ if (stats.stdDev < MIN_STD_DEV) {
367
+ confidence -= 0.4;
368
+ reasons.push(
369
+ `Standard deviation too low (${stats.stdDev.toFixed(6)}) — likely static synthetic data`,
370
+ );
371
+ } else if (stats.stdDev > MAX_STD_DEV) {
372
+ confidence -= 0.3;
373
+ reasons.push(
374
+ `Standard deviation too high (${stats.stdDev.toFixed(4)}) — likely random noise`,
375
+ );
376
+ } else {
377
+ reasons.push(`Standard deviation within expected range (${stats.stdDev.toFixed(4)})`);
378
+ }
379
+
380
+ // 4d. Autocorrelation check (physical movement has temporal correlation)
381
+ if (stats.autocorrelation < MIN_AUTOCORRELATION) {
382
+ confidence -= 0.3;
383
+ reasons.push(
384
+ `Low autocorrelation (${stats.autocorrelation.toFixed(4)}) — data resembles white noise`,
385
+ );
386
+ } else if (stats.autocorrelation > MAX_AUTOCORRELATION) {
387
+ confidence -= 0.2;
388
+ reasons.push(
389
+ `Very high autocorrelation (${stats.autocorrelation.toFixed(4)}) — possible replay`,
390
+ );
391
+ } else {
392
+ reasons.push(`Autocorrelation consistent with physical movement (${stats.autocorrelation.toFixed(4)})`);
393
+ }
394
+
395
+ // 4e. Repeating pattern detection
396
+ if (stats.repeatFraction > MAX_REPEAT_FRACTION) {
397
+ confidence -= 0.3;
398
+ reasons.push(
399
+ `High pattern repetition (${(stats.repeatFraction * 100).toFixed(1)}%) — possible replay attack`,
400
+ );
401
+ } else {
402
+ reasons.push('No significant repeating patterns detected');
403
+ }
404
+
405
+ // Clamp confidence
406
+ confidence = Math.max(0, Math.min(1, confidence));
407
+
408
+ return {
409
+ valid: confidence > 0.3,
410
+ confidence,
411
+ reasons,
412
+ };
413
+ }
414
+
415
+ // ---- Internal statistical analysis ----
416
+
417
+ private analyzeSensorData(
418
+ data: Float64Array,
419
+ _sensorType: EntropySensorType,
420
+ ): SensorStats {
421
+ const n = data.length;
422
+
423
+ // Mean
424
+ let sum = 0;
425
+ for (let i = 0; i < n; i++) sum += data[i];
426
+ const mean = sum / n;
427
+
428
+ // Variance and std dev
429
+ let varianceSum = 0;
430
+ for (let i = 0; i < n; i++) {
431
+ const diff = data[i] - mean;
432
+ varianceSum += diff * diff;
433
+ }
434
+ const variance = varianceSum / n;
435
+ const stdDev = Math.sqrt(variance);
436
+
437
+ // Min and max
438
+ let min = data[0];
439
+ let max = data[0];
440
+ for (let i = 1; i < n; i++) {
441
+ if (data[i] < min) min = data[i];
442
+ if (data[i] > max) max = data[i];
443
+ }
444
+
445
+ // Lag-1 autocorrelation
446
+ let autocorrelation = 0;
447
+ if (n > 1 && variance > 0) {
448
+ let autoSum = 0;
449
+ for (let i = 1; i < n; i++) {
450
+ autoSum += (data[i] - mean) * (data[i - 1] - mean);
451
+ }
452
+ autocorrelation = autoSum / ((n - 1) * variance);
453
+ }
454
+
455
+ // Repeating pattern detection: check consecutive blocks of size 3
456
+ const blockSize = 3;
457
+ let repeatCount = 0;
458
+ let totalBlocks = 0;
459
+ if (n >= blockSize * 2) {
460
+ for (let i = blockSize; i <= n - blockSize; i += blockSize) {
461
+ totalBlocks++;
462
+ let isRepeat = true;
463
+ for (let j = 0; j < blockSize; j++) {
464
+ if (data[i + j] !== data[i - blockSize + j]) {
465
+ isRepeat = false;
466
+ break;
467
+ }
468
+ }
469
+ if (isRepeat) repeatCount++;
470
+ }
471
+ }
472
+ const repeatFraction = totalBlocks > 0 ? repeatCount / totalBlocks : 0;
473
+
474
+ return { mean, variance, stdDev, min, max, autocorrelation, repeatFraction };
475
+ }
476
+ }
477
+
478
+ interface SensorStats {
479
+ mean: number;
480
+ variance: number;
481
+ stdDev: number;
482
+ min: number;
483
+ max: number;
484
+ autocorrelation: number;
485
+ repeatFraction: number;
486
+ }
487
+
488
+ /** Constant-time-ish comparison of two Uint8Arrays. */
489
+ function uint8ArrayEqual(a: Uint8Array, b: Uint8Array): boolean {
490
+ if (a.length !== b.length) return false;
491
+ let diff = 0;
492
+ for (let i = 0; i < a.length; i++) {
493
+ diff |= a[i] ^ b[i];
494
+ }
495
+ return diff === 0;
496
+ }
497
+
498
+ // ============================================================
499
+ // ZKRelayReputation — Zero-Knowledge Relay Reputation
500
+ // ============================================================
501
+
502
+ export class ZKRelayReputation {
503
+ private readonly localPeerId: string;
504
+
505
+ constructor(localPeerId: string) {
506
+ this.localPeerId = localPeerId;
507
+ }
508
+
509
+ // ---- Proof generation ----
510
+
511
+ /**
512
+ * Generates a simplified ZK reputation proof using hash commitments + Ed25519 signatures.
513
+ *
514
+ * The commitment is BLAKE3(relayCount || periodDays || reciprocityScore || nonce),
515
+ * and the proof is an Ed25519 signature over (commitment || claims).
516
+ *
517
+ * @param relayCount - Number of blobs relayed in the period.
518
+ * @param periodDays - Duration of the relay period in days.
519
+ * @param reciprocityScore - Reciprocity score (0.0–1.0).
520
+ * @param signingKey - Ed25519 private key (32 bytes).
521
+ */
522
+ generateProof(
523
+ relayCount: number,
524
+ periodDays: number,
525
+ reciprocityScore: number,
526
+ signingKey: Uint8Array,
527
+ ): ReputationProof {
528
+ const nonce = randomBytes(COMMITMENT_NONCE_LENGTH);
529
+ const timestamp = Date.now();
530
+
531
+ // Build commitment: BLAKE3(relayCount || periodDays || reciprocityScore || nonce)
532
+ const commitmentInput = concat(
533
+ encodeUint32BE(relayCount),
534
+ encodeUint32BE(periodDays),
535
+ encodeFloat64(reciprocityScore),
536
+ nonce,
537
+ );
538
+ const commitment = blake3(commitmentInput);
539
+
540
+ // Build claims payload for signing
541
+ const claims = {
542
+ minRelayCount: relayCount,
543
+ periodDays,
544
+ minReciprocityScore: reciprocityScore,
545
+ };
546
+
547
+ const claimsBytes = encodeString(JSON.stringify(claims));
548
+ const timestampBytes = encodeUint64BE(timestamp);
549
+ const peerIdBytes = encodeString(this.localPeerId);
550
+
551
+ // Sign: commitment || claims || timestamp || peerId
552
+ const signPayload = concat(commitment, claimsBytes, timestampBytes, peerIdBytes);
553
+ const proof = ed25519.sign(signPayload, signingKey);
554
+
555
+ return {
556
+ peerId: this.localPeerId,
557
+ commitment,
558
+ proof,
559
+ claims,
560
+ timestamp,
561
+ };
562
+ }
563
+
564
+ // ---- Proof verification ----
565
+
566
+ /**
567
+ * Verifies a reputation proof's Ed25519 signature.
568
+ *
569
+ * Note: this verifies the proof was signed by the holder of the corresponding
570
+ * private key. It does NOT verify the actual relay behavior — that requires
571
+ * on-chain or gossip-protocol corroboration.
572
+ *
573
+ * @param proof - The reputation proof to verify.
574
+ * @param publicKey - Ed25519 public key of the prover.
575
+ */
576
+ verifyProof(proof: ReputationProof, publicKey: Uint8Array): boolean {
577
+ // Check proof is not expired
578
+ const age = Date.now() - proof.timestamp;
579
+ if (age > PROOF_VALIDITY_MS || age < 0) {
580
+ return false;
581
+ }
582
+
583
+ const claimsBytes = encodeString(JSON.stringify(proof.claims));
584
+ const timestampBytes = encodeUint64BE(proof.timestamp);
585
+ const peerIdBytes = encodeString(proof.peerId);
586
+
587
+ const signPayload = concat(proof.commitment, claimsBytes, timestampBytes, peerIdBytes);
588
+
589
+ try {
590
+ return ed25519.verify(proof.proof, signPayload, publicKey);
591
+ } catch {
592
+ return false;
593
+ }
594
+ }
595
+
596
+ // ---- Reputation scoring ----
597
+
598
+ /**
599
+ * Aggregates multiple verified reputation proofs into a single 0.0–1.0 score.
600
+ *
601
+ * Scoring factors:
602
+ * - Number of valid proofs (more proofs = more trust)
603
+ * - Relay counts (higher is better, with diminishing returns)
604
+ * - Reciprocity scores (weighted average)
605
+ * - Recency (more recent proofs weigh more)
606
+ *
607
+ * @param proofs - Array of reputation proofs (assumed already signature-verified).
608
+ */
609
+ getReputationScore(proofs: ReputationProof[]): number {
610
+ if (proofs.length === 0) return 0;
611
+
612
+ const now = Date.now();
613
+ let weightedRelayScore = 0;
614
+ let weightedReciprocityScore = 0;
615
+ let totalWeight = 0;
616
+
617
+ for (const proof of proofs) {
618
+ // Recency weight: exponential decay over 30 days
619
+ const ageDays = (now - proof.timestamp) / (24 * 60 * 60 * 1_000);
620
+ const recencyWeight = Math.exp(-ageDays / 30);
621
+
622
+ // Relay score: logarithmic scaling with diminishing returns
623
+ // log2(relayCount + 1) / log2(1001) gives ~1.0 at 1000 relays
624
+ const relayScore = Math.min(
625
+ 1.0,
626
+ Math.log2(proof.claims.minRelayCount + 1) / Math.log2(1001),
627
+ );
628
+
629
+ weightedRelayScore += relayScore * recencyWeight;
630
+ weightedReciprocityScore += proof.claims.minReciprocityScore * recencyWeight;
631
+ totalWeight += recencyWeight;
632
+ }
633
+
634
+ if (totalWeight === 0) return 0;
635
+
636
+ const avgRelayScore = weightedRelayScore / totalWeight;
637
+ const avgReciprocityScore = weightedReciprocityScore / totalWeight;
638
+
639
+ // Proof volume bonus: more proofs = more trust (capped contribution)
640
+ const volumeBonus = Math.min(0.2, proofs.length * 0.04);
641
+
642
+ // Combine: 40% relay score + 40% reciprocity + 20% volume bonus
643
+ const rawScore = avgRelayScore * 0.4 + avgReciprocityScore * 0.4 + volumeBonus;
644
+
645
+ return Math.max(0, Math.min(1, rawScore));
646
+ }
647
+
648
+ /**
649
+ * Checks whether a proof meets minimum relay count and reciprocity thresholds.
650
+ *
651
+ * @param proof - The reputation proof to check.
652
+ * @param minRelays - Minimum required relay count.
653
+ * @param minScore - Minimum required reciprocity score.
654
+ */
655
+ meetsThreshold(proof: ReputationProof, minRelays: number, minScore: number): boolean {
656
+ return (
657
+ proof.claims.minRelayCount >= minRelays &&
658
+ proof.claims.minReciprocityScore >= minScore
659
+ );
660
+ }
661
+ }