Skip to content

Commit

Permalink
fix: ecies module works
Browse files Browse the repository at this point in the history
  • Loading branch information
nicolasbrugneaux committed Jan 17, 2024
1 parent 335300c commit 4de13d3
Show file tree
Hide file tree
Showing 2 changed files with 67 additions and 157 deletions.
68 changes: 22 additions & 46 deletions packages/sdk/utils/src/ecies.test.ts
Original file line number Diff line number Diff line change
@@ -1,78 +1,54 @@
import { privateToPublic } from '@ethereumjs/util'
import { ctr } from '@noble/ciphers/aes'
import { bytesToUtf8, utf8ToBytes } from '@noble/ciphers/utils'
import { randomBytes as randooom } from '@noble/ciphers/webcrypto/utils'
import { randomBytes } from 'crypto'
import { randomBytes } from '@noble/ciphers/webcrypto/utils'
import { secp256k1 } from '@noble/curves/secp256k1'
import { ECIES } from './ecies'

describe('ECIES', () => {
describe('encrypt', () => {
it('should encrypt a message without error', () => {
const privKey = randomBytes(32)
const pubKey = privateToPublic(privKey)
const pubKey = secp256k1.getPublicKey(privKey, false).slice(1)
const message = Buffer.from('foo')
const encrypted = ECIES.Encrypt(pubKey, message)
expect(encrypted.length).toBeGreaterThanOrEqual(113)
})

expect(() => ECIES.Encrypt(pubKey, message)).not.toThrow()
})
it('should throw an error if priv key is given', () => {
const privKey = randomBytes(32)
const message = Buffer.from('foo')
try {
ECIES.Encrypt(privKey, message)
expect(false).toBe(true)
} catch (error) {
// ok, encryption should not work when a priv key is given
}

expect(() => ECIES.Encrypt(privKey, message)).toThrow()
})
})

describe('roundtrip', () => {
it('should return the same plaintext after roundtrip - core', () => {
// const plaintext = utf8ToBytes('spam')
const privKey = randooom(32)
// const pubKey = privateToPublic(privKey)
// const iv = randomBytes(16)
// const encrypted = AES128Encrypt(privKey, iv, plaintext)
// const decrypted = AES128Decrypt(privKey, iv, encrypted)

const plaintext = 'Hello, World'
const aes = ctr(privKey, randomBytes(16))
const ciphertext_ = aes.encrypt(utf8ToBytes(plaintext))
const plaintext_ = aes.decrypt(ciphertext_)
expect(bytesToUtf8(plaintext_)).toEqual(plaintext)
})

it('should return the same plaintext after roundtrip', () => {
const plaintext = Buffer.from('spam')
const plaintext = 'spam'
const privKey = randomBytes(32)
const pubKey = privateToPublic(privKey)
const encrypted = ECIES.Encrypt(pubKey, plaintext)
const decrypted = ECIES.Decrypt(privKey, encrypted)
expect(decrypted.toString()).toEqual(plaintext.toString())
const pubKey = secp256k1.getPublicKey(privKey, false).slice(1)
const encrypted = ECIES.Encrypt(pubKey, utf8ToBytes(plaintext))
const decrypted = ECIES.Decrypt(Buffer.from(privKey), encrypted)

expect(bytesToUtf8(decrypted)).toEqual(plaintext)
})

it('should only decrypt if correct priv key is given', () => {
const plaintext = Buffer.from('spam')
const privKey = randomBytes(32)
const pubKey = privateToPublic(privKey)
const pubKey = secp256k1.getPublicKey(privKey, false).slice(1)
const fakePrivKey = randomBytes(32)
try {
ECIES.Encrypt(pubKey, plaintext)
ECIES.Decrypt(fakePrivKey, plaintext)
expect(false).toBe(true)
} catch (error) {
// ok, decryption should not work for incorrect priv key
}
const encrypted = ECIES.Encrypt(pubKey, plaintext)

expect(() => ECIES.Decrypt(fakePrivKey, encrypted)).toThrow()
})

it('should be able to encrypt and decrypt a longer message (1024 bytes)', () => {
const plaintext = randomBytes(1024)
const privKey = randomBytes(32)
const pubKey = privateToPublic(privKey)
const pubKey = secp256k1.getPublicKey(privKey, false).slice(1)
const encrypted = ECIES.Encrypt(pubKey, plaintext)
const decrypted = ECIES.Decrypt(privKey, encrypted)
expect(decrypted.toString()).toEqual(plaintext.toString())

expect(decrypted).toEqual(plaintext)
})
})
})
Expand All @@ -95,7 +71,7 @@ describe('AES128CTR', () => {
const macKey = randomBytes(16)
const encrypted = ECIES.AES128EncryptAndHMAC(encKey, macKey, plaintext)
const decrypted = ECIES.AES128DecryptAndHMAC(encKey, macKey, encrypted)
expect(decrypted.toString()).toEqual(plaintext.toString())
expect(bytesToUtf8(decrypted)).toEqual(plaintext.toString())
})

