Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Use proper authenticated encryption instead of insecure self-baked scheme #61

Open
wants to merge 19 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@ coverage
test
lib
dist/*.js
.DS_Store
5 changes: 2 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,13 +39,12 @@
"dependencies": {
"@babel/runtime": "7.4.4",
"bigi": "1.4.2",
"browserify-aes": "1.0.6",
"bs58": "4.0.1",
"bytebuffer": "5.0.1",
"create-hash": "1.1.3",
"create-hmac": "1.1.6",
"ecurve": "1.0.5",
"randombytes": "2.0.5"
"randombytes": "2.0.5",
"tweetnacl": "1.0.1"
},
"license": "MIT",
"devDependencies": {
Expand Down
169 changes: 78 additions & 91 deletions src/aes.js
Original file line number Diff line number Diff line change
@@ -1,36 +1,36 @@
const randomBytes = require('randombytes')
const ByteBuffer = require('bytebuffer')
const crypto = require('browserify-aes')
const assert = require('assert')
const PublicKey = require('./key_public')
const PrivateKey = require('./key_private')
const hash = require('./hash')

const Long = ByteBuffer.Long;
const nacl = require("tweetnacl/nacl-fast")

module.exports = {
encrypt,
decrypt
decrypt,
decrypt_shared_secret,
encrypt_shared_secret,
}

const nonceLength = 24
/**
Spec: http://localhost:3002/steem/@dantheman/how-to-encrypt-a-memo-when-transferring-steem

@throws {Error|TypeError} - "Invalid Key, ..."

@arg {PrivateKey} private_key - required and used for decryption
@arg {PublicKey} public_key - required and used to calcualte the shared secret
@arg {string} [nonce = uniqueNonce()] - assigned a random unique uint64

@return {object}
@property {string} nonce - random or unique uint64, provides entropy when re-using the same private/public keys.
@property {Buffer} message - Plain text message
@property {number} checksum - shared secret checksum
@property {Buffer} message - Secret
*/
function encrypt(private_key, public_key, message, nonce = uniqueNonce()) {
return crypt(private_key, public_key, nonce, message)
function encrypt(private_key, public_key, message) {
return crypt(private_key, public_key, message, true)
}

function encrypt_shared_secret(shared_secret, message) {
return crypt_shared_secret(shared_secret, message, true)
}
/**
Spec: http://localhost:3002/steem/@dantheman/how-to-encrypt-a-memo-when-transferring-steem

Expand All @@ -44,123 +44,110 @@ function encrypt(private_key, public_key, message, nonce = uniqueNonce()) {

@return {Buffer} - message
*/
function decrypt(private_key, public_key, nonce, message, checksum) {
return crypt(private_key, public_key, nonce, message, checksum).message
function decrypt(private_key, public_key, box) {
return crypt(private_key, public_key, box, false)
}

function decrypt_shared_secret(shared_secret, box) {
return crypt_shared_secret(shared_secret, box, false)
}

/**
@arg {Buffer} message - Encrypted or plain text message (see checksum)
@arg {number} checksum - shared secret checksum (null to encrypt, non-null to decrypt)
@private
*/
function crypt(private_key, public_key, nonce, message, checksum) {
function crypt(private_key, public_key, box, encrypt) {
let nonce, message
private_key = PrivateKey(private_key)
if (!private_key)
throw new TypeError('private_key is required')

public_key = PublicKey(public_key)
if (!public_key)
throw new TypeError('public_key is required')
throw new TypeError('public_key is required')

nonce = toLongObj(nonce)
if (!nonce)
throw new TypeError('nonce is required')
const S = private_key.getSharedSecret(public_key);
return crypt_shared_secret(S, box, encrypt);

}

if (!Buffer.isBuffer(message)) {
if (typeof message !== 'string')
throw new TypeError('message should be buffer or string')
message = new Buffer(message, 'binary')
}
if (checksum && typeof checksum !== 'number')
throw new TypeError('checksum should be a number')
function crypt_shared_secret(S, box, encrypt) {
let nonce, message
if(encrypt) {
nonce = uniqueNonce()
message = box
} else {
({nonce, message} = deserialize(box))
}
if (!Buffer.isBuffer(message)) {
if (typeof message !== 'string')
throw new TypeError('message should be buffer or string')
message = new Buffer(message, 'binary')
}
assert(Buffer.isBuffer(S), "S is not a buffer")
assert(Buffer.isBuffer(nonce), "nonce is not a buffer")

const ekey_length = S.length + nonce.length
let ebuf = Buffer.concat([nonce, S], ekey_length)
const encryption_key = hash.sha512(ebuf)

const iv = encryption_key.slice(32, 56)
const key = encryption_key.slice(0, 32)

if (encrypt) {
message = cryptoJsEncrypt(message, key, iv)
return serialize(nonce, message)
} else {
return cryptoJsDecrypt(message, key, iv)
}
}

const S = private_key.getSharedSecret(public_key);
let ebuf = new ByteBuffer(ByteBuffer.DEFAULT_CAPACITY, ByteBuffer.LITTLE_ENDIAN)
ebuf.writeUint64(nonce)
ebuf.append(S.toString('binary'), 'binary')
ebuf = new Buffer(ebuf.copy(0, ebuf.offset).toBinary(), 'binary')
const encryption_key = hash.sha512(ebuf)

// D E B U G
// console.log('crypt', {
// priv_to_pub: private_key.toPublic().toString(),
// pub: public_key.toString(),
// nonce: nonce.toString(),
// message: message.length,
// checksum,
// S: S.toString('hex'),
// encryption_key: encryption_key.toString('hex'),
// })

const iv = encryption_key.slice(32, 48)
const key = encryption_key.slice(0, 32)

// check is first 64 bit of sha256 hash treated as uint64_t truncated to 32 bits.
let check = hash.sha256(encryption_key)
check = check.slice(0, 4)
const cbuf = ByteBuffer.fromBinary(check.toString('binary'), ByteBuffer.DEFAULT_CAPACITY, ByteBuffer.LITTLE_ENDIAN)
check = cbuf.readUint32()

if (checksum) {
if (check !== checksum)
throw new Error('Invalid key')
message = cryptoJsDecrypt(message, key, iv)
} else {
message = cryptoJsEncrypt(message, key, iv)
}
return {nonce, message, checksum: check}
function serialize(nonce, message) {
const len = nonceLength + message.length
return Buffer.concat([nonce, message], len)
}

/** This method does not use a checksum, the returned data must be validated some other way.
function deserialize(buf) {
const nonce = buf.slice(0, nonceLength)
const message = buf.slice(nonceLength)
return {nonce, message}
}
/** This method both decrypts and checks the authenticity of the messsage.

@arg {string|Buffer} message - ciphertext binary format
@arg {string<utf8>|Buffer} key - 256bit
@arg {string<utf8>|Buffer} iv - 128bit
@arg {string<utf8>|Buffer} iv - 192bit

@return {Buffer}
*/
function cryptoJsDecrypt(message, key, iv) {
assert(message, "Missing cipher text")
message = toBinaryBuffer(message)
const decipher = crypto.createDecipheriv('aes-256-cbc', key, iv)
// decipher.setAutoPadding(true)
message = Buffer.concat([decipher.update(message), decipher.final()])
return message
function cryptoJsDecrypt(box, key, nonce) {
assert(box, "Missing cipher text")
box = toBinaryBuffer(box)
const decrypted = nacl.secretbox.open(box, nonce, key)
if(decrypted === null) {
throw new Error('Secretbox refused to open (wrong key or corrupted or tampered message)')
}
return Buffer.from(decrypted)
}

/** This method does not use a checksum, the returned data must be validated some other way.
/** This method both encrypts and authenticates the message.
@arg {string|Buffer} message - plaintext binary format
@arg {string<utf8>|Buffer} key - 256bit
@arg {string<utf8>|Buffer} iv - 128bit
@arg {string<utf8>|Buffer} iv - 192bit

@return {Buffer}
*/
function cryptoJsEncrypt(message, key, iv) {
function cryptoJsEncrypt(message, key, nonce) {
assert(message, "Missing plain text")
message = toBinaryBuffer(message)
const cipher = crypto.createCipheriv('aes-256-cbc', key, iv)
// cipher.setAutoPadding(true)
message = Buffer.concat([cipher.update(message), cipher.final()])
return message
return Buffer.from(nacl.secretbox(message, nonce, key))
}

/** @return {string} unique 64 bit unsigned number string. Being time based, this is careful to never choose the same nonce twice. This value could be recorded in the blockchain for a long time.
/** @return {string} 192bit random nonce. Long enough to be unique. This value could be recorded in the blockchain for a long time.
*/
function uniqueNonce() {
if(unique_nonce_entropy === null) {
const b = new Uint8Array(randomBytes(2))
unique_nonce_entropy = parseInt(b[0] << 8 | b[1], 10)
}
let long = Long.fromNumber(Date.now())
const entropy = ++unique_nonce_entropy % 0xFFFF
// console.log('uniqueNonce date\t', ByteBuffer.allocate(8).writeUint64(long).toHex(0))
// console.log('uniqueNonce entropy\t', ByteBuffer.allocate(8).writeUint64(Long.fromNumber(entropy)).toHex(0))
long = long.shiftLeft(16).or(Long.fromNumber(entropy));
// console.log('uniqueNonce final\t', ByteBuffer.allocate(8).writeUint64(long).toHex(0))
return long.toString()
return randomBytes(nonceLength)
}
let unique_nonce_entropy = null
// for(let i=1; i < 10; i++) key.uniqueNonce()

const toLongObj = o => (o ? Long.isLong(o) ? o : Long.fromString(o) : o)
const toBinaryBuffer = o => (o ? Buffer.isBuffer(o) ? o : new Buffer(o, 'binary') : o)
44 changes: 44 additions & 0 deletions src/aes.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
/* eslint-env mocha */
const assert = require('assert')
const ecc = require('.')

const alice = {
public_key: 'EOS81xEWcDyZCxACZcYQekiWXLjuSoPMwmRv16nZMuqm2BtQMvXbg',
private_key: '5JxhzyqYERz5MRSswNnDUXL1gFyM2m5Zxde9gGWfMkndbnjB8kD',
}
const bob = {
public_key: 'EOS7jAEWX9d4nZJWNckkaxBsHyqbe6yrVH6VUoCzP6DLxHAEvsBKM',
private_key: '5HrR1D5UbeeMETVR6Ud3Xc6PchVKbtAHmHiPmkmMQDqXY53bQKZ',
}

describe('encrypt/decrypt', () => {
it('Decrypt should recover the original message', async function() {
const message = Buffer.from("My first message")
let box = ecc.Aes.encrypt(alice.private_key, bob.public_key, message)
const decrypted = ecc.Aes.decrypt(bob.private_key, alice.public_key, box)
assert.deepEqual(decrypted, message)
})

/* The following test fails with the normal eosjs-ecc */
it('Tampered message should throw', async function() {
const message = Buffer.from("My first message")
let box = ecc.Aes.encrypt(alice.private_key, bob.public_key, message)

// a little tampering
box = Buffer.concat([box, box])

assert.throws(function() {
ecc.Aes.decrypt(bob.private_key, alice.public_key, box)
})
})

it("encryption with pre-existing shared secret", async function() {
const shared_secret = Buffer.from("1234")
const message = Buffer.from("My first message")
const box = ecc.Aes.encrypt_shared_secret(shared_secret, message)
const decrypted =ecc.Aes.decrypt_shared_secret(shared_secret, box)
assert.deepEqual(decrypted, message)
})

})

13 changes: 0 additions & 13 deletions src/common.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -81,16 +81,3 @@ describe('Common API', () => {
})
})

describe('Common API (initialized)', () => {
it('initialize', () => ecc.initialize())

it('randomKey', () => {
const cpuEntropyBits = 1
ecc.key_utils.addEntropy(1, 2, 3)
const pvt = ecc.unsafeRandomKey().then(pvt => {
assert.equal(typeof pvt, 'string', 'pvt')
assert(/^5[HJK]/.test(wif))
// assert(/^PVT_K1_/.test(pvt))
})
})
})
11 changes: 5 additions & 6 deletions src/key_private.js
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ function PrivateKey(d) {
/**
ECIES
@arg {string|Object} pubkey wif, PublicKey object
@return {Buffer} 64 byte shared secret
@return {Buffer} 32 byte shared secret
*/
function getSharedSecret(public_key) {
public_key = PublicKey(public_key)
Expand All @@ -89,8 +89,7 @@ function PrivateKey(d) {
let r = toBuffer()
let P = KBP.multiply(BigInteger.fromBuffer(r))
let S = P.affineX.toBuffer({size: 32})
// SHA512 used in ECIES
return hash.sha512(S)
return S
}

// /** ECIES TODO unit test
Expand Down Expand Up @@ -268,9 +267,9 @@ function initialize() {
return
}

unitTest()
keyUtils.addEntropy(...keyUtils.cpuEntropy())
assert(keyUtils.entropyCount() >= 128, 'insufficient entropy')
// unitTest()
// keyUtils.addEntropy(...keyUtils.cpuEntropy())
// assert(keyUtils.entropyCount() >= 128, 'insufficient entropy')

initialized = true
}
Expand Down
3 changes: 0 additions & 3 deletions src/key_utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,6 @@ function random32ByteBuffer({cpuEntropyBits = 0, safe = true} = {}) {
assert.equal(typeof cpuEntropyBits, 'number', 'cpuEntropyBits')
assert.equal(typeof safe, 'boolean', 'boolean')

if(safe) {
assert(entropyCount >= 128, 'Call initialize() to add entropy')
}

// if(entropyCount > 0) {
// console.log(`Additional private key entropy: ${entropyCount} events`)
Expand Down
9 changes: 7 additions & 2 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -502,7 +502,7 @@
dependencies:
"@babel/helper-plugin-utils" "^7.0.0"

"@babel/plugin-transform-runtime@^7.4.4":
"@babel/plugin-transform-runtime@7.4.4":
version "7.4.4"
resolved "https://registry.yarnpkg.com/@babel/plugin-transform-runtime/-/plugin-transform-runtime-7.4.4.tgz#a50f5d16e9c3a4ac18a1a9f9803c107c380bce08"
integrity sha512-aMVojEjPszvau3NRg+TIH14ynZLvPewH4xhlCW1w6A3rkxTS1m4uwzRclYR9oS+rl/dr+kT+pzbfHuAWP/lc7Q==
Expand Down Expand Up @@ -612,7 +612,7 @@
js-levenshtein "^1.1.3"
semver "^5.5.0"

"@babel/runtime@^7.4.4":
"@babel/runtime@7.4.4":
version "7.4.4"
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.4.4.tgz#dc2e34982eb236803aa27a07fea6857af1b9171d"
integrity sha512-w0+uT71b6Yi7i5SE0co4NioIpSYS6lLiXvCzWzGSKvpK5vdQtCbICHMj+gbAKAOtxiV6HsVh/MBdaF9EQ6faSg==
Expand Down Expand Up @@ -6295,6 +6295,11 @@ tunnel-agent@^0.6.0:
dependencies:
safe-buffer "^5.0.1"

tweetnacl@1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/tweetnacl/-/tweetnacl-1.0.1.tgz#2594d42da73cd036bd0d2a54683dd35a6b55ca17"
integrity sha512-kcoMoKTPYnoeS50tzoqjPY3Uv9axeuuFAZY9M/9zFnhoVvRfxz9K29IMPD7jGmt2c8SW7i3gT9WqDl2+nV7p4A==

tweetnacl@^0.14.3, tweetnacl@~0.14.0:
version "0.14.5"
resolved "https://registry.yarnpkg.com/tweetnacl/-/tweetnacl-0.14.5.tgz#5ae68177f192d4456269d108afa93ff8743f4f64"
Expand Down