From cf0a3ab9b04876c942d241e554e96db17e512b5b Mon Sep 17 00:00:00 2001 From: janniks Date: Tue, 22 Mar 2022 19:14:22 +0100 Subject: [PATCH] fix: private key compression --- packages/encryption/src/keys.ts | 61 ++++++++++---------------- packages/encryption/tests/keys.test.ts | 54 ++++++++++++++++++----- packages/storage/src/hub.ts | 9 ++-- packages/storage/tests/storage.test.ts | 15 +++++-- packages/transactions/src/keys.ts | 41 ++++++----------- 5 files changed, 98 insertions(+), 82 deletions(-) diff --git a/packages/encryption/src/keys.ts b/packages/encryption/src/keys.ts index 4e660fecb..721cc3763 100644 --- a/packages/encryption/src/keys.ts +++ b/packages/encryption/src/keys.ts @@ -1,7 +1,7 @@ import { hmac } from '@noble/hashes/hmac'; import { sha256 } from '@noble/hashes/sha256'; import { getPublicKey, signSync, utils } from '@noble/secp256k1'; -import { Buffer } from '@stacks/common'; +import { Buffer, privateKeyToBuffer, PRIVATE_KEY_COMPRESSED_LENGTH } from '@stacks/common'; import base58 from 'bs58'; import { hashRipemd160 } from './hashRipemd160'; import { hashSha256Sync } from './sha2Hash'; @@ -56,61 +56,48 @@ export function publicKeyToAddress(publicKey: string | Buffer) { * @ignore */ export function getPublicKeyFromPrivate(privateKey: string | Buffer) { - return Buffer.from(getPublicKey(privateKeyToBuffer(privateKey), true)).toString('hex'); -} - -/** - * Time - * @private - * @ignore - */ -function privateKeyToBuffer(privateKey: string | Buffer): Buffer { - const privateKeyBuffer = Buffer.isBuffer(privateKey) - ? privateKey - : Buffer.from(privateKey, 'hex'); - - switch (privateKeyBuffer.length) { - case 32: - return privateKeyBuffer; - case 33: - if (privateKeyBuffer[32] !== 1) { - throw new Error( - 'Improperly formatted compressed private-key. 66-length hex indicates compressed key, but the last byte must be == 1' - ); - } - return privateKeyBuffer.slice(0, 32); - default: - throw new Error( - 'Improperly formatted compressed private-key. Private-key hex length should be 64 or 66.' - ); - } + const privateKeyBuffer = privateKeyToBuffer(privateKey); + const shouldCompressPublicKey = privateKeyBuffer.length == PRIVATE_KEY_COMPRESSED_LENGTH; + return Buffer.from(getPublicKey(privateKeyBuffer.slice(0, 32), shouldCompressPublicKey)).toString( + 'hex' + ); } /** - * * @ignore */ export function ecSign(messageHash: Buffer, hexPrivateKey: string | Buffer) { return Buffer.from( - signSync(messageHash, privateKeyToBuffer(hexPrivateKey), { + signSync(messageHash, privateKeyToBuffer(hexPrivateKey).slice(0, 32), { der: false, }) ); } /** - * * @ignore */ -export function ecPrivateKeyToHexString(privateKey: Buffer) { - // add 01 suffix for backward compatibility - return `${privateKey.toString('hex')}01`; +export function ecPrivateKeyToHexString(privateKey: Buffer): string { + return privateKey.toString('hex'); } /** - * * @ignore */ -export function isValidPrivateKey(privateKey: string | Buffer) { +export function isValidPrivateKey(privateKey: string | Buffer): boolean { return utils.isValidPrivateKey(privateKeyToBuffer(privateKey)); } + +/** + * @ignore + */ +export function compressPrivateKey(privateKey: string | Buffer): string { + const privateKeyBuffer = privateKeyToBuffer(privateKey); + + const compressedPrivateKeyBuffer = + privateKeyBuffer.length == PRIVATE_KEY_COMPRESSED_LENGTH + ? privateKeyBuffer + : Buffer.concat([privateKeyBuffer, Buffer.from([1])]); + + return ecPrivateKeyToHexString(compressedPrivateKeyBuffer); +} diff --git a/packages/encryption/tests/keys.test.ts b/packages/encryption/tests/keys.test.ts index 76ea29100..e5dc1a4a2 100644 --- a/packages/encryption/tests/keys.test.ts +++ b/packages/encryption/tests/keys.test.ts @@ -1,5 +1,5 @@ import { utils } from '@noble/secp256k1'; -import { Buffer } from '@stacks/common'; +import { Buffer, PRIVATE_KEY_UNCOMPRESSED_LENGTH } from '@stacks/common'; import { address, ECPair, networks } from 'bitcoinjs-lib'; import bs58check from 'bs58check'; import { SECP256K1Client } from 'jsontokens'; @@ -10,6 +10,7 @@ import { publicKeyToAddress, ecSign, base58Encode, + compressPrivateKey, } from '../src'; import { hashRipemd160 } from '../src/hashRipemd160'; @@ -18,30 +19,41 @@ test('makeECPrivateKey', () => { expect(privateKey).toBeTruthy(); expect(typeof privateKey).toEqual('string'); + expect(privateKey.length).toEqual(PRIVATE_KEY_UNCOMPRESSED_LENGTH * 2); expect(utils.isValidPrivateKey(privateKey)).toBeTruthy(); }); test('getPublicKeyFromPrivate matches jsontokens', () => { const privateKey = makeECPrivateKey(); const publicKey = getPublicKeyFromPrivate(privateKey); - const secpClientPublicKey = SECP256K1Client.derivePublicKey(privateKey); + const secpClientPublicKey = SECP256K1Client.derivePublicKey(privateKey, false); expect(publicKeyToAddress(publicKey)).toEqual(publicKeyToAddress(secpClientPublicKey)); }); test('getPublicKeyFromPrivate matches bitcoinjs', () => { const privateKey = makeECPrivateKey(); - const keyPair = ECPair.fromPrivateKey(Buffer.from(privateKey, 'hex')); - const bitcoinJsPublicKey = keyPair.publicKey.toString('hex'); - - expect(getPublicKeyFromPrivate(privateKey)).toEqual(bitcoinJsPublicKey); + const privateKeyCompressed = `${privateKey}01`; + + const keyPairUncompressed = ECPair.fromPrivateKey(Buffer.from(privateKey, 'hex'), { + compressed: false, + }); + const bitcoinJsPublicKeyUncompressed = keyPairUncompressed.publicKey.toString('hex'); + expect(getPublicKeyFromPrivate(privateKey)).toEqual(bitcoinJsPublicKeyUncompressed); + + const keyPairCompressed = ECPair.fromPrivateKey(Buffer.from(privateKey, 'hex'), { + compressed: true, + }); + const bitcoinJsPublicKeyCompressed = keyPairCompressed.publicKey.toString('hex'); + expect(getPublicKeyFromPrivate(privateKeyCompressed)).toEqual(bitcoinJsPublicKeyCompressed); }); -test('getPublicKeyFromPrivate with bitcoinjs privatekey matches bitcoinjs', () => { +test('getPublicKeyFromPrivate with bitcoinjs private key matches bitcoinjs', () => { const privateKey = ECPair.makeRandom().privateKey!.toString('hex'); - const bitcoinJsKeyPair = ECPair.fromPrivateKey(Buffer.from(privateKey, 'hex')); + const bitcoinJsKeyPair = ECPair.fromPrivateKey(Buffer.from(privateKey, 'hex'), { + compressed: false, + }); const bitcoinJsPublicKey = bitcoinJsKeyPair.publicKey.toString('hex'); - expect(getPublicKeyFromPrivate(privateKey)).toEqual(bitcoinJsPublicKey); }); @@ -58,8 +70,13 @@ test('publicKeyToAddress matches bitcoinjs', () => { test('publicKeyToAddress', () => { const privateKey = '00cdce6b5f87d38f2a830cae0da82162e1b487f07c5affa8130f01fe1a2a25fb01'; const expectedAddress = '1WykMawQRnLh7SWmmoRL4qTDNCgAsVRF1'; - expect(publicKeyToAddress(getPublicKeyFromPrivate(privateKey))).toEqual(expectedAddress); + + const privateKeyUncompressed = '00cdce6b5f87d38f2a830cae0da82162e1b487f07c5affa8130f01fe1a2a25fb'; + const expectedAddressUncompressed = '1irPRMBJmuTd9FZQ5Cfdm4rcPvvez19uV'; + expect(publicKeyToAddress(getPublicKeyFromPrivate(privateKeyUncompressed))).toEqual( + expectedAddressUncompressed + ); }); test('hashToBase58Check', () => { @@ -82,3 +99,20 @@ test('ecSign', () => { expect(signature.toString('hex')).toEqual(signatureHex); }); + +describe(compressPrivateKey, () => { + it('does not change already compressed key', () => { + const privateKeyCompressed = + '00cdce6b5f87d38f2a830cae0da82162e1b487f07c5affa8130f01fe1a2a25fb01'; + + expect(compressPrivateKey(privateKeyCompressed)).toEqual(privateKeyCompressed); + }); + + it('compresses uncompressed key', () => { + const privateKey = '00cdce6b5f87d38f2a830cae0da82162e1b487f07c5affa8130f01fe1a2a25fb'; + const privateKeyCompressed = + '00cdce6b5f87d38f2a830cae0da82162e1b487f07c5affa8130f01fe1a2a25fb01'; + + expect(compressPrivateKey(privateKey)).toEqual(privateKeyCompressed); + }); +}); diff --git a/packages/storage/src/hub.ts b/packages/storage/src/hub.ts index 2e5206430..245f8b9dc 100644 --- a/packages/storage/src/hub.ts +++ b/packages/storage/src/hub.ts @@ -13,6 +13,7 @@ import { ValidationError, } from '@stacks/common'; import { + compressPrivateKey, ecSign, getPublicKeyFromPrivate, hashSha256Sync, @@ -149,7 +150,7 @@ function makeLegacyAuthToken(challengeText: string, signerKeyHex: string): strin } if (parsedChallenge[0] === 'gaiahub' && parsedChallenge[3] === 'blockstack_storage_please_sign') { const digest = hashSha256Sync(Buffer.from(challengeText)); - const signatureBuffer = ecSign(digest, signerKeyHex); + const signatureBuffer = ecSign(digest, compressPrivateKey(signerKeyHex)); const signatureWithHash = script.signature.encode(signatureBuffer, Transaction.SIGHASH_NONE); // We only want the DER encoding so remove the sighash version byte at the end. @@ -218,7 +219,9 @@ export async function connectToGaiaHub( const hubInfo = await response.json(); const readURL = hubInfo.read_url_prefix; const token = makeV1GaiaAuthToken(hubInfo, challengeSignerHex, gaiaHubUrl, associationToken); - const address = publicKeyToAddress(getPublicKeyFromPrivate(challengeSignerHex)); + const address = publicKeyToAddress( + getPublicKeyFromPrivate(compressPrivateKey(challengeSignerHex)) + ); return { url_prefix: readURL, max_file_upload_size_megabytes: hubInfo.max_file_upload_size_megabytes, @@ -240,7 +243,7 @@ export async function getBucketUrl(gaiaHubUrl: string, appPrivateKey: string): P const responseText = await response.text(); const responseJSON = JSON.parse(responseText); const readURL = responseJSON.read_url_prefix; - const address = publicKeyToAddress(getPublicKeyFromPrivate(appPrivateKey)); + const address = publicKeyToAddress(getPublicKeyFromPrivate(compressPrivateKey(appPrivateKey))); const bucketUrl = `${readURL}${address}/`; return bucketUrl; } diff --git a/packages/storage/tests/storage.test.ts b/packages/storage/tests/storage.test.ts index b9682a812..5d5e93fa5 100644 --- a/packages/storage/tests/storage.test.ts +++ b/packages/storage/tests/storage.test.ts @@ -13,7 +13,13 @@ import { import { Storage } from '../src'; import { UserSession, AppConfig, UserData, LOCALSTORAGE_SESSION_KEY } from '@stacks/auth'; -import { DoesNotExist, getAesCbcOutputLength, getBase64OutputLength, fetchPrivate } from '@stacks/common'; +import { + DoesNotExist, + getAesCbcOutputLength, + getBase64OutputLength, + fetchPrivate, + Buffer, +} from '@stacks/common'; import { StacksMainnet } from '@stacks/network'; import * as util from 'util'; import * as jsdom from 'jsdom'; @@ -254,14 +260,15 @@ test('Concurrent calls to deleteFile should delete etags in localStorage', async const appConfig = new AppConfig(); const userSession = new UserSession({ appConfig }); const session = userSession.store.getSessionData(); - session.userData = { + session.userData = { gaiaHubConfig, appPrivateKey: privateKey, }; userSession.store.setSessionData(session); const files = ['a.json', 'b.json', 'c.json', 'd.json']; - const fullReadUrl = 'https://gaia.testblockstack.org/hub/1NZNxhoxobqwsNvTb16pdeiqvFvce3Yg8U/file.json'; + const fullReadUrl = + 'https://gaia.testblockstack.org/hub/1NZNxhoxobqwsNvTb16pdeiqvFvce3Yg8U/file.json'; const uploadToGaiaHub = jest.fn().mockResolvedValue({ publicURL: fullReadUrl, etag: 'test-tag', @@ -297,7 +304,7 @@ test('Concurrent calls to deleteFile should delete etags in localStorage', async } await Promise.all(promises); const sessionData = userSession.store.getSessionData(); - const sessionFromLocalStore = JSON.parse(localStorage.getItem(LOCALSTORAGE_SESSION_KEY) || '{}' ); + const sessionFromLocalStore = JSON.parse(localStorage.getItem(LOCALSTORAGE_SESSION_KEY) || '{}'); const expectedEtags = {}; expect(sessionData.etags).toEqual(expectedEtags); diff --git a/packages/transactions/src/keys.ts b/packages/transactions/src/keys.ts index bd3373a51..7ba68fefb 100644 --- a/packages/transactions/src/keys.ts +++ b/packages/transactions/src/keys.ts @@ -1,4 +1,9 @@ -import { Buffer, hexToBigInt } from '@stacks/common'; +import { + Buffer, + hexToBigInt, + privateKeyToBuffer, + PRIVATE_KEY_COMPRESSED_LENGTH, +} from '@stacks/common'; import { AddressHashMode, AddressVersion, @@ -115,32 +120,10 @@ export function serializePublicKey(key: StacksPublicKey): Buffer { return bufferArray.concatBuffer(); } -export function isPrivateKeyCompressed(key: string | Buffer) { - const data = typeof key === 'string' ? Buffer.from(key, 'hex') : key; - let compressed = false; - if (data.length === 33) { - if (data[data.length - 1] !== 1) { - throw new Error( - 'Improperly formatted private-key. 33 byte length usually ' + - 'indicates compressed key, but last byte must be == 0x01' - ); - } - compressed = true; - } else if (data.length === 32) { - compressed = false; - } else { - throw new Error( - `Improperly formatted private-key hex string: length should be 32 or 33 bytes, provided with length ${data.length}` - ); - } - return compressed; -} - export function pubKeyfromPrivKey(privateKey: string | Buffer): StacksPublicKey { const privKey = createStacksPrivateKey(privateKey); - const isCompressed = isPrivateKeyCompressed(privateKey); - const pubKey = nobleGetPublicKey(privKey.data.slice(0, 32), isCompressed || privKey.compressed); - return createStacksPublicKey(utils.bytesToHex(pubKey)); + const publicKey = nobleGetPublicKey(privKey.data.slice(0, 32), privKey.compressed); + return createStacksPublicKey(utils.bytesToHex(publicKey)); } export function compressPublicKey(publicKey: string | Buffer): StacksPublicKey { @@ -159,13 +142,15 @@ export function deserializePublicKey(bufferReader: BufferReader): StacksPublicKe } export interface StacksPrivateKey { - data: Buffer; + // "compressed" private key is a misnomer: https://web.archive.org/web/20220131144208/https://www.oreilly.com/library/view/mastering-bitcoin/9781491902639/ch04.html#comp_priv + // it actually means: should public keys be generated as "compressed" or "uncompressed" from this private key compressed: boolean; + data: Buffer; } export function createStacksPrivateKey(key: string | Buffer): StacksPrivateKey { - const data = typeof key === 'string' ? Buffer.from(key, 'hex') : key; - const compressed: boolean = isPrivateKeyCompressed(key); + const data = privateKeyToBuffer(key); + const compressed = data.length == PRIVATE_KEY_COMPRESSED_LENGTH; return { data, compressed }; }