@forgesworn/shamir-words 0.0.0-development

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,44 @@
1
+ export declare class ShamirError extends Error {
2
+ constructor(message: string);
3
+ }
4
+ export declare class ShamirValidationError extends ShamirError {
5
+ constructor(message: string);
6
+ }
7
+ export declare class ShamirCryptoError extends ShamirError {
8
+ constructor(message: string);
9
+ }
10
+ export interface ShamirShare {
11
+ id: number;
12
+ threshold: number;
13
+ data: Uint8Array;
14
+ }
15
+ /**
16
+ * Split a secret into shares using Shamir's Secret Sharing over GF(256).
17
+ *
18
+ * @param secret The secret bytes to split
19
+ * @param threshold Minimum shares needed to reconstruct (>= 2)
20
+ * @param shares Total number of shares to create (>= threshold, <= 255)
21
+ * @returns Array of ShamirShare objects (each includes the threshold for encoding)
22
+ */
23
+ export declare function splitSecret(secret: Uint8Array, threshold: number, shares: number): ShamirShare[];
24
+ /**
25
+ * Reconstruct a secret from shares using Lagrange interpolation over GF(256).
26
+ *
27
+ * @param shares Array of shares (at least `threshold` shares)
28
+ * @param threshold The threshold used during splitting
29
+ * @returns The reconstructed secret bytes
30
+ */
31
+ export declare function reconstructSecret(shares: ShamirShare[], threshold: number): Uint8Array;
32
+ /**
33
+ * Encode a share as BIP-39 words.
34
+ * Format: [data_length, threshold, share_id, ...data, checksum] → 11-bit groups → BIP-39 words.
35
+ * The length prefix ensures exact roundtrip fidelity regardless of bit alignment.
36
+ * The checksum (first byte of SHA-256 of the preceding bytes) detects transcription errors.
37
+ */
38
+ export declare function shareToWords(share: ShamirShare): string[];
39
+ /**
40
+ * Decode BIP-39 words back to a share.
41
+ * Expects format: [data_length, threshold, share_id, ...data, checksum] encoded as 11-bit groups.
42
+ * Verifies the checksum to detect transcription errors.
43
+ */
44
+ export declare function wordsToShare(words: string[]): ShamirShare;
package/dist/index.js ADDED
@@ -0,0 +1,365 @@
1
+ // Shamir's Secret Sharing over GF(256)
2
+ // Split secrets into threshold-of-n shares using polynomial interpolation
3
+ // Shares can be encoded as BIP-39 words for human-readable exchange
4
+ import { randomBytes } from '@noble/hashes/utils.js';
5
+ import { sha256 } from '@noble/hashes/sha2.js';
6
+ import { wordlist as BIP39_WORDLIST } from '@scure/bip39/wordlists/english.js';
7
+ /** O(1) word-to-index lookup, built once at module load */
8
+ const BIP39_INDEX = new Map();
9
+ for (let i = 0; i < BIP39_WORDLIST.length; i++) {
10
+ BIP39_INDEX.set(BIP39_WORDLIST[i], i);
11
+ }
12
+ // ---------------------------------------------------------------------------
13
+ // Errors
14
+ // ---------------------------------------------------------------------------
15
+ export class ShamirError extends Error {
16
+ constructor(message) {
17
+ super(message);
18
+ this.name = 'ShamirError';
19
+ }
20
+ }
21
+ export class ShamirValidationError extends ShamirError {
22
+ constructor(message) {
23
+ super(message);
24
+ this.name = 'ShamirValidationError';
25
+ }
26
+ }
27
+ export class ShamirCryptoError extends ShamirError {
28
+ constructor(message) {
29
+ super(message);
30
+ this.name = 'ShamirCryptoError';
31
+ }
32
+ }
33
+ // ---------------------------------------------------------------------------
34
+ // GF(256) Arithmetic — irreducible polynomial 0x11b (same as AES)
35
+ // ---------------------------------------------------------------------------
36
+ const IRREDUCIBLE = 0x11b;
37
+ const GENERATOR = 0x03;
38
+ /** Log table: log_g(i) for i in [0..255]. LOG[0] is unused. */
39
+ const LOG = new Uint8Array(256);
40
+ /** Exp table: g^i for i in [0..255]. EXP[255] wraps to EXP[0]. */
41
+ const EXP = new Uint8Array(256);
42
+ /** Carryless multiplication used only during table construction */
43
+ function gf256MulSlow(a, b) {
44
+ let result = 0;
45
+ let aa = a;
46
+ let bb = b;
47
+ while (bb > 0) {
48
+ if (bb & 1)
49
+ result ^= aa;
50
+ aa <<= 1;
51
+ if (aa & 0x100)
52
+ aa ^= IRREDUCIBLE;
53
+ bb >>= 1;
54
+ }
55
+ return result;
56
+ }
57
+ // Build log/exp tables at module load time using generator 0x03
58
+ {
59
+ let val = 1;
60
+ for (let i = 0; i < 255; i++) {
61
+ EXP[i] = val;
62
+ LOG[val] = i;
63
+ val = gf256MulSlow(val, GENERATOR);
64
+ }
65
+ // Wrap: makes modular indexing simpler
66
+ EXP[255] = EXP[0];
67
+ }
68
+ /** Addition in GF(256) is XOR */
69
+ function gf256Add(a, b) {
70
+ return a ^ b;
71
+ }
72
+ /** Multiplication in GF(256) using log/exp tables */
73
+ function gf256Mul(a, b) {
74
+ if (a === 0 || b === 0)
75
+ return 0;
76
+ return EXP[(LOG[a] + LOG[b]) % 255];
77
+ }
78
+ /** Multiplicative inverse in GF(256) */
79
+ function gf256Inv(a) {
80
+ if (a === 0)
81
+ throw new ShamirCryptoError('No inverse for zero in GF(256)');
82
+ return EXP[(255 - LOG[a]) % 255];
83
+ }
84
+ // ---------------------------------------------------------------------------
85
+ // Shamir's Secret Sharing
86
+ // ---------------------------------------------------------------------------
87
+ /**
88
+ * Evaluate a polynomial at x in GF(256) using Horner's method.
89
+ * coeffs[0] is the constant term (the secret byte).
90
+ */
91
+ function evalPoly(coeffs, x) {
92
+ let result = 0;
93
+ for (let i = coeffs.length - 1; i >= 0; i--) {
94
+ result = gf256Add(gf256Mul(result, x), coeffs[i]);
95
+ }
96
+ return result;
97
+ }
98
+ /** Zero a byte array (defence-in-depth for secret material) */
99
+ function zeroBytes(arr) {
100
+ arr.fill(0);
101
+ }
102
+ /**
103
+ * Split a secret into shares using Shamir's Secret Sharing over GF(256).
104
+ *
105
+ * @param secret The secret bytes to split
106
+ * @param threshold Minimum shares needed to reconstruct (>= 2)
107
+ * @param shares Total number of shares to create (>= threshold, <= 255)
108
+ * @returns Array of ShamirShare objects (each includes the threshold for encoding)
109
+ */
110
+ export function splitSecret(secret, threshold, shares) {
111
+ if (!(secret instanceof Uint8Array)) {
112
+ throw new ShamirValidationError('Secret must be a Uint8Array');
113
+ }
114
+ if (secret.length === 0) {
115
+ throw new ShamirValidationError('Secret must not be empty');
116
+ }
117
+ if (!Number.isSafeInteger(threshold) || !Number.isSafeInteger(shares)) {
118
+ throw new ShamirValidationError('Threshold and shares must be safe integers');
119
+ }
120
+ if (threshold < 2) {
121
+ throw new ShamirValidationError('Threshold must be at least 2');
122
+ }
123
+ if (shares < threshold) {
124
+ throw new ShamirValidationError('Number of shares must be >= threshold');
125
+ }
126
+ if (shares > 255) {
127
+ throw new ShamirValidationError('Number of shares must be <= 255');
128
+ }
129
+ if (secret.length > 255) {
130
+ throw new ShamirValidationError('Secret must be at most 255 bytes for BIP-39 word encoding');
131
+ }
132
+ const secretLen = secret.length;
133
+ const result = [];
134
+ // Initialize share data arrays
135
+ for (let i = 0; i < shares; i++) {
136
+ result.push({ id: i + 1, threshold, data: new Uint8Array(secretLen) });
137
+ }
138
+ // For each byte of the secret, build a random polynomial and evaluate
139
+ for (let byteIdx = 0; byteIdx < secretLen; byteIdx++) {
140
+ // coeffs[0] = secret byte, coeffs[1..threshold-1] = random
141
+ const coeffs = new Uint8Array(threshold);
142
+ coeffs[0] = secret[byteIdx];
143
+ const rand = randomBytes(threshold - 1);
144
+ for (let j = 1; j < threshold; j++) {
145
+ coeffs[j] = rand[j - 1];
146
+ }
147
+ // Evaluate at x = 1, 2, ..., shares
148
+ for (let i = 0; i < shares; i++) {
149
+ result[i].data[byteIdx] = evalPoly(coeffs, i + 1);
150
+ }
151
+ zeroBytes(coeffs);
152
+ zeroBytes(rand);
153
+ }
154
+ return result;
155
+ }
156
+ /**
157
+ * Reconstruct a secret from shares using Lagrange interpolation over GF(256).
158
+ *
159
+ * @param shares Array of shares (at least `threshold` shares)
160
+ * @param threshold The threshold used during splitting
161
+ * @returns The reconstructed secret bytes
162
+ */
163
+ export function reconstructSecret(shares, threshold) {
164
+ if (!Number.isSafeInteger(threshold) || threshold < 2) {
165
+ throw new ShamirValidationError('Threshold must be an integer >= 2');
166
+ }
167
+ if (!Array.isArray(shares) || shares.length < threshold) {
168
+ throw new ShamirValidationError(`Need at least ${threshold} shares, got ${Array.isArray(shares) ? shares.length : 0}`);
169
+ }
170
+ // Use only the first `threshold` shares
171
+ const used = shares.slice(0, threshold);
172
+ // Validate share structure, IDs, and check for duplicates
173
+ const ids = new Set();
174
+ for (const share of used) {
175
+ if (!share || typeof share !== 'object') {
176
+ throw new ShamirValidationError('Each share must be an object with id and data properties');
177
+ }
178
+ if (!Number.isInteger(share.id) || share.id < 1 || share.id > 255) {
179
+ throw new ShamirValidationError('Invalid share ID: must be an integer in [1, 255]');
180
+ }
181
+ if (!(share.data instanceof Uint8Array)) {
182
+ throw new ShamirValidationError('Share data must be a Uint8Array');
183
+ }
184
+ if (ids.has(share.id)) {
185
+ throw new ShamirValidationError('Duplicate share IDs detected — each share must have a unique ID');
186
+ }
187
+ ids.add(share.id);
188
+ }
189
+ const secretLen = used[0].data.length;
190
+ if (secretLen === 0) {
191
+ throw new ShamirValidationError('Share data must not be empty');
192
+ }
193
+ for (const share of used) {
194
+ if (share.data.length !== secretLen) {
195
+ throw new ShamirValidationError('Inconsistent share lengths — shares may be from different secrets');
196
+ }
197
+ }
198
+ const result = new Uint8Array(secretLen);
199
+ // Lagrange interpolation at x = 0 for each byte position
200
+ for (let byteIdx = 0; byteIdx < secretLen; byteIdx++) {
201
+ let value = 0;
202
+ for (let i = 0; i < threshold; i++) {
203
+ const xi = used[i].id;
204
+ const yi = used[i].data[byteIdx];
205
+ // Lagrange basis l_i(0) = product of x_j / (x_i ^ x_j) for j != i
206
+ // In GF(256): subtraction = addition = XOR
207
+ let basis = 1;
208
+ for (let j = 0; j < threshold; j++) {
209
+ if (i === j)
210
+ continue;
211
+ const xj = used[j].id;
212
+ basis = gf256Mul(basis, gf256Mul(xj, gf256Inv(gf256Add(xi, xj))));
213
+ }
214
+ value = gf256Add(value, gf256Mul(yi, basis));
215
+ }
216
+ result[byteIdx] = value;
217
+ }
218
+ return result;
219
+ }
220
+ // ---------------------------------------------------------------------------
221
+ // BIP-39 Word Encoding
222
+ // ---------------------------------------------------------------------------
223
+ /**
224
+ * Encode a share as BIP-39 words.
225
+ * Format: [data_length, threshold, share_id, ...data, checksum] → 11-bit groups → BIP-39 words.
226
+ * The length prefix ensures exact roundtrip fidelity regardless of bit alignment.
227
+ * The checksum (first byte of SHA-256 of the preceding bytes) detects transcription errors.
228
+ */
229
+ export function shareToWords(share) {
230
+ if (!share || typeof share !== 'object') {
231
+ throw new ShamirValidationError('Share must be an object with id, threshold, and data properties');
232
+ }
233
+ if (!Number.isInteger(share.id) || share.id < 1 || share.id > 255) {
234
+ throw new ShamirValidationError('Share ID must be an integer in [1, 255]');
235
+ }
236
+ if (!Number.isInteger(share.threshold) || share.threshold < 2 || share.threshold > 255) {
237
+ throw new ShamirValidationError('Share threshold must be an integer in [2, 255]');
238
+ }
239
+ if (!(share.data instanceof Uint8Array) || share.data.length === 0) {
240
+ throw new ShamirValidationError('Share data must be a non-empty Uint8Array');
241
+ }
242
+ if (share.data.length > 255) {
243
+ throw new ShamirValidationError('Share data exceeds maximum length (255 bytes)');
244
+ }
245
+ // Build payload: [data_length, threshold, share_id, ...data]
246
+ const payloadLen = 3 + share.data.length;
247
+ const payload = new Uint8Array(payloadLen);
248
+ payload[0] = share.data.length;
249
+ payload[1] = share.threshold;
250
+ payload[2] = share.id;
251
+ payload.set(share.data, 3);
252
+ // Compute checksum: first byte of SHA-256 of the payload
253
+ const checksum = sha256(payload)[0];
254
+ // Final byte stream: payload + checksum
255
+ const bytes = new Uint8Array(payloadLen + 1);
256
+ bytes.set(payload, 0);
257
+ bytes[payloadLen] = checksum;
258
+ // Stream bytes into 11-bit word indices
259
+ const words = [];
260
+ let bits = 0;
261
+ let accumulator = 0;
262
+ for (const byte of bytes) {
263
+ accumulator = ((accumulator << 8) | byte) >>> 0;
264
+ bits += 8;
265
+ while (bits >= 11) {
266
+ bits -= 11;
267
+ const index = (accumulator >>> bits) & 0x7ff;
268
+ words.push(BIP39_WORDLIST[index]);
269
+ accumulator &= (1 << bits) - 1;
270
+ }
271
+ }
272
+ // Pad remaining bits on the right to form a final 11-bit group
273
+ if (bits > 0) {
274
+ const index = ((accumulator << (11 - bits)) >>> 0) & 0x7ff;
275
+ words.push(BIP39_WORDLIST[index]);
276
+ }
277
+ return words;
278
+ }
279
+ /**
280
+ * Decode BIP-39 words back to a share.
281
+ * Expects format: [data_length, threshold, share_id, ...data, checksum] encoded as 11-bit groups.
282
+ * Verifies the checksum to detect transcription errors.
283
+ */
284
+ export function wordsToShare(words) {
285
+ if (!Array.isArray(words)) {
286
+ throw new ShamirValidationError('Words must be an array of strings');
287
+ }
288
+ if (words.length === 0)
289
+ throw new ShamirValidationError('Cannot decode empty word list');
290
+ if (words.length > 256) {
291
+ throw new ShamirValidationError('Word count exceeds maximum (256)');
292
+ }
293
+ // Convert words to 11-bit indices using O(1) map lookup
294
+ const indices = [];
295
+ for (let i = 0; i < words.length; i++) {
296
+ const w = words[i];
297
+ if (typeof w !== 'string') {
298
+ throw new ShamirValidationError(`Word at position ${i + 1} must be a string`);
299
+ }
300
+ const idx = BIP39_INDEX.get(w.trim().toLowerCase());
301
+ if (idx === undefined) {
302
+ throw new ShamirValidationError(`Unknown BIP-39 word at position ${i + 1}`);
303
+ }
304
+ indices.push(idx);
305
+ }
306
+ // Stream 11-bit groups into bytes
307
+ let bits = 0;
308
+ let accumulator = 0;
309
+ const byteList = [];
310
+ for (const index of indices) {
311
+ accumulator = ((accumulator << 11) | index) >>> 0;
312
+ bits += 11;
313
+ while (bits >= 8) {
314
+ bits -= 8;
315
+ byteList.push((accumulator >>> bits) & 0xff);
316
+ accumulator &= (1 << bits) - 1;
317
+ }
318
+ }
319
+ // Verify padding bits in the last word are zero
320
+ if (bits > 0 && accumulator !== 0) {
321
+ throw new ShamirValidationError('Non-zero padding bits detected — word list may be corrupted');
322
+ }
323
+ // Need at least 5 bytes: data_length + threshold + id + 1 data byte + checksum
324
+ if (byteList.length < 5) {
325
+ throw new ShamirValidationError('Word list too short — need at least data_length + threshold + id + 1 data byte + checksum');
326
+ }
327
+ // Read header
328
+ const dataLength = byteList[0];
329
+ if (dataLength === 0) {
330
+ throw new ShamirValidationError('Encoded data length is zero');
331
+ }
332
+ // Total expected bytes: 3 header + dataLength + 1 checksum
333
+ const totalExpected = 4 + dataLength;
334
+ if (totalExpected > byteList.length) {
335
+ throw new ShamirValidationError('Word list too short for encoded data length');
336
+ }
337
+ // Enforce canonical encoding: word count must match expected
338
+ const expectedWords = Math.ceil(totalExpected * 8 / 11);
339
+ if (words.length !== expectedWords) {
340
+ throw new ShamirValidationError(`Expected ${expectedWords} words for data length ${dataLength}, got ${words.length}`);
341
+ }
342
+ const threshold = byteList[1];
343
+ if (threshold < 2 || threshold > 255) {
344
+ throw new ShamirValidationError('Invalid threshold: must be in [2, 255]');
345
+ }
346
+ const id = byteList[2];
347
+ if (id === 0) {
348
+ throw new ShamirValidationError('Invalid share ID: 0 is not a valid x-coordinate for GF(256)');
349
+ }
350
+ // Verify checksum
351
+ const payload = new Uint8Array(3 + dataLength);
352
+ for (let i = 0; i < 3 + dataLength; i++) {
353
+ payload[i] = byteList[i];
354
+ }
355
+ const expectedChecksum = sha256(payload)[0];
356
+ const actualChecksum = byteList[3 + dataLength];
357
+ if (actualChecksum !== expectedChecksum) {
358
+ throw new ShamirValidationError('Checksum mismatch — word list is corrupted or was incorrectly transcribed');
359
+ }
360
+ const data = new Uint8Array(dataLength);
361
+ for (let i = 0; i < dataLength; i++) {
362
+ data[i] = byteList[3 + i];
363
+ }
364
+ return { id, threshold, data };
365
+ }
package/package.json ADDED
@@ -0,0 +1,56 @@
1
+ {
2
+ "name": "@forgesworn/shamir-words",
3
+ "version": "0.0.0-development",
4
+ "description": "Shamir's Secret Sharing over GF(256) with BIP-39 word encoding for human-readable share exchange",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "types": "./dist/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "import": "./dist/index.js",
11
+ "types": "./dist/index.d.ts"
12
+ }
13
+ },
14
+ "files": [
15
+ "dist",
16
+ "src"
17
+ ],
18
+ "scripts": {
19
+ "build": "tsc",
20
+ "test": "vitest run",
21
+ "test:watch": "vitest",
22
+ "typecheck": "tsc --noEmit",
23
+ "clean": "rm -rf dist"
24
+ },
25
+ "keywords": [
26
+ "shamir",
27
+ "secret-sharing",
28
+ "gf256",
29
+ "bip39",
30
+ "mnemonic",
31
+ "backup",
32
+ "threshold",
33
+ "cryptography",
34
+ "key-splitting"
35
+ ],
36
+ "sideEffects": false,
37
+ "license": "MIT",
38
+ "engines": {
39
+ "node": ">=18"
40
+ },
41
+ "repository": {
42
+ "type": "git",
43
+ "url": "https://github.com/forgesworn/shamir-words.git"
44
+ },
45
+ "dependencies": {
46
+ "@noble/hashes": "^2.0.1",
47
+ "@scure/bip39": "^2.0.1"
48
+ },
49
+ "devDependencies": {
50
+ "@semantic-release/changelog": "^6.0.3",
51
+ "@semantic-release/git": "^10.0.1",
52
+ "semantic-release": "^25.0.3",
53
+ "typescript": "^5.7.0",
54
+ "vitest": "^3.0.0"
55
+ }
56
+ }
package/src/index.ts ADDED
@@ -0,0 +1,436 @@
1
+ // Shamir's Secret Sharing over GF(256)
2
+ // Split secrets into threshold-of-n shares using polynomial interpolation
3
+ // Shares can be encoded as BIP-39 words for human-readable exchange
4
+
5
+ import { randomBytes } from '@noble/hashes/utils.js';
6
+ import { sha256 } from '@noble/hashes/sha2.js';
7
+ import { wordlist as BIP39_WORDLIST } from '@scure/bip39/wordlists/english.js';
8
+
9
+ /** O(1) word-to-index lookup, built once at module load */
10
+ const BIP39_INDEX = new Map<string, number>();
11
+ for (let i = 0; i < BIP39_WORDLIST.length; i++) {
12
+ BIP39_INDEX.set(BIP39_WORDLIST[i]!, i);
13
+ }
14
+
15
+ // ---------------------------------------------------------------------------
16
+ // Errors
17
+ // ---------------------------------------------------------------------------
18
+
19
+ export class ShamirError extends Error {
20
+ constructor(message: string) {
21
+ super(message);
22
+ this.name = 'ShamirError';
23
+ }
24
+ }
25
+
26
+ export class ShamirValidationError extends ShamirError {
27
+ constructor(message: string) {
28
+ super(message);
29
+ this.name = 'ShamirValidationError';
30
+ }
31
+ }
32
+
33
+ export class ShamirCryptoError extends ShamirError {
34
+ constructor(message: string) {
35
+ super(message);
36
+ this.name = 'ShamirCryptoError';
37
+ }
38
+ }
39
+
40
+ // ---------------------------------------------------------------------------
41
+ // Types
42
+ // ---------------------------------------------------------------------------
43
+
44
+ export interface ShamirShare {
45
+ id: number; // 1-255 (the x coordinate)
46
+ threshold: number; // 2-255 (minimum shares needed to reconstruct)
47
+ data: Uint8Array; // evaluated polynomial bytes
48
+ }
49
+
50
+ // ---------------------------------------------------------------------------
51
+ // GF(256) Arithmetic — irreducible polynomial 0x11b (same as AES)
52
+ // ---------------------------------------------------------------------------
53
+
54
+ const IRREDUCIBLE = 0x11b;
55
+ const GENERATOR = 0x03;
56
+
57
+ /** Log table: log_g(i) for i in [0..255]. LOG[0] is unused. */
58
+ const LOG = new Uint8Array(256);
59
+ /** Exp table: g^i for i in [0..255]. EXP[255] wraps to EXP[0]. */
60
+ const EXP = new Uint8Array(256);
61
+
62
+ /** Carryless multiplication used only during table construction */
63
+ function gf256MulSlow(a: number, b: number): number {
64
+ let result = 0;
65
+ let aa = a;
66
+ let bb = b;
67
+ while (bb > 0) {
68
+ if (bb & 1) result ^= aa;
69
+ aa <<= 1;
70
+ if (aa & 0x100) aa ^= IRREDUCIBLE;
71
+ bb >>= 1;
72
+ }
73
+ return result;
74
+ }
75
+
76
+ // Build log/exp tables at module load time using generator 0x03
77
+ {
78
+ let val = 1;
79
+ for (let i = 0; i < 255; i++) {
80
+ EXP[i] = val;
81
+ LOG[val] = i;
82
+ val = gf256MulSlow(val, GENERATOR);
83
+ }
84
+ // Wrap: makes modular indexing simpler
85
+ EXP[255] = EXP[0]!;
86
+ }
87
+
88
+ /** Addition in GF(256) is XOR */
89
+ function gf256Add(a: number, b: number): number {
90
+ return a ^ b;
91
+ }
92
+
93
+ /** Multiplication in GF(256) using log/exp tables */
94
+ function gf256Mul(a: number, b: number): number {
95
+ if (a === 0 || b === 0) return 0;
96
+ return EXP[(LOG[a]! + LOG[b]!) % 255]!;
97
+ }
98
+
99
+ /** Multiplicative inverse in GF(256) */
100
+ function gf256Inv(a: number): number {
101
+ if (a === 0) throw new ShamirCryptoError('No inverse for zero in GF(256)');
102
+ return EXP[(255 - LOG[a]!) % 255]!;
103
+ }
104
+
105
+ // ---------------------------------------------------------------------------
106
+ // Shamir's Secret Sharing
107
+ // ---------------------------------------------------------------------------
108
+
109
+ /**
110
+ * Evaluate a polynomial at x in GF(256) using Horner's method.
111
+ * coeffs[0] is the constant term (the secret byte).
112
+ */
113
+ function evalPoly(coeffs: Uint8Array, x: number): number {
114
+ let result = 0;
115
+ for (let i = coeffs.length - 1; i >= 0; i--) {
116
+ result = gf256Add(gf256Mul(result, x), coeffs[i]!);
117
+ }
118
+ return result;
119
+ }
120
+
121
+ /** Zero a byte array (defence-in-depth for secret material) */
122
+ function zeroBytes(arr: Uint8Array): void {
123
+ arr.fill(0);
124
+ }
125
+
126
+ /**
127
+ * Split a secret into shares using Shamir's Secret Sharing over GF(256).
128
+ *
129
+ * @param secret The secret bytes to split
130
+ * @param threshold Minimum shares needed to reconstruct (>= 2)
131
+ * @param shares Total number of shares to create (>= threshold, <= 255)
132
+ * @returns Array of ShamirShare objects (each includes the threshold for encoding)
133
+ */
134
+ export function splitSecret(
135
+ secret: Uint8Array,
136
+ threshold: number,
137
+ shares: number,
138
+ ): ShamirShare[] {
139
+ if (!(secret instanceof Uint8Array)) {
140
+ throw new ShamirValidationError('Secret must be a Uint8Array');
141
+ }
142
+ if (secret.length === 0) {
143
+ throw new ShamirValidationError('Secret must not be empty');
144
+ }
145
+ if (!Number.isSafeInteger(threshold) || !Number.isSafeInteger(shares)) {
146
+ throw new ShamirValidationError('Threshold and shares must be safe integers');
147
+ }
148
+ if (threshold < 2) {
149
+ throw new ShamirValidationError('Threshold must be at least 2');
150
+ }
151
+ if (shares < threshold) {
152
+ throw new ShamirValidationError('Number of shares must be >= threshold');
153
+ }
154
+ if (shares > 255) {
155
+ throw new ShamirValidationError('Number of shares must be <= 255');
156
+ }
157
+ if (secret.length > 255) {
158
+ throw new ShamirValidationError('Secret must be at most 255 bytes for BIP-39 word encoding');
159
+ }
160
+
161
+ const secretLen = secret.length;
162
+ const result: ShamirShare[] = [];
163
+
164
+ // Initialize share data arrays
165
+ for (let i = 0; i < shares; i++) {
166
+ result.push({ id: i + 1, threshold, data: new Uint8Array(secretLen) });
167
+ }
168
+
169
+ // For each byte of the secret, build a random polynomial and evaluate
170
+ for (let byteIdx = 0; byteIdx < secretLen; byteIdx++) {
171
+ // coeffs[0] = secret byte, coeffs[1..threshold-1] = random
172
+ const coeffs = new Uint8Array(threshold);
173
+ coeffs[0] = secret[byteIdx]!;
174
+
175
+ const rand = randomBytes(threshold - 1);
176
+ for (let j = 1; j < threshold; j++) {
177
+ coeffs[j] = rand[j - 1]!;
178
+ }
179
+
180
+ // Evaluate at x = 1, 2, ..., shares
181
+ for (let i = 0; i < shares; i++) {
182
+ result[i]!.data[byteIdx] = evalPoly(coeffs, i + 1);
183
+ }
184
+
185
+ zeroBytes(coeffs);
186
+ zeroBytes(rand);
187
+ }
188
+
189
+ return result;
190
+ }
191
+
192
+ /**
193
+ * Reconstruct a secret from shares using Lagrange interpolation over GF(256).
194
+ *
195
+ * @param shares Array of shares (at least `threshold` shares)
196
+ * @param threshold The threshold used during splitting
197
+ * @returns The reconstructed secret bytes
198
+ */
199
+ export function reconstructSecret(
200
+ shares: ShamirShare[],
201
+ threshold: number,
202
+ ): Uint8Array {
203
+ if (!Number.isSafeInteger(threshold) || threshold < 2) {
204
+ throw new ShamirValidationError('Threshold must be an integer >= 2');
205
+ }
206
+ if (!Array.isArray(shares) || shares.length < threshold) {
207
+ throw new ShamirValidationError(`Need at least ${threshold} shares, got ${Array.isArray(shares) ? shares.length : 0}`);
208
+ }
209
+
210
+ // Use only the first `threshold` shares
211
+ const used = shares.slice(0, threshold);
212
+
213
+ // Validate share structure, IDs, and check for duplicates
214
+ const ids = new Set<number>();
215
+ for (const share of used) {
216
+ if (!share || typeof share !== 'object') {
217
+ throw new ShamirValidationError('Each share must be an object with id and data properties');
218
+ }
219
+ if (!Number.isInteger(share.id) || share.id < 1 || share.id > 255) {
220
+ throw new ShamirValidationError('Invalid share ID: must be an integer in [1, 255]');
221
+ }
222
+ if (!(share.data instanceof Uint8Array)) {
223
+ throw new ShamirValidationError('Share data must be a Uint8Array');
224
+ }
225
+ if (ids.has(share.id)) {
226
+ throw new ShamirValidationError('Duplicate share IDs detected — each share must have a unique ID');
227
+ }
228
+ ids.add(share.id);
229
+ }
230
+
231
+ const secretLen = used[0]!.data.length;
232
+ if (secretLen === 0) {
233
+ throw new ShamirValidationError('Share data must not be empty');
234
+ }
235
+ for (const share of used) {
236
+ if (share.data.length !== secretLen) {
237
+ throw new ShamirValidationError('Inconsistent share lengths — shares may be from different secrets');
238
+ }
239
+ }
240
+ const result = new Uint8Array(secretLen);
241
+
242
+ // Lagrange interpolation at x = 0 for each byte position
243
+ for (let byteIdx = 0; byteIdx < secretLen; byteIdx++) {
244
+ let value = 0;
245
+
246
+ for (let i = 0; i < threshold; i++) {
247
+ const xi = used[i]!.id;
248
+ const yi = used[i]!.data[byteIdx]!;
249
+
250
+ // Lagrange basis l_i(0) = product of x_j / (x_i ^ x_j) for j != i
251
+ // In GF(256): subtraction = addition = XOR
252
+ let basis = 1;
253
+ for (let j = 0; j < threshold; j++) {
254
+ if (i === j) continue;
255
+ const xj = used[j]!.id;
256
+ basis = gf256Mul(basis, gf256Mul(xj, gf256Inv(gf256Add(xi, xj))));
257
+ }
258
+
259
+ value = gf256Add(value, gf256Mul(yi, basis));
260
+ }
261
+
262
+ result[byteIdx] = value;
263
+ }
264
+
265
+ return result;
266
+ }
267
+
268
+ // ---------------------------------------------------------------------------
269
+ // BIP-39 Word Encoding
270
+ // ---------------------------------------------------------------------------
271
+
272
+ /**
273
+ * Encode a share as BIP-39 words.
274
+ * Format: [data_length, threshold, share_id, ...data, checksum] → 11-bit groups → BIP-39 words.
275
+ * The length prefix ensures exact roundtrip fidelity regardless of bit alignment.
276
+ * The checksum (first byte of SHA-256 of the preceding bytes) detects transcription errors.
277
+ */
278
+ export function shareToWords(share: ShamirShare): string[] {
279
+ if (!share || typeof share !== 'object') {
280
+ throw new ShamirValidationError('Share must be an object with id, threshold, and data properties');
281
+ }
282
+ if (!Number.isInteger(share.id) || share.id < 1 || share.id > 255) {
283
+ throw new ShamirValidationError('Share ID must be an integer in [1, 255]');
284
+ }
285
+ if (!Number.isInteger(share.threshold) || share.threshold < 2 || share.threshold > 255) {
286
+ throw new ShamirValidationError('Share threshold must be an integer in [2, 255]');
287
+ }
288
+ if (!(share.data instanceof Uint8Array) || share.data.length === 0) {
289
+ throw new ShamirValidationError('Share data must be a non-empty Uint8Array');
290
+ }
291
+ if (share.data.length > 255) {
292
+ throw new ShamirValidationError('Share data exceeds maximum length (255 bytes)');
293
+ }
294
+
295
+ // Build payload: [data_length, threshold, share_id, ...data]
296
+ const payloadLen = 3 + share.data.length;
297
+ const payload = new Uint8Array(payloadLen);
298
+ payload[0] = share.data.length;
299
+ payload[1] = share.threshold;
300
+ payload[2] = share.id;
301
+ payload.set(share.data, 3);
302
+
303
+ // Compute checksum: first byte of SHA-256 of the payload
304
+ const checksum = sha256(payload)[0]!;
305
+
306
+ // Final byte stream: payload + checksum
307
+ const bytes = new Uint8Array(payloadLen + 1);
308
+ bytes.set(payload, 0);
309
+ bytes[payloadLen] = checksum;
310
+
311
+ // Stream bytes into 11-bit word indices
312
+ const words: string[] = [];
313
+ let bits = 0;
314
+ let accumulator = 0;
315
+
316
+ for (const byte of bytes) {
317
+ accumulator = ((accumulator << 8) | byte) >>> 0;
318
+ bits += 8;
319
+ while (bits >= 11) {
320
+ bits -= 11;
321
+ const index = (accumulator >>> bits) & 0x7ff;
322
+ words.push(BIP39_WORDLIST[index]!);
323
+ accumulator &= (1 << bits) - 1;
324
+ }
325
+ }
326
+
327
+ // Pad remaining bits on the right to form a final 11-bit group
328
+ if (bits > 0) {
329
+ const index = ((accumulator << (11 - bits)) >>> 0) & 0x7ff;
330
+ words.push(BIP39_WORDLIST[index]!);
331
+ }
332
+
333
+ return words;
334
+ }
335
+
336
+ /**
337
+ * Decode BIP-39 words back to a share.
338
+ * Expects format: [data_length, threshold, share_id, ...data, checksum] encoded as 11-bit groups.
339
+ * Verifies the checksum to detect transcription errors.
340
+ */
341
+ export function wordsToShare(words: string[]): ShamirShare {
342
+ if (!Array.isArray(words)) {
343
+ throw new ShamirValidationError('Words must be an array of strings');
344
+ }
345
+ if (words.length === 0) throw new ShamirValidationError('Cannot decode empty word list');
346
+ if (words.length > 256) {
347
+ throw new ShamirValidationError('Word count exceeds maximum (256)');
348
+ }
349
+
350
+ // Convert words to 11-bit indices using O(1) map lookup
351
+ const indices: number[] = [];
352
+ for (let i = 0; i < words.length; i++) {
353
+ const w = words[i];
354
+ if (typeof w !== 'string') {
355
+ throw new ShamirValidationError(`Word at position ${i + 1} must be a string`);
356
+ }
357
+ const idx = BIP39_INDEX.get(w.trim().toLowerCase());
358
+ if (idx === undefined) {
359
+ throw new ShamirValidationError(`Unknown BIP-39 word at position ${i + 1}`);
360
+ }
361
+ indices.push(idx);
362
+ }
363
+
364
+ // Stream 11-bit groups into bytes
365
+ let bits = 0;
366
+ let accumulator = 0;
367
+ const byteList: number[] = [];
368
+
369
+ for (const index of indices) {
370
+ accumulator = ((accumulator << 11) | index) >>> 0;
371
+ bits += 11;
372
+ while (bits >= 8) {
373
+ bits -= 8;
374
+ byteList.push((accumulator >>> bits) & 0xff);
375
+ accumulator &= (1 << bits) - 1;
376
+ }
377
+ }
378
+
379
+ // Verify padding bits in the last word are zero
380
+ if (bits > 0 && accumulator !== 0) {
381
+ throw new ShamirValidationError('Non-zero padding bits detected — word list may be corrupted');
382
+ }
383
+
384
+ // Need at least 5 bytes: data_length + threshold + id + 1 data byte + checksum
385
+ if (byteList.length < 5) {
386
+ throw new ShamirValidationError('Word list too short — need at least data_length + threshold + id + 1 data byte + checksum');
387
+ }
388
+
389
+ // Read header
390
+ const dataLength = byteList[0]!;
391
+ if (dataLength === 0) {
392
+ throw new ShamirValidationError('Encoded data length is zero');
393
+ }
394
+
395
+ // Total expected bytes: 3 header + dataLength + 1 checksum
396
+ const totalExpected = 4 + dataLength;
397
+ if (totalExpected > byteList.length) {
398
+ throw new ShamirValidationError('Word list too short for encoded data length');
399
+ }
400
+
401
+ // Enforce canonical encoding: word count must match expected
402
+ const expectedWords = Math.ceil(totalExpected * 8 / 11);
403
+ if (words.length !== expectedWords) {
404
+ throw new ShamirValidationError(
405
+ `Expected ${expectedWords} words for data length ${dataLength}, got ${words.length}`,
406
+ );
407
+ }
408
+
409
+ const threshold = byteList[1]!;
410
+ if (threshold < 2 || threshold > 255) {
411
+ throw new ShamirValidationError('Invalid threshold: must be in [2, 255]');
412
+ }
413
+
414
+ const id = byteList[2]!;
415
+ if (id === 0) {
416
+ throw new ShamirValidationError('Invalid share ID: 0 is not a valid x-coordinate for GF(256)');
417
+ }
418
+
419
+ // Verify checksum
420
+ const payload = new Uint8Array(3 + dataLength);
421
+ for (let i = 0; i < 3 + dataLength; i++) {
422
+ payload[i] = byteList[i]!;
423
+ }
424
+ const expectedChecksum = sha256(payload)[0]!;
425
+ const actualChecksum = byteList[3 + dataLength]!;
426
+ if (actualChecksum !== expectedChecksum) {
427
+ throw new ShamirValidationError('Checksum mismatch — word list is corrupted or was incorrectly transcribed');
428
+ }
429
+
430
+ const data = new Uint8Array(dataLength);
431
+ for (let i = 0; i < dataLength; i++) {
432
+ data[i] = byteList[3 + i]!;
433
+ }
434
+
435
+ return { id, threshold, data };
436
+ }