auth.ts raw

   1  import { getDb } from './db';
   2  import bcrypt from 'bcryptjs';
   3  import crypto from 'crypto';
   4  
   5  const SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000;
   6  const CHALLENGE_TTL_MS = 5 * 60 * 1000;
   7  
   8  export function verifyPassword(email: string, password: string): string | null {
   9    const db = getDb();
  10    const user = db.query('SELECT id, password_hash FROM users WHERE email = ?').get(email) as any;
  11    if (!user || !user.password_hash) return null;
  12    return bcrypt.compareSync(password, user.password_hash) ? user.id : null;
  13  }
  14  
  15  export function createUser(email: string, password: string, nostrPubkey?: string): string {
  16    const db = getDb();
  17    const id = crypto.randomUUID();
  18    const hash = password ? bcrypt.hashSync(password, 10) : null;
  19    db.query('INSERT INTO users (id, email, password_hash, nostr_pubkey) VALUES (?, ?, ?, ?)').run(id, email, hash, nostrPubkey || null);
  20    return id;
  21  }
  22  
  23  export function findOrCreateNostrUser(pubkey: string): { userId: string; created: boolean } {
  24    const db = getDb();
  25    const user = db.query('SELECT id FROM users WHERE nostr_pubkey = ?').get(pubkey) as any;
  26    if (user) return { userId: user.id, created: false };
  27    const id = crypto.randomUUID();
  28    db.query('INSERT INTO users (id, nostr_pubkey) VALUES (?, ?)').run(id, pubkey);
  29    return { userId: id, created: true };
  30  }
  31  
  32  export function createSession(userId: string): string {
  33    const db = getDb();
  34    const token = crypto.randomBytes(32).toString('hex');
  35    db.query('INSERT INTO sessions (token, user_id, expires_at) VALUES (?, ?, ?)').run(token, userId, Date.now() + SESSION_TTL_MS);
  36    return token;
  37  }
  38  
  39  export function getUserFromSession(token: string): any {
  40    const db = getDb();
  41    const session = db.query('SELECT user_id, expires_at FROM sessions WHERE token = ?').get(token) as any;
  42    if (!session) return null;
  43    if (session.expires_at < Date.now()) {
  44      db.query('DELETE FROM sessions WHERE token = ?').run(token);
  45      return null;
  46    }
  47    return db.query('SELECT id, email, nostr_pubkey, is_admin, delivery_prefs, name, phone, password_hash FROM users WHERE id = ?').get(session.user_id);
  48  }
  49  
  50  export function deleteSession(token: string) {
  51    getDb().query('DELETE FROM sessions WHERE token = ?').run(token);
  52  }
  53  
  54  export function updateUserPrefs(userId: string, prefs: { name?: string; phone?: string; email?: string; delivery_prefs?: string }): any {
  55    const db = getDb();
  56    const sets: string[] = [];
  57    const vals: any[] = [];
  58    if (prefs.name !== undefined && prefs.name !== '') { sets.push('name = ?'); vals.push(prefs.name); }
  59    if (prefs.phone !== undefined && prefs.phone !== '') { sets.push('phone = ?'); vals.push(prefs.phone); }
  60    if (prefs.email !== undefined && prefs.email !== '') { sets.push('email = ?'); vals.push(prefs.email); }
  61    if (prefs.delivery_prefs !== undefined) { sets.push('delivery_prefs = ?'); vals.push(prefs.delivery_prefs); }
  62    if (sets.length === 0) return null;
  63    vals.push(userId);
  64    db.query(`UPDATE users SET ${sets.join(', ')} WHERE id = ?`).run(...vals);
  65    return db.query('SELECT id, email, nostr_pubkey, is_admin, delivery_prefs, name, phone FROM users WHERE id = ?').get(userId);
  66  }
  67  
  68  export function setUserPassword(userId: string, password: string): boolean {
  69    if (!password || password.length < 6) return false;
  70    const hash = bcrypt.hashSync(password, 10);
  71    getDb().query('UPDATE users SET password_hash = ? WHERE id = ?').run(hash, userId);
  72    return true;
  73  }
  74  
  75  export function issueNostrChallenge(): string {
  76    const db = getDb();
  77    const challenge = crypto.randomBytes(32).toString('hex');
  78    db.query('DELETE FROM nostr_challenges WHERE created_at < ?').run(Date.now() - CHALLENGE_TTL_MS);
  79    db.query('INSERT INTO nostr_challenges (challenge, created_at) VALUES (?, ?)').run(challenge, Date.now());
  80    return challenge;
  81  }
  82  
  83  function consumeChallenge(challenge: string): boolean {
  84    const db = getDb();
  85    const row = db.query('SELECT created_at FROM nostr_challenges WHERE challenge = ?').get(challenge) as any;
  86    if (!row) return false;
  87    db.query('DELETE FROM nostr_challenges WHERE challenge = ?').run(challenge);
  88    return row.created_at >= Date.now() - CHALLENGE_TTL_MS;
  89  }
  90  
  91  // verifies a NIP-07 signed auth event: kind 22242, a ["challenge", <challenge>]
  92  // tag matching a server-issued challenge, signed by `pubkey`. The event id is
  93  // recomputed server-side; the client-supplied id is ignored.
  94  export function verifyNostrAuthEvent(ev: any): string | null {
  95    try {
  96      if (!ev || ev.kind !== 22242) return null;
  97      if (typeof ev.pubkey !== 'string' || !/^[0-9a-f]{64}$/.test(ev.pubkey)) return null;
  98      if (typeof ev.sig !== 'string' || !/^[0-9a-f]{128}$/.test(ev.sig)) return null;
  99      if (!Array.isArray(ev.tags)) return null;
 100      const tag = ev.tags.find((t: any) => Array.isArray(t) && t[0] === 'challenge' && typeof t[1] === 'string');
 101      if (!tag) return null;
 102      if (Math.abs(Date.now() / 1000 - ev.created_at) > 600) return null;
 103      if (!consumeChallenge(tag[1])) return null;
 104      const serialized = JSON.stringify([0, ev.pubkey, ev.created_at, ev.kind, ev.tags, ev.content ?? '']);
 105      const id = crypto.createHash('sha256').update(new TextEncoder().encode(serialized)).digest();
 106      const { schnorr } = require('@noble/curves/secp256k1.js');
 107      const ok = schnorr.verify(Buffer.from(ev.sig, 'hex'), id, Buffer.from(ev.pubkey, 'hex'));
 108      return ok ? ev.pubkey : null;
 109    } catch {
 110      return null;
 111    }
 112  }
 113