it('should only decrypt if correct priv key is given', () => {
Expand All @@ -115,7 +91,7 @@ describe('AES128CTR', () => {
const macKey = randomBytes(16)
const encrypted = ECIES.AES128EncryptAndHMAC(encKey, macKey, plaintext)
const decrypted = ECIES.AES128DecryptAndHMAC(encKey, macKey, encrypted)
expect(decrypted.toString()).toEqual(plaintext.toString())
expect(decrypted).toEqual(plaintext)
})
})

Expand Down
156 changes: 45 additions & 111 deletions packages/sdk/utils/src/ecies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,109 +7,61 @@
'use strict'

import { ctr } from '@noble/ciphers/aes'
import { u8 } from '@noble/ciphers/utils'
import { PrivKey } from '@noble/curves/abstract/utils'
import { PubKey } from '@noble/curves/abstract/weierstrass'
import { secp256k1 } from '@noble/curves/secp256k1'
import { hkdf } from '@noble/hashes/hkdf'
import { hmac } from '@noble/hashes/hmac'
import { sha256 } from '@noble/hashes/sha256'
import { randomBytes } from '@noble/hashes/utils'

export const IV_LENGTH = 16

const Uint8ArrayEquals = (a: Uint8Array, b: Uint8Array) => {
if (a.length != b.length) return false
for (let i = 0; i < a.length; i++) {
if (a[i] != b[i]) return false
}
return true
}

/**
* Increments big endian uint32
*
* @param {Buffer} ctr 32 bit unsigned big endian integer to increment.
* @returns Incremented counter.
*/
const IncCounter = (ctr: Buffer) => {
for (let i = ctr.length - 1; i >= 0; i--) {
ctr[i]++
if (ctr[i] !== 0) {
return ctr
}
}
return ctr
}

/**
* NIST 8000-56C Rev 1 One Step KDF with the following parameters:
* - H(x) is SHA-256(x)
* - Fixed info is null
*
* TODO:
* - Implement proper ceiling on reps.
*
* @param {Buffer} px Input keying material to derive key from.
* @param {number} kdLen Length of output in bytes
* @returns {Buffer} Output keying material of length kdLen bytes.
*/
const ConcatKDF = (px: Buffer, kdLen: number) => {
const blockSize = 32
const reps = ((kdLen + 7) * 8) / (blockSize * 8)
let counter = Buffer.from('00000001', 'hex')
let k = Buffer.from('00', 'hex')
for (let i = 0; i <= reps; i++) {
const hash = sha256.create()
hash.update(counter)
hash.update(px)
k = Buffer.concat([k, hash.digest()])
counter = IncCounter(counter)
}
return k.slice(1, kdLen + 1)
}

/**
* AES-128 CTR encrypt
* @param {Buffer} encryptionKey
* @param {Buffer} iv
* @param {Uint8Array} encryptionKey
* @param {Uint8Array} iv
* @param {Uint8Array} plaintext
* @returns {Uint8Array} ciphertext
*/
export function AES128Encrypt(
encryptionKey: Buffer,
encryptionKey: Uint8Array,
iv: Uint8Array,
plaintext: Uint8Array
): Uint8Array {
const aes = ctr(encryptionKey, iv)
return aes.encrypt(plaintext)
const message = aes.encrypt(plaintext)
return u8(Buffer.concat([iv, message]))
}

