* rewrite

* testing worklflow

* sprcify version

* config stuff

* add hash as buffer

* delete docs

* use typedarray everywhere and docs

* readme

* aes

* cleanup

* rsa

* testing with playwright

* fix playwright

* readme

* docs

* update deps

* use headless

* add prepublish

* build pipeline

* move types up

* move types up

* add types legacy

* packaging

* versions bump

* cleanup

* maybe this time

* drop support for commonjs

* version bump

* cleanup
This commit is contained in:
2022-10-18 15:53:43 +02:00
committed by GitHub
parent f1f5e44b54
commit 56a8103582
58 changed files with 2701 additions and 4705 deletions

View File

@@ -1,25 +0,0 @@
import { createHash } from 'crypto'
enum Hashes {
MD5 = 'md5',
SHA1_1 = 'sha1',
SHA1_256 = 'sha256',
SHA1_512 = 'sha512',
SHA3_256 = 'sha3-256',
SHA3_384 = 'sha3-384',
SHA3_512 = 'sha3-512',
}
export default class Hash {
static Hashes = Hashes
/**
*
* @param {string} plain Input string
* @param {Hashes} type Type of hash to be used to digest
* @returns {string} Hash of the plain text
*/
static digest = (plain: string, type: Hashes = Hashes.SHA3_256): string => createHash(type).update(plain).digest().toString('hex')
}

View File

@@ -1,50 +0,0 @@
import { generateKeyPair, privateDecrypt, publicEncrypt } from 'crypto'
type PrivateKey = string
type PublicKey = string
export type KeyPair = {
pub: PublicKey
prv: PrivateKey
}
export default class RSA {
/**
*
* @param {number} size Number of bits for the key
* @returns {Promise<KeyPair>} Object that contains the key pair
*/
static gen = (size: number = 2 ** 12): Promise<KeyPair> => new Promise<KeyPair>((resolve, reject) => {
// @ts-ignore
generateKeyPair('rsa', {
modulusLength: size,
publicKeyEncoding: { type: 'pkcs1', format: 'pem' },
privateKeyEncoding: { type: 'pkcs1', format: 'pem' },
}, (err: string, pub: string, prv: string) => {
if (err) reject()
else resolve({
pub,
prv,
})
})
})
/**
*
* @param {string} plain
* @param {PublicKey} key
* @returns {string} Encrypted string
*/
static encrypt = (plain: string, key: PublicKey): string => publicEncrypt(key, Buffer.from(plain)).toString('base64')
/**
*
* @param {string} encrypted
* @param {PrivateKey} key
* @returns {string} Decrypted string
*/
static decrypt = (encrypted: string, key: PrivateKey): string => privateDecrypt(key, Buffer.from(encrypted, 'base64')).toString()
}

View File

