diff --git a/src/crypto/aes.ts b/src/crypto/aes.ts new file mode 100644 index 0000000..cfeb6a9 --- /dev/null +++ b/src/crypto/aes.ts @@ -0,0 +1,167 @@ +import { type TypedArray } from '../utils/base.js' +import { getCrypto } from './crypto.js' +import { Base64, Bytes } from './encoding.js' +import { Hashes } from './hash.js' +import { getRandomBytes } from './random.js' + +const Params = { + GCM: { + ivLength: 12, + tagLength: 128, + }, +} + +export type IRKeyData = { + name: 'PBKDF2' + hash: Hashes + iterations: number + salt: TypedArray + length: number +} + +// type IRObject = { +// algorithm: string +// iv: TypedArray +// data: TypedArray +// derived?: { +// algorithm: 'PBKDF2' +// hash: 'SHA1' +// iterations: number +// salt: TypedArray +// } +// } + +// /** +// * Intermediate representation of encrypted objects +// */ +// class IR { +// static delimiter = ':::' // delimiter with a character that is not allowed in base64 or hex + +// // static parse(s: string): IRObject { +// // const [algorithm, iv, data, derived] = s.split(IR.delimiter) +// // } +// } + +/** + * AES operation modes. + */ +export enum Modes { + AES_GCM = 'AES-GCM', +} + +export class AES { + static Modes = Modes + + private static delimiter = '--' // delimiter with a character that is not allowed in base64 or hex + private static delimiterEasy = '---' + + private static InvalidCiphertext = new Error('Invalid ciphertext') + + private static async join(...args: TypedArray[]): Promise { + const strings = await Promise.all(args.map(Base64.encode)) + return strings.join(AES.delimiter) + } + + private static async split(ciphertext: string): Promise { + const splitted = ciphertext.split(AES.delimiter) + return Promise.all(splitted.map(Base64.decode)) + } + + /** + * Derive a key from a password. + * To be used if the password is not 128, 192 or 256 bits or human made, non generated keys. + */ + static async derive(key: string, options?: IRKeyData): Promise<[TypedArray, IRKeyData]> { + options ??= { + name: 'PBKDF2', + hash: Hashes.SHA_512, + iterations: 100_000, + length: 256, + salt: await getRandomBytes(16), + } + const c = await getCrypto() + const keyBuffer = await c.subtle.importKey('raw', Bytes.encode(key), options.name, false, [ + 'deriveBits', + 'deriveKey', + ]) + const bits = await c.subtle.deriveBits(options, keyBuffer, options.length) + return [new Uint8Array(bits), options] + } + + static async encrypt(data: TypedArray, key: TypedArray, mode: Modes = Modes.AES_GCM): Promise { + const c = await getCrypto() + + let iv: Uint8Array + let alg: AlgorithmIdentifier + + switch (mode) { + case Modes.AES_GCM: + iv = c.getRandomValues(new Uint8Array(Params.GCM.ivLength)) + alg = mode + break + default: + throw new Error('Unsupported mode') + } + + const keyObj = await c.subtle.importKey('raw', key, alg, false, ['encrypt']) + const encrypted = await c.subtle.encrypt({ name: alg, iv }, keyObj, data) + const encryptedBuffer = new Uint8Array(encrypted) + + return AES.join(Bytes.encode(alg), iv, encryptedBuffer) + } + + static async decrypt(ciphertext: string, key: TypedArray): Promise { + const c = await getCrypto() + + const [alg, iv, data] = await AES.split(ciphertext) + if (!alg || !iv || !data) throw this.InvalidCiphertext + + const mode = Bytes.decode(alg) + switch (mode) { + case Modes.AES_GCM: + break + default: + throw new Error('Unsupported mode') + } + + const keyObj = await c.subtle.importKey('raw', key, mode, false, ['decrypt']) + const decrypted = await c.subtle.decrypt({ name: mode, iv }, keyObj, data) + return new Uint8Array(decrypted) + } + + static async encryptEasy(data: string | TypedArray, key: string, mode: Modes = Modes.AES_GCM): Promise { + const dataBuffer = typeof data === 'string' ? Bytes.encode(data) : data + const [keyDerived, options] = await AES.derive(key) + + const ciphertext = await this.encrypt(dataBuffer, keyDerived, mode) + const header = await this.join( + Bytes.encode(options.name), + Bytes.encode(options.hash), + Bytes.encode(options.iterations.toString()), + options.salt, + Bytes.encode(options.length.toString()) + ) + + return [header, ciphertext].join(this.delimiterEasy) + } + + static async decryptEasy(ciphertext: string, key: string): Promise { + const [header, data] = ciphertext.split(this.delimiterEasy) + if (!header || !data) throw this.InvalidCiphertext + const [name, hash, iterations, salt, length] = await this.split(header) + if (!name || !hash || !iterations || !salt || !length) throw this.InvalidCiphertext + + const options: IRKeyData = { + name: Bytes.decode(name) as any, + hash: Bytes.decode(hash) as any, + iterations: parseInt(Bytes.decode(iterations)), + salt, + length: parseInt(Bytes.decode(length)), + } + if (isNaN(options.iterations) || isNaN(options.length)) throw this.InvalidCiphertext + + const [keyDerived] = await AES.derive(key, options) + const decrypted = await this.decrypt(data, keyDerived) + return Bytes.decode(decrypted) + } +} diff --git a/src/crypto/encoding.ts b/src/crypto/encoding.ts index b1090a0..bc9f77c 100644 --- a/src/crypto/encoding.ts +++ b/src/crypto/encoding.ts @@ -1,24 +1,37 @@ import { split, type TypedArray } from '../utils/base.js' export class Base64 { - static encode(s: string): string { - return split({ - node() { + private static prefix = 'data:application/octet-stream;base64,' + + static encode(s: TypedArray): Promise { + return split({ + async node() { return Buffer.from(s).toString('base64') }, - browser() { - return btoa(s) + async browser() { + return new Promise((resolve, reject) => { + const reader = new FileReader() + reader.onload = function (event) { + const data = event.target?.result + if (typeof data === 'string') resolve(data.slice(Base64.prefix.length)) + else reject(new Error('Failed to read file')) + } + reader.readAsDataURL(new Blob([s])) + }) }, }) } - static decode(s: string): string { + static decode(s: string): Promise { return split({ - node() { - return Buffer.from(s, 'base64').toString('utf8') + async node() { + return Buffer.from(s, 'base64') }, - browser() { - return atob(s) + async browser() { + const ab = await fetch(Base64.prefix + s) + .then((r) => r.blob()) + .then((b) => b.arrayBuffer()) + return new Uint8Array(ab) }, }) } diff --git a/src/crypto/hash.ts b/src/crypto/hash.ts index 64a965b..af21511 100644 --- a/src/crypto/hash.ts +++ b/src/crypto/hash.ts @@ -19,12 +19,14 @@ export enum Hashes { SHA_512 = 'SHA-512', } -export async function hash(data: string, hash: Hashes): Promise -export async function hash(data: TypedArray, hash: Hashes): Promise -export async function hash(data: string | TypedArray, hash: Hashes): Promise { - const isString = typeof data === 'string' - const c = await getCrypto() - const result = await c.subtle.digest(hash, isString ? Bytes.encode(data) : data) - const buf = new Uint8Array(result) - return isString ? Hex.encode(buf) : buf +export class Hash { + static async hash(data: string, hash: Hashes): Promise + static async hash(data: TypedArray, hash: Hashes): Promise + static async hash(data: string | TypedArray, hash: Hashes): Promise { + const isString = typeof data === 'string' + const c = await getCrypto() + const result = await c.subtle.digest(hash, isString ? Bytes.encode(data) : data) + const buf = new Uint8Array(result) + return isString ? Hex.encode(buf) : buf + } } diff --git a/src/index.ts b/src/index.ts index e8bbdc2..b8649c9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,5 @@ -export { Base64, Bytes, Hex } from './crypto/encoding.js' -export { hash, Hashes } from './crypto/hash.js' -export { getRandomBytes } from './crypto/random.js' -export { TypedArray } from './utils/base.js' +export * from './crypto/aes.js' +export * from './crypto/encoding.js' +export * from './crypto/hash.js' +export * from './crypto/random.js' +export * from './utils/base.js' diff --git a/src/utils/base.ts b/src/utils/base.ts index 7166e81..d52e9e8 100644 --- a/src/utils/base.ts +++ b/src/utils/base.ts @@ -1,6 +1,13 @@ +/** + * @internal + */ export const isBrowser = typeof window !== 'undefined' +/** + * @internal + */ export type PromiseOrValue = T | Promise + export type TypedArray = | Int8Array | Uint8Array @@ -12,6 +19,9 @@ export type TypedArray = | BigInt64Array | BigUint64Array +/** + * @internal + */ export function split({ node, browser }: { node: () => T; browser: () => T }) { return isBrowser ? browser() : node() } diff --git a/test/aes.spec.js b/test/aes.spec.js new file mode 100644 index 0000000..f3e47f7 --- /dev/null +++ b/test/aes.spec.js @@ -0,0 +1,28 @@ +import { AES, Bytes, Hashes, Hex } from '../dist/esm/index.js' +import { Precomputed } from './values.js' + +describe('AES', () => { + it('Basic API', async () => { + const message = Precomputed.Crypto.Messages.nietzscheIpsum + const data = Bytes.encode(message) + const [key] = await AES.derive('foo', { + name: 'PBKDF2', + hash: Hashes.SHA_512, + iterations: 1000, + length: 256, + salt: Hex.decode(Precomputed.Crypto.Bytes[16]), + }) + const ciphertext = await AES.encrypt(data, key, AES.Modes.GCM) + const plaintext = await AES.decrypt(ciphertext, key) + chai.expect(data).to.be.deep.equal(plaintext) + chai.expect(message).to.be.equal(Bytes.decode(plaintext)) + }) + + it('Easy API', async () => { + const message = Precomputed.Crypto.Messages.nietzscheIpsum + const password = 'foobar' + const encrypted = await AES.encryptEasy(message, password) + const decrypted = await AES.decryptEasy(encrypted, password) + chai.expect(message).to.be.equal(decrypted) + }) +}) diff --git a/test/encoding.spec.js b/test/encoding.spec.js index b9ab5fd..2c43d75 100644 --- a/test/encoding.spec.js +++ b/test/encoding.spec.js @@ -1,14 +1,15 @@ -import { Base64 } from '../dist/esm/index.js' +import { Base64, Bytes } from '../dist/esm/index.js' import { Precomputed } from './values.js' describe('Encoding', () => { describe('Base64', () => { for (const [input, output] of Object.entries(Precomputed.Encoding.Base64)) { - it(`Should encode ${input} to ${output}`, () => { - chai.expect(Base64.encode(input)).to.equal(output) + const buffer = Bytes.encode(input) + it(`Should encode ${input} to ${output}`, async () => { + chai.expect(await Base64.encode(buffer)).to.equal(output) }) - it(`Should decode ${output} to ${input}`, () => { - chai.expect(Base64.decode(output)).to.equal(input) + it(`Should decode ${output} to ${input}`, async () => { + chai.expect(await Base64.decode(output)).to.deep.equal(buffer) }) } }) diff --git a/test/hash.spec.js b/test/hash.spec.js index 433e0d2..cefa80c 100644 --- a/test/hash.spec.js +++ b/test/hash.spec.js @@ -1,4 +1,4 @@ -import { Bytes, hash, Hashes, Hex } from '../dist/esm/index.js' +import { Bytes, Hash, Hashes, Hex } from '../dist/esm/index.js' import { Precomputed } from './values.js' describe('Hash', () => { @@ -7,14 +7,14 @@ describe('Hash', () => { const values = Precomputed.Hash[type] for (const [input, output] of Object.entries(values)) { it(`Should hash "${input}" to "${output.slice(0, 8)}..."`, async () => { - const hashed = await hash(input, Hashes[type]) + const hashed = await Hash.hash(input, Hashes[type]) chai.expect(hashed).to.equal(output) }) it(`Should hash "${input}" to "${output.slice(0, 8)}..." as buffer`, async () => { const outputBuffer = Hex.decode(output) const inputBuffer = Bytes.encode(input) - const hashed = await hash(inputBuffer, Hashes[type]) + const hashed = await Hash.hash(inputBuffer, Hashes[type]) chai.expect(hashed).to.deep.equal(outputBuffer) }) } diff --git a/test/values.js b/test/values.js index 009335b..6868d9b 100644 --- a/test/values.js +++ b/test/values.js @@ -30,4 +30,17 @@ export const Precomputed = { '9f7ff06148d415b12290ab7c21f021964ed627574f94f66c994aad4a8e319aa3168a9871edace3e736096cbd957cafa42dbf3feb6efd7763bf936ddc933c9470', }, }, + Crypto: { + Bytes: { + 14: 'e0b28a24252963ff30dd2bb3ec9c', + 16: '65eeb2044e9eb115956dbf4d0d70cd8f', + 24: '9fa9e0aace3b0bdcbc871aa3ee3ddb1bece759b811fa4603', + 32: '848ca08f01f82e28bfa91c85d55ef2a98afd8b32c707c9c790e86b1c53a177e4', + }, + Messages: { + test: 'test', + nietzscheIpsum: + 'Marvelous intentions joy deceptions overcome sexuality spirit against. Selfish of marvelous play dead war snare eternal-return ultimate. Reason aversion suicide.', + }, + }, }