@bsv/wallet-toolbox
Version:
BRC100 conforming wallet, wallet storage and wallet signer components
363 lines • 14.9 kB
JavaScript
"use strict";
/**
* ShamirWalletManager
*
* A wallet manager that uses Shamir Secret Sharing for key recovery
* instead of password-derived keys and on-chain UMP tokens.
*
* Security improvements over CWIStyleWalletManager:
* - No password enumeration attacks possible (no password-derived keys)
* - No encrypted key material stored on-chain
* - Server only holds 1 share (cannot reconstruct alone)
* - Defense-in-depth with mouse entropy + CSPRNG for key generation
*
* Default configuration (2-of-3):
* - Share 1 (server): Stored on WAB server, released only after OTP verification
* - Shares 2..n (user): Application decides how to store (print, password manager, etc.)
*
* The threshold and total shares are configurable. WAB always stores exactly one share.
*/
Object.defineProperty(exports, "__esModule", { value: true });
exports.ShamirWalletManager = void 0;
const sdk_1 = require("@bsv/sdk");
const PrivilegedKeyManager_1 = require("./sdk/PrivilegedKeyManager");
const WABClient_1 = require("./wab-client/WABClient");
const EntropyCollector_1 = require("./entropy/EntropyCollector");
class ShamirWalletManager {
constructor(config) {
var _a, _b;
this.config = config;
this.wabClient = new WABClient_1.WABClient(config.wabServerUrl);
this.entropyCollector = new EntropyCollector_1.EntropyCollector();
// Set defaults and validate
this.threshold = (_a = config.threshold) !== null && _a !== void 0 ? _a : 2;
this.totalShares = (_b = config.totalShares) !== null && _b !== void 0 ? _b : 3;
if (this.threshold < 2) {
throw new Error('Threshold must be at least 2');
}
if (this.totalShares < 3) {
throw new Error('Total shares must be at least 3');
}
// User must have at least threshold shares to recover independently
// (server holds 1 share, user holds totalShares - 1)
// This prevents WAB from becoming a custodian
const userShareCount = this.totalShares - 1;
if (userShareCount < this.threshold) {
throw new Error(`User must have at least ${this.threshold} shares to recover independently. ` +
`With ${this.totalShares} total shares and server holding 1, user only gets ${userShareCount}. ` +
`Increase totalShares to at least ${this.threshold + 1}.`);
}
}
/**
* Get the configured threshold
*/
getThreshold() {
return this.threshold;
}
/**
* Get the configured total shares
*/
getTotalShares() {
return this.totalShares;
}
/**
* Reset the entropy collector (e.g., if user wants to start over)
*/
resetEntropy() {
this.entropyCollector.reset();
}
/**
* Add a mouse movement sample for entropy collection
* Call this from your UI's mousemove handler
*/
addMouseEntropy(x, y) {
return this.entropyCollector.addMouseSample(x, y);
}
/**
* Check if enough entropy has been collected
*/
hasEnoughEntropy() {
return this.entropyCollector.isComplete();
}
/**
* Get entropy collection progress
*/
getEntropyProgress() {
return this.entropyCollector.getProgress();
}
/**
* Collect entropy from browser mouse movements
* Convenience method that sets up event listeners automatically
*/
async collectEntropyFromBrowser(element, onProgress) {
await this.entropyCollector.collectFromBrowser(element, onProgress);
}
/**
* Generate a user ID hash from a private key
* This is used to identify the user on the WAB server without revealing the key
*/
generateUserIdHash(privateKey) {
const publicKey = privateKey.toPublicKey().toString();
const hash = sdk_1.Hash.sha256(sdk_1.Utils.toArray(publicKey, 'utf8'));
return sdk_1.Utils.toHex(hash);
}
/**
* Create a new wallet with Shamir key split
*
* Flow:
* 1. Generate private key from entropy
* 2. Split into Shamir shares (threshold-of-totalShares)
* 3. Store first share on WAB server (requires OTP verification)
* 4. Return remaining shares for application to handle
*
* @param authPayload Auth method specific payload (e.g., { phoneNumber: "+1...", otp: "123456" })
* @param onUserSharesReady Callback when user shares are ready - return true to confirm saved
* @returns Result containing user shares (server share already stored)
*/
async createNewWallet(authPayload, onUserSharesReady) {
// 1. Generate private key from entropy (mixed with CSPRNG)
const entropy = this.entropyCollector.generateEntropy();
const privateKey = new sdk_1.PrivateKey(Array.from(entropy));
// 2. Split into Shamir shares
const shares = privateKey.toBackupShares(this.threshold, this.totalShares);
// First share goes to server, rest go to user
const serverShare = shares[0];
const userShares = shares.slice(1);
// 3. Generate user ID hash for server identification
const userIdHash = this.generateUserIdHash(privateKey);
// 4. Store server share on WAB server (requires OTP verification)
const storeResult = await this.wabClient.storeShare(this.config.authMethodType, authPayload, serverShare, userIdHash);
if (!storeResult.success) {
throw new Error(storeResult.message || 'Failed to store share on server');
}
// 5. Present user shares for application to handle
const sharesSaved = await onUserSharesReady(userShares, this.threshold, this.totalShares);
if (!sharesSaved) {
console.warn('User shares may not have been saved. Recovery may be limited.');
}
// Store state
this.privateKey = privateKey;
this.userIdHash = userIdHash;
return {
userShares,
userIdHash,
privateKey,
threshold: this.threshold,
totalShares: this.totalShares
};
}
/**
* Start OTP verification for share retrieval
* Call this before recoverWithSharesBC
*/
async startOTPVerification(payload) {
if (!this.userIdHash) {
throw new Error('User ID hash not set. Call setUserIdHash first for recovery.');
}
const result = await this.wabClient.startShareAuth(this.config.authMethodType, this.userIdHash, payload);
if (!result.success) {
throw new Error(result.message || 'Failed to start OTP verification');
}
}
/**
* Set the user ID hash for recovery operations
* This can be computed from Share A or C (both contain the same threshold/integrity)
*/
setUserIdHash(userIdHash) {
this.userIdHash = userIdHash;
}
/**
* Recover wallet using user shares plus the server share
* Requires OTP verification to retrieve the server share
*
* @param userShares Array of user-held shares (need threshold-1 shares)
* @param authPayload Contains OTP code and auth method data
*/
async recoverWithServerShare(userShares, authPayload) {
// Validate share formats
for (const share of userShares) {
this.validateShareFormat(share);
}
if (!this.userIdHash) {
throw new Error('User ID hash not set. Call setUserIdHash first.');
}
// Need threshold-1 user shares (server provides 1)
const threshold = this.getThresholdFromShare(userShares[0]);
if (userShares.length < threshold - 1) {
throw new Error(`Need at least ${threshold - 1} user shares to recover with server share. Got ${userShares.length}.`);
}
// Retrieve server share
const retrieveResult = await this.wabClient.retrieveShare(this.config.authMethodType, authPayload, this.userIdHash);
if (!retrieveResult.success || !retrieveResult.shareB) {
throw new Error(retrieveResult.message || 'Failed to retrieve share from server');
}
// Combine server share with user shares
const allShares = [retrieveResult.shareB, ...userShares.slice(0, threshold - 1)];
// Reconstruct private key
const privateKey = sdk_1.PrivateKey.fromBackupShares(allShares);
// Verify reconstruction by checking user ID hash
const reconstructedHash = this.generateUserIdHash(privateKey);
if (reconstructedHash !== this.userIdHash) {
throw new Error('Share reconstruction failed: integrity check failed');
}
this.privateKey = privateKey;
return privateKey;
}
/**
* Recover wallet using only user-held shares (no server interaction)
* Requires at least threshold shares
*
* @param userShares Array of user-held shares (need at least threshold shares)
*/
async recoverWithUserShares(userShares) {
if (userShares.length < 2) {
throw new Error('Need at least 2 shares to recover');
}
// Validate share formats
for (const share of userShares) {
this.validateShareFormat(share);
}
// Get threshold from share
const threshold = this.getThresholdFromShare(userShares[0]);
if (userShares.length < threshold) {
throw new Error(`Need at least ${threshold} shares to recover. Got ${userShares.length}.`);
}
// Reconstruct private key using threshold shares
const privateKey = sdk_1.PrivateKey.fromBackupShares(userShares.slice(0, threshold));
// Compute and store user ID hash
this.userIdHash = this.generateUserIdHash(privateKey);
this.privateKey = privateKey;
return privateKey;
}
/**
* Extract threshold from a share (format: x.y.threshold.integrity)
*/
getThresholdFromShare(share) {
const parts = share.split('.');
if (parts.length !== 4) {
throw new Error('Invalid share format');
}
const threshold = parseInt(parts[2], 10);
if (isNaN(threshold) || threshold < 2) {
throw new Error('Invalid threshold in share');
}
return threshold;
}
/**
* Build the underlying wallet after key recovery
*/
async buildWallet() {
if (!this.privateKey) {
throw new Error('No private key available. Create or recover wallet first.');
}
// Create privileged key manager for secure key operations
const privilegedKeyManager = new PrivilegedKeyManager_1.PrivilegedKeyManager(async () => this.privateKey);
// Build the wallet
this.underlying = await this.config.walletBuilder(this.privateKey, privilegedKeyManager);
return this.underlying;
}
/**
* Get the underlying wallet (must call buildWallet first)
*/
getWallet() {
if (!this.underlying) {
throw new Error('Wallet not built. Call buildWallet first.');
}
return this.underlying;
}
/**
* Rotate keys - generate new key and update server share
* User must save new user shares
*
* @param authPayload Contains OTP code and auth method data
* @param onUserSharesReady Callback when new user shares are ready
*/
async rotateKeys(authPayload, onUserSharesReady) {
if (!this.userIdHash) {
throw new Error('User ID hash not set. Cannot rotate keys.');
}
// Require fresh entropy for key rotation
if (!this.hasEnoughEntropy()) {
throw new Error('Collect entropy before key rotation');
}
// Generate new private key
const entropy = this.entropyCollector.generateEntropy();
const newPrivateKey = new sdk_1.PrivateKey(Array.from(entropy));
// Split into new Shamir shares
const shares = newPrivateKey.toBackupShares(this.threshold, this.totalShares);
const serverShare = shares[0];
const userShares = shares.slice(1);
// Generate new user ID hash
const newUserIdHash = this.generateUserIdHash(newPrivateKey);
// Update server share
const updateResult = await this.wabClient.updateShare(this.config.authMethodType, authPayload, this.userIdHash, serverShare);
if (!updateResult.success) {
throw new Error(updateResult.message || 'Failed to update share on server');
}
// Present user shares
const sharesSaved = await onUserSharesReady(userShares, this.threshold, this.totalShares);
if (!sharesSaved) {
console.warn('User shares may not have been saved. Recovery may be limited.');
}
// Update state
this.privateKey = newPrivateKey;
this.userIdHash = newUserIdHash;
return {
userShares,
userIdHash: newUserIdHash,
privateKey: newPrivateKey,
threshold: this.threshold,
totalShares: this.totalShares
};
}
/**
* Validate Shamir share format
* Expected format: x.y.threshold.integrity (4 dot-separated parts)
*/
validateShareFormat(share) {
const parts = share.split('.');
if (parts.length !== 4) {
throw new Error(`Invalid share format. Expected 4 dot-separated parts, got ${parts.length}`);
}
const threshold = parseInt(parts[2], 10);
if (isNaN(threshold) || threshold < 2) {
throw new Error('Invalid share: threshold must be at least 2');
}
}
/**
* Check if the manager has a loaded private key
*/
hasPrivateKey() {
return this.privateKey !== undefined;
}
/**
* Get the user ID hash (for display or storage)
*/
getUserIdHash() {
return this.userIdHash;
}
/**
* Delete the user's account and stored share from the WAB server
* Requires OTP verification
*
* WARNING: This permanently deletes the server share.
* User must have enough remaining shares to meet the threshold for recovery.
*
* @param authPayload Contains OTP code and auth method data
*/
async deleteAccount(authPayload) {
if (!this.userIdHash) {
throw new Error('User ID hash not set. Cannot delete account.');
}
const result = await this.wabClient.deleteShamirUser(this.config.authMethodType, authPayload, this.userIdHash);
if (!result.success) {
throw new Error(result.message || 'Failed to delete account');
}
// Clear local state
this.privateKey = undefined;
this.userIdHash = undefined;
this.underlying = undefined;
}
}
exports.ShamirWalletManager = ShamirWalletManager;
//# sourceMappingURL=ShamirWalletManager.js.map