@@ -1,162 +0,0 @@
import {
Cipher,
CipherCCM,
CipherGCM,
createCipheriv,
createDecipheriv,
Decipher,
DecipherCCM,
DecipherGCM,
randomBytes,
scryptSync,
} from 'crypto'
import { TransformOptions } from 'stream'
import { Base64 } from './Util'
export enum Ciphers {
ChaCha20,
AES_256_GCM,
AES_192_GCM,
AES_128_GCM,
AES_256_CTR,
AES_192_CTR,
AES_128_CTR,
}
type CipherConfig = {
alg: string,
keySize: number,
ivSize: number
mac?: number
}
type EncryptedItem = {
alg: string,
data: string,
iv: string,
salt: string
keySize: number,
tag?: string,
tagSize?: number
}
export default class Symmetric {
static Ciphers = Ciphers
static Encoding: BufferEncoding = 'base64'
/**
*
* @param plain {string} data The data to be encrypted
* @param key {string} The encryption key
* @param type {Ciphers} [type=Ciphers.AES_256_CTR] The cipher that will be used
* @returns {string} Encrypted data as string
*/
static encrypt(plain: string, key: string, type: Ciphers = Ciphers.AES_256_CTR): string {
const { alg, ivSize, mac, keySize } = Symmetric.getCipherConfig(type)
const iv = randomBytes(ivSize)
const salt = randomBytes(keySize)
const keyBuffered = scryptSync(Buffer.from(key), salt, keySize)
const options: TransformOptions | undefined = mac ? { authTagLength: mac } as TransformOptions : undefined
const cipher: CipherGCM | CipherCCM | Cipher = createCipheriv(alg, keyBuffered, iv, options)
let content: Buffer = Buffer.concat([
cipher.update(Buffer.from(plain)),
cipher.final(),
])
let tag: string | undefined = undefined
// @ts-ignore
if (mac) tag = cipher.getAuthTag().toString(Symmetric.Encoding)
const encrypted: EncryptedItem = {
alg,
data: content.toString(Symmetric.Encoding),
tag,
iv: iv.toString(Symmetric.Encoding),
salt: salt.toString(Symmetric.Encoding),
keySize,
tagSize: mac,
}
return Base64.encode(JSON.stringify(encrypted))
}
/**
*
* @param {string} encrypted The encrypted string
* @param {string} key The key used for encrypting
* @returns {string} The data as string
*/
static decrypt(encrypted: string, key: string): string {
const { alg, data, iv, tag, salt, keySize, tagSize }: EncryptedItem = JSON.parse(Base64.decode(encrypted))
const keyBuffered = scryptSync(Buffer.from(key), Buffer.from(salt, Symmetric.Encoding), keySize)
// @ts-ignore
const options: TransformOptions = tag ? { authTagLength: tagSize } : undefined
const decipher: DecipherGCM | DecipherCCM | Decipher = createDecipheriv(alg, keyBuffered, Buffer.from(iv, Symmetric.Encoding), options)
// @ts-ignore
if (tag) decipher.setAuthTag(Buffer.from(tag, Symmetric.Encoding))
const decrypted: Buffer = Buffer.concat([
decipher.update(Buffer.from(data, Symmetric.Encoding)),
decipher.final(),
])
return decrypted.toString()
}
private static getCipherConfig = (type: Ciphers): CipherConfig => {
switch (type) {
case Ciphers.AES_128_GCM:
return {
alg: 'aes-128-gcm',
ivSize: 16,
keySize: 16,
mac: 16,
}
case Ciphers.AES_192_GCM:
return {
alg: 'aes-192-gcm',
ivSize: 16,
keySize: 24,
mac: 16,
}
case Ciphers.AES_256_GCM:
return {
alg: 'aes-256-gcm',
ivSize: 16,
keySize: 32,
mac: 16,
}
case Ciphers.AES_128_CTR:
return {
alg: 'aes-128-ctr',
ivSize: 16,
keySize: 16,
}
case Ciphers.AES_192_CTR:
return {
alg: 'aes-192-ctr',
ivSize: 16,
keySize: 24,
}
case Ciphers.AES_256_CTR:
return {
alg: 'aes-256-ctr',
ivSize: 16,
keySize: 32,
}
case Ciphers.ChaCha20:
return {
alg: 'chacha20',
ivSize: 16,
keySize: 32,
}
}
}
}

View File

@@ -1,25 +0,0 @@
import { randomBytes } from 'crypto'
export class Base64 {
static encode = (s: string): string => Buffer.from(s).toString('base64')
static decode = (s: string): string => Buffer.from(s, 'base64').toString()
}
/**
*
* @param {number} length Length of the generated string
* @param {boolean} string Whether to return a string or bytes
* @returns {Buffer|String} The random string or buffer
*/
function Rand(length: number, string: false): Buffer
function Rand(length: number, string: true): string
function Rand(length: number, string: boolean = false): Buffer | string {
const r = randomBytes(length)
return string
? r.toString('ascii')
: r
}
export default {
Rand
}

144
src/crypto/aes.ts Normal file
View File

