botschat 0.1.4 → 0.1.7

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.
Files changed (66) hide show
  1. package/README.md +64 -24
  2. package/migrations/0011_e2e_encryption.sql +35 -0
  3. package/package.json +7 -2
  4. package/packages/api/package.json +2 -1
  5. package/packages/api/src/do/connection-do.ts +162 -42
  6. package/packages/api/src/index.ts +132 -13
  7. package/packages/api/src/routes/auth.ts +127 -30
  8. package/packages/api/src/routes/pairing.ts +14 -1
  9. package/packages/api/src/routes/setup.ts +72 -24
  10. package/packages/api/src/routes/upload.ts +12 -8
  11. package/packages/api/src/utils/auth.ts +212 -43
  12. package/packages/api/src/utils/id.ts +30 -14
  13. package/packages/api/src/utils/rate-limit.ts +73 -0
  14. package/packages/plugin/dist/src/accounts.d.ts.map +1 -1
  15. package/packages/plugin/dist/src/accounts.js +1 -0
  16. package/packages/plugin/dist/src/accounts.js.map +1 -1
  17. package/packages/plugin/dist/src/channel.d.ts +1 -0
  18. package/packages/plugin/dist/src/channel.d.ts.map +1 -1
  19. package/packages/plugin/dist/src/channel.js +151 -9
  20. package/packages/plugin/dist/src/channel.js.map +1 -1
  21. package/packages/plugin/dist/src/types.d.ts +16 -0
  22. package/packages/plugin/dist/src/types.d.ts.map +1 -1
  23. package/packages/plugin/dist/src/ws-client.d.ts +2 -0
  24. package/packages/plugin/dist/src/ws-client.d.ts.map +1 -1
  25. package/packages/plugin/dist/src/ws-client.js +14 -3
  26. package/packages/plugin/dist/src/ws-client.js.map +1 -1
  27. package/packages/plugin/package.json +4 -3
  28. package/packages/web/dist/architecture.png +0 -0
  29. package/packages/web/dist/assets/index-BoNQoJjQ.js +1497 -0
  30. package/packages/web/dist/assets/{index-DuGeoFJT.css → index-ewBIratI.css} +1 -1
  31. package/packages/web/dist/botschat-icon.svg +4 -0
  32. package/packages/web/dist/index.html +23 -3
  33. package/packages/web/dist/manifest.json +24 -0
  34. package/packages/web/dist/sw.js +40 -0
  35. package/packages/web/index.html +21 -1
  36. package/packages/web/package.json +1 -0
  37. package/packages/web/src/App.tsx +286 -103
  38. package/packages/web/src/analytics.ts +57 -0
  39. package/packages/web/src/api.ts +67 -3
  40. package/packages/web/src/components/ChatWindow.tsx +11 -11
  41. package/packages/web/src/components/ConnectionSettings.tsx +477 -0
  42. package/packages/web/src/components/CronDetail.tsx +475 -235
  43. package/packages/web/src/components/CronSidebar.tsx +1 -1
  44. package/packages/web/src/components/DebugLogPanel.tsx +116 -3
  45. package/packages/web/src/components/E2ESettings.tsx +122 -0
  46. package/packages/web/src/components/IconRail.tsx +56 -27
  47. package/packages/web/src/components/JobList.tsx +2 -6
  48. package/packages/web/src/components/LoginPage.tsx +143 -104
  49. package/packages/web/src/components/MobileLayout.tsx +480 -0
  50. package/packages/web/src/components/OnboardingPage.tsx +159 -21
  51. package/packages/web/src/components/ResizeHandle.tsx +34 -0
  52. package/packages/web/src/components/Sidebar.tsx +1 -1
  53. package/packages/web/src/components/TaskBar.tsx +2 -2
  54. package/packages/web/src/components/ThreadPanel.tsx +2 -5
  55. package/packages/web/src/e2e.ts +133 -0
  56. package/packages/web/src/hooks/useIsMobile.ts +27 -0
  57. package/packages/web/src/index.css +59 -0
  58. package/packages/web/src/main.tsx +12 -0
  59. package/packages/web/src/store.ts +16 -8
  60. package/packages/web/src/ws.ts +78 -4
  61. package/scripts/dev.sh +16 -16
  62. package/scripts/test-e2e-live.ts +194 -0
  63. package/scripts/verify-e2e-db.ts +48 -0
  64. package/scripts/verify-e2e.ts +56 -0
  65. package/wrangler.toml +3 -1
  66. package/packages/web/dist/assets/index-DyzTR_Y4.js +0 -847
@@ -1,6 +1,6 @@
1
1
  import { Hono } from "hono";
2
2
  import type { Env } from "../env.js";
