zappier-edge/src/accounts.ts

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);
}
}