tiny-crypto-suite
Version:
Tiny tools, big crypto — seamless encryption and certificate handling for modern web and Node apps.
412 lines (411 loc) • 17.5 kB
JavaScript
import { Buffer } from 'buffer';
import TinySecp256k1 from './index.mjs';
class TinyBtcSecp256k1 extends TinySecp256k1 {
/** @typedef {import('bs58check/index')} Bs58check */
/** @typedef {import('bech32/index')} Bech32 */
/** @typedef {import('elliptic').ec.KeyPair} KeyPair */
/** @typedef {import('./index.mjs').ValidationResult} ValidationResult */
/** @typedef {'p2pkh'|'bech32'} PubKeyTypes */
p2pkhPrefix = 0x00;
/**
* Creates an instance of TinyBtcSecp256k1.
*
* @param {Object} [options] - Optional parameters for the instance.
* @param {string|null} [options.prefix='bc'] - Crypto prefix used during message verification.
* @param {number|null} [options.p2pkhPrefix=0x00] - Crypto prefix used during message verification.
* @param {PubKeyTypes} [options.type=this.getType()] - The type of address to generate.
* @param {string|null} [options.msgPrefix='Bitcoin Signed Message:\n'] - Message prefix used during message signing.
* @param {string|null} [options.privateKey=null] - String representation of the private key.
* @param {BufferEncoding} [options.privateKeyEncoding='hex'] - Encoding used for the privateKey string.
*/
constructor({ type = 'bech32', p2pkhPrefix = 0x00, prefix = 'bc', msgPrefix = 'Bitcoin Signed Message:\n', privateKey = null, privateKeyEncoding = 'hex', } = {}) {
super({ type, prefix, msgPrefix, privateKey, privateKeyEncoding });
if (p2pkhPrefix !== null && typeof p2pkhPrefix !== 'number')
throw new Error('p2pkhPrefix must be a number or null');
if (typeof p2pkhPrefix === 'number')
this.p2pkhPrefix = p2pkhPrefix;
this.types['bech32'] = this.prefix;
this.prefixes[this.types['bech32']] = 'bech32';
this.types['p2pkh'] = String(this.p2pkhPrefix);
this.prefixes[this.types['p2pkh']] = 'p2pkh';
}
/**
* Returns the p2pkh prefix if it's a number.
*
* @returns {number}
* @throws {Error} If p2pkhPrefix is not a number.
*/
getP2pkhPrefix() {
if (typeof this.p2pkhPrefix !== 'number')
throw new Error('[getP2pkhPrefix] p2pkhPrefix must be a number.');
return this.p2pkhPrefix;
}
/**
* Initializes the internal elliptic key pair using the private key.
*
* @returns {Promise<KeyPair>} The elliptic key pair.
*/
async init() {
await Promise.all([this.fetchBs58check(), this.fetchElliptic(), this.fetchBech32()]);
const ec = this.getEc();
this.keyPair = ec.keyFromPrivate(this.privateKey);
return this.keyPair;
}
/**
* Dynamically imports the `bech32` module and stores it in the instance.
* Ensures the module is loaded only once (lazy singleton).
*
* @returns {Promise<{ base32: Bech32, base32m: Bech32 }>} The loaded `bech32` module.
*/
async fetchBech32() {
if (!this.bech32) {
const bech32 = await import(/* webpackMode: "eager" */ 'bech32').catch(() => {
console.warn('[Bech32] Warning: "bech32" is not installed. ' +
'Bech32 requires "bech32" to function properly. ' +
'Please install it with "npm install bech32".');
return null;
});
if (bech32) {
// @ts-ignore
this.bech32Module = bech32?.default ?? bech32;
this.bech32 = this.bech32Module.bech32;
this.bech32m = this.bech32Module.bech32m;
}
}
return { base32: this.getBech32(), base32m: this.getBech32m() };
}
/**
* Returns the initialized `bech32` instance from the bech32 module.
*
* @returns {Bech32} The bech32 instance.
* @throws Will throw an error if `bech32` is not initialized.
*/
getBech32m() {
if (typeof this.bech32m === 'undefined' || this.bech32m === null)
throw new Error(`Failed to initialize Bech32: Module is ${this.bech32m !== null ? 'undefined' : 'null'}.\n` +
'Please make sure "bech32" is installed.\n' +
'You can install it by running: npm install bech32');
// @ts-ignore
return this.bech32m;
}
/**
* Returns the initialized `bech32` instance from the bech32 module.
*
* @returns {Bech32} The bech32 instance.
* @throws Will throw an error if `bech32` is not initialized.
*/
getBech32() {
if (typeof this.bech32 === 'undefined' || this.bech32 === null)
throw new Error(`Failed to initialize Bech32: Module is ${this.bech32 !== null ? 'undefined' : 'null'}.\n` +
'Please make sure "bech32" is installed.\n' +
'You can install it by running: npm install bech32');
// @ts-ignore
return this.bech32;
}
/**
* Dynamically imports the `bs58check` module and stores it in the instance.
* Ensures the module is loaded only once (lazy singleton).
*
* @returns {Promise<Bs58check>} The loaded `bs58check` module.
*/
async fetchBs58check() {
if (!this.bs58check) {
const bs58check = await import(/* webpackMode: "eager" */ 'bs58check').catch(() => {
console.warn('[Bs58check] Warning: "bs58check" is not installed. ' +
'Bs58check requires "bs58check" to function properly. ' +
'Please install it with "npm install bs58check".');
return null;
});
if (bs58check) {
// @ts-ignore
this.bs58check = bs58check?.default ?? bs58check;
}
}
return this.getBs58check();
}
/**
* Returns the initialized `bs58check` instance from the bs58check module.
*
* @returns {Bs58check} The bs58check instance.
* @throws Will throw an error if `bs58check` is not initialized.
*/
getBs58check() {
if (typeof this.bs58check === 'undefined' || this.bs58check === null)
throw new Error(`Failed to initialize Bs58check: Module is ${this.bs58check !== null ? 'undefined' : 'null'}.\n` +
'Please make sure "bs58check" is installed.\n' +
'You can install it by running: npm install bs58check');
// @ts-ignore
return this.bs58check;
}
/**
* Returns the public key in hexadecimal format.
*
* @param {boolean} [compressed=true] - Whether to return the compressed version of the key.
* @returns {string} Hex representation of the public key.
*/
getPublicKeyHex(compressed = true) {
return this.getKeyPair().getPublic(compressed, 'hex');
}
/**
* Returns the public address derived from the public key.
*
* @param {string} [type=this.getType()] - The type of address to generate.
* @param {Buffer} [pubKey=this.getPublicKeyBuffer()] - The pubKey buffer.
* @returns {string} The public address.
*/
getAddress(pubKey = this.getPublicKeyBuffer(), type = this.getType()) {
// @ts-ignore
if (this.isType(type) && typeof this.#pubKeyTo[type] === 'function')
// @ts-ignore
return this.#pubKeyTo[type](pubKey);
// Nope
else
throw new Error(`Unsupported address type: ${type}`);
}
/**
* Bitcoin-style varString
* @param {string} str
* @returns {Buffer}
*/
#varStringBuffer(str) {
const buffer = Buffer.from(str, 'utf8');
return Buffer.concat([this.#varIntBuffer(buffer.length), buffer]);
}
/**
* Bitcoin-style varInt encoding
* @param {number} i
* @returns {Buffer}
*/
#varIntBuffer(i) {
if (i < 0xfd)
return Buffer.from([i]);
if (i <= 0xffff) {
const b = Buffer.alloc(3);
b[0] = 0xfd;
b.writeUInt16LE(i, 1);
return b;
}
throw new Error('Too long');
}
/**
* Internal: Computes the Bitcoin-style message hash using double SHA256.
*
* @type {(message: string|Buffer, msgPrefix?: string) => Buffer}
*/
#getMessageHash(message, msgPrefix = '') {
const buffMsg = !Buffer.isBuffer(message) ? message : message.toString('utf8');
const fullMessage = Buffer.concat([
this.#varStringBuffer(msgPrefix),
this.#varStringBuffer(buffMsg),
]);
return TinySecp256k1.doubleSha256(fullMessage);
}
#pubKeyTo = {
/**
* Generates P2PKH address from public key
* @param {Buffer} pubKey
* @returns {string}
*/
p2pkh: (pubKey) => {
const bs58check = this.getBs58check();
const pubkeyHash = TinySecp256k1.hash160(pubKey);
const versioned = Buffer.concat([Buffer.from([this.getP2pkhPrefix()]), pubkeyHash]); // Mainnet P2PKH
return bs58check.encode(versioned);
},
/**
* Generates Bech32 address from public key
* @param {Buffer} pubKey
* @returns {string}
*/
bech32: (pubKey) => {
const bech32 = this.getBech32();
const ripemd160 = TinySecp256k1.hash160(pubKey);
// Convert for bech32: witness version 0 + program (ripemd160 result)
const words = bech32.toWords(ripemd160);
words.unshift(0x00); // witness version
return bech32.encode(this.getPrefix(), words); // bc = mainnet
},
};
#toHash160 = {
/**
* Extracts hash160 from a P2PKH address
* @param {string} address
* @returns {Buffer}
*/
p2pkh: (address) => {
const bs58check = this.getBs58check();
const decoded = bs58check.decode(address);
const prefix = decoded[0];
if (prefix !== this.getP2pkhPrefix()) {
throw new Error('Invalid prefix for P2PKH address');
}
return decoded.subarray(1); // remove prefix, return hash160
},
/**
* Extracts hash160 from a Bech32 address
* @param {string} address
* @returns {Buffer}
*/
bech32: (address) => {
const bech32 = this.getBech32();
const { prefix, words } = bech32.decode(address);
if (prefix !== this.getPrefix()) {
throw new Error('Invalid Bech32 prefix');
}
const version = words.shift(); // witness version
if (version !== 0x00)
throw new Error('Unsupported witness version');
return Buffer.from(bech32.fromWords(words)); // this is the original hash160
},
};
/**
* Returns the address in hash160 format.
*
* @param {string} address - Whether to return the compressed version of the key.
* @param {string} [type=this.getType()] - The type of address to generate.
* @returns {Buffer} Hash160 representation of the public key.
*/
addressToVanilla(address, type = this.getType()) {
// @ts-ignore
if (this.isType(type) && typeof this.#toHash160[type] === 'function')
// @ts-ignore
return this.#toHash160[type](address);
// Nope
else
throw new Error(`Unsupported address type: ${type}`);
}
#validators = {
/**
* Validates a Bech32 (SegWit) Bitcoin address.
*
* This function checks whether the provided address is a valid Bech32 address
* according to the expected prefix and format. It supports witness version 0
* with either a 20-byte (P2WPKH) or 32-byte (P2WSH) program.
*
* @param {string} address - The address to validate.
* @returns {ValidationResult} Object containing validation result, address type, and optional hash.
*/
bech32: (address) => {
/** @type {ValidationResult} */
const result = { valid: false, type: null };
const bech32 = this.getBech32();
const { prefix, words } = bech32.decode(address);
const version = words[0];
const program = bech32.fromWords(words.slice(1));
if (prefix === this.getPrefix() &&
version === 0x00 &&
(program.length === 20 || program.length === 32)) {
result.valid = true;
result.type = 'bech32';
}
return result;
},
/**
* Validates a Base58Check (P2PKH) Bitcoin address.
*
* This function checks whether the provided address is a valid legacy P2PKH
* Base58Check address using the configured prefix and address length.
*
* @param {string} address - The address to validate.
* @returns {ValidationResult} Object containing validation result, address type, and optional hash.
*/
p2pkh: (address) => {
/** @type {ValidationResult} */
const result = { valid: false, type: null };
const bs58check = this.getBs58check();
const decoded = bs58check.decode(address);
const prefix = decoded[0];
if (prefix === this.getP2pkhPrefix() && decoded.length === 21) {
result.valid = true;
result.type = 'p2pkh';
}
return result;
},
};
/**
* Validates a Bitcoin address based on its type (P2PKH or Bech32).
* You can use the value "guess".
*
* @param {string} address - The address string to validate.
* @param {string} [type=this.getType()] - The type of address to generate.
* @returns {ValidationResult}
*/
// @ts-ignore
validateAddress(address, type = this.getType()) {
try {
if (this.isType(type)) {
// @ts-ignore
return this.#validators[type](address);
}
else if (type === 'guess') {
for (const type2 in this.#validators) {
// @ts-ignore
const result = this.#validators[type2](address);
if (result.valid)
return result;
}
}
return { valid: false, type: null };
}
catch {
return { valid: false, type: null };
}
}
/**
* Verifies a signed message using the given public key.
*
* @param {string|Buffer} message - The original message.
* @param {string|Buffer} signature - Signature in DER format (base64-encoded or Buffer).
* @param {Object} [options] - Verification options.
* @param {PubKeyTypes} [options.type=this.getType()] - The type of address to generate.
* @param {BufferEncoding} [options.encoding='base64'] - Encoding of the signature if it's a string.
* @param {string} [options.prefix=this.getMsgPrefix()] - Message prefix.
* @returns {string|null} The recovered compressed public key in hex format, or null if recovery fails.
*/
recoverMessage(message, signature, options = {}) {
const ec = this.getEc();
const { encoding = 'utf8', prefix = this.getMsgPrefix(), type = this.getType() } = options;
const sigBuf = typeof signature === 'string' ? Buffer.from(signature, encoding) : signature;
const msgHash = this.#getMessageHash(message, prefix);
if (sigBuf.length !== 65)
return null;
const header = sigBuf[0];
const recid = (header - 27) & 3;
const r = sigBuf.subarray(1, 33);
const s = sigBuf.subarray(33, 65);
const pubKey = ec.recoverPubKey(msgHash, { r, s }, recid);
// @ts-ignore
if (this.isType(type) && typeof this.#pubKeyTo[type] === 'function')
// @ts-ignore
return this.#pubKeyTo[type](Buffer.from(pubKey.encodeCompressed()));
// Nope
else
throw new Error(`Unsupported address type: ${type}`);
}
/**
* Signs a message using the instance's private key with a Bitcoin-style prefix and double SHA256 hashing.
*
* @param {string} message - The message to be signed.
* @param {Object} [options={}] - Optional signing parameters.
* @param {BufferEncoding} [options.encoding='utf8'] - Encoding for input message if it is a string.
* @param {string} [options.prefix] - Optional prefix (defaults to Bitcoin prefix or instance default).
* @returns {Buffer} The signature.
* @throws {Error} If recovery param could not be calculated.
*/
signMessage(message, options = {}) {
const keyPair = this.getKeyPair();
const { prefix = this.getMsgPrefix() } = options;
const hash = this.#getMessageHash(message, prefix);
const signature = keyPair.sign(hash, { canonical: true });
const r = signature.r.toArrayLike(Buffer, 'be', 32);
const s = signature.s.toArrayLike(Buffer, 'be', 32);
// Calculate recid (recovery param)
const recid = signature.recoveryParam;
if (recid === null)
throw new Error('Failed to calculate recovery param (recid) from signature. Signature may be invalid or keyPair is not properly initialized.');
const header = 27 + recid;
const sigBuffer = Buffer.concat([Buffer.from([header]), r, s]);
return sigBuffer;
}
}
export default TinyBtcSecp256k1;