Skip to content

Commit

Permalink
First pass at implementing noble-secp256k1
Browse files Browse the repository at this point in the history
  • Loading branch information
mds1 committed Apr 1, 2021
1 parent 1cce75e commit 187c476
Show file tree
Hide file tree
Showing 4 changed files with 59 additions and 67 deletions.
4 changes: 1 addition & 3 deletions umbra-js/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,16 +25,14 @@
"bn.js": "^5.1.3",
"buffer": "^6.0.2",
"dotenv": "^8.2.0",
"elliptic": "^6.5.4",
"eth-ens-namehash": "^2.0.8",
"ethers": "^5.0.25",
"js-sha3": "^0.8.0"
"noble-secp256k1": "^1.1.2"
},
"devDependencies": {
"@openzeppelin/test-environment": "^0.1.6",
"@types/bn.js": "^5.1.0",
"@types/chai": "^4.2.14",
"@types/elliptic": "^6.4.12",
"@types/mocha": "^8.0.4",
"@umbra/contracts": "^0.0.1",
"chai": "^4.2.0",
Expand Down
108 changes: 52 additions & 56 deletions umbra-js/src/classes/KeyPair.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,16 @@
* @dev Class for managing secp256k1 keys and performing operations with them
*/

import * as BN from 'bn.js';
import { ec as EC } from 'elliptic';
import * as secp from 'noble-secp256k1';
import { Wallet } from 'ethers';
import { BigNumber } from '@ethersproject/bignumber';
import { hexZeroPad, isHexString } from '@ethersproject/bytes';
import { computePublicKey, SigningKey } from '@ethersproject/signing-key';
import { sha256 } from '@ethersproject/sha2';
import { computeAddress } from '@ethersproject/transactions';
import { RandomNumber } from './RandomNumber';
import { lengths, padHex, recoverPublicKeyFromTransaction } from '../utils/utils';
import { CompressedPublicKey, EncryptedPayload, EthersProvider } from '../types';

const ec = new EC('secp256k1');

export class KeyPair {
readonly publicKeyHex: string; // Public key as hex string with 0x04 prefix
readonly privateKeyHex: string | null = null; // Private key as hex string with 0x prefix, or null if not provided
Expand All @@ -33,8 +30,8 @@ export class KeyPair {
if (key.length === lengths.privateKey) {
// Private key provided
this.privateKeyHex = key;
const publicKey = ec.g.mul(this.privateKeyHexSlim); // Multiply secp256k1 generator point by private key to get public key
this.publicKeyHex = `0x${publicKey.encode('hex') as string}`; // Save off public key, other forms computed as getters
const publicKey = secp.getPublicKey(this.privateKeyHexSlim as string); // hex without 0x prefix but with 04 prefix
this.publicKeyHex = `0x${publicKey}`; // Save off version with 0x prefix, other forms computed as getters
} else if (key.length === lengths.publicKey) {
// Public key provided
this.publicKeyHex = key; // Save off public key, other forms computed as getters
Expand All @@ -59,13 +56,6 @@ export class KeyPair {
return this.privateKeyHex ? this.privateKeyHex.slice(2) : null;
}

/**
* @notice Returns an elliptic instance generated from the public key
*/
get publicKeyEC() {
return ec.keyFromPublic(this.publicKeyHex.slice(2), 'hex');
}

/**
* @notice Returns checksum address derived from this key
*/
Expand All @@ -74,6 +64,7 @@ export class KeyPair {
}

// ============================================= ENCRYPTION / DECRYPTION =============================================

/**
* @notice Encrypt a random number with the instance's public key
* @param randomNumber Random number as instance of RandomNumber class
Expand All @@ -85,8 +76,7 @@ export class KeyPair {
}
// Get shared secret to use as encryption key
const ephemeralWallet = Wallet.createRandom();
const privateKey = new SigningKey(ephemeralWallet.privateKey);
const sharedSecret = privateKey.computeSharedSecret(this.publicKeyHex);
const sharedSecret = KeyPair.getSharedSecret(ephemeralWallet.privateKey, this.publicKeyHex);

// XOR random number with shared secret to get encrypted value
const ciphertext = randomNumber.value.xor(sharedSecret);
Expand All @@ -103,19 +93,16 @@ export class KeyPair {
* @returns Decrypted ciphertext as hex string
*/
decrypt(output: EncryptedPayload) {
if (!output.ephemeralPublicKey || !output.ciphertext) {
const { ephemeralPublicKey, ciphertext } = output;
if (!ephemeralPublicKey || !ciphertext) {
throw new Error('Input must be of type EncryptedPayload to decrypt');
}
if (!this.privateKeyHex) {
throw new Error('KeyPair has no associated private key to decrypt with');
}

// Get shared secret to use as decryption key
const { ephemeralPublicKey, ciphertext } = output;
const privateKey = new SigningKey(this.privateKeyHex);
const sharedSecret = privateKey.computeSharedSecret(ephemeralPublicKey);

// Decrypt
// Get shared secret to use as decryption key, then decrypt with XOR
const sharedSecret = KeyPair.getSharedSecret(this.privateKeyHex, ephemeralPublicKey);
const plaintext = BigNumber.from(ciphertext).xor(sharedSecret);
return hexZeroPad(plaintext.toHexString(), 32);
}
Expand All @@ -134,18 +121,14 @@ export class KeyPair {
}

const number = isHexString(value)
? (value as string).slice(2) // provided a valid hex string
: (value as RandomNumber).asHexSlim; // provided RandomNumber
? BigInt(value as string) // provided a valid hex string
: BigInt((value as RandomNumber).asHex); // provided RandomNumber

// Perform the multiplication
const publicKey = this.publicKeyEC.getPublic().mul(new BN(number, 16));
const publicKey = secp.Point.fromHex(this.publicKeyHex.slice(2)).multiply(number);

// Get x,y hex strings and pad each to 32 bytes
const x = padHex(publicKey.getX().toString('hex'));
const y = padHex(publicKey.getY().toString('hex'));

// Instantiate and return new instance
return new KeyPair(`0x04${x}${y}`);
// Return new KeyPair instance
return new KeyPair(`0x${publicKey.toHex()}`);
}

/**
Expand All @@ -165,21 +148,18 @@ export class KeyPair {

// Parse the number provided
const number = isHexString(value)
? (value as string) // provided a valid hex string
: (value as RandomNumber).asHex; // provided RandomNumber

// Get new private key. This gives us an arbitrarily large number that is not
// necessarily in the domain of the secp256k1 elliptic curve
const privateKeyFull = this.privateKeyBN.mul(number);
? BigInt(value as string) // provided a valid hex string
: BigInt((value as RandomNumber).asHex); // provided RandomNumber

// Modulo operation to get private key to be in correct range, where ec.n gives the
// order of our curve. We add the 0x prefix as it's required by ethers.js
const privateKeyMod = privateKeyFull.mod(`0x${(ec.n as BN).toString('hex')}`);
// Get new private key. Multiplication gives us an arbitrarily large number that is not necessarily in the domain
// of the secp256k1 curve, so then we use modulus operation to get in the correct range. We save it as a BigNumber
// for converting to hex
const privateKeyMod = BigNumber.from((BigInt(this.privateKeyHex) * number) % secp.CURVE.n);

// Remove 0x prefix to pad hex value, then add back 0x prefix
const privateKey = `0x${padHex(privateKeyMod.toHexString().slice(2))}`;

// Instantiate and return new instance
// Return new KeyPair instance
return new KeyPair(privateKey);
}

Expand All @@ -197,6 +177,22 @@ export class KeyPair {
return new KeyPair(publicKeyHex);
}

/**
* @notice Returns the shared secret for a given private key and public key
* @param privateKey Private key as hex string with 0x prefix
* @param publicKey Uncompressed public key as hex string with 0x04 prefix
* @returns 32-byte shared secret as 66 character hex string
*/
static getSharedSecret(privateKey: string, publicKey: string) {
if (privateKey.length !== lengths.privateKey || !isHexString(privateKey)) throw new Error('Invalid private key');
if (publicKey.length !== lengths.publicKey || !isHexString(publicKey)) throw new Error('Invalid public key');

// We use getSharedSecret(pk,Pk,true).slice() to ensure the shared secret is not dependent on the prefix, which
// enables us to uncompress ephemeralPublicKey from Umbra.sol logs as explained in comments of getUncompressedFromX
const sharedSecretRaw = secp.getSharedSecret(privateKey.slice(2), publicKey.slice(2), true).slice(2);
return sha256(`0x${sharedSecretRaw}`);
}

/**
* @notice Takes an uncompressed public key and returns the compressed public key
* @param publicKey Uncompressed public key, as hex string starting with 0x
Expand All @@ -206,36 +202,36 @@ export class KeyPair {
if (typeof publicKey !== 'string' || !isHexString(publicKey) || publicKey.length !== lengths.publicKey) {
throw new Error('Must provide uncompressed public key as hex string');
}
const compressedPublicKey = computePublicKey(publicKey, true);
const compressedPublicKey = secp.Point.fromHex(publicKey.slice(2)).toHex(true);
return {
prefix: Number(compressedPublicKey[3]), // prefix bit is the 4th character in the string (e.g. 0x03)
pubKeyXCoordinate: `0x${compressedPublicKey.slice(4)}`,
prefix: Number(compressedPublicKey[1]), // prefix bit is the 2th character in the string (no 0x prefix)
pubKeyXCoordinate: `0x${compressedPublicKey.slice(2)}`,
};
}

/**
* @notice Given the x-coordinate of a public key, without the identifying prefix bit, returns
* the uncompressed public key assuming the identifying bit is 02
* @dev We don't know if the identifying bit is 02 or 03 when uncompressing for the scanning use
* case, but it doesn't actually matter since we are not deriving an address from the public key.
* We use the public key to compute the shared secret to decrypt the random number, and since that
* involves multiplying this public key by a private key, the result is the same shared secret
* regardless of whether we assume the 02 or 03 prefix. Therefore if no prefix is provided, we
* can assume 02, and it's up to the user to make sure they are using this method safely.I
* @dev We don't know if the identifying bit is 02 or 03 when uncompressing for the scanning use case, but it
* doesn't actually matter since we are not deriving an address from the public key. We use the public key to
* compute the shared secret to decrypt the random number, and since that involves multiplying this public key
* by a private key, we can ensure the result is the same shared secret regardless of whether we assume the 02 or
* 03 prefix by using the compressed form of the hex shared secret and ignoring the prefix. Therefore if no prefix
* is provided, we can assume 02, and it's up to the user to make sure they are using this method safely. This is
* done because it saves gas in the Umbra contract
* @param pkx x-coordinate of compressed public key, as BigNumber or hex string
* @param prefix Prefix bit, must be 2 or 3
*/
static getUncompressedFromX(pkx: BigNumber | string, prefix: number | string | undefined = undefined) {
if (!(pkx instanceof BigNumber) && typeof pkx !== 'string') {
throw new Error('Compressed public key must be a BigNumber or string');
}
const hexWithoutPrefix = padHex(BigNumber.from(pkx).toHexString().slice(2));
if (!prefix) {
// Only safe to use this branch when uncompressed key is using for scanning your funds
const hexWithoutPrefix = BigNumber.from(pkx).toHexString().slice(2);
return computePublicKey(BigNumber.from(`0x02${hexWithoutPrefix}`).toHexString());
return `0x${secp.Point.fromHex(`02${hexWithoutPrefix}`).toHex()}`;
}
const hexWithoutPrefix = padHex(BigNumber.from(pkx).toHexString().slice(2));
const hexWithPrefix = `0x0${Number(prefix)}${hexWithoutPrefix}`;
return computePublicKey(BigNumber.from(hexWithPrefix).toHexString());
const hexWithPrefix = `0${Number(prefix)}${hexWithoutPrefix}`;
return `0x${secp.Point.fromHex(hexWithPrefix).toHex()}`;
}
}
2 changes: 1 addition & 1 deletion umbra-js/test/KeyPair.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,7 +176,7 @@ describe('KeyPair class', () => {
const keyPair2 = new KeyPair(wallet.publicKey);

expect(keyPair1.publicKeyHex).to.equal(keyPair2.publicKeyHex);
expect(JSON.stringify(keyPair1.publicKeyEC)).to.equal(JSON.stringify(keyPair2.publicKeyEC));
// expect(JSON.stringify(keyPair1.publicKeyEC)).to.equal(JSON.stringify(keyPair2.publicKeyEC));
});

it('supports encryption and decryption of the random number', async () => {
Expand Down
12 changes: 5 additions & 7 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -3351,13 +3351,6 @@
electron-notarize "^0.1.1"
electron-osx-sign "^0.4.11"

"@types/elliptic@^6.4.12":
version "6.4.12"
resolved "https://registry.yarnpkg.com/@types/elliptic/-/elliptic-6.4.12.tgz#e8add831f9cc9a88d9d84b3733ff669b68eaa124"
integrity sha512-gP1KsqoouLJGH6IJa28x7PXb3cRqh83X8HCLezd2dF+XcAIMKYv53KV+9Zn6QA561E120uOqZBQ+Jy/cl+fviw==
dependencies:
"@types/bn.js" "*"

"@types/eslint-visitor-keys@^1.0.0":
version "1.0.0"
resolved "https://registry.yarnpkg.com/@types/eslint-visitor-keys/-/eslint-visitor-keys-1.0.0.tgz#1ee30d79544ca84d68d4b3cdb0af4f205663dd2d"
Expand Down Expand Up @@ -14005,6 +13998,11 @@ no-case@^3.0.4:
lower-case "^2.0.2"
tslib "^2.0.3"

noble-secp256k1@^1.1.2:
version "1.1.2"
resolved "https://registry.yarnpkg.com/noble-secp256k1/-/noble-secp256k1-1.1.2.tgz#43f4cb08933264cb84bd1aabb0dae4adfd186acc"
integrity sha512-+fW9Vt7ev0aT+esL8tVsH79GiDB0b8Nzk9cgwPKXoG1rKJjaA7Phg2VzxV541qdu/V1bG/y8xA0nJBu1QvBmTg==

node-addon-api@^2.0.0:
version "2.0.2"
resolved "https://registry.yarnpkg.com/node-addon-api/-/node-addon-api-2.0.2.tgz#432cfa82962ce494b132e9d72a15b29f71ff5d32"
Expand Down

0 comments on commit 187c476

Please sign in to comment.