diff --git a/modules/sdk-coin-flrp/src/lib/utils.ts b/modules/sdk-coin-flrp/src/lib/utils.ts index aa84a337a6..cbcf934e9d 100644 --- a/modules/sdk-coin-flrp/src/lib/utils.ts +++ b/modules/sdk-coin-flrp/src/lib/utils.ts @@ -274,10 +274,40 @@ export class Utils implements BaseUtils { * @return signature */ createSignature(network: FlareNetwork, message: Buffer, prv: Buffer): Buffer { - // Use BitGo secp256k1 since FlareJS may not expose KeyPair in the same way + // Used BitGo secp256k1 since FlareJS may not expose KeyPair in the same way try { - const signature = ecc.sign(message, prv); - return Buffer.from(signature); + // Hash the message first: secp256k1 signing requires a 32-byte hash as input. + // It is essential that the same hashing (sha256 of the message) is applied during signature recovery, + // otherwise the recovered public key or signature verification will fail. + const messageHash = createHash('sha256').update(message).digest(); + + // Sign with recovery parameter + const signature = ecc.sign(messageHash, prv); + + // Get recovery parameter by trying both values + let recoveryParam = -1; + const pubKey = ecc.pointFromScalar(prv, true); + if (!pubKey) { + throw new Error('Failed to derive public key from private key'); + } + const recovered0 = ecc.recoverPublicKey(messageHash, signature, 0, true); + if (recovered0 && Buffer.from(recovered0).equals(Buffer.from(pubKey))) { + recoveryParam = 0; + } else { + const recovered1 = ecc.recoverPublicKey(messageHash, signature, 1, true); + if (recovered1 && Buffer.from(recovered1).equals(Buffer.from(pubKey))) { + recoveryParam = 1; + } else { + throw new Error('Could not determine correct recovery parameter for signature'); + } + } + + // Append recovery parameter to signature + const fullSig = Buffer.alloc(65); // 64 bytes signature + 1 byte recovery + fullSig.set(signature); + fullSig[64] = recoveryParam; + + return fullSig; } catch (error) { throw new Error(`Failed to create signature: ${error}`); } @@ -308,9 +338,24 @@ export class Utils implements BaseUtils { */ recoverySignature(network: FlareNetwork, message: Buffer, signature: Buffer): Buffer { try { - // This would need to be implemented with secp256k1 recovery - // For now, throwing error since recovery logic would need to be adapted - throw new NotImplementedError('recoverySignature not fully implemented for FlareJS'); + // Hash the message first - must match the hash used in signing + const messageHash = createHash('sha256').update(message).digest(); + + // Extract recovery parameter and signature + if (signature.length !== 65) { + throw new Error('Invalid signature length - expected 65 bytes (64 bytes signature + 1 byte recovery)'); + } + + const recoveryParam = signature[64]; + const sigOnly = signature.slice(0, 64); + + // Recover public key using the provided recovery parameter + const recovered = ecc.recoverPublicKey(messageHash, sigOnly, recoveryParam, true); + if (!recovered) { + throw new Error('Failed to recover public key'); + } + + return Buffer.from(recovered); } catch (error) { throw new Error(`Failed to recover signature: ${error}`); } diff --git a/modules/sdk-coin-flrp/test/unit/lib/utils.ts b/modules/sdk-coin-flrp/test/unit/lib/utils.ts index 2aa26540b4..6257cf231b 100644 --- a/modules/sdk-coin-flrp/test/unit/lib/utils.ts +++ b/modules/sdk-coin-flrp/test/unit/lib/utils.ts @@ -193,6 +193,68 @@ describe('Utils', function () { }); }); + describe('recoverySignature', function () { + it('should recover public key from valid signature', function () { + const network = coins.get('flrp').network as FlareNetwork; + const message = Buffer.from('hello world', 'utf8'); + const privateKey = Buffer.from('0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef', 'hex'); + + // Create signature using the same private key + const signature = utils.createSignature(network, message, privateKey); + + // Recover public key + const recoveredPubKey = utils.recoverySignature(network, message, signature); + + assert.ok(recoveredPubKey instanceof Buffer); + assert.strictEqual(recoveredPubKey.length, 33); // Should be compressed public key (33 bytes) + }); + + it('should recover same public key for same message and signature', function () { + const network = coins.get('flrp').network as FlareNetwork; + const message = Buffer.from('hello world', 'utf8'); + const privateKey = Buffer.from('0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef', 'hex'); + const signature = utils.createSignature(network, message, privateKey); + + const pubKey1 = utils.recoverySignature(network, message, signature); + const pubKey2 = utils.recoverySignature(network, message, signature); + + assert.deepStrictEqual(pubKey1, pubKey2); + }); + + it('should recover public key that matches original key', function () { + const network = coins.get('flrp').network as FlareNetwork; + const message = Buffer.from('hello world', 'utf8'); + const privateKey = Buffer.from('0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef', 'hex'); + + // Get original public key + const { ecc } = require('@bitgo/secp256k1'); + const originalPubKey = Buffer.from(ecc.pointFromScalar(privateKey, true) as Uint8Array); + + // Create signature and recover public key + const signature = utils.createSignature(network, message, privateKey); + const recoveredPubKey = utils.recoverySignature(network, message, signature); + + // Convert both to hex strings for comparison + assert.strictEqual(recoveredPubKey.toString('hex'), originalPubKey.toString('hex')); + }); + + it('should throw error for invalid signature', function () { + const network = coins.get('flrp').network as FlareNetwork; + const message = Buffer.from('hello world', 'utf8'); + const invalidSignature = Buffer.from('invalid signature', 'utf8'); + + assert.throws(() => utils.recoverySignature(network, message, invalidSignature), /Failed to recover signature/); + }); + + it('should throw error for empty message', function () { + const network = coins.get('flrp').network as FlareNetwork; + const message = Buffer.alloc(0); + const signature = Buffer.alloc(65); // Empty but valid length signature (65 bytes: 64 signature + 1 recovery param) + + assert.throws(() => utils.recoverySignature(network, message, signature), /Failed to recover signature/); + }); + }); + describe('address parsing utilities', function () { it('should handle address separator constants', function () { const { ADDRESS_SEPARATOR } = require('../../../src/lib/iface'); diff --git a/modules/secp256k1/src/index.ts b/modules/secp256k1/src/index.ts index 7eafbdb717..8b8ec66af8 100644 --- a/modules/secp256k1/src/index.ts +++ b/modules/secp256k1/src/index.ts @@ -103,6 +103,27 @@ const ecc = { return necc.verify(signature, h, Q, { strict }); }, + recoverPublicKey: ( + h: Uint8Array, + signature: Uint8Array, + recovery: number, + compressed?: boolean + ): Uint8Array | null => { + // Message hash must be exactly 32 bytes + if (h.length !== 32) { + return null; + } + // Signature must be exactly 64 bytes (r and s components) + if (signature.length !== 64) { + return null; + } + // Recovery value must be 0 or 1 + if (recovery !== 0 && recovery !== 1) { + return null; + } + return throwToNull(() => necc.recoverPublicKey(h, signature, recovery, defaultTrue(compressed))); + }, + verifySchnorr: (h: Uint8Array, Q: Uint8Array, signature: Uint8Array): boolean => { return necc.schnorr.verifySync(signature, h, Q); }, diff --git a/modules/secp256k1/test/index.ts b/modules/secp256k1/test/index.ts index e383d9bccb..1d007d3d74 100644 --- a/modules/secp256k1/test/index.ts +++ b/modules/secp256k1/test/index.ts @@ -1,5 +1,5 @@ import * as assert from 'assert'; - +import { createHash } from 'crypto'; import * as secp256k1 from '../src'; describe('secp256k1', function () { @@ -42,4 +42,81 @@ describe('secp256k1', function () { ); }); }); + + describe('ecc', function () { + describe('recoverPublicKey', function () { + const privKey = Buffer.from('1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef', 'hex'); + const message = Buffer.from('Hello, world!'); + const messageHash = createHash('sha256').update(message).digest(); + const signature = secp256k1.ecc.sign(messageHash, privKey); + const publicKey = secp256k1.ecc.pointFromScalar(privKey, true); + + it('successfully recovers compressed public key', function () { + // Test recovery with both possible recovery values (0 and 1) + const recoveredKey0 = secp256k1.ecc.recoverPublicKey(messageHash, signature, 0, true); + const recoveredKey1 = secp256k1.ecc.recoverPublicKey(messageHash, signature, 1, true); + + // One of the recovered keys should match our original compressed public key + const pubKeyHex = Buffer.from(publicKey || []).toString('hex'); + assert.ok( + (recoveredKey0 && Buffer.from(recoveredKey0).toString('hex') === pubKeyHex) || + (recoveredKey1 && Buffer.from(recoveredKey1).toString('hex') === pubKeyHex), + 'Failed to recover the correct compressed public key' + ); + }); + + it('successfully recovers uncompressed public key', function () { + // Test recovery with uncompressed format + const recoveredKey0 = secp256k1.ecc.recoverPublicKey(messageHash, signature, 0, false); + const recoveredKey1 = secp256k1.ecc.recoverPublicKey(messageHash, signature, 1, false); + const uncompressedPubKey = secp256k1.ecc.pointFromScalar(privKey, false); + + // One of the recovered keys should match the uncompressed public key + const pubKeyHex = Buffer.from(uncompressedPubKey || []).toString('hex'); + assert.ok( + (recoveredKey0 && Buffer.from(recoveredKey0).toString('hex') === pubKeyHex) || + (recoveredKey1 && Buffer.from(recoveredKey1).toString('hex') === pubKeyHex), + 'Failed to recover the correct uncompressed public key' + ); + }); + + it('returns null for invalid recovery param', function () { + const result = secp256k1.ecc.recoverPublicKey(messageHash, signature, 2, true); + assert.strictEqual(result, null); + }); + + it('returns null for invalid signature', function () { + const invalidSig = Buffer.alloc(64, 0); + const result = secp256k1.ecc.recoverPublicKey(messageHash, invalidSig, 0, true); + assert.strictEqual(result, null); + }); + + it('returns null for invalid message hash', function () { + // Create an invalid hash by using wrong length (should be 32 bytes) + const invalidHash = Buffer.alloc(31, 1); // 31 bytes of 1s + const result = secp256k1.ecc.recoverPublicKey(invalidHash, signature, 0, true); + assert.strictEqual(result, null, 'Should return null for invalid message hash length'); + + // Also test with empty hash + const emptyHash = Buffer.alloc(0); + const resultEmpty = secp256k1.ecc.recoverPublicKey(emptyHash, signature, 0, true); + assert.strictEqual(resultEmpty, null, 'Should return null for empty message hash'); + }); + + it('handles compressed parameter correctly', function () { + const compressedKey = secp256k1.ecc.recoverPublicKey(messageHash, signature, 0, true); + const uncompressedKey = secp256k1.ecc.recoverPublicKey(messageHash, signature, 0, false); + + assert.ok(compressedKey, 'Should recover compressed key'); + assert.ok(uncompressedKey, 'Should recover uncompressed key'); + assert.notStrictEqual( + Buffer.from(compressedKey).toString('hex'), + Buffer.from(uncompressedKey).toString('hex'), + 'Compressed and uncompressed keys should be different' + ); + assert.strictEqual(Buffer.from(compressedKey).length, 33, 'Compressed key should be 33 bytes'); + assert.strictEqual(Buffer.from(uncompressedKey).length, 65, 'Uncompressed key should be 65 bytes'); + }); + }); + }); });