Skip to content

Commit

Permalink
fix: use noble-secp256k1 in transaction to replace elliptic dependency
Browse files Browse the repository at this point in the history
  • Loading branch information
ahsan-javaid authored and janniks committed Feb 9, 2022
1 parent 85c7dd9 commit 534f1b8
Show file tree
Hide file tree
Showing 4 changed files with 170 additions and 64 deletions.
16 changes: 16 additions & 0 deletions packages/common/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -397,3 +397,19 @@ export function intToBigInt(value: IntegerType, signed: boolean): bigint {
export function with0x(value: string): string {
return !value.startsWith('0x') ? `0x${value}` : value;
}

/**
* Converts hex input string to bigint
* @param hex - hex input string without 0x prefix and in big endian format
* @example "6c7cde4d702830c1db34ef7c19e2776f59107afef39084776fc88bc78dbb9656"
*/
export function hexToBigInt(hex: string): bigint {
if (typeof hex !== 'string')
throw new TypeError('hexToNumber: expected string, got ' + typeof hex);
// Big Endian
return BigInt(`0x${hex}`);
}

export function utf8ToBytes(content: string) {
return new TextEncoder().encode(content);
}
6 changes: 4 additions & 2 deletions packages/transactions/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,9 @@
"@types/common-tags": "^1.8.0",
"@types/jest": "^26.0.22",
"@types/lodash.clonedeep": "^4.5.6",
"@types/elliptic": "^6.4.12",
"common-tags": "^1.8.0",
"elliptic": "^6.5.4",
"jest": "^26.6.3",
"jest-fetch-mock": "^3.0.3",
"jest-module-name-mapper": "^0.1.5",
Expand All @@ -40,17 +42,17 @@
"webpack-cli": "^4.6.0"
},
"dependencies": {
"@noble/hashes": "^1.0.0",
"@noble/secp256k1": "^1.5.2",
"@stacks/common": "^3.0.0",
"@stacks/network": "^3.2.0",
"@types/bn.js": "^4.11.6",
"@types/elliptic": "^6.4.12",
"@types/node": "^14.14.43",
"@types/randombytes": "^2.0.0",
"@types/sha.js": "^2.4.0",
"bn.js": "^4.12.0",
"c32check": "^1.1.3",
"cross-fetch": "^3.1.4",
"elliptic": "^6.5.4",
"lodash.clonedeep": "^4.5.0",
"randombytes": "^2.1.0",
"ripemd160-min": "^0.0.6",
Expand Down
126 changes: 68 additions & 58 deletions packages/transactions/src/keys.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Buffer } from '@stacks/common';
import { Buffer, hexToBigInt } from '@stacks/common';
import {
AddressHashMode,
AddressVersion,
Expand All @@ -16,10 +16,16 @@ import {
hexStringToInt,
intToHexString,
leftPadHexToLength,
randomBytes,
} from './utils';

import { ec as EC } from 'elliptic';
import {
getPublicKey as nobleGetPublicKey,
utils,
Point,
Signature,
signSync,
} from '@noble/secp256k1';
import { sha256 } from '@noble/hashes/sha256';
import { hmac } from '@noble/hashes/hmac';

import {
MessageSignature,
Expand All @@ -31,6 +37,19 @@ import {
import { BufferReader } from './bufferReader';
import { c32address } from 'c32check';

/**
* To use secp256k1.signSync set utils.hmacSha256Sync to a function using noble-hashes
* secp256k1.signSync is the counter part of secp256k1.sign (async version)
* secp256k1.signSync is used within signWithKey in this file
* secp256k1.signSync is used to maintain the semantics of signWithKey while migrating from elliptic lib
* utils.hmacSha256Sync docs: https://github.com/paulmillr/noble-secp256k1 readme file
*/
utils.hmacSha256Sync = (key: Uint8Array, ...msgs: Uint8Array[]) => {
const h = hmac.create(sha256, key);
msgs.forEach(msg => h.update(msg));
return h.digest();
};

export interface StacksPublicKey {
readonly type: StacksMessageType.PublicKey;
readonly data: Buffer;
Expand Down Expand Up @@ -71,23 +90,11 @@ export function publicKeyFromSignature(
messageSignature: MessageSignature,
pubKeyEncoding = PubKeyEncoding.Compressed
): string {
const ec = new EC('secp256k1');
const messageBN = ec.keyFromPrivate(message, 'hex').getPrivate().toString(10);

const parsedSignature = parseRecoverableSignature(messageSignature.data);

const publicKey = ec.recoverPubKey(
messageBN,
parsedSignature,
parsedSignature.recoveryParam,
'hex'
);

if (pubKeyEncoding == PubKeyEncoding.Uncompressed) {
return publicKey.encode('hex');
}

return publicKey.encodeCompressed('hex');
const signature = new Signature(hexToBigInt(parsedSignature.r), hexToBigInt(parsedSignature.s));
const point = Point.fromSignature(message, signature, parsedSignature.recoveryParam);
const compressed = pubKeyEncoding === PubKeyEncoding.Compressed;
return point.toHex(compressed);
}

export function publicKeyFromBuffer(data: Buffer): StacksPublicKey {
Expand All @@ -108,19 +115,38 @@ export function serializePublicKey(key: StacksPublicKey): Buffer {
return bufferArray.concatBuffer();
}

export function isPrivateKeyCompressed(key: string | Buffer) {
const data = typeof key === 'string' ? Buffer.from(key, 'hex') : key;
let compressed = false;
if (data.length === 33) {
if (data[data.length - 1] !== 1) {
throw new Error(
'Improperly formatted private-key. 33 byte length usually ' +
'indicates compressed key, but last byte must be == 0x01'
);
}
compressed = true;
} else if (data.length === 32) {
compressed = false;
} else {
throw new Error(
`Improperly formatted private-key hex string: length should be 32 or 33 bytes, provided with length ${data.length}`
);
}
return compressed;
}

export function pubKeyfromPrivKey(privateKey: string | Buffer): StacksPublicKey {
const privKey = createStacksPrivateKey(privateKey);
const ec = new EC('secp256k1');
const keyPair = ec.keyFromPrivate(privKey.data.toString('hex').slice(0, 64), 'hex');
const pubKey = keyPair.getPublic(privKey.compressed, 'hex');
return createStacksPublicKey(pubKey);
const isCompressed = isPrivateKeyCompressed(privateKey);
const pubKey = nobleGetPublicKey(privKey.data.slice(0, 32), isCompressed || privKey.compressed);
return createStacksPublicKey(utils.bytesToHex(pubKey));
}

export function compressPublicKey(publicKey: string | Buffer): StacksPublicKey {
const ec = new EC('secp256k1');
const key = ec.keyFromPublic(publicKey);
const pubKey = key.getPublic(true, 'hex');
return createStacksPublicKey(pubKey);
const hex = typeof publicKey === 'string' ? publicKey : utils.bytesToHex(publicKey);
const compressed = Point.fromHex(hex).toHex(true);
return createStacksPublicKey(compressed);
}

export function deserializePublicKey(bufferReader: BufferReader): StacksPublicKey {
Expand All @@ -139,45 +165,29 @@ export interface StacksPrivateKey {

export function createStacksPrivateKey(key: string | Buffer): StacksPrivateKey {
const data = typeof key === 'string' ? Buffer.from(key, 'hex') : key;
let compressed: boolean;
if (data.length === 33) {
if (data[data.length - 1] !== 1) {
throw new Error(
'Improperly formatted private-key. 33 byte length usually ' +
'indicates compressed key, but last byte must be == 0x01'
);
}
compressed = true;
} else if (data.length === 32) {
compressed = false;
} else {
throw new Error(
`Improperly formatted private-key hex string: length should be 32 or 33 bytes, provided with length ${data.length}`
);
}
const compressed: boolean = isPrivateKeyCompressed(key);
return { data, compressed };
}

export function makeRandomPrivKey(entropy?: Buffer): StacksPrivateKey {
const ec = new EC('secp256k1');
const options = { entropy: entropy || randomBytes(32) };
const keyPair = ec.genKeyPair(options);
const privateKey = keyPair.getPrivate().toString('hex', 32);
return createStacksPrivateKey(privateKey);
export function makeRandomPrivKey(): StacksPrivateKey {
return createStacksPrivateKey(utils.bytesToHex(utils.randomPrivateKey()));
}

export function signWithKey(privateKey: StacksPrivateKey, input: string): MessageSignature {
const ec = new EC('secp256k1');
const key = ec.keyFromPrivate(privateKey.data.toString('hex').slice(0, 64), 'hex');
const signature = key.sign(input, 'hex', { canonical: true });
const [rawSignature, recoveryParam] = signSync(input, privateKey.data.slice(0, 32), {
canonical: true,
recovered: true,
});
const signature = Signature.fromHex(rawSignature);
const coordinateValueBytes = 32;
const r = leftPadHexToLength(signature.r.toString('hex'), coordinateValueBytes * 2);
const s = leftPadHexToLength(signature.s.toString('hex'), coordinateValueBytes * 2);
if (signature.recoveryParam === undefined || signature.recoveryParam === null) {
const r = leftPadHexToLength(signature.r.toString(16), coordinateValueBytes * 2);
const s = leftPadHexToLength(signature.s.toString(16), coordinateValueBytes * 2);

if (recoveryParam === undefined || recoveryParam === null) {
throw new Error('"signature.recoveryParam" is not set');
}
const recoveryParam = intToHexString(signature.recoveryParam, 1);
const recoverableSignatureString = recoveryParam + r + s;
const recoveryParamHex = intToHexString(recoveryParam, 1);
const recoverableSignatureString = recoveryParamHex + r + s;
return createMessageSignature(recoverableSignatureString);
}

Expand Down
86 changes: 82 additions & 4 deletions packages/transactions/tests/keys.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,20 @@ import {
StacksMessageType,
TransactionVersion
} from '../src';

import { utf8ToBytes } from '@stacks/common';
import { randomBytes } from '../src/utils';
import {
utils,
verify as nobleSecp256k1Verify,
signSync as nobleSecp256k1Sign,
getPublicKey as nobleGetPublicKey
} from '@noble/secp256k1';
import { serializeDeserialize } from './macros';
import { ec as EC } from 'elliptic';

// Create and initialize EC context
// Better do it once and reuse it
const ec = new EC('secp256k1');

test('Stacks public key and private keys', () => {
const privKeyString = 'edf9aee84d9b7abc145504dde6726c64f369d37ee34ded868fabd876c26570bc';
Expand Down Expand Up @@ -70,11 +82,77 @@ test('Retrieve public key from signature', () => {
const compressedPubKey = '03ef788b3830c00abe8f64f62dc32fc863bc0b2cafeb073b6c8e1c7657d9c2c3ab';

const message = 'hello world';
const sig = signWithKey(privKey, message);
const messageHex = utils.bytesToHex(utf8ToBytes(message));
const sig = signWithKey(privKey, messageHex);

const uncompressedPubKeyFromSig = publicKeyFromSignature(message, sig, PubKeyEncoding.Uncompressed)
const compressedPubKeyFromSig = publicKeyFromSignature(message, sig, PubKeyEncoding.Compressed)
const uncompressedPubKeyFromSig = publicKeyFromSignature(messageHex, sig, PubKeyEncoding.Uncompressed)
const compressedPubKeyFromSig = publicKeyFromSignature(messageHex, sig, PubKeyEncoding.Compressed)

expect(uncompressedPubKeyFromSig).toBe(uncompressedPubKey);
expect(compressedPubKeyFromSig).toBe(compressedPubKey);
})

test('Sign msg using elliptic/secp256k1 and verify signature using @noble/secp256k1', () => {
// Maximum keypairs to try if a keypairs is not accepted by @noble/secp256k1
const keyPairAttempts = 8; // Normally a keypairs is accepted in first or second attempt

let nobleVerifyResult = false;

for (let i = 0; i < keyPairAttempts && !nobleVerifyResult; i++) {
// Generate keys
const options = { entropy: randomBytes(32) };
const keyPair = ec.genKeyPair(options);

const msg = 'hello world';
const msgHex = utils.bytesToHex(utf8ToBytes(msg));

// Sign msg using elliptic/secp256k1
// input must be an array, or a hex-string
const signature = keyPair.sign(msgHex);

// Export DER encoded signature in hex format
const signatureHex = signature.toDER('hex');

// Verify signature using elliptic/secp256k1
const ellipticVerifyResult = keyPair.verify(msgHex, signatureHex);

expect(ellipticVerifyResult).toBeTruthy();

// Get public key from key-pair
const publicKey = keyPair.getPublic().encodeCompressed('hex');

// Verify same signature using @noble/secp256k1
nobleVerifyResult = nobleSecp256k1Verify(signatureHex, msgHex, publicKey);
}
// Verification result by @noble/secp256k1 should be true
expect(nobleVerifyResult).toBeTruthy();
})

test('Sign msg using @noble/secp256k1 and verify signature using elliptic/secp256k1', () => {
// Generate private key
const privateKey = utils.randomPrivateKey();

const msg = 'hello world';
const msgHex = utils.bytesToHex(utf8ToBytes(msg));

// Sign msg using @noble/secp256k1
// input must be a hex-string
const signature = nobleSecp256k1Sign(msgHex, privateKey);

const publicKey = nobleGetPublicKey(privateKey);

// Verify signature using @noble/secp256k1
const nobleVerifyResult = nobleSecp256k1Verify(signature, msgHex, publicKey);

// Verification result by @noble/secp256k1
expect(nobleVerifyResult).toBeTruthy();

// Generate keypair using private key
const keyPair = ec.keyFromPrivate(privateKey);

// Verify signature using elliptic/secp256k1
const ellipticVerifyResult = keyPair.verify(msgHex, signature);

// Verification result by elliptic/secp256k1 should be true
expect(ellipticVerifyResult).toBeTruthy();
})

0 comments on commit 534f1b8

Please sign in to comment.