From e1e49ef8dadfcc5cb86ec25ee393e87d59326d50 Mon Sep 17 00:00:00 2001 From: aharo Date: Fri, 25 Sep 2026 18:56:48 +0000 Subject: [PATCH 1/2] Replace elliptic with @noble/secp256k1 1.7.x elliptic has no fix for GHSA-848j-6mx2-7j84 (wrong ECDSA signatures in some cases), and the SDK signs every request with it. - KeyUtils now signs with @noble/secp256k1 1.7.2 (CommonJS, no Node range change). Signatures are always low-S. - New KeyPair class keeps the elliptic methods the SDK and users call: getPrivate(), getPublic(), sign().toDER(), verify(). - Keys are parsed with bn.js 4.x, like elliptic did, so saved keys keep the same public key and SIN (hex of any length, Buffers, BN, and elliptic KeyPair objects). - getPrivate('hex') now always returns 64 chars. - Adds unit tests with vectors generated by elliptic 6.6.1. --- package-lock.json | 123 ++++------------------------ package.json | 7 +- src/Client.ts | 4 +- src/Client/BitPayClient.ts | 7 +- src/index.ts | 6 +- src/setup/BitPaySetup.ts | 6 +- src/util/KeyUtils.ts | 163 +++++++++++++++++++++++++++++++++---- test/keyUtilsUnit.spec.ts | 130 +++++++++++++++++++++++++++++ 8 files changed, 312 insertions(+), 134 deletions(-) create mode 100644 test/keyUtilsUnit.spec.ts diff --git a/package-lock.json b/package-lock.json index b5d1191..02c9fbc 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.6", "@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.2", @@ -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", @@ -3865,9 +3868,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3882,9 +3882,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3899,9 +3896,6 @@ "loong64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3916,9 +3910,6 @@ "loong64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3933,9 +3924,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3950,9 +3938,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -3967,9 +3952,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3984,9 +3966,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4001,9 +3980,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4018,9 +3994,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -4541,9 +4514,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": { @@ -4559,12 +4532,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", @@ -4997,21 +4964,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", @@ -5685,16 +5637,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", @@ -5732,17 +5674,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", @@ -5850,12 +5781,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", @@ -6815,18 +6740,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 e6c404a..323144e 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 --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.6", "@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.2", 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..eb706ce 100644 --- a/src/setup/BitPaySetup.ts +++ b/src/setup/BitPaySetup.ts @@ -1,12 +1,12 @@ -import { ec } from 'elliptic'; import * as fs from 'fs'; import * as readline from 'readline'; import * as BitPaySDK from '../index'; +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; 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); + }); + }); +}); From 11f05399599fdeaa54177bfd3adf03037cb95a84 Mon Sep 17 00:00:00 2001 From: aharo Date: Tue, 29 Sep 2026 16:48:48 +0000 Subject: [PATCH 2/2] Save the setup key and config files with owner-only permissions The setup script wrote the private key file with mode 0755 and the config file with the default mode (usually 0644). Both could be read by any local user. The config file holds the API tokens, and with the "plain text" option it also holds the private key. Both files are now written with mode 0600. If the file already exists from an earlier run, its permissions are tightened too, since the mode option of writeFile only applies when a file is created. --- package.json | 2 +- src/setup/BitPaySetup.ts | 15 +++++------- src/setup/SecretFile.ts | 16 +++++++++++++ test/setup/secretFileUnit.spec.ts | 38 +++++++++++++++++++++++++++++++ 4 files changed, 61 insertions(+), 10 deletions(-) create mode 100644 src/setup/SecretFile.ts create mode 100644 test/setup/secretFileUnit.spec.ts diff --git a/package.json b/package.json index 323144e..8032699 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 test/keyUtilsUnit.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 ", diff --git a/src/setup/BitPaySetup.ts b/src/setup/BitPaySetup.ts index eb706ce..cbc68b2 100644 --- a/src/setup/BitPaySetup.ts +++ b/src/setup/BitPaySetup.ts @@ -1,6 +1,7 @@ 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(); @@ -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/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'); + }); +});