@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.
- package/dist/index.d.ts +44 -0
- package/dist/index.js +365 -0
- package/package.json +56 -0
- package/src/index.ts +436 -0
package/dist/index.d.ts
ADDED
|
@@ -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
|
+
}
|