mfa.ts234 lines · main
1/**
2 * briven-engine MFA (TOTP) on Doltgres only.
3 * Pure HMAC-SHA1 TOTP (RFC 6238) — no stock Postgres, no SuperTokens Core.
4 */
5
6import { createHmac, randomBytes } from 'node:crypto';
7
8import { newId } from '@briven/shared';
9
10import { getEnginePool } from './db.js';
11import { isAuthCoreInitialized } from './engine.js';
12import { projectIdToTenantId } from './project-map.js';
13
14const BASE32 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
15
16function toBase32(buf: Buffer): string {
17 let bits = 0;
18 let value = 0;
19 let out = '';
20 for (const byte of buf) {
21 value = (value << 8) | byte;
22 bits += 8;
23 while (bits >= 5) {
24 out += BASE32[(value >>> (bits - 5)) & 31];
25 bits -= 5;
26 }
27 }
28 if (bits > 0) out += BASE32[(value << (5 - bits)) & 31];
29 return out;
30}
31
32function fromBase32(s: string): Buffer {
33 const clean = s.replace(/=+$/, '').toUpperCase();
34 let bits = 0;
35 let value = 0;
36 const out: number[] = [];
37 for (const ch of clean) {
38 const idx = BASE32.indexOf(ch);
39 if (idx < 0) continue;
40 value = (value << 5) | idx;
41 bits += 5;
42 if (bits >= 8) {
43 out.push((value >>> (bits - 8)) & 255);
44 bits -= 8;
45 }
46 }
47 return Buffer.from(out);
48}
49
50function hotp(secret: Buffer, counter: bigint): string {
51 const buf = Buffer.alloc(8);
52 buf.writeBigUInt64BE(counter);
53 const hmac = createHmac('sha1', secret).update(buf).digest();
54 const offset = hmac[hmac.length - 1]! & 0xf;
55 const code =
56 ((hmac[offset]! & 0x7f) << 24) |
57 ((hmac[offset + 1]! & 0xff) << 16) |
58 ((hmac[offset + 2]! & 0xff) << 8) |
59 (hmac[offset + 3]! & 0xff);
60 return String(code % 1_000_000).padStart(6, '0');
61}
62
63export function generateTotpCode(secretBase32: string, atMs = Date.now()): string {
64 const secret = fromBase32(secretBase32);
65 const counter = BigInt(Math.floor(atMs / 1000 / 30));
66 return hotp(secret, counter);
67}
68
69export function verifyTotpCode(
70 secretBase32: string,
71 code: string,
72 window = 1,
73 atMs = Date.now(),
74): boolean {
75 const normalized = code.replace(/\s/g, '');
76 if (!/^\d{6}$/.test(normalized)) return false;
77 for (let w = -window; w <= window; w++) {
78 const t = atMs + w * 30_000;
79 if (generateTotpCode(secretBase32, t) === normalized) return true;
80 }
81 return false;
82}
83
84export async function createTotpDevice(
85 userId: string,
86 deviceName: string,
87 opts?: { projectId?: string; tenantId?: string; issuer?: string },
88): Promise<{
89 ok: boolean;
90 engine: 'briven-engine';
91 storage?: 'doltgres';
92 deviceId?: string;
93 deviceName?: string;
94 secret?: string;
95 otpauthUrl?: string;
96 qrCodeString?: string;
97 message?: string;
98}> {
99 if (!isAuthCoreInitialized()) {
100 return { ok: false, engine: 'briven-engine', message: 'engine not ready' };
101 }
102 const tenantId =
103 opts?.tenantId ??
104 (opts?.projectId ? projectIdToTenantId(opts.projectId) : 'public');
105 const secret = toBase32(randomBytes(20));
106 const id = newId('btd');
107 const name = deviceName.trim() || 'default';
108 const pool = getEnginePool();
109 await pool.query(
110 `INSERT INTO be_totp_devices (id, user_id, tenant_id, device_name, secret_base32, verified)
111 VALUES ($1, $2, $3, $4, $5, FALSE)`,
112 [id, userId, tenantId, name, secret],
113 );
114 const issuer = encodeURIComponent(opts?.issuer ?? 'Briven Auth');
115 const label = encodeURIComponent(`${issuer}:${userId}`);
116 const otpauthUrl = `otpauth://totp/${label}?secret=${secret}&issuer=${issuer}&algorithm=SHA1&digits=6&period=30`;
117 return {
118 ok: true,
119 engine: 'briven-engine',
120 storage: 'doltgres',
121 deviceId: id,
122 deviceName: name,
123 secret,
124 otpauthUrl,
125 qrCodeString: otpauthUrl,
126 };
127}
128
129export async function verifyAndEnableTotpDevice(input: {
130 userId: string;
131 deviceId?: string;
132 deviceName?: string;
133 code: string;
134}): Promise<{ ok: boolean; engine: 'briven-engine'; message?: string }> {
135 if (!isAuthCoreInitialized()) {
136 return { ok: false, engine: 'briven-engine', message: 'engine not ready' };
137 }
138 const pool = getEnginePool();
139 let row: { id: string; secret_base32: string } | undefined;
140 if (input.deviceId) {
141 const res = await pool.query(
142 `SELECT id, secret_base32 FROM be_totp_devices WHERE id = $1 AND user_id = $2 LIMIT 1`,
143 [input.deviceId, input.userId],
144 );
145 row = res.rows[0] as typeof row;
146 } else if (input.deviceName) {
147 const res = await pool.query(
148 `SELECT id, secret_base32 FROM be_totp_devices
149 WHERE user_id = $1 AND device_name = $2 ORDER BY created_at DESC LIMIT 1`,
150 [input.userId, input.deviceName],
151 );
152 row = res.rows[0] as typeof row;
153 }
154 if (!row) {
155 return { ok: false, engine: 'briven-engine', message: 'device not found' };
156 }
157 if (!verifyTotpCode(row.secret_base32, input.code)) {
158 return { ok: false, engine: 'briven-engine', message: 'invalid code' };
159 }
160 await pool.query(
161 `UPDATE be_totp_devices SET verified = TRUE WHERE id = $1`,
162 [row.id],
163 );
164 return { ok: true, engine: 'briven-engine' };
165}
166
167export async function verifyUserTotp(
168 userId: string,
169 code: string,
170): Promise<{ ok: boolean; engine: 'briven-engine' }> {
171 if (!isAuthCoreInitialized()) return { ok: false, engine: 'briven-engine' };
172 const pool = getEnginePool();
173 const res = await pool.query(
174 `SELECT secret_base32 FROM be_totp_devices WHERE user_id = $1 AND verified = TRUE`,
175 [userId],
176 );
177 for (const row of res.rows as Array<{ secret_base32: string }>) {
178 if (verifyTotpCode(row.secret_base32, code)) {
179 return { ok: true, engine: 'briven-engine' };
180 }
181 }
182 return { ok: false, engine: 'briven-engine' };
183}
184
185/** True if user has at least one verified TOTP device. */
186export async function userHasVerifiedTotp(userId: string): Promise<boolean> {
187 if (!isAuthCoreInitialized()) return false;
188 const pool = getEnginePool();
189 const res = await pool.query(
190 `SELECT 1 AS ok FROM be_totp_devices
191 WHERE user_id = $1 AND verified = TRUE LIMIT 1`,
192 [userId],
193 );
194 return Boolean(res.rowCount && res.rowCount > 0);
195}
196
197export async function listTotpDevices(userId: string): Promise<{
198 engine: 'briven-engine';
199 storage: 'doltgres';
200 devices: Array<{ id: string; name: string; verified: boolean }>;
201}> {
202 if (!isAuthCoreInitialized()) {
203 return { engine: 'briven-engine', storage: 'doltgres', devices: [] };
204 }
205 const pool = getEnginePool();
206 const res = await pool.query(
207 `SELECT id, device_name, verified FROM be_totp_devices WHERE user_id = $1 ORDER BY created_at`,
208 [userId],
209 );
210 return {
211 engine: 'briven-engine',
212 storage: 'doltgres',
213 devices: (res.rows as Array<{ id: string; device_name: string; verified: boolean }>).map(
214 (d) => ({
215 id: d.id,
216 name: d.device_name,
217 verified: d.verified,
218 }),
219 ),
220 };
221}
222
223export async function removeTotpDevice(
224 userId: string,
225 deviceNameOrId: string,
226): Promise<{ ok: boolean; engine: 'briven-engine' }> {
227 if (!isAuthCoreInitialized()) return { ok: false, engine: 'briven-engine' };
228 const pool = getEnginePool();
229 const res = await pool.query(
230 `DELETE FROM be_totp_devices WHERE user_id = $1 AND (id = $2 OR device_name = $2)`,
231 [userId, deviceNameOrId],
232 );
233 return { ok: (res.rowCount ?? 0) > 0, engine: 'briven-engine' };
234}