homelab-personal-cloud/core/src/utils/secure-string.ts

141 lines
4.1 KiB
TypeScript

import * as bcrypt from 'bcrypt';
import * as crypto from 'node:crypto';
// https://passlib.readthedocs.io/en/stable/modular_crypt_format.html#mcf-identifiers
type OSDefinedHashPrefix = |
'$1$' |
'$2$' | '$2a$' | '$2x$' | '$2y$' | '$2b$' |
'$5$' |
'$6$'
;
enum NodeCryptoAlgs {
md5 = 'md5',
bcrypt = 'bcrypt',
sha256 = 'sha256',
sha512 = 'sha512',
}
const hashDictionary: Record<OSDefinedHashPrefix, NodeCryptoAlgs> = {
$1$: NodeCryptoAlgs.md5,
$2$: NodeCryptoAlgs.bcrypt,
$2a$: NodeCryptoAlgs.bcrypt,
$2x$: NodeCryptoAlgs.bcrypt,
$2y$: NodeCryptoAlgs.bcrypt,
$2b$: NodeCryptoAlgs.bcrypt,
$5$: NodeCryptoAlgs.sha256,
$6$: NodeCryptoAlgs.sha512,
}
const inversedHashDictionary: Record<NodeCryptoAlgs, OSDefinedHashPrefix> = {
[NodeCryptoAlgs.md5]: '$1$',
[NodeCryptoAlgs.bcrypt]: '$2$',
[NodeCryptoAlgs.sha256]: '$5$',
[NodeCryptoAlgs.sha512]: '$6$',
}
const saltStringLengthDictionary: Record<NodeCryptoAlgs, number> = {
[NodeCryptoAlgs.md5]: 23,
[NodeCryptoAlgs.bcrypt]: 23,
[NodeCryptoAlgs.sha256]: 23,
[NodeCryptoAlgs.sha512]: 23,
}
interface HashingProvider {
hash: (plainText: string, saltRounds: number) => Promise<string>;
compare: (plainText: string, hash: string) => Promise<boolean>;
}
const algImplementationMap: Record<NodeCryptoAlgs, HashingProvider> = {
[NodeCryptoAlgs.md5]: {
hash: (plainText: string, saltRounds: number): Promise<string> => { throw new Error('Not implemented') },
compare: (plainText: string, hash: string): Promise<boolean> => { throw new Error('Not implemented') },
},
[NodeCryptoAlgs.bcrypt]: bcrypt,
[NodeCryptoAlgs.sha256]: {
hash: (plainText: string, saltRounds: number): Promise<string> => { throw new Error('Not implemented') },
compare: (plainText: string, hash: string): Promise<boolean> => { throw new Error('Not implemented') },
},
[NodeCryptoAlgs.sha512]: {
hash: (plainText: string, saltRounds: number): Promise<string> => { throw new Error('Not implemented') },
compare: (plainText: string, hash: string): Promise<boolean> => { throw new Error('Not implemented') },
},
}
export enum JWA {
HSA256 = 'HSA256',
}
type SigningFunction = (rawString: string, signingComponent: string) => string;
const jwaImplementationMap: Record<JWA, SigningFunction> = {
[JWA.HSA256]: function (rawString: string, signingComponent: string) {
return crypto.createHmac('sha-256', signingComponent).update(rawString).digest('base64url');
},
}
export class SecureStringUtil {
private static readonly defaultWorkFactor = 15;
static async generateNewHash(rawString: string): Promise<string> {
const alg = NodeCryptoAlgs.bcrypt;
const saltRounds = this.defaultWorkFactor;
return algImplementationMap[alg].hash(rawString, saltRounds);
}
static async compare(rawString: string, hash: string): Promise<boolean> {
const { alg } = HashMeta.deserialize(hash);
return algImplementationMap[alg].compare(rawString, hash);
}
}
export class TokenSignatureUtil {
static generateSignature(rawString: string, alg: JWA, signingComponent: string): string {
return jwaImplementationMap[alg](rawString, signingComponent);
}
}
class HashMeta {
constructor(
public readonly alg: NodeCryptoAlgs,
public readonly workFactor: number,
public readonly salt: string,
public readonly hash: string,
) {}
static deserialize(hashMeta: string): HashMeta {
const parts = hashMeta.split('$');
if (parts.length !== 4) {
throw new Error();
}
const [_, alg, workFactor, salthash] = parts as [string, string, string, string];
const wrappedAlg = `$${alg}$` as OSDefinedHashPrefix;
const nodeCryptoAlg = hashDictionary[wrappedAlg];
const saltStringLength = saltStringLengthDictionary[nodeCryptoAlg];
const salt = salthash.substring(0, saltStringLength);
const hash = salthash.substring(saltStringLength);
return new HashMeta(
nodeCryptoAlg,
+workFactor,
salt,
hash
);
}
serialize(): string {
return [
'',
inversedHashDictionary[this.alg],
this.workFactor,
`${this.salt}${this.hash}`
].join('$');
}
}