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.
- package/README.md +64 -24
- package/migrations/0011_e2e_encryption.sql +35 -0
- package/package.json +7 -2
- package/packages/api/package.json +2 -1
- package/packages/api/src/do/connection-do.ts +162 -42
- package/packages/api/src/index.ts +132 -13
- package/packages/api/src/routes/auth.ts +127 -30
- package/packages/api/src/routes/pairing.ts +14 -1
- package/packages/api/src/routes/setup.ts +72 -24
- package/packages/api/src/routes/upload.ts +12 -8
- package/packages/api/src/utils/auth.ts +212 -43
- package/packages/api/src/utils/id.ts +30 -14
- package/packages/api/src/utils/rate-limit.ts +73 -0
- package/packages/plugin/dist/src/accounts.d.ts.map +1 -1
- package/packages/plugin/dist/src/accounts.js +1 -0
- package/packages/plugin/dist/src/accounts.js.map +1 -1
- package/packages/plugin/dist/src/channel.d.ts +1 -0
- package/packages/plugin/dist/src/channel.d.ts.map +1 -1
- package/packages/plugin/dist/src/channel.js +151 -9
- package/packages/plugin/dist/src/channel.js.map +1 -1
- package/packages/plugin/dist/src/types.d.ts +16 -0
- package/packages/plugin/dist/src/types.d.ts.map +1 -1
- package/packages/plugin/dist/src/ws-client.d.ts +2 -0
- package/packages/plugin/dist/src/ws-client.d.ts.map +1 -1
- package/packages/plugin/dist/src/ws-client.js +14 -3
- package/packages/plugin/dist/src/ws-client.js.map +1 -1
- package/packages/plugin/package.json +4 -3
- package/packages/web/dist/architecture.png +0 -0
- package/packages/web/dist/assets/index-BoNQoJjQ.js +1497 -0
- package/packages/web/dist/assets/{index-DuGeoFJT.css → index-ewBIratI.css} +1 -1
- package/packages/web/dist/botschat-icon.svg +4 -0
- package/packages/web/dist/index.html +23 -3
- package/packages/web/dist/manifest.json +24 -0
- package/packages/web/dist/sw.js +40 -0
- package/packages/web/index.html +21 -1
- package/packages/web/package.json +1 -0
- package/packages/web/src/App.tsx +286 -103
- package/packages/web/src/analytics.ts +57 -0
- package/packages/web/src/api.ts +67 -3
- package/packages/web/src/components/ChatWindow.tsx +11 -11
- package/packages/web/src/components/ConnectionSettings.tsx +477 -0
- package/packages/web/src/components/CronDetail.tsx +475 -235
- package/packages/web/src/components/CronSidebar.tsx +1 -1
- package/packages/web/src/components/DebugLogPanel.tsx +116 -3
- package/packages/web/src/components/E2ESettings.tsx +122 -0
- package/packages/web/src/components/IconRail.tsx +56 -27
- package/packages/web/src/components/JobList.tsx +2 -6
- package/packages/web/src/components/LoginPage.tsx +143 -104
- package/packages/web/src/components/MobileLayout.tsx +480 -0
- package/packages/web/src/components/OnboardingPage.tsx +159 -21
- package/packages/web/src/components/ResizeHandle.tsx +34 -0
- package/packages/web/src/components/Sidebar.tsx +1 -1
- package/packages/web/src/components/TaskBar.tsx +2 -2
- package/packages/web/src/components/ThreadPanel.tsx +2 -5
- package/packages/web/src/e2e.ts +133 -0
- package/packages/web/src/hooks/useIsMobile.ts +27 -0
- package/packages/web/src/index.css +59 -0
- package/packages/web/src/main.tsx +12 -0
- package/packages/web/src/store.ts +16 -8
- package/packages/web/src/ws.ts +78 -4
- package/scripts/dev.sh +16 -16
- package/scripts/test-e2e-live.ts +194 -0
- package/scripts/verify-e2e-db.ts +48 -0
- package/scripts/verify-e2e.ts +56 -0
- package/wrangler.toml +3 -1
- 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
|
-
|
|
59
|
-
"SELECT id, display_name FROM users WHERE email = ?",
|
|
60
|
-
).bind(email).first<{ id: string; display_name: string | null }>();
|
|
61
|
-
|
|
62
|
-
if (
|
|
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,
|
|
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 = ?
|
|
94
|
-
).bind(email
|
|
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
|
-
// ----
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
await c.env.DB.prepare(
|
|
108
|
-
|
|
109
|
-
|
|
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
|
|
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/
|
|
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
|
|
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
|
|
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
|
-
|
|
39
|
-
const
|
|
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
|
|
49
|
-
const
|
|
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
|
-
//
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
|
37
|
-
const
|
|
38
|
-
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
|
68
|
-
|
|
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
|
|
155
|
+
return `pbkdf2:${PBKDF2_ITERATIONS}:${saltHex}:${hashHex}`;
|
|
71
156
|
}
|
|
72
157
|
|
|
73
|
-
/**
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
|
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
|
-
/**
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
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
|
|
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
|
-
|
|
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,
|
|
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"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"accounts.js","sourceRoot":"","sources":["../../src/accounts.ts"],"names":[],"mappings":"AAMA,MAAM,kBAAkB,GAAG,SAAS,CAAC;AAErC,yDAAyD;AACzD,SAAS,OAAO,CAAC,GAAY;IAC3B,MAAM,CAAC,GAAG,GAA4B,CAAC;IACvC,OAAO,CAAC,EAAE,QAAQ,EAAE,QAAQ,IAAI,EAAE,CAAC;AACrC,CAAC;AAED,uCAAuC;AACvC,MAAM,UAAU,sBAAsB,CAAC,GAAY;IACjD,MAAM,CAAC,GAAG,OAAO,CAAC,GAAG,CAAC,CAAC;IACvB,MAAM,GAAG,GAAa,EAAE,CAAC;IACzB,mDAAmD;IACnD,IAAI,CAAC,CAAC,QAAQ,EAAE,CAAC;QACf,GAAG,CAAC,IAAI,CAAC,kBAAkB,CAAC,CAAC;IAC/B,CAAC;IACD,iBAAiB;IACjB,IAAI,CAAC,CAAC,QAAQ,EAAE,CAAC;QACf,KAAK,MAAM,EAAE,IAAI,MAAM,CAAC,IAAI,CAAC,CAAC,CAAC,QAAQ,CAAC,EAAE,CAAC;YACzC,IAAI,EAAE,KAAK,kBAAkB,IAAI,CAAC,CAAC,CAAC,QAAQ,EAAE,CAAC;gBAC7C,GAAG,CAAC,IAAI,CAAC,EAAE,CAAC,CAAC;YACf,CAAC;QACH,CAAC;IACH,CAAC;IACD,OAAO,GAAG,CAAC,MAAM,GAAG,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,kBAAkB,CAAC,CAAC;AACrD,CAAC;AAED,kCAAkC;AAClC,MAAM,UAAU,+BAA+B,CAAC,GAAY;IAC1D,MAAM,GAAG,GAAG,sBAAsB,CAAC,GAAG,CAAC,CAAC;IACxC,OAAO,GAAG,CAAC,CAAC,CAAC,IAAI,kBAAkB,CAAC;AACtC,CAAC;AAED,sCAAsC;AACtC,MAAM,UAAU,sBAAsB,CACpC,GAAY,EACZ,SAAyB;IAEzB,MAAM,CAAC,GAAG,OAAO,CAAC,GAAG,CAAC,CAAC;IACvB,MAAM,EAAE,GAAG,SAAS,IAAI,+BAA+B,CAAC,GAAG,CAAC,CAAC;IAC7D,IAAI,IAA2B,CAAC;IAEhC,IAAI,EAAE,KAAK,kBAAkB,IAAI,CAAC,CAAC,CAAC,QAAQ,EAAE,CAAC,EAAE,CAAC,EAAE,CAAC;QACnD,uBAAuB;QACvB,IAAI,GAAG;YACL,OAAO,EAAE,CAAC,CAAC,OAAO;YAClB,IAAI,EAAE,CAAC,CAAC,IAAI;YACZ,QAAQ,EAAE,CAAC,CAAC,QAAQ;YACpB,YAAY,EAAE,CAAC,CAAC,YAAY;
|
|
1
|
+
{"version":3,"file":"accounts.js","sourceRoot":"","sources":["../../src/accounts.ts"],"names":[],"mappings":"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"}
|