Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 51 additions & 6 deletions modules/sdk-coin-flrp/src/lib/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`);
}
Expand Down Expand Up @@ -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}`);
}
Expand Down
62 changes: 62 additions & 0 deletions modules/sdk-coin-flrp/test/unit/lib/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
21 changes: 21 additions & 0 deletions modules/secp256k1/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
},
Expand Down
79 changes: 78 additions & 1 deletion modules/secp256k1/test/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import * as assert from 'assert';

import { createHash } from 'crypto';
import * as secp256k1 from '../src';

describe('secp256k1', function () {
Expand Down Expand Up @@ -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');
});
});
});
});