Skip to content

Commit

Permalink
fix: private key compression
Browse files Browse the repository at this point in the history
  • Loading branch information
janniks committed Apr 13, 2022
1 parent 1c342ec commit cf0a3ab
Show file tree
Hide file tree
Showing 5 changed files with 98 additions and 82 deletions.
61 changes: 24 additions & 37 deletions packages/encryption/src/keys.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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);
}
54 changes: 44 additions & 10 deletions packages/encryption/tests/keys.test.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -10,6 +10,7 @@ import {
publicKeyToAddress,
ecSign,
base58Encode,
compressPrivateKey,
} from '../src';
import { hashRipemd160 } from '../src/hashRipemd160';

Expand All @@ -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);
});

Expand All @@ -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', () => {
Expand All @@ -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);
});
});
9 changes: 6 additions & 3 deletions packages/storage/src/hub.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
ValidationError,
} from '@stacks/common';
import {
compressPrivateKey,
ecSign,
getPublicKeyFromPrivate,
hashSha256Sync,
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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,
Expand All @@ -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;
}
Expand Down
15 changes: 11 additions & 4 deletions packages/storage/tests/storage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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 = <any> {
session.userData = <any>{
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',
Expand Down Expand Up @@ -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);
Expand Down
41 changes: 13 additions & 28 deletions packages/transactions/src/keys.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import { Buffer, hexToBigInt } from '@stacks/common';
import {
Buffer,
hexToBigInt,
privateKeyToBuffer,
PRIVATE_KEY_COMPRESSED_LENGTH,
} from '@stacks/common';
import {
AddressHashMode,
AddressVersion,
Expand Down Expand Up @@ -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 {
Expand All @@ -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 };
}

Expand Down

0 comments on commit cf0a3ab

Please sign in to comment.