group conversations with models and local files
1// WebAuthn auth + sessions.
2//
3// Two ceremonies: register (new account, first passkey) and authenticate
4// (login). Both pass the PRF extension input through to the browser so
5// the client can derive its identity key from the passkey PRF result.
6// The server never sees the PRF output.
7//
8// Trust model (prototype):
9// - First-ever signup (empty users table) becomes the founder.
10// - Otherwise registration requires a valid invite token.
11// - Authentication requires the email to match an existing user.
12
13import { createHash, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto'
14import {
15 generateRegistrationOptions,
16 verifyRegistrationResponse,
17 generateAuthenticationOptions,
18 verifyAuthenticationResponse,
19 type AuthenticationResponseJSON,
20 type RegistrationResponseJSON,
21 type PublicKeyCredentialCreationOptionsJSON,
22 type PublicKeyCredentialRequestOptionsJSON,
23} from '@simplewebauthn/server'
24import { db } from './db.js'
25import { emailHash } from './secret.js'
26import type { Identity, UserId } from '../src/shared/schema.js'
27
28export const RP_NAME = 'Hezo'
29export const RP_ID = process.env.HEZO_RP_ID ?? 'localhost'
30export const ORIGIN = process.env.HEZO_ORIGIN ?? 'http://localhost:5173'
31
32// The PRF input fed into the passkey during create() and get(). The
33// browser uses it as the `eval.first` value; the PRF output (32 bytes)
34// is then HKDF'd into an X25519 identity keypair on the client. Server
35// never sees the output.
36const PRF_INPUT_UTF8 = 'hezo.identity.v1'
37const PRF_INPUT = new TextEncoder().encode(PRF_INPUT_UTF8)
38// The wire format that PublicKeyCredentialCreationOptionsJSON expects for
39// binary fields. SimpleWebAuthn doesn't pre-encode PRF extension bytes
40// (it doesn't model the extension), so we encode manually and the client
41// decodes before passing to navigator.credentials.
42const PRF_INPUT_B64 = Buffer.from(PRF_INPUT).toString('base64url')
43
44const CHALLENGE_TTL_MS = 5 * 60 * 1000
45const SESSION_TTL_DAYS = 365
46
47export function randomToken(bytes = 32): string {
48 return randomBytes(bytes).toString('base64url')
49}
50
51// ---- session helpers ------------------------------------------------------
52
53export function createSession(userId: UserId): string {
54 const token = randomToken()
55 const now = Date.now()
56 db()
57 .prepare(
58 'INSERT INTO sessions (token, user_id, created_at, last_seen) VALUES (?, ?, ?, ?)',
59 )
60 .run(token, userId, now, now)
61 return token
62}
63
64export function getIdentityFromSession(
65 token: string | undefined,
66): Identity | null {
67 if (!token) return null
68 const row = db()
69 .prepare(
70 `SELECT u.id,
71 (u.passphrase_verifier IS NOT NULL) AS has_pass,
72 EXISTS (
73 SELECT 1 FROM webauthn_credentials c WHERE c.user_id = u.id
74 ) AS has_passkey
75 FROM sessions s JOIN users u ON u.id = s.user_id
76 WHERE s.token = ?`,
77 )
78 .get(token) as
79 | { id: string; has_pass: number; has_passkey: number }
80 | undefined
81 if (!row) return null
82 db()
83 .prepare('UPDATE sessions SET last_seen = ? WHERE token = ?')
84 .run(Date.now(), token)
85 return {
86 userId: row.id,
87 hasPassphrase: !!row.has_pass,
88 hasPasskey: !!row.has_passkey,
89 }
90}
91
92export function deleteSession(token: string): void {
93 db().prepare('DELETE FROM sessions WHERE token = ?').run(token)
94}
95
96// ---- challenge store ------------------------------------------------------
97
98function storeChallenge(
99 challenge: string,
100 ceremony: 'register' | 'authenticate',
101 opts: { userId?: string; emailHash?: Buffer } = {},
102): void {
103 const now = Date.now()
104 db()
105 .prepare(
106 `INSERT INTO webauthn_challenges (challenge, user_id, email_hash, ceremony, created_at, expires_at)
107 VALUES (?, ?, ?, ?, ?, ?)`,
108 )
109 .run(
110 challenge,
111 opts.userId ?? null,
112 opts.emailHash ?? null,
113 ceremony,
114 now,
115 now + CHALLENGE_TTL_MS,
116 )
117}
118
119function consumeChallenge(
120 challenge: string,
121 ceremony: 'register' | 'authenticate',
122): { userId: string | null; emailHash: Buffer | null } | null {
123 const row = db()
124 .prepare(
125 `SELECT user_id, email_hash, ceremony, expires_at FROM webauthn_challenges WHERE challenge = ?`,
126 )
127 .get(challenge) as
128 | {
129 user_id: string | null
130 email_hash: Buffer | null
131 ceremony: 'register' | 'authenticate'
132 expires_at: number
133 }
134 | undefined
135 if (!row) return null
136 // One-shot: delete on read whether or not it's valid.
137 db().prepare('DELETE FROM webauthn_challenges WHERE challenge = ?').run(challenge)
138 if (row.ceremony !== ceremony) return null
139 if (row.expires_at < Date.now()) return null
140 return {
141 userId: row.user_id,
142 emailHash: row.email_hash ? Buffer.from(row.email_hash) : null,
143 }
144}
145
146// ---- gating: who can register ---------------------------------------------
147
148export function userCountIsZero(): boolean {
149 const r = db().prepare('SELECT COUNT(*) AS n FROM users').get() as { n: number }
150 return r.n === 0
151}
152
153// Lookup is via HMAC blind index; the plaintext email never touches
154// the DB. Caller hands us whatever the user typed; we hash and query.
155export function findUserByEmail(email: string): Identity | null {
156 return findUserByEmailHash(emailHash(email))
157}
158
159export function findUserByEmailHash(hash: Buffer): Identity | null {
160 const r = db()
161 .prepare('SELECT id FROM users WHERE email_hash = ?')
162 .get(hash) as { id: string } | undefined
163 return r ? { userId: r.id } : null
164}
165
166// ---- registration ---------------------------------------------------------
167
168export async function makeRegisterOptions(
169 email: string,
170): Promise<PublicKeyCredentialCreationOptionsJSON> {
171 // Generate a fresh per-account user_id; SimpleWebAuthn wants Uint8Array.
172 const userId = new TextEncoder().encode(randomUUID())
173 // userDisplayName is purely presentational — shown by the OS
174 // passkey UI at create-time. Never stored server-side.
175 const displayName = email.split('@')[0]
176
177 const options = await generateRegistrationOptions({
178 rpName: RP_NAME,
179 rpID: RP_ID,
180 userName: email,
181 userID: userId,
182 userDisplayName: displayName,
183 attestationType: 'none',
184 authenticatorSelection: {
185 residentKey: 'preferred',
186 requireResidentKey: false,
187 userVerification: 'preferred',
188 },
189 })
190 // Inject the PRF extension input as base64url so the browser side can
191 // decode and pass it to navigator.credentials.create as a BufferSource.
192 // SimpleWebAuthn doesn't model PRF, so we tack it onto the JSON options
193 // we hand the client.
194 ;(options.extensions as Record<string, unknown>) = {
195 ...(options.extensions ?? {}),
196 prf: { eval: { first: PRF_INPUT_B64 } },
197 }
198
199 storeChallenge(options.challenge, 'register', { emailHash: emailHash(email) })
200 return options
201}
202
203export interface RegisterFinishInput {
204 response: RegistrationResponseJSON
205 identityPub: string // base64url X25519 pub
206 inviteToken?: string
207 // Passphrase backup is enrolled at signup so every account has both
208 // recovery paths from day one. Client derives a wrap key from the
209 // passphrase (Argon2id) and wraps the master (= PRF result) under
210 // it; server stores the opaque wrap + the verifier half + salt.
211 wrappedMaster: string // base64url
212 salt: string // base64url
213 verifier: string // base64url
214 argon2Params: Argon2Params
215}
216
217export interface RegisterFinishResult {
218 identity: Identity
219 sessionToken: string
220}
221
222export async function finishRegistration(
223 input: RegisterFinishInput,
224): Promise<RegisterFinishResult> {
225 const challenge = input.response.response.clientDataJSON
226 ? JSON.parse(
227 Buffer.from(input.response.response.clientDataJSON, 'base64url').toString(
228 'utf8',
229 ),
230 ).challenge
231 : ''
232 const stored = consumeChallenge(challenge, 'register')
233 if (!stored?.emailHash) throw new Error('unknown or expired challenge')
234
235 const eh = stored.emailHash
236 if (findUserByEmailHash(eh)) throw new Error('email already registered')
237
238 // Gating: first-ever signup is the founder; otherwise require invite.
239 if (!userCountIsZero()) {
240 if (!input.inviteToken) throw new Error('registration requires an invite token')
241 const inviteRow = db()
242 .prepare('SELECT project_id, used_at FROM invites WHERE token = ?')
243 .get(input.inviteToken) as
244 | { project_id: string; used_at: number | null }
245 | undefined
246 if (!inviteRow) throw new Error('invalid invite token')
247 if (inviteRow.used_at) throw new Error('invite already used')
248 }
249
250 const verification = await verifyRegistrationResponse({
251 response: input.response,
252 expectedChallenge: challenge,
253 expectedOrigin: ORIGIN,
254 expectedRPID: RP_ID,
255 requireUserVerification: false,
256 })
257 if (!verification.verified || !verification.registrationInfo) {
258 throw new Error('registration verification failed')
259 }
260 const { credential, credentialBackedUp, credentialDeviceType: _device } =
261 verification.registrationInfo
262
263 const userId = randomUUID()
264 const now = Date.now()
265
266 const wrappedMaster = Buffer.from(input.wrappedMaster, 'base64url')
267 const salt = Buffer.from(input.salt, 'base64url')
268 const verifierBytes = Buffer.from(input.verifier, 'base64url')
269 if (salt.length < 16) throw new Error('salt too short')
270 if (verifierBytes.length < 16) throw new Error('verifier too short')
271 if (wrappedMaster.length < 16) throw new Error('wrappedMaster too short')
272
273 db().transaction(() => {
274 db()
275 .prepare(
276 `INSERT INTO users
277 (id, email_hash, identity_pub, created_at,
278 wrapped_master, passphrase_salt, passphrase_verifier, argon2_params)
279 VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
280 )
281 .run(
282 userId,
283 eh,
284 input.identityPub,
285 now,
286 wrappedMaster,
287 salt,
288 verifierBytes,
289 JSON.stringify(input.argon2Params),
290 )
291 db()
292 .prepare(
293 `INSERT INTO webauthn_credentials
294 (credential_id, user_id, public_key, counter, transports,
295 backup_eligible, backup_state, created_at)
296 VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
297 )
298 .run(
299 credential.id,
300 userId,
301 Buffer.from(credential.publicKey),
302 credential.counter,
303 credential.transports ? JSON.stringify(credential.transports) : null,
304 credentialBackedUp ? 1 : 0,
305 credentialBackedUp ? 1 : 0,
306 now,
307 )
308 })()
309
310 const sessionToken = createSession(userId)
311 return {
312 // Brand-new accounts now enroll a passphrase at signup, so this
313 // is always true for fresh registrations. Passkey-first signups
314 // also have a passkey by definition.
315 identity: { userId, hasPassphrase: true, hasPasskey: true },
316 sessionToken,
317 }
318}
319
320// ---- authentication -------------------------------------------------------
321
322export async function makeAuthenticateOptions(
323 email: string,
324): Promise<PublicKeyCredentialRequestOptionsJSON> {
325 const user = findUserByEmail(email)
326 // Don't leak email existence: always issue options, even if user
327 // doesn't exist. The challenge will fail verification later.
328 let allowCredentials: Array<{
329 id: string
330 transports?: Array<
331 'usb' | 'nfc' | 'ble' | 'internal' | 'hybrid' | 'smart-card'
332 >
333 }> = []
334 if (user) {
335 const rows = db()
336 .prepare(
337 'SELECT credential_id, transports FROM webauthn_credentials WHERE user_id = ?',
338 )
339 .all(user.userId) as Array<{
340 credential_id: string
341 transports: string | null
342 }>
343 allowCredentials = rows.map((r) => ({
344 id: r.credential_id,
345 transports: r.transports
346 ? (JSON.parse(r.transports) as Array<
347 'usb' | 'nfc' | 'ble' | 'internal' | 'hybrid' | 'smart-card'
348 >)
349 : undefined,
350 }))
351 }
352
353 const options = await generateAuthenticationOptions({
354 rpID: RP_ID,
355 userVerification: 'preferred',
356 allowCredentials,
357 })
358 ;(options.extensions as Record<string, unknown>) = {
359 ...(options.extensions ?? {}),
360 prf: { eval: { first: PRF_INPUT_B64 } },
361 }
362
363 storeChallenge(options.challenge, 'authenticate', {
364 userId: user?.userId,
365 emailHash: emailHash(email),
366 })
367 return options
368}
369
370export interface AuthenticateFinishResult {
371 identity: Identity
372 sessionToken: string
373 // For users who enrolled their passkey AFTER account creation
374 // (passphrase-first flow), this is the master wrapped under the
375 // passkey's PRF result. Client unwraps it to recover the same
376 // master a passphrase login would yield. null for legacy /
377 // passkey-first signups where master = PRF result directly.
378 wrappedMasterPasskey: string | null
379}
380
381export async function finishAuthentication(
382 response: AuthenticationResponseJSON,
383): Promise<AuthenticateFinishResult> {
384 const challenge = JSON.parse(
385 Buffer.from(response.response.clientDataJSON, 'base64url').toString('utf8'),
386 ).challenge as string
387 const stored = consumeChallenge(challenge, 'authenticate')
388 if (!stored) throw new Error('unknown or expired challenge')
389 if (!stored.userId) throw new Error('no such account')
390
391 const credRow = db()
392 .prepare(
393 `SELECT credential_id, public_key, counter, transports,
394 wrapped_master_passkey
395 FROM webauthn_credentials
396 WHERE credential_id = ? AND user_id = ?`,
397 )
398 .get(response.id, stored.userId) as
399 | {
400 credential_id: string
401 public_key: Buffer
402 counter: number
403 transports: string | null
404 wrapped_master_passkey: Buffer | null
405 }
406 | undefined
407 if (!credRow) throw new Error('credential not registered')
408
409 const verification = await verifyAuthenticationResponse({
410 response,
411 expectedChallenge: challenge,
412 expectedOrigin: ORIGIN,
413 expectedRPID: RP_ID,
414 credential: {
415 id: credRow.credential_id,
416 publicKey: new Uint8Array(credRow.public_key),
417 counter: credRow.counter,
418 transports: credRow.transports
419 ? (JSON.parse(credRow.transports) as Array<
420 'usb' | 'nfc' | 'ble' | 'internal' | 'hybrid' | 'smart-card'
421 >)
422 : undefined,
423 },
424 requireUserVerification: false,
425 })
426 if (!verification.verified) throw new Error('authentication failed')
427
428 // Update counter to prevent cloning replay attacks.
429 db()
430 .prepare(
431 'UPDATE webauthn_credentials SET counter = ? WHERE credential_id = ?',
432 )
433 .run(verification.authenticationInfo.newCounter, credRow.credential_id)
434
435 const userRow = db()
436 .prepare(
437 `SELECT id, (passphrase_verifier IS NOT NULL) AS has_pass
438 FROM users WHERE id = ?`,
439 )
440 .get(stored.userId) as { id: string; has_pass: number } | undefined
441 if (!userRow) throw new Error('user vanished mid-auth')
442 const identity: Identity = {
443 userId: userRow.id,
444 hasPassphrase: !!userRow.has_pass,
445 // Anyone landing here just authenticated with a passkey, so they
446 // have one by definition.
447 hasPasskey: true,
448 }
449 // Per-credential wrap (each passkey has its own). Null for legacy
450 // creds that pre-date the wrapped-master scheme — client falls back
451 // to master = PRF directly in that case.
452 const wrappedMasterPasskey = credRow.wrapped_master_passkey
453 ? Buffer.from(credRow.wrapped_master_passkey).toString('base64url')
454 : null
455 const sessionToken = createSession(identity.userId)
456 return { identity, sessionToken, wrappedMasterPasskey }
457}
458
459// ---- passkey enrollment on an existing account ----------------------------
460//
461// Symmetric counterpart to passphraseSetup: lets a user who registered
462// via passphrase (or anyone who lost their passkey and wants to add a
463// new one) attach a passkey. The session cookie authorizes the request;
464// the client wraps its already-in-memory master under the new passkey's
465// PRF result and ships the ciphertext here.
466
467export async function makePasskeyEnrollOptions(
468 userId: UserId,
469 emailForUx: string,
470): Promise<PublicKeyCredentialCreationOptionsJSON> {
471 const userIdBytes = new TextEncoder().encode(userId)
472 // emailForUx is purely presentational — it's what the OS-level
473 // passkey UI shows the user. Never stored.
474 const displayName = emailForUx.split('@')[0] || userId.slice(0, 8)
475
476 const existing = db()
477 .prepare(
478 'SELECT credential_id, transports FROM webauthn_credentials WHERE user_id = ?',
479 )
480 .all(userId) as Array<{ credential_id: string; transports: string | null }>
481
482 const options = await generateRegistrationOptions({
483 rpName: RP_NAME,
484 rpID: RP_ID,
485 userName: emailForUx,
486 userID: userIdBytes,
487 userDisplayName: displayName,
488 attestationType: 'none',
489 authenticatorSelection: {
490 residentKey: 'preferred',
491 requireResidentKey: false,
492 userVerification: 'preferred',
493 },
494 excludeCredentials: existing.map((c) => ({
495 id: c.credential_id,
496 transports: c.transports
497 ? (JSON.parse(c.transports) as Array<
498 'usb' | 'nfc' | 'ble' | 'internal' | 'hybrid' | 'smart-card'
499 >)
500 : undefined,
501 })),
502 })
503 ;(options.extensions as Record<string, unknown>) = {
504 ...(options.extensions ?? {}),
505 prf: { eval: { first: PRF_INPUT_B64 } },
506 }
507
508 storeChallenge(options.challenge, 'register', { userId })
509 return options
510}
511
512export interface PasskeyEnrollFinishInput {
513 response: RegistrationResponseJSON
514 // Existing master wrapped under the new passkey's PRF result.
515 // Required: enrollment is pointless without it (login would have no
516 // way to recover master from the new passkey).
517 wrappedMasterPasskey: string // base64url
518}
519
520export async function finishPasskeyEnroll(
521 userId: UserId,
522 input: PasskeyEnrollFinishInput,
523): Promise<void> {
524 const challenge = JSON.parse(
525 Buffer.from(input.response.response.clientDataJSON, 'base64url').toString(
526 'utf8',
527 ),
528 ).challenge as string
529 const stored = consumeChallenge(challenge, 'register')
530 if (!stored?.userId) throw new Error('unknown or expired challenge')
531 if (stored.userId !== userId) throw new Error('challenge does not belong to current session')
532
533 const verification = await verifyRegistrationResponse({
534 response: input.response,
535 expectedChallenge: challenge,
536 expectedOrigin: ORIGIN,
537 expectedRPID: RP_ID,
538 requireUserVerification: false,
539 })
540 if (!verification.verified || !verification.registrationInfo) {
541 throw new Error('passkey enrollment verification failed')
542 }
543 const { credential, credentialBackedUp } = verification.registrationInfo
544
545 const wrappedBytes = Buffer.from(input.wrappedMasterPasskey, 'base64url')
546 if (wrappedBytes.length < 16) {
547 throw new Error('wrappedMasterPasskey too short')
548 }
549 const now = Date.now()
550
551 db()
552 .prepare(
553 `INSERT INTO webauthn_credentials
554 (credential_id, user_id, public_key, counter, transports,
555 backup_eligible, backup_state, created_at, wrapped_master_passkey)
556 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
557 )
558 .run(
559 credential.id,
560 userId,
561 Buffer.from(credential.publicKey),
562 credential.counter,
563 credential.transports ? JSON.stringify(credential.transports) : null,
564 credentialBackedUp ? 1 : 0,
565 credentialBackedUp ? 1 : 0,
566 now,
567 wrappedBytes,
568 )
569}
570
571// ---- passphrase fallback --------------------------------------------------
572//
573// For browsers / authenticators that can't do PRF (notably iOS via
574// third-party Credential Providers). The client derives a wrap key +
575// verifier from the passphrase via Argon2id. We store only the
576// verifier + salt + wrapped master here; the wrap key never leaves
577// the browser. Brute-forcing the wrap key from the stored verifier
578// requires re-running Argon2id over candidate passphrases.
579
580export interface Argon2Params {
581 m: number // memory cost in KiB
582 t: number // iterations
583 p: number // parallelism
584}
585
586// Defaults the client should use. Tunable; bumping these for new
587// signups doesn't break existing accounts because each user stores
588// the params they were enrolled with.
589export const ARGON2_DEFAULT: Argon2Params = { m: 32 * 1024, t: 2, p: 1 }
590
591interface PassphraseRow {
592 user_id: string
593 identity_pub: string
594 wrapped_master: Buffer | null
595 passphrase_salt: Buffer | null
596 passphrase_verifier: Buffer | null
597 argon2_params: string | null
598 has_passkey: number
599}
600
601function findPassphraseRow(email: string): PassphraseRow | null {
602 const row = db()
603 .prepare(
604 `SELECT id AS user_id, identity_pub,
605 wrapped_master, passphrase_salt, passphrase_verifier, argon2_params,
606 EXISTS (
607 SELECT 1 FROM webauthn_credentials c WHERE c.user_id = users.id
608 ) AS has_passkey
609 FROM users WHERE email_hash = ?`,
610 )
611 .get(emailHash(email)) as PassphraseRow | undefined
612 return row ?? null
613}
614
615// Used to give back a stable-looking dummy salt for unknown emails so
616// timing doesn't trivially reveal account existence. Real attackers
617// can still enumerate via other side channels — this is a basic
618// courtesy, not a strong defense.
619function dummySaltFor(email: string): Buffer {
620 // HMAC(SHA256, deployment-secret, email) would be ideal; for the
621 // prototype just deterministically hash the email so the same
622 // unknown email gets the same dummy reply.
623 return createHash('sha256').update(email).digest().subarray(0, 16)
624}
625
626export interface PassphraseLoginBeginResult {
627 salt: string // base64url
628 argon2Params: Argon2Params
629}
630
631export function passphraseLoginBegin(email: string): PassphraseLoginBeginResult {
632 const row = findPassphraseRow(email)
633 if (row?.passphrase_salt && row.argon2_params) {
634 return {
635 salt: Buffer.from(row.passphrase_salt).toString('base64url'),
636 argon2Params: JSON.parse(row.argon2_params) as Argon2Params,
637 }
638 }
639 return {
640 salt: dummySaltFor(email).toString('base64url'),
641 argon2Params: ARGON2_DEFAULT,
642 }
643}
644
645export interface PassphraseLoginFinishResult {
646 identity: Identity
647 sessionToken: string
648 identityPub: string
649 wrappedMaster: string // base64url
650}
651
652export function passphraseLoginFinish(
653 email: string,
654 verifierB64: string,
655): PassphraseLoginFinishResult {
656 const row = findPassphraseRow(email)
657 if (!row || !row.passphrase_verifier || !row.wrapped_master) {
658 throw new Error('passphrase login not enabled for this account')
659 }
660 const supplied = Buffer.from(verifierB64, 'base64url')
661 const expected = Buffer.from(row.passphrase_verifier)
662 if (supplied.length !== expected.length) {
663 throw new Error('invalid passphrase')
664 }
665 if (!timingSafeEqual(supplied, expected)) {
666 throw new Error('invalid passphrase')
667 }
668 // Passphrase login implies hasPassphrase by definition.
669 const identity: Identity = {
670 userId: row.user_id,
671 hasPassphrase: true,
672 hasPasskey: !!row.has_passkey,
673 }
674 const sessionToken = createSession(identity.userId)
675 return {
676 identity,
677 sessionToken,
678 identityPub: row.identity_pub,
679 wrappedMaster: Buffer.from(row.wrapped_master).toString('base64url'),
680 }
681}
682
683export interface PassphraseSetupInput {
684 wrappedMaster: string // base64url
685 salt: string // base64url
686 verifier: string // base64url
687 argon2Params: Argon2Params
688}
689
690export function passphraseSetup(
691 userId: string,
692 input: PassphraseSetupInput,
693): void {
694 const wrappedBytes = Buffer.from(input.wrappedMaster, 'base64url')
695 const saltBytes = Buffer.from(input.salt, 'base64url')
696 const verifierBytes = Buffer.from(input.verifier, 'base64url')
697 if (saltBytes.length < 16) throw new Error('salt too short')
698 if (verifierBytes.length < 16) throw new Error('verifier too short')
699 if (wrappedBytes.length < 16) throw new Error('wrappedMaster too short')
700 db()
701 .prepare(
702 `UPDATE users
703 SET wrapped_master = ?, passphrase_salt = ?, passphrase_verifier = ?,
704 argon2_params = ?
705 WHERE id = ?`,
706 )
707 .run(
708 wrappedBytes,
709 saltBytes,
710 verifierBytes,
711 JSON.stringify(input.argon2Params),
712 userId,
713 )
714}
715
716export interface PassphraseRegisterInput extends PassphraseSetupInput {
717 email: string
718 identityPub: string
719 inviteToken?: string
720}
721
722export interface PassphraseRegisterResult {
723 identity: Identity
724 sessionToken: string
725}
726
727export function passphraseRegister(
728 input: PassphraseRegisterInput,
729): PassphraseRegisterResult {
730 const email = input.email.trim().toLowerCase()
731 if (!email.includes('@')) throw new Error('invalid email')
732 if (findUserByEmail(email)) throw new Error('email already registered')
733 // Dev escape hatch: the well-known dev email may register without an
734 // invite in non-production environments, so a local UI iteration
735 // session can self-bootstrap an account. Production unaffected.
736 const isDevBypass =
737 process.env.NODE_ENV !== 'production' && email === 'dev@hezo.local'
738 if (!isDevBypass && !userCountIsZero()) {
739 if (!input.inviteToken) throw new Error('registration requires an invite token')
740 const inviteRow = db()
741 .prepare('SELECT project_id, used_at FROM invites WHERE token = ?')
742 .get(input.inviteToken) as
743 | { project_id: string; used_at: number | null }
744 | undefined
745 if (!inviteRow) throw new Error('invalid invite token')
746 if (inviteRow.used_at) throw new Error('invite already used')
747 }
748
749 const userId = randomUUID()
750 const now = Date.now()
751
752 db()
753 .prepare(
754 `INSERT INTO users (id, email_hash, identity_pub, created_at,
755 wrapped_master, passphrase_salt, passphrase_verifier, argon2_params)
756 VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
757 )
758 .run(
759 userId,
760 emailHash(email),
761 input.identityPub,
762 now,
763 Buffer.from(input.wrappedMaster, 'base64url'),
764 Buffer.from(input.salt, 'base64url'),
765 Buffer.from(input.verifier, 'base64url'),
766 JSON.stringify(input.argon2Params),
767 )
768
769 const sessionToken = createSession(userId)
770 return {
771 // Passphrase registration enrolls a passphrase by definition; the
772 // account starts without a passkey (user can add one via /enroll).
773 identity: { userId, hasPassphrase: true, hasPasskey: false },
774 sessionToken,
775 }
776}
777
778// ---- provider key sync ----------------------------------------------------
779
780export function getWrappedProviderKeys(userId: UserId): string | null {
781 const row = db()
782 .prepare('SELECT wrapped_provider_keys FROM users WHERE id = ?')
783 .get(userId) as { wrapped_provider_keys: Buffer | null } | undefined
784 if (!row?.wrapped_provider_keys) return null
785 return Buffer.from(row.wrapped_provider_keys).toString('base64url')
786}
787
788export function setWrappedProviderKeys(
789 userId: UserId,
790 wrappedB64: string,
791): void {
792 const bytes = Buffer.from(wrappedB64, 'base64url')
793 if (bytes.length === 0) {
794 db()
795 .prepare('UPDATE users SET wrapped_provider_keys = NULL WHERE id = ?')
796 .run(userId)
797 return
798 }
799 db()
800 .prepare('UPDATE users SET wrapped_provider_keys = ? WHERE id = ?')
801 .run(bytes, userId)
802}
803
804// ---- user-bot recipe sync -------------------------------------------------
805
806// Opaque, server-unreadable blob of the user's bot-recipe library.
807// Mirrors the provider-key sync above: encrypted client-side with a
808// master-derived key, stored only so the recipes follow the user to
809// their other devices.
810export function getWrappedUserBots(userId: UserId): string | null {
811 const row = db()
812 .prepare('SELECT wrapped_user_bots FROM users WHERE id = ?')
813 .get(userId) as { wrapped_user_bots: Buffer | null } | undefined
814 if (!row?.wrapped_user_bots) return null
815 return Buffer.from(row.wrapped_user_bots).toString('base64url')
816}
817
818export function setWrappedUserBots(userId: UserId, wrappedB64: string): void {
819 const bytes = Buffer.from(wrappedB64, 'base64url')
820 if (bytes.length === 0) {
821 db()
822 .prepare('UPDATE users SET wrapped_user_bots = NULL WHERE id = ?')
823 .run(userId)
824 return
825 }
826 db()
827 .prepare('UPDATE users SET wrapped_user_bots = ? WHERE id = ?')
828 .run(bytes, userId)
829}