@ursalock/server 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -2,12 +2,14 @@
2
2
  import { Hono as Hono5 } from "hono";
3
3
  import { cors } from "hono/cors";
4
4
  import { logger } from "hono/logger";
5
+ import { bodyLimit } from "hono/body-limit";
6
+ import { secureHeaders } from "hono/secure-headers";
5
7
  import { ZodError } from "zod";
6
8
 
7
9
  // src/api/auth/router.ts
8
- import { createHash as createHash2, randomBytes } from "crypto";
9
10
  import { Hono as Hono3 } from "hono";
10
11
  import { zValidator as zValidator3 } from "@hono/zod-validator";
12
+ import bcrypt from "bcryptjs";
11
13
 
12
14
  // src/db/client.ts
13
15
  import Database from "better-sqlite3";
@@ -20,7 +22,7 @@ var envSchema = {
20
22
  PORT: numeric.default("3456"),
21
23
  /** SQLite database file path */
22
24
  DATABASE_PATH: z.string().default("./data/vault.db"),
23
- /** JWT secret for signing tokens (32+ bytes recommended) */
25
+ /** JWT secret for signing tokens (32+ chars required). Comma-separated for rotation. */
24
26
  JWT_SECRET: z.string().min(32),
25
27
  /** JWT token expiry in seconds (default: 7 days) */
26
28
  JWT_EXPIRY: numeric.default("604800"),
@@ -31,11 +33,41 @@ var envSchema = {
31
33
  * e.g., "https://app1.example.com,https://app2.example.com"
32
34
  * The RP_ID is derived from the hostname of the validated origin
33
35
  */
34
- RP_ORIGINS: z.string().default("http://localhost:5173")
36
+ RP_ORIGINS: z.string().default("http://localhost:5173").refine(
37
+ (val) => {
38
+ const origins = val.split(",").map((s) => s.trim()).filter(Boolean);
39
+ return origins.every((o) => {
40
+ try {
41
+ const url = new URL(o);
42
+ return url.protocol === "https:" || url.protocol === "http:";
43
+ } catch {
44
+ return false;
45
+ }
46
+ });
47
+ },
48
+ { message: "RP_ORIGINS must be valid URLs (http or https)" }
49
+ ).refine(
50
+ (val) => {
51
+ if (process.env["NODE_ENV"] !== "production") return true;
52
+ const origins = val.split(",").map((s) => s.trim()).filter(Boolean);
53
+ return origins.every((o) => new URL(o).protocol === "https:");
54
+ },
55
+ { message: "RP_ORIGINS must use HTTPS in production" }
56
+ )
35
57
  };
36
- var skipEnvValidation = process.env["NODE_ENV"] === "test";
58
+ var TEST_JWT_SECRET = "ursalock-test-secret-DO-NOT-USE-IN-PRODUCTION-x9k2m";
59
+ var isTestEnv = process.env["NODE_ENV"] === "test";
37
60
  var env = (() => {
38
- if (skipEnvValidation) {
61
+ if (isTestEnv && !process.env["JWT_SECRET"]) {
62
+ process.env["JWT_SECRET"] = TEST_JWT_SECRET;
63
+ }
64
+ const jwtSecret = process.env["JWT_SECRET"];
65
+ if (!jwtSecret || jwtSecret.length < 32) {
66
+ throw new Error(
67
+ `JWT_SECRET must be at least 32 characters (got ${jwtSecret?.length ?? 0}). Set a strong secret in your environment.`
68
+ );
69
+ }
70
+ if (isTestEnv) {
39
71
  return Object.fromEntries(
40
72
  Object.entries(envSchema).map(([key, schema]) => {
41
73
  const result = schema.safeParse(process.env[key]);
@@ -51,6 +83,9 @@ ${errors2}`);
51
83
  }
52
84
  return parsed.data;
53
85
  })();
86
+ function getAllowedOrigins() {
87
+ return env.RP_ORIGINS.split(",").map((s) => s.trim()).filter(Boolean);
88
+ }
54
89
 
55
90
  // src/db/schema.ts
56
91
  var CREATE_TABLES_SQL = `
@@ -123,6 +158,7 @@ function getDb() {
123
158
  _db = new Database(env.DATABASE_PATH);
124
159
  _db.pragma("journal_mode = WAL");
125
160
  _db.pragma("foreign_keys = ON");
161
+ _db.pragma("secure_delete = ON");
126
162
  _db.exec(CREATE_TABLES_SQL);
127
163
  runMigrations(_db);
128
164
  }
@@ -148,14 +184,31 @@ function closeDb() {
148
184
  _db = null;
149
185
  }
150
186
  }
187
+ var USER_COLUMNS = `id, uid, email, password_hash as passwordHash,
188
+ opaque_id as opaqueId, display_name as displayName,
189
+ created_at as createdAt, updated_at as updatedAt`;
190
+ var USER_JOIN_COLUMNS = `u.id as "user.id", u.uid as "user.uid", u.email as "user.email",
191
+ u.password_hash as "user.passwordHash", u.opaque_id as "user.opaqueId",
192
+ u.display_name as "user.displayName", u.created_at as "user.createdAt",
193
+ u.updated_at as "user.updatedAt"`;
194
+ function userFromRow(row) {
195
+ return {
196
+ id: row["user.id"],
197
+ uid: row["user.uid"],
198
+ email: row["user.email"] ?? null,
199
+ passwordHash: row["user.passwordHash"] ?? null,
200
+ opaqueId: row["user.opaqueId"] ?? null,
201
+ displayName: row["user.displayName"] ?? null,
202
+ createdAt: row["user.createdAt"],
203
+ updatedAt: row["user.updatedAt"]
204
+ };
205
+ }
151
206
  function createUser(input) {
152
207
  const db = getDb();
153
208
  const stmt = db.prepare(`
154
209
  INSERT INTO users (email, password_hash, opaque_id, display_name)
155
210
  VALUES (?, ?, ?, ?)
156
- RETURNING id, uid, email, password_hash as passwordHash,
157
- opaque_id as opaqueId, display_name as displayName,
158
- created_at as createdAt, updated_at as updatedAt
211
+ RETURNING ${USER_COLUMNS}
159
212
  `);
160
213
  return stmt.get(
161
214
  input.email ?? null,
@@ -166,10 +219,9 @@ function createUser(input) {
166
219
  }
167
220
  function getUserByEmail(email) {
168
221
  const db = getDb();
222
+ email = email.toLowerCase().trim();
169
223
  const stmt = db.prepare(`
170
- SELECT id, uid, email, password_hash as passwordHash,
171
- opaque_id as opaqueId, display_name as displayName,
172
- created_at as createdAt, updated_at as updatedAt
224
+ SELECT ${USER_COLUMNS}
173
225
  FROM users WHERE email = ?
174
226
  `);
175
227
  return stmt.get(email);
@@ -177,9 +229,7 @@ function getUserByEmail(email) {
177
229
  function getUserByOpaqueId(opaqueId) {
178
230
  const db = getDb();
179
231
  const stmt = db.prepare(`
180
- SELECT id, uid, email, password_hash as passwordHash,
181
- opaque_id as opaqueId, display_name as displayName,
182
- created_at as createdAt, updated_at as updatedAt
232
+ SELECT ${USER_COLUMNS}
183
233
  FROM users WHERE opaque_id = ?
184
234
  `);
185
235
  return stmt.get(opaqueId);
@@ -209,7 +259,7 @@ function getPasskeyByCredentialId(credentialId) {
209
259
  SELECT
210
260
  p.id, p.user_id as userId, p.credential_id as credentialId, p.public_key as publicKey,
211
261
  p.counter, p.device_type as deviceType, p.backed_up as backedUp, p.transports, p.created_at as createdAt,
212
- u.id as "user.id", u.uid as "user.uid", u.email as "user.email"
262
+ ${USER_JOIN_COLUMNS}
213
263
  FROM passkeys p
214
264
  JOIN users u ON p.user_id = u.id
215
265
  WHERE p.credential_id = ?
@@ -226,16 +276,7 @@ function getPasskeyByCredentialId(credentialId) {
226
276
  backedUp: Boolean(row["backedUp"]),
227
277
  transports: row["transports"],
228
278
  createdAt: row["createdAt"],
229
- user: {
230
- id: row["user.id"],
231
- uid: row["user.uid"],
232
- email: row["user.email"],
233
- passwordHash: null,
234
- opaqueId: null,
235
- displayName: null,
236
- createdAt: 0,
237
- updatedAt: 0
238
- }
279
+ user: userFromRow(row)
239
280
  };
240
281
  }
241
282
  function getPasskeysByUserId(userId) {
@@ -252,8 +293,24 @@ function updatePasskeyCounter(credentialId, counter) {
252
293
  const stmt = db.prepare(`UPDATE passkeys SET counter = ? WHERE credential_id = ?`);
253
294
  stmt.run(counter, credentialId);
254
295
  }
296
+ var MAX_SESSIONS = 10;
255
297
  function createSession(input) {
256
298
  const db = getDb();
299
+ const countStmt = db.prepare(
300
+ `SELECT COUNT(*) as cnt FROM sessions WHERE user_id = ? AND expires_at > unixepoch()`
301
+ );
302
+ const { cnt } = countStmt.get(input.userId);
303
+ if (cnt >= MAX_SESSIONS) {
304
+ const deleteOldest = db.prepare(`
305
+ DELETE FROM sessions WHERE id IN (
306
+ SELECT id FROM sessions
307
+ WHERE user_id = ? AND expires_at > unixepoch()
308
+ ORDER BY created_at ASC
309
+ LIMIT ?
310
+ )
311
+ `);
312
+ deleteOldest.run(input.userId, cnt - MAX_SESSIONS + 1);
313
+ }
257
314
  const stmt = db.prepare(`
258
315
  INSERT INTO sessions (user_id, token_hash, expires_at)
259
316
  VALUES (?, ?, ?)
@@ -266,7 +323,7 @@ function getSessionByTokenHash(tokenHash) {
266
323
  const stmt = db.prepare(`
267
324
  SELECT
268
325
  s.id, s.user_id as userId, s.token_hash as tokenHash, s.expires_at as expiresAt, s.created_at as createdAt,
269
- u.id as "user.id", u.uid as "user.uid", u.email as "user.email"
326
+ ${USER_JOIN_COLUMNS}
270
327
  FROM sessions s
271
328
  JOIN users u ON s.user_id = u.id
272
329
  WHERE s.token_hash = ? AND s.expires_at > unixepoch()
@@ -279,16 +336,7 @@ function getSessionByTokenHash(tokenHash) {
279
336
  tokenHash: row["tokenHash"],
280
337
  expiresAt: row["expiresAt"],
281
338
  createdAt: row["createdAt"],
282
- user: {
283
- id: row["user.id"],
284
- uid: row["user.uid"],
285
- email: row["user.email"],
286
- passwordHash: null,
287
- opaqueId: null,
288
- displayName: null,
289
- createdAt: 0,
290
- updatedAt: 0
291
- }
339
+ user: userFromRow(row)
292
340
  };
293
341
  }
294
342
  function deleteSession(tokenHash) {
@@ -332,12 +380,20 @@ function getVaultsByUserId(userId) {
332
380
  }
333
381
  function updateVault(uid, userId, input) {
334
382
  const db = getDb();
383
+ if (input.version != null) {
384
+ const stmt2 = db.prepare(`
385
+ UPDATE vaults SET data = ?, salt = ?, version = ? + 1, updated_at = unixepoch()
386
+ WHERE uid = ? AND user_id = ? AND version = ?
387
+ RETURNING id, uid, user_id as userId, name, data, salt, version, created_at as createdAt, updated_at as updatedAt
388
+ `);
389
+ return stmt2.get(input.data, input.salt, input.version, uid, userId, input.version);
390
+ }
335
391
  const stmt = db.prepare(`
336
- UPDATE vaults SET data = ?, salt = ?, version = COALESCE(?, version), updated_at = unixepoch()
392
+ UPDATE vaults SET data = ?, salt = ?, version = version + 1, updated_at = unixepoch()
337
393
  WHERE uid = ? AND user_id = ?
338
394
  RETURNING id, uid, user_id as userId, name, data, salt, version, created_at as createdAt, updated_at as updatedAt
339
395
  `);
340
- return stmt.get(input.data, input.salt, input.version ?? null, uid, userId);
396
+ return stmt.get(input.data, input.salt, uid, userId);
341
397
  }
342
398
  function deleteVault(uid, userId) {
343
399
  const db = getDb();
@@ -348,24 +404,43 @@ function deleteVault(uid, userId) {
348
404
  // src/features/auth/jwt.ts
349
405
  import { createHash } from "crypto";
350
406
  import { SignJWT, jwtVerify } from "jose";
351
- function getSecretKey() {
352
- return new TextEncoder().encode(env.JWT_SECRET);
407
+ function getSecretKeys() {
408
+ if (!env.JWT_SECRET) {
409
+ throw new Error("JWT_SECRET is not set. Refusing to sign/verify tokens with an empty secret.");
410
+ }
411
+ const secrets = env.JWT_SECRET.split(",").map((s) => s.trim()).filter(Boolean);
412
+ if (secrets.length === 0) {
413
+ throw new Error("JWT_SECRET is empty after parsing. Refusing to sign/verify tokens.");
414
+ }
415
+ return secrets.map((s) => new TextEncoder().encode(s));
416
+ }
417
+ function getCurrentSecretKey() {
418
+ return getSecretKeys()[0];
353
419
  }
354
420
  async function createToken(payload) {
355
421
  const jti = crypto.randomUUID();
356
422
  const jwt = new SignJWT({ email: payload.email }).setSubject(payload.userId).setJti(jti).setIssuedAt().setExpirationTime(`${env.JWT_EXPIRY}s`).setProtectedHeader({ alg: "HS256" });
357
- return jwt.sign(getSecretKey());
423
+ return jwt.sign(getCurrentSecretKey());
358
424
  }
359
425
  async function verifyToken(token) {
360
- try {
361
- const { payload } = await jwtVerify(token, getSecretKey());
362
- if (!payload.sub) {
363
- return null;
426
+ const keys = getSecretKeys();
427
+ for (let i = 0; i < keys.length; i++) {
428
+ try {
429
+ const { payload } = await jwtVerify(token, keys[i]);
430
+ if (!payload.sub) {
431
+ return null;
432
+ }
433
+ if (i > 0) {
434
+ console.warn(
435
+ `[auth/jwt] Token for sub=${payload.sub} validated with rotated secret (index=${i}). The token was signed with a previous JWT_SECRET. It will stop working once that secret is removed.`
436
+ );
437
+ }
438
+ return payload;
439
+ } catch {
440
+ continue;
364
441
  }
365
- return payload;
366
- } catch {
367
- return null;
368
442
  }
443
+ return null;
369
444
  }
370
445
  function hashToken(token) {
371
446
  return createHash("sha256").update(token).digest("hex");
@@ -381,12 +456,14 @@ var ErrorCode = z2.enum([
381
456
  "unauthorized",
382
457
  "invalid_credentials",
383
458
  "email_already_exists",
459
+ "opaque_id_exists",
384
460
  "passkey_not_found",
385
461
  "session_expired",
386
462
  "invalid_origin",
387
463
  // Vault errors
388
464
  "vault_not_found",
389
465
  "vault_already_exists",
466
+ "vault_conflict",
390
467
  "invalid_vault_data",
391
468
  // Validation errors
392
469
  "validation_error",
@@ -407,6 +484,7 @@ var errors = {
407
484
  unauthorized: { code: "unauthorized", message: "Unauthorized" },
408
485
  invalid_credentials: { code: "invalid_credentials", message: "Invalid email or password" },
409
486
  email_already_exists: { code: "email_already_exists", message: "Email already registered" },
487
+ opaque_id_exists: { code: "opaque_id_exists", message: "Opaque ID already registered" },
410
488
  passkey_not_found: { code: "passkey_not_found", message: "Passkey not found" },
411
489
  session_expired: { code: "session_expired", message: "Session expired" },
412
490
  invalid_origin: { code: "invalid_origin", message: "Origin not allowed" },
@@ -416,6 +494,7 @@ var errors = {
416
494
  code: "vault_already_exists",
417
495
  message: `Vault "${name}" already exists`
418
496
  }),
497
+ vault_conflict: { code: "vault_conflict", message: "Version conflict - vault has been modified. Please refresh and retry." },
419
498
  invalid_vault_data: { code: "invalid_vault_data", message: "Invalid vault data" },
420
499
  // Validation errors
421
500
  validation_error: (details) => ({
@@ -529,19 +608,18 @@ var PasskeyLoginOptionsRequest = z3.object({
529
608
  var PasskeyLoginVerifyRequest = z3.object({
530
609
  credential: z3.record(z3.unknown())
531
610
  });
532
- var PasskeyCheckResponse = z3.object({
533
- hasPasskey: z3.boolean()
534
- });
611
+ var MAX_DATA_SIZE = 5 * 1024 * 1024;
612
+ var MAX_SALT_LENGTH = 64;
613
+ var BASE64_RE = /^[A-Za-z0-9+/\-_=]*$/;
614
+ var VAULT_NAME_RE = /^[A-Za-z0-9_-]+$/;
535
615
  var CreateVaultRequest = z3.object({
536
- name: z3.string().min(1).max(255),
537
- data: z3.string(),
538
- // Encrypted blob (base64)
539
- salt: z3.string()
540
- // Salt (base64)
616
+ name: z3.string().min(1).max(255).regex(VAULT_NAME_RE, "Name must be alphanumeric (hyphens and underscores allowed)"),
617
+ data: z3.string().max(MAX_DATA_SIZE, `Data must not exceed ${MAX_DATA_SIZE} bytes`).regex(BASE64_RE, "Data must be valid base64"),
618
+ salt: z3.string().max(MAX_SALT_LENGTH, `Salt must not exceed ${MAX_SALT_LENGTH} characters`).regex(BASE64_RE, "Salt must be valid base64")
541
619
  });
542
620
  var UpdateVaultRequest = z3.object({
543
- data: z3.string(),
544
- salt: z3.string(),
621
+ data: z3.string().max(MAX_DATA_SIZE, `Data must not exceed ${MAX_DATA_SIZE} bytes`).regex(BASE64_RE, "Data must be valid base64"),
622
+ salt: z3.string().max(MAX_SALT_LENGTH, `Salt must not exceed ${MAX_SALT_LENGTH} characters`).regex(BASE64_RE, "Salt must be valid base64"),
545
623
  version: z3.number().optional()
546
624
  });
547
625
  var VaultResponse = z3.object({
@@ -569,7 +647,7 @@ import {
569
647
 
570
648
  // src/features/auth/origin.ts
571
649
  var allowedOrigins = null;
572
- function getAllowedOrigins() {
650
+ function getAllowedOrigins2() {
573
651
  if (!allowedOrigins) {
574
652
  allowedOrigins = env.RP_ORIGINS.split(",").map((o) => o.trim()).filter(Boolean);
575
653
  }
@@ -579,7 +657,7 @@ function validateOrigin(origin) {
579
657
  if (!origin) {
580
658
  throw new ApiException(errors.invalid_origin, 403);
581
659
  }
582
- const allowed = getAllowedOrigins();
660
+ const allowed = getAllowedOrigins2();
583
661
  if (!allowed.includes(origin)) {
584
662
  console.warn(`[auth] Rejected origin: ${origin}. Allowed: ${allowed.join(", ")}`);
585
663
  throw new ApiException(errors.invalid_origin, 403);
@@ -593,15 +671,54 @@ function getRpConfigFromRequest(c) {
593
671
  return validateOrigin(origin);
594
672
  }
595
673
 
674
+ // src/features/auth/audit-log.ts
675
+ function logAuthEvent(event) {
676
+ console.log(JSON.stringify({ ...event, _tag: "auth_audit" }));
677
+ }
678
+ function extractRequestMeta(c) {
679
+ return {
680
+ ip: c.req.header("x-forwarded-for")?.split(",")[0]?.trim() ?? c.req.header("x-real-ip") ?? "unknown",
681
+ userAgent: c.req.header("user-agent") ?? "unknown"
682
+ };
683
+ }
684
+
596
685
  // src/api/auth/passkey.ts
686
+ var MAX_CHALLENGES = 1e3;
597
687
  var challengeStore = /* @__PURE__ */ new Map();
598
- setInterval(() => {
688
+ function pruneExpiredChallenges() {
599
689
  const now = Date.now();
600
690
  for (const [key, value] of challengeStore) {
601
691
  if (value.expiresAt < now) {
602
692
  challengeStore.delete(key);
603
693
  }
604
694
  }
695
+ }
696
+ function evictOldestChallenges() {
697
+ if (challengeStore.size <= MAX_CHALLENGES) return;
698
+ const excess = challengeStore.size - MAX_CHALLENGES;
699
+ let deleted = 0;
700
+ for (const key of challengeStore.keys()) {
701
+ if (deleted >= excess) break;
702
+ challengeStore.delete(key);
703
+ deleted++;
704
+ }
705
+ }
706
+ function setChallenge(key, entry) {
707
+ pruneExpiredChallenges();
708
+ challengeStore.set(key, entry);
709
+ evictOldestChallenges();
710
+ }
711
+ function getChallenge(key) {
712
+ const entry = challengeStore.get(key);
713
+ if (!entry) return void 0;
714
+ if (entry.expiresAt < Date.now()) {
715
+ challengeStore.delete(key);
716
+ return void 0;
717
+ }
718
+ return entry;
719
+ }
720
+ setInterval(() => {
721
+ pruneExpiredChallenges();
605
722
  }, 6e4);
606
723
  var PasskeyRegisterOptionsRequest2 = z4.object({
607
724
  email: z4.string().email().optional()
@@ -625,7 +742,8 @@ var passkeyRouter = new Hono().post(
625
742
  "/register/options",
626
743
  zValidator("json", PasskeyRegisterOptionsRequest2),
627
744
  async (c) => {
628
- const { email } = c.req.valid("json");
745
+ const { email: rawEmail } = c.req.valid("json");
746
+ const email = rawEmail ? rawEmail.toLowerCase().trim() : void 0;
629
747
  if (email) {
630
748
  const existing = getUserByEmail(email);
631
749
  if (existing) {
@@ -647,7 +765,7 @@ var passkeyRouter = new Hono().post(
647
765
  },
648
766
  timeout: 6e4
649
767
  });
650
- challengeStore.set(options.challenge, {
768
+ setChallenge(options.challenge, {
651
769
  challenge: options.challenge,
652
770
  email: email ?? void 0,
653
771
  expiresAt: Date.now() + 12e4
@@ -659,13 +777,14 @@ var passkeyRouter = new Hono().post(
659
777
  "/register/verify",
660
778
  zValidator("json", PasskeyRegisterVerifyRequest2),
661
779
  async (c) => {
662
- const { email, credential } = c.req.valid("json");
780
+ const { email: rawEmail, credential } = c.req.valid("json");
781
+ const email = rawEmail ? rawEmail.toLowerCase().trim() : void 0;
663
782
  const response = credential;
664
783
  const clientDataJSON = JSON.parse(
665
784
  Buffer.from(response.response.clientDataJSON, "base64url").toString("utf-8")
666
785
  );
667
786
  const challenge = clientDataJSON.challenge;
668
- const stored = challengeStore.get(challenge);
787
+ const stored = getChallenge(challenge);
669
788
  if (!stored) {
670
789
  throw new ApiException(errors.invalid_credentials, 401);
671
790
  }
@@ -713,7 +832,13 @@ var passkeyRouter = new Hono().post(
713
832
  });
714
833
  } catch (error) {
715
834
  if (error instanceof ApiException) throw error;
716
- console.error("Passkey registration error:", error);
835
+ logAuthEvent({
836
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
837
+ level: "warn",
838
+ event: "passkey_register_fail",
839
+ ...extractRequestMeta(c),
840
+ details: { reason: String(error) }
841
+ });
717
842
  throw new ApiException(errors.invalid_credentials, 401);
718
843
  }
719
844
  }
@@ -721,7 +846,8 @@ var passkeyRouter = new Hono().post(
721
846
  "/login/options",
722
847
  zValidator("json", PasskeyLoginOptionsRequest2),
723
848
  async (c) => {
724
- const { email } = c.req.valid("json");
849
+ const { email: rawEmail } = c.req.valid("json");
850
+ const email = rawEmail ? rawEmail.toLowerCase().trim() : void 0;
725
851
  let allowCredentials;
726
852
  if (email) {
727
853
  const user = getUserByEmail(email);
@@ -741,7 +867,7 @@ var passkeyRouter = new Hono().post(
741
867
  timeout: 6e4
742
868
  });
743
869
  const challengeKey = `auth:${options.challenge}`;
744
- challengeStore.set(challengeKey, {
870
+ setChallenge(challengeKey, {
745
871
  challenge: options.challenge,
746
872
  expiresAt: Date.now() + 12e4
747
873
  });
@@ -757,16 +883,16 @@ var passkeyRouter = new Hono().post(
757
883
  if (!passkey) {
758
884
  throw new ApiException(errors.passkey_not_found, 401);
759
885
  }
760
- let storedChallenge = null;
761
- for (const [key, value] of challengeStore) {
762
- if (key.startsWith("auth:") && value.expiresAt > Date.now()) {
763
- storedChallenge = value.challenge;
764
- break;
765
- }
766
- }
767
- if (!storedChallenge) {
886
+ const clientDataJSON = JSON.parse(
887
+ Buffer.from(response.response.clientDataJSON, "base64url").toString("utf-8")
888
+ );
889
+ const challengeFromResponse = clientDataJSON.challenge;
890
+ const challengeKey = `auth:${challengeFromResponse}`;
891
+ const storedChallengeData = getChallenge(challengeKey);
892
+ if (!storedChallengeData) {
768
893
  throw new ApiException(errors.invalid_credentials, 401);
769
894
  }
895
+ const storedChallenge = storedChallengeData.challenge;
770
896
  const { rpId, rpOrigin } = getRpConfigFromRequest(c);
771
897
  try {
772
898
  const verification = await verifyAuthenticationResponse({
@@ -784,7 +910,20 @@ var passkeyRouter = new Hono().post(
784
910
  if (!verification.verified) {
785
911
  throw new ApiException(errors.invalid_credentials, 401);
786
912
  }
787
- updatePasskeyCounter(passkey.credentialId, verification.authenticationInfo.newCounter);
913
+ const newCounter = verification.authenticationInfo.newCounter;
914
+ if (newCounter > 0 && newCounter <= passkey.counter) {
915
+ console.warn(
916
+ `[SECURITY] Possible cloned authenticator detected! credentialId=${passkey.credentialId}, storedCounter=${passkey.counter}, newCounter=${newCounter}, userId=${passkey.user.uid}`
917
+ );
918
+ throw new ApiException(
919
+ {
920
+ code: "invalid_credentials",
921
+ message: "Authentication rejected: authenticator counter regression detected (possible cloned authenticator)"
922
+ },
923
+ 401
924
+ );
925
+ }
926
+ updatePasskeyCounter(passkey.credentialId, newCounter);
788
927
  const token = await createToken({
789
928
  userId: passkey.user.uid,
790
929
  email: passkey.user.email ?? void 0
@@ -792,7 +931,7 @@ var passkeyRouter = new Hono().post(
792
931
  const tokenHash = hashToken(token);
793
932
  const expiresAt = Math.floor(Date.now() / 1e3) + env.JWT_EXPIRY;
794
933
  createSession({ userId: passkey.user.id, tokenHash, expiresAt });
795
- challengeStore.delete(`auth:${storedChallenge}`);
934
+ challengeStore.delete(challengeKey);
796
935
  return c.json({
797
936
  user: {
798
937
  id: passkey.user.uid,
@@ -803,7 +942,13 @@ var passkeyRouter = new Hono().post(
803
942
  });
804
943
  } catch (error) {
805
944
  if (error instanceof ApiException) throw error;
806
- console.error("Passkey authentication error:", error);
945
+ logAuthEvent({
946
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
947
+ level: "warn",
948
+ event: "passkey_login_fail",
949
+ ...extractRequestMeta(c),
950
+ details: { credentialId: response.id, reason: String(error) }
951
+ });
807
952
  throw new ApiException(errors.invalid_credentials, 401);
808
953
  }
809
954
  }
@@ -811,7 +956,8 @@ var passkeyRouter = new Hono().post(
811
956
  "/check",
812
957
  zValidator("json", PasskeyCheckRequest),
813
958
  (c) => {
814
- const { email } = c.req.valid("json");
959
+ const { email: rawEmail } = c.req.valid("json");
960
+ const email = rawEmail.toLowerCase().trim();
815
961
  const user = getUserByEmail(email);
816
962
  if (!user) {
817
963
  return c.json({ hasPasskey: false });
@@ -843,10 +989,18 @@ var zkcRouter = new Hono2().post(
843
989
  "/register",
844
990
  zValidator2("json", ZkcRegisterRequest),
845
991
  async (c) => {
992
+ getRpConfigFromRequest(c);
846
993
  const { opaqueId, displayName } = c.req.valid("json");
847
994
  const existing = getUserByOpaqueId(opaqueId);
848
995
  if (existing) {
849
- throw new ApiException(errors.email_already_exists, 409);
996
+ logAuthEvent({
997
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
998
+ level: "warn",
999
+ event: "zkc_register_fail",
1000
+ ...extractRequestMeta(c),
1001
+ details: { reason: "opaque_id_exists" }
1002
+ });
1003
+ throw new ApiException(errors.opaque_id_exists, 409);
850
1004
  }
851
1005
  const user = createUser({
852
1006
  opaqueId,
@@ -868,9 +1022,17 @@ var zkcRouter = new Hono2().post(
868
1022
  "/authenticate",
869
1023
  zValidator2("json", ZkcAuthenticateRequest),
870
1024
  async (c) => {
1025
+ getRpConfigFromRequest(c);
871
1026
  const { opaqueId } = c.req.valid("json");
872
1027
  const user = getUserByOpaqueId(opaqueId);
873
1028
  if (!user) {
1029
+ logAuthEvent({
1030
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
1031
+ level: "warn",
1032
+ event: "zkc_auth_fail",
1033
+ ...extractRequestMeta(c),
1034
+ details: { reason: "opaque_id_not_found" }
1035
+ });
874
1036
  throw new ApiException(errors.passkey_not_found, 401);
875
1037
  }
876
1038
  const token = await createToken({ userId: user.uid });
@@ -896,24 +1058,17 @@ var zkcRouter = new Hono2().post(
896
1058
  );
897
1059
 
898
1060
  // src/api/auth/router.ts
899
- function hashPassword(password, salt) {
900
- const crypto2 = createHash2("sha256");
901
- return crypto2.update(password + salt).digest("hex");
902
- }
903
- function generateSalt() {
904
- return randomBytes(16).toString("hex");
905
- }
906
1061
  var authRouter = new Hono3().post(
907
1062
  "/email/register",
908
1063
  zValidator3("json", EmailRegisterRequest),
909
1064
  async (c) => {
910
- const { email, password } = c.req.valid("json");
1065
+ const { email: rawEmail, password } = c.req.valid("json");
1066
+ const email = rawEmail.toLowerCase().trim();
911
1067
  const existing = getUserByEmail(email);
912
1068
  if (existing) {
913
1069
  throw new ApiException(errors.email_already_exists, 409);
914
1070
  }
915
- const salt = generateSalt();
916
- const passwordHash = hashPassword(password, salt) + ":" + salt;
1071
+ const passwordHash = await bcrypt.hash(password, 12);
917
1072
  const user = createUser({ email, passwordHash });
918
1073
  const token = await createToken({ userId: user.uid, email });
919
1074
  const tokenHash = hashToken(token);
@@ -932,17 +1087,14 @@ var authRouter = new Hono3().post(
932
1087
  "/email/login",
933
1088
  zValidator3("json", EmailLoginRequest),
934
1089
  async (c) => {
935
- const { email, password } = c.req.valid("json");
1090
+ const { email: rawEmail, password } = c.req.valid("json");
1091
+ const email = rawEmail.toLowerCase().trim();
936
1092
  const user = getUserByEmail(email);
937
1093
  if (!user || !user.passwordHash) {
938
1094
  throw new ApiException(errors.invalid_credentials, 401);
939
1095
  }
940
- const [storedHash, salt] = user.passwordHash.split(":");
941
- if (!salt) {
942
- throw new ApiException(errors.invalid_credentials, 401);
943
- }
944
- const inputHash = hashPassword(password, salt);
945
- if (inputHash !== storedHash) {
1096
+ const valid = await bcrypt.compare(password, user.passwordHash);
1097
+ if (!valid) {
946
1098
  throw new ApiException(errors.invalid_credentials, 401);
947
1099
  }
948
1100
  const token = await createToken({ userId: user.uid, email: user.email ?? void 0 });
@@ -967,8 +1119,7 @@ var authRouter = new Hono3().post(
967
1119
  user: {
968
1120
  id: session.user.uid,
969
1121
  email: session.user.email,
970
- createdAt: 0
971
- // Not needed for /me
1122
+ createdAt: session.user.createdAt
972
1123
  }
973
1124
  });
974
1125
  }
@@ -1075,6 +1226,12 @@ var VaultService = class {
1075
1226
  updateVault(uid, userId, data) {
1076
1227
  const vault = this.vaultRepo.update(uid, userId, data);
1077
1228
  if (!vault) {
1229
+ if (data.version != null) {
1230
+ const existing = this.vaultRepo.findByUid(uid, userId);
1231
+ if (existing) {
1232
+ throw new ApiException(errors.vault_conflict, 409);
1233
+ }
1234
+ }
1078
1235
  throw new ApiException(errors.vault_not_found, 404);
1079
1236
  }
1080
1237
  return toVaultResponse(vault);
@@ -1167,6 +1324,109 @@ var vaultRouter = new Hono4().use("/*", requireAuthMiddleware).get(
1167
1324
  }
1168
1325
  );
1169
1326
 
1327
+ // src/features/auth/rate-limit.ts
1328
+ import { createMiddleware as createMiddleware2 } from "hono/factory";
1329
+ var DEFAULT_CONFIG = { max: 100, windowMs: 6e4 };
1330
+ var CLEANUP_INTERVAL_MS = 6e4;
1331
+ function createStore(windowMs) {
1332
+ const buckets = /* @__PURE__ */ new Map();
1333
+ const timer = setInterval(() => {
1334
+ const cutoff = Date.now() - windowMs;
1335
+ for (const [ip, entry] of buckets) {
1336
+ entry.timestamps = entry.timestamps.filter((t) => t > cutoff);
1337
+ if (entry.timestamps.length === 0) {
1338
+ buckets.delete(ip);
1339
+ }
1340
+ }
1341
+ }, CLEANUP_INTERVAL_MS);
1342
+ if (timer.unref) timer.unref();
1343
+ return {
1344
+ /**
1345
+ * Record a hit and return the current count within the window.
1346
+ */
1347
+ hit(ip) {
1348
+ const now = Date.now();
1349
+ const cutoff = now - windowMs;
1350
+ let entry = buckets.get(ip);
1351
+ if (!entry) {
1352
+ entry = { timestamps: [] };
1353
+ buckets.set(ip, entry);
1354
+ }
1355
+ entry.timestamps = entry.timestamps.filter((t) => t > cutoff);
1356
+ entry.timestamps.push(now);
1357
+ return entry.timestamps.length;
1358
+ },
1359
+ /**
1360
+ * Return the oldest timestamp still in the window for Retry-After calculation.
1361
+ */
1362
+ oldestInWindow(ip) {
1363
+ return buckets.get(ip)?.timestamps[0];
1364
+ }
1365
+ };
1366
+ }
1367
+ function rateLimit(config = {}) {
1368
+ const { max, windowMs } = { ...DEFAULT_CONFIG, ...config };
1369
+ const store = createStore(windowMs);
1370
+ return createMiddleware2(async (c, next) => {
1371
+ const ip = c.req.header("x-forwarded-for")?.split(",")[0]?.trim() ?? c.req.header("x-real-ip") ?? "unknown";
1372
+ const count = store.hit(ip);
1373
+ const remaining = Math.max(0, max - count);
1374
+ c.header("X-RateLimit-Limit", String(max));
1375
+ c.header("X-RateLimit-Remaining", String(remaining));
1376
+ if (count > max) {
1377
+ const oldest = store.oldestInWindow(ip);
1378
+ const retryAfterSec = oldest ? Math.ceil((oldest + windowMs - Date.now()) / 1e3) : Math.ceil(windowMs / 1e3);
1379
+ c.header("Retry-After", String(Math.max(1, retryAfterSec)));
1380
+ throw new ApiException(
1381
+ { code: "invalid_request", message: "Too many requests, please try again later" },
1382
+ 429
1383
+ );
1384
+ }
1385
+ await next();
1386
+ });
1387
+ }
1388
+
1389
+ // src/features/auth/csrf.ts
1390
+ import { createMiddleware as createMiddleware3 } from "hono/factory";
1391
+ import { getCookie, setCookie } from "hono/cookie";
1392
+ var CSRF_COOKIE_NAME = "__csrf";
1393
+ var CSRF_HEADER_NAME = "x-csrf-token";
1394
+ var TOKEN_BYTES = 32;
1395
+ var SAFE_METHODS = /* @__PURE__ */ new Set(["GET", "HEAD", "OPTIONS"]);
1396
+ function csrfCookieOptions() {
1397
+ return {
1398
+ path: "/",
1399
+ httpOnly: false,
1400
+ // Client JS must read this value
1401
+ sameSite: "Strict",
1402
+ secure: env.NODE_ENV === "production"
1403
+ };
1404
+ }
1405
+ function generateToken() {
1406
+ const bytes = new Uint8Array(TOKEN_BYTES);
1407
+ crypto.getRandomValues(bytes);
1408
+ return Array.from(bytes).map((b) => b.toString(16).padStart(2, "0")).join("");
1409
+ }
1410
+ var csrfProtection = createMiddleware3(async (c, next) => {
1411
+ if (SAFE_METHODS.has(c.req.method)) {
1412
+ const existing = getCookie(c, CSRF_COOKIE_NAME);
1413
+ if (!existing) {
1414
+ setCookie(c, CSRF_COOKIE_NAME, generateToken(), csrfCookieOptions());
1415
+ }
1416
+ return next();
1417
+ }
1418
+ const cookieToken = getCookie(c, CSRF_COOKIE_NAME);
1419
+ const headerToken = c.req.header(CSRF_HEADER_NAME);
1420
+ if (!cookieToken || !headerToken || cookieToken !== headerToken) {
1421
+ throw new ApiException(
1422
+ { code: "invalid_request", message: "Invalid or missing CSRF token" },
1423
+ 403
1424
+ );
1425
+ }
1426
+ setCookie(c, CSRF_COOKIE_NAME, generateToken(), csrfCookieOptions());
1427
+ await next();
1428
+ });
1429
+
1170
1430
  // src/app.ts
1171
1431
  var errorHandler = (error, c) => {
1172
1432
  const requestId = c.req.header("x-request-id") ?? crypto.randomUUID();
@@ -1198,24 +1458,47 @@ var errorHandler = (error, c) => {
1198
1458
  };
1199
1459
  function createApp() {
1200
1460
  const app = new Hono5();
1201
- app.use("*", logger());
1461
+ app.use("*", bodyLimit({ maxSize: 11 * 1024 * 1024 }));
1462
+ app.use(
1463
+ "*",
1464
+ secureHeaders({
1465
+ strictTransportSecurity: "max-age=63072000; includeSubDomains; preload",
1466
+ contentSecurityPolicy: {
1467
+ defaultSrc: ["'self'"],
1468
+ scriptSrc: ["'self'"],
1469
+ styleSrc: ["'self'", "'unsafe-inline'"],
1470
+ connectSrc: ["'self'"],
1471
+ frameAncestors: ["'none'"]
1472
+ },
1473
+ xFrameOptions: "DENY",
1474
+ xContentTypeOptions: "nosniff",
1475
+ referrerPolicy: "strict-origin-when-cross-origin"
1476
+ })
1477
+ );
1478
+ if (env.NODE_ENV !== "production") {
1479
+ app.use("*", logger());
1480
+ }
1481
+ const allowedOrigins2 = new Set(getAllowedOrigins());
1202
1482
  app.use(
1203
1483
  "*",
1204
1484
  cors({
1205
- origin: "*",
1206
- // Configure for production
1485
+ origin: (origin) => allowedOrigins2.has(origin) ? origin : "",
1207
1486
  allowMethods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"],
1208
- allowHeaders: ["Content-Type", "Authorization"],
1209
- exposeHeaders: ["X-Request-Id"]
1487
+ allowHeaders: ["Content-Type", "Authorization", "X-CSRF-Token"],
1488
+ exposeHeaders: ["X-Request-Id", "X-RateLimit-Limit", "X-RateLimit-Remaining", "Retry-After"]
1210
1489
  })
1211
1490
  );
1491
+ app.use("*", rateLimit({ max: 100, windowMs: 6e4 }));
1492
+ app.use("*", csrfProtection);
1212
1493
  app.get("/health", (c) => c.json({ status: "ok", timestamp: Date.now() }));
1494
+ app.use("/auth/*", rateLimit({ max: 10, windowMs: 6e4 }));
1213
1495
  app.route("/auth", authRouter);
1214
1496
  app.route("/vault", vaultRouter);
1215
1497
  app.onError(errorHandler);
1216
1498
  app.notFound((c) => {
1499
+ const requestId = c.req.header("x-request-id") ?? crypto.randomUUID();
1217
1500
  return c.json(
1218
- { error: { code: "not_found", message: "Endpoint not found" } },
1501
+ { error: { code: "not_found", message: "Endpoint not found" }, requestId },
1219
1502
  404
1220
1503
  );
1221
1504
  });
@@ -1228,7 +1511,6 @@ export {
1228
1511
  EmailLoginRequest,
1229
1512
  EmailRegisterRequest,
1230
1513
  MeResponse,
1231
- PasskeyCheckResponse,
1232
1514
  PasskeyLoginOptionsRequest,
1233
1515
  PasskeyLoginVerifyRequest,
1234
1516
  PasskeyRegisterOptionsRequest,