diff --git a/src/handlers/accounts.ts b/src/handlers/accounts.ts index 376f507..245ab40 100644 --- a/src/handlers/accounts.ts +++ b/src/handlers/accounts.ts @@ -27,12 +27,6 @@ export async function handleRegister(request: Request, env: Env): Promise { const storage = new StorageService(env.DB); + const auth = new AuthService(env); const user = await storage.getUserById(userId); if (!user) { @@ -217,7 +216,8 @@ export async function handleVerifyPassword(request: Request, env: Env, userId: s return errorResponse('masterPasswordHash is required', 400); } - if (body.masterPasswordHash !== user.masterPasswordHash) { + const valid = await auth.verifyPassword(body.masterPasswordHash, user.masterPasswordHash); + if (!valid) { return errorResponse('Invalid password', 400); } diff --git a/src/handlers/attachments.ts b/src/handlers/attachments.ts index 52233d9..de4cdae 100644 --- a/src/handlers/attachments.ts +++ b/src/handlers/attachments.ts @@ -1,4 +1,4 @@ -import { Env, Attachment } from '../types'; +import { Env, Attachment, DEFAULT_DEV_SECRET } from '../types'; import { StorageService } from '../services/storage'; import { jsonResponse, errorResponse } from '../utils/response'; import { generateUUID } from '../utils/uuid'; @@ -210,6 +210,11 @@ export async function handlePublicDownloadAttachment( cipherId: string, attachmentId: string ): Promise { + const secret = (env.JWT_SECRET || '').trim(); + if (!secret || secret.length < 32 || secret === DEFAULT_DEV_SECRET) { + return errorResponse('Server configuration error', 500); + } + const url = new URL(request.url); const token = url.searchParams.get('token'); diff --git a/src/handlers/ciphers.ts b/src/handlers/ciphers.ts index 6fecde2..79fce4f 100644 --- a/src/handlers/ciphers.ts +++ b/src/handlers/ciphers.ts @@ -68,10 +68,12 @@ export async function handleGetCiphers(request: Request, env: Env, userId: strin ? ciphers : ciphers.filter(c => !c.deletedAt); + const attachmentsByCipher = await storage.getAttachmentsByCipherIds(filteredCiphers.map(c => c.id)); + // Get attachments for all ciphers const cipherResponses = []; for (const cipher of filteredCiphers) { - const attachments = await storage.getAttachmentsByCipher(cipher.id); + const attachments = attachmentsByCipher.get(cipher.id) || []; cipherResponses.push(cipherToResponse(cipher, attachments)); } diff --git a/src/handlers/identity.ts b/src/handlers/identity.ts index d9afeed..db4f32c 100644 --- a/src/handlers/identity.ts +++ b/src/handlers/identity.ts @@ -32,12 +32,7 @@ export async function handleToken(request: Request, env: Env): Promise return identityErrorResponse('Email and password are required', 'invalid_request', 400); } - const user = await storage.getUser(email); - if (!user) { - return identityErrorResponse('Username or password is incorrect. Try again', 'invalid_grant', 400); - } - - // Check if login is rate limited (only after confirming user exists) + // Check login lockout before user lookup to reduce user-enumeration signal const loginCheck = await rateLimit.checkLoginAttempt(email); if (!loginCheck.allowed) { return identityErrorResponse( @@ -47,6 +42,12 @@ export async function handleToken(request: Request, env: Env): Promise ); } + const user = await storage.getUser(email); + if (!user) { + await rateLimit.recordFailedLogin(email); + return identityErrorResponse('Username or password is incorrect. Try again', 'invalid_grant', 400); + } + const valid = await auth.verifyPassword(passwordHash, user.masterPasswordHash); if (!valid) { // Record failed login attempt diff --git a/src/handlers/sync.ts b/src/handlers/sync.ts index e154049..50a8174 100644 --- a/src/handlers/sync.ts +++ b/src/handlers/sync.ts @@ -14,6 +14,7 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr const ciphers = await storage.getAllCiphers(userId); const folders = await storage.getAllFolders(userId); + const attachmentsByCipher = await storage.getAttachmentsByCipherIds(ciphers.map(c => c.id)); // Build profile response const profile: ProfileResponse = { @@ -43,7 +44,7 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr // Build cipher responses with attachments const cipherResponses: CipherResponse[] = []; for (const cipher of ciphers) { - const attachments = await storage.getAttachmentsByCipher(cipher.id); + const attachments = attachmentsByCipher.get(cipher.id) || []; cipherResponses.push(cipherToResponse(cipher, attachments)); } diff --git a/src/router.ts b/src/router.ts index a67411c..f32812c 100644 --- a/src/router.ts +++ b/src/router.ts @@ -1,4 +1,4 @@ -import { Env } from './types'; +import { Env, DEFAULT_DEV_SECRET } from './types'; import { AuthService } from './services/auth'; import { RateLimitService, getClientIdentifier } from './services/ratelimit'; import { handleCors, errorResponse, jsonResponse } from './utils/response'; @@ -184,7 +184,7 @@ export async function handleRequest(request: Request, env: Env): Promise { - // In Bitwarden, the client sends the password hash directly - // We compare the hashes - return inputHash === storedHash; + const input = new TextEncoder().encode(inputHash); + const stored = new TextEncoder().encode(storedHash); + if (input.length !== stored.length) return false; + + let diff = 0; + for (let i = 0; i < input.length; i++) { + diff |= input[i] ^ stored[i]; + } + return diff === 0; } // Generate access token diff --git a/src/services/storage.ts b/src/services/storage.ts index 4f1f7ed..9007be8 100644 --- a/src/services/storage.ts +++ b/src/services/storage.ts @@ -9,6 +9,17 @@ import { User, Cipher, Folder, Attachment } from '../types'; export class StorageService { constructor(private db: D1Database) {} + private async sha256Hex(input: string): Promise { + const bytes = new TextEncoder().encode(input); + const digest = await crypto.subtle.digest('SHA-256', bytes); + return Array.from(new Uint8Array(digest)).map(b => b.toString(16).padStart(2, '0')).join(''); + } + + private async refreshTokenKey(token: string): Promise { + const digest = await this.sha256Hex(token); + return `sha256:${digest}`; + } + // --- Database initialization --- // Idempotent auto-init for environments where D1 migrations have not been applied // (e.g. one-click deploy). Mirrors the schema in migrations/0001_init.sql — @@ -245,6 +256,35 @@ CREATE INDEX IF NOT EXISTS idx_api_rate_window ON api_rate_limits(window_start); .run(); } + async createFirstUser(user: User): Promise { + const email = user.email.toLowerCase(); + const result = await this.db + .prepare( + 'INSERT INTO users(id, email, name, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, created_at, updated_at) ' + + 'SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? ' + + 'WHERE NOT EXISTS (SELECT 1 FROM users LIMIT 1)' + ) + .bind( + user.id, + email, + user.name, + user.masterPasswordHash, + user.key, + user.privateKey, + user.publicKey, + user.kdfType, + user.kdfIterations, + user.kdfMemory ?? null, + user.kdfParallelism ?? null, + user.securityStamp, + user.createdAt, + user.updatedAt + ) + .run(); + + return (result.meta.changes ?? 0) > 0; + } + // --- Ciphers --- async getCipher(id: string): Promise { @@ -412,6 +452,36 @@ CREATE INDEX IF NOT EXISTS idx_api_rate_window ON api_rate_limits(window_start); })); } + async getAttachmentsByCipherIds(cipherIds: string[]): Promise> { + const grouped = new Map(); + if (cipherIds.length === 0) return grouped; + + const placeholders = cipherIds.map(() => '?').join(','); + const res = await this.db + .prepare(`SELECT id, cipher_id, file_name, size, size_name, key FROM attachments WHERE cipher_id IN (${placeholders})`) + .bind(...cipherIds) + .all(); + + for (const row of (res.results || [])) { + const item: Attachment = { + id: row.id, + cipherId: row.cipher_id, + fileName: row.file_name, + size: row.size, + sizeName: row.size_name, + key: row.key, + }; + const list = grouped.get(item.cipherId); + if (list) { + list.push(item); + } else { + grouped.set(item.cipherId, [item]); + } + } + + return grouped; + } + async addAttachmentToCipher(cipherId: string, attachmentId: string): Promise { // Kept for API compatibility; no-op because attachments table already links cipher_id. // We still validate that the attachment exists and belongs to cipher. @@ -441,20 +511,39 @@ CREATE INDEX IF NOT EXISTS idx_api_rate_window ON api_rate_limits(window_start); async saveRefreshToken(token: string, userId: string, expiresAtMs?: number): Promise { const expiresAt = expiresAtMs ?? (Date.now() + 30 * 24 * 60 * 60 * 1000); + const tokenKey = await this.refreshTokenKey(token); await this.db.prepare( 'INSERT INTO refresh_tokens(token, user_id, expires_at) VALUES(?, ?, ?) ' + 'ON CONFLICT(token) DO UPDATE SET user_id=excluded.user_id, expires_at=excluded.expires_at' ) - .bind(token, userId, expiresAt) + .bind(tokenKey, userId, expiresAt) .run(); } async getRefreshTokenUserId(token: string): Promise { const now = Date.now(); - const row = await this.db.prepare('SELECT user_id, expires_at FROM refresh_tokens WHERE token = ?') - .bind(token) + const tokenKey = await this.refreshTokenKey(token); + + let row = await this.db.prepare('SELECT user_id, expires_at FROM refresh_tokens WHERE token = ?') + .bind(tokenKey) .first<{ user_id: string; expires_at: number }>(); + if (!row) { + const legacyRow = await this.db.prepare('SELECT user_id, expires_at FROM refresh_tokens WHERE token = ?') + .bind(token) + .first<{ user_id: string; expires_at: number }>(); + + if (legacyRow) { + if (legacyRow.expires_at && legacyRow.expires_at < now) { + await this.deleteRefreshToken(token); + return null; + } + await this.saveRefreshToken(token, legacyRow.user_id, legacyRow.expires_at); + await this.db.prepare('DELETE FROM refresh_tokens WHERE token = ?').bind(token).run(); + return legacyRow.user_id; + } + } + if (!row) return null; if (row.expires_at && row.expires_at < now) { await this.deleteRefreshToken(token); @@ -464,7 +553,9 @@ CREATE INDEX IF NOT EXISTS idx_api_rate_window ON api_rate_limits(window_start); } async deleteRefreshToken(token: string): Promise { + const tokenKey = await this.refreshTokenKey(token); await this.db.prepare('DELETE FROM refresh_tokens WHERE token = ?').bind(token).run(); + await this.db.prepare('DELETE FROM refresh_tokens WHERE token = ?').bind(tokenKey).run(); } // --- Revision dates ---