import { User, Cipher, Folder, Attachment, Device } from '../types'; import { LIMITS } from '../config/limits'; const TWO_FACTOR_REMEMBER_TTL_MS = 30 * 24 * 60 * 60 * 1000; // IMPORTANT: // Keep this schema list in sync with migrations/0001_init.sql. // Any new table/column/index must be added to both places together. const SCHEMA_STATEMENTS: readonly string[] = [ 'CREATE TABLE IF NOT EXISTS users (' + 'id TEXT PRIMARY KEY, email TEXT NOT NULL UNIQUE, name TEXT, master_password_hash TEXT NOT NULL, ' + 'key TEXT NOT NULL, private_key TEXT, public_key TEXT, kdf_type INTEGER NOT NULL, ' + 'kdf_iterations INTEGER NOT NULL, kdf_memory INTEGER, kdf_parallelism INTEGER, ' + 'security_stamp TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL)', 'CREATE TABLE IF NOT EXISTS user_revisions (' + 'user_id TEXT PRIMARY KEY, revision_date TEXT NOT NULL, ' + 'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)', 'CREATE TABLE IF NOT EXISTS ciphers (' + 'id TEXT PRIMARY KEY, user_id TEXT NOT NULL, type INTEGER NOT NULL, folder_id TEXT, name TEXT, notes TEXT, ' + 'favorite INTEGER NOT NULL DEFAULT 0, data TEXT NOT NULL, reprompt INTEGER, key TEXT, ' + 'created_at TEXT NOT NULL, updated_at TEXT NOT NULL, deleted_at TEXT, ' + 'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)', 'CREATE INDEX IF NOT EXISTS idx_ciphers_user_updated ON ciphers(user_id, updated_at)', 'CREATE INDEX IF NOT EXISTS idx_ciphers_user_deleted ON ciphers(user_id, deleted_at)', 'CREATE TABLE IF NOT EXISTS folders (' + 'id TEXT PRIMARY KEY, user_id TEXT NOT NULL, name TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, ' + 'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)', 'CREATE INDEX IF NOT EXISTS idx_folders_user_updated ON folders(user_id, updated_at)', 'CREATE TABLE IF NOT EXISTS attachments (' + 'id TEXT PRIMARY KEY, cipher_id TEXT NOT NULL, file_name TEXT NOT NULL, size INTEGER NOT NULL, ' + 'size_name TEXT NOT NULL, key TEXT, ' + 'FOREIGN KEY (cipher_id) REFERENCES ciphers(id) ON DELETE CASCADE)', 'CREATE INDEX IF NOT EXISTS idx_attachments_cipher ON attachments(cipher_id)', 'CREATE TABLE IF NOT EXISTS refresh_tokens (' + 'token TEXT PRIMARY KEY, user_id TEXT NOT NULL, expires_at INTEGER NOT NULL, ' + 'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)', 'CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user ON refresh_tokens(user_id)', 'CREATE TABLE IF NOT EXISTS devices (' + 'user_id TEXT NOT NULL, device_identifier TEXT NOT NULL, name TEXT NOT NULL, type INTEGER NOT NULL, ' + 'created_at TEXT NOT NULL, updated_at TEXT NOT NULL, ' + 'PRIMARY KEY (user_id, device_identifier), ' + 'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)', 'CREATE INDEX IF NOT EXISTS idx_devices_user_updated ON devices(user_id, updated_at)', 'CREATE TABLE IF NOT EXISTS trusted_two_factor_device_tokens (' + 'token TEXT PRIMARY KEY, user_id TEXT NOT NULL, device_identifier TEXT NOT NULL, expires_at INTEGER NOT NULL, ' + 'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)', 'CREATE INDEX IF NOT EXISTS idx_trusted_two_factor_device_tokens_user_device ON trusted_two_factor_device_tokens(user_id, device_identifier)', 'CREATE TABLE IF NOT EXISTS api_rate_limits (' + 'identifier TEXT NOT NULL, window_start INTEGER NOT NULL, count INTEGER NOT NULL, ' + 'PRIMARY KEY (identifier, window_start))', 'CREATE INDEX IF NOT EXISTS idx_api_rate_window ON api_rate_limits(window_start)', 'CREATE TABLE IF NOT EXISTS login_attempts_ip (' + 'ip TEXT PRIMARY KEY, attempts INTEGER NOT NULL, locked_until INTEGER, updated_at INTEGER NOT NULL)', 'CREATE TABLE IF NOT EXISTS used_attachment_download_tokens (' + 'jti TEXT PRIMARY KEY, expires_at INTEGER NOT NULL)', ]; // D1-backed storage. // Contract: // - All methods are scoped by userId where applicable. // - Uses SQL constraints (PK/unique/FK) to avoid KV-style index race conditions. // - Revision date is maintained per user for Bitwarden sync. export class StorageService { private static attachmentTokenTableReady = false; private static schemaVerified = false; private static lastRefreshTokenCleanupAt = 0; private static lastAttachmentTokenCleanupAt = 0; private static readonly REFRESH_TOKEN_CLEANUP_INTERVAL_MS = LIMITS.cleanup.refreshTokenCleanupIntervalMs; private static readonly ATTACHMENT_TOKEN_CLEANUP_INTERVAL_MS = LIMITS.cleanup.attachmentTokenCleanupIntervalMs; private static readonly PERIODIC_CLEANUP_PROBABILITY = LIMITS.cleanup.cleanupProbability; constructor(private db: D1Database) {} /** * D1 .bind() throws on `undefined` values. This helper converts every * `undefined` in the argument list to `null` so we never hit that runtime * error - especially important after the opaque-passthrough change where * client-supplied JSON may omit fields we later reference as columns. */ private safeBind(stmt: D1PreparedStatement, ...values: any[]): D1PreparedStatement { return stmt.bind(...values.map(v => v === undefined ? null : v)); } 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}`; } private shouldRunPeriodicCleanup(lastRunAt: number, intervalMs: number): boolean { const now = Date.now(); if (now - lastRunAt < intervalMs) return false; return Math.random() < StorageService.PERIODIC_CLEANUP_PROBABILITY; } private async maybeCleanupExpiredRefreshTokens(nowMs: number): Promise { if (!this.shouldRunPeriodicCleanup(StorageService.lastRefreshTokenCleanupAt, StorageService.REFRESH_TOKEN_CLEANUP_INTERVAL_MS)) { return; } await this.db.prepare('DELETE FROM refresh_tokens WHERE expires_at < ?').bind(nowMs).run(); StorageService.lastRefreshTokenCleanupAt = nowMs; } // --- Database initialization --- // Strategy: // - Run only once per isolate. // - Execute idempotent schema SQL on first request in each isolate. // - Keep statements idempotent so updates are safe. async initializeDatabase(): Promise { if (StorageService.schemaVerified) return; await this.db.prepare('PRAGMA foreign_keys = ON').run(); await this.db.prepare('CREATE TABLE IF NOT EXISTS config (key TEXT PRIMARY KEY, value TEXT NOT NULL)').run(); for (const stmt of SCHEMA_STATEMENTS) { await this.executeSchemaStatement(stmt); } StorageService.schemaVerified = true; } private async executeSchemaStatement(statement: string): Promise { try { await this.db.prepare(statement).run(); } catch (error) { const msg = error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase(); // Keep migration resilient if a future non-idempotent DDL is retried. if (msg.includes('already exists') || msg.includes('duplicate column name')) { return; } throw error; } } // --- Config / setup --- async isRegistered(): Promise { const row = await this.db.prepare('SELECT value FROM config WHERE key = ?').bind('registered').first<{ value: string }>(); return row?.value === 'true'; } async setRegistered(): Promise { await this.db.prepare('INSERT INTO config(key, value) VALUES(?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value') .bind('registered', 'true') .run(); } async isSetupDisabled(): Promise { const row = await this.db.prepare('SELECT value FROM config WHERE key = ?').bind('setup_disabled').first<{ value: string }>(); return row?.value === 'true'; } async setSetupDisabled(): Promise { await this.db.prepare('INSERT INTO config(key, value) VALUES(?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value') .bind('setup_disabled', 'true') .run(); } // --- Users --- async getUser(email: string): Promise { const row = await this.db .prepare( 'SELECT 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 FROM users WHERE email = ?' ) .bind(email.toLowerCase()) .first(); if (!row) return null; return { id: row.id, email: row.email, name: row.name, masterPasswordHash: row.master_password_hash, key: row.key, privateKey: row.private_key, publicKey: row.public_key, kdfType: row.kdf_type, kdfIterations: row.kdf_iterations, kdfMemory: row.kdf_memory ?? undefined, kdfParallelism: row.kdf_parallelism ?? undefined, securityStamp: row.security_stamp, createdAt: row.created_at, updatedAt: row.updated_at, }; } async getUserById(id: string): Promise { const row = await this.db .prepare( 'SELECT 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 FROM users WHERE id = ?' ) .bind(id) .first(); if (!row) return null; return { id: row.id, email: row.email, name: row.name, masterPasswordHash: row.master_password_hash, key: row.key, privateKey: row.private_key, publicKey: row.public_key, kdfType: row.kdf_type, kdfIterations: row.kdf_iterations, kdfMemory: row.kdf_memory ?? undefined, kdfParallelism: row.kdf_parallelism ?? undefined, securityStamp: row.security_stamp, createdAt: row.created_at, updatedAt: row.updated_at, }; } async saveUser(user: User): Promise { const email = user.email.toLowerCase(); const stmt = 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) ' + 'VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' + 'ON CONFLICT(id) DO UPDATE SET ' + 'email=excluded.email, name=excluded.name, master_password_hash=excluded.master_password_hash, key=excluded.key, private_key=excluded.private_key, public_key=excluded.public_key, ' + 'kdf_type=excluded.kdf_type, kdf_iterations=excluded.kdf_iterations, kdf_memory=excluded.kdf_memory, kdf_parallelism=excluded.kdf_parallelism, security_stamp=excluded.security_stamp, updated_at=excluded.updated_at' ); await this.safeBind(stmt, user.id, email, user.name, user.masterPasswordHash, user.key, user.privateKey, user.publicKey, user.kdfType, user.kdfIterations, user.kdfMemory, user.kdfParallelism, user.securityStamp, user.createdAt, user.updatedAt ).run(); } async createFirstUser(user: User): Promise { const email = user.email.toLowerCase(); const stmt = 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)' ); const result = await this.safeBind(stmt, user.id, email, user.name, user.masterPasswordHash, user.key, user.privateKey, user.publicKey, user.kdfType, user.kdfIterations, user.kdfMemory, user.kdfParallelism, user.securityStamp, user.createdAt, user.updatedAt ).run(); return (result.meta.changes ?? 0) > 0; } // --- Ciphers --- async getCipher(id: string): Promise { const row = await this.db.prepare('SELECT data FROM ciphers WHERE id = ?').bind(id).first<{ data: string }>(); return row?.data ? (JSON.parse(row.data) as Cipher) : null; } async saveCipher(cipher: Cipher): Promise { const data = JSON.stringify(cipher); const stmt = this.db.prepare( 'INSERT INTO ciphers(id, user_id, type, folder_id, name, notes, favorite, data, reprompt, key, created_at, updated_at, deleted_at) ' + 'VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' + 'ON CONFLICT(id) DO UPDATE SET ' + 'user_id=excluded.user_id, type=excluded.type, folder_id=excluded.folder_id, name=excluded.name, notes=excluded.notes, favorite=excluded.favorite, data=excluded.data, reprompt=excluded.reprompt, key=excluded.key, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at' ); await this.safeBind(stmt, cipher.id, cipher.userId, Number(cipher.type) || 1, cipher.folderId, cipher.name, cipher.notes, cipher.favorite ? 1 : 0, data, cipher.reprompt ?? 0, cipher.key, cipher.createdAt, cipher.updatedAt, cipher.deletedAt ).run(); } async deleteCipher(id: string, userId: string): Promise { // hard delete await this.db.prepare('DELETE FROM ciphers WHERE id = ? AND user_id = ?').bind(id, userId).run(); } async getAllCiphers(userId: string): Promise { const res = await this.db.prepare('SELECT data FROM ciphers WHERE user_id = ? ORDER BY updated_at DESC').bind(userId).all<{ data: string }>(); return (res.results || []).map(r => JSON.parse(r.data) as Cipher); } async getCiphersPage(userId: string, includeDeleted: boolean, limit: number, offset: number): Promise { const whereDeleted = includeDeleted ? '' : 'AND deleted_at IS NULL'; const res = await this.db .prepare( `SELECT data FROM ciphers WHERE user_id = ? ${whereDeleted} ORDER BY updated_at DESC LIMIT ? OFFSET ?` ) .bind(userId, limit, offset) .all<{ data: string }>(); return (res.results || []).map(r => JSON.parse(r.data) as Cipher); } async getCiphersByIds(ids: string[], userId: string): Promise { if (ids.length === 0) return []; // D1 doesn't support binding arrays directly; build placeholders. const placeholders = ids.map(() => '?').join(','); const stmt = this.db.prepare(`SELECT data FROM ciphers WHERE user_id = ? AND id IN (${placeholders})`); const res = await stmt.bind(userId, ...ids).all<{ data: string }>(); return (res.results || []).map(r => JSON.parse(r.data) as Cipher); } async bulkMoveCiphers(ids: string[], folderId: string | null, userId: string): Promise { if (ids.length === 0) return; const now = new Date().toISOString(); const uniqueIds = Array.from(new Set(ids)); const patch = JSON.stringify({ folderId, updatedAt: now, }); const chunkSize = LIMITS.performance.bulkMoveChunkSize; for (let i = 0; i < uniqueIds.length; i += chunkSize) { const chunk = uniqueIds.slice(i, i + chunkSize); const placeholders = chunk.map(() => '?').join(','); await this.db .prepare( `UPDATE ciphers SET folder_id = ?, updated_at = ?, data = json_patch(data, ?) WHERE user_id = ? AND id IN (${placeholders})` ) .bind(folderId, now, patch, userId, ...chunk) .run(); } await this.updateRevisionDate(userId); } // --- Folders --- async getFolder(id: string): Promise { const row = await this.db .prepare('SELECT id, user_id, name, created_at, updated_at FROM folders WHERE id = ?') .bind(id) .first(); if (!row) return null; return { id: row.id, userId: row.user_id, name: row.name, createdAt: row.created_at, updatedAt: row.updated_at, }; } async saveFolder(folder: Folder): Promise { await this.db .prepare( 'INSERT INTO folders(id, user_id, name, created_at, updated_at) VALUES(?, ?, ?, ?, ?) ' + 'ON CONFLICT(id) DO UPDATE SET user_id=excluded.user_id, name=excluded.name, updated_at=excluded.updated_at' ) .bind(folder.id, folder.userId, folder.name, folder.createdAt, folder.updatedAt) .run(); } async deleteFolder(id: string, userId: string): Promise { await this.db.prepare('DELETE FROM folders WHERE id = ? AND user_id = ?').bind(id, userId).run(); } // Clear folder references from all ciphers owned by the user. // Without this, deleting a folder leaves stale folderId values in cipher JSON. async clearFolderFromCiphers(userId: string, folderId: string): Promise { const now = new Date().toISOString(); const res = await this.db .prepare('SELECT data FROM ciphers WHERE user_id = ? AND folder_id = ?') .bind(userId, folderId) .all<{ data: string }>(); for (const row of (res.results || [])) { const cipher = JSON.parse(row.data) as Cipher; cipher.folderId = null; cipher.updatedAt = now; await this.saveCipher(cipher); } } async getAllFolders(userId: string): Promise { const res = await this.db .prepare('SELECT id, user_id, name, created_at, updated_at FROM folders WHERE user_id = ? ORDER BY updated_at DESC') .bind(userId) .all(); return (res.results || []).map(r => ({ id: r.id, userId: r.user_id, name: r.name, createdAt: r.created_at, updatedAt: r.updated_at, })); } async getFoldersPage(userId: string, limit: number, offset: number): Promise { const res = await this.db .prepare( 'SELECT id, user_id, name, created_at, updated_at FROM folders WHERE user_id = ? ORDER BY updated_at DESC LIMIT ? OFFSET ?' ) .bind(userId, limit, offset) .all(); return (res.results || []).map(r => ({ id: r.id, userId: r.user_id, name: r.name, createdAt: r.created_at, updatedAt: r.updated_at, })); } // --- Attachments --- async getAttachment(id: string): Promise { const row = await this.db .prepare('SELECT id, cipher_id, file_name, size, size_name, key FROM attachments WHERE id = ?') .bind(id) .first(); if (!row) return null; return { id: row.id, cipherId: row.cipher_id, fileName: row.file_name, size: row.size, sizeName: row.size_name, key: row.key, }; } async saveAttachment(attachment: Attachment): Promise { const stmt = this.db.prepare( 'INSERT INTO attachments(id, cipher_id, file_name, size, size_name, key) VALUES(?, ?, ?, ?, ?, ?) ' + 'ON CONFLICT(id) DO UPDATE SET cipher_id=excluded.cipher_id, file_name=excluded.file_name, size=excluded.size, size_name=excluded.size_name, key=excluded.key' ); await this.safeBind(stmt, attachment.id, attachment.cipherId, attachment.fileName, attachment.size, attachment.sizeName, attachment.key).run(); } async deleteAttachment(id: string): Promise { await this.db.prepare('DELETE FROM attachments WHERE id = ?').bind(id).run(); } async getAttachmentsByCipher(cipherId: string): Promise { const res = await this.db .prepare('SELECT id, cipher_id, file_name, size, size_name, key FROM attachments WHERE cipher_id = ?') .bind(cipherId) .all(); return (res.results || []).map(r => ({ id: r.id, cipherId: r.cipher_id, fileName: r.file_name, size: r.size, sizeName: r.size_name, key: r.key, })); } async getAttachmentsByCipherIds(cipherIds: string[]): Promise> { const grouped = new Map(); if (cipherIds.length === 0) return grouped; const uniqueCipherIds = [...new Set(cipherIds)]; const chunkSize = LIMITS.performance.bulkMoveChunkSize; for (let i = 0; i < uniqueCipherIds.length; i += chunkSize) { const chunk = uniqueCipherIds.slice(i, i + chunkSize); const placeholders = chunk.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(...chunk) .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 getAttachmentsByUserId(userId: string): Promise> { const grouped = new Map(); const res = await this.db .prepare( `SELECT a.id, a.cipher_id, a.file_name, a.size, a.size_name, a.key FROM attachments a INNER JOIN ciphers c ON c.id = a.cipher_id WHERE c.user_id = ?` ) .bind(userId) .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. await this.db.prepare('UPDATE attachments SET cipher_id = ? WHERE id = ?').bind(cipherId, attachmentId).run(); } async removeAttachmentFromCipher(cipherId: string, attachmentId: string): Promise { // No-op: schema uses NOT NULL cipher_id. // Callers always delete attachment row afterwards, so this method is kept for compatibility only. void cipherId; void attachmentId; } async deleteAllAttachmentsByCipher(cipherId: string): Promise { await this.db.prepare('DELETE FROM attachments WHERE cipher_id = ?').bind(cipherId).run(); } async updateCipherRevisionDate(cipherId: string): Promise { const cipher = await this.getCipher(cipherId); if (!cipher) return; cipher.updatedAt = new Date().toISOString(); await this.saveCipher(cipher); await this.updateRevisionDate(cipher.userId); } // --- Refresh tokens --- async saveRefreshToken(token: string, userId: string, expiresAtMs?: number): Promise { const expiresAt = expiresAtMs ?? (Date.now() + LIMITS.auth.refreshTokenTtlMs); await this.maybeCleanupExpiredRefreshTokens(Date.now()); 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(tokenKey, userId, expiresAt) .run(); } async getRefreshTokenUserId(token: string): Promise { const now = Date.now(); await this.maybeCleanupExpiredRefreshTokens(now); 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); return null; } return row.user_id; } 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(); } // Keep a short overlap window for rotated refresh token to reduce // multi-context refresh races (e.g. browser extension popup/background). // Expiry is only tightened, never extended. async constrainRefreshTokenExpiry(token: string, maxExpiresAtMs: number): Promise { const tokenKey = await this.refreshTokenKey(token); await this.db.prepare( 'UPDATE refresh_tokens ' + 'SET expires_at = CASE WHEN expires_at > ? THEN ? ELSE expires_at END ' + 'WHERE token = ?' ).bind(maxExpiresAtMs, maxExpiresAtMs, tokenKey).run(); // Best-effort legacy plaintext support for older rows. await this.db.prepare( 'UPDATE refresh_tokens ' + 'SET expires_at = CASE WHEN expires_at > ? THEN ? ELSE expires_at END ' + 'WHERE token = ?' ).bind(maxExpiresAtMs, maxExpiresAtMs, token).run(); } private async trustedTwoFactorTokenKey(token: string): Promise { const digest = await this.sha256Hex(token); return `sha256:${digest}`; } // --- Devices --- async upsertDevice(userId: string, deviceIdentifier: string, name: string, type: number): Promise { const now = new Date().toISOString(); await this.db.prepare( 'INSERT INTO devices(user_id, device_identifier, name, type, created_at, updated_at) VALUES(?, ?, ?, ?, ?, ?) ' + 'ON CONFLICT(user_id, device_identifier) DO UPDATE SET name=excluded.name, type=excluded.type, updated_at=excluded.updated_at' ) .bind(userId, deviceIdentifier, name, type, now, now) .run(); } async isKnownDevice(userId: string, deviceIdentifier: string): Promise { const row = await this.db .prepare('SELECT 1 FROM devices WHERE user_id = ? AND device_identifier = ? LIMIT 1') .bind(userId, deviceIdentifier) .first<{ '1': number }>(); return !!row; } async isKnownDeviceByEmail(email: string, deviceIdentifier: string): Promise { const user = await this.getUser(email); if (!user) return false; return this.isKnownDevice(user.id, deviceIdentifier); } async getDevicesByUserId(userId: string): Promise { const res = await this.db .prepare( 'SELECT user_id, device_identifier, name, type, created_at, updated_at ' + 'FROM devices WHERE user_id = ? ORDER BY updated_at DESC' ) .bind(userId) .all(); return (res.results || []).map(row => ({ userId: row.user_id, deviceIdentifier: row.device_identifier, name: row.name, type: row.type, createdAt: row.created_at, updatedAt: row.updated_at, })); } // --- Trusted 2FA remember tokens (device-bound) --- async saveTrustedTwoFactorDeviceToken( token: string, userId: string, deviceIdentifier: string, expiresAtMs?: number ): Promise { const expiresAt = expiresAtMs ?? (Date.now() + TWO_FACTOR_REMEMBER_TTL_MS); const tokenKey = await this.trustedTwoFactorTokenKey(token); await this.db.prepare('DELETE FROM trusted_two_factor_device_tokens WHERE expires_at < ?').bind(Date.now()).run(); await this.db.prepare( 'INSERT INTO trusted_two_factor_device_tokens(token, user_id, device_identifier, expires_at) VALUES(?, ?, ?, ?) ' + 'ON CONFLICT(token) DO UPDATE SET user_id=excluded.user_id, device_identifier=excluded.device_identifier, expires_at=excluded.expires_at' ) .bind(tokenKey, userId, deviceIdentifier, expiresAt) .run(); } async getTrustedTwoFactorDeviceTokenUserId(token: string, deviceIdentifier: string): Promise { const now = Date.now(); const tokenKey = await this.trustedTwoFactorTokenKey(token); const row = await this.db .prepare( 'SELECT user_id, expires_at FROM trusted_two_factor_device_tokens WHERE token = ? AND device_identifier = ?' ) .bind(tokenKey, deviceIdentifier) .first<{ user_id: string; expires_at: number }>(); if (!row) return null; if (row.expires_at && row.expires_at < now) { await this.db.prepare('DELETE FROM trusted_two_factor_device_tokens WHERE token = ?').bind(tokenKey).run(); return null; } return row.user_id; } // --- Revision dates --- async getRevisionDate(userId: string): Promise { const row = await this.db.prepare('SELECT revision_date FROM user_revisions WHERE user_id = ?') .bind(userId) .first<{ revision_date: string }>(); if (row?.revision_date) return row.revision_date; const date = new Date().toISOString(); await this.db .prepare( 'INSERT INTO user_revisions(user_id, revision_date) VALUES(?, ?) ' + 'ON CONFLICT(user_id) DO NOTHING' ) .bind(userId, date) .run(); return date; } async updateRevisionDate(userId: string): Promise { const date = new Date().toISOString(); await this.db.prepare( 'INSERT INTO user_revisions(user_id, revision_date) VALUES(?, ?) ' + 'ON CONFLICT(user_id) DO UPDATE SET revision_date = excluded.revision_date' ) .bind(userId, date) .run(); return date; } // --- One-time attachment download tokens --- private async ensureUsedAttachmentDownloadTokenTable(): Promise { if (StorageService.attachmentTokenTableReady) return; await this.db.prepare( 'CREATE TABLE IF NOT EXISTS used_attachment_download_tokens (' + 'jti TEXT PRIMARY KEY, ' + 'expires_at INTEGER NOT NULL' + ')' ).run(); StorageService.attachmentTokenTableReady = true; } // Marks an attachment download token JTI as consumed. // Returns true only on first use. Reuse returns false. async consumeAttachmentDownloadToken(jti: string, expUnixSeconds: number): Promise { await this.ensureUsedAttachmentDownloadTokenTable(); const nowMs = Date.now(); if ( this.shouldRunPeriodicCleanup( StorageService.lastAttachmentTokenCleanupAt, StorageService.ATTACHMENT_TOKEN_CLEANUP_INTERVAL_MS ) ) { await this.db.prepare('DELETE FROM used_attachment_download_tokens WHERE expires_at < ?').bind(nowMs).run(); StorageService.lastAttachmentTokenCleanupAt = nowMs; } const expiresAtMs = expUnixSeconds * 1000; const result = await this.db.prepare( 'INSERT INTO used_attachment_download_tokens(jti, expires_at) VALUES(?, ?) ' + 'ON CONFLICT(jti) DO NOTHING' ).bind(jti, expiresAtMs).run(); return (result.meta.changes ?? 0) > 0; } }