/**
* AES-128 CTR encrypt with message authentication
* @param {Buffer} encryptionKey
* @param {Buffer} macKey
* @param {Uint8Array} encryptionKey
* @param {Uint8Array} macKey
* @param {Uint8Array} plaintext
* @returns {Buffer} ciphertext
* @returns {Uint8Array} ciphertext
*/
export function AES128EncryptAndHMAC(
encryptionKey: Buffer,
encryptionKey: Uint8Array,
macKey: Uint8Array,
plaintext: Uint8Array
): Buffer {
): Uint8Array {
const iv = randomBytes(IV_LENGTH)
console.log('RANDOM IV:', iv)
const dataToMac = AES128Encrypt(encryptionKey, iv, plaintext)
const mac = hmac(sha256, macKey, dataToMac)

return Buffer.concat([dataToMac, mac])
return u8(Buffer.concat([dataToMac, mac]))
}

/**
* AES-128 CTR decrypt
* @param {Buffer} encryptionKey
* @param {Uint8Array} encryptionKey
* @param {Uint8Array} iv
* @param {Uint8Array} ciphertext
* @returns {Uint8Array} plaintext
*/
export function AES128Decrypt(
encryptionKey: Buffer,
encryptionKey: Uint8Array,
iv: Uint8Array,
ciphertext: Uint8Array
): Uint8Array {
Expand All @@ -119,94 +71,76 @@ export function AES128Decrypt(

/**
* AES-128 CTR decrypt with message authentication
* @param {Buffer} encryptionKey
* @param {Buffer} macKey
* @param {Uint8Array} encryptionKey
* @param {Uint8Array} macKey
* @param {Uint8Array} ciphertext
* @returns {Uint8Array} plaintext
*/
export function AES128DecryptAndHMAC(
encryptionKey: Buffer,
encryptionKey: Uint8Array,
macKey: Uint8Array,
ciphertext: Uint8Array
): Uint8Array {
const iv = ciphertext.subarray(0, IV_LENGTH)
console.log('SLICED FROM CIPHER IV:', iv)
const message = ciphertext.slice(IV_LENGTH, ciphertext.length - 32)
const mac = ciphertext.slice(ciphertext.length - 32, ciphertext.length)
const iv = ciphertext.slice(0, IV_LENGTH)
const message = ciphertext.slice(IV_LENGTH, ciphertext.length - sha256.outputLen)
const mac = ciphertext.slice(ciphertext.length - sha256.outputLen, ciphertext.length)
const dataToMac = Buffer.concat([iv, message])
const computedMac = hmac(sha256, macKey, dataToMac)
if (!Uint8ArrayEquals(mac, computedMac)) {
if (!Buffer.from(mac).equals(Buffer.from(computedMac))) {
throw new Error('MAC mismatch')
}

return Buffer.from(AES128Decrypt(encryptionKey, iv, message))
return AES128Decrypt(encryptionKey, iv, message)
}

const COMPRESSED_KEY_LENGTH = secp256k1.CURVE.Fp.BYTES + 1 // e.g. 33 for 32
/**
* ECIES encrypt
* @param {Buffer} pubKeyTo Ethereum pub key, 64 bytes.
* @param {Uint8Array} plaintext Plaintext to be encrypted.
* @returns {Buffer} Encrypted message, serialized, 113+ bytes
*/
export function Encrypt(pubKeyTo: Buffer, plaintext: Uint8Array) {
export function Encrypt(pubKeyTo: PubKey, plaintext: Uint8Array) {
const ephemPrivKey = secp256k1.utils.randomPrivateKey()
const ephemPubKey = Buffer.from(secp256k1.getPublicKey(ephemPrivKey, false))
const ephemPubKey = Buffer.from(secp256k1.getPublicKey(ephemPrivKey))
const ephemPubKeyEncoded = Buffer.from(ephemPubKey)
const pubKeyToEncoded = Buffer.concat([Buffer.from([0x04]), pubKeyTo])
const px = secp256k1.getSharedSecret(ephemPrivKey, pubKeyToEncoded)

// TODO: remove after I find a way to test this better
// const EC = require('elliptic').ec
// const ec = new EC('secp256k1')
// const _ephemPrivKey = ec.keyFromPrivate(ephemPrivKey)
// const _ephemPubKey = _ephemPrivKey.getPublic(false, 'hex')
// const _ephemPubKeyEncoded = Buffer.from(_ephemPubKey, 'hex')
// const _px = _ephemPrivKey.derive(ec.keyFromPublic(pubKeyToEncoded).getPublic())
if (typeof pubKeyTo === 'string') {
pubKeyTo = secp256k1.ProjectivePoint.fromHex(pubKeyTo).toRawBytes()
}

// console.log({
// ephemPubKeyEncoded,
// _ephemPubKeyEncoded,
// eq: ephemPubKeyEncoded.equals(_ephemPubKeyEncoded),
// })
// console.log({
// px: Buffer.from(px).subarray(1), // remove 0x04 prefix
// _px: _px.toArrayLike(Buffer),
// eq: Buffer.from(px).subarray(1).equals(_px.toArrayLike(Buffer)),
// })
const pubKeyToEncoded = Buffer.concat([Buffer.from([0x04]), pubKeyTo as Buffer])
const px = secp256k1.getSharedSecret(ephemPrivKey, pubKeyToEncoded).slice(1)

// better TODO: don't do handcrafted encryption??????
const unprefixedPx = Buffer.from(px).subarray(1) // remove 0x04 prefix
const hash = ConcatKDF(unprefixedPx, 32)
const encryptionKey = hash.subarray(0, IV_LENGTH)
const hash = hkdf(sha256, px, undefined, undefined, 32)
const encryptionKey = hash.subarray(0, 16)
const macKey = sha256.create().update(hash.subarray(16)).digest()
const message = AES128EncryptAndHMAC(encryptionKey, macKey, plaintext)
const message = AES128EncryptAndHMAC(Buffer.from(encryptionKey), macKey, plaintext)
const serializedCiphertext = Buffer.concat([
ephemPubKeyEncoded, // 65 bytes
ephemPubKeyEncoded, // {COMPRESSED_KEY_LENGTH} bytes
message, // iv + ciphertext + mac (min 48 bytes)
])

return serializedCiphertext
}

/**
* ECIES decrypt
* @param {Buffer} privKey Ethereum private key, 32 bytes.
* @param {Buffer} encrypted Encrypted message, serialized, 113+ bytes
* @returns {Uint8Array} plaintext
* @returns {Buffer} plaintext
*/
export function Decrypt(privKey: Buffer, encrypted: Buffer) {
export function Decrypt(privKey: PrivKey, encrypted: Buffer) {
// Read iv, ephemPubKey, mac, ciphertext from encrypted message
const ephemPubKeyEncoded = encrypted.subarray(0, 65)
const symmetricEncrypted = encrypted.subarray(65)
const ephemPubKeyEncoded = u8(encrypted).slice(0, COMPRESSED_KEY_LENGTH)
const symmetricEncrypted = u8(encrypted).slice(COMPRESSED_KEY_LENGTH)

const px = secp256k1.getSharedSecret(privKey, ephemPubKeyEncoded)
const unprefixedPx = Buffer.from(px).subarray(1) // remove 0x04 prefix
const hash = ConcatKDF(unprefixedPx, 32)
const px = secp256k1.getSharedSecret(privKey, ephemPubKeyEncoded).slice(1)
const hash = hkdf(sha256, px, undefined, undefined, 32)
// km, ke
const encryptionKey = hash.subarray(0, 16)

const macKey = sha256.create().update(hash.subarray(16)).digest()

return AES128DecryptAndHMAC(encryptionKey, macKey, symmetricEncrypted)
return AES128DecryptAndHMAC(Buffer.from(encryptionKey), macKey, symmetricEncrypted)
}

export const ECIES = {
Expand Down

0 comments on commit 4de13d3

Please sign in to comment.