diff --git a/package.json b/package.json index 77cf386e..25fde425 100644 --- a/package.json +++ b/package.json @@ -81,6 +81,12 @@ }, "./codecs/raw": { "import": "./src/codecs/raw.js" + }, + "./codecs/encrypted": { + "import": "./src/codecs/encrypted.js" + }, + "./crypto/aes": { + "import": "./src/crypto/aes.js" } }, "devDependencies": { @@ -103,6 +109,8 @@ "dependencies": { "buffer": "^5.6.1", "cids": "^1.0.2", + "js-crypto-aes": "^1.0.0", + "js-crypto-random": "^1.0.0", "lodash.transform": "^4.6.0" }, "directories": { diff --git a/src/codecs/encrypted.js b/src/codecs/encrypted.js new file mode 100644 index 00000000..6abf97c4 --- /dev/null +++ b/src/codecs/encrypted.js @@ -0,0 +1,44 @@ +// @ts-check +import * as varint from '../varint.js' +import { codec } from './codec.js' + +const code = 0x1400 + +/** + * @template {number} Code + * @param {Object} options + * @param {Uint8Array} options.bytes + * @param {Uint8Array} options.iv + * @param {Code} options.code + * @returns {Uint8Array} + */ +const encode = ({ iv, code, bytes }) => { + const codeLength = varint.encodingLength(code) + const ivsizeLength = varint.encodingLength(iv.byteLength) + const length = codeLength + ivsizeLength + iv.byteLength + bytes.byteLength + const buff = new Uint8Array(length) + varint.encodeTo(code, buff) + let offset = codeLength + varint.encodeTo(iv.byteLength, buff, offset) + offset += ivsizeLength + buff.set(iv, offset) + offset += iv.byteLength + buff.set(bytes, offset) + return buff +} + +/** + * @param {Uint8Array} bytes + */ +const decode = bytes => { + const [code, vlength] = varint.decode(bytes) + let offset = vlength + const [ivsize, ivsizeLength] = varint.decode(bytes.subarray(offset)) + offset += ivsizeLength + const iv = bytes.subarray(offset, offset + ivsize) + offset += ivsize + bytes = bytes.slice(offset) + return { iv, code, bytes } +} + +export default codec({ encode, decode, code, name: 'encrypted' }) diff --git a/src/crypto/aes.js b/src/crypto/aes.js new file mode 100644 index 00000000..77bea2bf --- /dev/null +++ b/src/crypto/aes.js @@ -0,0 +1,62 @@ +import CID from '../cid.js' +import random from 'js-crypto-random' +import aes from 'js-crypto-aes' + +/** + * @param {Uint8Array[]} buffers + */ +const concat = buffers => Uint8Array.from(buffers.map(b => [...b]).flat()) + +/** + * @template {'aes-gcm' | 'aes-cbc' | 'aes-ctr'} Name + * @template {number} Code + * @param {Object} options + * @param {Name} options.name + * @param {Code} options.code + * @param {number} options.ivsize + */ +const mkcrypto = ({ name, code, ivsize }) => { + // Line below does a type cast, because type checker can't infer that + // `toUpperCase` will result in desired string literal. + const cyperType = /** @type {import('js-crypto-aes/dist/params').cipherTypes} */(name.toUpperCase()) + /** + * @param {Object} options + * @param {Uint8Array} options.key + * @param {Object} options.value + * @param {Uint8Array} options.value.bytes + * @param {Uint8Array} options.value.iv + */ + const decrypt = async ({ key, value: { iv, bytes } }) => { + bytes = await aes.decrypt(bytes, key, { name: cyperType, iv, tagLength: 16 }) + const [cid, remainder] = CID.decodeFirst(bytes) + return { cid, bytes: remainder } + } + /** + * @param {Object} options + * @param {Uint8Array} options.key + * @param {Uint8Array} options.bytes + * @param {CID} options.cid + */ + const encrypt = async ({ key, cid, bytes }) => { + const iv = random.getRandomBytes(ivsize) + const msg = concat([cid.bytes, bytes]) + bytes = await aes.encrypt(msg, key, { name: cyperType, iv, tagLength: 16 }) + return { bytes, iv, code } + } + + return { + code, + // Note: Do a type cast becasue `toLowerCase()` turns liternal type + // into a string. + name: /** @type {Name} */(name.toLowerCase()), + encrypt, + decrypt, + ivsize + } +} + +const gcm = mkcrypto({ name: 'aes-gcm', code: 0x1401, ivsize: 12 }) +const cbc = mkcrypto({ name: 'aes-cbc', code: 0x1402, ivsize: 16 }) +const ctr = mkcrypto({ name: 'aes-ctr', code: 0x1403, ivsize: 12 }) + +export { gcm, cbc, ctr } diff --git a/test/test-block.js b/test/test-block.js index 8521b170..a375fb8d 100644 --- a/test/test-block.js +++ b/test/test-block.js @@ -1,5 +1,8 @@ /* globals describe, it */ +import random from 'js-crypto-random' import codec from 'multiformats/codecs/json' +import * as ciphers from 'multiformats/crypto/aes' +import encrypted from 'multiformats/codecs/encrypted' import { sha256 as hasher } from 'multiformats/hashes/sha2' import * as main from 'multiformats/block' import { CID, bytes } from 'multiformats' @@ -61,6 +64,32 @@ describe('block', () => { }) }) + describe('ciphers', () => { + const createTest = name => { + const json = codec + test(`aes-${name}`, async () => { + const block = await main.encode({ value: fixture, codec: json, hasher }) + const crypto = ciphers[name] + const key = random.getRandomBytes(32) + const value = await crypto.encrypt({ ...block, key }) + const eblock = await main.encode({ codec: encrypted, value, hasher }) + const eeblock = await main.decode({ codec: encrypted, bytes: eblock.bytes, hasher }) + same(eeblock.cid.toString(), eblock.cid.toString()) + same([...eeblock.bytes], [...eblock.bytes]) + same([...eeblock.value.bytes], [...eblock.value.bytes]) + same([...eeblock.value.iv], [...eblock.value.iv]) + const { cid, bytes } = await crypto.decrypt({ ...eblock, key }) + same(cid.toString(), block.cid.toString()) + same([...bytes], [...block.bytes]) + const dblock = await main.create({ cid, bytes, codec: json, hasher }) + same(dblock.value, block.value) + }) + } + createTest('gcm') + createTest('cbc') + createTest('ctr') + }) + test('kitchen sink', () => { const sink = { one: { two: { arr: [true, false, null], three: 3, buff, link } } } const block = main.createUnsafe({ value: sink, codec, bytes: true, cid: true }) diff --git a/tsconfig.json b/tsconfig.json index 4ce54b0e..104133d7 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -16,7 +16,7 @@ "strict": true, "alwaysStrict": true, "esModuleInterop": true, - "target": "ES2018", + "target": "ES2019", "moduleResolution": "node", "declaration": true, "declarationMap": true,