mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-20 13:00:39 +00:00
18d3490c4f
- Added functions for managing account passkeys including creation, listing, updating, and deletion. - Introduced login methods using account passkeys with options for direct unlock and login-only modes. - Enhanced error handling and response parsing for passkey-related API calls. - Updated UI styles for account passkey management components. - Added new translations for account passkey features in multiple languages. - Modified network status handling to improve service reachability checks.
788 lines
29 KiB
TypeScript
788 lines
29 KiB
TypeScript
import { User, Cipher, Folder, Attachment, Device, Invite, AuditLog, Send, TrustedDeviceTokenSummary, RefreshTokenRecord, CustomEquivalentDomain, AccountPasskeyChallenge, AccountPasskeyChallengeScope, AccountPasskeyCredential } from '../types';
|
|
import { LIMITS } from '../config/limits';
|
|
import { ensureStorageSchema } from './storage-schema';
|
|
import {
|
|
getConfigValue as getStoredConfigValue,
|
|
isRegistered as getRegisteredFlag,
|
|
setConfigValue as saveConfigValue,
|
|
setRegistered as saveRegisteredFlag,
|
|
} from './storage-config-repo';
|
|
import {
|
|
createFirstUser as createFirstStoredUser,
|
|
createUser as createStoredUser,
|
|
deleteUserById as deleteStoredUserById,
|
|
getAllUsers as listStoredUsers,
|
|
getUser as findStoredUserByEmail,
|
|
getUserById as findStoredUserById,
|
|
getUserCount as countStoredUsers,
|
|
saveUser as saveStoredUser,
|
|
} from './storage-user-repo';
|
|
import {
|
|
type AuditLogListOptions,
|
|
createAuditLog as createStoredAuditLog,
|
|
clearAuditLogs as clearStoredAuditLogs,
|
|
createInvite as createStoredInvite,
|
|
deleteAllInvites as deleteStoredInvites,
|
|
getInvite as findStoredInvite,
|
|
listAuditLogs as listStoredAuditLogs,
|
|
listInvites as listStoredInvites,
|
|
markInviteUsed as markStoredInviteUsed,
|
|
pruneAuditLogs as pruneStoredAuditLogs,
|
|
pruneAuditLogsToMax as pruneStoredAuditLogsToMax,
|
|
revokeInvite as revokeStoredInvite,
|
|
} from './storage-admin-repo';
|
|
import {
|
|
bulkDeleteFolders as deleteStoredFolders,
|
|
clearFolderFromCiphers as clearStoredFolderFromCiphers,
|
|
deleteFolder as deleteStoredFolder,
|
|
getAllFolders as listStoredFolders,
|
|
getFolder as findStoredFolder,
|
|
getFoldersPage as listStoredFoldersPage,
|
|
saveFolder as saveStoredFolder,
|
|
} from './storage-folder-repo';
|
|
import {
|
|
bulkArchiveCiphers as archiveStoredCiphers,
|
|
bulkDeleteCiphers as deleteStoredCiphers,
|
|
bulkMoveCiphers as moveStoredCiphers,
|
|
bulkRestoreCiphers as restoreStoredCiphers,
|
|
bulkSoftDeleteCiphers as softDeleteStoredCiphers,
|
|
bulkUnarchiveCiphers as unarchiveStoredCiphers,
|
|
getAllCiphers as listStoredCiphers,
|
|
getCipher as findStoredCipher,
|
|
getCiphersByIds as listStoredCiphersByIds,
|
|
getCiphersPage as listStoredCiphersPage,
|
|
saveCipher as saveStoredCipher,
|
|
deleteCipher as deleteStoredCipher,
|
|
} from './storage-cipher-repo';
|
|
import {
|
|
addAttachmentToCipher as attachStoredAttachmentToCipher,
|
|
bulkDeleteAttachmentsByIds as deleteStoredAttachmentsByIds,
|
|
deleteAllAttachmentsByCipher as deleteStoredAttachmentsByCipher,
|
|
deleteAttachment as deleteStoredAttachment,
|
|
getAttachment as findStoredAttachment,
|
|
getAttachmentsByCipher as listStoredAttachmentsByCipher,
|
|
getAttachmentsByCipherIds as listStoredAttachmentsByCipherIds,
|
|
getAttachmentsByUserId as listStoredAttachmentsByUserId,
|
|
saveAttachment as saveStoredAttachment,
|
|
updateCipherRevisionDate as updateStoredCipherRevisionDate,
|
|
} from './storage-attachment-repo';
|
|
import {
|
|
bulkDeleteSends as deleteStoredSends,
|
|
deleteSend as deleteStoredSend,
|
|
getAllSends as listStoredSends,
|
|
getSend as findStoredSend,
|
|
getSendsByIds as listStoredSendsByIds,
|
|
getSendsPage as listStoredSendsPage,
|
|
incrementSendAccessCount as incrementStoredSendAccessCount,
|
|
saveSend as saveStoredSend,
|
|
} from './storage-send-repo';
|
|
import {
|
|
constrainRefreshTokenExpiry as constrainStoredRefreshTokenExpiry,
|
|
deleteRefreshToken as deleteStoredRefreshToken,
|
|
deleteRefreshTokensByDevice as deleteStoredRefreshTokensByDevice,
|
|
deleteRefreshTokensByUserId as deleteStoredRefreshTokensByUserId,
|
|
getRefreshTokenRecord as findStoredRefreshTokenRecord,
|
|
saveRefreshToken as saveStoredRefreshToken,
|
|
} from './storage-refresh-token-repo';
|
|
import {
|
|
deleteDevice as deleteStoredDevice,
|
|
deleteDevicesByUserId as deleteStoredDevicesByUserId,
|
|
clearDeviceKeys as clearStoredDeviceKeys,
|
|
deleteTrustedTwoFactorTokensByDevice as deleteStoredTrustedTokensByDevice,
|
|
deleteTrustedTwoFactorTokensByUserId as deleteStoredTrustedTokensByUserId,
|
|
getDevice as findStoredDevice,
|
|
getDevicesByUserId as listStoredDevicesByUserId,
|
|
getTrustedDeviceTokenSummariesByUserId as listStoredTrustedTokenSummaries,
|
|
getTrustedTwoFactorDeviceTokenUserId as findStoredTrustedTokenUserId,
|
|
isKnownDevice as getKnownStoredDevice,
|
|
isKnownDeviceByEmail as getKnownStoredDeviceByEmail,
|
|
saveTrustedTwoFactorDeviceToken as saveStoredTrustedDeviceToken,
|
|
touchDeviceLastSeen as touchStoredDeviceLastSeen,
|
|
upsertDevice as saveStoredDevice,
|
|
updateDeviceName as updateStoredDeviceName,
|
|
updateDeviceKeys as updateStoredDeviceKeys,
|
|
updateTrustedTwoFactorTokensExpiryByDevice as updateStoredTrustedTokensExpiryByDevice,
|
|
} from './storage-device-repo';
|
|
import {
|
|
ensureUsedAttachmentDownloadTokenTable as ensureStoredAttachmentTokenTable,
|
|
consumeAttachmentDownloadToken as consumeStoredAttachmentDownloadToken,
|
|
} from './storage-attachment-token-repo';
|
|
import {
|
|
getRevisionDate as getStoredRevisionDate,
|
|
updateRevisionDate as updateStoredRevisionDate,
|
|
} from './storage-revision-repo';
|
|
import {
|
|
getUserDomainSettings as getStoredUserDomainSettings,
|
|
saveUserDomainSettings as saveStoredUserDomainSettings,
|
|
} from './storage-domain-rules-repo';
|
|
import {
|
|
consumeAccountPasskeyChallenge as consumeStoredAccountPasskeyChallenge,
|
|
countAccountPasskeyCredentialsByUserId as countStoredAccountPasskeyCredentialsByUserId,
|
|
deleteAccountPasskeyCredential as deleteStoredAccountPasskeyCredential,
|
|
getAccountPasskeyCredentialByCredentialId as findStoredAccountPasskeyCredentialByCredentialId,
|
|
getAccountPasskeyCredentialById as findStoredAccountPasskeyCredentialById,
|
|
listAccountPasskeyCredentialsByUserId as listStoredAccountPasskeyCredentialsByUserId,
|
|
saveAccountPasskeyChallenge as saveStoredAccountPasskeyChallenge,
|
|
saveAccountPasskeyCredential as saveStoredAccountPasskeyCredential,
|
|
updateAccountPasskeyCounter as updateStoredAccountPasskeyCounter,
|
|
updateAccountPasskeyEncryption as updateStoredAccountPasskeyEncryption,
|
|
} from './storage-account-passkey-repo';
|
|
|
|
const TWO_FACTOR_REMEMBER_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
|
const STORAGE_SCHEMA_VERSION_KEY = 'schema.version';
|
|
// IMPORTANT:
|
|
// Bump this whenever src/services/storage-schema.ts or migrations/0001_init.sql
|
|
// changes. Existing D1 installs only rerun ensureStorageSchema() when this value
|
|
// differs from config.schema.version.
|
|
const STORAGE_SCHEMA_VERSION = '2026-06-09-account-passkeys';
|
|
const REQUIRED_ACCOUNT_PASSKEY_TABLES = ['webauthn_credentials', 'webauthn_challenges'] as const;
|
|
|
|
// 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 MAX_D1_SQL_VARIABLES = 100;
|
|
|
|
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 hasAccountPasskeyTables(): Promise<boolean> {
|
|
const placeholders = REQUIRED_ACCOUNT_PASSKEY_TABLES.map(() => '?').join(', ');
|
|
const result = await this.db
|
|
.prepare(`SELECT name FROM sqlite_master WHERE type = 'table' AND name IN (${placeholders})`)
|
|
.bind(...REQUIRED_ACCOUNT_PASSKEY_TABLES)
|
|
.all<{ name: string }>();
|
|
const found = new Set((result.results || []).map((row) => row.name));
|
|
return REQUIRED_ACCOUNT_PASSKEY_TABLES.every((table) => found.has(table));
|
|
}
|
|
|
|
private sqlChunkSize(fixedBindCount: number): number {
|
|
return Math.max(
|
|
1,
|
|
Math.min(LIMITS.performance.bulkMoveChunkSize, StorageService.MAX_D1_SQL_VARIABLES - fixedBindCount)
|
|
);
|
|
}
|
|
|
|
private async sha256Hex(input: string): Promise<string> {
|
|
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<string> {
|
|
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<void> {
|
|
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<void> {
|
|
if (StorageService.schemaVerified) return;
|
|
|
|
await this.db.prepare('CREATE TABLE IF NOT EXISTS config (key TEXT PRIMARY KEY, value TEXT NOT NULL)').run();
|
|
const schemaVersion = await getStoredConfigValue(this.db, STORAGE_SCHEMA_VERSION_KEY);
|
|
const schemaMissingRequiredTables = schemaVersion === STORAGE_SCHEMA_VERSION
|
|
? !(await this.hasAccountPasskeyTables())
|
|
: true;
|
|
if (schemaVersion !== STORAGE_SCHEMA_VERSION || schemaMissingRequiredTables) {
|
|
await ensureStorageSchema(this.db);
|
|
await saveConfigValue(this.db, STORAGE_SCHEMA_VERSION_KEY, STORAGE_SCHEMA_VERSION);
|
|
}
|
|
|
|
StorageService.schemaVerified = true;
|
|
}
|
|
|
|
// --- Config / setup ---
|
|
|
|
async isRegistered(): Promise<boolean> {
|
|
return getRegisteredFlag(this.db);
|
|
}
|
|
|
|
async getConfigValue(key: string): Promise<string | null> {
|
|
return getStoredConfigValue(this.db, key);
|
|
}
|
|
|
|
async setConfigValue(key: string, value: string): Promise<void> {
|
|
await saveConfigValue(this.db, key, value);
|
|
}
|
|
|
|
async setRegistered(): Promise<void> {
|
|
await saveRegisteredFlag(this.db);
|
|
}
|
|
|
|
// --- Users ---
|
|
|
|
async getUser(email: string): Promise<User | null> {
|
|
return findStoredUserByEmail(this.db, email);
|
|
}
|
|
|
|
async getUserById(id: string): Promise<User | null> {
|
|
return findStoredUserById(this.db, id);
|
|
}
|
|
|
|
async getUserCount(): Promise<number> {
|
|
return countStoredUsers(this.db);
|
|
}
|
|
|
|
async getAllUsers(): Promise<User[]> {
|
|
return listStoredUsers(this.db);
|
|
}
|
|
|
|
async saveUser(user: User): Promise<void> {
|
|
await saveStoredUser(this.db, this.safeBind.bind(this), user);
|
|
}
|
|
|
|
async createUser(user: User): Promise<void> {
|
|
await createStoredUser(this.db, this.safeBind.bind(this), user);
|
|
}
|
|
|
|
async createFirstUser(user: User): Promise<boolean> {
|
|
return createFirstStoredUser(this.db, this.safeBind.bind(this), user);
|
|
}
|
|
|
|
async deleteUserById(id: string): Promise<boolean> {
|
|
return deleteStoredUserById(this.db, id);
|
|
}
|
|
|
|
async createInvite(invite: Invite): Promise<void> {
|
|
await createStoredInvite(this.db, invite);
|
|
}
|
|
|
|
async getInvite(code: string): Promise<Invite | null> {
|
|
return findStoredInvite(this.db, code);
|
|
}
|
|
|
|
async listInvites(includeInactive: boolean = false): Promise<Invite[]> {
|
|
return listStoredInvites(this.db, includeInactive);
|
|
}
|
|
|
|
async markInviteUsed(code: string, userId: string): Promise<boolean> {
|
|
return markStoredInviteUsed(this.db, code, userId);
|
|
}
|
|
|
|
async revokeInvite(code: string): Promise<boolean> {
|
|
return revokeStoredInvite(this.db, code);
|
|
}
|
|
|
|
async deleteAllInvites(): Promise<number> {
|
|
return deleteStoredInvites(this.db);
|
|
}
|
|
|
|
async createAuditLog(log: AuditLog): Promise<void> {
|
|
await createStoredAuditLog(this.db, log);
|
|
}
|
|
|
|
async listAuditLogs(options: AuditLogListOptions): Promise<{ logs: AuditLog[]; total: number; hasMore: boolean }> {
|
|
return listStoredAuditLogs(this.db, options);
|
|
}
|
|
|
|
async pruneAuditLogs(beforeIso: string): Promise<number> {
|
|
return pruneStoredAuditLogs(this.db, beforeIso);
|
|
}
|
|
|
|
async pruneAuditLogsToMax(maxEntries: number): Promise<number> {
|
|
return pruneStoredAuditLogsToMax(this.db, maxEntries);
|
|
}
|
|
|
|
async clearAuditLogs(): Promise<number> {
|
|
return clearStoredAuditLogs(this.db);
|
|
}
|
|
|
|
// --- Domain rules ---
|
|
|
|
async getUserDomainSettings(userId: string) {
|
|
return getStoredUserDomainSettings(this.db, userId);
|
|
}
|
|
|
|
async saveUserDomainSettings(
|
|
userId: string,
|
|
equivalentDomains: string[][],
|
|
customEquivalentDomains: CustomEquivalentDomain[],
|
|
excludedGlobalEquivalentDomains: number[]
|
|
): Promise<void> {
|
|
await saveStoredUserDomainSettings(
|
|
this.db,
|
|
userId,
|
|
equivalentDomains,
|
|
customEquivalentDomains,
|
|
excludedGlobalEquivalentDomains,
|
|
new Date().toISOString()
|
|
);
|
|
await this.updateRevisionDate(userId);
|
|
}
|
|
|
|
// --- Account passkeys / WebAuthn login credentials ---
|
|
|
|
async saveAccountPasskeyCredential(credential: AccountPasskeyCredential): Promise<void> {
|
|
await saveStoredAccountPasskeyCredential(this.db, this.safeBind.bind(this), credential);
|
|
}
|
|
|
|
async getAccountPasskeyCredentialsByUserId(userId: string): Promise<AccountPasskeyCredential[]> {
|
|
return listStoredAccountPasskeyCredentialsByUserId(this.db, userId);
|
|
}
|
|
|
|
async getAccountPasskeyCredentialById(userId: string, id: string): Promise<AccountPasskeyCredential | null> {
|
|
return findStoredAccountPasskeyCredentialById(this.db, userId, id);
|
|
}
|
|
|
|
async getAccountPasskeyCredentialByCredentialId(credentialId: string): Promise<AccountPasskeyCredential | null> {
|
|
return findStoredAccountPasskeyCredentialByCredentialId(this.db, credentialId);
|
|
}
|
|
|
|
async countAccountPasskeyCredentialsByUserId(userId: string): Promise<number> {
|
|
return countStoredAccountPasskeyCredentialsByUserId(this.db, userId);
|
|
}
|
|
|
|
async updateAccountPasskeyCounter(
|
|
userId: string,
|
|
credentialId: string,
|
|
counter: number,
|
|
updatedAt: string = new Date().toISOString()
|
|
): Promise<void> {
|
|
await updateStoredAccountPasskeyCounter(this.db, userId, credentialId, counter, updatedAt);
|
|
}
|
|
|
|
async updateAccountPasskeyEncryption(
|
|
userId: string,
|
|
credentialId: string,
|
|
encryptedUserKey: string,
|
|
encryptedPublicKey: string,
|
|
encryptedPrivateKey: string,
|
|
updatedAt: string = new Date().toISOString()
|
|
): Promise<boolean> {
|
|
return updateStoredAccountPasskeyEncryption(
|
|
this.db,
|
|
userId,
|
|
credentialId,
|
|
encryptedUserKey,
|
|
encryptedPublicKey,
|
|
encryptedPrivateKey,
|
|
updatedAt
|
|
);
|
|
}
|
|
|
|
async deleteAccountPasskeyCredential(userId: string, id: string): Promise<boolean> {
|
|
return deleteStoredAccountPasskeyCredential(this.db, userId, id);
|
|
}
|
|
|
|
async saveAccountPasskeyChallenge(challenge: AccountPasskeyChallenge): Promise<void> {
|
|
await saveStoredAccountPasskeyChallenge(this.db, challenge);
|
|
}
|
|
|
|
async consumeAccountPasskeyChallenge(
|
|
challengeHash: string,
|
|
scope: AccountPasskeyChallengeScope,
|
|
userId: string | null,
|
|
nowMs: number = Date.now()
|
|
): Promise<AccountPasskeyChallenge | null> {
|
|
return consumeStoredAccountPasskeyChallenge(this.db, challengeHash, scope, userId, nowMs);
|
|
}
|
|
|
|
// --- Ciphers ---
|
|
|
|
async getCipher(id: string): Promise<Cipher | null> {
|
|
return findStoredCipher(this.db, id);
|
|
}
|
|
|
|
async saveCipher(cipher: Cipher): Promise<void> {
|
|
await saveStoredCipher(this.db, this.safeBind.bind(this), cipher);
|
|
}
|
|
|
|
async deleteCipher(id: string, userId: string): Promise<void> {
|
|
await deleteStoredCipher(this.db, id, userId);
|
|
}
|
|
|
|
async bulkSoftDeleteCiphers(ids: string[], userId: string): Promise<string | null> {
|
|
return softDeleteStoredCiphers(this.db, this.sqlChunkSize.bind(this), this.updateRevisionDate.bind(this), ids, userId);
|
|
}
|
|
|
|
async bulkRestoreCiphers(ids: string[], userId: string): Promise<string | null> {
|
|
return restoreStoredCiphers(this.db, this.sqlChunkSize.bind(this), this.updateRevisionDate.bind(this), ids, userId);
|
|
}
|
|
|
|
async bulkArchiveCiphers(ids: string[], userId: string): Promise<string | null> {
|
|
return archiveStoredCiphers(this.db, this.sqlChunkSize.bind(this), this.updateRevisionDate.bind(this), ids, userId);
|
|
}
|
|
|
|
async bulkUnarchiveCiphers(ids: string[], userId: string): Promise<string | null> {
|
|
return unarchiveStoredCiphers(this.db, this.sqlChunkSize.bind(this), this.updateRevisionDate.bind(this), ids, userId);
|
|
}
|
|
|
|
async bulkDeleteCiphers(ids: string[], userId: string): Promise<string | null> {
|
|
return deleteStoredCiphers(this.db, this.sqlChunkSize.bind(this), this.updateRevisionDate.bind(this), ids, userId);
|
|
}
|
|
|
|
async getAllCiphers(userId: string): Promise<Cipher[]> {
|
|
return listStoredCiphers(this.db, userId);
|
|
}
|
|
|
|
async getCiphersPage(userId: string, includeDeleted: boolean, limit: number, offset: number): Promise<Cipher[]> {
|
|
return listStoredCiphersPage(this.db, userId, includeDeleted, limit, offset);
|
|
}
|
|
|
|
async getCiphersByIds(ids: string[], userId: string): Promise<Cipher[]> {
|
|
return listStoredCiphersByIds(this.db, this.sqlChunkSize.bind(this), ids, userId);
|
|
}
|
|
|
|
async bulkMoveCiphers(ids: string[], folderId: string | null, userId: string): Promise<string | null> {
|
|
return moveStoredCiphers(this.db, this.sqlChunkSize.bind(this), this.updateRevisionDate.bind(this), ids, folderId, userId);
|
|
}
|
|
|
|
// --- Folders ---
|
|
|
|
async getFolder(id: string): Promise<Folder | null> {
|
|
return findStoredFolder(this.db, id);
|
|
}
|
|
|
|
async saveFolder(folder: Folder): Promise<void> {
|
|
await saveStoredFolder(this.db, folder);
|
|
}
|
|
|
|
async deleteFolder(id: string, userId: string): Promise<void> {
|
|
await deleteStoredFolder(this.db, id, userId);
|
|
}
|
|
|
|
async bulkDeleteFolders(ids: string[], userId: string): Promise<string | null> {
|
|
return deleteStoredFolders(
|
|
this.db,
|
|
userId,
|
|
ids,
|
|
this.sqlChunkSize.bind(this),
|
|
this.updateRevisionDate.bind(this)
|
|
);
|
|
}
|
|
|
|
// 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<void> {
|
|
await clearStoredFolderFromCiphers(this.db, userId, folderId);
|
|
}
|
|
|
|
async getAllFolders(userId: string): Promise<Folder[]> {
|
|
return listStoredFolders(this.db, userId);
|
|
}
|
|
|
|
async getFoldersPage(userId: string, limit: number, offset: number): Promise<Folder[]> {
|
|
return listStoredFoldersPage(this.db, userId, limit, offset);
|
|
}
|
|
|
|
// --- Attachments ---
|
|
|
|
async getAttachment(id: string): Promise<Attachment | null> {
|
|
return findStoredAttachment(this.db, id);
|
|
}
|
|
|
|
async saveAttachment(attachment: Attachment): Promise<void> {
|
|
await saveStoredAttachment(this.db, this.safeBind.bind(this), attachment);
|
|
}
|
|
|
|
async deleteAttachment(id: string): Promise<void> {
|
|
await deleteStoredAttachment(this.db, id);
|
|
}
|
|
|
|
async bulkDeleteAttachmentsByIds(ids: string[]): Promise<void> {
|
|
await deleteStoredAttachmentsByIds(this.db, this.sqlChunkSize.bind(this), ids);
|
|
}
|
|
|
|
async getAttachmentsByCipher(cipherId: string): Promise<Attachment[]> {
|
|
return listStoredAttachmentsByCipher(this.db, cipherId);
|
|
}
|
|
|
|
async getAttachmentsByCipherIds(cipherIds: string[]): Promise<Map<string, Attachment[]>> {
|
|
return listStoredAttachmentsByCipherIds(this.db, this.sqlChunkSize.bind(this), cipherIds);
|
|
}
|
|
|
|
async getAttachmentsByUserId(userId: string): Promise<Map<string, Attachment[]>> {
|
|
return listStoredAttachmentsByUserId(this.db, userId);
|
|
}
|
|
|
|
async addAttachmentToCipher(cipherId: string, attachmentId: string): Promise<void> {
|
|
await attachStoredAttachmentToCipher(this.db, cipherId, attachmentId);
|
|
}
|
|
|
|
async deleteAllAttachmentsByCipher(cipherId: string): Promise<void> {
|
|
await deleteStoredAttachmentsByCipher(this.db, cipherId);
|
|
}
|
|
|
|
async updateCipherRevisionDate(cipherId: string): Promise<{ userId: string; revisionDate: string } | null> {
|
|
return updateStoredCipherRevisionDate(
|
|
this.getCipher.bind(this),
|
|
this.saveCipher.bind(this),
|
|
this.updateRevisionDate.bind(this),
|
|
cipherId
|
|
);
|
|
}
|
|
|
|
// --- Refresh tokens ---
|
|
|
|
async saveRefreshToken(
|
|
token: string,
|
|
userId: string,
|
|
expiresAtMs?: number,
|
|
deviceIdentifier?: string | null,
|
|
deviceSessionStamp?: string | null
|
|
): Promise<void> {
|
|
const expiresAt = expiresAtMs ?? (Date.now() + LIMITS.auth.refreshTokenTtlMs);
|
|
await saveStoredRefreshToken(
|
|
this.db,
|
|
this.refreshTokenKey.bind(this),
|
|
this.maybeCleanupExpiredRefreshTokens.bind(this),
|
|
token,
|
|
userId,
|
|
expiresAt,
|
|
deviceIdentifier,
|
|
deviceSessionStamp
|
|
);
|
|
}
|
|
|
|
async getRefreshTokenRecord(token: string): Promise<RefreshTokenRecord | null> {
|
|
return findStoredRefreshTokenRecord(
|
|
this.db,
|
|
this.refreshTokenKey.bind(this),
|
|
this.maybeCleanupExpiredRefreshTokens.bind(this),
|
|
this.deleteRefreshToken.bind(this),
|
|
token
|
|
);
|
|
}
|
|
|
|
async getRefreshTokenUserId(token: string): Promise<string | null> {
|
|
const record = await this.getRefreshTokenRecord(token);
|
|
return record?.userId ?? null;
|
|
}
|
|
|
|
async deleteRefreshToken(token: string): Promise<void> {
|
|
await deleteStoredRefreshToken(this.db, this.refreshTokenKey.bind(this), token);
|
|
}
|
|
|
|
// --- Sends ---
|
|
|
|
async getSend(id: string): Promise<Send | null> {
|
|
return findStoredSend(this.db, id);
|
|
}
|
|
|
|
async saveSend(send: Send): Promise<void> {
|
|
await saveStoredSend(this.db, this.safeBind.bind(this), send);
|
|
}
|
|
|
|
/**
|
|
* Atomically increment access_count and update updated_at.
|
|
* Returns true if the row was updated (send still available),
|
|
* false if max_access_count has already been reached.
|
|
*/
|
|
async incrementSendAccessCount(sendId: string): Promise<boolean> {
|
|
return incrementStoredSendAccessCount(this.db, sendId);
|
|
}
|
|
|
|
async deleteSend(id: string, userId: string): Promise<void> {
|
|
await deleteStoredSend(this.db, id, userId);
|
|
}
|
|
|
|
async getSendsByIds(ids: string[], userId: string): Promise<Send[]> {
|
|
return listStoredSendsByIds(this.db, this.sqlChunkSize.bind(this), ids, userId);
|
|
}
|
|
|
|
async bulkDeleteSends(ids: string[], userId: string): Promise<string | null> {
|
|
return deleteStoredSends(this.db, this.sqlChunkSize.bind(this), this.updateRevisionDate.bind(this), ids, userId);
|
|
}
|
|
|
|
async getAllSends(userId: string): Promise<Send[]> {
|
|
return listStoredSends(this.db, userId);
|
|
}
|
|
|
|
async getSendsPage(userId: string, limit: number, offset: number): Promise<Send[]> {
|
|
return listStoredSendsPage(this.db, userId, limit, offset);
|
|
}
|
|
|
|
async deleteRefreshTokensByUserId(userId: string): Promise<number> {
|
|
return deleteStoredRefreshTokensByUserId(this.db, userId);
|
|
}
|
|
|
|
async deleteRefreshTokensByDevice(userId: string, deviceIdentifier: string): Promise<number> {
|
|
return deleteStoredRefreshTokensByDevice(this.db, userId, deviceIdentifier);
|
|
}
|
|
|
|
// 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<void> {
|
|
await constrainStoredRefreshTokenExpiry(this.db, this.refreshTokenKey.bind(this), token, maxExpiresAtMs);
|
|
}
|
|
|
|
private async trustedTwoFactorTokenKey(token: string): Promise<string> {
|
|
const digest = await this.sha256Hex(token);
|
|
return `sha256:${digest}`;
|
|
}
|
|
|
|
// --- Devices ---
|
|
|
|
async upsertDevice(
|
|
userId: string,
|
|
deviceIdentifier: string,
|
|
name: string,
|
|
type: number,
|
|
sessionStamp?: string,
|
|
keys?: {
|
|
encryptedUserKey?: string | null;
|
|
encryptedPublicKey?: string | null;
|
|
encryptedPrivateKey?: string | null;
|
|
}
|
|
): Promise<void> {
|
|
await saveStoredDevice(this.db, this.getDevice.bind(this), userId, deviceIdentifier, name, type, sessionStamp, keys);
|
|
}
|
|
|
|
async isKnownDevice(userId: string, deviceIdentifier: string): Promise<boolean> {
|
|
return getKnownStoredDevice(this.db, userId, deviceIdentifier);
|
|
}
|
|
|
|
async isKnownDeviceByEmail(email: string, deviceIdentifier: string): Promise<boolean> {
|
|
return getKnownStoredDeviceByEmail(this.getUser.bind(this), this.isKnownDevice.bind(this), email, deviceIdentifier);
|
|
}
|
|
|
|
async getDevicesByUserId(userId: string): Promise<Device[]> {
|
|
return listStoredDevicesByUserId(this.db, userId);
|
|
}
|
|
|
|
async getDevice(userId: string, deviceIdentifier: string): Promise<Device | null> {
|
|
return findStoredDevice(this.db, userId, deviceIdentifier);
|
|
}
|
|
|
|
async updateDeviceKeys(
|
|
userId: string,
|
|
deviceIdentifier: string,
|
|
keys: {
|
|
encryptedUserKey?: string | null;
|
|
encryptedPublicKey?: string | null;
|
|
encryptedPrivateKey?: string | null;
|
|
}
|
|
): Promise<boolean> {
|
|
return updateStoredDeviceKeys(this.db, userId, deviceIdentifier, keys);
|
|
}
|
|
|
|
async updateDeviceName(userId: string, deviceIdentifier: string, name: string): Promise<boolean> {
|
|
return updateStoredDeviceName(this.db, userId, deviceIdentifier, name);
|
|
}
|
|
|
|
async touchDeviceLastSeen(userId: string, deviceIdentifier: string): Promise<boolean> {
|
|
return touchStoredDeviceLastSeen(this.db, userId, deviceIdentifier);
|
|
}
|
|
|
|
async clearDeviceKeys(userId: string, deviceIdentifiers: string[]): Promise<number> {
|
|
return clearStoredDeviceKeys(this.db, userId, deviceIdentifiers);
|
|
}
|
|
|
|
async deleteDevice(userId: string, deviceIdentifier: string): Promise<boolean> {
|
|
return deleteStoredDevice(this.db, userId, deviceIdentifier);
|
|
}
|
|
|
|
async deleteDevicesByUserId(userId: string): Promise<number> {
|
|
return deleteStoredDevicesByUserId(this.db, userId);
|
|
}
|
|
|
|
async getTrustedDeviceTokenSummariesByUserId(userId: string): Promise<TrustedDeviceTokenSummary[]> {
|
|
return listStoredTrustedTokenSummaries(this.db, userId);
|
|
}
|
|
|
|
async deleteTrustedTwoFactorTokensByDevice(userId: string, deviceIdentifier: string): Promise<number> {
|
|
return deleteStoredTrustedTokensByDevice(this.db, userId, deviceIdentifier);
|
|
}
|
|
|
|
async deleteTrustedTwoFactorTokensByUserId(userId: string): Promise<number> {
|
|
return deleteStoredTrustedTokensByUserId(this.db, userId);
|
|
}
|
|
|
|
async updateTrustedTwoFactorTokensExpiryByDevice(userId: string, deviceIdentifier: string, expiresAtMs: number): Promise<number> {
|
|
return updateStoredTrustedTokensExpiryByDevice(this.db, userId, deviceIdentifier, expiresAtMs);
|
|
}
|
|
|
|
// --- Trusted 2FA remember tokens (device-bound) ---
|
|
|
|
async saveTrustedTwoFactorDeviceToken(
|
|
token: string,
|
|
userId: string,
|
|
deviceIdentifier: string,
|
|
expiresAtMs?: number
|
|
): Promise<void> {
|
|
const expiresAt = expiresAtMs ?? (Date.now() + TWO_FACTOR_REMEMBER_TTL_MS);
|
|
await saveStoredTrustedDeviceToken(this.db, this.trustedTwoFactorTokenKey.bind(this), token, userId, deviceIdentifier, expiresAt);
|
|
}
|
|
|
|
async getTrustedTwoFactorDeviceTokenUserId(token: string, deviceIdentifier: string): Promise<string | null> {
|
|
return findStoredTrustedTokenUserId(this.db, this.trustedTwoFactorTokenKey.bind(this), token, deviceIdentifier);
|
|
}
|
|
|
|
// --- Revision dates ---
|
|
|
|
async getRevisionDate(userId: string): Promise<string> {
|
|
return getStoredRevisionDate(this.db, userId);
|
|
}
|
|
|
|
async updateRevisionDate(userId: string): Promise<string> {
|
|
return updateStoredRevisionDate(this.db, userId);
|
|
}
|
|
|
|
// --- One-time attachment download tokens ---
|
|
|
|
private async ensureUsedAttachmentDownloadTokenTable(): Promise<void> {
|
|
if (StorageService.attachmentTokenTableReady) return;
|
|
await ensureStoredAttachmentTokenTable(this.db);
|
|
|
|
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<boolean> {
|
|
await this.ensureUsedAttachmentDownloadTokenTable();
|
|
const result = await consumeStoredAttachmentDownloadToken(
|
|
this.db,
|
|
this.shouldRunPeriodicCleanup.bind(this),
|
|
StorageService.lastAttachmentTokenCleanupAt,
|
|
StorageService.ATTACHMENT_TOKEN_CLEANUP_INTERVAL_MS,
|
|
jti,
|
|
expUnixSeconds
|
|
);
|
|
if (result.cleanedUpAt !== null) {
|
|
StorageService.lastAttachmentTokenCleanupAt = result.cleanedUpAt;
|
|
}
|
|
return result.consumed;
|
|
}
|
|
}
|