mirror of
https://github.com/cupcakearmy/occulto.git
synced 2025-01-22 18:06:28 +00:00
aes
This commit is contained in:
parent
d52f59f709
commit
be4e736ffd
167
src/crypto/aes.ts
Normal file
167
src/crypto/aes.ts
Normal file
@ -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<string> {
|
||||||
|
const strings = await Promise.all(args.map(Base64.encode))
|
||||||
|
return strings.join(AES.delimiter)
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async split(ciphertext: string): Promise<TypedArray[]> {
|
||||||
|
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<string> {
|
||||||
|
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<TypedArray> {
|
||||||
|
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<string> {
|
||||||
|
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<string> {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
@ -1,24 +1,37 @@
|
|||||||
import { split, type TypedArray } from '../utils/base.js'
|
import { split, type TypedArray } from '../utils/base.js'
|
||||||
|
|
||||||
export class Base64 {
|
export class Base64 {
|
||||||
static encode(s: string): string {
|
private static prefix = 'data:application/octet-stream;base64,'
|
||||||
return split<string>({
|
|
||||||
node() {
|
static encode(s: TypedArray): Promise<string> {
|
||||||
|
return split({
|
||||||
|
async node() {
|
||||||
return Buffer.from(s).toString('base64')
|
return Buffer.from(s).toString('base64')
|
||||||
},
|
},
|
||||||
browser() {
|
async browser() {
|
||||||
return btoa(s)
|
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<TypedArray> {
|
||||||
return split({
|
return split({
|
||||||
node() {
|
async node() {
|
||||||
return Buffer.from(s, 'base64').toString('utf8')
|
return Buffer.from(s, 'base64')
|
||||||
},
|
},
|
||||||
browser() {
|
async browser() {
|
||||||
return atob(s)
|
const ab = await fetch(Base64.prefix + s)
|
||||||
|
.then((r) => r.blob())
|
||||||
|
.then((b) => b.arrayBuffer())
|
||||||
|
return new Uint8Array(ab)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
@ -19,12 +19,14 @@ export enum Hashes {
|
|||||||
SHA_512 = 'SHA-512',
|
SHA_512 = 'SHA-512',
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function hash(data: string, hash: Hashes): Promise<string>
|
export class Hash {
|
||||||
export async function hash(data: TypedArray, hash: Hashes): Promise<TypedArray>
|
static async hash(data: string, hash: Hashes): Promise<string>
|
||||||
export async function hash(data: string | TypedArray, hash: Hashes): Promise<string | TypedArray> {
|
static async hash(data: TypedArray, hash: Hashes): Promise<TypedArray>
|
||||||
const isString = typeof data === 'string'
|
static async hash(data: string | TypedArray, hash: Hashes): Promise<string | TypedArray> {
|
||||||
const c = await getCrypto()
|
const isString = typeof data === 'string'
|
||||||
const result = await c.subtle.digest(hash, isString ? Bytes.encode(data) : data)
|
const c = await getCrypto()
|
||||||
const buf = new Uint8Array(result)
|
const result = await c.subtle.digest(hash, isString ? Bytes.encode(data) : data)
|
||||||
return isString ? Hex.encode(buf) : buf
|
const buf = new Uint8Array(result)
|
||||||
|
return isString ? Hex.encode(buf) : buf
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
export { Base64, Bytes, Hex } from './crypto/encoding.js'
|
export * from './crypto/aes.js'
|
||||||
export { hash, Hashes } from './crypto/hash.js'
|
export * from './crypto/encoding.js'
|
||||||
export { getRandomBytes } from './crypto/random.js'
|
export * from './crypto/hash.js'
|
||||||
export { TypedArray } from './utils/base.js'
|
export * from './crypto/random.js'
|
||||||
|
export * from './utils/base.js'
|
||||||
|
@ -1,6 +1,13 @@
|
|||||||
|
/**
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
export const isBrowser = typeof window !== 'undefined'
|
export const isBrowser = typeof window !== 'undefined'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
export type PromiseOrValue<T> = T | Promise<T>
|
export type PromiseOrValue<T> = T | Promise<T>
|
||||||
|
|
||||||
export type TypedArray =
|
export type TypedArray =
|
||||||
| Int8Array
|
| Int8Array
|
||||||
| Uint8Array
|
| Uint8Array
|
||||||
@ -12,6 +19,9 @@ export type TypedArray =
|
|||||||
| BigInt64Array
|
| BigInt64Array
|
||||||
| BigUint64Array
|
| BigUint64Array
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @internal
|
||||||
|
*/
|
||||||
export function split<T>({ node, browser }: { node: () => T; browser: () => T }) {
|
export function split<T>({ node, browser }: { node: () => T; browser: () => T }) {
|
||||||
return isBrowser ? browser() : node()
|
return isBrowser ? browser() : node()
|
||||||
}
|
}
|
||||||
|
28
test/aes.spec.js
Normal file
28
test/aes.spec.js
Normal file
@ -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)
|
||||||
|
})
|
||||||
|
})
|
@ -1,14 +1,15 @@
|
|||||||
import { Base64 } from '../dist/esm/index.js'
|
import { Base64, Bytes } from '../dist/esm/index.js'
|
||||||
import { Precomputed } from './values.js'
|
import { Precomputed } from './values.js'
|
||||||
|
|
||||||
describe('Encoding', () => {
|
describe('Encoding', () => {
|
||||||
describe('Base64', () => {
|
describe('Base64', () => {
|
||||||
for (const [input, output] of Object.entries(Precomputed.Encoding.Base64)) {
|
for (const [input, output] of Object.entries(Precomputed.Encoding.Base64)) {
|
||||||
it(`Should encode ${input} to ${output}`, () => {
|
const buffer = Bytes.encode(input)
|
||||||
chai.expect(Base64.encode(input)).to.equal(output)
|
it(`Should encode ${input} to ${output}`, async () => {
|
||||||
|
chai.expect(await Base64.encode(buffer)).to.equal(output)
|
||||||
})
|
})
|
||||||
it(`Should decode ${output} to ${input}`, () => {
|
it(`Should decode ${output} to ${input}`, async () => {
|
||||||
chai.expect(Base64.decode(output)).to.equal(input)
|
chai.expect(await Base64.decode(output)).to.deep.equal(buffer)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
@ -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'
|
import { Precomputed } from './values.js'
|
||||||
|
|
||||||
describe('Hash', () => {
|
describe('Hash', () => {
|
||||||
@ -7,14 +7,14 @@ describe('Hash', () => {
|
|||||||
const values = Precomputed.Hash[type]
|
const values = Precomputed.Hash[type]
|
||||||
for (const [input, output] of Object.entries(values)) {
|
for (const [input, output] of Object.entries(values)) {
|
||||||
it(`Should hash "${input}" to "${output.slice(0, 8)}..."`, async () => {
|
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)
|
chai.expect(hashed).to.equal(output)
|
||||||
})
|
})
|
||||||
|
|
||||||
it(`Should hash "${input}" to "${output.slice(0, 8)}..." as buffer`, async () => {
|
it(`Should hash "${input}" to "${output.slice(0, 8)}..." as buffer`, async () => {
|
||||||
const outputBuffer = Hex.decode(output)
|
const outputBuffer = Hex.decode(output)
|
||||||
const inputBuffer = Bytes.encode(input)
|
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)
|
chai.expect(hashed).to.deep.equal(outputBuffer)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
@ -30,4 +30,17 @@ export const Precomputed = {
|
|||||||
'9f7ff06148d415b12290ab7c21f021964ed627574f94f66c994aad4a8e319aa3168a9871edace3e736096cbd957cafa42dbf3feb6efd7763bf936ddc933c9470',
|
'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.',
|
||||||
|
},
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user