cripta
Version:
A layer of encryption a little too judicious
209 lines (168 loc) • 6.16 kB
text/typescript
import crypto from 'node:crypto'
import { Config } from './config'
import { TYPE, AcceptedValue, ConfigOptions } from './types'
import { sha256 } from './hash'
export class Factory {
private separator = '\x1f'
public config: Config = new Config()
public lastConfig: Config | null = null
constructor(config?: ConfigOptions | Config) {
if (config) this.setConfig(config)
}
public same(): this {
this.config.entropy = 0
return this.setConfig(this.config)
}
public unique(): this {
this.config.entropy = 4
return this.setConfig(this.config)
}
public setConfig(config: ConfigOptions | Config): this {
this.config = config instanceof Config ? config : new Config(config)
return this
}
public onceConfig(config: ConfigOptions | Config): this {
if (!(config instanceof Config)) config = new Config(config)
this.lastConfig = this.config
this.config = Object.assign(new Config(), config)
return this
}
public onceKey(secret: string): this {
this.lastConfig = Object.assign(new Config(), this.config)
this.config.setKey(secret)
return this
}
private maybeRestoreConfig(): this {
if (this.lastConfig !== null) {
this.config = Object.assign(new Config(), this.lastConfig)
this.lastConfig = null
}
return this
}
private validateConfig(): void {
const key = this.config.key
if (!key || !key?.byteLength || !key.length)
throw new Error('Secret key is required for use cripta.')
}
public encode(data: AcceptedValue): string {
this.validateConfig()
const eLength = this.config.entropy
const entropy = eLength ? crypto.randomBytes(eLength) : Buffer.alloc(0)
const payload = Buffer.from(this.toText(data))
const encoded = this.baseEncode(Buffer.concat([
this.cipher(payload, this.forgeKey(entropy)),
entropy
]))
this.maybeRestoreConfig()
return encoded
}
public decode(data: string): AcceptedValue {
this.validateConfig()
const combined = this.baseDecode(data)
const eLength = this.config.entropy
const entropy = eLength ? combined.subarray(-eLength) : Buffer.alloc(0)
const ciphered = eLength ? combined.subarray(0, -eLength) : combined
const result = this.convert(this.cipher(ciphered, this.forgeKey(entropy)).toString('utf8'))
this.maybeRestoreConfig()
return result
}
private convert(value: string): AcceptedValue {
const separatorIndex = value.indexOf(this.separator)
if (separatorIndex !== -1) {
const typeStr = value.substring(0, separatorIndex)
const typeNum = parseInt(typeStr, 10)
const data = value.substring(separatorIndex + 1)
switch (typeNum) {
case TYPE.null: return null
case TYPE.boolean: return data === 'true' || data === '1'
case TYPE.string: return data
case TYPE.int: return parseInt(data, 10)
case TYPE.float: return parseFloat(data)
case TYPE.bigint: return BigInt(data)
case TYPE.array:
case TYPE.object:
try {
return JSON.parse(data)
} catch {
return null
}
case TYPE.symbol: return Symbol.for(data)
case TYPE.undefined: return undefined
}
}
try {
return JSON.parse(value)
} catch {
return value
}
}
private toText(value: AcceptedValue): string {
if (value === null) return `${TYPE.null}${this.separator}`
if (value === undefined) return `${TYPE.undefined}${this.separator}`
const type = typeof value
if (type === 'boolean')
return `${TYPE.boolean}${this.separator}${value ? '1' : '0'}`
if (type === 'string')
return `${TYPE.string}${this.separator}${value}`
if (type === 'number')
return `${Number.isInteger(value) ? TYPE.int : TYPE.float}${this.separator}${value}`
if (type === 'bigint')
return `${TYPE.bigint}${this.separator}${value.toString()}`
if (type === 'symbol')
// @ts-ignore
return `${TYPE.symbol}${this.separator}${Symbol.keyFor(value) ?? value.toString()}`
if (Array.isArray(value))
return `${TYPE.array}${this.separator}${JSON.stringify(value)}`
if (type === 'object')
return `${TYPE.object}${this.separator}${JSON.stringify(value)}`
return ''
}
private cipher(data: Buffer, key: Buffer): Buffer {
const result = Buffer.allocUnsafe(data.length)
const keyLength = key.length
for (let i = 0; i < data.length; i++)
result[i] = data[i] ^ key[i % keyLength]
return result
}
private forgeKey(entropy: Buffer): Buffer {
return sha256(Buffer.concat([this.config.key as Buffer, entropy]), 'buffer')
}
private maybeUseAlphabet(data: string, from?: string, to?: string): string {
if (!data || typeof data !== 'string' || !from || !to || from === to)
return data
return this.strtr(data, from, to)
}
private strtr(str: string, from: string, to: string): string {
let result = ''
for (let i = 0; i < str.length; i++) {
const char = str[i]
const index = from.indexOf(char)
result += index !== -1 && index < to.length ? to[index] : char
}
return result
}
baseEncode(data: Buffer): string {
return this.maybeUseAlphabet(
data.toString('base64').replace(/[=+/]/g, char => {
switch (char) {
case '=': return ''
case '+': return '-'
case '/': return '_'
default: return char
}
}),
// data.toString('base64').replace(/=/g, '').replace(/\+/g, '-').replace(/\//g, '_'),
this.config.baseAlphabet,
this.config.alphabet
)
}
#PADDING_CACHE = ['', '===', '==', '=']
baseDecode(data: string): Buffer {
data = this.maybeUseAlphabet(data, this.config.alphabet, this.config.baseAlphabet)
const remainder = data.length % 4
if (remainder) data += this.#PADDING_CACHE[remainder]
return Buffer.from(data.replace(/[-_]/g, (char) => char === '-' ? '+' : '/'), 'base64')
// if (remainder) data += '='.repeat(4 - remainder)
// return Buffer.from(data.replace(/-/g, '+').replace(/_/g, '/'), 'base64')
}
}