SPB Git forge

spb/spinza

Public
8commits 1branches 0releases
1.6 MBsize
maindefault branch
16 days agolast push
TypeScript 97.6% SQL 1.4% JavaScript 0.5%
5.0 KB · 155 lines typescript
Raw Blame History
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