Skip to content

Commit a5038c1

Browse files
TinyChain --> TinySecp256k1 updated
1 parent 74aea99 commit a5038c1

2 files changed

Lines changed: 69 additions & 47 deletions

File tree

src/TinyChain/Secp256k1.mjs

Lines changed: 65 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ function doubleSha256(buf) {
4242
* ### Usage:
4343
* ```js
4444
* const signer = new TinySecp256k1({
45-
* prefix: '\x18MyApp Signed Message:\n',
45+
* msgPrefix: '\x18MyApp Signed Message:\n',
4646
* privateKey: 'a1b2c3...',
4747
* privateKeyEncoding: 'hex'
4848
* });
@@ -64,18 +64,18 @@ class TinySecp256k1 {
6464
/** @typedef {import('elliptic')} Elliptic */
6565
/** @typedef {import('elliptic').ec} ec */
6666
/** @typedef {import('elliptic').ec.KeyPair} KeyPair */
67-
#prefix = '\x18Tinychain Signed Message:\n';
67+
#msgPrefix = '\x18Tinychain Signed Message:\n';
6868

6969
/**
7070
* Creates an instance of TinySecp256k1.
7171
*
7272
* @param {Object} [options] - Optional parameters for the instance.
73-
* @param {string|null} [options.prefix=null] - Message prefix used during message signing.
73+
* @param {string|null} [options.msgPrefix=null] - Message prefix used during message signing.
7474
* @param {string|null} [options.privateKey=null] - String representation of the private key.
7575
* @param {BufferEncoding} [options.privateKeyEncoding='hex'] - Encoding used for the privateKey string.
7676
*/
77-
constructor({ prefix = null, privateKey = null, privateKeyEncoding = 'hex' } = {}) {
78-
if (typeof prefix === 'string') this.#prefix = prefix;
77+
constructor({ msgPrefix = null, privateKey = null, privateKeyEncoding = 'hex' } = {}) {
78+
if (typeof msgPrefix === 'string') this.#msgPrefix = msgPrefix;
7979
this.privateKey = privateKey ? Buffer.from(privateKey, privateKeyEncoding) : randomBytes(32);
8080
}
8181

@@ -84,7 +84,7 @@ class TinySecp256k1 {
8484
*
8585
* @returns {Promise<KeyPair>} The elliptic key pair.
8686
*/
87-
async init() {
87+
async initEc() {
8888
const ec = await this.fetchElliptic();
8989
this.keyPair = ec.keyFromPrivate(this.privateKey);
9090
return this.keyPair;
@@ -190,7 +190,7 @@ class TinySecp256k1 {
190190
*/
191191
signECDSA(message, encoding = 'utf8') {
192192
const msgBuffer = Buffer.isBuffer(message) ? message : Buffer.from(message, encoding);
193-
const hash = sha256(msgBuffer); // one SHA256 pass
193+
const hash = doubleSha256(msgBuffer);
194194
const signature = this.getKeyPair().sign(hash, { canonical: true });
195195
return Buffer.from(signature.toDER());
196196
}
@@ -207,60 +207,81 @@ class TinySecp256k1 {
207207
verifyECDSA(message, signatureBuffer, pubKeyHex, encoding) {
208208
const ec = this.getEc();
209209
const msgBuffer = Buffer.isBuffer(message) ? message : Buffer.from(message, encoding);
210-
const hash = sha256(msgBuffer);
210+
const hash = doubleSha256(msgBuffer);
211211
const key = ec.keyFromPublic(pubKeyHex, 'hex');
212212
return key.verify(hash, signatureBuffer);
213213
}
214214

215215
/**
216-
* Recovers the public key from a signed message.
216+
* Normalizes a 65-byte compact ECDSA signature into its r, s, and v components.
217217
*
218-
* @param {string|Buffer} message - The original message string or buffer.
219-
* @param {Buffer} signature - The signature buffer (must include recovery param).
220-
* @returns {string|null} Recovered public key in hex format or null if invalid.
218+
* @param {Buffer} signature - A 65-byte buffer in the format [r (32) | s (32) | v (1)].
219+
* @returns {{ r: Buffer, s: Buffer, v: number }} The signature components.
220+
* @throws {Error} If the signature length is invalid or recovery param is out of range.
221221
*/
222-
recoverMessage(message, signature) {
223-
const ec = this.getEc();
224-
const msgBuffer = Buffer.isBuffer(message) ? message : Buffer.from(message);
225-
const prefix = Buffer.from(this.#prefix + msgBuffer.length);
226-
const fullMessage = Buffer.concat([prefix, msgBuffer]);
227-
const hash = doubleSha256(fullMessage); // Bitcoin/Ethereum-style prefixing
222+
#normalizeSignature(signature) {
223+
if (signature.length === 65) {
224+
let v = signature[64];
225+
if (v >= 27) v -= 27;
226+
if (v < 0 || v > 3) throw new Error('Invalid recovery param (v): must be 0, 1, 2, or 3');
227+
return {
228+
r: signature.subarray(0, 32),
229+
s: signature.subarray(32, 64),
230+
v,
231+
};
232+
} else throw new Error('Invalid signature length. Expected 65 bytes (r+s+v)');
233+
}
228234

229-
if (signature.length !== 65) {
230-
console.warn('[recoverMessage] Signature must be 65 bytes (r + s + recovery param).');
231-
return null;
232-
}
235+
/**
236+
* @type {(message: string|Buffer, encoding: BufferEncoding, prefix?: string) => Buffer}
237+
*/
238+
#getMessageHash(message, encoding, prefix = '\x18Bitcoin Signed Message:\n') {
239+
const msgBuffer = Buffer.isBuffer(message) ? message : Buffer.from(message, encoding);
240+
const msgPrefix = Buffer.from(prefix + msgBuffer.length);
241+
const fullMessage = Buffer.concat([msgPrefix, msgBuffer]);
242+
const hash = doubleSha256(fullMessage);
243+
return hash;
244+
}
233245

234-
const r = signature.slice(0, 32);
235-
const s = signature.slice(32, 64);
236-
const recoveryParam = signature[64];
246+
/**
247+
* Recovers the public key from a signed message and signature with recovery param.
248+
*
249+
* @param {string|Buffer} message - The original signed message.
250+
* @param {Buffer} signature - A 65-byte compact signature buffer (r + s + v).
251+
* @param {Object} [options] - Options for decoding the message hash.
252+
* @param {BufferEncoding} [options.encoding='hex'] - The encoding of the input message.
253+
* @param {string} [options.prefix=this.#msgPrefix] - Optional prefix used before hashing the message.
254+
* @returns {string} The recovered compressed public key in hex format, or null if recovery fails.
255+
* @throws {Error} If the encoding type is unsupported or signature is invalid.
256+
*/
257+
recoverMessage(message, signature, options = {}) {
258+
const ec = this.getEc();
259+
const { encoding = 'hex', prefix = this.#msgPrefix } = options;
260+
const hash = this.#getMessageHash(message, encoding, prefix);
237261

238-
try {
239-
const pubKey = ec.recoverPubKey(hash, { r, s }, recoveryParam);
240-
return pubKey.encodeCompressed('hex'); // use 'false' for uncompressed
241-
} catch (err) {
242-
console.warn('[recoverMessage] Failed to recover public key:', err);
243-
return null;
244-
}
262+
const { r, s, v } = this.#normalizeSignature(signature);
263+
const pubKey = ec.recoverPubKey(hash, { r, s }, v);
264+
return pubKey.encodeCompressed('hex');
245265
}
246266

247267
/**
248-
* Signs a message and returns a 65-byte recoverable signature.
268+
* Signs a message using ECDSA and includes the recovery param in the result.
249269
*
250-
* @param {string|Buffer} message - The original message string or buffer.
251-
* @returns {Buffer} The signature with recovery param (65 bytes total).
270+
* @param {string|Buffer} message - The message to sign.
271+
* @param {Object} [options] - Options for the message hashing process.
272+
* @param {BufferEncoding} [options.encoding='hex'] - The encoding used for string messages.
273+
* @param {string} [options.prefix=this.#msgPrefix] - Optional message prefix for the hash.
274+
* @returns {Buffer} A 65-byte recoverable signature (r + s + v).
275+
* @throws {Error} If recovery param is missing or encoding type is unsupported.
252276
*/
253-
signMessageWithRecovery(message) {
254-
const msgBuffer = Buffer.isBuffer(message) ? message : Buffer.from(message);
255-
const prefix = Buffer.from(this.#prefix + msgBuffer.length);
256-
const fullMessage = Buffer.concat([prefix, msgBuffer]);
257-
const hash = doubleSha256(fullMessage); // Same hash style as recoverMessage
258-
259-
const { r, s, recoveryParam } = this.getKeyPair().sign(hash, { canonical: true });
277+
signMessage(message, options = {}) {
278+
const keyPair = this.getKeyPair();
279+
const { encoding = 'hex', prefix = this.#msgPrefix } = options;
280+
const hash = this.#getMessageHash(message, encoding, prefix);
260281

261-
if (typeof recoveryParam !== 'number') {
282+
const { r, s, recoveryParam } = keyPair.sign(hash, { canonical: true });
283+
if (typeof recoveryParam !== 'number')
262284
throw new Error('[signMessageWithRecovery] Missing recovery param from signature');
263-
}
264285

265286
const rBuf = r.toArrayLike(Buffer, 'be', 32);
266287
const sBuf = s.toArrayLike(Buffer, 'be', 32);

test/TinyChain.mjs

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -133,8 +133,8 @@ const tinyWalletSimulation = async () => {
133133
const tinySignatureTest = async () => {
134134
console.log('\n🔐🔹 TinySecp256k1 Signature Test 🔹🔐\n');
135135

136-
const signer = new TinyChain.Secp256k1();
137-
await signer.init();
136+
const signer = new TinyChain.Secp256k1({ msgPrefix: '\x18Bitcoin Signed Message:\n' });
137+
await signer.initEc();
138138

139139
const privateKey = signer.getPrivateKeyHex();
140140
const publicKey = signer.getPublicKeyHex();
@@ -158,12 +158,13 @@ const tinySignatureTest = async () => {
158158
const recoverableMessage = 'Hello world';
159159
console.log('♻️ Signing with Recovery Param');
160160
console.log('──────────────────────────────');
161-
const sig = signer.signMessageWithRecovery(recoverableMessage);
161+
const sig = signer.signMessage(recoverableMessage);
162162
console.log(`📄 Signature (Recoverable): ${sig.toString('hex')}`);
163163

164164
const recoveredPubKey = signer.recoverMessage(recoverableMessage, sig);
165165
const isValid = recoveredPubKey === signer.getPublicKeyHex();
166166
console.log(`🔍 Message Signature Valid? ${isValid}\n`);
167+
console.log(`📄 Message Signature (Recoverable): ${recoveredPubKey}`);
167168

168169
console.log('✅ Test Completed!\n');
169170
};

0 commit comments

Comments
 (0)