Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 22 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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

Expand Down Expand Up @@ -48,7 +49,9 @@ import {
encrypt,
decrypt,
deriveSeedKey,
deriveSeedKeyPair
deriveSeedKeyPair,
splitMnemonic,
combineMnemonic
} from '@tetherto/wdk-utils';
```

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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<string[]>` — 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<string>` — 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
Expand Down
1 change: 1 addition & 0 deletions index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'
33 changes: 32 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
166 changes: 166 additions & 0 deletions src/shamir/index.js
Original file line number Diff line number Diff line change
@@ -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<string[]>} 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<string>} 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)
}
}
Loading