secure-cookie
Version:
Cookie library/middleware with signing and encryption support
226 lines (182 loc) • 6.27 kB
text/typescript
/* eslint-disable @typescript-eslint/ban-ts-comment */
import compare from 'tsscmp'
import crypto, {BinaryToTextEncoding, CipherKey, Encoding} from "crypto";
import {CIPHER_INFO} from "./ciphers";
const AUTH_TAG_REQUIRED = /-(gcm|ccm)/
export interface KeyStoreOpts {
signing?: {
keys: string[],
algorithm?: string | 'blake2b512' | 'blake2s256' | 'gost' | 'md4' | 'md5' | 'rmd160' | 'sha1' | 'sha224'
| 'sha256' | 'sha3-224' | 'sha3-256' | 'sha3-384' | 'sha3-512' | 'sha384' | 'sha512' | 'sha512-224'
| 'sha512-256' | 'shake128' | 'shake256' | 'sm3';
encoding?: BinaryToTextEncoding
},
encryption?: {
keys: string[] | CipherKey[],
algorithm?: keyof typeof CIPHER_INFO | string,
encoding?: Encoding
authTagLength?: number
}
}
export type EncryptOptions = Required<KeyStoreOpts['encryption']> & { key?: string | undefined }
export type DecryptOptions = Required<KeyStoreOpts['encryption']> & {
key?: string | CipherKey | undefined,
iv?: string | Buffer | undefined,
authTag?: string | Buffer | undefined
}
export class KeyStore {
encryption: Required<NonNullable<KeyStoreOpts['encryption']>>;
signing: Required<NonNullable<KeyStoreOpts['signing']>>;
static cipherInfo = CIPHER_INFO as Record<string | keyof typeof CIPHER_INFO, {
ivLength: number | undefined,
keyLength: number
}>
constructor(opts?: KeyStoreOpts) {
opts = opts || {}
this.encryption = Object.assign({
algorithm: 'aes-192-ccm',
authTagLength: 16,
encoding: 'hex',
keys: []
}, opts.encryption || {} as any)
this.signing = Object.assign({
encoding: 'base64',
algorithm: 'sha1',
keys: []
}, opts.signing || {} as any)
}
encrypt(data?: null, options?: Partial<EncryptOptions>): null
encrypt(data: string | Buffer, options?: Partial<EncryptOptions>): string
encrypt(data?: string | Buffer | null, options?: Partial<EncryptOptions>): string | null {
if (!data) {
return null
}
const {
keys,
algorithm,
encoding,
authTagLength,
key
}: EncryptOptions = options ? Object.assign({}, this.encryption, options) : this.encryption
const secret = key || keys[0]
if (!secret) {
throw new Error("no key found")
}
const cipherInfo = KeyStore.cipherInfo[algorithm]
if (!cipherInfo) {
throw new Error("unsupported cipher")
}
const iv = cipherInfo.ivLength ? crypto.randomBytes(cipherInfo.ivLength) : null;
const dataBuff = typeof data === "string" ? Buffer.from(data, 'utf-8') : data
const cipher = crypto.createCipheriv(algorithm as any, secret, iv, {authTagLength})
const text = cipher.update(dataBuff);
const pad = cipher.final();
let authTag: Buffer | undefined;
if (AUTH_TAG_REQUIRED.test(algorithm)) {
authTag = cipher.getAuthTag();
}
return Buffer.concat([
...iv ? [iv] : [],
...authTag ? [authTag] : [],
text,
pad
]).toString(encoding)
}
decrypt(data?: null, options?: Partial<DecryptOptions>): null
decrypt(data: string | Buffer, options?: Partial<DecryptOptions>): string
decrypt(data?: string | Buffer | null, options?: Partial<DecryptOptions>): string | null {
if (!data) {
return null
}
const finalOptions: DecryptOptions = options ? Object.assign({}, this.encryption, options) : this.encryption
const {
encoding,
key,
keys: defaultKeys,
algorithm,
authTagLength,
} = finalOptions
const keys = key ? [key] : defaultKeys
if (keys.length === 0) {
throw new Error("keys required for encrypted cookies")
}
let {
iv,
authTag
} = finalOptions
let dataBuff = typeof data === "string" ? Buffer.from(data, encoding) : data
const cipherInfo = KeyStore.cipherInfo[algorithm]
if (!cipherInfo) {
throw new Error("unsupported cipher")
}
if (typeof iv === "string") {
iv = Buffer.from(iv, encoding)
}
if (typeof authTag === "string") {
authTag = Buffer.from(authTag, encoding)
}
if (cipherInfo.ivLength !== undefined) {
if (!iv) {
iv = dataBuff.slice(0, cipherInfo.ivLength)
}
dataBuff = dataBuff.slice(cipherInfo.ivLength, dataBuff.length)
}
if (AUTH_TAG_REQUIRED.test(algorithm)) {
if (!authTag) {
authTag = dataBuff.slice(0, authTagLength)
}
dataBuff = dataBuff.slice(authTagLength, dataBuff.length)
}
for (let i = 0; i < keys.length; i++) {
const message = KeyStore.doDecrypt(dataBuff, {...finalOptions, key: keys[i], iv, authTag,});
if (message !== null) return message
}
return null
}
private static doDecrypt(data: Buffer, options: DecryptOptions): string | null {
const {algorithm, key, iv, authTagLength, authTag} = options
const decipher = crypto.createDecipheriv(algorithm as any, key!, iv as Buffer || null, {authTagLength});
if (authTag) {
decipher.setAuthTag(authTag as Buffer)
}
const plainText = decipher.update(data)
let final: Buffer
try {
final = decipher.final()
} catch(e:any) {
// authentication failed
return null
}
return Buffer.concat([plainText, final]).toString('utf-8')
}
//region: signing
sign(data?: null, key?: string | CipherKey): null
sign(data: string, key?: string | CipherKey): string
sign(data?: string | null, key?: string | CipherKey): string | null {
if (!data) {
return null
}
const {algorithm, encoding, keys} = this.signing
key = key || keys[0] as CipherKey
return crypto
.createHmac(algorithm, key)
.update(data).digest(encoding)
.replace(/\/|\+|=/g, function (x) {
return ({"/": "_", "+": "-", "=": ""})[x] as string
})
}
verify(data: string, digest: string): boolean {
return this.indexOf(data, digest) > -1
}
indexOf(data: string, digest: string): number {
const {keys} = this.signing
if (keys.length === 0) {
throw new Error("keys required for signed cookies")
}
for (let i = 0; i < keys.length; i++) {
if (compare(digest, this.sign(data, keys[i] as string))) return i
}
return -1
}
//end-region: signing
}