TypeScript 97.6%
SQL 1.4%
JavaScript 0.5%
1import argon2 from "argon2";2import { createCipheriv, createDecipheriv, createHash, createHmac, randomBytes, randomInt, timingSafeEqual } from "node:crypto";3import { RECOVERY_CODE_PREFIX, ROUND_ID_PREFIX } from "@spinza/shared";4import { config } from "../config";56/* ------------------------------------------------------------- passwords */78const ARGON_OPTS: argon2.Options = { type: argon2.argon2id, memoryCost: 19456, timeCost: 2, parallelism: 1 };910export async function hashPassword(password: string): Promise<string> {11 return argon2.hash(password, ARGON_OPTS);12}1314export async function verifyPassword(hash: string, password: string): Promise<boolean> {15 try {16 return await argon2.verify(hash, password);17 } catch {18 return false;19 }20}2122/* ---------------------------------------------------------------- tokens */2324export function randomToken(bytes = 32): string {25 return randomBytes(bytes).toString("base64url");26}2728export function sha256(input: string): string {29 return createHash("sha256").update(input).digest("hex");30}3132export function hmac(input: string): string {33 return createHmac("sha256", config.sessionSecret).update(input).digest("hex");34}3536/** Round ids: spz_rnd_ + 12 chars from an unambiguous alphabet. */37const ROUND_ALPHABET = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";38export function newRoundId(): string {39 let s = "";40 for (let i = 0; i < 12; i++) s += ROUND_ALPHABET[randomInt(ROUND_ALPHABET.length)];41 return `${ROUND_ID_PREFIX}${s}`;42}4344/* --------------------------------------------------------- recovery code */4546export function generateRecoveryCode(): string {47 const groups: string[] = [];48 for (let g = 0; g < 3; g++) {49 let s = "";50 for (let i = 0; i < 4; i++) s += ROUND_ALPHABET[randomInt(ROUND_ALPHABET.length)];51 groups.push(s);52 }53 return `${RECOVERY_CODE_PREFIX}-${groups.join("-")}`;54}5556export function normalizeRecoveryCode(code: string): string {57 return code.trim().toUpperCase().replace(/[^A-Z0-9]/g, "");58}5960export async function hashRecoveryCode(code: string): Promise<string> {61 return argon2.hash(normalizeRecoveryCode(code), ARGON_OPTS);62}6364export async function verifyRecoveryCode(hash: string, code: string): Promise<boolean> {65 try {66 return await argon2.verify(hash, normalizeRecoveryCode(code));67 } catch {68 return false;69 }70}7172/* -------------------------------------------------- symmetric encryption */7374function key(): Buffer {75 return createHash("sha256").update(config.sessionSecret).digest();76}7778export function encrypt(plain: string): string {79 const iv = randomBytes(12);80 const c = createCipheriv("aes-256-gcm", key(), iv);81 const enc = Buffer.concat([c.update(plain, "utf8"), c.final()]);82 return `${iv.toString("base64url")}.${enc.toString("base64url")}.${c.getAuthTag().toString("base64url")}`;83}8485export function decrypt(payload: string): string {86 const [iv, enc, tag] = payload.split(".").map((p) => Buffer.from(p, "base64url"));87 const d = createDecipheriv("aes-256-gcm", key(), iv);88 d.setAuthTag(tag);89 return Buffer.concat([d.update(enc), d.final()]).toString("utf8");90}9192/* ------------------------------------------------------------------ TOTP */9394const B32 = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567";9596export function base32Encode(buf: Buffer): string {97 let bits = 0;98 let value = 0;99 let out = "";100 for (const byte of buf) {101 value = (value << 8) | byte;102 bits += 8;103 while (bits >= 5) {104 out += B32[(value >>> (bits - 5)) & 31];105 bits -= 5;106 }107 }108 if (bits > 0) out += B32[(value << (5 - bits)) & 31];109 return out;110}111112export function base32Decode(s: string): Buffer {113 const clean = s.toUpperCase().replace(/=+$/, "").replace(/[^A-Z2-7]/g, "");114 let bits = 0;115 let value = 0;116 const out: number[] = [];117 for (const ch of clean) {118 value = (value << 5) | B32.indexOf(ch);119 bits += 5;120 if (bits >= 8) {121 out.push((value >>> (bits - 8)) & 255);122 bits -= 8;123 }124 }125 return Buffer.from(out);126}127128export function generateTotpSecret(): string {129 return base32Encode(randomBytes(20));130}131132export function totpCode(secret: string, time = Date.now(), step = 30, digits = 6): string {133 const counter = Math.floor(time / 1000 / step);134 const msg = Buffer.alloc(8);135 msg.writeBigUInt64BE(BigInt(counter));136 const h = createHmac("sha1", base32Decode(secret)).update(msg).digest();137 const offset = h[h.length - 1] & 0xf;138 const bin = ((h[offset] & 0x7f) << 24) | (h[offset + 1] << 16) | (h[offset + 2] << 8) | h[offset + 3];139 return String(bin % 10 ** digits).padStart(digits, "0");140}141142export function verifyTotp(secret: string, code: string, window = 1): boolean {143 const c = code.replace(/\s+/g, "");144 if (!/^\d{6}$/.test(c)) return false;145 for (let w = -window; w <= window; w++) {146 const expected = totpCode(secret, Date.now() + w * 30_000);147 if (timingSafeEqual(Buffer.from(expected), Buffer.from(c))) return true;148 }149 return false;150}151152export function otpauthUrl(username: string, secret: string): string {153 return `otpauth://totp/Spinza%20Admin:${encodeURIComponent(username)}?secret=${secret}&issuer=Spinza%20Admin&algorithm=SHA1&digits=6&period=30`;154}155