3
- import { createToken, hashPassword } from "../utils/auth.js";
3
+ import { createToken, hashPassword, verifyPassword, getJwtSecret } from "../utils/auth.js";
4
4
  import { verifyFirebaseIdToken } from "../utils/firebase.js";
5
5
  import { generateId, generatePairingToken } from "../utils/id.js";
6
6
  import { resolveCloudUrlWithHints } from "../utils/resolve-url.js";
@@ -22,6 +22,7 @@ setup.post("/init", async (c) => {
22
22
  email?: string;
23
23
  password?: string;
24
24
  idToken?: string;
25
+ e2ePassword?: string;
25
26
  }>();
26
27
 
27
28
  let userId: string;
@@ -51,18 +52,30 @@ setup.post("/init", async (c) => {
51
52
 
52
53
  // Find or create user
53
54
  let row = await c.env.DB.prepare(
54
- "SELECT id, display_name FROM users WHERE firebase_uid = ?",
55
- ).bind(firebaseUid).first<{ id: string; display_name: string | null }>();
55
+ "SELECT id, display_name, password_hash FROM users WHERE firebase_uid = ?",
56
+ ).bind(firebaseUid).first<{ id: string; display_name: string | null; password_hash: string }>();
56
57
 
57
58
  if (!row) {
58
- row = await c.env.DB.prepare(
59
- "SELECT id, display_name FROM users WHERE email = ?",
60
- ).bind(email).first<{ id: string; display_name: string | null }>();
61
-
62
- if (row) {
59
+ const existing = await c.env.DB.prepare(
60
+ "SELECT id, display_name, password_hash FROM users WHERE email = ?",
61
+ ).bind(email).first<{ id: string; display_name: string | null; password_hash: string }>();
62
+
63
+ if (existing) {
64
+ if (existing.password_hash) {
65
+ // SECURITY: refuse auto-link to password-protected account
66
+ return c.json(
67
+ {
68
+ error: "An account with this email already exists. Use email+password instead.",
69
+ code: "EMAIL_EXISTS_WITH_PASSWORD",
70
+ },
71
+ 409,
72
+ );
73
+ }
74
+ // OAuth-only account — safe to link
63
75
  await c.env.DB.prepare(
64
76
  "UPDATE users SET firebase_uid = ?, updated_at = unixepoch() WHERE id = ?",
65
- ).bind(firebaseUid, row.id).run();
77
+ ).bind(firebaseUid, existing.id).run();
78
+ row = existing;
66
79
  }
67
80
  }
68
81
 
@@ -75,38 +88,72 @@ setup.post("/init", async (c) => {
75
88
  `INSERT INTO users (id, email, password_hash, display_name, auth_provider, firebase_uid)
76
89
  VALUES (?, ?, '', ?, ?, ?)`,
77
90
  ).bind(id, email, displayName ?? email.split("@")[0], authProvider, firebaseUid).run();
78
- row = { id, display_name: displayName };
91
+ row = { id, display_name: displayName, password_hash: "" };
79
92
  }
80
93
 
81
94
  userId = row.id;
82
95
  displayName = row.display_name;
83
96
  } else {
84
- // ---- Email + password path ----
97
+ // ---- Email + password path (local dev only) ----
98
+ if (c.env.ENVIRONMENT !== "development") {
99
+ return c.json({ error: "Email login is disabled. Please use Google or GitHub sign-in." }, 403);
100
+ }
101
+
85
102
  if (!body.email?.trim() || !body.password?.trim()) {
86
103
  return c.json({ error: "email+password or idToken is required" }, 400);
87
104
  }
88
105
 
106
+ // Cap password length to prevent PBKDF2 resource exhaustion (DoS)
107
+ if (body.password.length > 256) {
108
+ return c.json({ error: "Invalid email or password" }, 401);
109
+ }
110
+
89
111
  email = body.email.trim().toLowerCase();
90
- const passwordHash = await hashPassword(body.password);
91
112
 
92
113
  const row = await c.env.DB.prepare(
93
- "SELECT id, display_name FROM users WHERE email = ? AND password_hash = ?",
94
- ).bind(email, passwordHash).first<{ id: string; display_name: string | null }>();
114
+ "SELECT id, display_name, password_hash FROM users WHERE email = ?",
115
+ ).bind(email).first<{ id: string; display_name: string | null; password_hash: string }>();
95
116
 
96
- if (!row) {
117
+ if (!row || !row.password_hash) {
97
118
  return c.json({ error: "Invalid email or password" }, 401);
98
119
  }
99
120
 
121
+ const { valid, needsRehash } = await verifyPassword(body.password, row.password_hash);
122
+ if (!valid) {
123
+ return c.json({ error: "Invalid email or password" }, 401);
124
+ }
125
+
126
+ // Transparently upgrade legacy SHA-256 hashes to PBKDF2
127
+ if (needsRehash) {
128
+ const newHash = await hashPassword(body.password);
129
+ await c.env.DB.prepare("UPDATE users SET password_hash = ? WHERE id = ?")
130
+ .bind(newHash, row.id)
131
+ .run();
132
+ }
133
+
100
134
  userId = row.id;
101
135
  displayName = row.display_name;
102
136
  }
103
137
 
104
- // ---- Create a fresh pairing token for this setup ----
105
- const ptId = generateId("pt_");
106
- const pairingToken = generatePairingToken();
107
- await c.env.DB.prepare(
108
- "INSERT INTO pairing_tokens (id, user_id, token, label) VALUES (?, ?, ?, ?)",
109
- ).bind(ptId, userId, pairingToken, "CLI setup").run();
138
+ // ---- Reuse recent token or create a new pairing token ----
139
+ // If a CLI setup token was created in the last 5 minutes, reuse it
140
+ let pairingToken: string;
141
+ const recentToken = await c.env.DB.prepare(
142
+ `SELECT token FROM pairing_tokens
143
+ WHERE user_id = ? AND label = 'CLI setup' AND revoked_at IS NULL
144
+ AND created_at > unixepoch() - 300
145
+ ORDER BY created_at DESC LIMIT 1`,
146
+ ).bind(userId).first<{ token: string }>();
147
+
148
+ if (recentToken) {
149
+ pairingToken = recentToken.token;
150
+ } else {
151
+ const ptId = generateId("pt_");
152
+ pairingToken = generatePairingToken();
153
+ await c.env.DB.prepare(
154
+ "INSERT INTO pairing_tokens (id, user_id, token, label) VALUES (?, ?, ?, ?)",
155
+ ).bind(ptId, userId, pairingToken, "CLI setup").run();
156
+ }
110
157
 
111
158
  // ---- Ensure a default channel exists ----
112
159
  let channel = await c.env.DB.prepare(
@@ -122,7 +169,7 @@ setup.post("/init", async (c) => {
122
169
  }
123
170
 
124
171
  // ---- Issue JWT ----
125
- const secret = c.env.JWT_SECRET ?? "botschat-dev-secret";
172
+ const secret = getJwtSecret(c.env);
126
173
  const token = await createToken(userId, secret);
127
174
 
128
175
  // ---- Resolve the best cloud URL for the plugin to connect back ----
@@ -138,9 +185,10 @@ setup.post("/init", async (c) => {
138
185
  ...(isLoopback ? { cloudUrlWarning: hint } : {}),
139
186
  channel: { id: channel.id, name: channel.name },
140
187
  setupCommands: [
141
- "openclaw plugins install @botschat/openclaw-plugin",
188
+ "openclaw plugins install @botschat/botschat",
142
189
  `openclaw config set channels.botschat.cloudUrl ${cloudUrl}`,
143
190
  `openclaw config set channels.botschat.pairingToken ${pairingToken}`,
191
+ ...(body.e2ePassword ? [`openclaw config set channels.botschat.e2ePassword "${body.e2ePassword}"`] : []),
144
192
  "openclaw config set channels.botschat.enabled true",
145
193
  "openclaw gateway restart",
146
194
  ],
@@ -171,7 +219,7 @@ setup.get("/status", async (c) => {
171
219
  }
172
220
 
173
221
  const { verifyToken } = await import("../utils/auth.js");
174
- const jwtSecret = c.env.JWT_SECRET ?? "botschat-dev-secret";
222
+ const jwtSecret = getJwtSecret(c.env);
175
223
  const payload = await verifyToken(authHeader.slice(7), jwtSecret);
176
224
  if (!payload) {
177
225
  return c.json({ error: "Invalid or expired token" }, 401);
@@ -1,12 +1,13 @@
1
1
  import { Hono } from "hono";
2
2
  import type { Env } from "../env.js";
3
+ import { signMediaUrl, getJwtSecret } from "../utils/auth.js";
3
4
 
4
5
  export const upload = new Hono<{
5
6
  Bindings: Env;
6
7
  Variables: { userId: string };
7
8
  }>();
8
9
 
9
- /** POST / — Upload a file to R2 and return its public URL. */
10
+ /** POST / — Upload a file to R2 and return a signed URL. */
10
11
  upload.post("/", async (c) => {
11
12
  const userId = c.get("userId");
12
13
  const contentType = c.req.header("Content-Type") ?? "";
@@ -22,9 +23,9 @@ upload.post("/", async (c) => {
22
23
  return c.json({ error: "No file provided" }, 400);
23
24
  }
24
25
 
25
- // Validate file type — only images allowed
26
- if (!file.type.startsWith("image/")) {
27
- return c.json({ error: "Only image files are allowed" }, 400);
26
+ // Validate file type — only raster images allowed (SVG is an XSS vector)
27
+ if (!file.type.startsWith("image/") || file.type.includes("svg")) {
28
+ return c.json({ error: "Only image files are allowed (SVG is not permitted)" }, 400);
28
29
  }
29
30
 
30
31
  // Limit file size to 10 MB
@@ -35,8 +36,10 @@ upload.post("/", async (c) => {
35
36
 
36
37
  // Generate a unique key: media/{userId}/{timestamp}-{random}.{ext}
37
38
  const ext = file.name.split(".").pop()?.toLowerCase() ?? "png";
38
- const safeExt = ["jpg", "jpeg", "png", "gif", "webp", "svg", "bmp", "ico"].includes(ext) ? ext : "png";
39
- const key = `media/${userId}/${Date.now()}-${crypto.randomUUID().slice(0, 8)}.${safeExt}`;
39
+ // SVG is excluded it can contain <script> tags and is a known XSS vector
40
+ const safeExt = ["jpg", "jpeg", "png", "gif", "webp", "bmp", "ico"].includes(ext) ? ext : "png";
41
+ const filename = `${Date.now()}-${crypto.randomUUID().slice(0, 8)}.${safeExt}`;
42
+ const key = `media/${userId}/${filename}`;
40
43
 
41
44
  // Upload to R2
42
45
  await c.env.MEDIA.put(key, file.stream(), {
@@ -45,8 +48,9 @@ upload.post("/", async (c) => {
45
48
  },
46
49
  });
47
50
 
48
- // Return the URL for serving through the API
49
- const url = `/api/media/${key.replace("media/", "")}`;
51
+ // Return a signed URL (1 hour expiry)
52
+ const secret = getJwtSecret(c.env);
53
+ const url = await signMediaUrl(userId, filename, secret, 3600);
50
54
 
51
55
  return c.json({ url, key });
52
56
  });
@@ -1,8 +1,8 @@
1
1
  import type { Context, MiddlewareHandler } from "hono";
2
2
  import type { Env } from "../env.js";
3
+ import { SignJWT, jwtVerify } from "jose";
3
4
 
4
- // Simple JWT-like token using HMAC-SHA256.
5
- // In production, use a proper JWT library or Cloudflare Access.
5
+ // JWT implementation using the `jose` library (timing-safe, standards-compliant).
6
6
 
7
7
  type TokenPayload = {
8
8
  sub: string; // user ID
@@ -11,6 +11,81 @@ type TokenPayload = {
11
11
 
12
12
  const ENCODER = new TextEncoder();
13
13
 
14
+ /** Derive a CryptoKey from a string secret for HMAC-SHA256. */
15
+ function getSecretKey(secret: string): Uint8Array {
16
+ return ENCODER.encode(secret);
17
+ }
18
+
19
+ /** Create a short-lived access token (default 30 minutes). */
20
+ export async function createToken(
21
+ userId: string,
22
+ secret: string,
23
+ expiresInSeconds = 1800, // 30 minutes
24
+ ): Promise<string> {
25
+ return new SignJWT({ sub: userId, type: "access" })
26
+ .setProtectedHeader({ alg: "HS256" })
27
+ .setIssuedAt()
28
+ .setIssuer("botschat")
29
+ .setExpirationTime(`${expiresInSeconds}s`)
30
+ .sign(getSecretKey(secret));
31
+ }
32
+
33
+ /** Create a long-lived refresh token (default 7 days). */
34
+ export async function createRefreshToken(
35
+ userId: string,
36
+ secret: string,
37
+ expiresInSeconds = 86400 * 7, // 7 days
38
+ ): Promise<string> {
39
+ return new SignJWT({ sub: userId, type: "refresh" })
40
+ .setProtectedHeader({ alg: "HS256" })
41
+ .setIssuedAt()
42
+ .setIssuer("botschat")
43
+ .setExpirationTime(`${expiresInSeconds}s`)
44
+ .sign(getSecretKey(secret));
45
+ }
46
+
47
+ /** Verify a refresh token and return the payload. */
48
+ export async function verifyRefreshToken(
49
+ token: string,
50
+ secret: string,
51
+ ): Promise<TokenPayload | null> {
52
+ try {
53
+ const { payload } = await jwtVerify(token, getSecretKey(secret), {
54
+ issuer: "botschat",
55
+ });
56
+ if (!payload.sub) return null;
57
+ // Must be a refresh token
58
+ if (payload.type !== "refresh") return null;
59
+ return { sub: payload.sub, exp: payload.exp ?? 0 };
60
+ } catch {
61
+ return null;
62
+ }
63
+ }
64
+
65
+ export async function verifyToken(
66
+ token: string,
67
+ secret: string,
68
+ ): Promise<TokenPayload | null> {
69
+ try {
70
+ const { payload } = await jwtVerify(token, getSecretKey(secret), {
71
+ issuer: "botschat",
72
+ });
73
+ if (!payload.sub) return null;
74
+ return { sub: payload.sub, exp: payload.exp ?? 0 };
75
+ } catch {
76
+ // Also try verifying without issuer check for backward compatibility
77
+ // with tokens issued before this migration
78
+ try {
79
+ const { payload } = await jwtVerify(token, getSecretKey(secret));
80
+ if (!payload.sub) return null;
81
+ return { sub: payload.sub, exp: payload.exp ?? 0 };
82
+ } catch {
83
+ return null;
84
+ }
85
+ }
86
+ }
87
+
88
+ // HMAC-SHA256 signing for non-JWT purposes (media URL signing)
14
89
  async function hmacSign(secret: string, data: string): Promise<string> {
15
90
  const key = await crypto.subtle.importKey(
16
91
  "raw",
@@ -26,57 +101,113 @@ async function hmacSign(secret: string, data: string): Promise<string> {
26
101
  .replace(/=+$/, "");
27
102
  }
28
103
 
29
- function base64UrlEncode(obj: unknown): string {
30
- return btoa(JSON.stringify(obj))
31
- .replace(/\+/g, "-")
32
- .replace(/\//g, "_")
33
- .replace(/=+$/, "");
104
+ // ---- Password hashing (PBKDF2 with migration support) ----
105
+
106
+ const PBKDF2_ITERATIONS = 600_000; // OWASP 2023 recommendation
107
+ const SALT_LENGTH = 16; // 128-bit salt
108
+
109
+ function toHex(buf: ArrayBuffer): string {
110
+ return Array.from(new Uint8Array(buf))
111
+ .map((b) => b.toString(16).padStart(2, "0"))
112
+ .join("");
34
113
  }
35
114
 
36
- function base64UrlDecode(str: string): unknown {
37
- const padded = str.replace(/-/g, "+").replace(/_/g, "/");
38
- return JSON.parse(atob(padded));
115
+ function fromHex(hex: string): Uint8Array {
116
+ const bytes = new Uint8Array(hex.length / 2);
117
+ for (let i = 0; i < hex.length; i += 2) {
118
+ bytes[i / 2] = parseInt(hex.substring(i, i + 2), 16);
119
+ }
120
+ return bytes;
39
121
  }
40
122
 
41
- export async function createToken(
42
- userId: string,
43
- secret: string,
44
- expiresInSeconds = 86400 * 7, // 7 days
45
- ): Promise<string> {
46
- const payload: TokenPayload = {
47
- sub: userId,
48
- exp: Math.floor(Date.now() / 1000) + expiresInSeconds,
49
- };
50
- const header = base64UrlEncode({ alg: "HS256", typ: "JWT" });
51
- const body = base64UrlEncode(payload);
52
- const signature = await hmacSign(secret, `${header}.${body}`);
53
- return `${header}.${body}.${signature}`;
123
+ /** Check if a stored hash is the legacy SHA-256 format (64-char hex string). */
124
+ function isLegacyHash(hash: string): boolean {
125
+ return /^[0-9a-f]{64}$/.test(hash);
54
126
  }
55
127
 
56
- export async function verifyToken(
57
- token: string,
58
- secret: string,
59
- ): Promise<TokenPayload | null> {
60
- const parts = token.split(".");
61
- if (parts.length !== 3) return null;
128
+ /** Legacy SHA-256 hash (for migration comparison only). */
129
+ async function legacySha256(password: string): Promise<string> {
130
+ const data = ENCODER.encode(password);
131
+ const hash = await crypto.subtle.digest("SHA-256", data);
132
+ return toHex(hash);
133
+ }
62
134
 
63
- const [header, body, signature] = parts;
64
- const expected = await hmacSign(secret, `${header}.${body}`);
65
- if (signature !== expected) return null;
135
+ /** Hash a password using PBKDF2-SHA256 with a random salt. */
136
+ export async function hashPassword(password: string): Promise<string> {
137
+ const salt = new Uint8Array(SALT_LENGTH);
138
+ crypto.getRandomValues(salt);
139
+ const saltHex = toHex(salt.buffer);
66
140
 
67
- const payload = base64UrlDecode(body) as TokenPayload;
68
- if (payload.exp < Math.floor(Date.now() / 1000)) return null;
141
+ const keyMaterial = await crypto.subtle.importKey(
142
+ "raw",
143
+ ENCODER.encode(password),
144
+ "PBKDF2",
145
+ false,
146
+ ["deriveBits"],
147
+ );
148
+ const derived = await crypto.subtle.deriveBits(
149
+ { name: "PBKDF2", salt, iterations: PBKDF2_ITERATIONS, hash: "SHA-256" },
150
+ keyMaterial,
151
+ 256,
152
+ );
153
+ const hashHex = toHex(derived);
69
154
 
70
- return payload;
155
+ return `pbkdf2:${PBKDF2_ITERATIONS}:${saltHex}:${hashHex}`;
71
156
  }
72
157
 
73
- /** Hash a password using SHA-256 (for simplicity; production should use bcrypt/scrypt). */
74
- export async function hashPassword(password: string): Promise<string> {
75
- const data = ENCODER.encode(password);
76
- const hash = await crypto.subtle.digest("SHA-256", data);
77
- return Array.from(new Uint8Array(hash))
78
- .map((b) => b.toString(16).padStart(2, "0"))
79
- .join("");
158
+ /**
159
+ * Verify a password against a stored hash (supports both PBKDF2 and legacy SHA-256).
160
+ * Returns { valid, needsRehash } — if needsRehash is true, the caller should
161
+ * update the stored hash to the new PBKDF2 format.
162
+ */
163
+ export async function verifyPassword(
164
+ password: string,
165
+ storedHash: string,
166
+ ): Promise<{ valid: boolean; needsRehash: boolean }> {
167
+ // Legacy SHA-256 format: 64-char hex
168
+ if (isLegacyHash(storedHash)) {
169
+ const computed = await legacySha256(password);
170
+ return { valid: computed === storedHash, needsRehash: true };
171
+ }
172
+
173
+ // PBKDF2 format: pbkdf2:<iterations>:<salt>:<hash>
174
+ const parts = storedHash.split(":");
175
+ if (parts.length !== 4 || parts[0] !== "pbkdf2") {
176
+ return { valid: false, needsRehash: false };
177
+ }
178
+
179
+ const [, iterStr, saltHex, expectedHash] = parts;
180
+ const iterations = parseInt(iterStr, 10);
181
+ const salt = fromHex(saltHex);
182
+
183
+ const keyMaterial = await crypto.subtle.importKey(
184
+ "raw",
185
+ ENCODER.encode(password),
186
+ "PBKDF2",
187
+ false,
188
+ ["deriveBits"],
189
+ );
190
+ const derived = await crypto.subtle.deriveBits(
191
+ { name: "PBKDF2", salt, iterations, hash: "SHA-256" },
192
+ keyMaterial,
193
+ 256,
194
+ );
195
+ const computedHash = toHex(derived);
196
+
197
+ const valid = computedHash === expectedHash;
198
+ // If using fewer iterations than current standard, suggest rehash
199
+ const needsRehash = valid && iterations < PBKDF2_ITERATIONS;
200
+ return { valid, needsRehash };
201
+ }
202
+
203
+ /**
204
+ * Get the JWT secret from environment, throwing a clear error if not set.
205
+ * In development (wrangler dev), falls back to a dev-only secret.
206
+ */
207
+ export function getJwtSecret(env: Env): string {
208
+ if (env.JWT_SECRET) return env.JWT_SECRET;
209
+ if (env.ENVIRONMENT === "development") return "botschat-dev-secret-local-only";
210
+ throw new Error("JWT_SECRET environment variable is not set. Configure it via `wrangler secret put JWT_SECRET`.");
80
211
  }
81
212
 
82
213
  /** Auth middleware: extracts user ID from Bearer token and sets it on context. */
@@ -88,7 +219,7 @@ export function authMiddleware(): MiddlewareHandler<{ Bindings: Env; Variables:
88
219
  }
89
220
 
90
221
  const token = authHeader.slice(7);
91
- const secret = c.env.JWT_SECRET ?? "botschat-dev-secret";
222
+ const secret = getJwtSecret(c.env);
92
223
  const payload = await verifyToken(token, secret);
93
224
 
94
225
  if (!payload) {
@@ -99,3 +230,41 @@ export function authMiddleware(): MiddlewareHandler<{ Bindings: Env; Variables:
99
230
  await next();
100
231
  };
101
232
  }
233
+
234
+ // ---- Signed media URLs ----
235
+
236
+ /**
237
+ * Generate a signed media URL path with expiry.
238
+ * Format: /api/media/:userId/:filename?expires=<ts>&sig=<hex>
239
+ */
240
+ export async function signMediaUrl(
241
+ userId: string,
242
+ filename: string,
243
+ secret: string,
244
+ expiresInSeconds = 3600,
245
+ ): Promise<string> {
246
+ const expires = Math.floor(Date.now() / 1000) + expiresInSeconds;
247
+ const data = `${userId}/${filename}:${expires}`;
248
+ const sig = await hmacSign(secret, data);
249
+ return `/api/media/${userId}/${encodeURIComponent(filename)}?expires=${expires}&sig=${encodeURIComponent(sig)}`;
250
+ }
251
+
252
+ /**
253
+ * Verify a signed media URL.
254
+ * Returns true if the signature is valid and the URL has not expired.
255
+ */
256
+ export async function verifyMediaSignature(
257
+ userId: string,
258
+ filename: string,
259
+ expires: string,
260
+ sig: string,
261
+ secret: string,
262
+ ): Promise<boolean> {
263
+ const now = Math.floor(Date.now() / 1000);
264
+ const expiresNum = parseInt(expires, 10);
265
+ if (isNaN(expiresNum) || expiresNum < now) return false;
266
+
267
+ const data = `${userId}/${filename}:${expires}`;
268
+ const expected = await hmacSign(secret, data);
269
+ return sig === expected;
270
+ }
@@ -1,19 +1,35 @@
1
- /** Generate a short random ID (URL-safe). */
2
- export function generateId(prefix = ""): string {
3
- const chars = "abcdefghijklmnopqrstuvwxyz0123456789";
4
- let id = prefix;
5
- for (let i = 0; i < 16; i++) {
6
- id += chars[Math.floor(Math.random() * chars.length)];
1
+ /**
2
+ * Pick a uniformly random character from `chars` using rejection sampling.
3
+ * Avoids modulo bias that occurs when 256 % chars.length !== 0.
4
+ */
5
+ const CHARS = "abcdefghijklmnopqrstuvwxyz0123456789"; // 36 chars
6
+ const CHARS_LEN = CHARS.length;
7
+ // Largest multiple of CHARS_LEN that fits in a byte (252 = 36 * 7)
8
+ const MAX_VALID = CHARS_LEN * Math.floor(256 / CHARS_LEN) - 1; // 251
9
+
10
+ function uniformRandomChars(count: number): string {
11
+ let result = "";
12
+ // Allocate extra bytes to reduce the chance of needing multiple rounds
13
+ const buf = new Uint8Array(count + 16);
14
+ let pos = 0;
15
+ while (result.length < count) {
16
+ crypto.getRandomValues(buf);
17
+ for (pos = 0; pos < buf.length && result.length < count; pos++) {
18
+ if (buf[pos] <= MAX_VALID) {
19
+ result += CHARS[buf[pos] % CHARS_LEN];
20
+ }
21
+ // else: reject and continue (bias-free)
22
+ }
7
23
  }
8
- return id;
24
+ return result;
25
+ }
26
+
27
+ /** Generate a short random ID (URL-safe) using CSPRNG. */
28
+ export function generateId(prefix = ""): string {
29
+ return prefix + uniformRandomChars(16);
9
30
  }
10
31
 
11
- /** Generate a pairing token with bc_pat_ prefix. */
32
+ /** Generate a pairing token with bc_pat_ prefix using CSPRNG. */
12
33
  export function generatePairingToken(): string {
13
- const chars = "abcdefghijklmnopqrstuvwxyz0123456789";
14
- let token = "bc_pat_";
15
- for (let i = 0; i < 32; i++) {
16
- token += chars[Math.floor(Math.random() * chars.length)];
17
- }
18
- return token;
34
+ return "bc_pat_" + uniformRandomChars(32);
19
35
  }
@@ -0,0 +1,73 @@
1
+ import type { MiddlewareHandler } from "hono";
2
+ import type { Env } from "../env.js";
3
+
4
+ /**
5
+ * Simple in-memory sliding-window rate limiter for Cloudflare Workers.
6
+ *
7
+ * Note: Each CF Worker isolate has its own memory, so this is per-isolate.
8
+ * For stronger guarantees, use Cloudflare Rate Limiting Rules in the Dashboard.
9
+ * This middleware provides a best-effort defense against brute-force attacks.
10
+ */
11
+
12
+ type RateLimitEntry = {
13
+ timestamps: number[];
14
+ };
15
+
16
+ const store = new Map<string, RateLimitEntry>();
17
+
18
+ // Periodically clean up old entries to prevent memory leaks
19
+ let lastCleanup = Date.now();
20
+ function cleanup(windowMs: number): void {
21
+ const now = Date.now();
22
+ if (now - lastCleanup < windowMs) return;
23
+ lastCleanup = now;
24
+ const cutoff = now - windowMs;
25
+ for (const [key, entry] of store) {
26
+ entry.timestamps = entry.timestamps.filter((t) => t > cutoff);
27
+ if (entry.timestamps.length === 0) store.delete(key);
28
+ }
29
+ }
30
+
31
+ /**
32
+ * Create a rate-limiting middleware.
33
+ * @param maxRequests Maximum requests allowed in the window
34
+ * @param windowMs Time window in milliseconds (default: 60_000 = 1 minute)
35
+ */
36
+ export function rateLimit(
37
+ maxRequests: number,
38
+ windowMs = 60_000,
39
+ ): MiddlewareHandler<{ Bindings: Env }> {
40
+ return async (c, next) => {
41
+ // Use CF-Connecting-IP (real client IP behind Cloudflare)
42
+ const ip =
43
+ c.req.header("CF-Connecting-IP") ??
44
+ c.req.header("X-Forwarded-For")?.split(",")[0]?.trim() ??
45
+ "unknown";
46
+
47
+ const key = `${c.req.path}:${ip}`;
48
+ const now = Date.now();
49
+ const cutoff = now - windowMs;
50
+
51
+ cleanup(windowMs);
52
+
53
+ let entry = store.get(key);
54
+ if (!entry) {
55
+ entry = { timestamps: [] };
56
+ store.set(key, entry);
57
+ }
58
+
59
+ // Remove timestamps outside the window
60
+ entry.timestamps = entry.timestamps.filter((t) => t > cutoff);
61
+
62
+ if (entry.timestamps.length >= maxRequests) {
63
+ const retryAfter = Math.ceil((entry.timestamps[0] + windowMs - now) / 1000);
64
+ return c.json(
65
+ { error: "Too many requests. Please try again later." },
66
+ { status: 429, headers: { "Retry-After": String(retryAfter) } },
67
+ );
68
+ }
69
+
70
+ entry.timestamps.push(now);
71
+ await next();
72
+ };
73
+ }
@@ -1 +1 @@
1
- {"version":3,"file":"accounts.d.ts","sourceRoot":"","sources":["../../src/accounts.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAGV,uBAAuB,EACxB,MAAM,YAAY,CAAC;AAUpB,uCAAuC;AACvC,wBAAgB,sBAAsB,CAAC,GAAG,EAAE,OAAO,GAAG,MAAM,EAAE,CAgB7D;AAED,kCAAkC;AAClC,wBAAgB,+BAA+B,CAAC,GAAG,EAAE,OAAO,GAAG,MAAM,CAGpE;AAED,sCAAsC;AACtC,wBAAgB,sBAAsB,CACpC,GAAG,EAAE,OAAO,EACZ,SAAS,CAAC,EAAE,MAAM,GAAG,IAAI,GACxB,uBAAuB,CA6BzB;AAED,mEAAmE;AACnE,wBAAgB,qBAAqB,CACnC,GAAG,EAAE,OAAO,EACZ,SAAS,EAAE,MAAM,GAChB,OAAO,CAsBT;AAED,8DAA8D;AAC9D,wBAAgB,yBAAyB,CACvC,GAAG,EAAE,OAAO,EACZ,SAAS,EAAE,MAAM,EACjB,OAAO,EAAE,OAAO,GACf,OAAO,CAoBT"}
1
+ {"version":3,"file":"accounts.d.ts","sourceRoot":"","sources":["../../src/accounts.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAGV,uBAAuB,EACxB,MAAM,YAAY,CAAC;AAUpB,uCAAuC;AACvC,wBAAgB,sBAAsB,CAAC,GAAG,EAAE,OAAO,GAAG,MAAM,EAAE,CAgB7D;AAED,kCAAkC;AAClC,wBAAgB,+BAA+B,CAAC,GAAG,EAAE,OAAO,GAAG,MAAM,CAGpE;AAED,sCAAsC;AACtC,wBAAgB,sBAAsB,CACpC,GAAG,EAAE,OAAO,EACZ,SAAS,CAAC,EAAE,MAAM,GAAG,IAAI,GACxB,uBAAuB,CA8BzB;AAED,mEAAmE;AACnE,wBAAgB,qBAAqB,CACnC,GAAG,EAAE,OAAO,EACZ,SAAS,EAAE,MAAM,GAChB,OAAO,CAsBT;AAED,8DAA8D;AAC9D,wBAAgB,yBAAyB,CACvC,GAAG,EAAE,OAAO,EACZ,SAAS,EAAE,MAAM,EACjB,OAAO,EAAE,OAAO,GACf,OAAO,CAoBT"}
@@ -39,6 +39,7 @@ export function resolveBotsChatAccount(cfg, accountId) {
39
39
  name: s.name,
40
40
  cloudUrl: s.cloudUrl,
41
41
  pairingToken: s.pairingToken,
42
+ e2ePassword: s.e2ePassword,
42
43
  };
43
44
  }
44
45
  else {
@@ -1 +1 @@
1
- {"version":3,"file":"accounts.js","sourceRoot":"","sources":["../../src/accounts.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"accounts.js","sourceRoot":"","sources":["../../src/accounts.ts"],"names":[],"mappings":"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"}