Skip to content

Commit b963e1f

Browse files
committed
feat(secp256k1): implement public key recovery in recoverySignature method and add unit tests
enhance signature creation and recovery with secp256k1 package for sdk-coin-flrp and added related test cases TICKET: WIN-7654
1 parent 8b0b018 commit b963e1f

File tree

4 files changed

+212
-7
lines changed

4 files changed

+212
-7
lines changed

modules/sdk-coin-flrp/src/lib/utils.ts

Lines changed: 51 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -274,10 +274,40 @@ export class Utils implements BaseUtils {
274274
* @return signature
275275
*/
276276
createSignature(network: FlareNetwork, message: Buffer, prv: Buffer): Buffer {
277-
// Use BitGo secp256k1 since FlareJS may not expose KeyPair in the same way
277+
// Used BitGo secp256k1 since FlareJS may not expose KeyPair in the same way
278278
try {
279-
const signature = ecc.sign(message, prv);
280-
return Buffer.from(signature);
279+
// Hash the message first: secp256k1 signing requires a 32-byte hash as input.
280+
// It is essential that the same hashing (sha256 of the message) is applied during signature recovery,
281+
// otherwise the recovered public key or signature verification will fail.
282+
const messageHash = createHash('sha256').update(message).digest();
283+
284+
// Sign with recovery parameter
285+
const signature = ecc.sign(messageHash, prv);
286+
287+
// Get recovery parameter by trying both values
288+
let recoveryParam = -1;
289+
const pubKey = ecc.pointFromScalar(prv, true);
290+
if (!pubKey) {
291+
throw new Error('Failed to derive public key from private key');
292+
}
293+
const recovered0 = ecc.recoverPublicKey(messageHash, signature, 0, true);
294+
if (recovered0 && Buffer.from(recovered0).equals(Buffer.from(pubKey))) {
295+
recoveryParam = 0;
296+
} else {
297+
const recovered1 = ecc.recoverPublicKey(messageHash, signature, 1, true);
298+
if (recovered1 && Buffer.from(recovered1).equals(Buffer.from(pubKey))) {
299+
recoveryParam = 1;
300+
} else {
301+
throw new Error('Could not determine correct recovery parameter for signature');
302+
}
303+
}
304+
305+
// Append recovery parameter to signature
306+
const fullSig = Buffer.alloc(65); // 64 bytes signature + 1 byte recovery
307+
fullSig.set(signature);
308+
fullSig[64] = recoveryParam;
309+
310+
return fullSig;
281311
} catch (error) {
282312
throw new Error(`Failed to create signature: ${error}`);
283313
}
@@ -308,9 +338,24 @@ export class Utils implements BaseUtils {
308338
*/
309339
recoverySignature(network: FlareNetwork, message: Buffer, signature: Buffer): Buffer {
310340
try {
311-
// This would need to be implemented with secp256k1 recovery
312-
// For now, throwing error since recovery logic would need to be adapted
313-
throw new NotImplementedError('recoverySignature not fully implemented for FlareJS');
341+
// Hash the message first - must match the hash used in signing
342+
const messageHash = createHash('sha256').update(message).digest();
343+
344+
// Extract recovery parameter and signature
345+
if (signature.length !== 65) {
346+
throw new Error('Invalid signature length - expected 65 bytes (64 bytes signature + 1 byte recovery)');
347+
}
348+
349+
const recoveryParam = signature[64];
350+
const sigOnly = signature.slice(0, 64);
351+
352+
// Recover public key using the provided recovery parameter
353+
const recovered = ecc.recoverPublicKey(messageHash, sigOnly, recoveryParam, true);
354+
if (!recovered) {
355+
throw new Error('Failed to recover public key');
356+
}
357+
358+
return Buffer.from(recovered);
314359
} catch (error) {
315360
throw new Error(`Failed to recover signature: ${error}`);
316361
}

modules/sdk-coin-flrp/test/unit/lib/utils.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,68 @@ describe('Utils', function () {
193193
});
194194
});
195195

196+
describe('recoverySignature', function () {
197+
it('should recover public key from valid signature', function () {
198+
const network = coins.get('flrp').network as FlareNetwork;
199+
const message = Buffer.from('hello world', 'utf8');
200+
const privateKey = Buffer.from('0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef', 'hex');
201+
202+
// Create signature using the same private key
203+
const signature = utils.createSignature(network, message, privateKey);
204+
205+
// Recover public key
206+
const recoveredPubKey = utils.recoverySignature(network, message, signature);
207+
208+
assert.ok(recoveredPubKey instanceof Buffer);
209+
assert.strictEqual(recoveredPubKey.length, 33); // Should be compressed public key (33 bytes)
210+
});
211+
212+
it('should recover same public key for same message and signature', function () {
213+
const network = coins.get('flrp').network as FlareNetwork;
214+
const message = Buffer.from('hello world', 'utf8');
215+
const privateKey = Buffer.from('0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef', 'hex');
216+
const signature = utils.createSignature(network, message, privateKey);
217+
218+
const pubKey1 = utils.recoverySignature(network, message, signature);
219+
const pubKey2 = utils.recoverySignature(network, message, signature);
220+
221+
assert.deepStrictEqual(pubKey1, pubKey2);
222+
});
223+
224+
it('should recover public key that matches original key', function () {
225+
const network = coins.get('flrp').network as FlareNetwork;
226+
const message = Buffer.from('hello world', 'utf8');
227+
const privateKey = Buffer.from('0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef', 'hex');
228+
229+
// Get original public key
230+
const { ecc } = require('@bitgo/secp256k1');
231+
const originalPubKey = Buffer.from(ecc.pointFromScalar(privateKey, true) as Uint8Array);
232+
233+
// Create signature and recover public key
234+
const signature = utils.createSignature(network, message, privateKey);
235+
const recoveredPubKey = utils.recoverySignature(network, message, signature);
236+
237+
// Convert both to hex strings for comparison
238+
assert.strictEqual(recoveredPubKey.toString('hex'), originalPubKey.toString('hex'));
239+
});
240+
241+
it('should throw error for invalid signature', function () {
242+
const network = coins.get('flrp').network as FlareNetwork;
243+
const message = Buffer.from('hello world', 'utf8');
244+
const invalidSignature = Buffer.from('invalid signature', 'utf8');
245+
246+
assert.throws(() => utils.recoverySignature(network, message, invalidSignature), /Failed to recover signature/);
247+
});
248+
249+
it('should throw error for empty message', function () {
250+
const network = coins.get('flrp').network as FlareNetwork;
251+
const message = Buffer.alloc(0);
252+
const signature = Buffer.alloc(65); // Empty but valid length signature (65 bytes: 64 signature + 1 recovery param)
253+
254+
assert.throws(() => utils.recoverySignature(network, message, signature), /Failed to recover signature/);
255+
});
256+
});
257+
196258
describe('address parsing utilities', function () {
197259
it('should handle address separator constants', function () {
198260
const { ADDRESS_SEPARATOR } = require('../../../src/lib/iface');

modules/secp256k1/src/index.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,27 @@ const ecc = {
103103
return necc.verify(signature, h, Q, { strict });
104104
},
105105

106+
recoverPublicKey: (
107+
h: Uint8Array,
108+
signature: Uint8Array,
109+
recovery: number,
110+
compressed?: boolean
111+
): Uint8Array | null => {
112+
// Message hash must be exactly 32 bytes
113+
if (h.length !== 32) {
114+
return null;
115+
}
116+
// Signature must be exactly 64 bytes (r and s components)
117+
if (signature.length !== 64) {
118+
return null;
119+
}
120+
// Recovery value must be 0 or 1
121+
if (recovery !== 0 && recovery !== 1) {
122+
return null;
123+
}
124+
return throwToNull(() => necc.recoverPublicKey(h, signature, recovery, defaultTrue(compressed)));
125+
},
126+
106127
verifySchnorr: (h: Uint8Array, Q: Uint8Array, signature: Uint8Array): boolean => {
107128
return necc.schnorr.verifySync(signature, h, Q);
108129
},

modules/secp256k1/test/index.ts

Lines changed: 78 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import * as assert from 'assert';
2-
2+
import { createHash } from 'crypto';
33
import * as secp256k1 from '../src';
44

55
describe('secp256k1', function () {
@@ -42,4 +42,81 @@ describe('secp256k1', function () {
4242
);
4343
});
4444
});
45+
46+
describe('ecc', function () {
47+
describe('recoverPublicKey', function () {
48+
const privKey = Buffer.from('1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef', 'hex');
49+
const message = Buffer.from('Hello, world!');
50+
const messageHash = createHash('sha256').update(message).digest();
51+
const signature = secp256k1.ecc.sign(messageHash, privKey);
52+
const publicKey = secp256k1.ecc.pointFromScalar(privKey, true);
53+
54+
it('successfully recovers compressed public key', function () {
55+
// Test recovery with both possible recovery values (0 and 1)
56+
const recoveredKey0 = secp256k1.ecc.recoverPublicKey(messageHash, signature, 0, true);
57+
const recoveredKey1 = secp256k1.ecc.recoverPublicKey(messageHash, signature, 1, true);
58+
59+
// One of the recovered keys should match our original compressed public key
60+
const pubKeyHex = Buffer.from(publicKey || []).toString('hex');
61+
assert.ok(
62+
(recoveredKey0 && Buffer.from(recoveredKey0).toString('hex') === pubKeyHex) ||
63+
(recoveredKey1 && Buffer.from(recoveredKey1).toString('hex') === pubKeyHex),
64+
'Failed to recover the correct compressed public key'
65+
);
66+
});
67+
68+
it('successfully recovers uncompressed public key', function () {
69+
// Test recovery with uncompressed format
70+
const recoveredKey0 = secp256k1.ecc.recoverPublicKey(messageHash, signature, 0, false);
71+
const recoveredKey1 = secp256k1.ecc.recoverPublicKey(messageHash, signature, 1, false);
72+
const uncompressedPubKey = secp256k1.ecc.pointFromScalar(privKey, false);
73+
74+
// One of the recovered keys should match the uncompressed public key
75+
const pubKeyHex = Buffer.from(uncompressedPubKey || []).toString('hex');
76+
assert.ok(
77+
(recoveredKey0 && Buffer.from(recoveredKey0).toString('hex') === pubKeyHex) ||
78+
(recoveredKey1 && Buffer.from(recoveredKey1).toString('hex') === pubKeyHex),
79+
'Failed to recover the correct uncompressed public key'
80+
);
81+
});
82+
83+
it('returns null for invalid recovery param', function () {
84+
const result = secp256k1.ecc.recoverPublicKey(messageHash, signature, 2, true);
85+
assert.strictEqual(result, null);
86+
});
87+
88+
it('returns null for invalid signature', function () {
89+
const invalidSig = Buffer.alloc(64, 0);
90+
const result = secp256k1.ecc.recoverPublicKey(messageHash, invalidSig, 0, true);
91+
assert.strictEqual(result, null);
92+
});
93+
94+
it('returns null for invalid message hash', function () {
95+
// Create an invalid hash by using wrong length (should be 32 bytes)
96+
const invalidHash = Buffer.alloc(31, 1); // 31 bytes of 1s
97+
const result = secp256k1.ecc.recoverPublicKey(invalidHash, signature, 0, true);
98+
assert.strictEqual(result, null, 'Should return null for invalid message hash length');
99+
100+
// Also test with empty hash
101+
const emptyHash = Buffer.alloc(0);
102+
const resultEmpty = secp256k1.ecc.recoverPublicKey(emptyHash, signature, 0, true);
103+
assert.strictEqual(resultEmpty, null, 'Should return null for empty message hash');
104+
});
105+
106+
it('handles compressed parameter correctly', function () {
107+
const compressedKey = secp256k1.ecc.recoverPublicKey(messageHash, signature, 0, true);
108+
const uncompressedKey = secp256k1.ecc.recoverPublicKey(messageHash, signature, 0, false);
109+
110+
assert.ok(compressedKey, 'Should recover compressed key');
111+
assert.ok(uncompressedKey, 'Should recover uncompressed key');
112+
assert.notStrictEqual(
113+
Buffer.from(compressedKey).toString('hex'),
114+
Buffer.from(uncompressedKey).toString('hex'),
115+
'Compressed and uncompressed keys should be different'
116+
);
117+
assert.strictEqual(Buffer.from(compressedKey).length, 33, 'Compressed key should be 33 bytes');
118+
assert.strictEqual(Buffer.from(uncompressedKey).length, 65, 'Uncompressed key should be 65 bytes');
119+
});
120+
});
121+
});
45122
});

0 commit comments

Comments
 (0)