@@ -0,0 +1,144 @@
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 KeyData = {
name: 'PBKDF2'
hash: Hashes
iterations: number
salt: TypedArray
length: number
}
/**
* 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?: KeyData): Promise<[TypedArray, KeyData]> {
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: KeyData = {
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)
}
}

20
src/crypto/crypto.ts Normal file
View File

@@ -0,0 +1,20 @@
import { isBrowser } from '../utils/base.js'
let crypto: typeof window.crypto | null = null
export async function getCrypto(): Promise<typeof window.crypto> {
if (!crypto) {
if (isBrowser) crypto = window.crypto
else if (typeof require !== 'undefined') {
const { webcrypto } = await require('crypto')
crypto = webcrypto
} else {
// @ts-ignore
const { webcrypto } = await import('crypto')
crypto = webcrypto as any
}
}
if (!crypto) throw new Error('No crypto available')
return crypto
}

83
src/crypto/encoding.ts Normal file
View File

@@ -0,0 +1,83 @@
import { split, type TypedArray } from '../utils/base.js'
export class Base64 {
private static prefix = 'data:application/octet-stream;base64,'
static encode(s: TypedArray): Promise<string> {
return split({
async node() {
return Buffer.from(s).toString('base64')
},
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): Promise<TypedArray> {
return split({
async node() {
return Buffer.from(s, 'base64')
},
async browser() {
const ab = await fetch(Base64.prefix + s)
.then((r) => r.blob())
.then((b) => b.arrayBuffer())
return new Uint8Array(ab)
},
})
}
}
export class Hex {
static encode(buffer: TypedArray): string {
let s = ''
for (const i of new Uint8Array(buffer)) {
s += i.toString(16).padStart(2, '0')
}
return s
}
static decode(s: string): TypedArray {
const size = s.length / 2
const buffer = new Uint8Array(size)
for (let i = 0; i < size; i++) {
const idx = i * 2
const segment = s.slice(idx, idx + 2)
buffer[i] = parseInt(segment, 16)
}
return buffer
}
}
export class Bytes {
static decode(data: TypedArray): string {
return split({
node() {
return Buffer.from(data).toString('utf-8')
},
browser() {
return new TextDecoder().decode(data)
},
})
}
static encode(data: string): TypedArray {
return split({
node() {
return Buffer.from(data)
},
browser() {
return new TextEncoder().encode(data)
},
})
}
}

32
src/crypto/hash.ts Normal file
View File

@@ -0,0 +1,32 @@
import { type TypedArray } from '../utils/base.js'
import { getCrypto } from './crypto.js'
import { Bytes, Hex } from './encoding.js'
/**
* List of available hash functions.
*
* @remarks
* For cryptographic details refer to: https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto/digest#supported_algorithms
* Reference: https://nvlpubs.nist.gov/nistpubs/FIPS/NIST.FIPS.180-4.pdf
*/
export enum Hashes {
/**
* @remarks SHA-1 is not recommended for new applications as it's not cryptographically secure.
*/
SHA_1 = 'SHA-1',
SHA_256 = 'SHA-256',
SHA_384 = 'SHA-384',
SHA_512 = 'SHA-512',
}
export class Hash {
static async hash(data: string, hash: Hashes): Promise<string>
static async hash(data: TypedArray, hash: Hashes): Promise<TypedArray>
static async hash(data: string | TypedArray, hash: Hashes): Promise<string | TypedArray> {
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
}
}

11
src/crypto/random.ts Normal file
View File

@@ -0,0 +1,11 @@
import { type TypedArray } from '../utils/base.js'
import { getCrypto } from './crypto.js'
export async function getRandomBytes(bytes: number): Promise<TypedArray> {
if (bytes <= 0) throw new Error('Invalid number of bytes')
const buffer = new Uint8Array(bytes)
const crypto = await getCrypto()
crypto.getRandomValues(buffer)
return buffer
}

137
src/crypto/rsa.ts Normal file
View File

