From 9f63ad0613f91b914af0e4df89d1f9e3c68b7a2d Mon Sep 17 00:00:00 2001 From: Henry Tsai Date: Wed, 29 Mar 2023 14:57:00 -0700 Subject: [PATCH] #276 - added methods for AES encryption and decryption --- README.md | 2 +- package-lock.json | 4 +- package.json | 2 +- src/index.ts | 1 + src/utils/encryption.ts | 63 +++++++++++++++++ tests/utils/encryption.spec.ts | 120 +++++++++++++++++++++++++++++++++ 6 files changed, 188 insertions(+), 4 deletions(-) create mode 100644 src/utils/encryption.ts create mode 100644 tests/utils/encryption.spec.ts diff --git a/README.md b/README.md index 3b97ed95d..711d704d0 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ # Decentralized Web Node (DWN) SDK Code Coverage -![Statements](https://img.shields.io/badge/statements-95.1%25-brightgreen.svg?style=flat) ![Branches](https://img.shields.io/badge/branches-93.05%25-brightgreen.svg?style=flat) ![Functions](https://img.shields.io/badge/functions-94.09%25-brightgreen.svg?style=flat) ![Lines](https://img.shields.io/badge/lines-95.1%25-brightgreen.svg?style=flat) +![Statements](https://img.shields.io/badge/statements-93.73%25-brightgreen.svg?style=flat) ![Branches](https://img.shields.io/badge/branches-92.9%25-brightgreen.svg?style=flat) ![Functions](https://img.shields.io/badge/functions-90.55%25-brightgreen.svg?style=flat) ![Lines](https://img.shields.io/badge/lines-93.73%25-brightgreen.svg?style=flat) ## Introduction diff --git a/package-lock.json b/package-lock.json index f677122ff..e902f3837 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@tbd54566975/dwn-sdk-js", - "version": "0.0.27", + "version": "0.0.28", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@tbd54566975/dwn-sdk-js", - "version": "0.0.27", + "version": "0.0.28", "license": "Apache-2.0", "dependencies": { "@ipld/dag-cbor": "9.0.0", diff --git a/package.json b/package.json index 05e2708bd..9fc9efa77 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@tbd54566975/dwn-sdk-js", - "version": "0.0.27", + "version": "0.0.28", "description": "A reference implementation of https://identity.foundation/decentralized-web-node/spec/", "type": "module", "types": "./dist/esm/src/index.d.ts", diff --git a/src/index.ts b/src/index.ts index a97ad4090..379f7384d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -27,6 +27,7 @@ export { Dwn } from './dwn.js'; export { DwnConstant } from './core/dwn-constant.js'; export { DwnInterfaceName, DwnMethodName } from './core/message.js'; export { Encoder } from './utils/encoder.js'; +export { Encryption } from './utils/encryption.js'; export { HooksWrite, HooksWriteOptions } from './interfaces/hooks/messages/hooks-write.js'; export { Jws } from './utils/jws.js'; export { KeyMaterial, PrivateJwk, PublicJwk } from './jose/types.js'; diff --git a/src/utils/encryption.ts b/src/utils/encryption.ts new file mode 100644 index 000000000..be1fed7d5 --- /dev/null +++ b/src/utils/encryption.ts @@ -0,0 +1,63 @@ +import * as crypto from 'crypto'; +import { Readable } from 'readable-stream'; + +/** + * Utility class for performing common encryption operations. + */ +export class Encryption { + /** + * Encrypts the given plaintext stream using AES-256-CTR algorithm. + */ + public static async aes256CtrEncrypt(key: Uint8Array, initializationVector: Uint8Array, plaintextStream: Readable): Promise { + const cipher = crypto.createCipheriv('aes-256-ctr', key, initializationVector); + + const cipherStream = new Readable({ + read(): void { } + }); + + plaintextStream.on('data', (chunk) => { + const encryptedChunk = cipher.update(chunk); + cipherStream.push(encryptedChunk); + }); + + plaintextStream.on('end', () => { + const finalChunk = cipher.final(); + cipherStream.push(finalChunk); + cipherStream.push(null); + }); + + plaintextStream.on('error', (err) => { + cipherStream.emit('error', err); + }); + + return cipherStream; + } + + /** + * Decrypts the given cipher stream using AES-256-CTR algorithm. + */ + public static async aes256CtrDecrypt(key: Uint8Array, initializationVector: Uint8Array, cipherStream: Readable): Promise { + const decipher = crypto.createDecipheriv('aes-256-ctr', key, initializationVector); + + const plaintextStream = new Readable({ + read(): void { } + }); + + cipherStream.on('data', (chunk) => { + const decryptedChunk = decipher.update(chunk); + plaintextStream.push(decryptedChunk); + }); + + cipherStream.on('end', () => { + const finalChunk = decipher.final(); + plaintextStream.push(finalChunk); + plaintextStream.push(null); + }); + + cipherStream.on('error', (err) => { + plaintextStream.emit('error', err); + }); + + return plaintextStream; + } +} diff --git a/tests/utils/encryption.spec.ts b/tests/utils/encryption.spec.ts new file mode 100644 index 000000000..a110f97ae --- /dev/null +++ b/tests/utils/encryption.spec.ts @@ -0,0 +1,120 @@ +import { Comparer } from '../utils/comparer.js'; +import { DataStream } from '../../src/index.js'; +import { Encryption } from '../../src/utils/encryption.js'; +import { expect } from 'chai'; +import { Readable } from 'readable-stream'; +import { TestDataGenerator } from './test-data-generator.js'; + +describe('Encryption', () => { + describe('AES-256-CTR', () => { + it('should be able to encrypt and decrypt a data stream correctly', async () => { + const key = TestDataGenerator.randomBytes(32); + const initializationVector = TestDataGenerator.randomBytes(16); + + const inputBytes = TestDataGenerator.randomBytes(1_000_000); + const inputStream = DataStream.fromBytes(inputBytes); + + const cipherStream = await Encryption.aes256CtrEncrypt(key, initializationVector, inputStream); + + const plaintextStream = await Encryption.aes256CtrDecrypt(key, initializationVector, cipherStream); + const plaintextBytes = await DataStream.toBytes(plaintextStream); + + expect(Comparer.byteArraysEqual(inputBytes, plaintextBytes)).to.be.true; + }); + + it('should emit error on encrypt if the plaintext data stream emits an error', async () => { + const key = TestDataGenerator.randomBytes(32); + const initializationVector = TestDataGenerator.randomBytes(16); + + let errorOccurred = false; + + // a mock plaintext stream + const randomByteGenerator = asyncRandomByteGenerator({ totalIterations: 10, bytesPerIteration: 1 }); + const mockPlaintextStream = new Readable({ + async read(): Promise { + if (errorOccurred) { + return; + } + + // MUST use async generator/iterator, else caller will repeatedly call `read()` in a blocking manner until `null` is returned + const { value } = await randomByteGenerator.next(); + this.push(value); + } + }); + + const cipherStream = await Encryption.aes256CtrEncrypt(key, initializationVector, mockPlaintextStream); + + const simulatedErrorMessage = 'Simulated error'; + + // test that the `error` event from plaintext stream will propagate to the cipher stream + const eventPromise = new Promise((resolve, _reject) => { + cipherStream.on('error', (error) => { + expect(error).to.equal(simulatedErrorMessage); + errorOccurred = true; + resolve(); + }); + }); + + // trigger the `error` in the plaintext stream + mockPlaintextStream.emit('error', simulatedErrorMessage); + + await eventPromise; + + expect(errorOccurred).to.be.true; + }); + }); + + it('should emit error on decrypt if the plaintext data stream emits an error', async () => { + const key = TestDataGenerator.randomBytes(32); + const initializationVector = TestDataGenerator.randomBytes(16); + + let errorOccurred = false; + + // a mock cipher stream + const randomByteGenerator = asyncRandomByteGenerator({ totalIterations: 10, bytesPerIteration: 1 }); + const mockCipherStream = new Readable({ + async read(): Promise { + if (errorOccurred) { + return; + } + + // MUST use async generator/iterator, else caller will repeatedly call `read()` in a blocking manner until `null` is returned + const { value } = await randomByteGenerator.next(); + this.push(value); + } + }); + + const plaintextStream = await Encryption.aes256CtrDecrypt(key, initializationVector, mockCipherStream); + + const simulatedErrorMessage = 'Simulated error'; + + // test that the `error` event from cipher stream will propagate to the plaintext stream + const eventPromise = new Promise((resolve, _reject) => { + plaintextStream.on('error', (error) => { + expect(error).to.equal(simulatedErrorMessage); + errorOccurred = true; + resolve(); + }); + }); + + // trigger the `error` in the cipher stream + mockCipherStream.emit('error', simulatedErrorMessage); + + await eventPromise; + + expect(errorOccurred).to.be.true; + }); +}); + +/** + * Generates iterations of random bytes + */ +async function* asyncRandomByteGenerator(input: { totalIterations: number, bytesPerIteration: number }): AsyncGenerator { + let i = 0; + while (i < input.totalIterations) { + yield TestDataGenerator.randomBytes(input.bytesPerIteration); + i++; + } + + yield null; +}