165 lines
4.7 KiB
TypeScript
165 lines
4.7 KiB
TypeScript
import { createHmac, randomBytes, scryptSync, timingSafeEqual } from 'crypto';
|
|
|
|
/**
|
|
* Customer-portal identity primitives: password hashing (scrypt), TOTP
|
|
* two-factor secrets (RFC 6238), and portal sessions. Pure functions plus
|
|
* repo interfaces; SQLite adapters live in src/db/.
|
|
*/
|
|
|
|
/* ---------------- password hashing (scrypt) ---------------- */
|
|
|
|
const SCRYPT_N = 16384;
|
|
const SCRYPT_R = 8;
|
|
const SCRYPT_P = 1;
|
|
const KEY_LEN = 32;
|
|
|
|
/** Format: scrypt:N:r:p:<salt b64>:<hash b64> */
|
|
export function hashPassword(password: string): string {
|
|
const salt = randomBytes(16);
|
|
const hash = scryptSync(password, salt, KEY_LEN, {
|
|
N: SCRYPT_N,
|
|
r: SCRYPT_R,
|
|
p: SCRYPT_P,
|
|
});
|
|
return `scrypt:${SCRYPT_N}:${SCRYPT_R}:${SCRYPT_P}:${salt.toString('base64')}:${hash.toString('base64')}`;
|
|
}
|
|
|
|
export function verifyPassword(password: string, stored: string): boolean {
|
|
const parts = stored.split(':');
|
|
if (parts.length !== 6 || parts[0] !== 'scrypt') return false;
|
|
const [, n, r, p, saltB64, hashB64] = parts;
|
|
const expected = Buffer.from(hashB64, 'base64');
|
|
if (expected.length === 0) return false;
|
|
const actual = scryptSync(password, Buffer.from(saltB64, 'base64'), expected.length, {
|
|
N: Number(n),
|
|
r: Number(r),
|
|
p: Number(p),
|
|
});
|
|
return timingSafeEqual(actual, expected);
|
|
}
|
|
|
|
/* ---------------- base32 (RFC 4648, no padding) ---------------- */
|
|
|
|
const B32_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
|
|
|
|
export function base32Encode(buf: Buffer): string {
|
|
let bits = 0;
|
|
let value = 0;
|
|
let out = '';
|
|
for (const byte of buf) {
|
|
value = (value << 8) | byte;
|
|
bits += 8;
|
|
while (bits >= 5) {
|
|
out += B32_ALPHABET[(value >>> (bits - 5)) & 31];
|
|
bits -= 5;
|
|
}
|
|
}
|
|
if (bits > 0) out += B32_ALPHABET[(value << (5 - bits)) & 31];
|
|
return out;
|
|
}
|
|
|
|
export function base32Decode(s: string): Buffer {
|
|
let bits = 0;
|
|
let value = 0;
|
|
const out: number[] = [];
|
|
for (const ch of s.toUpperCase().replace(/=+$/, '')) {
|
|
const idx = B32_ALPHABET.indexOf(ch);
|
|
if (idx < 0) throw new Error(`invalid base32 character: ${ch}`);
|
|
value = (value << 5) | idx;
|
|
bits += 5;
|
|
if (bits >= 8) {
|
|
out.push((value >>> (bits - 8)) & 0xff);
|
|
bits -= 8;
|
|
}
|
|
}
|
|
return Buffer.from(out);
|
|
}
|
|
|
|
/* ---------------- TOTP (RFC 6238, HMAC-SHA1, 30 s step, 6 digits) ---------------- */
|
|
|
|
export function hotp(secret: string, counter: number, digits = 6): string {
|
|
const key = base32Decode(secret);
|
|
const msg = Buffer.alloc(8);
|
|
msg.writeBigUInt64BE(BigInt(counter));
|
|
const h = createHmac('sha1', key).update(msg).digest();
|
|
const offset = h[h.length - 1] & 0x0f;
|
|
const code =
|
|
(((h[offset] & 0x7f) << 24) |
|
|
(h[offset + 1] << 16) |
|
|
(h[offset + 2] << 8) |
|
|
h[offset + 3]) %
|
|
10 ** digits;
|
|
return String(code).padStart(digits, '0');
|
|
}
|
|
|
|
export function totp(secret: string, atMs: number, stepSec = 30, digits = 6): string {
|
|
return hotp(secret, Math.floor(atMs / 1000 / stepSec), digits);
|
|
}
|
|
|
|
export function verifyTotp(
|
|
secret: string,
|
|
code: string,
|
|
atMs: number,
|
|
window = 1,
|
|
): boolean {
|
|
if (!/^\d{6}$/.test(code)) return false;
|
|
for (let w = -window; w <= window; w++) {
|
|
if (totp(secret, atMs + w * 30_000) === code) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/** 160-bit secret, base32 without padding (authenticator-app standard). */
|
|
export function generateTotpSecret(): string {
|
|
return base32Encode(randomBytes(20));
|
|
}
|
|
|
|
export function totpUri(secret: string, email: string, issuer = 'Zappier'): string {
|
|
return `otpauth://totp/${encodeURIComponent(issuer)}:${encodeURIComponent(email)}?secret=${secret}&issuer=${encodeURIComponent(issuer)}`;
|
|
}
|
|
|
|
/* ---------------- portal sessions ---------------- */
|
|
|
|
export interface PortalSession {
|
|
token: string;
|
|
customerId: string;
|
|
createdMs: number;
|
|
expiresMs: number;
|
|
}
|
|
|
|
export interface SessionRepo {
|
|
create(customerId: string, ttlMs: number): PortalSession;
|
|
/** Returns the session, or undefined when unknown or expired at nowMs. */
|
|
get(token: string, nowMs?: number): PortalSession | undefined;
|
|
delete(token: string): void;
|
|
}
|
|
|
|
export function newSessionToken(): string {
|
|
return randomBytes(24).toString('hex');
|
|
}
|
|
|
|
export class InMemorySessionRepo implements SessionRepo {
|
|
private sessions = new Map<string, PortalSession>();
|
|
|
|
create(customerId: string, ttlMs: number): PortalSession {
|
|
const now = Date.now();
|
|
const session: PortalSession = {
|
|
token: newSessionToken(),
|
|
customerId,
|
|
createdMs: now,
|
|
expiresMs: now + ttlMs,
|
|
};
|
|
this.sessions.set(session.token, session);
|
|
return session;
|
|
}
|
|
|
|
get(token: string, nowMs = Date.now()): PortalSession | undefined {
|
|
const s = this.sessions.get(token);
|
|
if (!s || s.expiresMs <= nowMs) return undefined;
|
|
return s;
|
|
}
|
|
|
|
delete(token: string): void {
|
|
this.sessions.delete(token);
|
|
}
|
|
}
|