diff --git a/README.md b/README.md index ae6d04b..10e040d 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # @tetherto/wdk-utils -A collection of utilities for validating cryptocurrency addresses, passphrase-protected seed encryption, HKDF-based key derivation, and payment URIs. This package provides a set of functions to validate various address formats and parse payment requests from different blockchain networks. +A collection of utilities for validating cryptocurrency addresses, passphrase-protected seed encryption, HKDF-based key derivation, Shamir secret sharing for mnemonics, and payment URIs. This package provides a set of functions to validate various address formats and parse payment requests from different blockchain networks. ## 🔍 About WDK @@ -19,6 +19,7 @@ For detailed documentation about the complete WDK ecosystem, visit [docs.wallet. - **UMA Address Validation**: Validates Universal Money Addresses. - **Seed Encryption**: Encrypts and decrypts seed phrases with AES-256-GCM and scrypt key derivation. - **Key Derivation**: HKDF-SHA256 seed derivation and deterministic ed25519 keypair generation. +- **Shamir Secret Sharing**: Splits a BIP-39 mnemonic into `n` shares, any `k` of which reconstruct it. ## ⬇️ Installation @@ -48,7 +49,9 @@ import { encrypt, decrypt, deriveSeedKey, - deriveSeedKeyPair + deriveSeedKeyPair, + splitMnemonic, + combineMnemonic } from '@tetherto/wdk-utils'; ``` @@ -95,6 +98,11 @@ const decrypted = decrypt(payload, passphrase); const derivedKey = deriveSeedKey(seed, { salt: 'wdk-addressbook-v1', info: 'autobase-encryption' }); const { publicKey, secretKey } = deriveSeedKeyPair(seed, { salt: 'wdk-addressbook-v1', info: 'bootstrap-writer' }); +// Shamir Secret Sharing — split a mnemonic into 5 shares, any 3 reconstruct it +const shares = await splitMnemonic(mnemonic, { shares: 5, threshold: 3 }); +const recovered = await combineMnemonic(shares.slice(0, 3)); +console.log(recovered === mnemonic); // true + ``` ## 📚 API Reference @@ -206,6 +214,18 @@ Derives a deterministic ed25519 keypair from a seed. Output is byte-compatible w - **options**: `{ salt, info }` — domain-separation labels (key length is fixed at 32 bytes). - **Returns**: `{ publicKey: Uint8Array, secretKey: Uint8Array }` — `publicKey` is 32 bytes; `secretKey` is 64 bytes (`seed32 || publicKey`). +### `splitMnemonic(mnemonic: string, options: SplitOptions)` +Splits a BIP-39 mnemonic into hex-encoded Shamir shares. The mnemonic is decoded to its raw BIP-39 entropy (16–32 bytes) and prefixed with a 4-byte integrity checksum before splitting, so only that is shared and the phrase is re-derived on `combineMnemonic`. +- **mnemonic**: A valid BIP-39 mnemonic (12, 15, 18, 21, or 24 words). Leading/trailing/repeated whitespace is normalized; an invalid checksum, a non-wordlist word, or a bad word count is rejected here. +- **options**: `{ shares, threshold }` — `shares` is the total number of shares to create (n, `2..255`); `threshold` is the minimum needed to reconstruct (k, `2..shares`). +- **Returns**: `Promise` — an array of `shares` hex-encoded strings (~21 bytes each for a 12-word mnemonic: 16-byte entropy + 4-byte checksum + index). + +### `combineMnemonic(shares: string[])` +Reconstructs a BIP-39 mnemonic from Shamir shares. At least `threshold` shares must be supplied; share hex is case-insensitive. +- **shares**: Array of hex-encoded shares produced by `splitMnemonic` (at least 2). +- **Returns**: `Promise` — the reconstructed BIP-39 mnemonic. +- **Integrity**: The embedded checksum is verified, so wrong, corrupted, or insufficient shares are rejected instead of returning an incorrect phrase. This is error detection, not authentication against maliciously crafted shares. + ## 🛠️ Development ### Testing diff --git a/index.js b/index.js index 3d33848..b0a30c0 100644 --- a/index.js +++ b/index.js @@ -4,3 +4,4 @@ export * from './src/bolt11/index.js' export * from './src/encryption/index.js' export * from './src/key-derivation/index.js' export * from './src/bip21/index.js' +export * from './src/shamir/index.js' diff --git a/package-lock.json b/package-lock.json index e93fd40..62b0cd9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,7 +13,9 @@ "@noble/curves": "^2.2.0", "@noble/hashes": "^2.2.0", "@scure/base": "^2.2.0", - "bare-node-runtime": "^1.4.0" + "@scure/bip39": "^2.2.0", + "bare-node-runtime": "^1.4.0", + "shamir-secret-sharing": "^0.0.4" }, "devDependencies": { "bolt11": "^1.4.1", @@ -54,6 +56,7 @@ "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", @@ -1112,6 +1115,19 @@ "url": "https://paulmillr.com/funding/" } }, + "node_modules/@scure/bip39": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@scure/bip39/-/bip39-2.2.0.tgz", + "integrity": "sha512-T/Bj/YvYMNkIPq6EENO6/rcs2e7qTNuyoUXf0KBFDmp0ZDu0H2X4Lq6yC3i0c8PcWkov5EbW+yQZZbdMmk154A==", + "license": "MIT", + "dependencies": { + "@noble/hashes": "2.2.0", + "@scure/base": "2.2.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, "node_modules/@sinclair/typebox": { "version": "0.27.10", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.10.tgz", @@ -1285,6 +1301,7 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -1713,6 +1730,7 @@ "resolved": "https://registry.npmjs.org/bare-abort-controller/-/bare-abort-controller-1.0.0.tgz", "integrity": "sha512-RSUPsS16TC0EfqfjZUlQwLAtFCUUl7NA0CYaE4/Sl9ExFx3q0WIogBHmW5Zn16NRZUHDJqEffmtnvIyw6LFUjA==", "license": "Apache-2.0", + "peer": true, "dependencies": { "bare-events": "^2.7.0" } @@ -2463,6 +2481,7 @@ "resolved": "https://registry.npmjs.org/bare-tcp/-/bare-tcp-2.5.0.tgz", "integrity": "sha512-lwUy3jSVoloVBbCCyPFmmqT1KaeBk/XEkpLMHU+BCap8WNXc48iQfiWEQYgJkCRYuP6vnkZ0XHCLY222TJ29Wg==", "license": "Apache-2.0", + "peer": true, "dependencies": { "bare-dns": "^2.0.4", "bare-events": "^2.5.4", @@ -2785,6 +2804,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.10.12", "caniuse-lite": "^1.0.30001782", @@ -3627,6 +3647,7 @@ "deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.6.1", @@ -3834,6 +3855,7 @@ "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@rtsao/scc": "^1.1.0", "array-includes": "^3.1.9", @@ -3891,6 +3913,7 @@ "integrity": "sha512-jDex9s7D/Qial8AGVIHq4W7NswpUD5DPDL2RH8Lzd9EloWUuvUkHfv4FRLMipH5q2UtyurorBkPeNi1wVWNh3Q==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "builtins": "^5.0.1", "eslint-plugin-es": "^4.1.0", @@ -3930,6 +3953,7 @@ "integrity": "sha512-57Zzfw8G6+Gq7axm2Pdo3gW/Rx3h9Yywgn61uE/3elTCOePEHVrn2i5CdfBwA1BLK0Q0WqctICIUSqXZW/VprQ==", "dev": true, "license": "ISC", + "peer": true, "engines": { "node": "^12.22.0 || ^14.17.0 || >=16.0.0" }, @@ -3946,6 +3970,7 @@ "integrity": "sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "array-includes": "^3.1.8", "array.prototype.findlast": "^1.2.5", @@ -7578,6 +7603,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/shamir-secret-sharing": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/shamir-secret-sharing/-/shamir-secret-sharing-0.0.4.tgz", + "integrity": "sha512-ui8u/cIg2j16b9on/LH3V/glL6wHdwiTo/Iajzqx0STniBvuOzl6VYUxhrow3waF0dggovJfYiBZSWfTAN4XLQ==", + "license": "Apache-2.0" + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", diff --git a/package.json b/package.json index 0458fb2..96d4635 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,9 @@ "@noble/curves": "^2.2.0", "@noble/hashes": "^2.2.0", "@scure/base": "^2.2.0", - "bare-node-runtime": "^1.4.0" + "@scure/bip39": "^2.2.0", + "bare-node-runtime": "^1.4.0", + "shamir-secret-sharing": "^0.0.4" }, "devDependencies": { "cross-env": "7.0.3", diff --git a/src/shamir/index.js b/src/shamir/index.js new file mode 100644 index 0000000..41932b9 --- /dev/null +++ b/src/shamir/index.js @@ -0,0 +1,166 @@ +// Copyright 2026 Tether Operations Limited +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +'use strict' + +import { split as shamirSplit, combine as shamirCombine } from 'shamir-secret-sharing' +import { mnemonicToEntropy, entropyToMnemonic } from '@scure/bip39' +import { wordlist } from '@scure/bip39/wordlists/english.js' +import { sha256 } from '@noble/hashes/sha2.js' +import { bytesToHex, hexToBytes, clean } from '@noble/hashes/utils.js' + +const MAX_SHARES = 255 + +// Bytes of a SHA-256 digest prefixed to the entropy before splitting. Shamir +// shares are otherwise unauthenticated, so this lets combine detect a wrong or +// corrupted share set (error detection, not defense against forged shares). +const CHECKSUM_LEN = 4 + +/** + * Prefixes the entropy with a truncated SHA-256 checksum. + * @param {Uint8Array} entropy + * @returns {Uint8Array} `checksum || entropy` + */ +function attachChecksum (entropy) { + const secret = new Uint8Array(CHECKSUM_LEN + entropy.length) + secret.set(sha256(entropy).subarray(0, CHECKSUM_LEN)) + secret.set(entropy, CHECKSUM_LEN) + return secret +} + +/** + * Separates a reconstructed `checksum || entropy` secret and verifies the checksum. + * @param {Uint8Array} secret + * @returns {Uint8Array} The verified entropy (a view into `secret`). + * @throws If the secret is too short or the checksum does not match. + */ +function verifyChecksum (secret) { + if (secret.length <= CHECKSUM_LEN) throw new Error('reconstructed secret is too short') + const entropy = secret.subarray(CHECKSUM_LEN) + const expected = sha256(entropy).subarray(0, CHECKSUM_LEN) + for (let i = 0; i < CHECKSUM_LEN; i++) { + if (secret[i] !== expected[i]) throw new Error('checksum mismatch') + } + return entropy +} + +/** + * @typedef {Object} SplitOptions + * @property {number} shares - Total number of shares to create (n). 2..255. + * @property {number} threshold - Minimum shares needed to reconstruct (k). 2..shares. + */ + +/** + * @param {SplitOptions} [options] + * @returns {{ shares: number, threshold: number }} + */ +function validateSplitOptions (options) { + if (!options || typeof options !== 'object') { + throw new Error('Options must be an object with shares and threshold properties') + } + + const { shares, threshold } = options + + if (!Number.isInteger(shares)) throw new Error('shares must be an integer') + if (!Number.isInteger(threshold)) throw new Error('threshold must be an integer') + if (shares < 2) throw new Error('shares must be at least 2') + if (threshold < 2) throw new Error('threshold must be at least 2') + if (threshold > shares) throw new Error('threshold cannot be greater than shares') + if (shares > MAX_SHARES) throw new Error(`shares cannot exceed ${MAX_SHARES}`) + + return { shares, threshold } +} + +/** + * Validates the shares array and returns lowercase-normalized hex strings. + * Accepts either case; `hexToBytes` (via the native `Uint8Array.fromHex`) only + * decodes lowercase, so we normalize here to keep behavior runtime-independent. + * + * @param {string[]} shares + * @returns {string[]} + */ +function validateShares (shares) { + if (!Array.isArray(shares)) throw new Error('Shares must be an array') + if (shares.length < 2) throw new Error('At least 2 shares are required to reconstruct the secret') + + return shares.map((share, i) => { + if (typeof share !== 'string') throw new Error(`Share at index ${i} must be a string`) + if (share.length === 0 || share.length % 2 !== 0 || !/^[0-9a-fA-F]+$/.test(share)) { + throw new Error(`Share at index ${i} is not a valid hex string`) + } + return share.toLowerCase() + }) +} + +/** + * Splits a BIP-39 mnemonic into hex-encoded Shamir shares. + * + * The mnemonic is decoded to its raw BIP-39 entropy (16-32 bytes) before + * splitting, so an invalid checksum or a non-wordlist word is rejected here. + * A 4-byte integrity checksum is prefixed to the entropy so that a wrong or + * corrupted share set is rejected by {@link combineMnemonic}; the phrase itself + * is re-derived on combine, not stored in the shares. + * + * @param {string} mnemonic - A valid BIP-39 mnemonic (12, 15, 18, 21, or 24 words). + * @param {SplitOptions} options - Split configuration. + * @returns {Promise} Hex-encoded shares, `options.shares` of them. + */ +export async function splitMnemonic (mnemonic, options) { + const { shares, threshold } = validateSplitOptions(options) + if (typeof mnemonic !== 'string') throw new Error('Mnemonic must be a string') + + const normalized = mnemonic.trim().replace(/\s+/g, ' ') + let entropy + try { + entropy = mnemonicToEntropy(normalized, wordlist) + } catch { + throw new Error('Invalid mnemonic: expected a valid BIP-39 phrase') + } + + let secret + try { + secret = attachChecksum(entropy) + const shareArrays = await shamirSplit(secret, shares, threshold) + return shareArrays.map((share) => bytesToHex(share)) + } finally { + clean(entropy) + if (secret) clean(secret) + } +} + +/** + * Reconstructs a BIP-39 mnemonic from Shamir shares. At least `threshold` + * shares must be supplied. + * + * The 4-byte checksum embedded at split time is verified here, so wrong, + * corrupted, or insufficient shares are rejected instead of returning an + * incorrect phrase. This is error detection, not authentication against + * maliciously crafted shares. + * + * @param {string[]} shares - Hex-encoded shares produced by {@link splitMnemonic}. + * @returns {Promise} The reconstructed BIP-39 mnemonic. + */ +export async function combineMnemonic (shares) { + const normalized = validateShares(shares) + const shareArrays = normalized.map((share) => hexToBytes(share)) + + let secret + try { + secret = await shamirCombine(shareArrays) + return entropyToMnemonic(verifyChecksum(secret), wordlist) + } catch { + throw new Error('Invalid shares: could not reconstruct a valid mnemonic') + } finally { + if (secret) clean(secret) + } +} diff --git a/tests/shamir.test.js b/tests/shamir.test.js new file mode 100644 index 0000000..6b9bfd0 --- /dev/null +++ b/tests/shamir.test.js @@ -0,0 +1,177 @@ +import { splitMnemonic, combineMnemonic } from '../src/shamir/index.js' + +// Well-known BIP-39 test vectors (all-`abandon` prefixes with valid checksums). +// NOT for production use. +const MNEMONICS = { + 12: 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about', + 15: 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon address', + 18: 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon agent', + 21: 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon admit', + 24: 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon art' +} + +const SCHEMES = [ + { shares: 2, threshold: 2 }, + { shares: 5, threshold: 3 }, + { shares: 10, threshold: 5 } +] + +// Deterministic subset picker: take `k` evenly-spread shares from the set. +function pick (shares, k) { + const step = Math.floor(shares.length / k) + const subset = [] + for (let i = 0; i < k; i++) subset.push(shares[i * step]) + return subset +} + +describe('shamir', () => { + describe('round-trip across word lengths and schemes', () => { + for (const [words, mnemonic] of Object.entries(MNEMONICS)) { + for (const { shares, threshold } of SCHEMES) { + it(`reconstructs a ${words}-word mnemonic under ${threshold}-of-${shares}`, async () => { + const parts = await splitMnemonic(mnemonic, { shares, threshold }) + + expect(parts).toHaveLength(shares) + parts.forEach((part) => expect(part).toMatch(/^[0-9a-f]+$/)) + + const recovered = await combineMnemonic(pick(parts, threshold)) + expect(recovered).toBe(mnemonic) + }) + } + } + }) + + describe('combineMnemonic', () => { + it('reconstructs from any threshold-sized subset', async () => { + const mnemonic = MNEMONICS[12] + const parts = await splitMnemonic(mnemonic, { shares: 5, threshold: 3 }) + + expect(await combineMnemonic([parts[0], parts[2], parts[4]])).toBe(mnemonic) + expect(await combineMnemonic([parts[1], parts[3], parts[4]])).toBe(mnemonic) + }) + + it('reconstructs from all shares', async () => { + const mnemonic = MNEMONICS[24] + const parts = await splitMnemonic(mnemonic, { shares: 3, threshold: 2 }) + expect(await combineMnemonic(parts)).toBe(mnemonic) + }) + }) + + describe('share size is entropy-based', () => { + it('produces 21-byte shares for a 12-word mnemonic (16-byte entropy + 4-byte checksum + index)', async () => { + const parts = await splitMnemonic(MNEMONICS[12], { shares: 3, threshold: 2 }) + parts.forEach((part) => expect(part.length / 2).toBe(21)) + }) + + it('produces 37-byte shares for a 24-word mnemonic (32-byte entropy + 4-byte checksum + index)', async () => { + const parts = await splitMnemonic(MNEMONICS[24], { shares: 3, threshold: 2 }) + parts.forEach((part) => expect(part.length / 2).toBe(37)) + }) + }) + + describe('integrity checksum on combine', () => { + it('rejects a corrupted share instead of returning a wrong phrase', async () => { + const parts = await splitMnemonic(MNEMONICS[12], { shares: 3, threshold: 2 }) + const corrupted = [...parts] + corrupted[0] = (corrupted[0][0] === '0' ? '1' : '0') + corrupted[0].slice(1) + await expect(combineMnemonic([corrupted[0], corrupted[1]])).rejects.toThrow('Invalid shares') + }) + + it('rejects a below-threshold share subset', async () => { + const parts = await splitMnemonic(MNEMONICS[12], { shares: 5, threshold: 3 }) + await expect(combineMnemonic([parts[0], parts[1]])).rejects.toThrow('Invalid shares') + }) + }) + + describe('mnemonic normalization on split', () => { + it('tolerates leading, trailing, and repeated whitespace', async () => { + const messy = `\t ${MNEMONICS[12].replace(/ /g, ' ')}\n` + const parts = await splitMnemonic(messy, { shares: 3, threshold: 2 }) + expect(await combineMnemonic(parts)).toBe(MNEMONICS[12]) + }) + }) + + describe('mnemonic validation on split', () => { + it('rejects a mnemonic with a bad checksum', async () => { + const badChecksum = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon' + await expect(splitMnemonic(badChecksum, { shares: 3, threshold: 2 })).rejects.toThrow('Invalid mnemonic') + }) + + it('rejects a mnemonic containing a non-wordlist word', async () => { + const nonWordlist = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon zzzzzz' + await expect(splitMnemonic(nonWordlist, { shares: 3, threshold: 2 })).rejects.toThrow('Invalid mnemonic') + }) + + it('rejects an invalid word count', async () => { + await expect(splitMnemonic('abandon abandon abandon', { shares: 3, threshold: 2 })).rejects.toThrow('Invalid mnemonic') + }) + + it('rejects a non-string mnemonic', async () => { + await expect(splitMnemonic(12345, { shares: 3, threshold: 2 })).rejects.toThrow('Mnemonic must be a string') + }) + }) + + describe('option validation on split', () => { + const mnemonic = MNEMONICS[12] + + it('requires an options object', async () => { + await expect(splitMnemonic(mnemonic)).rejects.toThrow('Options must be an object') + }) + + it('rejects non-integer shares', async () => { + await expect(splitMnemonic(mnemonic, { shares: 3.5, threshold: 2 })).rejects.toThrow('shares must be an integer') + }) + + it('rejects shares below 2', async () => { + await expect(splitMnemonic(mnemonic, { shares: 1, threshold: 1 })).rejects.toThrow('shares must be at least 2') + }) + + it('rejects threshold below 2', async () => { + await expect(splitMnemonic(mnemonic, { shares: 3, threshold: 1 })).rejects.toThrow('threshold must be at least 2') + }) + + it('rejects threshold greater than shares', async () => { + await expect(splitMnemonic(mnemonic, { shares: 3, threshold: 5 })).rejects.toThrow('threshold cannot be greater than shares') + }) + + it('rejects shares above 255', async () => { + await expect(splitMnemonic(mnemonic, { shares: 256, threshold: 2 })).rejects.toThrow('shares cannot exceed 255') + }) + }) + + describe('share validation on combine', () => { + it('rejects a non-array input', async () => { + await expect(combineMnemonic('not-an-array')).rejects.toThrow('Shares must be an array') + }) + + it('rejects fewer than 2 shares', async () => { + const parts = await splitMnemonic(MNEMONICS[12], { shares: 5, threshold: 3 }) + await expect(combineMnemonic([parts[0]])).rejects.toThrow('At least 2 shares are required') + }) + + it('rejects a non-string share', async () => { + await expect(combineMnemonic(['abcd', 1234, 'ef01'])).rejects.toThrow('must be a string') + }) + + it('rejects an invalid hex share', async () => { + await expect(combineMnemonic(['abcd', 'xyz9', 'ef01'])).rejects.toThrow('not a valid hex string') + }) + + it('accepts uppercase hex shares', async () => { + const parts = await splitMnemonic(MNEMONICS[12], { shares: 3, threshold: 2 }) + const upper = parts.map((part) => part.toUpperCase()) + expect(await combineMnemonic(upper)).toBe(MNEMONICS[12]) + }) + + it('rejects duplicate shares', async () => { + const parts = await splitMnemonic(MNEMONICS[12], { shares: 3, threshold: 2 }) + await expect(combineMnemonic([parts[0], parts[0]])).rejects.toThrow('Invalid shares') + }) + + it('rejects shares of differing lengths', async () => { + const short = await splitMnemonic(MNEMONICS[12], { shares: 2, threshold: 2 }) + const long = await splitMnemonic(MNEMONICS[24], { shares: 2, threshold: 2 }) + await expect(combineMnemonic([short[0], long[0]])).rejects.toThrow('Invalid shares') + }) + }) +}) diff --git a/types/index.d.ts b/types/index.d.ts index 5ac4621..c1aaa89 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -4,3 +4,4 @@ export * from "./src/bolt11/index.js"; export * from "./src/encryption/index.js"; export * from "./src/key-derivation/index.js"; export * from "./src/bip21/index.js"; +export * from "./src/shamir/index.js"; diff --git a/types/src/shamir/index.d.ts b/types/src/shamir/index.d.ts new file mode 100644 index 0000000..94d6771 --- /dev/null +++ b/types/src/shamir/index.d.ts @@ -0,0 +1,37 @@ +/** + * Splits a BIP-39 mnemonic into hex-encoded Shamir shares. + * + * The mnemonic is decoded to its raw BIP-39 entropy (16-32 bytes) before + * splitting, so an invalid checksum or a non-wordlist word is rejected here. + * A 4-byte integrity checksum is prefixed to the entropy so that a wrong or + * corrupted share set is rejected by {@link combineMnemonic}; the phrase itself + * is re-derived on combine, not stored in the shares. + * + * @param {string} mnemonic - A valid BIP-39 mnemonic (12, 15, 18, 21, or 24 words). + * @param {SplitOptions} options - Split configuration. + * @returns {Promise} Hex-encoded shares, `options.shares` of them. + */ +export function splitMnemonic(mnemonic: string, options: SplitOptions): Promise; +/** + * Reconstructs a BIP-39 mnemonic from Shamir shares. At least `threshold` + * shares must be supplied. + * + * The 4-byte checksum embedded at split time is verified here, so wrong, + * corrupted, or insufficient shares are rejected instead of returning an + * incorrect phrase. This is error detection, not authentication against + * maliciously crafted shares. + * + * @param {string[]} shares - Hex-encoded shares produced by {@link splitMnemonic}. + * @returns {Promise} The reconstructed BIP-39 mnemonic. + */ +export function combineMnemonic(shares: string[]): Promise; +export type SplitOptions = { + /** + * - Total number of shares to create (n). 2..255. + */ + shares: number; + /** + * - Minimum shares needed to reconstruct (k). 2..shares. + */ + threshold: number; +};