@@ -0,0 +1,137 @@
import type { TypedArray } from '../utils/base.js'
import { getCrypto } from './crypto.js'
import { Base64 } from './encoding.js'
const Constants = Object.freeze({
name: 'RSA-OAEP',
hash: 'SHA-512',
exponent: new Uint8Array([1, 0, 1]),
error: {
invalidKey: new Error('invalid key'),
shouldBePublicKey: new Error('should be a public key'),
shouldBePrivateKey: new Error('should be a private key'),
dataTooLong: new Error('data too long'),
},
})
class Key {
/**
* Exports a key to a PEM string.
*/
static async encode(key: CryptoKey): Promise<string> {
const c = await getCrypto()
let type: 'pkcs8' | 'spki'
let label: string
switch (key.type) {
case 'private':
type = 'pkcs8'
label = 'PRIVATE'
break
case 'public':
type = 'spki'
label = 'PUBLIC'
break
default:
throw new Error('invalid key type')
}
const exported = await c.subtle.exportKey(type, key)
// Export in PEM format
const base64 = await Base64.encode(new Uint8Array(exported))
const formatted = base64.match(/.{1,64}/g)?.join('\n')
return `-----BEGIN ${label} KEY-----\n${formatted}\n-----END ${label} KEY-----`
}
static async decode(s: string): Promise<CryptoKey> {
const isPrivate = s.includes('BEGIN PRIVATE KEY')
const cleaned = s.replace(/-----.*?-----/g, '').replace(/\s/g, '')
const bytes = await Base64.decode(cleaned)
const c = await getCrypto()
return await c.subtle.importKey(
isPrivate ? 'pkcs8' : 'spki',
bytes,
{
name: Constants.name,
hash: Constants.hash,
},
true,
isPrivate ? ['decrypt'] : ['encrypt']
)
}
/**
* Get max size of payload to be encrypted.
* The size depends on the key size and the hash function used.
* https://www.rfc-editor.org/rfc/rfc2437#section-7.1.1
*/
static getMaxMessageSize(key: CryptoKey): number {
if (key.type !== 'public') throw Constants.error.shouldBePublicKey
// @ts-ignore
const mod = key?.algorithm?.modulusLength
if (isNaN(mod)) throw Constants.error.invalidKey
return mod / 8 - (2 * 512) / 8 - 2
}
}
export class RSA {
static async generateKeyPair(bits: number = 2 ** 12): Promise<{ private: string; public: string }> {
const c = await getCrypto()
if (bits < 2 ** 11) {
throw new Error('bit sizes below 2048 are considered insecure.')
}
const pair = await c.subtle.generateKey(
{
name: Constants.name,
modulusLength: bits,
publicExponent: Constants.exponent,
hash: Constants.hash,
},
true,
['encrypt', 'decrypt']
)
return {
private: await Key.encode(pair.privateKey),
public: await Key.encode(pair.publicKey),
}
}
static async encrypt(data: TypedArray, key: string): Promise<TypedArray> {
let keyObj: CryptoKey
try {
keyObj = await Key.decode(key)
} catch (e) {
throw Constants.error.invalidKey
}
if (keyObj.type !== 'public') {
throw Constants.error.shouldBePublicKey
}
// Check if data is too large
if (data.length > Key.getMaxMessageSize(keyObj)) throw Constants.error.dataTooLong
const c = await getCrypto()
const encrypted = await c.subtle.encrypt({ name: Constants.name }, keyObj, data)
return new Uint8Array(encrypted)
}
static async decrypt(data: TypedArray, key: string): Promise<TypedArray> {
let keyObj: CryptoKey
try {
keyObj = await Key.decode(key)
} catch (e) {
throw Constants.error.invalidKey
}
if (keyObj.type !== 'private') {
throw Constants.error.shouldBePrivateKey
}
const c = await getCrypto()
const decrypted = await c.subtle.decrypt({ name: Constants.name }, keyObj, data)
return new Uint8Array(decrypted)
}
}

View File

@@ -1,20 +1,6 @@
import H from './Hash'
import R from './RSA'
import S from './Symmetric'
import U from './Util'
export const RSA = R
export const Symmetric = S
export const Hash = H
export const Util = U
export default {
RSA,
Symmetric,
Hash,
Util,
}
// Require node 11
if (parseInt(process.versions.node.split('.')[0]) < 11) throw new Error('Node 11 or higher is required')
export * from './crypto/aes.js'
export * from './crypto/encoding.js'
export * from './crypto/hash.js'
export * from './crypto/random.js'
export * from './crypto/rsa.js'
export * from './utils/base.js'

27
src/utils/base.ts Normal file
View File

@@ -0,0 +1,27 @@
/**
* @internal
*/
export const isBrowser = typeof window !== 'undefined'
/**
* @internal
*/
export type PromiseOrValue<T> = T | Promise<T>
export type TypedArray =
| Int8Array
| Uint8Array
| Uint8ClampedArray
| Int16Array
| Uint16Array
| Int32Array
| Uint32Array
| BigInt64Array
| BigUint64Array
/**
* @internal
*/
export function split<T>({ node, browser }: { node: () => T; browser: () => T }) {
return isBrowser ? browser() : node()
}