diff --git a/.github/workflows/js.yml b/.github/workflows/js.yml index 9fe148f4..fa8f557b 100644 --- a/.github/workflows/js.yml +++ b/.github/workflows/js.yml @@ -27,6 +27,8 @@ jobs: run: npm run lint - name: Build run: npm run build + - name: Crypto Tests + run: npm run crypto-tests - name: Static Tests (TS ESM) run: npm run ts-test-static - name: Static Tests (JS CJS) diff --git a/README.md b/README.md index 955c4c55..297568eb 100644 --- a/README.md +++ b/README.md @@ -57,12 +57,12 @@ Actively maintained, typed, and safe SDK for the Binance REST APIs and Websocket ### Features - Spot, Margin, Futures and Delivery API -- Portfolio Margin API *\*soon*\* - Testnet support - Proxy support (REST and WS) - Customizable HTTP headers - Customizable request parameters -- RSA/ECDSA support *\*soon*\* +- RSA/ECDSA support +- Portfolio Margin API *\*soon*\* - Websocket handling with automatic reconnection - RecvWindow and automatic timestamps generation - Ability to call any endpoint, even if not supported directly by the library diff --git a/package.json b/package.json index 748df70c..1c806bcb 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,8 @@ "ts-test-live": "mocha ./tests/binance-class-live.test.ts", "ts-test-static": "mocha ./tests/binance-class-static.test.ts", "test-cjs": "node ./tests/cjs-test.cjs", + "crypto-tests": "mocha ./tests/crypto.test.ts", + "ws-tests": "mocha ./tests/binance-class-ws.test.ts", "ws-tests-spot": "mocha ./tests/binance-ws-spot.test.ts --exit", "ws-tests-futures": "mocha ./tests/binance-ws-futures.test.ts --exit", "test-debug": "mocha --inspect-brk", diff --git a/src/node-binance-api.ts b/src/node-binance-api.ts index c9ac110c..e68b6743 100644 --- a/src/node-binance-api.ts +++ b/src/node-binance-api.ts @@ -11,6 +11,7 @@ import { HttpsProxyAgent } from 'https-proxy-agent'; import { SocksProxyAgent } from 'socks-proxy-agent'; // @ts-ignore import nodeFetch from 'node-fetch'; + // @ts-ignore import zip from 'lodash.zipobject'; import stringHash from 'string-hash'; @@ -63,6 +64,8 @@ export default class Binance { APIKEY: string = undefined; APISECRET: string = undefined; + PRIVATEKEY: string = undefined; + PRIVATEKEYPASSWORD: string = undefined; test = false; timeOffset: number = 0; @@ -209,6 +212,8 @@ export default class Binance { if (this.Options.APIKEY) this.APIKEY = this.Options.APIKEY; if (this.Options.APISECRET) this.APISECRET = this.Options.APISECRET; + if (this.Options.PRIVATEKEY) this.PRIVATEKEY = this.Options.PRIVATEKEY; + if (this.Options.PRIVATEKEYPASSWORD) this.PRIVATEKEYPASSWORD = this.Options.PRIVATEKEYPASSWORD; if (this.Options.test) this.test = true; if (this.Options.headers) this.headers = this.Options.Headers; if (this.Options.domain) this.domain = this.Options.domain; @@ -535,7 +540,7 @@ export default class Binance { data.timestamp += this.timeOffset; } query = this.makeQueryString(data); - data.signature = crypto.createHmac('sha256', this.APISECRET).update(query).digest('hex'); // HMAC hash header + data.signature = this.generateSignature(query); opt.url = `${url}?${query}&signature=${data.signature}`; } (opt as any).qs = data; @@ -647,7 +652,9 @@ export default class Binance { if (!data.recvWindow) data.recvWindow = this.Options.recvWindow; const query = method === 'POST' && noDataInSignature ? '' : this.makeQueryString(data); - const signature = crypto.createHmac('sha256', this.Options.APISECRET).update(query).digest('hex'); // set the HMAC hash header + + const signature = this.generateSignature(query); + if (method === 'POST') { const opt = this.reqObjPOST( url, @@ -670,6 +677,44 @@ export default class Binance { } } + generateSignature(query: string, encode = true) { + const secret = this.APISECRET || this.PRIVATEKEY; + let signature = ''; + if (secret.includes ('PRIVATE KEY')) { + // if less than the below length, then it can't be RSA key + let keyObject: crypto.KeyObject; + try { + const privateKeyObj: crypto.PrivateKeyInput = { key: secret }; + + if (this.PRIVATEKEYPASSWORD) { + privateKeyObj.passphrase = this.PRIVATEKEYPASSWORD; + } + + keyObject = crypto.createPrivateKey(privateKeyObj); + + } catch (e){ + throw new Error( + 'Invalid private key. Please provide a valid RSA or ED25519 private key. ' + e.toString() + ); + } + + if (secret.length > 120) { + // RSA key + signature = crypto + .sign('RSA-SHA256', Buffer.from(query), keyObject) + .toString('base64'); + if (encode) signature = encodeURIComponent (signature); + return signature; + } else { + // Ed25519 key + signature = crypto.sign(null, Buffer.from(query), keyObject).toString('base64'); + } + } else { + signature = crypto.createHmac('sha256', this.Options.APISECRET).update(query).digest('hex'); // set the HMAC hash header + } + return signature; + } + // --- ENDPOINTS --- // /** diff --git a/src/types.ts b/src/types.ts index f89e8e1c..d90b9e56 100644 --- a/src/types.ts +++ b/src/types.ts @@ -125,6 +125,8 @@ export type Callback = (...args: any) => any; export interface IConstructorArgs { APIKEY: string; APISECRET: string; + PRIVATEKEY: string; // when using RSA/EDCSA keys + PRIVATEKEYPASSWORD: string; // when using RSA/EDCSA keys recvWindow: number; useServerTime: boolean; reconnect: boolean; diff --git a/tests/crypto.test.ts b/tests/crypto.test.ts new file mode 100644 index 00000000..3b1c5149 --- /dev/null +++ b/tests/crypto.test.ts @@ -0,0 +1,50 @@ +import Binance from '../src/node-binance-api'; +import { assert } from 'chai'; + + +const testCases = [ + { + "description": "Unencrypted PKCS8 ed22519 private key", + "private_key": "-----BEGIN PRIVATE KEY-----\nMC4CAQAwBQYDK2VwBCIEIPQmzwVKJETqVd7L9E/DFbkvrOigy1tLL+9QF0mSn6dV\n-----END PRIVATE KEY-----\n", + "password": undefined, + "expected_signature": "a4Pm3p02D2HXtNfo3DBaVCe9Ov7kledewgYtGjekotFmZ5wXa3mC5AtLB7CpAphyNjeyovIuDP+9fyjYmsojCw==", + }, + { + "description": "Unencrypted PKCS8 ed22519 private key in bytes", + "private_key": "-----BEGIN PRIVATE KEY-----\nMC4CAQAwBQYDK2VwBCIEIPQmzwVKJETqVd7L9E/DFbkvrOigy1tLL+9QF0mSn6dV\n-----END PRIVATE KEY-----\n", + "password": undefined, + "expected_signature": "a4Pm3p02D2HXtNfo3DBaVCe9Ov7kledewgYtGjekotFmZ5wXa3mC5AtLB7CpAphyNjeyovIuDP+9fyjYmsojCw==", + }, + { + "description": "Encrypted PKCS8 RSA private key", + "private_key": "-----BEGIN ENCRYPTED PRIVATE KEY-----\nMIIFNTBfBgkqhkiG9w0BBQ0wUjAxBgkqhkiG9w0BBQwwJAQQWW+iEMYYCPUntrPq\nZ2RCMAICCAAwDAYIKoZIhvcNAgkFADAdBglghkgBZQMEASoEEIw3ViSuTp8JeN43\n5VGlHt0EggTQBvEzd2w2F561CzU+MDouZDOPj4RTIStC471z0/bxTgYqH3gYchoe\nOfi2lsLuD8B+ivIRuXB8GT66BIseIOMV8t/tiMe97rFI/cV4h6DrBO1xlmSrBG97\nvFF9qPA5yPRlrHtWKkGxhXteNVsT3w/7Y7KsulO/gA2KpsOElMElOhUP462Yd0Wl\nOxAIV3+knl2niozws2Kq3EdzTF3N6hlavUPryiU/w4RRsPN5qgjchVVLq/sYRYhx\nN8uWJbkjhCcHsULkD5KkdgddR0VOhpQPXIdY+gPkSBJq1ltRWy/TYdXiU2fEBNZW\nhFUVrxnS76+u2R3vukY2IAX8zTC6h2AbCBG+r4XXzgk/l/4peySKHsPQRzQ0in39\na9o5sctOmUNeD4uJ6cClXDdqyEwXhnPmRKZjJ8qeH4D9wl7HOG7iQsYiyfJe/igi\nFEXVRZOtLBdbwX45rU6wiWWjxzY+mDnw4BXE31ZBPwgtoh+CLTyK8NI8LnCV/CgO\nzOY4sm/KDWmbfTTZjLSdYRFj7wEpOdUWjZ13viDFZqnmy/o1auvLmBcqbRrCyW+B\nOMI7aHE0mZ/52vEFQYU1tH0BxMmRfWXUCJj0TjwxDY6BQmmW4YlhsrgGNekLFDo1\n6phFd0pA4UPqGXfNLzHp1dtLhUEb4YzcpDn+HMzMf1gfez7qeqU28nNFg/AwwqHZ\nTWdGclCFjiah7SfvOslob4vdLGwkUhgCBKQUQoU1DltX2GOgIv9SNY3q6X0NwdZG\nL5gqk225WVUwIRzmi5nfUEXlbaTvyHg3BuGedUKJ91IhRCW1ZjvU8GQcfVsu8bse\nTCKMdr7wi/zEZXSldCza6vL4m3tmBLtWkHVOW8bcDWvoVwRswbFHfleHzckl7EeC\n9C4TRa66gA5UOv14SrpC8noQUNpSegg+1KI4BSNvwaheiSUqjQbisb0qYCxML0ZP\nmQodwVsXG6LYo+Y6y6CpHbT7UYkfa59q/CGOZByL1bEzzgd98ZHwjihOjHVaV6sY\nBW018AvGxr7kjEU4LNqIteydTp0o31ZJN/qK78w5EQFfJxfImrx/E4nYKtg4higj\nKOQCgJALKIveidqQEFsbGWsulYrMXwnu0nPThofR1D8eCJZpdTxvOh2nIrNrAeY8\nZMAwG1uQos5A0yEZ1auHxz+rb4errnk92OnVlWnElf1TwwlkFFNLdNDl8VpiMP40\n6en9VtlOfgH8AwB03WsoeuEQsxYTIcRKWZZPRsLx3hd0BsOw0FcYDSX2XIGPkVVW\niYf9hzFSQsWV3d6utloIm4nG8XONfNaRimGECbUSZyHZimrO1m4Gga5pE3LKuDri\nJKR2lR7b6XPR7+FS+lG1zq5KY7onAVQY1oABfTjpJRju6pQGWt70hairo6EaVC3u\nrBy8UkLwBbfDuigSvsVk+sF2+Ic0IzX6IniU0F5kMe+MKqGB4aicXP6FFGBpPFTe\nv6yHD+DYAu1rnlXrqmFL50CfutTF78uPPJ9D2Sm0DcGPFj+6IrCigj48uxoHR9Qb\nFeNzfsmVwoFAWWq/MpkPbX6Aql8ddCbpMxDUUkybwVV9rJmEMTLil44FrxKAKFhP\n0Av7JeFvdz15pfnf/IQ3IOvVhHGFChFS13sbYSvFHMQF3P0BiyvjhBI=\n-----END ENCRYPTED PRIVATE KEY-----\n", + "password": "testpwd", + "expected_signature": "S4l9IONXGHIdt4NjwmpCIhawDTitjUQls73d+mi0HJTSbTGyn95NabX5hC9+n6HsTqLcWPvxKgTvLFMnTaf6Jxl+xwQMbu9/6mw88KF7i1pEQizerKcr91rPUPVBQ4OY10Q018QEamIAymRgo/eoRYSm7CqCdeibGyO0XfXZBaJnVGFJ9hgrPIwSKHgeUnfK8qMenULvL0qKMEJ6ziYPiqh7k9xX3xIV7lGIpokk+ekqlFd01f/Lov45osJCFuccJO4xuUUZewZnVGF7Uw6Rim3UsKhXKZUN9WZWa5RT+dpBIJ5DTBIXBSvowwj3GZC3j+XvWw8Sn0Ls9836l89BXw==", + }, + { + "description": "Encrypted PKCS8 RSA private key in bytes", + "private_key": "-----BEGIN ENCRYPTED PRIVATE KEY-----\nMIIFNTBfBgkqhkiG9w0BBQ0wUjAxBgkqhkiG9w0BBQwwJAQQWW+iEMYYCPUntrPq\nZ2RCMAICCAAwDAYIKoZIhvcNAgkFADAdBglghkgBZQMEASoEEIw3ViSuTp8JeN43\n5VGlHt0EggTQBvEzd2w2F561CzU+MDouZDOPj4RTIStC471z0/bxTgYqH3gYchoe\nOfi2lsLuD8B+ivIRuXB8GT66BIseIOMV8t/tiMe97rFI/cV4h6DrBO1xlmSrBG97\nvFF9qPA5yPRlrHtWKkGxhXteNVsT3w/7Y7KsulO/gA2KpsOElMElOhUP462Yd0Wl\nOxAIV3+knl2niozws2Kq3EdzTF3N6hlavUPryiU/w4RRsPN5qgjchVVLq/sYRYhx\nN8uWJbkjhCcHsULkD5KkdgddR0VOhpQPXIdY+gPkSBJq1ltRWy/TYdXiU2fEBNZW\nhFUVrxnS76+u2R3vukY2IAX8zTC6h2AbCBG+r4XXzgk/l/4peySKHsPQRzQ0in39\na9o5sctOmUNeD4uJ6cClXDdqyEwXhnPmRKZjJ8qeH4D9wl7HOG7iQsYiyfJe/igi\nFEXVRZOtLBdbwX45rU6wiWWjxzY+mDnw4BXE31ZBPwgtoh+CLTyK8NI8LnCV/CgO\nzOY4sm/KDWmbfTTZjLSdYRFj7wEpOdUWjZ13viDFZqnmy/o1auvLmBcqbRrCyW+B\nOMI7aHE0mZ/52vEFQYU1tH0BxMmRfWXUCJj0TjwxDY6BQmmW4YlhsrgGNekLFDo1\n6phFd0pA4UPqGXfNLzHp1dtLhUEb4YzcpDn+HMzMf1gfez7qeqU28nNFg/AwwqHZ\nTWdGclCFjiah7SfvOslob4vdLGwkUhgCBKQUQoU1DltX2GOgIv9SNY3q6X0NwdZG\nL5gqk225WVUwIRzmi5nfUEXlbaTvyHg3BuGedUKJ91IhRCW1ZjvU8GQcfVsu8bse\nTCKMdr7wi/zEZXSldCza6vL4m3tmBLtWkHVOW8bcDWvoVwRswbFHfleHzckl7EeC\n9C4TRa66gA5UOv14SrpC8noQUNpSegg+1KI4BSNvwaheiSUqjQbisb0qYCxML0ZP\nmQodwVsXG6LYo+Y6y6CpHbT7UYkfa59q/CGOZByL1bEzzgd98ZHwjihOjHVaV6sY\nBW018AvGxr7kjEU4LNqIteydTp0o31ZJN/qK78w5EQFfJxfImrx/E4nYKtg4higj\nKOQCgJALKIveidqQEFsbGWsulYrMXwnu0nPThofR1D8eCJZpdTxvOh2nIrNrAeY8\nZMAwG1uQos5A0yEZ1auHxz+rb4errnk92OnVlWnElf1TwwlkFFNLdNDl8VpiMP40\n6en9VtlOfgH8AwB03WsoeuEQsxYTIcRKWZZPRsLx3hd0BsOw0FcYDSX2XIGPkVVW\niYf9hzFSQsWV3d6utloIm4nG8XONfNaRimGECbUSZyHZimrO1m4Gga5pE3LKuDri\nJKR2lR7b6XPR7+FS+lG1zq5KY7onAVQY1oABfTjpJRju6pQGWt70hairo6EaVC3u\nrBy8UkLwBbfDuigSvsVk+sF2+Ic0IzX6IniU0F5kMe+MKqGB4aicXP6FFGBpPFTe\nv6yHD+DYAu1rnlXrqmFL50CfutTF78uPPJ9D2Sm0DcGPFj+6IrCigj48uxoHR9Qb\nFeNzfsmVwoFAWWq/MpkPbX6Aql8ddCbpMxDUUkybwVV9rJmEMTLil44FrxKAKFhP\n0Av7JeFvdz15pfnf/IQ3IOvVhHGFChFS13sbYSvFHMQF3P0BiyvjhBI=\n-----END ENCRYPTED PRIVATE KEY-----\n", + "password": "testpwd", + "expected_signature": "S4l9IONXGHIdt4NjwmpCIhawDTitjUQls73d+mi0HJTSbTGyn95NabX5hC9+n6HsTqLcWPvxKgTvLFMnTaf6Jxl+xwQMbu9/6mw88KF7i1pEQizerKcr91rPUPVBQ4OY10Q018QEamIAymRgo/eoRYSm7CqCdeibGyO0XfXZBaJnVGFJ9hgrPIwSKHgeUnfK8qMenULvL0qKMEJ6ziYPiqh7k9xX3xIV7lGIpokk+ekqlFd01f/Lov45osJCFuccJO4xuUUZewZnVGF7Uw6Rim3UsKhXKZUN9WZWa5RT+dpBIJ5DTBIXBSvowwj3GZC3j+XvWw8Sn0Ls9836l89BXw==", + }, +] + +describe('Test crypto signature', function () { + + it('RSA and ed22519 tests ', function () { + + const dataQuery = 'price=50000&quantity=1&side=BUY&symbol=BTCUSDT×tamp=1631234567890&type=LIMIT' + + for (const testCase of testCases) { + const binance = new Binance({ + APISECRET: testCase.private_key, + PRIVATEKEYPASSWORD: testCase.password, + }) + + const signature = binance.generateSignature(dataQuery, false); + assert.equal(signature, testCase.expected_signature, testCase.description); + } + + }); + +}); \ No newline at end of file