diff --git a/package-lock.json b/package-lock.json index c7ab7e2..47df81d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,8 +9,9 @@ "version": "8.0.6", "license": "MIT", "dependencies": { + "@noble/secp256k1": "1.7.2", + "bn.js": "4.12.5", "bs58": "6.0.0", - "elliptic": "6.6.1", "lodash": "4.18.1", "zod": "4.5.1" }, @@ -22,7 +23,7 @@ "@babel/preset-env": "7.29.7", "@eslint/eslintrc": "3.3.7", "@eslint/js": "10.0.1", - "@types/elliptic": "6.4.18", + "@types/bn.js": "5.2.0", "@types/jest": "30.0.0", "@types/lodash": "4.17.25", "@types/node": "26.6.3", @@ -2935,6 +2936,18 @@ "@emnapi/runtime": "^1.7.1 || ^2.0.0-alpha.4" } }, + "node_modules/@noble/secp256k1": { + "version": "1.7.2", + "resolved": "https://registry.npmjs.org/@noble/secp256k1/-/secp256k1-1.7.2.tgz", + "integrity": "sha512-/qzwYl5eFLH8OWIecQWM31qld2g1NfjgylK+TNhqtaUKP37Nm+Y+z30Fjhw0Ct8p9yCQEm2N3W/AckdIb3SMcQ==", + "funding": [ + { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + ], + "license": "MIT" + }, "node_modules/@open-draft/deferred-promise": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/@open-draft/deferred-promise/-/deferred-promise-2.2.0.tgz", @@ -3392,16 +3405,6 @@ "@types/node": "*" } }, - "node_modules/@types/elliptic": { - "version": "6.4.18", - "resolved": "https://registry.npmjs.org/@types/elliptic/-/elliptic-6.4.18.tgz", - "integrity": "sha512-UseG6H5vjRiNpQvrhy4VF/JXdA3V/Fp5amvveaL+fs28BZ6xIKJBPnUPRlEaZpysD9MbpfaLi8lbl7PGUAkpWw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/bn.js": "*" - } - }, "node_modules/@types/esrecurse": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", @@ -4392,9 +4395,9 @@ } }, "node_modules/bn.js": { - "version": "4.12.3", - "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.3.tgz", - "integrity": "sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==", + "version": "4.12.5", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.12.5.tgz", + "integrity": "sha512-3aRg6/JxfffFD+OlOjOFR3Vo79l39ooBTFucxx+MT3dhCtzn3EmiUPQo+6/OZuI2jbXi3YKgmiTFBgChQMwIRQ==", "license": "MIT" }, "node_modules/brace-expansion": { @@ -4410,12 +4413,6 @@ "node": "20 || >=22" } }, - "node_modules/brorand": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/brorand/-/brorand-1.1.0.tgz", - "integrity": "sha512-cKV8tMCEpQs4hK/ik71d6LrPOnpkpGBR0wzxqr68g2m/LB2GxVYQroAjMJZRVM1Y4BCjCKc3vAamxSzOY2RP+w==", - "license": "MIT" - }, "node_modules/browserslist": { "version": "4.28.9", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.9.tgz", @@ -4848,21 +4845,6 @@ "dev": true, "license": "ISC" }, - "node_modules/elliptic": { - "version": "6.6.1", - "resolved": "https://registry.npmjs.org/elliptic/-/elliptic-6.6.1.tgz", - "integrity": "sha512-RaddvvMatK2LJHqFJ+YA4WysVN5Ita9E35botqIYspQ4TkRAlCicdzKOjlyv/1Za5RyTNn7di//eEV0uTAfe3g==", - "license": "MIT", - "dependencies": { - "bn.js": "^4.11.9", - "brorand": "^1.1.0", - "hash.js": "^1.0.0", - "hmac-drbg": "^1.0.1", - "inherits": "^2.0.4", - "minimalistic-assert": "^1.0.1", - "minimalistic-crypto-utils": "^1.0.1" - } - }, "node_modules/emittery": { "version": "0.13.1", "resolved": "https://registry.npmjs.org/emittery/-/emittery-0.13.1.tgz", @@ -5536,16 +5518,6 @@ "node": ">=8" } }, - "node_modules/hash.js": { - "version": "1.1.7", - "resolved": "https://registry.npmjs.org/hash.js/-/hash.js-1.1.7.tgz", - "integrity": "sha512-taOaskGt4z4SOANNseOviYDvjEJinIkRgmp7LbKP2YTTmVxWBl87s/uzK9r+44BclBSp2X7K1hqeNfz9JbBeXA==", - "license": "MIT", - "dependencies": { - "inherits": "^2.0.3", - "minimalistic-assert": "^1.0.1" - } - }, "node_modules/hashery": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/hashery/-/hashery-1.5.1.tgz", @@ -5583,17 +5555,6 @@ "set-cookie-parser": "^3.0.1" } }, - "node_modules/hmac-drbg": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/hmac-drbg/-/hmac-drbg-1.0.1.tgz", - "integrity": "sha512-Tti3gMqLdZfhOQY1Mzf/AanLiqh1WTiJgEj26ZuYQ9fbkLomzGchCws4FyrSd4VkpBfiNhaE1On+lOz894jvXg==", - "license": "MIT", - "dependencies": { - "hash.js": "^1.0.3", - "minimalistic-assert": "^1.0.0", - "minimalistic-crypto-utils": "^1.0.1" - } - }, "node_modules/hookified": { "version": "1.15.1", "resolved": "https://registry.npmjs.org/hookified/-/hookified-1.15.1.tgz", @@ -5701,12 +5662,6 @@ "node": ">=0.8.19" } }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "license": "ISC" - }, "node_modules/is-arrayish": { "version": "0.2.1", "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", @@ -6644,18 +6599,6 @@ "node": ">=6" } }, - "node_modules/minimalistic-assert": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", - "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", - "license": "ISC" - }, - "node_modules/minimalistic-crypto-utils": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/minimalistic-crypto-utils/-/minimalistic-crypto-utils-1.0.1.tgz", - "integrity": "sha512-JIYlbt6g8i5jKfJ3xz7rF0LXmv2TkDxBLUkiBeZ7bAx4GnnNMr8xFpGnOxn6GhTEHx3SjRrZEoU+j04prX1ktg==", - "license": "MIT" - }, "node_modules/minimatch": { "version": "10.2.6", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz", diff --git a/package.json b/package.json index 621f34c..f7a8c5e 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,7 @@ "prepare": "npm run build && husky", "prettier": "prettier --config .prettierrc 'src/**/*.ts' 'test/**/*.spec.ts' 'examples/**/*'", "test": "npm run format:ci && npm run lint && npm run coverage", - "unit": "jest test/clientUnit.spec.ts test/Client/bitPayResponseUnit.spec.ts --runInBand", + "unit": "jest test/clientUnit.spec.ts test/Client/bitPayResponseUnit.spec.ts test/keyUtilsUnit.spec.ts test/setup/secretFileUnit.spec.ts --runInBand", "functional": "jest test/clientFunctional.spec.ts --runInBand" }, "author": "Antonio Buedo ", @@ -51,8 +51,9 @@ }, "homepage": "https://github.com/bitpay/nodejs-bitpay-client#readme", "dependencies": { + "@noble/secp256k1": "1.7.2", + "bn.js": "4.12.5", "bs58": "6.0.0", - "elliptic": "6.6.1", "lodash": "4.18.1", "zod": "4.5.1" }, @@ -61,7 +62,7 @@ "@babel/preset-env": "7.29.7", "@eslint/eslintrc": "3.3.7", "@eslint/js": "10.0.1", - "@types/elliptic": "6.4.18", + "@types/bn.js": "5.2.0", "@types/jest": "30.0.0", "@types/lodash": "4.17.25", "@types/node": "26.6.3", diff --git a/src/Client.ts b/src/Client.ts index 6436b9b..b417906 100644 --- a/src/Client.ts +++ b/src/Client.ts @@ -1,6 +1,5 @@ /* eslint-disable @typescript-eslint/no-unused-vars*/ -import { ec } from 'elliptic'; import { BillClient, BitPayClient, @@ -41,6 +40,7 @@ import { PosToken } from './PosToken'; import { PrivateKey } from './PrivateKey'; import { TokenContainer } from './TokenContainer'; import { GuidGenerator } from './util/GuidGenerator'; +import { KeyPair } from './util/KeyUtils'; import { ParamsRemover } from './util/ParamsRemover'; export class Client { @@ -757,7 +757,7 @@ export class Client { return environment.toUpperCase() == Env.Test ? Env.TestUrl : Env.ProdUrl; } - private getIdentity(ecKey: ec.KeyPair) { + private getIdentity(ecKey: KeyPair) { return this.keyUtils.getPublicKeyFromPrivateKey(ecKey); } diff --git a/src/Client/BitPayClient.ts b/src/Client/BitPayClient.ts index 8270810..c5e7046 100644 --- a/src/Client/BitPayClient.ts +++ b/src/Client/BitPayClient.ts @@ -1,6 +1,5 @@ import { Env, KeyUtils } from '../index'; -import { ec } from 'elliptic'; -import KeyPair = ec.KeyPair; +import { KeyPairLike } from '../util/KeyUtils'; import * as qs from 'querystring'; import BitPayException from '../Exceptions/BitPayException'; import { BitPayResponseParser } from '../util/BitPayResponseParser'; @@ -8,14 +7,14 @@ import { BitPayExceptionProvider } from '../Exceptions/BitPayExceptionProvider'; import { LoggerProvider } from '../Logger/LoggerProvider'; export class BitPayClient { - private readonly ecKey: KeyPair | null; + private readonly ecKey: KeyPairLike | null; private readonly identity: string | null; private readonly baseUrl: string; private readonly defaultHeaders: Record; private readonly keyUtils: KeyUtils; private readonly responseParser: BitPayResponseParser; - public constructor(baseUrl: string, ecKey: KeyPair | null, identity: string | null, platformInfo?: string) { + public constructor(baseUrl: string, ecKey: KeyPairLike | null, identity: string | null, platformInfo?: string) { this.ecKey = ecKey; this.baseUrl = baseUrl; this.identity = identity; diff --git a/src/index.ts b/src/index.ts index 62665da..8d1141b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -13,7 +13,8 @@ * See the LICENSE file for more info. */ -import { KeyUtils } from './util/KeyUtils'; +import { KeyUtils, KeyPair, PublicKey, Signature } from './util/KeyUtils'; +export type { KeyInput, KeyPairLike } from './util/KeyUtils'; import * as BitPayExceptions from './Exceptions/index'; import * as Models from './Model/index'; import { Client } from './Client'; @@ -32,6 +33,9 @@ import * as PayoutStatus from './Model/Payout/PayoutStatus'; export { KeyUtils, + KeyPair, + PublicKey, + Signature, BitPayExceptions, Models, Config, diff --git a/src/setup/BitPaySetup.ts b/src/setup/BitPaySetup.ts index c5de7c4..cbc68b2 100644 --- a/src/setup/BitPaySetup.ts +++ b/src/setup/BitPaySetup.ts @@ -1,12 +1,13 @@ -import { ec } from 'elliptic'; import * as fs from 'fs'; import * as readline from 'readline'; import * as BitPaySDK from '../index'; +import { writeSecretFile } from './SecretFile'; +import { KeyPair } from '../util/KeyUtils'; const keyUtils = new BitPaySDK.KeyUtils(); let configFilePath = process.cwd(); -let keyPair: ec.KeyPair; -let ecKey: ec.KeyPair; +let keyPair: KeyPair; +let ecKey: KeyPair; let environment: string; // eslint-disable-next-line @typescript-eslint/no-unused-vars let storeFile = true; @@ -165,10 +166,8 @@ const storeKey = async () => { console.log('Saving private key... \n'); sleep(500); - fs.writeFile(keyPath, ecKey.getPrivate('hex'), { mode: 0o755 }, function (err) { - if (err) throw err; - console.log('Private key saved in file: ' + keyPath + '\n'); - }); + writeSecretFile(keyPath, ecKey.getPrivate('hex')); + console.log('Private key saved in file: ' + keyPath + '\n'); await sleep(1000); selectTokens(); @@ -323,11 +322,9 @@ const updateConfigFile = async () => { } }; - fs.writeFile(configFilePath + '/BitPay.config.json', JSON.stringify(configurationObject, null, 4), function (err) { - if (err) throw err; - console.log('Generated configuration file'); - console.log('And saved in file: ' + configFilePath + '/BitPay.config.json' + '\n'); - }); + writeSecretFile(configFilePath + '/BitPay.config.json', JSON.stringify(configurationObject, null, 4)); + console.log('Generated configuration file'); + console.log('And saved in file: ' + configFilePath + '/BitPay.config.json' + '\n'); await sleep(5000); console.log('Configuration generated successfully! \n'); diff --git a/src/setup/SecretFile.ts b/src/setup/SecretFile.ts new file mode 100644 index 0000000..9283404 --- /dev/null +++ b/src/setup/SecretFile.ts @@ -0,0 +1,16 @@ +import * as fs from 'fs'; + +/** + * Writes a file that holds secrets (private key, API tokens) so only the owner can read and write it. + * + * The mode given to writeFileSync only applies when the file is created. If the file already + * exists, for example from an older setup run, its permissions are tightened before and after + * writing. On Windows, chmod only controls the read-only flag, so this has no effect there. + */ +export function writeSecretFile(path: string, content: string): void { + if (fs.existsSync(path)) { + fs.chmodSync(path, 0o600); + } + fs.writeFileSync(path, content, { mode: 0o600 }); + fs.chmodSync(path, 0o600); +} diff --git a/src/util/KeyUtils.ts b/src/util/KeyUtils.ts index aa92b3b..cc9a13a 100644 --- a/src/util/KeyUtils.ts +++ b/src/util/KeyUtils.ts @@ -1,42 +1,173 @@ +import * as BN from 'bn.js'; import bs58 from 'bs58'; import * as crypto from 'crypto'; -import * as elliptic from 'elliptic'; +import * as secp from '@noble/secp256k1'; -const ec = new elliptic.ec('secp256k1'); +// noble 1.7.x needs a sync HMAC to create deterministic (RFC 6979) signatures without async. +secp.utils.hmacSha256Sync = (key: Uint8Array, ...messages: Uint8Array[]): Uint8Array => { + const hmac = crypto.createHmac('sha256', key); + messages.forEach((message) => hmac.update(message)); + return new Uint8Array(hmac.digest()); +}; + +const CURVE_N = new BN(secp.CURVE.n.toString(16), 16); + +/** + * Anything with a getPrivate() that returns a BN, for example a KeyPair created with elliptic. + */ +export interface KeyPairLike { + getPrivate(): BN; +} + +export type KeyInput = Buffer | Uint8Array | string | BN | KeyPairLike; + +export class PublicKey { + public constructor( + private readonly compressed: Uint8Array, + private readonly uncompressed: Uint8Array + ) {} + + public encode(): number[]; + public encode(enc: 'hex', compact?: boolean): string; + public encode(enc?: 'hex', compact?: boolean): number[] | string { + const bytes = compact ? this.compressed : this.uncompressed; + return enc === 'hex' ? Buffer.from(bytes).toString('hex') : Array.from(bytes); + } + + public encodeCompressed(): number[]; + public encodeCompressed(enc: 'hex'): string; + public encodeCompressed(enc?: 'hex'): number[] | string { + return enc === 'hex' ? Buffer.from(this.compressed).toString('hex') : Array.from(this.compressed); + } +} + +export class Signature { + public constructor(private readonly der: Uint8Array) {} + + public toDER(): number[]; + public toDER(enc: 'hex'): string; + public toDER(enc?: 'hex'): number[] | string { + return enc === 'hex' ? Buffer.from(this.der).toString('hex') : Array.from(this.der); + } +} + +/** + * secp256k1 key pair. Keeps the methods of the elliptic KeyPair that the SDK and its users rely on. + */ +export class KeyPair implements KeyPairLike { + private readonly priv: BN; + private readonly privBytes: Uint8Array; + + public constructor(priv: BN) { + if (priv.isZero()) { + throw new Error('Invalid private key'); + } + this.priv = priv; + this.privBytes = new Uint8Array(priv.toArray('be', 32)); + } + + public getPrivate(): BN; + public getPrivate(enc: 'hex'): string; + public getPrivate(enc?: 'hex'): BN | string { + // Always 64 hex chars. elliptic dropped leading zeros, which gave shorter keys about 1 time in 256. + return enc === 'hex' ? this.priv.toString(16, 64) : this.priv.clone(); + } + + public getPublic(): PublicKey; + public getPublic(enc: 'hex'): string; + public getPublic(compact: boolean, enc: 'hex'): string; + public getPublic(compactOrEnc?: boolean | 'hex', enc?: 'hex'): PublicKey | string { + const publicKey = new PublicKey(secp.getPublicKey(this.privBytes, true), secp.getPublicKey(this.privBytes, false)); + + if (typeof compactOrEnc === 'boolean') { + return enc === 'hex' ? publicKey.encode('hex', compactOrEnc) : publicKey; + } + + return compactOrEnc === 'hex' ? publicKey.encode('hex', false) : publicKey; + } + + /** + * Signs a message hash. Signatures are always low-S (canonical). + */ + public sign(msgHash: Buffer | Uint8Array | number[] | string): Signature { + return new Signature(secp.signSync(KeyPair.toBytes(msgHash), this.privBytes, { der: true, canonical: true })); + } + + /** + * Verifies a DER signature for a message hash. Accepts high-S signatures, like elliptic. + */ + public verify( + msgHash: Buffer | Uint8Array | number[] | string, + signature: Buffer | Uint8Array | number[] | string + ): boolean { + const publicKey = secp.getPublicKey(this.privBytes, true); + return secp.verify(KeyPair.toBytes(signature), KeyPair.toBytes(msgHash), publicKey, { strict: false }); + } + + private static toBytes(value: Buffer | Uint8Array | number[] | string): Uint8Array { + return typeof value === 'string' ? new Uint8Array(Buffer.from(value, 'hex')) : Uint8Array.from(value); + } +} export class KeyUtils { - public generate_keypair(): elliptic.ec.KeyPair { - const kp = ec.genKeyPair(); - return kp; + public generate_keypair(): KeyPair { + return new KeyPair(new BN(secp.utils.randomPrivateKey())); } - public load_keypair(buf: Buffer | string | elliptic.ec.KeyPair): elliptic.ec.KeyPair { - return ec.keyFromPrivate(buf); + /** + * Reads a private key the same way elliptic's keyFromPrivate() did, so existing keys keep + * the same public key and SIN: hex strings of any length, Buffers, BN, or a key pair. + */ + public load_keypair(key: KeyInput): KeyPair { + if (key instanceof KeyPair) { + return key; + } + + return new KeyPair(KeyUtils.toPrivateBN(key)); } - public get_sin_from_key(kp: elliptic.ec.KeyPair): string { - const pk: Buffer = Buffer.from(kp.getPublic().encodeCompressed()); + public get_sin_from_key(kp: KeyInput): string { + const pk: Buffer = Buffer.from(this.load_keypair(kp).getPublic().encodeCompressed()); const version: Buffer = this.get_version_from_compressed_key(pk); const checksum: Buffer = this.get_checksum_from_version(version); return bs58.encode(Buffer.concat([version, checksum])); } - public signOrig(data: string, kp: elliptic.ec.KeyPair): Buffer { + public signOrig(data: string, kp: KeyInput): Buffer { const digest = crypto.createHash('sha256').update(data).digest(); - return Buffer.from(kp.sign(digest).toDER()); + return Buffer.from(this.load_keypair(kp).sign(digest).toDER()); } - public sign(data: string, privkey: elliptic.ec.KeyPair): string { + public sign(data: string, privkey: KeyInput): string { const dataBuffer = Buffer.from(data, 'utf-8'); const hashBuffer = crypto.createHash('sha256').update(dataBuffer).digest(); - return Buffer.from(privkey.sign(hashBuffer).toDER()).toString('hex'); + return this.load_keypair(privkey).sign(hashBuffer).toDER('hex'); } - public getPublicKeyFromPrivateKey(privkey: string | Buffer | elliptic.ec.KeyPair): string { - const ecKey = this.load_keypair(privkey); + public getPublicKeyFromPrivateKey(privkey: KeyInput): string { + return this.load_keypair(privkey).getPublic().encodeCompressed('hex'); + } + + private static toPrivateBN(key: KeyInput): BN { + if (BN.isBN(key)) { + return (key as BN).umod(CURVE_N); + } + + if (typeof key === 'string') { + // Same parsing as elliptic: bn.js 4.x, base 16. + return new BN(key, 16).umod(CURVE_N); + } + + if (key instanceof Uint8Array) { + return new BN(key).umod(CURVE_N); + } + + if (key && typeof (key as KeyPairLike).getPrivate === 'function') { + return KeyUtils.toPrivateBN((key as KeyPairLike).getPrivate()); + } - return ecKey.getPublic().encodeCompressed('hex'); + throw new Error('Unsupported private key format'); } private get_version_from_compressed_key(pk: Buffer): Buffer { diff --git a/test/keyUtilsUnit.spec.ts b/test/keyUtilsUnit.spec.ts new file mode 100644 index 0000000..c83c414 --- /dev/null +++ b/test/keyUtilsUnit.spec.ts @@ -0,0 +1,130 @@ +import * as BN from 'bn.js'; +import * as crypto from 'crypto'; +import { KeyPair, KeyUtils } from '../src/util/KeyUtils'; + +// Expected values were generated with elliptic 6.6.1, the library this SDK used before 8.1.0. +// They check that keys saved with older versions still load as the same key and SIN. +const keyUtils = new KeyUtils(); + +const HEX = '2f72ed3291b536aa43d750829875f5a742a0f1095b8ad529944cbc0bd498693f'; +const PUB_COMPRESSED = '0312cd27740676028cebe2238fb797c3957ba5e94b1f2a8567a44982c502769ae5'; +const PUB_UNCOMPRESSED = + '0412cd27740676028cebe2238fb797c3957ba5e94b1f2a8567a44982c502769ae5' + + '58e0ce03cc0f05d0953d7199b2434d33d7bdc85437a8aea5f74e7b9953938595'; +const SIN = 'TfBqeTz1xKY35nMq7PdGdahsfdCcgDsgQmF'; + +describe('KeyUtils', () => { + describe('load_keypair reads keys like elliptic did', () => { + const cases: Array<[string, string | Buffer, string, string]> = [ + ['hex', HEX, HEX, PUB_COMPRESSED], + ['upper case hex', HEX.toUpperCase(), HEX, PUB_COMPRESSED], + ['hex with trailing newline (key file)', HEX + '\n', HEX, PUB_COMPRESSED], + ['32 byte Buffer', Buffer.from(HEX, 'hex'), HEX, PUB_COMPRESSED], + [ + 'hex shorter than 64 chars (leading zero byte)', + '39ac75f6acc2fcbed3316a8b51c5e767dbb0d1c03c14507e1a3ae9d0513618', + '0039ac75f6acc2fcbed3316a8b51c5e767dbb0d1c03c14507e1a3ae9d0513618', + '02e20056f497dbef39f1ee60d34cc23498efa1e98052dadf1f03190e72c6de0155' + ], + [ + 'hex with an invalid char', + '2f72ed3291b536aa43d750829875f5a742a0f1095b8ad529944cbc0bd498693g', + '2f72ed3291b536aa43d750829875f5a742a0f1095b8ad529944cbc0bd4986937', + '02cfc16f49e449c825721e1684b356fdfd42a1a6600df6c687200de2e54369806c' + ], + [ + 'Buffer with the hex as ASCII bytes', + Buffer.from('81cc183fe31e318337f885f0d7058a615855f5e930f25dc1510c4283a52a823f'), + '66f0b35dd55a6dd3d4262db5d47dd89c572f6e84e0aabeca1a2e8680fe667b09', + '02ada2095c23b2a9e9dace39a18ace691c3e4b2cc8356554d6e2077d6f19070191' + ] + ]; + + it.each(cases)('%s', (_name, input, expectedPrivate, expectedPublic) => { + const kp = keyUtils.load_keypair(input); + expect(kp.getPrivate('hex')).toBe(expectedPrivate); + expect(keyUtils.getPublicKeyFromPrivateKey(kp)).toBe(expectedPublic); + }); + + it('returns the same KeyPair when given a KeyPair', () => { + const kp = keyUtils.load_keypair(HEX); + expect(keyUtils.load_keypair(kp)).toBe(kp); + }); + + it('accepts a BN and any object with getPrivate() (e.g. an elliptic KeyPair)', () => { + const fromBN = keyUtils.load_keypair(new BN(HEX, 16)); + const fromLike = keyUtils.load_keypair({ getPrivate: () => new BN(HEX, 16) }); + expect(fromBN.getPrivate('hex')).toBe(HEX); + expect(fromLike.getPrivate('hex')).toBe(HEX); + }); + + it('rejects a zero key', () => { + expect(() => keyUtils.load_keypair('00')).toThrow('Invalid private key'); + }); + }); + + describe('KeyPair', () => { + const kp = keyUtils.load_keypair(HEX); + + it('getPrivate() returns a BN that loads the same key', () => { + const priv = kp.getPrivate(); + expect(BN.isBN(priv)).toBe(true); + expect(keyUtils.getPublicKeyFromPrivateKey(priv)).toBe(PUB_COMPRESSED); + }); + + it('getPrivate("hex") is always 64 chars', () => { + const shortKey = keyUtils.load_keypair('39ac75f6acc2fcbed3316a8b51c5e767dbb0d1c03c14507e1a3ae9d0513618'); + expect(shortKey.getPrivate('hex')).toHaveLength(64); + }); + + it('getPublic("hex") is uncompressed, like elliptic', () => { + expect(kp.getPublic('hex')).toBe(PUB_UNCOMPRESSED); + expect(kp.getPublic(true, 'hex')).toBe(PUB_COMPRESSED); + expect(kp.getPublic(false, 'hex')).toBe(PUB_UNCOMPRESSED); + expect(kp.getPublic().encodeCompressed('hex')).toBe(PUB_COMPRESSED); + expect(kp.getPublic().encode('hex')).toBe(PUB_UNCOMPRESSED); + }); + + it('get_sin_from_key returns the same SIN as before', () => { + expect(keyUtils.get_sin_from_key(kp)).toBe(SIN); + }); + + it('generate_keypair returns a usable KeyPair', () => { + const generated = keyUtils.generate_keypair(); + expect(generated).toBeInstanceOf(KeyPair); + expect(generated.getPrivate('hex')).toMatch(/^[0-9a-f]{64}$/); + }); + }); + + describe('sign', () => { + const kp = keyUtils.load_keypair(HEX); + + it('matches elliptic canonical signature (deterministic, RFC 6979)', () => { + expect(keyUtils.sign('https://test.bitpay.com/invoices/1234', kp)).toBe( + '3045022100ab41b28d196b0efcf059991ff90b1106e93e69032e3a45de2038c456ade793b9' + + '02200c1221d95af72cd8a5e2470126ea767cc5a3fa1523d41b3921a5b39cd847d61b' + ); + }); + + it('returns low-S where elliptic default returned high-S, and both verify', () => { + const data = 'https://test.bitpay.com/invoices/0'; + const ellipticHighS = + '3046022100df88ce90fc8034234fd1e875e073ac9fc84c204dfbf1a5f45da1b34114d610f0' + + '022100ea159c56154208cbae5e8570d6dc9175a37b604a496c8c4f8ef8b937954a094e'; + const lowS = + '3045022100df88ce90fc8034234fd1e875e073ac9fc84c204dfbf1a5f45da1b34114d610f0' + + '022015ea63a9eabdf73451a17a8f29236e8917337c9c65dc13ec30d9a5553aec37f3'; + + expect(keyUtils.sign(data, kp)).toBe(lowS); + + const hash = crypto.createHash('sha256').update(data, 'utf-8').digest(); + expect(kp.verify(hash, lowS)).toBe(true); + expect(kp.verify(hash, ellipticHighS)).toBe(true); + }); + + it('signOrig returns DER bytes', () => { + const der = keyUtils.signOrig('https://test.bitpay.com/invoices/1234', kp); + expect(der.toString('hex').startsWith('30')).toBe(true); + }); + }); +}); diff --git a/test/setup/secretFileUnit.spec.ts b/test/setup/secretFileUnit.spec.ts new file mode 100644 index 0000000..c83356d --- /dev/null +++ b/test/setup/secretFileUnit.spec.ts @@ -0,0 +1,38 @@ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { writeSecretFile } from '../../src/setup/SecretFile'; + +// POSIX permissions do not apply on Windows. +const describePosix = process.platform === 'win32' ? describe.skip : describe; + +describePosix('writeSecretFile', () => { + let dir: string; + + beforeEach(() => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'bitpay-secret-')); + }); + + afterEach(() => { + fs.rmSync(dir, { recursive: true, force: true }); + }); + + it('creates the file readable only by its owner (0600)', () => { + const file = path.join(dir, 'private_key_test.key'); + writeSecretFile(file, 'abc'); + + expect(fs.statSync(file).mode & 0o777).toBe(0o600); + expect(fs.readFileSync(file, 'utf8')).toBe('abc'); + }); + + it('tightens an existing file created with wider permissions', () => { + const file = path.join(dir, 'BitPay.config.json'); + fs.writeFileSync(file, 'old', { mode: 0o755 }); + fs.chmodSync(file, 0o755); + + writeSecretFile(file, 'new'); + + expect(fs.statSync(file).mode & 0o777).toBe(0o600); + expect(fs.readFileSync(file, 'utf8')).toBe('new'); + }); +});