@sip-protocol/sdk 0.4.0 → 0.5.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.
@@ -0,0 +1,529 @@
1
+ /**
2
+ * Threshold Viewing Keys using Shamir's Secret Sharing
3
+ *
4
+ * Enables N-of-M viewing key disclosure for multi-party authorization.
5
+ * Use case: Multiple board members must agree to reveal transaction details.
6
+ *
7
+ * ## Security Properties
8
+ * - Information-theoretic security (< N shares reveal nothing)
9
+ * - Share verification without revealing the secret
10
+ * - Cryptographically binding commitments
11
+ *
12
+ * ## Implementation Details
13
+ * - Uses prime field arithmetic (GF(p) where p = 2^256 - 189)
14
+ * - Polynomial interpolation via Lagrange coefficients
15
+ * - SHA-256 commitments for share verification
16
+ *
17
+ * @example
18
+ * ```typescript
19
+ * import { ThresholdViewingKey, generateViewingKey } from '@sip-protocol/sdk'
20
+ *
21
+ * // Generate a viewing key
22
+ * const viewingKey = generateViewingKey()
23
+ *
24
+ * // Create 3-of-5 threshold shares
25
+ * const threshold = ThresholdViewingKey.create({
26
+ * threshold: 3,
27
+ * totalShares: 5,
28
+ * viewingKey: viewingKey.key,
29
+ * })
30
+ *
31
+ * // Distribute shares to board members
32
+ * console.log(threshold.shares) // ['share1', 'share2', ...]
33
+ *
34
+ * // Verify a share without revealing the key
35
+ * const isValid = ThresholdViewingKey.verifyShare(
36
+ * threshold.shares[0],
37
+ * threshold.commitment
38
+ * )
39
+ *
40
+ * // Reconstruct with 3 shares
41
+ * const reconstructed = ThresholdViewingKey.reconstruct([
42
+ * threshold.shares[0],
43
+ * threshold.shares[2],
44
+ * threshold.shares[4],
45
+ * ])
46
+ * ```
47
+ *
48
+ * @module compliance/threshold
49
+ */
50
+
51
+ import { sha256 } from '@noble/hashes/sha256'
52
+ import { bytesToHex, hexToBytes, randomBytes } from '@noble/hashes/utils'
53
+ import type { HexString } from '@sip-protocol/types'
54
+ import { ValidationError, CryptoError, ErrorCode } from '../errors'
55
+
56
+ /**
57
+ * Large prime for finite field arithmetic (2^256 - 189)
58
+ * This is a safe prime suitable for cryptographic operations
59
+ */
60
+ const FIELD_PRIME = 2n ** 256n - 189n
61
+
62
+ /**
63
+ * Threshold shares configuration
64
+ */
65
+ export interface ThresholdShares {
66
+ /** Array of encoded shares */
67
+ shares: string[]
68
+ /** Commitment hash for share verification */
69
+ commitment: string
70
+ /** Threshold (N) - minimum shares needed */
71
+ threshold: number
72
+ /** Total shares (M) */
73
+ totalShares: number
74
+ }
75
+
76
+ /**
77
+ * Encoded share format: "x:y:len:commitment"
78
+ * where len is the original viewing key length (without 0x prefix)
79
+ */
80
+ interface DecodedShare {
81
+ x: bigint // Share index (1-based)
82
+ y: bigint // Share value
83
+ keyLength: number // Original viewing key hex length (without 0x)
84
+ commitment: string // Commitment hash
85
+ }
86
+
87
+ /**
88
+ * Threshold Viewing Key implementation using Shamir's Secret Sharing
89
+ *
90
+ * Allows splitting a viewing key into N-of-M shares where any N shares
91
+ * can reconstruct the original key, but fewer than N shares reveal nothing.
92
+ */
93
+ export class ThresholdViewingKey {
94
+ /**
95
+ * Create threshold shares from a viewing key
96
+ *
97
+ * @param params - Configuration parameters
98
+ * @returns Threshold shares with commitment
99
+ * @throws ValidationError if parameters are invalid
100
+ *
101
+ * @example
102
+ * ```typescript
103
+ * const threshold = ThresholdViewingKey.create({
104
+ * threshold: 3,
105
+ * totalShares: 5,
106
+ * viewingKey: '0xabc123...',
107
+ * })
108
+ * ```
109
+ */
110
+ static create(params: {
111
+ threshold: number
112
+ totalShares: number
113
+ viewingKey: HexString
114
+ }): ThresholdShares {
115
+ // Validate parameters
116
+ this.validateParams(params.threshold, params.totalShares)
117
+ this.validateViewingKey(params.viewingKey)
118
+
119
+ // Convert viewing key to secret (bigint)
120
+ const secret = this.viewingKeyToSecret(params.viewingKey)
121
+
122
+ // Store original key length (without 0x prefix)
123
+ const keyLength = params.viewingKey.slice(2).length
124
+
125
+ // Generate random polynomial coefficients
126
+ const coefficients = this.generateCoefficients(params.threshold, secret)
127
+
128
+ // Create commitment hash
129
+ const commitment = this.createCommitment(secret, coefficients)
130
+
131
+ // Generate shares by evaluating polynomial at different points
132
+ const shares: string[] = []
133
+ for (let i = 1; i <= params.totalShares; i++) {
134
+ const x = BigInt(i)
135
+ const y = this.evaluatePolynomial(coefficients, x)
136
+ shares.push(this.encodeShare(x, y, keyLength, commitment))
137
+ }
138
+
139
+ return {
140
+ shares,
141
+ commitment,
142
+ threshold: params.threshold,
143
+ totalShares: params.totalShares,
144
+ }
145
+ }
146
+
147
+ /**
148
+ * Reconstruct viewing key from threshold shares
149
+ *
150
+ * @param shares - Array of encoded shares (must be >= threshold)
151
+ * @returns Reconstructed viewing key
152
+ * @throws ValidationError if insufficient or invalid shares
153
+ *
154
+ * @example
155
+ * ```typescript
156
+ * const viewingKey = ThresholdViewingKey.reconstruct([
157
+ * 'share1',
158
+ * 'share2',
159
+ * 'share3',
160
+ * ])
161
+ * ```
162
+ */
163
+ static reconstruct(shares: string[]): HexString {
164
+ // Validate shares
165
+ if (!shares || shares.length === 0) {
166
+ throw new ValidationError(
167
+ 'at least one share is required',
168
+ 'shares',
169
+ { received: shares },
170
+ ErrorCode.MISSING_REQUIRED
171
+ )
172
+ }
173
+
174
+ // Decode shares
175
+ const decodedShares = shares.map((s) => this.decodeShare(s))
176
+
177
+ // Verify all shares have the same commitment and keyLength
178
+ const commitment = decodedShares[0].commitment
179
+ const keyLength = decodedShares[0].keyLength
180
+ for (const share of decodedShares) {
181
+ if (share.commitment !== commitment) {
182
+ throw new ValidationError(
183
+ 'shares must all have the same commitment',
184
+ 'shares',
185
+ { commitment },
186
+ ErrorCode.INVALID_SHARE
187
+ )
188
+ }
189
+ if (share.keyLength !== keyLength) {
190
+ throw new ValidationError(
191
+ 'shares must all have the same key length',
192
+ 'shares',
193
+ { keyLength },
194
+ ErrorCode.INVALID_SHARE
195
+ )
196
+ }
197
+ }
198
+
199
+ // Check for duplicate x-coordinates
200
+ const xCoords = new Set(decodedShares.map((s) => s.x.toString()))
201
+ if (xCoords.size !== decodedShares.length) {
202
+ throw new ValidationError(
203
+ 'shares must have unique x-coordinates',
204
+ 'shares',
205
+ undefined,
206
+ ErrorCode.INVALID_SHARE
207
+ )
208
+ }
209
+
210
+ // Reconstruct secret using Lagrange interpolation
211
+ const secret = this.lagrangeInterpolate(decodedShares)
212
+
213
+ // Convert secret back to viewing key with original length
214
+ return this.secretToViewingKey(secret, keyLength)
215
+ }
216
+
217
+ /**
218
+ * Verify a share without revealing the viewing key
219
+ *
220
+ * @param share - Encoded share to verify
221
+ * @param expectedCommitment - Expected commitment hash
222
+ * @returns True if share is valid
223
+ *
224
+ * @example
225
+ * ```typescript
226
+ * const isValid = ThresholdViewingKey.verifyShare(
227
+ * 'share1',
228
+ * 'commitment_hash'
229
+ * )
230
+ * ```
231
+ */
232
+ static verifyShare(share: string, expectedCommitment: string): boolean {
233
+ try {
234
+ const decoded = this.decodeShare(share)
235
+ return decoded.commitment === expectedCommitment
236
+ } catch {
237
+ return false
238
+ }
239
+ }
240
+
241
+ // ─── Private Helper Methods ─────────────────────────────────────────────────
242
+
243
+ /**
244
+ * Validate threshold and total shares parameters
245
+ */
246
+ private static validateParams(threshold: number, totalShares: number): void {
247
+ if (!Number.isInteger(threshold) || threshold < 2) {
248
+ throw new ValidationError(
249
+ 'threshold must be an integer >= 2',
250
+ 'threshold',
251
+ { received: threshold },
252
+ ErrorCode.INVALID_THRESHOLD
253
+ )
254
+ }
255
+
256
+ if (!Number.isInteger(totalShares) || totalShares < threshold) {
257
+ throw new ValidationError(
258
+ 'totalShares must be an integer >= threshold',
259
+ 'totalShares',
260
+ { received: totalShares, threshold },
261
+ ErrorCode.INVALID_THRESHOLD
262
+ )
263
+ }
264
+
265
+ if (totalShares > 255) {
266
+ throw new ValidationError(
267
+ 'totalShares must be <= 255',
268
+ 'totalShares',
269
+ { received: totalShares },
270
+ ErrorCode.INVALID_THRESHOLD
271
+ )
272
+ }
273
+ }
274
+
275
+ /**
276
+ * Validate viewing key format
277
+ */
278
+ private static validateViewingKey(viewingKey: HexString): void {
279
+ if (!viewingKey || typeof viewingKey !== 'string') {
280
+ throw new ValidationError(
281
+ 'viewingKey is required',
282
+ 'viewingKey',
283
+ { received: viewingKey },
284
+ ErrorCode.MISSING_REQUIRED
285
+ )
286
+ }
287
+
288
+ if (!viewingKey.startsWith('0x')) {
289
+ throw new ValidationError(
290
+ 'viewingKey must be hex-encoded (start with 0x)',
291
+ 'viewingKey',
292
+ { received: viewingKey },
293
+ ErrorCode.INVALID_FORMAT
294
+ )
295
+ }
296
+
297
+ if (viewingKey.length < 66) {
298
+ throw new ValidationError(
299
+ 'viewingKey must be at least 32 bytes',
300
+ 'viewingKey',
301
+ { received: viewingKey.length },
302
+ ErrorCode.INVALID_FORMAT
303
+ )
304
+ }
305
+ }
306
+
307
+ /**
308
+ * Convert viewing key to secret (bigint)
309
+ */
310
+ private static viewingKeyToSecret(viewingKey: HexString): bigint {
311
+ const bytes = hexToBytes(viewingKey.slice(2))
312
+ let secret = 0n
313
+ for (let i = 0; i < bytes.length; i++) {
314
+ secret = (secret << 8n) | BigInt(bytes[i])
315
+ }
316
+ // Ensure secret is within field
317
+ return this.mod(secret, FIELD_PRIME)
318
+ }
319
+
320
+ /**
321
+ * Convert secret (bigint) back to viewing key
322
+ * @param secret - The secret as bigint
323
+ * @param hexLength - Length of the hex string (without 0x prefix)
324
+ */
325
+ private static secretToViewingKey(secret: bigint, hexLength: number): HexString {
326
+ // Convert bigint to hex string
327
+ let hex = secret.toString(16)
328
+ // Pad to original length
329
+ hex = hex.padStart(hexLength, '0')
330
+ return `0x${hex}` as HexString
331
+ }
332
+
333
+ /**
334
+ * Generate random polynomial coefficients
335
+ * Polynomial: f(x) = a₀ + a₁x + a₂x² + ... + aₙ₋₁xⁿ⁻¹
336
+ * where a₀ = secret
337
+ */
338
+ private static generateCoefficients(threshold: number, secret: bigint): bigint[] {
339
+ const coefficients: bigint[] = [secret] // a₀ = secret
340
+
341
+ // Generate threshold-1 random coefficients
342
+ for (let i = 1; i < threshold; i++) {
343
+ const randomCoeff = this.randomFieldElement()
344
+ coefficients.push(randomCoeff)
345
+ }
346
+
347
+ return coefficients
348
+ }
349
+
350
+ /**
351
+ * Generate a random field element
352
+ */
353
+ private static randomFieldElement(): bigint {
354
+ const bytes = randomBytes(32)
355
+ let value = 0n
356
+ for (let i = 0; i < bytes.length; i++) {
357
+ value = (value << 8n) | BigInt(bytes[i])
358
+ }
359
+ return this.mod(value, FIELD_PRIME)
360
+ }
361
+
362
+ /**
363
+ * Evaluate polynomial at point x
364
+ * f(x) = a₀ + a₁x + a₂x² + ... + aₙ₋₁xⁿ⁻¹
365
+ */
366
+ private static evaluatePolynomial(coefficients: bigint[], x: bigint): bigint {
367
+ let result = 0n
368
+ let xPower = 1n
369
+
370
+ for (const coeff of coefficients) {
371
+ result = this.mod(result + this.mod(coeff * xPower, FIELD_PRIME), FIELD_PRIME)
372
+ xPower = this.mod(xPower * x, FIELD_PRIME)
373
+ }
374
+
375
+ return result
376
+ }
377
+
378
+ /**
379
+ * Create commitment hash from secret and coefficients
380
+ */
381
+ private static createCommitment(secret: bigint, coefficients: bigint[]): string {
382
+ const data = [secret, ...coefficients].map((c) => c.toString(16).padStart(64, '0')).join('')
383
+ const hash = sha256(hexToBytes(data))
384
+ return bytesToHex(hash)
385
+ }
386
+
387
+ /**
388
+ * Encode share as string: "x:y:len:commitment"
389
+ */
390
+ private static encodeShare(x: bigint, y: bigint, keyLength: number, commitment: string): string {
391
+ const xHex = x.toString(16).padStart(2, '0')
392
+ const yHex = y.toString(16).padStart(64, '0')
393
+ const lenHex = keyLength.toString(16).padStart(4, '0')
394
+ return `${xHex}:${yHex}:${lenHex}:${commitment}`
395
+ }
396
+
397
+ /**
398
+ * Decode share from string
399
+ */
400
+ private static decodeShare(share: string): DecodedShare {
401
+ if (!share || typeof share !== 'string') {
402
+ throw new ValidationError(
403
+ 'share must be a non-empty string',
404
+ 'share',
405
+ { received: share },
406
+ ErrorCode.INVALID_SHARE
407
+ )
408
+ }
409
+
410
+ const parts = share.split(':')
411
+ if (parts.length !== 4) {
412
+ throw new ValidationError(
413
+ 'share must have format "x:y:len:commitment"',
414
+ 'share',
415
+ { received: share },
416
+ ErrorCode.INVALID_SHARE
417
+ )
418
+ }
419
+
420
+ const [xHex, yHex, lenHex, commitment] = parts
421
+
422
+ try {
423
+ const x = BigInt(`0x${xHex}`)
424
+ const y = BigInt(`0x${yHex}`)
425
+ const keyLength = parseInt(lenHex, 16)
426
+
427
+ if (x <= 0n) {
428
+ throw new ValidationError(
429
+ 'share x-coordinate must be positive',
430
+ 'share',
431
+ { x },
432
+ ErrorCode.INVALID_SHARE
433
+ )
434
+ }
435
+
436
+ if (keyLength < 64) {
437
+ throw new ValidationError(
438
+ 'key length must be at least 64 (32 bytes)',
439
+ 'share',
440
+ { keyLength },
441
+ ErrorCode.INVALID_SHARE
442
+ )
443
+ }
444
+
445
+ return { x, y, keyLength, commitment }
446
+ } catch (error) {
447
+ throw new ValidationError(
448
+ 'failed to decode share',
449
+ 'share',
450
+ { error: (error as Error).message },
451
+ ErrorCode.INVALID_SHARE
452
+ )
453
+ }
454
+ }
455
+
456
+ /**
457
+ * Lagrange interpolation to reconstruct secret
458
+ * Evaluates polynomial at x=0 to get f(0) = secret
459
+ */
460
+ private static lagrangeInterpolate(shares: DecodedShare[]): bigint {
461
+ let secret = 0n
462
+
463
+ for (let i = 0; i < shares.length; i++) {
464
+ const xi = shares[i].x
465
+ const yi = shares[i].y
466
+
467
+ // Calculate Lagrange coefficient
468
+ let numerator = 1n
469
+ let denominator = 1n
470
+
471
+ for (let j = 0; j < shares.length; j++) {
472
+ if (i === j) continue
473
+
474
+ const xj = shares[j].x
475
+ // Evaluate at x=0: (0 - xj) / (xi - xj)
476
+ numerator = this.mod(numerator * this.mod(-xj, FIELD_PRIME), FIELD_PRIME)
477
+ denominator = this.mod(denominator * this.mod(xi - xj, FIELD_PRIME), FIELD_PRIME)
478
+ }
479
+
480
+ // Compute yi * (numerator / denominator)
481
+ const coeff = this.mod(numerator * this.modInverse(denominator, FIELD_PRIME), FIELD_PRIME)
482
+ secret = this.mod(secret + this.mod(yi * coeff, FIELD_PRIME), FIELD_PRIME)
483
+ }
484
+
485
+ return secret
486
+ }
487
+
488
+ /**
489
+ * Modular arithmetic: a mod m
490
+ */
491
+ private static mod(a: bigint, m: bigint): bigint {
492
+ return ((a % m) + m) % m
493
+ }
494
+
495
+ /**
496
+ * Modular multiplicative inverse using Extended Euclidean Algorithm
497
+ * Returns x such that (a * x) mod m = 1
498
+ */
499
+ private static modInverse(a: bigint, m: bigint): bigint {
500
+ const a0 = this.mod(a, m)
501
+
502
+ if (a0 === 0n) {
503
+ throw new CryptoError(
504
+ 'modular inverse does not exist (a = 0)',
505
+ ErrorCode.CRYPTO_OPERATION_FAILED,
506
+ { operation: 'modInverse' }
507
+ )
508
+ }
509
+
510
+ let [old_r, r] = [a0, m]
511
+ let [old_s, s] = [1n, 0n]
512
+
513
+ while (r !== 0n) {
514
+ const quotient = old_r / r
515
+ ;[old_r, r] = [r, old_r - quotient * r]
516
+ ;[old_s, s] = [s, old_s - quotient * s]
517
+ }
518
+
519
+ if (old_r !== 1n) {
520
+ throw new CryptoError(
521
+ 'modular inverse does not exist (gcd != 1)',
522
+ ErrorCode.CRYPTO_OPERATION_FAILED,
523
+ { operation: 'modInverse' }
524
+ )
525
+ }
526
+
527
+ return this.mod(old_s, m)
528
+ }
529
+ }