Initial import of zappier-edge from zapier monorepo
This commit is contained in:
commit
9d72cecabd
120 changed files with 19867 additions and 0 deletions
165
src/accounts.ts
Normal file
165
src/accounts.ts
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
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);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue