blockstack
Version:
The Blockstack Javascript library for authentication, identity, and storage.
415 lines (380 loc) • 13.5 kB
text/typescript
import { ec as EllipticCurve } from 'elliptic'
import * as BN from 'bn.js'
import { randomBytes } from './cryptoRandom'
import { FailedDecryptionError } from '../errors'
import { getPublicKeyFromPrivate } from '../keys'
import { hashSha256Sync, hashSha512Sync } from './sha2Hash'
import { createHmacSha256 } from './hmacSha256'
import { createCipher } from './aesCipher'
import { getAesCbcOutputLength, getBase64OutputLength } from '../utils'
const ecurve = new EllipticCurve('secp256k1')
/**
* Controls how the encrypted data buffer will be encoded as a string in the JSON payload.
* Options:
* `hex` -- the legacy default, file size increase 100% (2x).
* `base64` -- file size increased ~33%.
* @ignore
*/
export type CipherTextEncoding = 'hex' | 'base64'
/**
* @ignore
*/
export type CipherObject = {
iv: string,
ephemeralPK: string,
cipherText: string,
/** If undefined then hex encoding is used for the `cipherText` string. */
cipherTextEncoding?: CipherTextEncoding,
mac: string,
wasString: boolean
}
/**
* @ignore
*/
export type SignedCipherObject = {
/** Hex encoded DER signature (up to 144 chars) */
signature: string,
/** Hex encoded public key (66 char length) */
publicKey: string,
/** The stringified json of a `CipherObject` */
cipherText: string
}
/**
* @ignore
*/
export async function aes256CbcEncrypt(iv: Buffer,
key: Buffer,
plaintext: Buffer
): Promise<Buffer> {
const cipher = await createCipher()
const result = await cipher.encrypt('aes-256-cbc', key, iv, plaintext)
return result
}
/**
* @ignore
*/
async function aes256CbcDecrypt(iv: Buffer, key: Buffer, ciphertext: Buffer): Promise<Buffer> {
const cipher = await createCipher()
const result = await cipher.decrypt('aes-256-cbc', key, iv, ciphertext)
return result
}
/**
* @ignore
*/
async function hmacSha256(key: Buffer, content: Buffer) {
const hmacSha256 = await createHmacSha256()
return hmacSha256.digest(key, content)
}
/**
* @ignore
*/
function equalConstTime(b1: Buffer, b2: Buffer) {
if (b1.length !== b2.length) {
return false
}
let res = 0
for (let i = 0; i < b1.length; i++) {
res |= b1[i] ^ b2[i] // jshint ignore:line
}
return res === 0
}
/**
* @ignore
*/
function sharedSecretToKeys(sharedSecret: Buffer): { encryptionKey: Buffer; hmacKey: Buffer; } {
// generate mac and encryption key from shared secret
const hashedSecret = hashSha512Sync(sharedSecret)
return {
encryptionKey: hashedSecret.slice(0, 32),
hmacKey: hashedSecret.slice(32)
}
}
/**
* Hex encodes a 32-byte BN.js instance.
* The result string is zero padded and always 64 characters in length.
* @ignore
*/
export function getHexFromBN(bnInput: BN): string {
const hexOut = bnInput.toString('hex', 64)
if (hexOut.length === 64) {
return hexOut
} else if (hexOut.length < 64) {
// pad with leading zeros
// the padStart function would require node 9
const padding = '0'.repeat(64 - hexOut.length)
return `${padding}${hexOut}`
} else {
throw new Error('Generated a > 32-byte BN for encryption. Failing.')
}
}
/**
* Returns a big-endian encoded 32-byte BN.js instance.
* The result Buffer is zero padded and always 32 bytes in length.
* @ignore
*/
export function getBufferFromBN(bnInput: BN): Buffer {
const result = bnInput.toArrayLike(Buffer, 'be', 32)
if (result.byteLength !== 32) {
throw new Error('Generated a 32-byte BN for encryption. Failing.')
}
return result
}
/**
* Get details about the JSON envelope size overhead for ciphertext payloads.
* @ignore
*/
export function getCipherObjectWrapper(opts: {
wasString: boolean,
cipherTextEncoding: CipherTextEncoding,
}): {
/** The stringified JSON string of an empty `CipherObject`. */
payloadShell: string,
/** Total string length of all the `CipherObject` values that always have constant lengths. */
payloadValuesLength: number,
} {
// Placeholder structure of the ciphertext payload, used to determine the
// stringified JSON overhead length.
const shell: CipherObject = {
iv: '',
ephemeralPK: '',
mac: '',
cipherText: '',
wasString: !!opts.wasString,
}
if (opts.cipherTextEncoding === 'base64') {
shell.cipherTextEncoding = 'base64'
}
// Hex encoded 16 byte buffer.
const ivLength = 32
// Hex encoded, compressed EC pubkey of 33 bytes.
const ephemeralPKLength = 66
// Hex encoded 32 byte hmac-sha256.
const macLength = 64
return {
payloadValuesLength: ivLength + ephemeralPKLength + macLength,
payloadShell: JSON.stringify(shell)
}
}
/**
* Get details about the JSON envelope size overhead for signed ciphertext payloads.
* @param payloadShell - The JSON stringified empty `CipherObject`
* @ignore
*/
export function getSignedCipherObjectWrapper(payloadShell: string): {
/** The stringified JSON string of an empty `SignedCipherObject`. */
signedPayloadValuesLength: number;
/** Total string length of all the `SignedCipherObject` values
* that always have constant lengths */
signedPayloadShell: string;
} {
// Placeholder structure of the signed ciphertext payload, used to determine the
// stringified JSON overhead length.
const shell: SignedCipherObject = {
signature: '',
publicKey: '',
cipherText: payloadShell
}
// Hex encoded DER signature, up to 72 byte length.
const signatureLength = 144
// Hex encoded 33 byte public key.
const publicKeyLength = 66
return {
signedPayloadValuesLength: signatureLength + publicKeyLength,
signedPayloadShell: JSON.stringify(shell)
}
}
/**
* Fast function that determines the final ASCII string byte length of the
* JSON stringified ECIES encrypted payload.
* @ignore
*/
export function eciesGetJsonStringLength(opts: {
contentLength: number,
wasString: boolean,
sign: boolean,
cipherTextEncoding: CipherTextEncoding
}): number {
const { payloadShell, payloadValuesLength } = getCipherObjectWrapper(opts)
// Calculate the AES output length given the input length.
const cipherTextLength = getAesCbcOutputLength(opts.contentLength)
// Get the encoded string length of the cipherText.
let encodedCipherTextLength: number
if (!opts.cipherTextEncoding || opts.cipherTextEncoding === 'hex') {
encodedCipherTextLength = (cipherTextLength * 2)
} else if (opts.cipherTextEncoding === 'base64') {
encodedCipherTextLength = getBase64OutputLength(cipherTextLength)
} else {
throw new Error(`Unexpected cipherTextEncoding "${opts.cipherTextEncoding}"`)
}
if (!opts.sign) {
// Add the length of the JSON envelope, ciphertext length, and length of const values.
return payloadShell.length
+ payloadValuesLength
+ encodedCipherTextLength
} else {
// Get the signed version of the JSON envelope
const {
signedPayloadShell,
signedPayloadValuesLength
} = getSignedCipherObjectWrapper(payloadShell)
// Add length of the JSON envelope, ciphertext length, and length of the const values.
return signedPayloadShell.length
+ signedPayloadValuesLength
+ payloadValuesLength
+ encodedCipherTextLength
}
}
/**
* Encrypt content to elliptic curve publicKey using ECIES
* @param publicKey - secp256k1 public key hex string
* @param content - content to encrypt
* @return Object containing:
* iv (initialization vector, hex encoding),
* cipherText (cipher text either hex or base64 encoded),
* mac (message authentication code, hex encoded),
* ephemeral public key (hex encoded),
* wasString (boolean indicating with or not to return a buffer or string on decrypt)
* @private
* @ignore
*/
export async function encryptECIES(publicKey: string,
content: Buffer,
wasString: boolean,
cipherTextEncoding?: CipherTextEncoding):
Promise<CipherObject> {
const ecPK = ecurve.keyFromPublic(publicKey, 'hex').getPublic()
const ephemeralSK = ecurve.genKeyPair()
const ephemeralPK = Buffer.from(ephemeralSK.getPublic().encodeCompressed())
const sharedSecret = ephemeralSK.derive(ecPK) as BN
const sharedSecretBuffer = getBufferFromBN(sharedSecret)
const sharedKeys = sharedSecretToKeys(sharedSecretBuffer)
const initializationVector = randomBytes(16)
const cipherText = await aes256CbcEncrypt(
initializationVector, sharedKeys.encryptionKey, content
)
const macData = Buffer.concat([initializationVector,
ephemeralPK,
cipherText])
const mac = await hmacSha256(sharedKeys.hmacKey, macData)
let cipherTextString: string
if (!cipherTextEncoding || cipherTextEncoding === 'hex') {
cipherTextString = cipherText.toString('hex')
} else if (cipherTextEncoding === 'base64') {
cipherTextString = cipherText.toString('base64')
} else {
throw new Error(`Unexpected cipherTextEncoding "${cipherTextEncoding}"`)
}
const result: CipherObject = {
iv: initializationVector.toString('hex'),
ephemeralPK: ephemeralPK.toString('hex'),
cipherText: cipherTextString,
mac: mac.toString('hex'),
wasString: !!wasString
}
if (cipherTextEncoding && cipherTextEncoding !== 'hex') {
result.cipherTextEncoding = cipherTextEncoding
}
return result
}
/**
* Decrypt content encrypted using ECIES
* @param {String} privateKey - secp256k1 private key hex string
* @param {Object} cipherObject - object to decrypt, should contain:
* iv (initialization vector), cipherText (cipher text),
* mac (message authentication code), ephemeralPublicKey
* wasString (boolean indicating with or not to return a buffer or string on decrypt)
* @return {Buffer} plaintext
* @throws {FailedDecryptionError} if unable to decrypt
* @private
* @ignore
*/
export async function decryptECIES(privateKey: string, cipherObject: CipherObject):
Promise<Buffer | string> {
const ecSK = ecurve.keyFromPrivate(privateKey, 'hex')
let ephemeralPK = null
try {
ephemeralPK = ecurve.keyFromPublic(cipherObject.ephemeralPK, 'hex').getPublic()
} catch (error) {
throw new FailedDecryptionError('Unable to get public key from cipher object. '
+ 'You might be trying to decrypt an unencrypted object.')
}
const sharedSecret = ecSK.derive(ephemeralPK) as BN
const sharedSecretBuffer = getBufferFromBN(sharedSecret)
const sharedKeys = sharedSecretToKeys(sharedSecretBuffer)
const ivBuffer = Buffer.from(cipherObject.iv, 'hex')
let cipherTextBuffer: Buffer
if (!cipherObject.cipherTextEncoding || cipherObject.cipherTextEncoding === 'hex') {
cipherTextBuffer = Buffer.from(cipherObject.cipherText, 'hex')
} else if (cipherObject.cipherTextEncoding === 'base64') {
cipherTextBuffer = Buffer.from(cipherObject.cipherText, 'base64')
} else {
throw new Error(`Unexpected cipherTextEncoding "${cipherObject.cipherText}"`)
}
const macData = Buffer.concat([ivBuffer,
Buffer.from(ephemeralPK.encodeCompressed()),
cipherTextBuffer])
const actualMac = await hmacSha256(sharedKeys.hmacKey, macData)
const expectedMac = Buffer.from(cipherObject.mac, 'hex')
if (!equalConstTime(expectedMac, actualMac)) {
throw new FailedDecryptionError('Decryption failed: failure in MAC check')
}
const plainText = await aes256CbcDecrypt(
ivBuffer, sharedKeys.encryptionKey, cipherTextBuffer
)
if (cipherObject.wasString) {
return plainText.toString()
} else {
return plainText
}
}
/**
* Sign content using ECDSA
*
* @param {String} privateKey - secp256k1 private key hex string
* @param {Object} content - content to sign
* @return {Object} contains:
* signature - Hex encoded DER signature
* public key - Hex encoded private string taken from privateKey
* @private
* @ignore
*/
export function signECDSA(privateKey: string, content: string | Buffer): {
publicKey: string, signature: string
} {
const contentBuffer = content instanceof Buffer ? content : Buffer.from(content)
const ecPrivate = ecurve.keyFromPrivate(privateKey, 'hex')
const publicKey = getPublicKeyFromPrivate(privateKey)
const contentHash = hashSha256Sync(contentBuffer)
const signature = ecPrivate.sign(contentHash)
const signatureString: string = signature.toDER('hex')
return {
signature: signatureString,
publicKey
}
}
/**
* @ignore
*/
function getBuffer(content: string | ArrayBuffer | Buffer) {
if (content instanceof Buffer) return content
else if (content instanceof ArrayBuffer) return Buffer.from(content)
else return Buffer.from(content)
}
/**
* Verify content using ECDSA
* @param {String | Buffer} content - Content to verify was signed
* @param {String} publicKey - secp256k1 private key hex string
* @param {String} signature - Hex encoded DER signature
* @return {Boolean} returns true when signature matches publickey + content, false if not
* @private
* @ignore
*/
export function verifyECDSA(
content: string | ArrayBuffer | Buffer,
publicKey: string,
signature: string): boolean {
const contentBuffer = getBuffer(content)
const ecPublic = ecurve.keyFromPublic(publicKey, 'hex')
const contentHash = hashSha256Sync(contentBuffer)
return ecPublic.verify(contentHash, <any>signature)
}