group conversations with models and local files
0

Configure Feed

Select the types of activity you want to include in your feed.

hezo / server / auth.ts
28 kB 829 lines
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}