diff --git a/src/ecpair.js b/src/ecpair.js new file mode 100644 index 0000000..b4ecf48 --- /dev/null +++ b/src/ecpair.js @@ -0,0 +1,142 @@ +var assert = require('assert') +var base58check = require('bs58check') +var bcrypto = require('./crypto') +var ecdsa = require('./ecdsa') +var ecurve = require('ecurve') +var networks = require('./networks') +var randomBytes = require('randombytes') +var typeForce = require('typeforce') + +var Address = require('./address') +var BigInteger = require('bigi') + +function findNetworkByWIFVersion (version) { + for (var networkName in networks) { + var network = networks[networkName] + + if (network.wif === version) return network + } + + assert(false, 'Unknown network') +} + +function ECPair (d, Q, options) { + options = options || {} + + var compressed = options.compressed === undefined ? true : options.compressed + var network = options.network === undefined ? networks.bitcoin : options.network + + typeForce('Boolean', compressed) + assert('pubKeyHash' in network, 'Unknown pubKeyHash constants for network') + + if (d) { + assert(d.signum() > 0, 'Private key must be greater than 0') + assert(d.compareTo(ECPair.curve.n) < 0, 'Private key must be less than the curve order') + + assert(!Q, 'Unexpected publicKey parameter') + Q = ECPair.curve.G.multiply(d) + + // enforce Q is a public key if no private key given + } else { + typeForce('Point', Q) + } + + this.compressed = compressed + this.d = d + this.Q = Q + this.network = network +} + +// Public access to secp256k1 curve +ECPair.curve = ecurve.getCurveByName('secp256k1') + +ECPair.fromPublicKeyBuffer = function (buffer, network) { + var Q = ecurve.Point.decodeFrom(ECPair.curve, buffer) + + return new ECPair(null, Q, { + compressed: Q.compressed, + network: network + }) +} + +ECPair.fromWIF = function (string) { + var payload = base58check.decode(string) + var version = payload.readUInt8(0) + var compressed + + if (payload.length === 34) { + assert.strictEqual(payload[33], 0x01, 'Invalid compression flag') + + // truncate the version/compression bytes + payload = payload.slice(1, -1) + compressed = true + + // no compression flag + } else { + assert.equal(payload.length, 33, 'Invalid WIF payload length') + + // Truncate the version byte + payload = payload.slice(1) + compressed = false + } + + var network = findNetworkByWIFVersion(version) + var d = BigInteger.fromBuffer(payload) + + return new ECPair(d, null, { + compressed: compressed, + network: network + }) +} + +ECPair.makeRandom = function (options) { + options = options || {} + + var rng = options.rng || randomBytes + var buffer = rng(32) + typeForce('Buffer', buffer) + assert.equal(buffer.length, 32, 'Expected 256-bit Buffer from RNG') + + var d = BigInteger.fromBuffer(buffer) + d = d.mod(ECPair.curve.n) + + return new ECPair(d, null, options) +} + +ECPair.prototype.toWIF = function () { + assert(this.d, 'Missing private key') + + var bufferLen = this.compressed ? 34 : 33 + var buffer = new Buffer(bufferLen) + + buffer.writeUInt8(this.network.wif, 0) + this.d.toBuffer(32).copy(buffer, 1) + + if (this.compressed) { + buffer.writeUInt8(0x01, 33) + } + + return base58check.encode(buffer) +} + +ECPair.prototype.getAddress = function () { + var pubKey = this.getPublicKeyBuffer() + + return new Address(bcrypto.hash160(pubKey), this.network.pubKeyHash) +} + +ECPair.prototype.getPublicKeyBuffer = function () { + return this.Q.getEncoded(this.compressed) +} + +ECPair.prototype.sign = function (hash) { + assert(this.d, 'Missing private key') + + return ecdsa.sign(ECPair.curve, hash, this.d) +} + +ECPair.prototype.verify = function (hash, signature) { + return ecdsa.verify(ECPair.curve, hash, signature, this.Q) +} + +module.exports = ECPair diff --git a/test/ecpair.js b/test/ecpair.js new file mode 100644 index 0000000..9135284 --- /dev/null +++ b/test/ecpair.js @@ -0,0 +1,234 @@ +/* global describe, it, beforeEach */ +/* eslint-disable no-new */ + +var assert = require('assert') +var ecdsa = require('../src/ecdsa') +var ecurve = require('ecurve') +var networks = require('../src/networks') +var proxyquire = require('proxyquire') +var sinon = require('sinon') + +var BigInteger = require('bigi') +var ECPair = require('../src/ecpair') + +var fixtures = require('./fixtures/ecpair.json') + +describe('ECPair', function () { + describe('constructor', function () { + it('defaults to compressed', function () { + var keyPair = new ECPair(BigInteger.ONE) + + assert.equal(keyPair.compressed, true) + }) + + it('supports the uncompressed option', function () { + var keyPair = new ECPair(BigInteger.ONE, null, { + compressed: false + }) + + assert.equal(keyPair.compressed, false) + }) + + it('supports the network option', function () { + var keyPair = new ECPair(BigInteger.ONE, null, { + compressed: false, + network: networks.testnet + }) + + assert.equal(keyPair.network, networks.testnet) + }) + + it('throws if compressed option is not a bool', function () { + assert.throws(function () { + new ECPair(null, null, { + compressed: 2 + }, /Expected Boolean, got 2/) + }) + }) + + it('throws if public and private key given', function () { + var qBuffer = new Buffer('0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798', 'hex') + var Q = ecurve.Point.decodeFrom(ECPair.curve, qBuffer) + + assert.throws(function () { + new ECPair(BigInteger.ONE, Q) + }, /Unexpected publicKey parameter/) + }) + + it('throws if network is missing pubKeyHash constants', function () { + assert.throws(function () { + new ECPair(null, null, { + network: {} + }, /Unknown pubKeyHash constants for network/) + }) + }) + + fixtures.valid.forEach(function (f) { + it('calculates the public point for ' + f.WIF, function () { + var d = new BigInteger(f.d) + var keyPair = new ECPair(d, null, { + compressed: f.compressed + }) + + assert.equal(keyPair.getPublicKeyBuffer().toString('hex'), f.Q) + }) + }) + + fixtures.invalid.constructor.forEach(function (f) { + it('throws on ' + f.d, function () { + var d = new BigInteger(f.d) + + assert.throws(function () { + new ECPair(d) + }, new RegExp(f.exception)) + }) + }) + }) + + describe('getPublicKeyBuffer', function () { + var keyPair + + beforeEach(function () { + keyPair = new ECPair(BigInteger.ONE) + }) + + it('wraps Q.getEncoded', sinon.test(function () { + this.mock(keyPair.Q).expects('getEncoded') + .once().calledWith(keyPair.compressed) + + keyPair.getPublicKeyBuffer() + })) + }) + + describe('fromWIF', function () { + fixtures.valid.forEach(function (f) { + it('imports ' + f.WIF + ' correctly', function () { + var keyPair = ECPair.fromWIF(f.WIF) + + assert.equal(keyPair.d.toString(), f.d) + assert.equal(keyPair.compressed, f.compressed) + assert.equal(keyPair.network, networks[f.network]) + }) + }) + + fixtures.invalid.fromWIF.forEach(function (f) { + it('throws on ' + f.string, function () { + assert.throws(function () { + ECPair.fromWIF(f.string) + }, new RegExp(f.exception)) + }) + }) + }) + + describe('toWIF', function () { + fixtures.valid.forEach(function (f) { + it('exports ' + f.WIF + ' correctly', function () { + var keyPair = ECPair.fromWIF(f.WIF) + var result = keyPair.toWIF() + + assert.equal(result, f.WIF) + }) + }) + }) + + describe('makeRandom', function () { + var d = new Buffer('0404040404040404040404040404040404040404040404040404040404040404', 'hex') + var exWIF = 'KwMWvwRJeFqxYyhZgNwYuYjbQENDAPAudQx5VEmKJrUZcq6aL2pv' + + describe('uses randombytes RNG', function () { + it('generates a ECPair', function () { + var stub = { randombytes: function () { return d } } + var ProxiedECPair = proxyquire('../src/ecpair', stub) + + var keyPair = ProxiedECPair.makeRandom() + assert.equal(keyPair.toWIF(), exWIF) + }) + + it('passes the options param', sinon.test(function () { + var options = { + compressed: true + } + + // FIXME: waiting on https://github.com/cjohansen/Sinon.JS/issues/613 +// this.mock(ECPair).expects('constructor') +// .once().calledWith(options) + + ECPair.makeRandom(options) + })) + }) + + it('allows a custom RNG to be used', function () { + var keyPair = ECPair.makeRandom({ + rng: function (size) { return d.slice(0, size) } + }) + + assert.equal(keyPair.toWIF(), exWIF) + }) + }) + + describe('getAddress', function () { + fixtures.valid.forEach(function (f) { + it('returns ' + f.address + ' for ' + f.WIF, function () { + var keyPair = ECPair.fromWIF(f.WIF) + + assert.equal(keyPair.getAddress().toString(), f.address) + }) + }) + }) + + describe('ecdsa wrappers', function () { + var keyPair, hash + + beforeEach(function () { + keyPair = ECPair.makeRandom() + hash = new Buffer(32) + }) + + it('uses the secp256k1 curve by default', function () { + var secp256k1 = ecurve.getCurveByName('secp256k1') + + for (var property in secp256k1) { + // FIXME: circular structures in ecurve + if (property === 'G') continue + if (property === 'infinity') continue + + var actual = ECPair.curve[property] + var expected = secp256k1[property] + + assert.deepEqual(actual, expected) + } + }) + + describe('signing', function () { + it('wraps ecdsa.sign', sinon.test(function () { + this.mock(ecdsa).expects('sign') + .once().calledWith(ECPair.curve, hash, keyPair.d) + + keyPair.sign(hash) + })) + + it('throws if no private key is found', function () { + keyPair.d = null + + assert.throws(function () { + keyPair.sign(hash) + }, /Missing private key/) + }) + }) + + describe('verify', function () { + var signature + + beforeEach(function () { + signature = keyPair.sign(hash) + }) + + it('wraps ecdsa.verify', sinon.test(function () { + this.mock(ecdsa).expects('verify') + .once().calledWith(ECPair.curve, hash, signature, keyPair.Q) + + keyPair.verify(hash, signature) + })) + }) + }) +}) diff --git a/test/fixtures/ecpair.json b/test/fixtures/ecpair.json new file mode 100644 index 0000000..bc117e6 --- /dev/null +++ b/test/fixtures/ecpair.json @@ -0,0 +1,102 @@ +{ + "valid": [ + { + "d": "1", + "Q": "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", + "compressed": true, + "network": "bitcoin", + "address": "1BgGZ9tcN4rm9KBzDn7KprQz87SZ26SAMH", + "WIF": "KwDiBf89QgGbjEhKnhXJuH7LrciVrZi3qYjgd9M7rFU73sVHnoWn" + }, + { + "d": "1", + "Q": "0479be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798483ada7726a3c4655da4fbfc0e1108a8fd17b448a68554199c47d08ffb10d4b8", + "compressed": false, + "network": "bitcoin", + "address": "1EHNa6Q4Jz2uvNExL497mE43ikXhwF6kZm", + "WIF": "5HpHagT65TZzG1PH3CSu63k8DbpvD8s5ip4nEB3kEsreAnchuDf" + }, + { + "d": "19898843618908353587043383062236220484949425084007183071220218307100305431102", + "Q": "02b80011a883a0fd621ad46dfc405df1e74bf075cbaf700fd4aebef6e96f848340", + "compressed": true, + "network": "bitcoin", + "address": "1MasfEKgSiaSeri2C6kgznaqBNtyrZPhNq", + "WIF": "KxhEDBQyyEFymvfJD96q8stMbJMbZUb6D1PmXqBWZDU2WvbvVs9o" + }, + { + "d": "48968302285117906840285529799176770990048954789747953886390402978935544927851", + "Q": "024289801366bcee6172b771cf5a7f13aaecd237a0b9a1ff9d769cabc2e6b70a34", + "compressed": true, + "network": "bitcoin", + "address": "1LwwMWdSEMHJ2dMhSvAHZ3g95tG2UBv9jg", + "WIF": "KzrA86mCVMGWnLGBQu9yzQa32qbxb5dvSK4XhyjjGAWSBKYX4rHx" + }, + { + "d": "48968302285117906840285529799176770990048954789747953886390402978935544927851", + "Q": "044289801366bcee6172b771cf5a7f13aaecd237a0b9a1ff9d769cabc2e6b70a34cec320a0565fb7caf11b1ca2f445f9b7b012dda5718b3cface369ee3a034ded6", + "compressed": false, + "network": "bitcoin", + "address": "1zXcfvKCLgsFdJDYPuqpu1sF3q92tnnUM", + "WIF": "5JdxzLtFPHNe7CAL8EBC6krdFv9pwPoRo4e3syMZEQT9srmK8hh" + }, + { + "d": "48968302285117906840285529799176770990048954789747953886390402978935544927851", + "Q": "024289801366bcee6172b771cf5a7f13aaecd237a0b9a1ff9d769cabc2e6b70a34", + "compressed": true, + "network": "testnet", + "address": "n1TteZiR3NiYojqKAV8fNxtTwsrjM7kVdj", + "WIF": "cRD9b1m3vQxmwmjSoJy7Mj56f4uNFXjcWMCzpQCEmHASS4edEwXv" + }, + { + "d": "48968302285117906840285529799176770990048954789747953886390402978935544927851", + "Q": "044289801366bcee6172b771cf5a7f13aaecd237a0b9a1ff9d769cabc2e6b70a34cec320a0565fb7caf11b1ca2f445f9b7b012dda5718b3cface369ee3a034ded6", + "compressed": false, + "network": "testnet", + "address": "mgWUuj1J1N882jmqFxtDepEC73Rr22E9GU", + "WIF": "92Qba5hnyWSn5Ffcka56yMQauaWY6ZLd91Vzxbi4a9CCetaHtYj" + }, + { + "d": "115792089237316195423570985008687907852837564279074904382605163141518161494336", + "Q": "0379be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798", + "compressed": true, + "network": "bitcoin", + "address": "1GrLCmVQXoyJXaPJQdqssNqwxvha1eUo2E", + "WIF": "L5oLkpV3aqBjhki6LmvChTCV6odsp4SXM6FfU2Gppt5kFLaHLuZ9" + } + ], + "invalid": { + "constructor": [ + { + "exception": "Private key must be greater than 0", + "d": "-1" + }, + { + "exception": "Private key must be greater than 0", + "d": "0" + }, + { + "exception": "Private key must be less than the curve order", + "d": "115792089237316195423570985008687907852837564279074904382605163141518161494337" + }, + { + "exception": "Private key must be less than the curve order", + "d": "115792089237316195423570985008687907853269984665640564039457584007913129639935" + } + ], + "fromWIF": [ + { + "exception": "Invalid compression flag", + "string": "ju9rooVsmagsb4qmNyTysUSFB1GB6MdpD7eoGjUTPmZRAApJxRz" + }, + { + "exception": "Invalid WIF payload length", + "string": "7ZEtRQLhCsDQrd6ZKfmcESdXgas8ggZPN24ByEi5ey6VJW" + }, + { + "exception": "Invalid WIF payload length", + "string": "5qibUKwsnMo1qDiNp3prGaQkD2JfVJa8F8Na87H2CkMHvuVg6uKhw67Rh" + } + ] + } +}