tiny-crypto-suite
Version:
Tiny tools, big crypto — seamless encryption and certificate handling for modern web and Node apps.
259 lines (229 loc) • 9.26 kB
JavaScript
'use strict';
var buffer = require('buffer');
var index = require('./index.cjs');
class TinyEthSecp256k1 extends index {
/** @typedef {import('js-sha3')} JsSha3 */
/** @typedef {import('elliptic').ec.KeyPair} KeyPair */
/** @typedef {import('./index.mjs').ValidationResult} ValidationResult */
/**
* Creates an instance of TinyEthSecp256k1.
*
* @param {Object} [options] - Optional parameters for the instance.
* @param {string|null} [options.prefix='0x'] - Crypto prefix used during message verification.
* @param {'keccak256'} [options.type='keccak256'] - The type of address to generate.
* @param {string|null} [options.msgPrefix='\x19Ethereum 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({
prefix = '0x',
type = 'keccak256',
msgPrefix = '\x19Ethereum Signed Message:\n',
privateKey = null,
privateKeyEncoding = 'hex',
} = {}) {
super({ type, prefix, msgPrefix, privateKey, privateKeyEncoding });
this.types['keccak256'] = this.prefix;
this.prefixes[this.types['keccak256']] = 'keccak256';
}
/**
* Initializes the internal elliptic key pair using the private key.
*
* @returns {Promise<KeyPair>} The elliptic key pair.
*/
async init() {
await Promise.all([this.fetchElliptic(), this.fetchJsSha3()]);
const ec = this.getEc();
this.keyPair = ec.keyFromPrivate(this.privateKey);
return this.keyPair;
}
/**
* Dynamically imports the `jsSha3` module and stores it in the instance.
* Ensures the module is loaded only once (lazy singleton).
*
* @returns {Promise<JsSha3>} The loaded `jsSha3` module.
*/
async fetchJsSha3() {
if (!this.jsSha3) {
const jsSha3 = await import(/* webpackMode: "eager" */ 'js-sha3').catch(() => {
console.warn(
'[JsSha3] Warning: "js-sha3" is not installed. ' +
'JsSha3 requires "js-sha3" to function properly. ' +
'Please install it with "npm install js-sha3".',
);
return null;
});
if (jsSha3) {
// @ts-ignore
this.jsSha3 = jsSha3?.default ?? jsSha3;
}
}
return this.getJsSha3();
}
/**
* Returns the initialized `jsSha3` instance from the jsSha3 module.
*
* @returns {JsSha3} The jsSha3 instance.
* @throws Will throw an error if `jsSha3` is not initialized.
*/
getJsSha3() {
if (typeof this.jsSha3 === 'undefined' || this.jsSha3 === null)
throw new Error(
`Failed to initialize JsSha3: Module is ${this.jsSha3 !== null ? 'undefined' : 'null'}.\n` +
'Please make sure "js-sha3" is installed.\n' +
'You can install it by running: npm install js-sha3',
);
// @ts-ignore
return this.jsSha3;
}
/**
* Apply EIP-55 checksum to a lowercase address.
* @param {string} address - Hex string without 0x prefix.
* @returns {string}
*/
#toChecksumAddress(address) {
const { keccak256 } = this.getJsSha3();
const hash = keccak256(address.toLowerCase());
let checksumAddress = this.getPrefix();
for (let i = 0; i < address.length; i++) {
const char = address[i];
const hashChar = parseInt(hash[i], 16);
checksumAddress += hashChar >= 8 ? char.toUpperCase() : char.toLowerCase();
}
return checksumAddress;
}
/**
* Generate the Ethereum address from the public key.
*
* @param {Buffer} pubKey
* @returns {string}
*/
#getAddress(pubKey) {
const { keccak256 } = this.getJsSha3();
const addressBuf = buffer.Buffer.from(keccak256.arrayBuffer(pubKey)).subarray(-20);
const addressHex = [...addressBuf].map((b) => b.toString(16).padStart(2, '0')).join('');
return this.#toChecksumAddress(addressHex);
}
/**
* Returns the public key in hexadecimal.
* @returns {string}
*/
getPublicKeyHex() {
return this.getKeyPair().getPublic(false, 'hex');
}
/**
* Generate the Ethereum address from the public key.
* @param {Buffer} [pubKey=this.getPublicKeyBuffer(false).subarray(1)] - The pubKey buffer (remove byte 0x04).
* @returns {string}
*/
getAddress(pubKey = this.getPublicKeyBuffer(false)) {
return this.#getAddress(pubKey.subarray(1));
}
/**
* Returns the public key in vanilla format.
*
* @returns {Buffer} Hash160 representation of the public key.
*/
getPubVanillaAddress() {
return this.#addressToVanilla(this.getAddress());
}
/**
* @param {string} address
* @returns {Buffer}
*/
#addressToVanilla(address) {
let addr = '';
if (address.startsWith(this.getPrefix())) addr = address.slice(this.getPrefix().length);
if (addr.length !== 40) throw new Error('Invalid Ethereum address length');
return buffer.Buffer.from(addr, 'hex');
}
/**
* Returns the address in keccak256 format.
*
* @param {string} address - Whether to return the compressed version of the key.
* @returns {Buffer} - Hash160 representation of the public key.
*/
addressToVanilla(address) {
return this.#addressToVanilla(address);
}
/**
* Validates a Ethereum address.
*
* @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()) {
/** @type {ValidationResult} */
const result = { valid: false, type: null };
if (typeof address !== 'string') return result;
const prefix = this.getPrefix();
if (!address.startsWith(prefix)) return result;
const stripped = address.slice(prefix.length);
if (!/^[0-9a-fA-F]{40}$/.test(stripped)) return result;
// Accept lowercase or checksum format, but not mixed invalid case
const lower = stripped.toLowerCase();
const checksummed = this.#toChecksumAddress(lower);
if (address === checksummed || address === prefix + lower) {
result.valid = true;
result.type = 'keccak256';
}
return result;
}
/**
* Sign a message using Ethereum prefix.
* @param {string|Buffer} 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 Ethereum prefix or instance default).
* @returns {Buffer} The signature.
*/
signMessage(message, options = {}) {
const { keccak256 } = this.getJsSha3();
const keyPair = this.getKeyPair();
const { prefix = this.getMsgPrefix(), encoding = 'utf8' } = options;
const msgBuffer = buffer.Buffer.isBuffer(message) ? message : buffer.Buffer.from(message, encoding);
const ethMessage = buffer.Buffer.concat([buffer.Buffer.from(`${prefix}${msgBuffer.length}`), msgBuffer]);
const msgHash = buffer.Buffer.from(keccak256.arrayBuffer(ethMessage));
const sig = keyPair.sign(msgHash, { canonical: true });
// Calculate recid (recovery param)
const recid = sig.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 r = sig.r.toArrayLike(buffer.Buffer, 'be', 32);
const s = sig.s.toArrayLike(buffer.Buffer, 'be', 32);
const v = buffer.Buffer.from([recid + 27]);
return buffer.Buffer.concat([r, s, v]);
}
/**
* Recovers the address from the message and the signature.
* @param {string|Buffer} message The original message.
* @param {Buffer|string} signature - Signature in DER format (base64-encoded or Buffer).
* @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 Ethereum prefix or instance default).
* @returns {string|null}
*/
recoverMessage(message, signature, options = {}) {
const { keccak256 } = this.getJsSha3();
const { prefix = this.getMsgPrefix(), encoding = 'utf8' } = options;
const sigBuf = typeof signature === 'string' ? buffer.Buffer.from(signature, 'hex') : signature;
if (sigBuf.length !== 65) return null;
const r = sigBuf.subarray(0, 32);
const s = sigBuf.subarray(32, 64);
const vRaw = sigBuf[64];
if (vRaw !== 27 && vRaw !== 28) return null;
const v = vRaw - 27;
const msgBuffer = buffer.Buffer.isBuffer(message) ? message : buffer.Buffer.from(message, encoding);
const ethMessage = buffer.Buffer.concat([buffer.Buffer.from(`${prefix}${msgBuffer.length}`), msgBuffer]);
const msgHash = buffer.Buffer.from(keccak256.arrayBuffer(ethMessage));
const ec = this.getEc();
const pubKey = ec.recoverPubKey(msgHash, { r, s }, v);
const pubBuffer = buffer.Buffer.from(pubKey.encode('array', false)).subarray(1);
return this.#getAddress(pubBuffer);
}
}
module.exports = TinyEthSecp256k1;