Compare commits

..
24 Commits
Author SHA1 Message Date
shuaiplus 51428461a8 fix: cap multipart backup and upload requests 2026-07-06 15:38:59 +08:00
shuaiplus 23c53bd1af fix: validate anonymous notification hub requests 2026-07-06 15:38:52 +08:00
shuaiplus ae168bea31 fix rate limit reset bypasses 2026-07-06 14:24:01 +08:00
shuaiplus 00e0ec0892 fix(backup): redact destination secrets in settings 2026-07-06 13:50:49 +08:00
shuaiplus 2df43ccdb0 fix(auth): revoke current access token session 2026-07-06 13:49:06 +08:00
shuaiplus fd46dffc34 fix: align push relay installation requests 2026-07-06 02:09:35 +08:00
shuaiplus 56b301f2d1 fix: align fill assist compatibility 2026-07-06 02:09:28 +08:00
shuaiplus f0e523376c fix: add admin auth request compatibility 2026-07-06 01:35:26 +08:00
shuaiplus 8b2f98b847 fix: add Bitwarden device registration endpoints 2026-07-06 01:35:19 +08:00
shuaiplus cde4555add fix: return unsupported for email and kdf flows 2026-07-06 01:35:12 +08:00
shuaiplus 1bad32fd90 fix: keep remembered 2fa token on bad password 2026-07-06 01:07:26 +08:00
shuaiplus e376a840c2 fix: add device verification settings endpoints 2026-07-06 00:59:34 +08:00
shuaiplus 9de0d3bd87 fix: clarify extended cipher type icons 2026-07-06 00:56:57 +08:00
shuaiplus 109593da90 feat: support Bitwarden extended cipher types 2026-07-06 00:44:17 +08:00
shuaiplus 01ff627ac6 fix(i18n): localize audit logs and new validation messages 2026-07-05 23:45:26 +08:00
shuaiplus d028b194e7 fix(admin): record audit clears and passkey 2fa status 2026-07-05 23:45:18 +08:00
shuaiplus c53d71fc28 feat(settings): move device management into settings 2026-07-05 23:44:54 +08:00
shuaiplus 6e722205b1 fix(totp): validate qr image uploads 2026-07-05 23:44:44 +08:00
shuaiplus cf14704d99 fix(import): validate payloads and zip entries 2026-07-05 23:44:36 +08:00
shuaiplus 0cef6a04e9 fix(backup): verify remote deletes and validate archives 2026-07-05 23:44:25 +08:00
shuaiplus 8c481a1564 fix(send): refresh routes and gate file access 2026-07-05 23:44:07 +08:00
shuaiplus d9a36fefe6 fix(security): harden auth and request limits 2026-07-05 23:43:49 +08:00
shuaiplus 12af18e3a3 feat: update device management link handling in navigation 2026-07-05 15:41:27 +08:00
shuaiplus d8cc88d9c0 feat: add UUID normalization functions and enhance WebAuthn response handling 2026-07-05 15:37:02 +08:00
61 changed files with 2869 additions and 528 deletions
+3
View File
@@ -62,6 +62,9 @@
// Refresh-token grant budget per IP per minute. // Refresh-token grant budget per IP per minute.
// refresh_token 授权每 IP 每分钟请求配额。 // refresh_token 授权每 IP 每分钟请求配额。
refreshTokenRequestsPerMinute: 30, refreshTokenRequestsPerMinute: 30,
// Passwordless/auth-request creation budget per IP/email/device per minute.
// 免密/设备审批请求创建接口每 IP/邮箱/设备每分钟配额。
authRequestRequestsPerMinute: 5,
// Fixed window size for API rate limiting in seconds. // Fixed window size for API rate limiting in seconds.
// API 限流固定窗口大小(秒)。 // API 限流固定窗口大小(秒)。
apiWindowSeconds: 60, apiWindowSeconds: 60,
+4 -4
View File
@@ -19,7 +19,7 @@ import {
executeConfiguredBackup, executeConfiguredBackup,
importAndAuditRemoteBackupFile, importAndAuditRemoteBackupFile,
} from '../handlers/backup'; } from '../handlers/backup';
import { verifyBackupArchiveFileNameChecksum } from '../services/backup-archive'; import { isSafeBackupAttachmentBlobName, verifyBackupArchiveFileNameChecksum } from '../services/backup-archive';
import { zipSync } from 'fflate'; import { zipSync } from 'fflate';
const BACKUP_JOB_STATE_KEY = 'backup.job.state.v1'; const BACKUP_JOB_STATE_KEY = 'backup.job.state.v1';
@@ -372,7 +372,7 @@ export class BackupTransferRunner {
return badRequest('Remote attachment download payload is invalid'); return badRequest('Remote attachment download payload is invalid');
} }
const blobName = String(body?.blobName || '').trim(); const blobName = String(body?.blobName || '').trim();
if (!body?.destination || !blobName) { if (!body?.destination || !isSafeBackupAttachmentBlobName(blobName)) {
return badRequest('Remote attachment download payload is invalid'); return badRequest('Remote attachment download payload is invalid');
} }
const file = await downloadRemoteBackupFile(body.destination, `attachments/${blobName}`).catch(() => null); const file = await downloadRemoteBackupFile(body.destination, `attachments/${blobName}`).catch(() => null);
@@ -398,7 +398,7 @@ export class BackupTransferRunner {
const blobNames = Array.from(new Set( const blobNames = Array.from(new Set(
(Array.isArray(body?.blobNames) ? body.blobNames : []) (Array.isArray(body?.blobNames) ? body.blobNames : [])
.map((blobName) => String(blobName || '').trim()) .map((blobName) => String(blobName || '').trim())
.filter(Boolean) .filter(isSafeBackupAttachmentBlobName)
)); ));
if (!body?.destination || !blobNames.length || blobNames.length > 40) { if (!body?.destination || !blobNames.length || blobNames.length > 40) {
return badRequest('Remote attachment batch download payload is invalid'); return badRequest('Remote attachment batch download payload is invalid');
@@ -446,7 +446,7 @@ export class BackupTransferRunner {
for (const attachment of body.attachments) { for (const attachment of body.attachments) {
const blobName = String(attachment?.blobName || '').trim(); const blobName = String(attachment?.blobName || '').trim();
if (!blobName) { if (!isSafeBackupAttachmentBlobName(blobName)) {
return badRequest('Attachment chunk payload is invalid'); return badRequest('Attachment chunk payload is invalid');
} }
+76 -4
View File
@@ -42,6 +42,9 @@ function looksLikeEncString(value: string): boolean {
*/ */
function validateKdfParams(kdfType: number | undefined, kdfIterations: number | undefined, kdfMemory?: number | undefined, kdfParallelism?: number | undefined): string | null { function validateKdfParams(kdfType: number | undefined, kdfIterations: number | undefined, kdfMemory?: number | undefined, kdfParallelism?: number | undefined): string | null {
const type = kdfType ?? 0; const type = kdfType ?? 0;
if (type !== 0 && type !== 1) {
return 'KDF type must be PBKDF2-SHA256 or Argon2id';
}
if (type === 0) { if (type === 0) {
// PBKDF2-SHA256: minimum 100 000 iterations // PBKDF2-SHA256: minimum 100 000 iterations
if (typeof kdfIterations === 'number' && kdfIterations < 100_000) { if (typeof kdfIterations === 'number' && kdfIterations < 100_000) {
@@ -448,7 +451,7 @@ export async function handleGetPasswordHint(request: Request, env: Env): Promise
} }
const rateLimit = new RateLimitService(env.DB); const rateLimit = new RateLimitService(env.DB);
const minuteBudget = await rateLimit.consumeBudgetWithWindow( const minuteBudget = await rateLimit.consumeStrictBudgetWithWindow(
`${clientIdentifier}:password-hint`, `${clientIdentifier}:password-hint`,
LIMITS.rateLimit.passwordHintRequestsPerMinute, LIMITS.rateLimit.passwordHintRequestsPerMinute,
60 60
@@ -470,7 +473,7 @@ export async function handleGetPasswordHint(request: Request, env: Env): Promise
); );
} }
const hourlyBudget = await rateLimit.consumeBudgetWithWindow( const hourlyBudget = await rateLimit.consumeStrictBudgetWithWindow(
`${clientIdentifier}:password-hint-hour`, `${clientIdentifier}:password-hint-hour`,
LIMITS.rateLimit.passwordHintRequestsPerHour, LIMITS.rateLimit.passwordHintRequestsPerHour,
60 * 60 60 * 60
@@ -734,6 +737,11 @@ export async function handleChangePassword(request: Request, env: Env, userId: s
const nextKdfParallelism = body.kdfParallelism ?? readNestedNumber(body, ['unlockData', 'kdf', 'parallelism']); const nextKdfParallelism = body.kdfParallelism ?? readNestedNumber(body, ['unlockData', 'kdf', 'parallelism']);
const kdfErr = validateKdfParams(nextKdf, nextKdfIterations, nextKdfMemory, nextKdfParallelism); const kdfErr = validateKdfParams(nextKdf, nextKdfIterations, nextKdfMemory, nextKdfParallelism);
if (kdfErr) return errorResponse(kdfErr, 400); if (kdfErr) return errorResponse(kdfErr, 400);
const shouldUpdateHint = typeof body.masterPasswordHint === 'string' || body.masterPasswordHint === null;
const nextMasterPasswordHint = shouldUpdateHint ? normalizeMasterPasswordHint(body.masterPasswordHint) : undefined;
if (nextMasterPasswordHint && nextMasterPasswordHint.length > 120) {
return errorResponse('masterPasswordHint must be 120 characters or fewer', 400);
}
user.masterPasswordHash = await auth.hashPasswordServer(newMasterPasswordHash, user.email); user.masterPasswordHash = await auth.hashPasswordServer(newMasterPasswordHash, user.email);
if (nextKey) user.key = nextKey; if (nextKey) user.key = nextKey;
@@ -743,8 +751,8 @@ export async function handleChangePassword(request: Request, env: Env, userId: s
if (typeof nextKdfIterations === 'number') user.kdfIterations = nextKdfIterations; if (typeof nextKdfIterations === 'number') user.kdfIterations = nextKdfIterations;
if (typeof nextKdfMemory === 'number') user.kdfMemory = nextKdfMemory; if (typeof nextKdfMemory === 'number') user.kdfMemory = nextKdfMemory;
if (typeof nextKdfParallelism === 'number') user.kdfParallelism = nextKdfParallelism; if (typeof nextKdfParallelism === 'number') user.kdfParallelism = nextKdfParallelism;
if (typeof body.masterPasswordHint === 'string' || body.masterPasswordHint === null) { if (shouldUpdateHint) {
user.masterPasswordHint = body.masterPasswordHint; user.masterPasswordHint = nextMasterPasswordHint ?? null;
} }
user.securityStamp = generateUUID(); user.securityStamp = generateUUID();
user.updatedAt = new Date().toISOString(); user.updatedAt = new Date().toISOString();
@@ -811,6 +819,18 @@ function yubiKeyResponse(user: User): Record<string, unknown> {
}; };
} }
function deviceVerificationSettingsResponse(user: User): Record<string, unknown> {
const enabled = user.verifyDevices !== false;
return {
Enabled: enabled,
enabled,
VerifyDevices: enabled,
verifyDevices: enabled,
Object: 'deviceVerificationSettings',
object: 'deviceVerificationSettings',
};
}
async function yubiKeySettingsResponse(storage: StorageService, env: Env, user: User): Promise<Record<string, unknown>> { async function yubiKeySettingsResponse(storage: StorageService, env: Env, user: User): Promise<Record<string, unknown>> {
const credentials = await getStoredYubicoCredentials(storage, env); const credentials = await getStoredYubicoCredentials(storage, env);
return { return {
@@ -885,6 +905,58 @@ export async function handleGetTwoFactorYubiKey(request: Request, env: Env, user
return jsonResponse(await yubiKeySettingsResponse(storage, env, user)); return jsonResponse(await yubiKeySettingsResponse(storage, env, user));
} }
// POST /api/two-factor/get-device-verification-settings
export async function handleGetDeviceVerificationSettings(request: Request, env: Env, userId: string): Promise<Response> {
void request;
const storage = new StorageService(env.DB);
const user = await storage.getUserById(userId);
if (!user) return errorResponse('User not found', 404);
return jsonResponse(deviceVerificationSettingsResponse(user));
}
// PUT/POST /api/two-factor/device-verification-settings
export async function handlePutDeviceVerificationSettings(request: Request, env: Env, userId: string): Promise<Response> {
const storage = new StorageService(env.DB);
const auth = new AuthService(env);
const user = await storage.getUserById(userId);
if (!user) return errorResponse('User not found', 404);
let body: Record<string, unknown>;
try {
body = await readRequestBody(request);
} catch {
return errorResponse('Invalid JSON', 400);
}
const rawEnabled = body.enabled ?? body.Enabled ?? body.verifyDevices ?? body.VerifyDevices;
if (typeof rawEnabled !== 'boolean') {
return errorResponse('enabled must be true or false', 400);
}
const secret = readBodyString(body, ['masterPasswordHash', 'MasterPasswordHash', 'secret', 'Secret']);
const verified = await verifyUserSecret(auth, user, secret);
if (!verified) return errorResponse('User verification failed.', 400);
user.verifyDevices = rawEnabled;
user.updatedAt = new Date().toISOString();
await storage.saveUser(user);
await writeAuditEvent(storage, {
actorUserId: user.id,
action: 'account.verify_devices.update',
category: 'security',
level: 'security',
targetType: 'user',
targetId: user.id,
metadata: {
verifyDevices: user.verifyDevices,
source: 'two-factor.device-verification-settings',
...auditRequestMetadata(request),
},
});
return jsonResponse(deviceVerificationSettingsResponse(user));
}
// PUT/POST /api/two-factor/authenticator // PUT/POST /api/two-factor/authenticator
export async function handlePutTwoFactorAuthenticator(request: Request, env: Env, userId: string): Promise<Response> { export async function handlePutTwoFactorAuthenticator(request: Request, env: Env, userId: string): Promise<Response> {
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
+11 -4
View File
@@ -69,18 +69,22 @@ export async function handleAdminListUsers(
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
const users = await storage.getAllUsers(); const users = await storage.getAllUsers();
return jsonResponse({ const data = await Promise.all(users.map(async user => {
data: users.map(user => ({ const hasTwoFactorPasskey = await storage.countAccountPasskeyCredentialsByUserId(user.id, 'twoFactor') > 0;
return {
id: user.id, id: user.id,
email: user.email, email: user.email,
name: user.name, name: user.name,
role: user.role, role: user.role,
status: user.status, status: user.status,
twoFactorEnabled: !!user.totpSecret || Boolean(user.yubikeyKey1 || user.yubikeyKey2 || user.yubikeyKey3 || user.yubikeyKey4 || user.yubikeyKey5), twoFactorEnabled: !!user.totpSecret || Boolean(user.yubikeyKey1 || user.yubikeyKey2 || user.yubikeyKey3 || user.yubikeyKey4 || user.yubikeyKey5) || hasTwoFactorPasskey,
creationDate: user.createdAt, creationDate: user.createdAt,
revisionDate: user.updatedAt, revisionDate: user.updatedAt,
object: 'user', object: 'user',
})), };
}));
return jsonResponse({
data,
object: 'list', object: 'list',
continuationToken: null, continuationToken: null,
}); });
@@ -183,6 +187,9 @@ export async function handleAdminClearAuditLogs(
} }
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
const deleted = await storage.clearAuditLogs(); const deleted = await storage.clearAuditLogs();
await writeAuditLog(storage, actorUser.id, 'admin.audit.clear', 'auditLog', null, {
deleted,
}, request);
return jsonResponse({ object: 'auditLogClear', deleted }); return jsonResponse({ object: 'auditLogClear', deleted });
} }
+4
View File
@@ -124,6 +124,10 @@ async function processAttachmentUpload(
} }
const path = getAttachmentObjectKey(cipherId, attachment.id); const path = getAttachmentObjectKey(cipherId, attachment.id);
if (await getBlobObject(env, path)) {
return errorResponse('Attachment file has already been uploaded', 409);
}
try { try {
await putBlobObject(env, path, upload.body, { await putBlobObject(env, path, upload.body, {
size: upload.size, size: upload.size,
+94
View File
@@ -5,6 +5,8 @@ import { readAuthRequestDeviceInfo, readActingDeviceIdentifier } from '../utils/
import { errorResponse, jsonResponse } from '../utils/response'; import { errorResponse, jsonResponse } from '../utils/response';
import { isAuthRequestExpired } from '../services/storage-auth-request-repo'; import { isAuthRequestExpired } from '../services/storage-auth-request-repo';
import { notifyAuthRequestResponse, notifyUserAuthRequest } from '../durable/notifications-hub'; import { notifyAuthRequestResponse, notifyUserAuthRequest } from '../durable/notifications-hub';
import { RateLimitService, getClientIdentifier } from '../services/ratelimit';
import { LIMITS } from '../config/limits';
const AUTH_REQUEST_TYPE_AUTHENTICATE_AND_UNLOCK = 0; const AUTH_REQUEST_TYPE_AUTHENTICATE_AND_UNLOCK = 0;
const AUTH_REQUEST_TYPE_UNLOCK = 1; const AUTH_REQUEST_TYPE_UNLOCK = 1;
@@ -131,6 +133,30 @@ async function readJsonBody(request: Request): Promise<Record<string, any> | nul
} }
} }
async function enforceAuthRequestCreateRateLimit(
request: Request,
env: Env,
email: string,
deviceIdentifier: string
): Promise<Response | null> {
const clientIdentifier = getClientIdentifier(request);
if (!clientIdentifier) return errorResponse('Client IP is required', 403);
const rateLimit = new RateLimitService(env.DB);
const limit = LIMITS.rateLimit.authRequestRequestsPerMinute;
const encodedEmail = encodeURIComponent(email || 'missing');
const encodedDevice = encodeURIComponent(deviceIdentifier || 'missing');
const budgets = await Promise.all([
rateLimit.consumeStrictBudget(`auth-request:ip:${clientIdentifier}`, limit),
rateLimit.consumeStrictBudget(`auth-request:email:${encodedEmail}`, limit),
rateLimit.consumeStrictBudget(`auth-request:device:${encodedDevice}`, limit),
]);
const blocked = budgets.find((budget) => !budget.allowed);
if (!blocked) return null;
return errorResponse('Too many authentication requests. Try again later.', 429);
}
function readBodyValue(body: Record<string, any>, names: string[]): unknown { function readBodyValue(body: Record<string, any>, names: string[]): unknown {
for (const name of names) { for (const name of names) {
if (body[name] !== undefined) return body[name]; if (body[name] !== undefined) return body[name];
@@ -164,6 +190,8 @@ export async function handleCreateAuthRequest(request: Request, env: Env): Promi
if (!email || !publicKey || !accessCode || !deviceInfo.deviceIdentifier) { if (!email || !publicKey || !accessCode || !deviceInfo.deviceIdentifier) {
return errorResponse('Email, public key, device identifier, and access code are required.', 400); return errorResponse('Email, public key, device identifier, and access code are required.', 400);
} }
const rateLimitResponse = await enforceAuthRequestCreateRateLimit(request, env, email, deviceInfo.deviceIdentifier);
if (rateLimitResponse) return rateLimitResponse;
if (!isSupportedAuthRequestType(type) || type === AUTH_REQUEST_TYPE_ADMIN_APPROVAL) { if (!isSupportedAuthRequestType(type) || type === AUTH_REQUEST_TYPE_ADMIN_APPROVAL) {
return errorResponse('Invalid auth request type.', 400); return errorResponse('Invalid auth request type.', 400);
} }
@@ -199,6 +227,72 @@ export async function handleCreateAuthRequest(request: Request, env: Env): Promi
return jsonResponse(toAuthRequestResponse(request, authRequest)); return jsonResponse(toAuthRequestResponse(request, authRequest));
} }
export async function handleCreateAdminAuthRequest(
request: Request,
env: Env,
userId: string,
userEmail: string
): Promise<Response> {
const storage = new StorageService(env.DB);
const body = await readJsonBody(request);
if (!body) return errorResponse('Invalid request payload', 400);
const email = normalizeText(readBodyValue(body, ['email', 'Email']), 320).toLowerCase() || userEmail.toLowerCase();
const publicKey = normalizeText(readBodyValue(body, ['publicKey', 'PublicKey']), 8192);
const accessCode = normalizeText(readBodyValue(body, ['accessCode', 'AccessCode']), 25);
const requestedType = Number(readBodyValue(body, ['type', 'Type']));
const deviceInfo = readAuthRequestDeviceInfo(
{
deviceIdentifier: normalizeText(readBodyValue(body, ['deviceIdentifier', 'DeviceIdentifier']), 128),
deviceName: normalizeText(readBodyValue(body, ['deviceName', 'DeviceName']), 128),
deviceType: String(readBodyValue(body, ['deviceType', 'DeviceType']) ?? ''),
},
request
);
if (requestedType !== AUTH_REQUEST_TYPE_ADMIN_APPROVAL) {
return errorResponse('Invalid AuthRequestType. Expected AdminApproval.', 400);
}
if (email !== userEmail.toLowerCase()) {
return errorResponse('Email does not match authenticated user.', 400);
}
if (!publicKey || !accessCode || !deviceInfo.deviceIdentifier) {
return errorResponse('Public key, device identifier, and access code are required.', 400);
}
const rateLimitResponse = await enforceAuthRequestCreateRateLimit(request, env, email, deviceInfo.deviceIdentifier);
if (rateLimitResponse) return rateLimitResponse;
const user = await storage.getUserById(userId);
if (!user || user.status !== 'active') {
return errorResponse('User not found.', 404);
}
await storage.pruneExpiredAuthRequests();
const now = new Date().toISOString();
const authRequest: AuthRequestRecord = {
id: generateUUID(),
userId: user.id,
organizationId: null,
type: AUTH_REQUEST_TYPE_ADMIN_APPROVAL,
requestDeviceIdentifier: deviceInfo.deviceIdentifier,
requestDeviceType: deviceInfo.deviceType,
requestIpAddress: getClientIp(request),
requestCountryName: getCountryName(request),
responseDeviceIdentifier: null,
accessCode,
publicKey,
key: null,
masterPasswordHash: null,
approved: null,
creationDate: now,
responseDate: null,
authenticationDate: null,
};
await storage.createAuthRequest(authRequest);
notifyUserAuthRequest(env, user.id, authRequest.id, deviceInfo.deviceIdentifier);
return jsonResponse(toAuthRequestResponse(request, authRequest));
}
export async function handleGetAuthRequest(request: Request, env: Env, userId: string, id: string): Promise<Response> { export async function handleGetAuthRequest(request: Request, env: Env, userId: string, id: string): Promise<Response> {
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
const authRequest = await storage.getAuthRequestByIdForUser(id, userId); const authRequest = await storage.getAuthRequestByIdForUser(id, userId);
+56 -12
View File
@@ -2,8 +2,10 @@ import type { Env, User } from '../types';
import { errorResponse, jsonResponse } from '../utils/response'; import { errorResponse, jsonResponse } from '../utils/response';
import { import {
type BackupArchiveBundle, type BackupArchiveBundle,
MAX_BACKUP_ARCHIVE_BYTES,
buildBackupArchive, buildBackupArchive,
inspectBackupArchiveFileNameChecksum, inspectBackupArchiveFileNameChecksum,
isSafeBackupAttachmentBlobName,
parseBackupArchive, parseBackupArchive,
verifyBackupArchiveFileNameChecksum, verifyBackupArchiveFileNameChecksum,
} from '../services/backup-archive'; } from '../services/backup-archive';
@@ -18,6 +20,7 @@ import {
loadBackupSettings, loadBackupSettings,
normalizeBackupSettingsInput, normalizeBackupSettingsInput,
normalizeImportedBackupSettings, normalizeImportedBackupSettings,
redactBackupSettingsSecrets,
repairBackupSettings, repairBackupSettings,
requireBackupDestination, requireBackupDestination,
saveBackupSettings, saveBackupSettings,
@@ -45,6 +48,7 @@ import { AuthService } from '../services/auth';
import { auditRequestMetadata, writeAuditEvent } from '../services/audit-events'; import { auditRequestMetadata, writeAuditEvent } from '../services/audit-events';
import { getBlobObject } from '../services/blob-store'; import { getBlobObject } from '../services/blob-store';
import { notifyUserBackupProgress, notifyUserBackupRestoreProgress } from '../durable/notifications-hub'; import { notifyUserBackupProgress, notifyUserBackupRestoreProgress } from '../durable/notifications-hub';
import { getMultipartRequestMaxBytes } from '../utils/direct-upload';
import { verifyPasskeyUserVerificationToken } from '../utils/user-verification-token'; import { verifyPasskeyUserVerificationToken } from '../utils/user-verification-token';
import { unzipSync } from 'fflate'; import { unzipSync } from 'fflate';
@@ -52,6 +56,14 @@ function isAdmin(user: User): boolean {
return user.role === 'admin' && user.status === 'active'; return user.role === 'admin' && user.status === 'active';
} }
function parseRequestContentLength(request: Request): number | null {
const raw = request.headers.get('content-length');
if (!raw) return null;
const value = Number(raw);
if (!Number.isFinite(value) || value < 0) return null;
return Math.floor(value);
}
async function requireBackupUserVerification(actorUser: User, masterPasswordHash: string, env: Env): Promise<Response | null> { async function requireBackupUserVerification(actorUser: User, masterPasswordHash: string, env: Env): Promise<Response | null> {
const normalized = String(masterPasswordHash || '').trim(); const normalized = String(masterPasswordHash || '').trim();
if (!normalized) { if (!normalized) {
@@ -129,11 +141,18 @@ function ensureBackupBlobName(value: string): string {
if (!normalized) { if (!normalized) {
throw new Error('Backup attachment blob is required'); throw new Error('Backup attachment blob is required');
} }
const parts = normalized.split('/').filter(Boolean); if (!isSafeBackupAttachmentBlobName(normalized)) {
if (!parts.length || parts.some((part) => part === '.' || part === '..')) {
throw new Error('Backup attachment blob is invalid'); throw new Error('Backup attachment blob is invalid');
} }
return parts.join('/'); return normalized;
}
function contentDispositionBackup(fileName: string | null | undefined): string {
const fallback = 'nodewarden_backup.zip';
const value = String(fileName || fallback)
.replace(/[\\/\r\n"]/g, '_')
.trim() || fallback;
return `attachment; filename="${value}"`;
} }
const REMOTE_ATTACHMENT_INDEX_PATH = 'attachments/.nodewarden-attachment-index.v1.json'; const REMOTE_ATTACHMENT_INDEX_PATH = 'attachments/.nodewarden-attachment-index.v1.json';
@@ -654,6 +673,7 @@ function collectExternalRemoteAttachmentBlobNames(archiveBytes: Uint8Array): str
if (parsed.files[inlinePath]) continue; if (parsed.files[inlinePath]) continue;
const ref = refs.get(`${cipherId}/${attachmentId}`); const ref = refs.get(`${cipherId}/${attachmentId}`);
const blobName = String(ref?.blobName || '').trim(); const blobName = String(ref?.blobName || '').trim();
if (!isSafeBackupAttachmentBlobName(blobName)) continue;
if (blobName && !seen.has(blobName)) { if (blobName && !seen.has(blobName)) {
seen.add(blobName); seen.add(blobName);
names.push(blobName); names.push(blobName);
@@ -666,6 +686,7 @@ function collectExternalRemoteAttachmentBlobNames(archiveBytes: Uint8Array): str
function toImportStatusCode(message: string): number { function toImportStatusCode(message: string): number {
const lower = message.toLowerCase(); const lower = message.toLowerCase();
if (lower.includes('checksum')) return 400; if (lower.includes('checksum')) return 400;
if (lower.includes('invalid remote backup path') || lower.includes('please select a backup zip file')) return 409;
if (lower.includes('invalid backup') || lower.includes('invalid json')) return 400; if (lower.includes('invalid backup') || lower.includes('invalid json')) return 400;
if (lower.includes('fresh instance')) return 409; if (lower.includes('fresh instance')) return 409;
if (lower.includes('not configured') || lower.includes('kv')) return 409; if (lower.includes('not configured') || lower.includes('kv')) return 409;
@@ -849,7 +870,7 @@ export async function handleGetAdminBackupSettings(request: Request, env: Env, a
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
try { try {
const settings = await loadBackupSettings(storage, env, 'UTC'); const settings = await loadBackupSettings(storage, env, 'UTC');
return jsonResponse(settings); return jsonResponse(redactBackupSettingsSecrets(settings));
} catch (error) { } catch (error) {
return errorResponse(error instanceof Error ? error.message : 'Backup settings could not be loaded', 409); return errorResponse(error instanceof Error ? error.message : 'Backup settings could not be loaded', 409);
} }
@@ -888,7 +909,7 @@ export async function handleUpdateAdminBackupSettings(request: Request, env: Env
destinationCount: next.destinations.length, destinationCount: next.destinations.length,
scheduledDestinationCount: next.destinations.filter((destination) => destination.schedule.enabled).length, scheduledDestinationCount: next.destinations.filter((destination) => destination.schedule.enabled).length,
}, request); }, request);
return jsonResponse(next); return jsonResponse(redactBackupSettingsSecrets(next));
} }
export async function handleGetAdminBackupSettingsRepairState(request: Request, env: Env, actorUser: User): Promise<Response> { export async function handleGetAdminBackupSettingsRepairState(request: Request, env: Env, actorUser: User): Promise<Response> {
@@ -941,7 +962,7 @@ export async function handleRepairAdminBackupSettings(request: Request, env: Env
destinationCount: next.destinations.length, destinationCount: next.destinations.length,
scheduledDestinationCount: next.destinations.filter((destination) => destination.schedule.enabled).length, scheduledDestinationCount: next.destinations.filter((destination) => destination.schedule.enabled).length,
}, request); }, request);
return jsonResponse(next); return jsonResponse(redactBackupSettingsSecrets(next));
} }
export async function handleRunAdminConfiguredBackup(request: Request, env: Env, actorUser: User): Promise<Response> { export async function handleRunAdminConfiguredBackup(request: Request, env: Env, actorUser: User): Promise<Response> {
@@ -978,7 +999,7 @@ export async function handleRunAdminConfiguredBackup(request: Request, env: Env,
provider: outcome.result.provider, provider: outcome.result.provider,
remotePath: outcome.result.remotePath, remotePath: outcome.result.remotePath,
}, },
settings: outcome.settings, settings: redactBackupSettingsSecrets(outcome.settings),
}); });
} catch (error) { } catch (error) {
return errorResponse(error instanceof Error ? error.message : 'Backup run failed', 500); return errorResponse(error instanceof Error ? error.message : 'Backup run failed', 500);
@@ -1028,8 +1049,9 @@ export async function handleDownloadAdminRemoteBackup(request: Request, env: Env
status: 200, status: 200,
headers: { headers: {
'Content-Type': remoteFile.contentType || 'application/zip', 'Content-Type': remoteFile.contentType || 'application/zip',
'Content-Disposition': `attachment; filename="${remoteFile.fileName}"`, 'Content-Disposition': contentDispositionBackup(remoteFile.fileName),
'Cache-Control': 'no-store', 'Cache-Control': 'no-store',
'X-Content-Type-Options': 'nosniff',
}, },
}); });
} catch (error) { } catch (error) {
@@ -1063,12 +1085,21 @@ export async function handleInspectAdminRemoteBackup(request: Request, env: Env,
export async function handleDeleteAdminRemoteBackup(request: Request, env: Env, actorUser: User): Promise<Response> { export async function handleDeleteAdminRemoteBackup(request: Request, env: Env, actorUser: User): Promise<Response> {
if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403); if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403);
let body: { destinationId?: string; path?: string; masterPasswordHash?: string };
try {
body = await request.json<{ destinationId?: string; path?: string; masterPasswordHash?: string }>();
} catch {
return errorResponse('Remote backup delete payload is invalid', 400);
}
const verificationError = await requireBackupUserVerification(actorUser, String(body.masterPasswordHash || ''), env);
if (verificationError) return verificationError;
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
try { try {
const settings = await loadBackupSettings(storage, env, 'UTC'); const settings = await loadBackupSettings(storage, env, 'UTC');
const url = new URL(request.url); const path = ensureRemoteRestoreCandidate(String(body.path || ''));
const path = ensureRemoteRestoreCandidate(url.searchParams.get('path') || ''); const destination = requireBackupDestination(settings, body.destinationId || null);
const destination = requireBackupDestination(settings, url.searchParams.get('destinationId') || null);
await deleteRemoteBackupFile(destination, path); await deleteRemoteBackupFile(destination, path);
await writeAuditLog(storage, actorUser.id, 'admin.backup.remote.delete', 'backup', null, { await writeAuditLog(storage, actorUser.id, 'admin.backup.remote.delete', 'backup', null, {
...getBackupDestinationSummary(destination), ...getBackupDestinationSummary(destination),
@@ -1196,8 +1227,9 @@ export async function handleAdminExportBackup(request: Request, env: Env, actorU
status: 200, status: 200,
headers: { headers: {
'Content-Type': 'application/zip', 'Content-Type': 'application/zip',
'Content-Disposition': `attachment; filename="${archive.fileName}"`, 'Content-Disposition': contentDispositionBackup(archive.fileName),
'Cache-Control': 'no-store', 'Cache-Control': 'no-store',
'X-Content-Type-Options': 'nosniff',
}, },
}); });
} }
@@ -1228,6 +1260,15 @@ export async function handleDownloadAdminBackupAttachment(request: Request, env:
export async function handleAdminImportBackup(request: Request, env: Env, actorUser: User): Promise<Response> { export async function handleAdminImportBackup(request: Request, env: Env, actorUser: User): Promise<Response> {
if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403); if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403);
const contentType = request.headers.get('Content-Type') || '';
if (!contentType.includes('multipart/form-data')) {
return errorResponse('Content-Type must be multipart/form-data', 400);
}
const declaredSize = parseRequestContentLength(request);
if (declaredSize !== null && declaredSize > getMultipartRequestMaxBytes(MAX_BACKUP_ARCHIVE_BYTES)) {
return errorResponse(`Backup file too large. Maximum size is ${Math.floor(MAX_BACKUP_ARCHIVE_BYTES / (1024 * 1024))}MB`, 413);
}
let formData: FormData; let formData: FormData;
try { try {
formData = await request.formData(); formData = await request.formData();
@@ -1239,6 +1280,9 @@ export async function handleAdminImportBackup(request: Request, env: Env, actorU
if (!file || typeof file !== 'object' || !('arrayBuffer' in file)) { if (!file || typeof file !== 'object' || !('arrayBuffer' in file)) {
return errorResponse('Backup file is required', 400); return errorResponse('Backup file is required', 400);
} }
if ('size' in file && typeof (file as File).size === 'number' && (file as File).size > MAX_BACKUP_ARCHIVE_BYTES) {
return errorResponse(`Backup file too large. Maximum size is ${Math.floor(MAX_BACKUP_ARCHIVE_BYTES / (1024 * 1024))}MB`, 413);
}
const verificationError = await requireBackupUserVerification(actorUser, String(formData.get('masterPasswordHash') || ''), env); const verificationError = await requireBackupUserVerification(actorUser, String(formData.get('masterPasswordHash') || ''), env);
if (verificationError) return verificationError; if (verificationError) return verificationError;
+90 -2
View File
@@ -7,6 +7,9 @@ import {
CipherResponse, CipherResponse,
CipherSecureNote, CipherSecureNote,
CipherSshKey, CipherSshKey,
CipherBankAccount,
CipherDriversLicense,
CipherPassport,
Attachment, Attachment,
PasswordHistory, PasswordHistory,
} from '../types'; } from '../types';
@@ -254,6 +257,49 @@ function sanitizeEncryptedObject<T extends Record<string, any>>(
return next as T; return next as T;
} }
const BANK_ACCOUNT_ENCRYPTED_KEYS = [
'bankName',
'nameOnAccount',
'accountType',
'accountNumber',
'routingNumber',
'branchNumber',
'pin',
'swiftCode',
'iban',
'bankContactPhone',
] as const;
const DRIVERS_LICENSE_ENCRYPTED_KEYS = [
'firstName',
'middleName',
'lastName',
'dateOfBirth',
'licenseNumber',
'issuingCountry',
'issuingState',
'issueDate',
'expirationDate',
'issuingAuthority',
'licenseClass',
] as const;
const PASSPORT_ENCRYPTED_KEYS = [
'surname',
'givenName',
'dateOfBirth',
'sex',
'birthPlace',
'nationality',
'issuingCountry',
'passportNumber',
'passportType',
'nationalIdentificationNumber',
'issuingAuthority',
'issueDate',
'expirationDate',
] as const;
function normalizeCipherForStorage(cipher: Cipher): Cipher { function normalizeCipherForStorage(cipher: Cipher): Cipher {
cipher.login = normalizeCipherLoginForStorage(cipher.login); cipher.login = normalizeCipherLoginForStorage(cipher.login);
cipher.sshKey = normalizeCipherSshKeyForCompatibility(cipher.sshKey); cipher.sshKey = normalizeCipherSshKeyForCompatibility(cipher.sshKey);
@@ -376,6 +422,20 @@ export function validateCipherEncryptedFieldsForCompatibility(cipher: Cipher): s
if (fingerprint != null && !isValidEncString(fingerprint)) return 'SSH key fingerprint must be an encrypted string.'; if (fingerprint != null && !isValidEncString(fingerprint)) return 'SSH key fingerprint must be an encrypted string.';
} }
const typedEncryptedObjects: Array<[string, any, readonly string[]]> = [
['Bank account', (cipher as any).bankAccount, BANK_ACCOUNT_ENCRYPTED_KEYS],
['Drivers license', (cipher as any).driversLicense, DRIVERS_LICENSE_ENCRYPTED_KEYS],
['Passport', (cipher as any).passport, PASSPORT_ENCRYPTED_KEYS],
];
for (const [label, source, keys] of typedEncryptedObjects) {
if (!source || typeof source !== 'object') continue;
for (const key of keys) {
if (source[key] != null && !optionalEncStringWithin(source[key], 10000)) {
return `${label} ${key} must be an encrypted string.`;
}
}
}
// Validate password history — each password must be an encrypted string. // Validate password history — each password must be an encrypted string.
if (Array.isArray(cipher.passwordHistory)) { if (Array.isArray(cipher.passwordHistory)) {
for (const entry of cipher.passwordHistory) { for (const entry of cipher.passwordHistory) {
@@ -752,7 +812,20 @@ export function cipherToResponse(
'licenseNumber', 'licenseNumber',
]); ]);
const normalizedSshKey = normalizeCipherSshKeyForCompatibility((passthrough as any).sshKey ?? null); const normalizedSshKey = normalizeCipherSshKeyForCompatibility((passthrough as any).sshKey ?? null);
const normalizedSecureNote = Number(cipher.type) === 2 const normalizedBankAccount = sanitizeEncryptedObject(
(passthrough as any).bankAccount ?? null,
BANK_ACCOUNT_ENCRYPTED_KEYS
);
const normalizedDriversLicense = sanitizeEncryptedObject(
(passthrough as any).driversLicense ?? null,
DRIVERS_LICENSE_ENCRYPTED_KEYS
);
const normalizedPassport = sanitizeEncryptedObject(
(passthrough as any).passport ?? null,
PASSPORT_ENCRYPTED_KEYS
);
const responseType = Number(cipher.type) || 1;
const normalizedSecureNote = responseType === 2
? normalizeCipherSecureNoteForCompatibility((passthrough as any).secureNote ?? null) ?? { type: 0 } ? normalizeCipherSecureNoteForCompatibility((passthrough as any).secureNote ?? null) ?? { type: 0 }
: null; : null;
const responseAttachments = applyCipherEmbeddedAttachmentMetadata(cipher, attachments); const responseAttachments = applyCipherEmbeddedAttachmentMetadata(cipher, attachments);
@@ -763,7 +836,7 @@ export function cipherToResponse(
...passthrough, ...passthrough,
// Server-computed / enforced fields (always override) // Server-computed / enforced fields (always override)
folderId: normalizeResponseFolderId(cipher.folderId, options.validFolderIds), folderId: normalizeResponseFolderId(cipher.folderId, options.validFolderIds),
type: Number(cipher.type) || 1, type: responseType,
organizationId: normalizeOptionalId((passthrough as any).organizationId ?? null), organizationId: normalizeOptionalId((passthrough as any).organizationId ?? null),
organizationUseTotp: !!((passthrough as any).organizationUseTotp ?? false), organizationUseTotp: !!((passthrough as any).organizationUseTotp ?? false),
creationDate: createdAt, creationDate: createdAt,
@@ -785,6 +858,9 @@ export function cipherToResponse(
fields: normalizeCipherFieldsForCompatibility((passthrough as any).fields), fields: normalizeCipherFieldsForCompatibility((passthrough as any).fields),
passwordHistory: normalizePasswordHistoryForCompatibility((passthrough as any).passwordHistory), passwordHistory: normalizePasswordHistoryForCompatibility((passthrough as any).passwordHistory),
sshKey: normalizedSshKey, sshKey: normalizedSshKey,
bankAccount: responseType === 6 ? normalizedBankAccount : null,
driversLicense: responseType === 7 ? normalizedDriversLicense : null,
passport: responseType === 8 ? normalizedPassport : null,
key: responseCipherKey, key: responseCipherKey,
data: typeof (passthrough as any).data === 'string' ? (passthrough as any).data : null, data: typeof (passthrough as any).data === 'string' ? (passthrough as any).data : null,
encryptedFor: (passthrough as any).encryptedFor ?? null, encryptedFor: (passthrough as any).encryptedFor ?? null,
@@ -880,6 +956,9 @@ export async function handleCreateCipher(request: Request, env: Env, userId: str
const createIdentity = readCipherProp<CipherIdentity | null>(cipherData, ['identity', 'Identity']); const createIdentity = readCipherProp<CipherIdentity | null>(cipherData, ['identity', 'Identity']);
const createSecureNote = readCipherProp<CipherSecureNote | null>(cipherData, ['secureNote', 'SecureNote']); const createSecureNote = readCipherProp<CipherSecureNote | null>(cipherData, ['secureNote', 'SecureNote']);
const createSshKey = readCipherProp<CipherSshKey | null>(cipherData, ['sshKey', 'SshKey']); const createSshKey = readCipherProp<CipherSshKey | null>(cipherData, ['sshKey', 'SshKey']);
const createBankAccount = readCipherProp<CipherBankAccount | null>(cipherData, ['bankAccount', 'BankAccount']);
const createDriversLicense = readCipherProp<CipherDriversLicense | null>(cipherData, ['driversLicense', 'DriversLicense']);
const createPassport = readCipherProp<CipherPassport | null>(cipherData, ['passport', 'Passport']);
const createPasswordHistory = readCipherProp<PasswordHistory[] | null>(cipherData, ['passwordHistory', 'PasswordHistory']); const createPasswordHistory = readCipherProp<PasswordHistory[] | null>(cipherData, ['passwordHistory', 'PasswordHistory']);
if (createKey.present && !shouldAcceptCipherKey(createKey.value)) { if (createKey.present && !shouldAcceptCipherKey(createKey.value)) {
@@ -909,6 +988,9 @@ export async function handleCreateCipher(request: Request, env: Env, userId: str
cipher.identity = createIdentity.present ? (createIdentity.value ?? null) : (cipher.identity ?? null); cipher.identity = createIdentity.present ? (createIdentity.value ?? null) : (cipher.identity ?? null);
cipher.secureNote = createSecureNote.present ? (createSecureNote.value ?? null) : (cipher.secureNote ?? null); cipher.secureNote = createSecureNote.present ? (createSecureNote.value ?? null) : (cipher.secureNote ?? null);
cipher.sshKey = createSshKey.present ? (createSshKey.value ?? null) : (cipher.sshKey ?? null); cipher.sshKey = createSshKey.present ? (createSshKey.value ?? null) : (cipher.sshKey ?? null);
cipher.bankAccount = createBankAccount.present ? (createBankAccount.value ?? null) : ((cipher as any).bankAccount ?? null);
cipher.driversLicense = createDriversLicense.present ? (createDriversLicense.value ?? null) : ((cipher as any).driversLicense ?? null);
cipher.passport = createPassport.present ? (createPassport.value ?? null) : ((cipher as any).passport ?? null);
cipher.passwordHistory = createPasswordHistory.present ? (createPasswordHistory.value ?? null) : (cipher.passwordHistory ?? null); cipher.passwordHistory = createPasswordHistory.present ? (createPasswordHistory.value ?? null) : (cipher.passwordHistory ?? null);
const createFields = getAliasedProp(cipherData, ['fields', 'Fields']); const createFields = getAliasedProp(cipherData, ['fields', 'Fields']);
cipher.fields = createFields.present ? (createFields.value ?? null) : (cipher.fields ?? null); cipher.fields = createFields.present ? (createFields.value ?? null) : (cipher.fields ?? null);
@@ -960,6 +1042,9 @@ export async function handleUpdateCipher(request: Request, env: Env, userId: str
const incomingIdentity = readCipherProp<CipherIdentity | null>(cipherData, ['identity', 'Identity']); const incomingIdentity = readCipherProp<CipherIdentity | null>(cipherData, ['identity', 'Identity']);
const incomingSecureNote = readCipherProp<CipherSecureNote | null>(cipherData, ['secureNote', 'SecureNote']); const incomingSecureNote = readCipherProp<CipherSecureNote | null>(cipherData, ['secureNote', 'SecureNote']);
const incomingSshKey = readCipherProp<CipherSshKey | null>(cipherData, ['sshKey', 'SshKey']); const incomingSshKey = readCipherProp<CipherSshKey | null>(cipherData, ['sshKey', 'SshKey']);
const incomingBankAccount = readCipherProp<CipherBankAccount | null>(cipherData, ['bankAccount', 'BankAccount']);
const incomingDriversLicense = readCipherProp<CipherDriversLicense | null>(cipherData, ['driversLicense', 'DriversLicense']);
const incomingPassport = readCipherProp<CipherPassport | null>(cipherData, ['passport', 'Passport']);
const incomingPasswordHistory = readCipherProp<PasswordHistory[] | null>(cipherData, ['passwordHistory', 'PasswordHistory']); const incomingPasswordHistory = readCipherProp<PasswordHistory[] | null>(cipherData, ['passwordHistory', 'PasswordHistory']);
const incomingRevisionDate = readCipherRevisionDate(cipherData); const incomingRevisionDate = readCipherRevisionDate(cipherData);
const hasAttachmentMigrationMetadata = hasIncomingAttachmentMetadata(cipherData); const hasAttachmentMigrationMetadata = hasIncomingAttachmentMetadata(cipherData);
@@ -1008,6 +1093,9 @@ export async function handleUpdateCipher(request: Request, env: Env, userId: str
cipher.card = nextType === 3 ? (incomingCard.present ? (incomingCard.value ?? null) : (existingCipher.card ?? null)) : null; cipher.card = nextType === 3 ? (incomingCard.present ? (incomingCard.value ?? null) : (existingCipher.card ?? null)) : null;
cipher.identity = nextType === 4 ? (incomingIdentity.present ? (incomingIdentity.value ?? null) : (existingCipher.identity ?? null)) : null; cipher.identity = nextType === 4 ? (incomingIdentity.present ? (incomingIdentity.value ?? null) : (existingCipher.identity ?? null)) : null;
cipher.sshKey = nextType === 5 ? (incomingSshKey.present ? (incomingSshKey.value ?? null) : (existingCipher.sshKey ?? null)) : null; cipher.sshKey = nextType === 5 ? (incomingSshKey.present ? (incomingSshKey.value ?? null) : (existingCipher.sshKey ?? null)) : null;
cipher.bankAccount = nextType === 6 ? (incomingBankAccount.present ? (incomingBankAccount.value ?? null) : ((existingCipher as any).bankAccount ?? null)) : null;
cipher.driversLicense = nextType === 7 ? (incomingDriversLicense.present ? (incomingDriversLicense.value ?? null) : ((existingCipher as any).driversLicense ?? null)) : null;
cipher.passport = nextType === 8 ? (incomingPassport.present ? (incomingPassport.value ?? null) : ((existingCipher as any).passport ?? null)) : null;
if (incomingPasswordHistory.present) { if (incomingPasswordHistory.present) {
cipher.passwordHistory = incomingPasswordHistory.value ?? null; cipher.passwordHistory = incomingPasswordHistory.value ?? null;
} }
+80 -1
View File
@@ -6,7 +6,7 @@ import { auditRequestMetadata, writeAuditEvent } from '../services/audit-events'
import { registerMobilePushDevice, unregisterMobilePushDevice } from '../services/push-relay'; import { registerMobilePushDevice, unregisterMobilePushDevice } from '../services/push-relay';
import { StorageService } from '../services/storage'; import { StorageService } from '../services/storage';
import { errorResponse, jsonResponse } from '../utils/response'; import { errorResponse, jsonResponse } from '../utils/response';
import { readKnownDeviceProbe } from '../utils/device'; import { readAuthRequestDeviceInfo, readKnownDeviceProbe } from '../utils/device';
import { generateUUID } from '../utils/uuid'; import { generateUUID } from '../utils/uuid';
const PERMANENT_TRUST_EXPIRES_AT_MS = Date.UTC(2099, 11, 31, 23, 59, 59); const PERMANENT_TRUST_EXPIRES_AT_MS = Date.UTC(2099, 11, 31, 23, 59, 59);
@@ -125,6 +125,85 @@ function parseDeviceName(value: unknown): string {
return String(value || '').trim().slice(0, 128); return String(value || '').trim().slice(0, 128);
} }
function parseDeviceType(value: unknown): number | null {
if (typeof value === 'number' && Number.isFinite(value)) return Math.max(0, Math.floor(value));
const parsed = Number.parseInt(String(value ?? ''), 10);
return Number.isFinite(parsed) && parsed >= 0 ? parsed : null;
}
// POST /api/devices
export async function handleRegisterDevice(request: Request, env: Env, userId: string): Promise<Response> {
const body = await readJsonBody(request);
if (!body) return errorResponse('Invalid request payload', 400);
const identifier = normalizeIdentifier(body.identifier ?? body.Identifier ?? body.deviceIdentifier ?? body.DeviceIdentifier);
const name = parseDeviceName(body.name ?? body.Name ?? body.deviceName ?? body.DeviceName) || 'Unknown device';
const type = parseDeviceType(body.type ?? body.Type ?? body.deviceType ?? body.DeviceType);
if (!identifier || type == null) return errorResponse('Device identifier and type are required', 400);
const storage = new StorageService(env.DB);
await storage.upsertDevice(userId, identifier, name, type, undefined, parseKeysBody(body));
const pushToken = String(body.pushToken ?? body.PushToken ?? '').trim();
if (pushToken) {
const device = await storage.getDevice(userId, identifier);
const pushUuid = device?.pushUuid || generateUUID();
const updated = await storage.updateDevicePushToken(userId, identifier, pushUuid, pushToken);
if (updated) {
await registerMobilePushDevice(env, {
userId,
deviceIdentifier: identifier,
type,
pushUuid,
pushToken,
});
}
}
const device = await storage.getDevice(userId, identifier);
if (!device) return errorResponse('Device registration failed', 500);
await writeAuditEvent(storage, {
actorUserId: userId,
action: 'device.register',
category: 'device',
level: 'info',
targetType: 'device',
targetId: identifier,
metadata: auditRequestMetadata(request),
});
return jsonResponse(buildDeviceResponse(device));
}
// POST /api/devices/lost-trust
export async function handleReportLostTrust(request: Request, env: Env, userId: string): Promise<Response> {
const body = await readJsonBody(request) || {};
const deviceInfo = readAuthRequestDeviceInfo(
{
deviceIdentifier: String(body.identifier ?? body.Identifier ?? body.deviceIdentifier ?? body.DeviceIdentifier ?? ''),
deviceName: String(body.name ?? body.Name ?? body.deviceName ?? body.DeviceName ?? ''),
deviceType: String(body.type ?? body.Type ?? body.deviceType ?? body.DeviceType ?? ''),
},
request
);
if (!deviceInfo.deviceIdentifier) return errorResponse('Please provide a device identifier', 400);
const storage = new StorageService(env.DB);
await writeAuditEvent(storage, {
actorUserId: userId,
action: 'device.lost_trust',
category: 'device',
level: 'warn',
targetType: 'device',
targetId: deviceInfo.deviceIdentifier,
metadata: {
deviceIdentifier: deviceInfo.deviceIdentifier,
deviceType: deviceInfo.deviceType,
...auditRequestMetadata(request),
},
});
return new Response(null, { status: 200 });
}
// GET /api/devices/knowndevice // GET /api/devices/knowndevice
// Compatible with Bitwarden/Vaultwarden behavior: // Compatible with Bitwarden/Vaultwarden behavior:
// - X-Request-Email: base64url(email) without padding // - X-Request-Email: base64url(email) without padding
+48 -4
View File
@@ -1,19 +1,46 @@
const EMPTY_FORMS_FILENAME = 'forms.v1.json'; const EMPTY_FORMS_FILENAME = 'forms.v1.json';
const EMPTY_FORMS_SCHEMA_FILENAME = 'forms.v1.schema.json';
const EMPTY_FORMS_CID = 'sha256:189fa7c9bcf8951e65c18b5d9feacf74a5223c75e01667c4235388cbc67091fe';
const EMPTY_FORMS_BODY = JSON.stringify({ const EMPTY_FORMS_BODY = JSON.stringify({
schemaVersion: '1.0.0', schemaVersion: '1.0.0',
hosts: {}, hosts: {},
}); });
const EMPTY_FORMS_SCHEMA_BODY = JSON.stringify({
$schema: 'https://json-schema.org/draft/2020-12/schema',
title: 'Bitwarden Fill Assist Forms v1',
type: 'object',
required: ['schemaVersion', 'hosts'],
properties: {
schemaVersion: { type: 'string' },
hosts: { type: 'object' },
},
additionalProperties: true,
});
const EMPTY_MANIFEST_BODY = JSON.stringify({ const EMPTY_MANIFEST_BODY = JSON.stringify({
buildId: 'nodewarden-empty-fill-assist-v1',
timestamp: '2026-07-06T00:00:00.000Z',
gitSha: 'nodewarden',
maps: { maps: {
forms: { forms: {
v1: { v1: {
filename: EMPTY_FORMS_FILENAME, filename: EMPTY_FORMS_FILENAME,
cid: 'sha256:nodewarden-empty-fill-assist-v1', cid: EMPTY_FORMS_CID,
schema: EMPTY_FORMS_SCHEMA_FILENAME,
deprecated: false,
}, },
}, },
}, },
}); });
const DIGITAL_ASSET_LINK_CHECK_BODY = JSON.stringify({
linked: false,
maxAge: '86400s',
debugString: 'No matching digital asset link policy is configured for this server.',
});
function fillAssistJsonResponse(body: string): Response { function fillAssistJsonResponse(body: string): Response {
return new Response(body, { return new Response(body, {
status: 200, status: 200,
@@ -24,13 +51,30 @@ function fillAssistJsonResponse(body: string): Response {
}); });
} }
function normalizeFilename(filename: string): string {
const raw = String(filename || '').trim();
try {
return decodeURIComponent(raw);
} catch {
return raw;
}
}
export function handleFillAssistManifest(): Response { export function handleFillAssistManifest(): Response {
return fillAssistJsonResponse(EMPTY_MANIFEST_BODY); return fillAssistJsonResponse(EMPTY_MANIFEST_BODY);
} }
export function handleFillAssistForms(filename: string): Response { export function handleFillAssistForms(filename: string): Response {
if (String(filename || '').trim() !== EMPTY_FORMS_FILENAME) { const normalized = normalizeFilename(filename);
return new Response('Not found', { status: 404 }); if (normalized === EMPTY_FORMS_FILENAME) {
}
return fillAssistJsonResponse(EMPTY_FORMS_BODY); return fillAssistJsonResponse(EMPTY_FORMS_BODY);
}
if (normalized === EMPTY_FORMS_SCHEMA_FILENAME) {
return fillAssistJsonResponse(EMPTY_FORMS_SCHEMA_BODY);
}
return new Response('Not found', { status: 404 });
}
export function handleDigitalAssetLinkCheck(): Response {
return fillAssistJsonResponse(DIGITAL_ASSET_LINK_CHECK_BODY);
} }
+45 -6
View File
@@ -122,6 +122,16 @@ function readBodyValue(body: Record<string, string>, names: string[]): string |
return undefined; return undefined;
} }
async function sha256Hex(value: string): Promise<string> {
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(value));
return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, '0')).join('');
}
async function loginRateLimitKey(clientIdentifier: string, grantType: string, subject: string): Promise<string> {
const subjectHash = await sha256Hex(`${grantType}:${String(subject || '').trim() || 'unknown'}`);
return `${clientIdentifier}:login:${grantType}:${subjectHash}`;
}
async function getStoredYubicoCredentials(storage: StorageService, env: Env): Promise<YubicoApiCredentials | null> { async function getStoredYubicoCredentials(storage: StorageService, env: Env): Promise<YubicoApiCredentials | null> {
const fromEnv = yubicoCredentialsFromEnv(env); const fromEnv = yubicoCredentialsFromEnv(env);
if (fromEnv) return fromEnv; if (fromEnv) return fromEnv;
@@ -163,6 +173,30 @@ function withWebRefreshCookie(request: Request, response: Response, refreshToken
}); });
} }
async function revokePresentedAccessTokenSession(request: Request, env: Env, storage: StorageService): Promise<void> {
const authHeader = request.headers.get('Authorization');
if (!authHeader) return;
const auth = new AuthService(env);
const verified = await auth.verifyAccessTokenWithUser(authHeader);
if (!verified) return;
const deviceIdentifier = String(verified.payload.did || '').trim();
if (deviceIdentifier) {
const nextSessionStamp = generateUUID();
await storage.rotateDeviceSessionStamp(verified.user.id, deviceIdentifier, nextSessionStamp);
await storage.deleteRefreshTokensByDevice(verified.user.id, deviceIdentifier);
AuthService.invalidateDeviceCache(verified.user.id, deviceIdentifier);
return;
}
verified.user.securityStamp = generateUUID();
verified.user.updatedAt = new Date().toISOString();
await storage.saveUser(verified.user);
await storage.deleteRefreshTokensByUserId(verified.user.id);
AuthService.invalidateUserCache(verified.user.id);
}
function buildPreloginResponse( function buildPreloginResponse(
email: string, email: string,
kdfType: number, kdfType: number,
@@ -319,13 +353,13 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
const twoFactorToken = readBodyValue(body, ['twoFactorToken', 'TwoFactorToken']); const twoFactorToken = readBodyValue(body, ['twoFactorToken', 'TwoFactorToken']);
const twoFactorProvider = readBodyValue(body, ['twoFactorProvider', 'TwoFactorProvider']); const twoFactorProvider = readBodyValue(body, ['twoFactorProvider', 'TwoFactorProvider']);
const twoFactorRemember = readBodyValue(body, ['twoFactorRemember', 'TwoFactorRemember']); const twoFactorRemember = readBodyValue(body, ['twoFactorRemember', 'TwoFactorRemember']);
const loginIdentifier = clientIdentifier;
const deviceInfo = readAuthRequestDeviceInfo(body, request); const deviceInfo = readAuthRequestDeviceInfo(body, request);
if (!email || !passwordHash) { if (!email || !passwordHash) {
// Bitwarden clients expect OAuth-style error fields. // Bitwarden clients expect OAuth-style error fields.
return identityErrorResponse('Email and password are required', 'invalid_request', 400); return identityErrorResponse('Email and password are required', 'invalid_request', 400);
} }
const loginIdentifier = await loginRateLimitKey(clientIdentifier, grantType, email);
// Check login lockout before user lookup to reduce user-enumeration signal // Check login lockout before user lookup to reduce user-enumeration signal
const loginCheck = await rateLimit.checkLoginAttempt(loginIdentifier); const loginCheck = await rateLimit.checkLoginAttempt(loginIdentifier);
@@ -584,7 +618,8 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
: baseResponse; : baseResponse;
} else if (grantType === 'webauthn') { } else if (grantType === 'webauthn') {
const loginIdentifier = clientIdentifier; const token = String(body.token || '').trim();
const loginIdentifier = await loginRateLimitKey(clientIdentifier, grantType, token || 'missing-token');
const loginCheck = await rateLimit.checkLoginAttempt(loginIdentifier); const loginCheck = await rateLimit.checkLoginAttempt(loginIdentifier);
if (!loginCheck.allowed) { if (!loginCheck.allowed) {
return identityErrorResponse( return identityErrorResponse(
@@ -594,7 +629,6 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
); );
} }
const token = String(body.token || '').trim();
let deviceResponse: unknown = body.deviceResponse; let deviceResponse: unknown = body.deviceResponse;
if (typeof deviceResponse === 'string') { if (typeof deviceResponse === 'string') {
try { try {
@@ -712,11 +746,12 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
const scope = body.scope; const scope = body.scope;
const deviceInfo = readAuthRequestDeviceInfo(body, request); const deviceInfo = readAuthRequestDeviceInfo(body, request);
const loginIdentifier = clientIdentifier;
const parmValid = checkClientCredentialsParam(clientId, clientSecret, scope); const parmValid = checkClientCredentialsParam(clientId, clientSecret, scope);
if (!parmValid) { if (!parmValid) {
return identityErrorResponse('Parameter error', 'invalid_request', 400); return identityErrorResponse('Parameter error', 'invalid_request', 400);
} }
const uid = clientId.slice(5);
const loginIdentifier = await loginRateLimitKey(clientIdentifier, grantType, uid);
// Check login lockout before user lookup to reduce user-enumeration signal // Check login lockout before user lookup to reduce user-enumeration signal
const loginCheck = await rateLimit.checkLoginAttempt(loginIdentifier); const loginCheck = await rateLimit.checkLoginAttempt(loginIdentifier);
@@ -728,7 +763,6 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
); );
} }
const uid = clientId.slice(5);
const user = await storage.getUserById(uid); const user = await storage.getUserById(uid);
if (!user) { if (!user) {
await rateLimit.recordFailedLogin(loginIdentifier); await rateLimit.recordFailedLogin(loginIdentifier);
@@ -871,7 +905,7 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
passwordHashB64, passwordHashB64,
password, password,
rateLimit, rateLimit,
`${clientIdentifier}:send-password` clientIdentifier
); );
if ('error' in result) { if ('error' in result) {
return result.error; return result.error;
@@ -1010,6 +1044,11 @@ export async function handlePrelogin(request: Request, env: Env): Promise<Respon
// RFC 7009 allows returning 200 even if token is unknown. // RFC 7009 allows returning 200 even if token is unknown.
export async function handleRevocation(request: Request, env: Env): Promise<Response> { export async function handleRevocation(request: Request, env: Env): Promise<Response> {
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
try {
await revokePresentedAccessTokenSession(request, env, storage);
} catch {
// RFC 7009 revocation is best-effort and should not reveal token state.
}
let body: Record<string, string>; let body: Record<string, string>;
const contentType = request.headers.get('content-type') || ''; const contentType = request.headers.get('content-type') || '';
+27 -7
View File
@@ -17,6 +17,9 @@ interface CiphersImportRequest {
favorite?: boolean; favorite?: boolean;
reprompt?: number; reprompt?: number;
sshKey?: any | null; sshKey?: any | null;
bankAccount?: any | null;
driversLicense?: any | null;
passport?: any | null;
key?: string | null; key?: string | null;
login?: { login?: {
uris?: Array<{ uri: string | null; uriChecksum?: string | null; match?: number | null }> | null; uris?: Array<{ uri: string | null; uriChecksum?: string | null; match?: number | null }> | null;
@@ -92,6 +95,12 @@ function readAliasedImportProp<T = unknown>(source: any, aliases: string[]): T |
return undefined; return undefined;
} }
function normalizeOptionalId(value: unknown): string | null {
if (value == null) return null;
const normalized = String(value).trim();
return normalized ? normalized : null;
}
async function runBatchInChunks(db: D1Database, statements: D1PreparedStatement[], chunkSize: number): Promise<void> { async function runBatchInChunks(db: D1Database, statements: D1PreparedStatement[], chunkSize: number): Promise<void> {
for (let i = 0; i < statements.length; i += chunkSize) { for (let i = 0; i < statements.length; i += chunkSize) {
const chunk = statements.slice(i, i + chunkSize); const chunk = statements.slice(i, i + chunkSize);
@@ -112,9 +121,9 @@ export async function handleCiphersImport(request: Request, env: Env, userId: st
return errorResponse('Invalid JSON', 400); return errorResponse('Invalid JSON', 400);
} }
const folders = importData.folders || []; const folders = Array.isArray(importData.folders) ? importData.folders : [];
const ciphers = importData.ciphers || []; const ciphers = Array.isArray(importData.ciphers) ? importData.ciphers : [];
const folderRelationships = importData.folderRelationships || []; const folderRelationships = Array.isArray(importData.folderRelationships) ? importData.folderRelationships : [];
if (folders.length + ciphers.length > LIMITS.performance.importItemLimit) { if (folders.length + ciphers.length > LIMITS.performance.importItemLimit) {
return errorResponse(`Import exceeds maximum of ${LIMITS.performance.importItemLimit} items`, 400); return errorResponse(`Import exceeds maximum of ${LIMITS.performance.importItemLimit} items`, 400);
@@ -128,13 +137,14 @@ export async function handleCiphersImport(request: Request, env: Env, userId: st
const folderRows: Folder[] = []; const folderRows: Folder[] = [];
for (let i = 0; i < folders.length; i++) { for (let i = 0; i < folders.length; i++) {
const importedFolder = folders[i] && typeof folders[i] === 'object' ? folders[i] : null;
const folderId = generateUUID(); const folderId = generateUUID();
folderIdMap.set(i, folderId); folderIdMap.set(i, folderId);
const folder: Folder = { const folder: Folder = {
id: folderId, id: folderId,
userId: userId, userId: userId,
name: folders[i].name, name: typeof importedFolder?.name === 'string' && importedFolder.name ? importedFolder.name : 'Folder',
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
}; };
@@ -157,24 +167,31 @@ export async function handleCiphersImport(request: Request, env: Env, userId: st
// Build cipher index -> folder id mapping from relationships // Build cipher index -> folder id mapping from relationships
const cipherFolderMap = new Map<number, string>(); const cipherFolderMap = new Map<number, string>();
for (const rel of folderRelationships) { for (const rel of folderRelationships) {
if (!rel || typeof rel !== 'object') continue;
const folderId = folderIdMap.get(rel.value); const folderId = folderIdMap.get(rel.value);
if (folderId) { if (folderId) {
cipherFolderMap.set(rel.key, folderId); cipherFolderMap.set(rel.key, folderId);
} }
} }
const existingFolderIds = new Set((await storage.getAllFolders(userId)).map((folder) => folder.id));
// Create ciphers // Create ciphers
const cipherRows: Cipher[] = []; const cipherRows: Cipher[] = [];
const cipherMapRows: Array<{ index: number; sourceId: string | null; id: string }> = []; const cipherMapRows: Array<{ index: number; sourceId: string | null; id: string }> = [];
for (let i = 0; i < ciphers.length; i++) { for (let i = 0; i < ciphers.length; i++) {
const c = ciphers[i]; const c = ciphers[i] && typeof ciphers[i] === 'object' ? ciphers[i] : {} as CiphersImportRequest['ciphers'][number];
const folderId = cipherFolderMap.get(i) || readAliasedImportProp<string | null>(c, ['folderId', 'FolderId']) || null; const importedFolderId = normalizeOptionalId(readAliasedImportProp<string | null>(c, ['folderId', 'FolderId']));
const folderId = cipherFolderMap.get(i) || (importedFolderId && existingFolderIds.has(importedFolderId) ? importedFolderId : null);
const sourceIdRaw = String(c?.id ?? '').trim(); const sourceIdRaw = String(c?.id ?? '').trim();
const sourceId = sourceIdRaw || null; const sourceId = sourceIdRaw || null;
const login = readAliasedImportProp<any | null>(c, ['login', 'Login']); const login = readAliasedImportProp<any | null>(c, ['login', 'Login']);
const card = readAliasedImportProp<any | null>(c, ['card', 'Card']); const card = readAliasedImportProp<any | null>(c, ['card', 'Card']);
const identity = readAliasedImportProp<any | null>(c, ['identity', 'Identity']); const identity = readAliasedImportProp<any | null>(c, ['identity', 'Identity']);
const secureNote = readAliasedImportProp<any | null>(c, ['secureNote', 'SecureNote']); const secureNote = readAliasedImportProp<any | null>(c, ['secureNote', 'SecureNote']);
const sshKey = readAliasedImportProp<any | null>(c, ['sshKey', 'SshKey']);
const bankAccount = readAliasedImportProp<any | null>(c, ['bankAccount', 'BankAccount']);
const driversLicense = readAliasedImportProp<any | null>(c, ['driversLicense', 'DriversLicense']);
const passport = readAliasedImportProp<any | null>(c, ['passport', 'Passport']);
const fields = readAliasedImportProp<any[] | null>(c, ['fields', 'Fields']); const fields = readAliasedImportProp<any[] | null>(c, ['fields', 'Fields']);
const passwordHistory = readAliasedImportProp<any[] | null>(c, ['passwordHistory', 'PasswordHistory']); const passwordHistory = readAliasedImportProp<any[] | null>(c, ['passwordHistory', 'PasswordHistory']);
const key = readAliasedImportProp<string | null>(c, ['key', 'Key']); const key = readAliasedImportProp<string | null>(c, ['key', 'Key']);
@@ -244,7 +261,10 @@ export async function handleCiphersImport(request: Request, env: Env, userId: st
})) || null, })) || null,
passwordHistory: passwordHistory ?? null, passwordHistory: passwordHistory ?? null,
reprompt: c.reprompt ?? 0, reprompt: c.reprompt ?? 0,
sshKey: normalizeCipherSshKeyForCompatibility((c as any).sshKey ?? null), sshKey: normalizeCipherSshKeyForCompatibility(sshKey ?? null),
bankAccount: bankAccount ?? null,
driversLicense: driversLicense ?? null,
passport: passport ?? null,
key: key ?? null, key: key ?? null,
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
+8
View File
@@ -1,4 +1,6 @@
import { AuthService } from '../services/auth'; import { AuthService } from '../services/auth';
import { StorageService } from '../services/storage';
import { isAuthRequestExpired } from '../services/storage-auth-request-repo';
import type { Env, JWTPayload } from '../types'; import type { Env, JWTPayload } from '../types';
import { errorResponse, jsonResponse } from '../utils/response'; import { errorResponse, jsonResponse } from '../utils/response';
import { generateUUID } from '../utils/uuid'; import { generateUUID } from '../utils/uuid';
@@ -65,6 +67,12 @@ export async function handleAnonymousNotificationsHub(request: Request, env: Env
return errorResponse('Expected websocket', 426); return errorResponse('Expected websocket', 426);
} }
const storage = new StorageService(env.DB);
const authRequest = await storage.getAuthRequestById(authRequestId);
if (!authRequest || isAuthRequestExpired(authRequest)) {
return errorResponse('Not found', 404);
}
const id = env.NOTIFICATIONS_HUB.idFromName(authRequestId); const id = env.NOTIFICATIONS_HUB.idFromName(authRequestId);
const stub = env.NOTIFICATIONS_HUB.get(id); const stub = env.NOTIFICATIONS_HUB.get(id);
const forwardedUrl = new URL(request.url); const forwardedUrl = new URL(request.url);
+29 -5
View File
@@ -8,6 +8,7 @@ import { LIMITS } from '../config/limits';
import { import {
getBlobStorageMaxBytes, getBlobStorageMaxBytes,
getSendFileObjectKey, getSendFileObjectKey,
getBlobObject,
putBlobObject, putBlobObject,
deleteBlobObject, deleteBlobObject,
} from '../services/blob-store'; } from '../services/blob-store';
@@ -34,6 +35,8 @@ import {
} from './sends-shared'; } from './sends-shared';
import { auditRequestMetadata, writeAuditEvent } from '../services/audit-events'; import { auditRequestMetadata, writeAuditEvent } from '../services/audit-events';
const SEND_EMAIL_AUTH_UNSUPPORTED_MESSAGE = 'Send email verification is not supported by this server.';
async function writeSendAudit( async function writeSendAudit(
storage: StorageService, storage: StorageService,
request: Request, request: Request,
@@ -82,8 +85,13 @@ async function processSendFileUpload(
return upload; return upload;
} }
const path = getSendFileObjectKey(send.id, fileId);
if (await getBlobObject(env, path)) {
return errorResponse('Send file has already been uploaded', 409);
}
try { try {
await putBlobObject(env, getSendFileObjectKey(send.id, fileId), upload.body, { await putBlobObject(env, path, upload.body, {
size: upload.size, size: upload.size,
contentType: upload.contentType, contentType: upload.contentType,
customMetadata: { customMetadata: {
@@ -210,11 +218,17 @@ export async function handleCreateSend(request: Request, env: Env, userId: strin
if (authTypeRaw.present && requestedAuthType === null) { if (authTypeRaw.present && requestedAuthType === null) {
return errorResponse('Invalid authType', 400); return errorResponse('Invalid authType', 400);
} }
if (requestedAuthType === SendAuthType.Email) {
return errorResponse(SEND_EMAIL_AUTH_UNSUPPORTED_MESSAGE, 501);
}
const normalizedEmails = normalizeEmails(emailsRaw.value); const normalizedEmails = normalizeEmails(emailsRaw.value);
if (emailsRaw.present && emailsRaw.value !== null && normalizedEmails === null) { if (emailsRaw.present && emailsRaw.value !== null && normalizedEmails === null) {
return errorResponse('Invalid emails', 400); return errorResponse('Invalid emails', 400);
} }
if (normalizedEmails) {
return errorResponse(SEND_EMAIL_AUTH_UNSUPPORTED_MESSAGE, 501);
}
const now = new Date().toISOString(); const now = new Date().toISOString();
const send: Send = { const send: Send = {
@@ -334,11 +348,17 @@ export async function handleCreateFileSendV2(request: Request, env: Env, userId:
if (authTypeRaw.present && requestedAuthType === null) { if (authTypeRaw.present && requestedAuthType === null) {
return errorResponse('Invalid authType', 400); return errorResponse('Invalid authType', 400);
} }
if (requestedAuthType === SendAuthType.Email) {
return errorResponse(SEND_EMAIL_AUTH_UNSUPPORTED_MESSAGE, 501);
}
const normalizedEmails = normalizeEmails(emailsRaw.value); const normalizedEmails = normalizeEmails(emailsRaw.value);
if (emailsRaw.present && emailsRaw.value !== null && normalizedEmails === null) { if (emailsRaw.present && emailsRaw.value !== null && normalizedEmails === null) {
return errorResponse('Invalid emails', 400); return errorResponse('Invalid emails', 400);
} }
if (normalizedEmails) {
return errorResponse(SEND_EMAIL_AUTH_UNSUPPORTED_MESSAGE, 501);
}
const now = new Date().toISOString(); const now = new Date().toISOString();
const send: Send = { const send: Send = {
@@ -592,10 +612,11 @@ export async function handleUpdateSend(request: Request, env: Env, userId: strin
if (parsedAuthType === null) { if (parsedAuthType === null) {
return errorResponse('Invalid authType', 400); return errorResponse('Invalid authType', 400);
} }
send.authType = parsedAuthType; if (parsedAuthType === SendAuthType.Email) {
if (parsedAuthType !== SendAuthType.Email) { return errorResponse(SEND_EMAIL_AUTH_UNSUPPORTED_MESSAGE, 501);
send.emails = null;
} }
send.authType = parsedAuthType;
send.emails = null;
} }
const emailsRaw = getAliasedProp(body, ['emails', 'Emails']); const emailsRaw = getAliasedProp(body, ['emails', 'Emails']);
@@ -604,10 +625,13 @@ export async function handleUpdateSend(request: Request, env: Env, userId: strin
if (emailsRaw.value !== null && normalizedEmails === null) { if (emailsRaw.value !== null && normalizedEmails === null) {
return errorResponse('Invalid emails', 400); return errorResponse('Invalid emails', 400);
} }
if (normalizedEmails) {
return errorResponse(SEND_EMAIL_AUTH_UNSUPPORTED_MESSAGE, 501);
}
send.emails = normalizedEmails; send.emails = normalizedEmails;
if (send.emails) { if (send.emails) {
send.authType = SendAuthType.Email; send.authType = SendAuthType.Email;
} else if (send.authType === SendAuthType.Email) { } else if (Number(send.authType) === SendAuthType.Email) {
send.authType = SendAuthType.None; send.authType = SendAuthType.None;
} }
} }
+17 -6
View File
@@ -68,7 +68,7 @@ export async function handleAccessSend(request: Request, env: Env, accessId: str
if (!clientIdentifier) { if (!clientIdentifier) {
return errorResponse('Client IP is required', 403); return errorResponse('Client IP is required', 403);
} }
sendPasswordLimitIpKey = sendPasswordLimitKey(clientIdentifier); sendPasswordLimitIpKey = sendPasswordLimitKey(clientIdentifier, send.id);
sendPasswordRateLimit = new RateLimitService(env.DB); sendPasswordRateLimit = new RateLimitService(env.DB);
const sendPasswordCheck = await sendPasswordRateLimit.checkLoginAttempt(sendPasswordLimitIpKey); const sendPasswordCheck = await sendPasswordRateLimit.checkLoginAttempt(sendPasswordLimitIpKey);
if (!sendPasswordCheck.allowed) { if (!sendPasswordCheck.allowed) {
@@ -142,7 +142,7 @@ export async function handleAccessSendFile(
if (!clientIdentifier) { if (!clientIdentifier) {
return errorResponse('Client IP is required', 403); return errorResponse('Client IP is required', 403);
} }
sendPasswordLimitIpKey = sendPasswordLimitKey(clientIdentifier); sendPasswordLimitIpKey = sendPasswordLimitKey(clientIdentifier, send.id);
sendPasswordRateLimit = new RateLimitService(env.DB); sendPasswordRateLimit = new RateLimitService(env.DB);
const sendPasswordCheck = await sendPasswordRateLimit.checkLoginAttempt(sendPasswordLimitIpKey); const sendPasswordCheck = await sendPasswordRateLimit.checkLoginAttempt(sendPasswordLimitIpKey);
if (!sendPasswordCheck.allowed) { if (!sendPasswordCheck.allowed) {
@@ -290,12 +290,20 @@ export async function handleDownloadSendFile(
} }
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
const send = await storage.getSend(sendId);
if (!send || !isSendAvailable(send) || send.type !== SendType.File) {
return errorResponse(SEND_INACCESSIBLE_MSG, 404);
}
const data = parseStoredSendData(send);
const expectedFileId = typeof data.id === 'string' ? data.id : null;
if (!expectedFileId || expectedFileId !== fileId) {
return errorResponse(SEND_INACCESSIBLE_MSG, 404);
}
const object = await getBlobObject(env, getSendFileObjectKey(sendId, fileId)); const object = await getBlobObject(env, getSendFileObjectKey(sendId, fileId));
if (!object) { if (!object) {
return errorResponse('Send file not found', 404); return errorResponse('Send file not found', 404);
} }
const send = await storage.getSend(sendId);
const data = send ? parseStoredSendData(send) : {};
const fileName = typeof data.fileName === 'string' ? data.fileName : fileId; const fileName = typeof data.fileName === 'string' ? data.fileName : fileId;
const firstUse = await storage.consumeAttachmentDownloadToken(`send:${claims.jti}`, claims.exp); const firstUse = await storage.consumeAttachmentDownloadToken(`send:${claims.jti}`, claims.exp);
@@ -320,7 +328,7 @@ export async function issueSendAccessToken(
passwordHashB64?: string | null, passwordHashB64?: string | null,
password?: string | null, password?: string | null,
rateLimit?: RateLimitService, rateLimit?: RateLimitService,
sendPasswordLimitIpKey?: string clientIdentifier?: string
): Promise<{ token: string } | { error: Response }> { ): Promise<{ token: string } | { error: Response }> {
const jwt = getSafeJwtSecret(env); const jwt = getSafeJwtSecret(env);
if (!jwt.ok) { if (!jwt.ok) {
@@ -360,11 +368,14 @@ export async function issueSendAccessToken(
Object: 'error', Object: 'error',
}, },
}, },
400 501
), ),
}; };
} }
const sendPasswordLimitIpKey =
rateLimit && clientIdentifier ? sendPasswordLimitKey(clientIdentifier, send.id) : null;
if (send.passwordHash) { if (send.passwordHash) {
if (rateLimit && sendPasswordLimitIpKey) { if (rateLimit && sendPasswordLimitIpKey) {
const sendPasswordCheck = await rateLimit.checkLoginAttempt(sendPasswordLimitIpKey); const sendPasswordCheck = await rateLimit.checkLoginAttempt(sendPasswordLimitIpKey);
+7 -3
View File
@@ -434,8 +434,8 @@ export type PublicSendAccessValidationResult =
| { ok: true } | { ok: true }
| { ok: false; response: Response; reason: 'email_auth_unsupported' | 'password_missing' | 'invalid_password' }; | { ok: false; response: Response; reason: 'email_auth_unsupported' | 'password_missing' | 'invalid_password' };
export function sendPasswordLimitKey(clientIdentifier: string): string { export function sendPasswordLimitKey(clientIdentifier: string, sendId: string): string {
return `${clientIdentifier}:${SEND_PASSWORD_LIMIT_SCOPE}`; return `${clientIdentifier}:${SEND_PASSWORD_LIMIT_SCOPE}:${String(sendId || '').trim() || 'unknown-send'}`;
} }
function sendPasswordLockMessage(retryAfterSeconds: number): string { function sendPasswordLockMessage(retryAfterSeconds: number): string {
@@ -464,7 +464,11 @@ export function sendPasswordLockedOAuthResponse(retryAfterSeconds: number): Resp
export async function validatePublicSendAccess(send: Send, body: unknown): Promise<PublicSendAccessValidationResult> { export async function validatePublicSendAccess(send: Send, body: unknown): Promise<PublicSendAccessValidationResult> {
if (hasEmailAuth(send)) { if (hasEmailAuth(send)) {
return { ok: false, response: errorResponse(SEND_INACCESSIBLE_MSG, 404), reason: 'email_auth_unsupported' }; return {
ok: false,
response: errorResponse('Send email verification is not supported by this server.', 501),
reason: 'email_auth_unsupported',
};
} }
if (!send.passwordHash) return { ok: true }; if (!send.passwordHash) return { ok: true };
+24
View File
@@ -13,6 +13,23 @@ import {
handleAdminClearAuditLogs, handleAdminClearAuditLogs,
} from './handlers/admin'; } from './handlers/admin';
import { handleAdminBackupRoute } from './router-admin-backup'; import { handleAdminBackupRoute } from './router-admin-backup';
import { errorResponse } from './utils/response';
function isKnownAdminPath(path: string): boolean {
return (
path === '/api/admin/users' ||
path === '/api/admin/logs' ||
path === '/api/admin/logs/settings' ||
path === '/api/admin/invites' ||
path.startsWith('/api/admin/backup') ||
/^\/api\/admin\/invites\/[^/]+$/i.test(path) ||
/^\/api\/admin\/users\/[a-f0-9-]+(?:\/status)?$/i.test(path)
);
}
function isActiveAdmin(user: User): boolean {
return user.role === 'admin' && user.status === 'active';
}
export async function handleAdminRoute( export async function handleAdminRoute(
request: Request, request: Request,
@@ -21,6 +38,13 @@ export async function handleAdminRoute(
path: string, path: string,
method: string method: string
): Promise<Response | null> { ): Promise<Response | null> {
if (!isKnownAdminPath(path)) {
return null;
}
if (!isActiveAdmin(actorUser)) {
return errorResponse('Forbidden', 403);
}
if (path === '/api/admin/users' && method === 'GET') { if (path === '/api/admin/users' && method === 'GET') {
return handleAdminListUsers(request, env, actorUser); return handleAdminListUsers(request, env, actorUser);
} }
+55 -4
View File
@@ -1,5 +1,5 @@
import type { Env, User } from './types'; import type { Env, User } from './types';
import { errorResponse, jsonResponse } from './utils/response'; import { errorResponse, jsonResponse, unsupportedResponse } from './utils/response';
import { import {
handleGetProfile, handleGetProfile,
handleUpdateProfile, handleUpdateProfile,
@@ -19,6 +19,8 @@ import {
handlePutTwoFactorYubiKey, handlePutTwoFactorYubiKey,
handlePutTwoFactorYubiKeyConfig, handlePutTwoFactorYubiKeyConfig,
handleBootstrapTwoFactorYubiKeyConfig, handleBootstrapTwoFactorYubiKeyConfig,
handleGetDeviceVerificationSettings,
handlePutDeviceVerificationSettings,
handleDisableTwoFactorProvider, handleDisableTwoFactorProvider,
handleGetApiKey, handleGetApiKey,
handleRotateApiKey, handleRotateApiKey,
@@ -88,6 +90,7 @@ import {
handleUpdateAccountPasskeyEncryption, handleUpdateAccountPasskeyEncryption,
} from './handlers/account-passkeys'; } from './handlers/account-passkeys';
import { import {
handleCreateAdminAuthRequest,
handleGetAuthRequest, handleGetAuthRequest,
handleListAuthRequests, handleListAuthRequests,
handleListPendingAuthRequests, handleListPendingAuthRequests,
@@ -114,6 +117,40 @@ export async function handleAuthenticatedRoute(
} }
} }
if ((path === '/api/accounts/kdf' || path === '/accounts/kdf') && (method === 'POST' || method === 'PUT')) {
return unsupportedResponse('KDF changes are not supported by this server.');
}
const mailBackedAccountPaths = new Set([
'/api/accounts/email-token',
'/accounts/email-token',
'/api/accounts/verify-email',
'/accounts/verify-email',
'/api/accounts/verify-email-token',
'/accounts/verify-email-token',
'/api/accounts/request-otp',
'/accounts/request-otp',
'/api/accounts/verify-otp',
'/accounts/verify-otp',
]);
if (mailBackedAccountPaths.has(path) && (method === 'POST' || method === 'PUT')) {
return unsupportedResponse('Email delivery is not supported by this server.');
}
const emailTwoFactorPaths = new Set([
'/api/two-factor/get-email',
'/two-factor/get-email',
'/api/two-factor/send-email',
'/two-factor/send-email',
'/api/two-factor/send-email-login',
'/two-factor/send-email-login',
'/api/two-factor/email',
'/two-factor/email',
]);
if (emailTwoFactorPaths.has(path) && (method === 'POST' || method === 'PUT' || method === 'DELETE')) {
return unsupportedResponse('Email two-step login is not supported by this server.');
}
if (path === '/api/accounts/profile') { if (path === '/api/accounts/profile') {
if (method === 'GET') return handleGetProfile(request, env, userId); if (method === 'GET') return handleGetProfile(request, env, userId);
if (method === 'PUT') return handleUpdateProfile(request, env, userId); if (method === 'PUT') return handleUpdateProfile(request, env, userId);
@@ -153,6 +190,15 @@ export async function handleAuthenticatedRoute(
return handleGetTwoFactorYubiKey(request, env, userId); return handleGetTwoFactorYubiKey(request, env, userId);
} }
if (path === '/api/two-factor/get-device-verification-settings' && method === 'POST') {
return handleGetDeviceVerificationSettings(request, env, userId);
}
if (path === '/api/two-factor/device-verification-settings') {
if (method === 'PUT' || method === 'POST') return handlePutDeviceVerificationSettings(request, env, userId);
return errorResponse('Method not allowed', 405);
}
if (path === '/api/two-factor/get-webauthn' && method === 'POST') { if (path === '/api/two-factor/get-webauthn' && method === 'POST') {
return handleGetTwoFactorWebAuthn(request, env, userId, currentUser); return handleGetTwoFactorWebAuthn(request, env, userId, currentUser);
} }
@@ -334,17 +380,22 @@ export async function handleAuthenticatedRoute(
if (method === 'DELETE') return handleDeleteFolder(request, env, userId, folderId); if (method === 'DELETE') return handleDeleteFolder(request, env, userId, folderId);
} }
if (path === '/api/auth-requests' || path === '/api/auth-requests/') { if (path === '/api/auth-requests' || path === '/api/auth-requests/' || path === '/auth-requests' || path === '/auth-requests/') {
if (method === 'GET') return handleListAuthRequests(request, env, userId); if (method === 'GET') return handleListAuthRequests(request, env, userId);
return errorResponse('Method not allowed', 405); return errorResponse('Method not allowed', 405);
} }
if (path === '/api/auth-requests/pending') { if (path === '/api/auth-requests/pending' || path === '/auth-requests/pending') {
if (method === 'GET') return handleListPendingAuthRequests(request, env, userId); if (method === 'GET') return handleListPendingAuthRequests(request, env, userId);
return errorResponse('Method not allowed', 405); return errorResponse('Method not allowed', 405);
} }
const authRequestMatch = path.match(/^\/api\/auth-requests\/([a-f0-9-]+)$/i); if (path === '/api/auth-requests/admin-request' || path === '/auth-requests/admin-request') {
if (method === 'POST') return handleCreateAdminAuthRequest(request, env, userId, currentUser.email);
return errorResponse('Method not allowed', 405);
}
const authRequestMatch = path.match(/^\/(?:api\/)?auth-requests\/([a-f0-9-]+)$/i);
if (authRequestMatch) { if (authRequestMatch) {
if (method === 'GET') return handleGetAuthRequest(request, env, userId, authRequestMatch[1]); if (method === 'GET') return handleGetAuthRequest(request, env, userId, authRequestMatch[1]);
if (method === 'PUT') return handleUpdateAuthRequest(request, env, userId, authRequestMatch[1]); if (method === 'PUT') return handleUpdateAuthRequest(request, env, userId, authRequestMatch[1]);
+7
View File
@@ -18,6 +18,8 @@ import {
handleUpdateDeviceToken, handleUpdateDeviceToken,
handleUpdateDeviceWebPushAuth, handleUpdateDeviceWebPushAuth,
handleClearDeviceToken, handleClearDeviceToken,
handleRegisterDevice,
handleReportLostTrust,
} from './handlers/devices'; } from './handlers/devices';
function devicesPath(pattern: string): RegExp { function devicesPath(pattern: string): RegExp {
@@ -33,10 +35,15 @@ export async function handleAuthenticatedDeviceRoute(
): Promise<Response | null> { ): Promise<Response | null> {
if (path === '/api/devices' || path === '/devices') { if (path === '/api/devices' || path === '/devices') {
if (method === 'GET') return handleGetDevices(request, env, userId); if (method === 'GET') return handleGetDevices(request, env, userId);
if (method === 'POST') return handleRegisterDevice(request, env, userId);
if (method === 'DELETE') return handleDeleteAllDevices(request, env, userId); if (method === 'DELETE') return handleDeleteAllDevices(request, env, userId);
return null; return null;
} }
if ((path === '/api/devices/lost-trust' || path === '/devices/lost-trust') && method === 'POST') {
return handleReportLostTrust(request, env, userId);
}
if (path === '/api/devices/authorized' || path === '/devices/authorized') { if (path === '/api/devices/authorized' || path === '/devices/authorized') {
if (method === 'GET') return handleGetAuthorizedDevices(request, env, userId); if (method === 'GET') return handleGetAuthorizedDevices(request, env, userId);
if (method === 'DELETE') return handleRevokeAllTrustedDevices(request, env, userId); if (method === 'DELETE') return handleRevokeAllTrustedDevices(request, env, userId);
+42 -5
View File
@@ -7,7 +7,11 @@ import {
handleDownloadSendFile, handleDownloadSendFile,
} from './handlers/sends'; } from './handlers/sends';
import { handleKnownDevice } from './handlers/devices'; import { handleKnownDevice } from './handlers/devices';
import { handleFillAssistForms, handleFillAssistManifest } from './handlers/fill-assist'; import {
handleDigitalAssetLinkCheck,
handleFillAssistForms,
handleFillAssistManifest,
} from './handlers/fill-assist';
import { handleToken, handlePrelogin, handleRevocation } from './handlers/identity'; import { handleToken, handlePrelogin, handleRevocation } from './handlers/identity';
import { handleGetAccountPasskeyAssertionOptions } from './handlers/account-passkeys'; import { handleGetAccountPasskeyAssertionOptions } from './handlers/account-passkeys';
import { import {
@@ -28,7 +32,7 @@ import {
} from './handlers/notifications'; } from './handlers/notifications';
import { handlePublicUploadSendFile } from './handlers/sends'; import { handlePublicUploadSendFile } from './handlers/sends';
import { isSafeWebsiteIconContentType } from './utils/content-type'; import { isSafeWebsiteIconContentType } from './utils/content-type';
import { jsonResponse } from './utils/response'; import { jsonResponse, unsupportedResponse } from './utils/response';
import { StorageService } from './services/storage'; import { StorageService } from './services/storage';
import type { Env } from './types'; import type { Env } from './types';
@@ -97,7 +101,7 @@ function buildIconServiceCsp(origin: string): string {
} }
function buildConfigResponse(origin: string) { function buildConfigResponse(origin: string) {
const fillAssistBase = `${origin}/fill-assist`; const fillAssistBase = `${origin}/fill-assist/`;
return { return {
version: LIMITS.compatibility.bitwardenServerVersion, version: LIMITS.compatibility.bitwardenServerVersion,
gitHash: 'nodewarden', gitHash: 'nodewarden',
@@ -350,6 +354,12 @@ export async function handlePublicRoute(
return handleFillAssistManifest(); return handleFillAssistManifest();
} }
if ((path === '/v1/assetlinks:check' || path === '/api/v1/assetlinks:check') && method === 'GET') {
const blocked = await enforcePublicRateLimit('public-read', LIMITS.rateLimit.publicReadRequestsPerMinute);
if (blocked) return blocked;
return handleDigitalAssetLinkCheck();
}
const fillAssistFormsMatch = path.match(/^\/fill-assist\/([^/]+)$/i); const fillAssistFormsMatch = path.match(/^\/fill-assist\/([^/]+)$/i);
if (fillAssistFormsMatch && method === 'GET') { if (fillAssistFormsMatch && method === 'GET') {
const blocked = await enforcePublicRateLimit('public-read', LIMITS.rateLimit.publicReadRequestsPerMinute); const blocked = await enforcePublicRateLimit('public-read', LIMITS.rateLimit.publicReadRequestsPerMinute);
@@ -412,13 +422,13 @@ export async function handlePublicRoute(
return handleDownloadSendFile(request, env, sendDownloadMatch[1], sendDownloadMatch[2]); return handleDownloadSendFile(request, env, sendDownloadMatch[1], sendDownloadMatch[2]);
} }
if ((path === '/api/auth-requests' || path === '/api/auth-requests/') && method === 'POST') { if ((path === '/api/auth-requests' || path === '/api/auth-requests/' || path === '/auth-requests' || path === '/auth-requests/') && method === 'POST') {
const blocked = await enforcePublicRateLimit('public-sensitive', LIMITS.rateLimit.sensitivePublicRequestsPerMinute); const blocked = await enforcePublicRateLimit('public-sensitive', LIMITS.rateLimit.sensitivePublicRequestsPerMinute);
if (blocked) return blocked; if (blocked) return blocked;
return handleCreateAuthRequest(request, env); return handleCreateAuthRequest(request, env);
} }
const authRequestResponseMatch = path.match(/^\/api\/auth-requests\/([a-f0-9-]+)\/response$/i); const authRequestResponseMatch = path.match(/^\/(?:api\/)?auth-requests\/([a-f0-9-]+)\/response$/i);
if (authRequestResponseMatch && method === 'GET') { if (authRequestResponseMatch && method === 'GET') {
const blocked = await enforcePublicRateLimit('public-sensitive', LIMITS.rateLimit.sensitivePublicRequestsPerMinute); const blocked = await enforcePublicRateLimit('public-sensitive', LIMITS.rateLimit.sensitivePublicRequestsPerMinute);
if (blocked) return blocked; if (blocked) return blocked;
@@ -465,9 +475,34 @@ export async function handlePublicRoute(
} }
if ((path === '/identity/accounts/recover-2fa' || path === '/api/accounts/recover-2fa') && method === 'POST') { if ((path === '/identity/accounts/recover-2fa' || path === '/api/accounts/recover-2fa') && method === 'POST') {
const blocked = await enforcePublicRateLimit('public-sensitive', LIMITS.rateLimit.sensitivePublicRequestsPerMinute);
if (blocked) return blocked;
return handleRecoverTwoFactor(request, env); return handleRecoverTwoFactor(request, env);
} }
const publicMailBackedPaths = new Set([
'/api/accounts/resend-new-device-otp',
'/accounts/resend-new-device-otp',
'/api/accounts/register/send-verification-email',
'/accounts/register/send-verification-email',
'/identity/accounts/register/send-verification-email',
'/api/accounts/register/verification-email-clicked',
'/accounts/register/verification-email-clicked',
'/identity/accounts/register/verification-email-clicked',
'/api/accounts/register/finish',
'/accounts/register/finish',
'/identity/accounts/register/finish',
'/api/accounts/verify-email-token',
'/accounts/verify-email-token',
'/api/two-factor/send-email-login',
'/two-factor/send-email-login',
]);
if (publicMailBackedPaths.has(path) && method === 'POST') {
const blocked = await enforcePublicRateLimit('public-sensitive', LIMITS.rateLimit.sensitivePublicRequestsPerMinute);
if (blocked) return blocked;
return unsupportedResponse('Email delivery is not supported by this server.');
}
if (path === '/api/accounts/password-hint' && method === 'POST') { if (path === '/api/accounts/password-hint' && method === 'POST') {
const blocked = await enforcePublicRateLimit('public-sensitive', LIMITS.rateLimit.sensitivePublicRequestsPerMinute); const blocked = await enforcePublicRateLimit('public-sensitive', LIMITS.rateLimit.sensitivePublicRequestsPerMinute);
if (blocked) return blocked; if (blocked) return blocked;
@@ -514,6 +549,8 @@ export async function handlePublicRoute(
} }
if (path === '/notifications/anonymous-hub' && method === 'GET') { if (path === '/notifications/anonymous-hub' && method === 'GET') {
const blocked = await enforcePublicRateLimit('public-sensitive', LIMITS.rateLimit.sensitivePublicRequestsPerMinute);
if (blocked) return blocked;
return handleAnonymousNotificationsHub(request, env); return handleAnonymousNotificationsHub(request, env);
} }
return null; return null;
+73 -10
View File
@@ -20,6 +20,7 @@ function canServeWithUnsafeJwtSecret(path: string, method: string): boolean {
if (method === 'GET' && path === '/.well-known/appspecific/com.chrome.devtools.json') return true; if (method === 'GET' && path === '/.well-known/appspecific/com.chrome.devtools.json') return true;
if (method === 'GET' && path === '/fill-assist/manifest.json') return true; if (method === 'GET' && path === '/fill-assist/manifest.json') return true;
if (method === 'GET' && /^\/fill-assist\/[^/]+$/i.test(path)) return true; if (method === 'GET' && /^\/fill-assist\/[^/]+$/i.test(path)) return true;
if (method === 'GET' && (path === '/v1/assetlinks:check' || path === '/api/v1/assetlinks:check')) return true;
if (method === 'GET' && /^\/icons\/[^/]+\/icon\.png$/i.test(path)) return true; if (method === 'GET' && /^\/icons\/[^/]+\/icon\.png$/i.test(path)) return true;
return false; return false;
} }
@@ -36,6 +37,70 @@ function isImportBypassRequest(request: Request, path: string, method: string):
return false; return false;
} }
const BODY_LIMIT_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
function isLargeUploadPath(path: string): boolean {
return (
/^\/api\/ciphers\/[a-f0-9-]+\/attachment\/[a-f0-9-]+$/i.test(path) ||
/^\/api\/sends\/[a-f0-9-]+\/file\/[a-f0-9-]+$/i.test(path) ||
path === '/api/admin/backup/import'
);
}
async function enforceRequestBodyLimit(
request: Request,
path: string,
method: string
): Promise<Request | Response> {
if (!BODY_LIMIT_METHODS.has(method) || isLargeUploadPath(path) || !request.body) {
return request;
}
const contentLengthRaw = request.headers.get('Content-Length');
if (contentLengthRaw) {
const contentLength = Number(contentLengthRaw);
if (Number.isFinite(contentLength) && contentLength > LIMITS.request.maxBodyBytes) {
return errorResponse('Request body too large', 413);
}
if (Number.isFinite(contentLength) && contentLength >= 0) {
return request;
}
}
const reader = request.body.getReader();
const chunks: Uint8Array[] = [];
let total = 0;
while (true) {
const { done, value } = await reader.read();
if (done) break;
if (!value) continue;
total += value.byteLength;
if (total > LIMITS.request.maxBodyBytes) {
try {
await reader.cancel();
} catch {
// Ignore cancellation races after the oversized body is rejected.
}
return errorResponse('Request body too large', 413);
}
chunks.push(value);
}
const body = new Uint8Array(total);
let offset = 0;
for (const chunk of chunks) {
body.set(chunk, offset);
offset += chunk.byteLength;
}
return new Request(request.url, {
method: request.method,
headers: request.headers,
body,
redirect: request.redirect,
});
}
export async function handleRequest(request: Request, env: Env): Promise<Response> { export async function handleRequest(request: Request, env: Env): Promise<Response> {
const url = new URL(request.url); const url = new URL(request.url);
const path = url.pathname; const path = url.pathname;
@@ -60,7 +125,10 @@ export async function handleRequest(request: Request, env: Env): Promise<Respons
} }
const rateLimit = new RateLimitService(env.DB); const rateLimit = new RateLimitService(env.DB);
const check = await rateLimit.consumeBudget(`${clientId}:${category}`, maxRequests); const shouldUseStrictBudget = category === 'public-sensitive' || category === 'register';
const check = shouldUseStrictBudget
? await rateLimit.consumeStrictBudget(`${clientId}:${category}`, maxRequests)
: await rateLimit.consumeBudget(`${clientId}:${category}`, maxRequests);
if (check.allowed) return null; if (check.allowed) return null;
return new Response( return new Response(
@@ -84,16 +152,11 @@ export async function handleRequest(request: Request, env: Env): Promise<Respons
} }
try { try {
const isLargeUploadPath = const bodyLimitResult = await enforceRequestBodyLimit(request, path, method);
/^\/api\/ciphers\/[a-f0-9-]+\/attachment\/[a-f0-9-]+$/i.test(path) || if (bodyLimitResult instanceof Response) {
/^\/api\/sends\/[a-f0-9-]+\/file\/[a-f0-9-]+$/i.test(path) || return bodyLimitResult;
path === '/api/admin/backup/import';
if (!isLargeUploadPath) {
const contentLength = parseInt(request.headers.get('Content-Length') || '0', 10);
if (contentLength > LIMITS.request.maxBodyBytes) {
return errorResponse('Request body too large', 413);
}
} }
request = bodyLimitResult;
const secretIssue = jwtSecretUnsafeReason(env); const secretIssue = jwtSecretUnsafeReason(env);
if (secretIssue && !canServeWithUnsafeJwtSecret(path, method)) { if (secretIssue && !canServeWithUnsafeJwtSecret(path, method)) {
+65 -5
View File
@@ -1,4 +1,4 @@
import { zipSync, unzipSync } from 'fflate'; import { zipSync, unzipSync, type UnzipFileInfo } from 'fflate';
import type { Env } from '../types'; import type { Env } from '../types';
import { APP_VERSION } from '../../shared/app-version'; import { APP_VERSION } from '../../shared/app-version';
import { BACKUP_SETTINGS_CONFIG_KEY } from './backup-config'; import { BACKUP_SETTINGS_CONFIG_KEY } from './backup-config';
@@ -28,10 +28,11 @@ const BACKUP_FILE_HASH_PREFIX_LENGTH = 5;
// Prefer store-only ZIP entries over heavier compression to keep exports reliable. // Prefer store-only ZIP entries over heavier compression to keep exports reliable.
const BACKUP_TEXT_COMPRESSION_LEVEL = 0; const BACKUP_TEXT_COMPRESSION_LEVEL = 0;
const BACKUP_JSON_INDENT = 2; const BACKUP_JSON_INDENT = 2;
const MAX_BACKUP_ARCHIVE_BYTES = 64 * 1024 * 1024; export const MAX_BACKUP_ARCHIVE_BYTES = 64 * 1024 * 1024;
const MAX_BACKUP_ARCHIVE_ENTRY_COUNT = 10_000; const MAX_BACKUP_ARCHIVE_ENTRY_COUNT = 10_000;
const MAX_BACKUP_EXTRACTED_BYTES = 64 * 1024 * 1024; const MAX_BACKUP_EXTRACTED_BYTES = 64 * 1024 * 1024;
const MAX_BACKUP_DB_JSON_BYTES = 32 * 1024 * 1024; const MAX_BACKUP_DB_JSON_BYTES = 32 * 1024 * 1024;
const MAX_BACKUP_PATH_SEGMENT_LENGTH = 128;
export interface BackupManifest { export interface BackupManifest {
formatVersion: 1; formatVersion: 1;
@@ -186,6 +187,61 @@ function validateArchiveSize(bytes: Uint8Array): void {
} }
} }
function isSafeBackupPathSegment(value: string): boolean {
if (!value || value.length > MAX_BACKUP_PATH_SEGMENT_LENGTH) return false;
if (value === '.' || value === '..') return false;
return /^[A-Za-z0-9._-]+$/.test(value);
}
export function isSafeBackupAttachmentBlobName(value: unknown): boolean {
const normalized = String(value ?? '').trim();
const parts = normalized.split('/');
return parts.length === 2 && parts.every(isSafeBackupPathSegment);
}
function isSafeBackupAttachmentEntryName(value: string): boolean {
if (!value.startsWith('attachments/') || !value.endsWith('.bin')) return false;
const relative = value.slice('attachments/'.length, -'.bin'.length);
return isSafeBackupAttachmentBlobName(relative);
}
function validateBackupEntryName(name: string): void {
const normalized = String(name || '').trim();
if (normalized !== name || !normalized) {
throw new Error('Backup archive contains an invalid file name');
}
if (normalized.includes('\\') || normalized.includes('\0') || normalized.startsWith('/') || normalized.includes('//')) {
throw new Error(`Backup archive contains an unsafe file name: ${normalized}`);
}
if (normalized !== 'manifest.json' && normalized !== 'db.json' && !isSafeBackupAttachmentEntryName(normalized)) {
throw new Error(`Backup archive contains an unsupported file: ${normalized}`);
}
}
function createBackupUnzipFilter(): (file: UnzipFileInfo) => boolean {
let entryCount = 0;
let totalOriginalBytes = 0;
return (file: UnzipFileInfo): boolean => {
entryCount += 1;
if (entryCount > MAX_BACKUP_ARCHIVE_ENTRY_COUNT) {
throw new Error('Backup archive contains too many files');
}
validateBackupEntryName(file.name);
const originalSize = Number(file.originalSize);
if (!Number.isFinite(originalSize) || originalSize < 0) {
throw new Error(`Backup archive contains an invalid file size: ${file.name}`);
}
if (file.name === 'db.json' && originalSize > MAX_BACKUP_DB_JSON_BYTES) {
throw new Error('Backup archive database payload is too large');
}
totalOriginalBytes += originalSize;
if (totalOriginalBytes > MAX_BACKUP_EXTRACTED_BYTES) {
throw new Error('Backup archive expands beyond the current restore limit');
}
return true;
};
}
function getRequiredZipEntries(db: BackupPayload['db']): string[] { function getRequiredZipEntries(db: BackupPayload['db']): string[] {
const entries: string[] = []; const entries: string[] = [];
for (const row of db.attachments) { for (const row of db.attachments) {
@@ -223,8 +279,11 @@ export function parseBackupArchive(
validateArchiveSize(bytes); validateArchiveSize(bytes);
let zipped: Record<string, Uint8Array>; let zipped: Record<string, Uint8Array>;
try { try {
zipped = unzipSync(bytes); zipped = unzipSync(bytes, { filter: createBackupUnzipFilter() });
} catch { } catch (error) {
if (error instanceof Error && error.message.startsWith('Backup archive ')) {
throw error;
}
throw new Error('Invalid backup archive'); throw new Error('Invalid backup archive');
} }
@@ -235,6 +294,7 @@ export function parseBackupArchive(
let totalExtractedBytes = 0; let totalExtractedBytes = 0;
for (const entry of entryNames) { for (const entry of entryNames) {
validateBackupEntryName(entry);
const entryBytes = zipped[entry]; const entryBytes = zipped[entry];
totalExtractedBytes += entryBytes.byteLength; totalExtractedBytes += entryBytes.byteLength;
if (entry === 'db.json' && entryBytes.byteLength > MAX_BACKUP_DB_JSON_BYTES) { if (entry === 'db.json' && entryBytes.byteLength > MAX_BACKUP_DB_JSON_BYTES) {
@@ -368,7 +428,7 @@ export function validateBackupPayloadContents(
for (const row of attachmentRows) { for (const row of attachmentRows) {
const id = String(row.id || '').trim(); const id = String(row.id || '').trim();
const cipherId = String(row.cipher_id || '').trim(); const cipherId = String(row.cipher_id || '').trim();
if (!id || !cipherId || !cipherIds.has(cipherId)) { if (!id || !cipherId || !isSafeBackupPathSegment(id) || !isSafeBackupPathSegment(cipherId) || !cipherIds.has(cipherId)) {
throw new Error('Backup archive contains an invalid attachment row'); throw new Error('Backup archive contains an invalid attachment row');
} }
const attachmentPath = `attachments/${cipherId}/${id}.bin`; const attachmentPath = `attachments/${cipherId}/${id}.bin`;
+57 -1
View File
@@ -28,6 +28,7 @@ import {
export const BACKUP_SETTINGS_CONFIG_KEY = 'backup.settings.v1'; export const BACKUP_SETTINGS_CONFIG_KEY = 'backup.settings.v1';
const BACKUP_RUNTIME_CONFIG_KEY = 'backup.runtime.v1'; const BACKUP_RUNTIME_CONFIG_KEY = 'backup.runtime.v1';
export const BACKUP_SCHEDULER_WINDOW_MINUTES = 5; export const BACKUP_SCHEDULER_WINDOW_MINUTES = 5;
export const REDACTED_BACKUP_SECRET = '********';
const MAX_BACKUP_DESTINATIONS = 24; const MAX_BACKUP_DESTINATIONS = 24;
export type { export type {
@@ -180,6 +181,32 @@ function normalizeDestination(
return normalizeWebDavDestination(destination, allowIncomplete); return normalizeWebDavDestination(destination, allowIncomplete);
} }
function shouldPreserveBackupSecret(value: unknown): boolean {
if (value === undefined || value === null) return true;
const raw = String(value);
return raw === '' || raw === REDACTED_BACKUP_SECRET;
}
function withPreservedDestinationSecret(
destinationType: BackupDestinationType,
inputDestination: unknown,
previous: BackupDestinationRecord | undefined
): unknown {
const source = isPlainObject(inputDestination) ? { ...inputDestination } : {};
if (destinationType === 's3') {
const previousDestination = previous?.type === 's3' ? previous.destination as S3BackupDestination : null;
if (shouldPreserveBackupSecret(source.secretAccessKey)) {
source.secretAccessKey = previousDestination?.secretAccessKey || '';
}
} else {
const previousDestination = previous?.type === 'webdav' ? previous.destination as WebDavBackupDestination : null;
if (shouldPreserveBackupSecret(source.password)) {
source.password = previousDestination?.password || '';
}
}
return source;
}
function normalizeRuntime(value: unknown): BackupRuntimeState { function normalizeRuntime(value: unknown): BackupRuntimeState {
const source = isPlainObject(value) ? value : {}; const source = isPlainObject(value) ? value : {};
const asIso = (input: unknown): string | null => { const asIso = (input: unknown): string | null => {
@@ -250,7 +277,11 @@ function normalizeDestinationRecord(
retentionCount: normalizeRetentionCount(retentionSource, previousSchedule.retentionCount), retentionCount: normalizeRetentionCount(retentionSource, previousSchedule.retentionCount),
}; };
const destination = normalizeDestination(type, input.destination, !schedule.enabled); const destination = normalizeDestination(
type,
withPreservedDestinationSecret(type, input.destination, previous),
!schedule.enabled
);
return { return {
id, id,
@@ -432,6 +463,31 @@ export function serializeBackupSettings(settings: BackupSettings): string {
return JSON.stringify(stripRuntimeFromSettings(settings)); return JSON.stringify(stripRuntimeFromSettings(settings));
} }
export function redactBackupSettingsSecrets(settings: BackupSettings): BackupSettings {
return {
destinations: settings.destinations.map((destination) => {
if (destination.type === 's3') {
const config = destination.destination as S3BackupDestination;
return {
...destination,
destination: {
...config,
secretAccessKey: config.secretAccessKey ? REDACTED_BACKUP_SECRET : '',
},
};
}
const config = destination.destination as WebDavBackupDestination;
return {
...destination,
destination: {
...config,
password: config.password ? REDACTED_BACKUP_SECRET : '',
},
};
}),
};
}
export async function loadBackupSettings(storage: StorageService, env: Env, fallbackTimezone: string = 'UTC'): Promise<BackupSettings> { export async function loadBackupSettings(storage: StorageService, env: Env, fallbackTimezone: string = 'UTC'): Promise<BackupSettings> {
const raw = await storage.getConfigValue(BACKUP_SETTINGS_CONFIG_KEY); const raw = await storage.getConfigValue(BACKUP_SETTINGS_CONFIG_KEY);
const mergeRuntime = async (settings: BackupSettings): Promise<BackupSettings> => ( const mergeRuntime = async (settings: BackupSettings): Promise<BackupSettings> => (
+15 -3
View File
@@ -4,6 +4,7 @@ import { BACKUP_SETTINGS_CONFIG_KEY, normalizeImportedBackupSettingsValue } from
import { import {
type BackupManifestAttachmentBlob, type BackupManifestAttachmentBlob,
type BackupPayload, type BackupPayload,
isSafeBackupAttachmentBlobName,
parseBackupArchive, parseBackupArchive,
validateBackupPayloadContents, validateBackupPayloadContents,
} from './backup-archive'; } from './backup-archive';
@@ -462,9 +463,20 @@ async function restoreBlobFiles(env: Env, db: BackupPayload['db'], files: Record
} }
function buildAttachmentBlobLookup(manifest: BackupPayload['manifest']): Map<string, BackupManifestAttachmentBlob> { function buildAttachmentBlobLookup(manifest: BackupPayload['manifest']): Map<string, BackupManifestAttachmentBlob> {
return new Map( const lookup = new Map<string, BackupManifestAttachmentBlob>();
(manifest.attachmentBlobs || []).map((item) => [`${item.cipherId}/${item.attachmentId}`, item]) for (const item of manifest.attachmentBlobs || []) {
); const cipherId = String(item.cipherId || '').trim();
const attachmentId = String(item.attachmentId || '').trim();
const blobName = String(item.blobName || '').trim();
if (!cipherId || !attachmentId || !isSafeBackupAttachmentBlobName(blobName)) continue;
lookup.set(`${cipherId}/${attachmentId}`, {
...item,
cipherId,
attachmentId,
blobName,
});
}
return lookup;
} }
async function prepareRemoteAttachmentPayload( async function prepareRemoteAttachmentPayload(
+4 -21
View File
@@ -62,27 +62,10 @@ export async function ensurePushInstallationCredentials(db: D1Database): Promise
method: 'POST', method: 'POST',
headers: { headers: {
accept: 'application/json', accept: 'application/json',
'accept-language': 'zh-CN,zh;q=0.9,en;q=0.8',
'cache-control': 'no-cache',
'content-type': 'application/json', 'content-type': 'application/json',
origin: 'https://bitwarden.com',
pragma: 'no-cache',
priority: 'u=1, i',
referer: 'https://bitwarden.com/host/',
'sec-ch-ua': '"Google Chrome";v="137", "Chromium";v="137", "Not/A)Brand";v="24"',
'sec-ch-ua-mobile': '?0',
'sec-ch-ua-platform': '"Windows"',
'sec-fetch-dest': 'empty',
'sec-fetch-mode': 'cors',
'sec-fetch-site': 'same-site',
'user-agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.0.0.0 Safari/537.36',
}, },
body: JSON.stringify({ body: JSON.stringify({
formName: 'request_host',
url: '/host/',
locale: 'zh-CN',
email: randomInstallationEmail(), email: randomInstallationEmail(),
region: 'us',
}), }),
}, },
'Failed to request Bitwarden push installation:' 'Failed to request Bitwarden push installation:'
@@ -94,9 +77,9 @@ export async function ensurePushInstallationCredentials(db: D1Database): Promise
return null; return null;
} }
const body = (await response.json().catch(() => null)) as { id?: string; key?: string; enabled?: boolean } | null; const body = (await response.json().catch(() => null)) as { id?: string; Id?: string; key?: string; Key?: string; enabled?: boolean; Enabled?: boolean } | null;
const id = String(body?.id || '').trim(); const id = String(body?.id || body?.Id || '').trim();
const key = String(body?.key || '').trim(); const key = String(body?.key || body?.Key || '').trim();
if (!id || !key) { if (!id || !key) {
console.error('Bitwarden push installation response did not include id/key'); console.error('Bitwarden push installation response did not include id/key');
return null; return null;
@@ -234,7 +217,7 @@ export async function registerMobilePushDevice(
export async function unregisterMobilePushDevice(env: Env, pushUuid: string | null | undefined): Promise<boolean> { export async function unregisterMobilePushDevice(env: Env, pushUuid: string | null | undefined): Promise<boolean> {
const normalized = String(pushUuid || '').trim(); const normalized = String(pushUuid || '').trim();
if (!normalized) return false; if (!normalized) return false;
return postToPushRelay(env, `/push/delete/${encodeURIComponent(normalized)}`); return postToPushRelay(env, '/push/delete', { id: normalized });
} }
export async function notifyMobilePush( export async function notifyMobilePush(
+86
View File
@@ -3,6 +3,7 @@ import { LIMITS } from '../config/limits';
// Rate limiting service. // Rate limiting service.
// - Login attempts: D1-backed (low volume, security-critical, needs cross-colo persistence). // - Login attempts: D1-backed (low volume, security-critical, needs cross-colo persistence).
// - API budgets: Cloudflare Cache API (high volume, auto-expires, zero D1 writes). // - API budgets: Cloudflare Cache API (high volume, auto-expires, zero D1 writes).
// - Strict budgets: D1-backed fixed windows for low-volume anonymous sensitive endpoints.
const CONFIG = { const CONFIG = {
LOGIN_MAX_ATTEMPTS: LIMITS.rateLimit.loginMaxAttempts, LOGIN_MAX_ATTEMPTS: LIMITS.rateLimit.loginMaxAttempts,
@@ -12,11 +13,14 @@ const CONFIG = {
export class RateLimitService { export class RateLimitService {
private static loginIpTableReady = false; private static loginIpTableReady = false;
private static strictBudgetTableReady = false;
private static lastLoginIpCleanupAt = 0; private static lastLoginIpCleanupAt = 0;
private static lastStrictBudgetCleanupAt = 0;
private static readonly PERIODIC_CLEANUP_PROBABILITY = LIMITS.rateLimit.cleanupProbability; private static readonly PERIODIC_CLEANUP_PROBABILITY = LIMITS.rateLimit.cleanupProbability;
private static readonly LOGIN_IP_CLEANUP_INTERVAL_MS = LIMITS.rateLimit.loginIpCleanupIntervalMs; private static readonly LOGIN_IP_CLEANUP_INTERVAL_MS = LIMITS.rateLimit.loginIpCleanupIntervalMs;
private static readonly LOGIN_IP_RETENTION_MS = LIMITS.rateLimit.loginIpRetentionMs; private static readonly LOGIN_IP_RETENTION_MS = LIMITS.rateLimit.loginIpRetentionMs;
private static readonly STRICT_BUDGET_CLEANUP_INTERVAL_MS = LIMITS.rateLimit.loginIpCleanupIntervalMs;
constructor(private db: D1Database) {} constructor(private db: D1Database) {}
@@ -58,6 +62,35 @@ export class RateLimitService {
RateLimitService.loginIpTableReady = true; RateLimitService.loginIpTableReady = true;
} }
private async ensureStrictBudgetTable(): Promise<void> {
if (RateLimitService.strictBudgetTableReady) return;
await this.db
.prepare(
'CREATE TABLE IF NOT EXISTS rate_limit_buckets (' +
'bucket_key TEXT PRIMARY KEY, ' +
'count INTEGER NOT NULL, ' +
'expires_at INTEGER NOT NULL, ' +
'updated_at INTEGER NOT NULL' +
')'
)
.run();
await this.db
.prepare('CREATE INDEX IF NOT EXISTS idx_rate_limit_buckets_expires ON rate_limit_buckets(expires_at)')
.run();
RateLimitService.strictBudgetTableReady = true;
}
private async maybeCleanupStrictBudgets(nowMs: number): Promise<void> {
if (!this.shouldRunCleanup(RateLimitService.lastStrictBudgetCleanupAt, RateLimitService.STRICT_BUDGET_CLEANUP_INTERVAL_MS)) {
return;
}
await this.db.prepare('DELETE FROM rate_limit_buckets WHERE expires_at < ?').bind(nowMs).run();
RateLimitService.lastStrictBudgetCleanupAt = nowMs;
}
async checkLoginAttempt(ip: string): Promise<{ async checkLoginAttempt(ip: string): Promise<{
allowed: boolean; allowed: boolean;
remainingAttempts: number; remainingAttempts: number;
@@ -174,6 +207,59 @@ export class RateLimitService {
return { allowed: true, remaining: Math.max(0, maxRequests - count) }; return { allowed: true, remaining: Math.max(0, maxRequests - count) };
} }
async consumeStrictBudget(
identifier: string,
maxRequests: number
): Promise<{ allowed: boolean; remaining: number; retryAfterSeconds?: number }> {
return this.consumeStrictBudgetWithWindow(identifier, maxRequests, CONFIG.API_WINDOW_SECONDS);
}
async consumeStrictBudgetWithWindow(
identifier: string,
maxRequests: number,
windowSeconds: number
): Promise<{ allowed: boolean; remaining: number; retryAfterSeconds?: number }> {
await this.ensureStrictBudgetTable();
const key = String(identifier || '').trim() || 'unknown';
const max = Math.max(1, Math.floor(maxRequests));
const windowSize = Math.max(1, Math.floor(windowSeconds));
const nowMs = Date.now();
const nowSec = Math.floor(nowMs / 1000);
const windowStart = nowSec - (nowSec % windowSize);
const windowEndMs = (windowStart + windowSize) * 1000;
const retryAfterSeconds = Math.max(1, Math.ceil((windowEndMs - nowMs) / 1000));
const bucketKey = `${key}:${windowStart}`;
await this.maybeCleanupStrictBudgets(nowMs);
await this.db
.prepare(
'INSERT OR IGNORE INTO rate_limit_buckets(bucket_key, count, expires_at, updated_at) VALUES(?, 0, ?, ?)'
)
.bind(bucketKey, windowEndMs, nowMs)
.run();
const update = await this.db
.prepare(
'UPDATE rate_limit_buckets SET count = count + 1, expires_at = ?, updated_at = ? ' +
'WHERE bucket_key = ? AND count < ?'
)
.bind(windowEndMs, nowMs, bucketKey, max)
.run();
const allowed = Number(update.meta?.changes ?? 0) > 0;
const row = await this.db
.prepare('SELECT count FROM rate_limit_buckets WHERE bucket_key = ?')
.bind(bucketKey)
.first<{ count: number }>();
const count = Math.max(0, Number(row?.count || 0));
if (!allowed) {
return { allowed: false, remaining: 0, retryAfterSeconds };
}
return { allowed: true, remaining: Math.max(0, max - count) };
}
// General-purpose fixed-window budget. // General-purpose fixed-window budget.
// Callers supply an identifier (must be unique per rate-limit category) and the // Callers supply an identifier (must be unique per rate-limit category) and the
// per-window maximum. This single method replaces all previous specialised // per-window maximum. This single method replaces all previous specialised
+1 -1
View File
@@ -268,7 +268,7 @@ export async function updateAccountPasskeyEncryption(
const result = await db const result = await db
.prepare( .prepare(
'UPDATE webauthn_credentials SET encrypted_user_key = ?, encrypted_public_key = ?, encrypted_private_key = ?, supports_prf = 1, updated_at = ? ' + 'UPDATE webauthn_credentials SET encrypted_user_key = ?, encrypted_public_key = ?, encrypted_private_key = ?, supports_prf = 1, updated_at = ? ' +
'WHERE user_id = ? AND credential_id = ?' "WHERE user_id = ? AND credential_id = ? AND purpose = 'login'"
) )
.bind(encryptedUserKey, encryptedPublicKey, encryptedPrivateKey, updatedAt, userId, credentialId) .bind(encryptedUserKey, encryptedPublicKey, encryptedPrivateKey, updatedAt, userId, credentialId)
.run(); .run();
+14
View File
@@ -97,6 +97,20 @@ export async function touchDeviceLastSeen(
return Number(result.meta.changes ?? 0) > 0; return Number(result.meta.changes ?? 0) > 0;
} }
export async function rotateDeviceSessionStamp(
db: D1Database,
userId: string,
deviceIdentifier: string,
sessionStamp: string
): Promise<boolean> {
const now = new Date().toISOString();
const result = await db
.prepare('UPDATE devices SET session_stamp = ?, updated_at = ? WHERE user_id = ? AND device_identifier = ?')
.bind(sessionStamp, now, userId, deviceIdentifier)
.run();
return Number(result.meta.changes ?? 0) > 0;
}
export async function updateDeviceKeys( export async function updateDeviceKeys(
db: D1Database, db: D1Database,
userId: string, userId: string,
+6 -2
View File
@@ -93,9 +93,13 @@ export async function incrementSendAccessCount(db: D1Database, sendId: string):
const result = await db const result = await db
.prepare( .prepare(
'UPDATE sends SET access_count = access_count + 1, updated_at = ? ' + 'UPDATE sends SET access_count = access_count + 1, updated_at = ? ' +
'WHERE id = ? AND (max_access_count IS NULL OR access_count < max_access_count)' 'WHERE id = ? ' +
'AND disabled = 0 ' +
'AND (max_access_count IS NULL OR access_count < max_access_count) ' +
'AND (expiration_date IS NULL OR expiration_date > ?) ' +
'AND deletion_date > ?'
) )
.bind(now, sendId) .bind(now, sendId, now, now)
.run(); .run();
return (result.meta.changes ?? 0) > 0; return (result.meta.changes ?? 0) > 0;
} }
+5
View File
@@ -109,6 +109,7 @@ import {
isKnownDevice as getKnownStoredDevice, isKnownDevice as getKnownStoredDevice,
isKnownDeviceByEmail as getKnownStoredDeviceByEmail, isKnownDeviceByEmail as getKnownStoredDeviceByEmail,
saveTrustedTwoFactorDeviceToken as saveStoredTrustedDeviceToken, saveTrustedTwoFactorDeviceToken as saveStoredTrustedDeviceToken,
rotateDeviceSessionStamp as rotateStoredDeviceSessionStamp,
touchDeviceLastSeen as touchStoredDeviceLastSeen, touchDeviceLastSeen as touchStoredDeviceLastSeen,
upsertDevice as saveStoredDevice, upsertDevice as saveStoredDevice,
updateDeviceName as updateStoredDeviceName, updateDeviceName as updateStoredDeviceName,
@@ -761,6 +762,10 @@ export class StorageService {
return findStoredDevice(this.db, userId, deviceIdentifier); return findStoredDevice(this.db, userId, deviceIdentifier);
} }
async rotateDeviceSessionStamp(userId: string, deviceIdentifier: string, sessionStamp: string): Promise<boolean> {
return rotateStoredDeviceSessionStamp(this.db, userId, deviceIdentifier, sessionStamp);
}
async updateDeviceKeys( async updateDeviceKeys(
userId: string, userId: string,
deviceIdentifier: string, deviceIdentifier: string,
+56
View File
@@ -124,6 +124,10 @@ export enum CipherType {
SecureNote = 2, SecureNote = 2,
Card = 3, Card = 3,
Identity = 4, Identity = 4,
SSHKey = 5,
BankAccount = 6,
DriversLicense = 7,
Passport = 8,
} }
export interface CipherLoginUri { export interface CipherLoginUri {
@@ -158,6 +162,52 @@ export interface CipherSshKey {
keyFingerprint: string; keyFingerprint: string;
} }
export interface CipherBankAccount {
bankName: string | null;
nameOnAccount: string | null;
accountType: string | null;
accountNumber: string | null;
routingNumber: string | null;
branchNumber: string | null;
pin: string | null;
swiftCode: string | null;
iban: string | null;
bankContactPhone: string | null;
[key: string]: any;
}
export interface CipherDriversLicense {
firstName: string | null;
middleName: string | null;
lastName: string | null;
dateOfBirth: string | null;
licenseNumber: string | null;
issuingCountry: string | null;
issuingState: string | null;
issueDate: string | null;
expirationDate: string | null;
issuingAuthority: string | null;
licenseClass: string | null;
[key: string]: any;
}
export interface CipherPassport {
surname: string | null;
givenName: string | null;
dateOfBirth: string | null;
sex: string | null;
birthPlace: string | null;
nationality: string | null;
issuingCountry: string | null;
passportNumber: string | null;
passportType: string | null;
nationalIdentificationNumber: string | null;
issuingAuthority: string | null;
issueDate: string | null;
expirationDate: string | null;
[key: string]: any;
}
export interface CipherIdentity { export interface CipherIdentity {
title: string | null; title: string | null;
firstName: string | null; firstName: string | null;
@@ -208,6 +258,9 @@ export interface Cipher {
identity: CipherIdentity | null; identity: CipherIdentity | null;
secureNote: CipherSecureNote | null; secureNote: CipherSecureNote | null;
sshKey: CipherSshKey | null; sshKey: CipherSshKey | null;
bankAccount?: CipherBankAccount | null;
driversLicense?: CipherDriversLicense | null;
passport?: CipherPassport | null;
fields: CipherField[] | null; fields: CipherField[] | null;
passwordHistory: PasswordHistory[] | null; passwordHistory: PasswordHistory[] | null;
reprompt: number; reprompt: number;
@@ -547,6 +600,9 @@ export interface CipherResponse {
identity: CipherIdentity | null; identity: CipherIdentity | null;
secureNote: CipherSecureNote | null; secureNote: CipherSecureNote | null;
sshKey: CipherSshKey | null; sshKey: CipherSshKey | null;
bankAccount: CipherBankAccount | null;
driversLicense: CipherDriversLicense | null;
passport: CipherPassport | null;
fields: CipherField[] | null; fields: CipherField[] | null;
passwordHistory: PasswordHistory[] | null; passwordHistory: PasswordHistory[] | null;
reprompt: number; reprompt: number;
+55 -14
View File
@@ -32,6 +32,44 @@ function textBytes(value: string): Uint8Array {
return new TextEncoder().encode(value); return new TextEncoder().encode(value);
} }
function hexByte(value: number): string {
return value.toString(16).padStart(2, '0');
}
function dotNetGuidBytesToUuid(bytes: Uint8Array): string | null {
if (bytes.length !== 16) return null;
return [
[bytes[3], bytes[2], bytes[1], bytes[0]].map(hexByte).join(''),
[bytes[5], bytes[4]].map(hexByte).join(''),
[bytes[7], bytes[6]].map(hexByte).join(''),
[bytes[8], bytes[9]].map(hexByte).join(''),
Array.from(bytes.slice(10, 16)).map(hexByte).join(''),
].join('-');
}
function uuidToDotNetGuidBytes(value: string): Uint8Array | null {
const match = String(value || '').trim().match(
/^([0-9a-f]{8})-([0-9a-f]{4})-([0-9a-f]{4})-([0-9a-f]{4})-([0-9a-f]{12})$/i
);
if (!match) return null;
const hex = match.slice(1).join('');
const bytes = new Uint8Array(16);
for (let i = 0; i < 16; i += 1) {
bytes[i] = Number.parseInt(hex.slice(i * 2, i * 2 + 2), 16);
}
return new Uint8Array([
bytes[3], bytes[2], bytes[1], bytes[0],
bytes[5], bytes[4],
bytes[7], bytes[6],
bytes[8], bytes[9],
bytes[10], bytes[11], bytes[12], bytes[13], bytes[14], bytes[15],
]);
}
function normalizeWebAuthnBase64(value: unknown): string {
return String(value || '').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
}
async function importHmacKey(secret: string): Promise<CryptoKey> { async function importHmacKey(secret: string): Promise<CryptoKey> {
return crypto.subtle.importKey('raw', textBytes(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign', 'verify']); return crypto.subtle.importKey('raw', textBytes(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign', 'verify']);
} }
@@ -141,13 +179,16 @@ export function getAccountPasskeyRpConfig(request: Request, env: Env): { rpId: s
} }
export function userIdToWebAuthnUserId(userId: string): Uint8Array { export function userIdToWebAuthnUserId(userId: string): Uint8Array {
return textBytes(userId); return uuidToDotNetGuidBytes(userId) || textBytes(userId);
} }
export function userHandleToUserId(userHandle: string | undefined): string | null { export function userHandleToUserId(userHandle: string | undefined): string | null {
if (!userHandle) return null; if (!userHandle) return null;
try { try {
const decoded = new TextDecoder().decode(base64UrlToBytes(userHandle)); const bytes = base64UrlToBytes(userHandle);
const officialGuid = dotNetGuidBytesToUuid(bytes);
if (officialGuid) return officialGuid;
const decoded = new TextDecoder().decode(bytes);
return decoded.trim() || null; return decoded.trim() || null;
} catch { } catch {
return null; return null;
@@ -209,17 +250,17 @@ export function normalizeRegistrationResponse(raw: unknown): RegistrationRespons
const clientDataJSON = response.clientDataJSON || response.clientDataJson; const clientDataJSON = response.clientDataJSON || response.clientDataJson;
if (!input.id || !input.rawId || !clientDataJSON || !response.attestationObject) return null; if (!input.id || !input.rawId || !clientDataJSON || !response.attestationObject) return null;
return { return {
id: String(input.id), id: normalizeWebAuthnBase64(input.id),
rawId: String(input.rawId), rawId: normalizeWebAuthnBase64(input.rawId),
type: 'public-key', type: 'public-key',
authenticatorAttachment: input.authenticatorAttachment, authenticatorAttachment: input.authenticatorAttachment,
clientExtensionResults: input.clientExtensionResults || input.extensions || {}, clientExtensionResults: input.clientExtensionResults || input.extensions || {},
response: { response: {
attestationObject: String(response.attestationObject), attestationObject: normalizeWebAuthnBase64(response.attestationObject),
clientDataJSON: String(clientDataJSON), clientDataJSON: normalizeWebAuthnBase64(clientDataJSON),
authenticatorData: response.authenticatorData ? String(response.authenticatorData) : undefined, authenticatorData: response.authenticatorData ? normalizeWebAuthnBase64(response.authenticatorData) : undefined,
transports: Array.isArray(response.transports) ? response.transports.map(String) as AuthenticatorTransportFuture[] : undefined, transports: Array.isArray(response.transports) ? response.transports.map(String) as AuthenticatorTransportFuture[] : undefined,
publicKey: response.publicKey ? String(response.publicKey) : undefined, publicKey: response.publicKey ? normalizeWebAuthnBase64(response.publicKey) : undefined,
publicKeyAlgorithm: typeof response.publicKeyAlgorithm === 'number' ? response.publicKeyAlgorithm : undefined, publicKeyAlgorithm: typeof response.publicKeyAlgorithm === 'number' ? response.publicKeyAlgorithm : undefined,
}, },
}; };
@@ -232,16 +273,16 @@ export function normalizeAuthenticationResponse(raw: unknown): AuthenticationRes
const clientDataJSON = response.clientDataJSON || response.clientDataJson; const clientDataJSON = response.clientDataJSON || response.clientDataJson;
if (!input.id || !input.rawId || !clientDataJSON || !response.authenticatorData || !response.signature) return null; if (!input.id || !input.rawId || !clientDataJSON || !response.authenticatorData || !response.signature) return null;
return { return {
id: String(input.id), id: normalizeWebAuthnBase64(input.id),
rawId: String(input.rawId), rawId: normalizeWebAuthnBase64(input.rawId),
type: 'public-key', type: 'public-key',
authenticatorAttachment: input.authenticatorAttachment, authenticatorAttachment: input.authenticatorAttachment,
clientExtensionResults: input.clientExtensionResults || input.extensions || {}, clientExtensionResults: input.clientExtensionResults || input.extensions || {},
response: { response: {
authenticatorData: String(response.authenticatorData), authenticatorData: normalizeWebAuthnBase64(response.authenticatorData),
clientDataJSON: String(clientDataJSON), clientDataJSON: normalizeWebAuthnBase64(clientDataJSON),
signature: String(response.signature), signature: normalizeWebAuthnBase64(response.signature),
userHandle: response.userHandle ? String(response.userHandle) : undefined, userHandle: response.userHandle ? normalizeWebAuthnBase64(response.userHandle) : undefined,
}, },
}; };
} }
+10
View File
@@ -19,6 +19,8 @@ interface ParseDirectUploadOptions {
fileNameMismatchMessage?: string; fileNameMismatchMessage?: string;
} }
const MULTIPART_FORMDATA_OVERHEAD_BYTES = 256 * 1024;
export function buildDirectUploadUrl(request: Request, path: string, token: string): string { export function buildDirectUploadUrl(request: Request, path: string, token: string): string {
const version = '2023-11-03'; const version = '2023-11-03';
const expiresAt = '2099-12-31T23:59:59Z'; const expiresAt = '2099-12-31T23:59:59Z';
@@ -34,6 +36,10 @@ export function getSafeJwtSecret(env: Env): string | null {
return secret; return secret;
} }
export function getMultipartRequestMaxBytes(maxFileSize: number): number {
return maxFileSize + MULTIPART_FORMDATA_OVERHEAD_BYTES;
}
function parseContentLength(request: Request): number | null { function parseContentLength(request: Request): number | null {
const raw = request.headers.get('content-length'); const raw = request.headers.get('content-length');
if (!raw) return null; if (!raw) return null;
@@ -59,6 +65,10 @@ export async function parseDirectUploadPayload(
const contentType = request.headers.get('content-type') || ''; const contentType = request.headers.get('content-type') || '';
if (contentType.includes('multipart/form-data')) { if (contentType.includes('multipart/form-data')) {
const declaredSize = parseContentLength(request);
if (declaredSize !== null && declaredSize > getMultipartRequestMaxBytes(maxFileSize)) {
return errorResponse(tooLargeMessage, 413);
}
const formData = await request.formData(); const formData = await request.formData();
const file = formData.get('data') as File | null; const file = formData.get('data') as File | null;
if (!file) { if (!file) {
+7
View File
@@ -29,6 +29,9 @@ function isExtensionOrigin(origin: string): boolean {
function isWildcardCorsPath(path: string): boolean { function isWildcardCorsPath(path: string): boolean {
return ( return (
path.startsWith('/icons/') path.startsWith('/icons/')
|| path.startsWith('/fill-assist/')
|| path === '/v1/assetlinks:check'
|| path === '/api/v1/assetlinks:check'
|| path === '/config' || path === '/config'
|| path === '/api/config' || path === '/api/config'
|| path === '/api/version' || path === '/api/version'
@@ -136,6 +139,10 @@ export function errorResponse(message: string, status: number = 400): Response {
); );
} }
export function unsupportedResponse(message: string = 'This feature is not supported by this server.'): Response {
return errorResponse(message, 501);
}
// Identity endpoint error response (for /identity/connect/token) // Identity endpoint error response (for /identity/connect/token)
export function identityErrorResponse(message: string, error: string = 'invalid_grant', status: number = 400): Response { export function identityErrorResponse(message: string, error: string = 'invalid_grant', status: number = 400): Response {
return jsonResponse( return jsonResponse(
+50 -6
View File
@@ -227,6 +227,50 @@
return out; return out;
} }
function trustedParentOrigin() {
var parent = decodeRepeated(params.get("parent"));
if (!parent) return "";
try {
var parentUrl = new URL(parent);
if (
parentUrl.protocol === "chrome-extension:" ||
parentUrl.protocol === "moz-extension:" ||
parentUrl.protocol === "safari-web-extension:"
) {
return parentUrl.protocol + "//" + parentUrl.host;
}
if (parentUrl.origin === window.location.origin) {
return parentUrl.origin;
}
} catch (_error) {
return "";
}
return "";
}
function safeShallowCopy(source) {
var copy = {};
if (!source || typeof source !== "object") return copy;
Object.keys(source).forEach(function (key) {
if (key === "__proto__" || key === "prototype" || key === "constructor") return;
copy[key] = source[key];
});
return copy;
}
function postResult(message) {
var parentOrigin = trustedParentOrigin();
if (parentOrigin) {
if (window.opener && !window.opener.closed) {
window.opener.postMessage(message, parentOrigin);
}
if (window.parent && window.parent !== window) {
window.parent.postMessage(message, parentOrigin);
}
}
window.postMessage(message, window.location.origin);
}
function showMessage(kind, message) { function showMessage(kind, message) {
msgEl.textContent = String(message || ""); msgEl.textContent = String(message || "");
msgEl.className = "msg show " + kind; msgEl.className = "msg show " + kind;
@@ -279,13 +323,13 @@
function normalizeOptions(options) { function normalizeOptions(options) {
if (!options || typeof options !== "object") throw new Error("Cannot parse data."); if (!options || typeof options !== "object") throw new Error("Cannot parse data.");
var copy = Object.assign({}, options); var copy = safeShallowCopy(options);
copy.challenge = bytesFromBase64Url(copy.challenge); copy.challenge = bytesFromBase64Url(copy.challenge);
if (Array.isArray(copy.allowCredentials)) { if (Array.isArray(copy.allowCredentials)) {
copy.allowCredentials = copy.allowCredentials.map(function (credential) { copy.allowCredentials = copy.allowCredentials.map(function (credential) {
return Object.assign({}, credential, { var next = safeShallowCopy(credential);
id: bytesFromBase64Url(credential.id), next.id = bytesFromBase64Url(credential && credential.id);
}); return next;
}); });
} }
return copy; return copy;
@@ -327,11 +371,11 @@
if (!(credential instanceof PublicKeyCredential)) { if (!(credential instanceof PublicKeyCredential)) {
throw new Error("No security key was selected."); throw new Error("No security key was selected.");
} }
window.postMessage({ postResult({
command: "webAuthnResult", command: "webAuthnResult",
data: credentialToDataString(credential), data: credentialToDataString(credential),
remember: rememberEl.checked, remember: rememberEl.checked,
}, "*"); });
sentSuccess = true; sentSuccess = true;
showMessage("success", text.success); showMessage("success", text.success);
} catch (error) { } catch (error) {
+12 -8
View File
@@ -228,6 +228,7 @@ export default function App() {
hint: null, hint: null,
}); });
const [inviteCodeFromUrl, setInviteCodeFromUrl] = useState(initialInviteCode); const [inviteCodeFromUrl, setInviteCodeFromUrl] = useState(initialInviteCode);
const [hashPathRaw, setHashPathRaw] = useState(() => (typeof window !== 'undefined' ? window.location.hash || '' : ''));
const [unlockPassword, setUnlockPassword] = useState(''); const [unlockPassword, setUnlockPassword] = useState('');
const [pendingTotp, setPendingTotp] = useState<PendingTotp | null>(null); const [pendingTotp, setPendingTotp] = useState<PendingTotp | null>(null);
const [pendingTotpMode, setPendingTotpMode] = useState<'login' | 'unlock' | null>(null); const [pendingTotpMode, setPendingTotpMode] = useState<'login' | 'unlock' | null>(null);
@@ -295,15 +296,16 @@ export default function App() {
}, [pushToast]); }, [pushToast]);
useEffect(() => { useEffect(() => {
const syncInviteFromUrl = () => { const syncUrlState = () => {
setInviteCodeFromUrl(readInviteCodeFromUrl()); setInviteCodeFromUrl(readInviteCodeFromUrl());
setHashPathRaw(window.location.hash || '');
}; };
syncInviteFromUrl(); syncUrlState();
window.addEventListener('hashchange', syncInviteFromUrl); window.addEventListener('hashchange', syncUrlState);
window.addEventListener('popstate', syncInviteFromUrl); window.addEventListener('popstate', syncUrlState);
return () => { return () => {
window.removeEventListener('hashchange', syncInviteFromUrl); window.removeEventListener('hashchange', syncUrlState);
window.removeEventListener('popstate', syncInviteFromUrl); window.removeEventListener('popstate', syncUrlState);
}; };
}, []); }, []);
@@ -1862,7 +1864,6 @@ export default function App() {
await pendingAuthRequestsQuery.refetch(); await pendingAuthRequestsQuery.refetch();
}; };
const hashPathRaw = typeof window !== 'undefined' ? window.location.hash || '' : '';
const hashPath = hashPathRaw.startsWith('#') ? hashPathRaw.slice(1) : hashPathRaw; const hashPath = hashPathRaw.startsWith('#') ? hashPathRaw.slice(1) : hashPathRaw;
const hashPathOnly = String(hashPath || '').split('?')[0].split('#')[0]; const hashPathOnly = String(hashPath || '').split('?')[0].split('#')[0];
const trimmedHashPath = hashPathOnly.replace(/^\/+/, '').replace(/\/+$/, ''); const trimmedHashPath = hashPathOnly.replace(/^\/+/, '').replace(/\/+$/, '');
@@ -2121,7 +2122,10 @@ export default function App() {
return backupActions.downloadRemoteBackup(hash, destinationId, path, onProgress); return backupActions.downloadRemoteBackup(hash, destinationId, path, onProgress);
}, },
onInspectRemoteBackup: backupActions.inspectRemoteBackup, onInspectRemoteBackup: backupActions.inspectRemoteBackup,
onDeleteRemoteBackup: backupActions.deleteRemoteBackup, onDeleteRemoteBackup: async (masterPassword: string, destinationId: string, path: string) => {
const hash = await deriveCurrentMasterPasswordHash(masterPassword);
return backupActions.deleteRemoteBackup(hash, destinationId, path);
},
onRestoreRemoteBackup: async (masterPassword: string, destinationId: string, path: string, replaceExisting?: boolean) => { onRestoreRemoteBackup: async (masterPassword: string, destinationId: string, path: string, replaceExisting?: boolean) => {
const hash = await deriveCurrentMasterPasswordHash(masterPassword); const hash = await deriveCurrentMasterPasswordHash(masterPassword);
return backupActions.restoreRemoteBackup(hash, destinationId, path, replaceExisting); return backupActions.restoreRemoteBackup(hash, destinationId, path, replaceExisting);
@@ -1,4 +1,4 @@
import { ArrowUpDown, Check, ChevronDown, Clock3, Cloud, FileClock, Folder as FolderIcon, Globe2, KeyRound, Lock, LogOut, MonitorSmartphone, Send as SendIcon, Settings as SettingsIcon, ShieldUser, SlidersHorizontal, Users } from 'lucide-preact'; import { ArrowUpDown, Check, ChevronDown, Clock3, Cloud, FileClock, Folder as FolderIcon, KeyRound, Lock, LogOut, MonitorSmartphone, Send as SendIcon, Settings as SettingsIcon, ShieldUser, SlidersHorizontal, Users } from 'lucide-preact';
import type { ComponentChildren } from 'preact'; import type { ComponentChildren } from 'preact';
import { useEffect, useRef, useState } from 'preact/hooks'; import { useEffect, useRef, useState } from 'preact/hooks';
import { Link } from 'wouter'; import { Link } from 'wouter';
@@ -56,10 +56,11 @@ export default function AppAuthenticatedShell(props: AppAuthenticatedShellProps)
const isLogRoute = props.location === '/logs'; const isLogRoute = props.location === '/logs';
const isAdmin = isAdminProfile(props.profile); const isAdmin = isAdminProfile(props.profile);
const vaultActive = props.location === '/vault' || props.location === '/vault/totp'; const vaultActive = props.location === '/vault' || props.location === '/vault/totp';
const settingsActive = props.location === props.settingsAccountRoute || props.location === '/settings/domain-rules';
const dataActive = props.location === '/backup' || props.isImportRoute;
const deviceManagementActive = props.location === DEVICE_MANAGEMENT_ROUTE || props.location === LEGACY_DEVICE_MANAGEMENT_ROUTE; const deviceManagementActive = props.location === DEVICE_MANAGEMENT_ROUTE || props.location === LEGACY_DEVICE_MANAGEMENT_ROUTE;
const managementActive = props.location === '/admin' || deviceManagementActive || props.location === '/logs'; const settingsActive = props.location === '/settings' || props.location === props.settingsAccountRoute || props.location === '/settings/domain-rules' || deviceManagementActive;
const flatSettingsActive = settingsActive && !deviceManagementActive;
const dataActive = props.location === '/backup' || props.isImportRoute;
const managementActive = props.location === '/admin' || props.location === '/logs';
const [navLayoutMode, setNavLayoutMode] = useState<NavLayoutMode>(readNavLayoutMode); const [navLayoutMode, setNavLayoutMode] = useState<NavLayoutMode>(readNavLayoutMode);
const [navLayoutPickerOpen, setNavLayoutPickerOpen] = useState(false); const [navLayoutPickerOpen, setNavLayoutPickerOpen] = useState(false);
const navLayoutPickerRef = useRef<HTMLDivElement | null>(null); const navLayoutPickerRef = useRef<HTMLDivElement | null>(null);
@@ -175,13 +176,12 @@ export default function AppAuthenticatedShell(props: AppAuthenticatedShellProps)
{renderSideLink('/vault', props.location === '/vault', <KeyRound size={16} />, t('nav_vault_items'))} {renderSideLink('/vault', props.location === '/vault', <KeyRound size={16} />, t('nav_vault_items'))}
{renderSideLink('/vault/totp', props.location === '/vault/totp', <Clock3 size={16} />, t('txt_verification_code'))} {renderSideLink('/vault/totp', props.location === '/vault/totp', <Clock3 size={16} />, t('txt_verification_code'))}
{renderSideLink('/sends', props.location === '/sends', <SendIcon size={16} />, t('nav_sends'))} {renderSideLink('/sends', props.location === '/sends', <SendIcon size={16} />, t('nav_sends'))}
{renderSideLink(props.settingsAccountRoute, props.location === props.settingsAccountRoute, <SettingsIcon size={16} />, t('nav_account_settings'))} {renderSideLink('/settings', flatSettingsActive, <SettingsIcon size={16} />, t('txt_settings'))}
{renderSideLink('/settings/domain-rules', props.location === '/settings/domain-rules', <Globe2 size={16} />, t('nav_domain_rules'))} {renderSideLink(DEVICE_MANAGEMENT_ROUTE, deviceManagementActive, <MonitorSmartphone size={16} />, t('nav_device_management'))}
{isAdmin && renderSideLink('/backup', props.location === '/backup', <Cloud size={16} />, t('nav_backup_strategy'))} {isAdmin && renderSideLink('/backup', props.location === '/backup', <Cloud size={16} />, t('nav_backup_strategy'))}
{renderSideLink(props.importRoute, props.isImportRoute, <ArrowUpDown size={16} />, t('nav_import_export'))} {renderSideLink(props.importRoute, props.isImportRoute, <ArrowUpDown size={16} />, t('nav_import_export'))}
{isAdmin && renderSideLink('/admin', props.location === '/admin', <Users size={16} />, t('nav_admin_panel'))} {isAdmin && renderSideLink('/admin', props.location === '/admin', <Users size={16} />, t('nav_admin_panel'))}
{isAdmin && renderSideLink('/logs', props.location === '/logs', <FileClock size={16} />, t('nav_log_center'))} {isAdmin && renderSideLink('/logs', props.location === '/logs', <FileClock size={16} />, t('nav_log_center'))}
{renderSideLink(DEVICE_MANAGEMENT_ROUTE, deviceManagementActive, <MonitorSmartphone size={16} />, t('nav_device_management'))}
</> </>
); );
@@ -206,6 +206,7 @@ export default function AppAuthenticatedShell(props: AppAuthenticatedShellProps)
<> <>
{renderSubLink(props.settingsAccountRoute, props.location === props.settingsAccountRoute, t('nav_account_settings'))} {renderSubLink(props.settingsAccountRoute, props.location === props.settingsAccountRoute, t('nav_account_settings'))}
{renderSubLink('/settings/domain-rules', props.location === '/settings/domain-rules', t('nav_domain_rules'))} {renderSubLink('/settings/domain-rules', props.location === '/settings/domain-rules', t('nav_domain_rules'))}
{renderSubLink(DEVICE_MANAGEMENT_ROUTE, deviceManagementActive, t('nav_device_management'))}
</> </>
)} )}
{renderNavGroup( {renderNavGroup(
@@ -226,7 +227,6 @@ export default function AppAuthenticatedShell(props: AppAuthenticatedShellProps)
<> <>
{isAdmin && renderSubLink('/admin', props.location === '/admin', t('nav_admin_panel'))} {isAdmin && renderSubLink('/admin', props.location === '/admin', t('nav_admin_panel'))}
{isAdmin && renderSubLink('/logs', props.location === '/logs', t('nav_log_center'))} {isAdmin && renderSubLink('/logs', props.location === '/logs', t('nav_log_center'))}
{renderSubLink(DEVICE_MANAGEMENT_ROUTE, deviceManagementActive, t('nav_device_management'))}
</> </>
)} )}
</> </>
+26 -14
View File
@@ -169,7 +169,7 @@ export interface AppMainRoutesProps {
onListRemoteBackups: (destinationId: string, path: string) => Promise<RemoteBackupBrowserResponse>; onListRemoteBackups: (destinationId: string, path: string) => Promise<RemoteBackupBrowserResponse>;
onDownloadRemoteBackup: (masterPassword: string, destinationId: string, path: string, onProgress?: (percent: number | null) => void) => Promise<void>; onDownloadRemoteBackup: (masterPassword: string, destinationId: string, path: string, onProgress?: (percent: number | null) => void) => Promise<void>;
onInspectRemoteBackup: (destinationId: string, path: string) => Promise<{ object: 'backup-remote-integrity'; destinationId: string; path: string; fileName: string; integrity: { hasChecksumPrefix: boolean; expectedPrefix: string | null; actualPrefix: string; matches: boolean } }>; onInspectRemoteBackup: (destinationId: string, path: string) => Promise<{ object: 'backup-remote-integrity'; destinationId: string; path: string; fileName: string; integrity: { hasChecksumPrefix: boolean; expectedPrefix: string | null; actualPrefix: string; matches: boolean } }>;
onDeleteRemoteBackup: (destinationId: string, path: string) => Promise<void>; onDeleteRemoteBackup: (masterPassword: string, destinationId: string, path: string) => Promise<void>;
onRestoreRemoteBackup: (masterPassword: string, destinationId: string, path: string, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>; onRestoreRemoteBackup: (masterPassword: string, destinationId: string, path: string, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
onRestoreRemoteBackupAllowingChecksumMismatch: (masterPassword: string, destinationId: string, path: string, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>; onRestoreRemoteBackupAllowingChecksumMismatch: (masterPassword: string, destinationId: string, path: string, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
} }
@@ -320,7 +320,9 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
</Route> </Route>
<Route path="/settings"> <Route path="/settings">
{props.profile ? ( {props.profile ? (
<section className="card mobile-settings-card"> <section className="card mobile-settings-card settings-home-card">
<div className="settings-home-section">
<h3>{t('txt_settings')}</h3>
<div className="mobile-settings-links"> <div className="mobile-settings-links">
<Link href={props.settingsAccountRoute} className="mobile-settings-link"> <Link href={props.settingsAccountRoute} className="mobile-settings-link">
<SettingsIcon size={18} /> <SettingsIcon size={18} />
@@ -334,22 +336,15 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
<Globe2 size={18} /> <Globe2 size={18} />
<span>{t('nav_domain_rules')}</span> <span>{t('nav_domain_rules')}</span>
</Link> </Link>
</div>
</div>
<div className="settings-home-section">
<h3>{t('nav_group_data_backup')}</h3>
<div className="mobile-settings-links">
<Link href={props.importRoute} className="mobile-settings-link"> <Link href={props.importRoute} className="mobile-settings-link">
<ArrowUpDown size={18} /> <ArrowUpDown size={18} />
<span>{t('nav_import_export')}</span> <span>{t('nav_import_export')}</span>
</Link> </Link>
{isAdmin && (
<Link href="/admin" className="mobile-settings-link">
<ShieldUser size={18} />
<span>{t('nav_admin_panel')}</span>
</Link>
)}
{isAdmin && (
<Link href="/logs" className="mobile-settings-link">
<FileClock size={18} />
<span>{t('nav_log_center')}</span>
</Link>
)}
{isAdmin && ( {isAdmin && (
<Link href="/backup" className="mobile-settings-link"> <Link href="/backup" className="mobile-settings-link">
<Cloud size={18} /> <Cloud size={18} />
@@ -357,6 +352,23 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
</Link> </Link>
)} )}
</div> </div>
</div>
{isAdmin && (
<div className="settings-home-section">
<h3>{t('nav_group_management')}</h3>
<div className="mobile-settings-links">
<Link href="/admin" className="mobile-settings-link">
<ShieldUser size={18} />
<span>{t('nav_admin_panel')}</span>
</Link>
<Link href="/logs" className="mobile-settings-link">
<FileClock size={18} />
<span>{t('nav_log_center')}</span>
</Link>
</div>
</div>
)}
<div className="settings-home-spacer" />
<button type="button" className="btn btn-secondary mobile-settings-logout" onClick={props.onLogout}> <button type="button" className="btn btn-secondary mobile-settings-logout" onClick={props.onLogout}>
<LogOut size={14} className="btn-icon" /> <LogOut size={14} className="btn-icon" />
{t('txt_sign_out')} {t('txt_sign_out')}
+104 -67
View File
@@ -43,7 +43,7 @@ interface BackupCenterPageProps {
onListRemoteBackups: (destinationId: string, path: string) => Promise<RemoteBackupBrowserResponse>; onListRemoteBackups: (destinationId: string, path: string) => Promise<RemoteBackupBrowserResponse>;
onDownloadRemoteBackup: (masterPassword: string, destinationId: string, path: string, onProgress?: (percent: number | null) => void) => Promise<void>; onDownloadRemoteBackup: (masterPassword: string, destinationId: string, path: string, onProgress?: (percent: number | null) => void) => Promise<void>;
onInspectRemoteBackup: (destinationId: string, path: string) => Promise<{ object: 'backup-remote-integrity'; destinationId: string; path: string; fileName: string; integrity: BackupFileIntegrityCheckResult }>; onInspectRemoteBackup: (destinationId: string, path: string) => Promise<{ object: 'backup-remote-integrity'; destinationId: string; path: string; fileName: string; integrity: BackupFileIntegrityCheckResult }>;
onDeleteRemoteBackup: (destinationId: string, path: string) => Promise<void>; onDeleteRemoteBackup: (masterPassword: string, destinationId: string, path: string) => Promise<void>;
onRestoreRemoteBackup: (masterPassword: string, destinationId: string, path: string, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>; onRestoreRemoteBackup: (masterPassword: string, destinationId: string, path: string, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
onRestoreRemoteBackupAllowingChecksumMismatch: (masterPassword: string, destinationId: string, path: string, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>; onRestoreRemoteBackupAllowingChecksumMismatch: (masterPassword: string, destinationId: string, path: string, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
onNotify: (type: 'success' | 'error' | 'warning', text: string) => void; onNotify: (type: 'success' | 'error' | 'warning', text: string) => void;
@@ -60,6 +60,7 @@ type PendingBackupVerification =
| { action: 'import'; replaceExisting: boolean; allowChecksumMismatch: boolean; knownIntegrity?: BackupFileIntegrityCheckResult } | { action: 'import'; replaceExisting: boolean; allowChecksumMismatch: boolean; knownIntegrity?: BackupFileIntegrityCheckResult }
| { action: 'runRemoteBackup' } | { action: 'runRemoteBackup' }
| { action: 'downloadRemote'; path: string } | { action: 'downloadRemote'; path: string }
| { action: 'deleteRemote'; destinationId: string; path: string }
| { action: 'restoreRemote'; path: string; replaceExisting: boolean; allowChecksumMismatch: boolean; knownIntegrity?: BackupFileIntegrityCheckResult }; | { action: 'restoreRemote'; path: string; replaceExisting: boolean; allowChecksumMismatch: boolean; knownIntegrity?: BackupFileIntegrityCheckResult };
interface BackupProgressPhase { interface BackupProgressPhase {
@@ -204,6 +205,7 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
const [confirmRemoteDeleteOpen, setConfirmRemoteDeleteOpen] = useState(false); const [confirmRemoteDeleteOpen, setConfirmRemoteDeleteOpen] = useState(false);
const [pendingBackupVerification, setPendingBackupVerification] = useState<PendingBackupVerification | null>(null); const [pendingBackupVerification, setPendingBackupVerification] = useState<PendingBackupVerification | null>(null);
const [backupPasswordValue, setBackupPasswordValue] = useState(''); const [backupPasswordValue, setBackupPasswordValue] = useState('');
const [backupPasswordError, setBackupPasswordError] = useState('');
const [backupPasswordSubmitting, setBackupPasswordSubmitting] = useState(false); const [backupPasswordSubmitting, setBackupPasswordSubmitting] = useState(false);
const [pendingRestoreIntegrity, setPendingRestoreIntegrity] = useState<PendingRestoreIntegrity | null>(null); const [pendingRestoreIntegrity, setPendingRestoreIntegrity] = useState<PendingRestoreIntegrity | null>(null);
const [pendingRemoteRestorePath, setPendingRemoteRestorePath] = useState(''); const [pendingRemoteRestorePath, setPendingRemoteRestorePath] = useState('');
@@ -247,10 +249,28 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
? t('txt_backup_run_manual') ? t('txt_backup_run_manual')
: pendingBackupVerification?.action === 'downloadRemote' : pendingBackupVerification?.action === 'downloadRemote'
? t('txt_backup_remote_download') ? t('txt_backup_remote_download')
: pendingBackupVerification?.action === 'deleteRemote'
? t('txt_delete')
: pendingBackupVerification?.action === 'restoreRemote' : pendingBackupVerification?.action === 'restoreRemote'
? t('txt_backup_import') ? t('txt_backup_import')
: t('txt_backup_import'); : t('txt_backup_import');
function openBackupPasswordPrompt(request: PendingBackupVerification): void {
setPendingBackupVerification(request);
setBackupPasswordValue('');
setBackupPasswordError('');
}
function showActionError(error: unknown, fallback: string): string {
const message = error instanceof Error ? error.message : fallback;
setLocalError(message);
if (backupPasswordSubmitting || pendingBackupVerification) {
setBackupPasswordError(message);
}
props.onNotify('error', message);
return message;
}
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
setLoadingSettings(true); setLoadingSettings(true);
@@ -502,12 +522,11 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
destinations: (savedSettings?.destinations || []).filter((destination) => destination.id !== destinationIdToDelete), destinations: (savedSettings?.destinations || []).filter((destination) => destination.id !== destinationIdToDelete),
}; };
setPendingBackupVerification({ action: 'deleteDestination', destinationId: destinationIdToDelete, settings: nextSettings }); openBackupPasswordPrompt({ action: 'deleteDestination', destinationId: destinationIdToDelete, settings: nextSettings });
setBackupPasswordValue('');
setConfirmDeleteDestinationOpen(false); setConfirmDeleteDestinationOpen(false);
} }
async function executeDeleteDestination(masterPassword: string, destinationIdToDelete: string, payload: AdminBackupSettings) { async function executeDeleteDestination(masterPassword: string, destinationIdToDelete: string, payload: AdminBackupSettings): Promise<boolean> {
setSavingSettings(true); setSavingSettings(true);
setLocalError(''); setLocalError('');
try { try {
@@ -527,10 +546,10 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
setSelectedDestinationId(nextSelected); setSelectedDestinationId(nextSelected);
setConfirmDeleteDestinationOpen(false); setConfirmDeleteDestinationOpen(false);
props.onNotify('success', t('txt_backup_destination_deleted')); props.onNotify('success', t('txt_backup_destination_deleted'));
return true;
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : t('txt_backup_settings_save_failed'); showActionError(error, t('txt_backup_settings_save_failed'));
setLocalError(message); return false;
props.onNotify('error', message);
} finally { } finally {
setSavingSettings(false); setSavingSettings(false);
} }
@@ -538,22 +557,21 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
async function handleExport() { async function handleExport() {
if (exporting) return; if (exporting) return;
setPendingBackupVerification({ action: 'export' }); openBackupPasswordPrompt({ action: 'export' });
setBackupPasswordValue('');
} }
async function executeExport(masterPassword: string) { async function executeExport(masterPassword: string): Promise<boolean> {
setLocalError(''); setLocalError('');
setExporting(true); setExporting(true);
try { try {
startRestoreProgress('backup-export', t('txt_backup_export'), { source: 'local', includeAttachments: exportIncludeAttachments }); startRestoreProgress('backup-export', t('txt_backup_export'), { source: 'local', includeAttachments: exportIncludeAttachments });
await props.onExport(masterPassword, exportIncludeAttachments); await props.onExport(masterPassword, exportIncludeAttachments);
props.onNotify('success', t('txt_backup_export_success')); props.onNotify('success', t('txt_backup_export_success'));
return true;
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : t('txt_backup_export_failed'); showActionError(error, t('txt_backup_export_failed'));
setLocalError(message);
props.onNotify('error', message);
window.setTimeout(() => clearRestoreProgress(), 1200); window.setTimeout(() => clearRestoreProgress(), 1200);
return false;
} finally { } finally {
setExporting(false); setExporting(false);
} }
@@ -571,13 +589,12 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
props.onNotify('error', message); props.onNotify('error', message);
return; return;
} }
setPendingBackupVerification({ openBackupPasswordPrompt({
action: 'import', action: 'import',
replaceExisting, replaceExisting,
allowChecksumMismatch, allowChecksumMismatch,
knownIntegrity, knownIntegrity,
}); });
setBackupPasswordValue('');
} }
async function executeLocalRestore( async function executeLocalRestore(
@@ -585,13 +602,14 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
replaceExisting: boolean, replaceExisting: boolean,
allowChecksumMismatch: boolean = false, allowChecksumMismatch: boolean = false,
knownIntegrity?: BackupFileIntegrityCheckResult knownIntegrity?: BackupFileIntegrityCheckResult
) { ): Promise<boolean> {
if (importing) return; if (importing) return false;
if (!selectedFile) { if (!selectedFile) {
const message = t('txt_backup_file_required'); const message = t('txt_backup_file_required');
setLocalError(message); setLocalError(message);
setBackupPasswordError(message);
props.onNotify('error', message); props.onNotify('error', message);
return; return false;
} }
setLocalError(''); setLocalError('');
setConfirmLocalRestoreOpen(false); setConfirmLocalRestoreOpen(false);
@@ -614,17 +632,17 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
setConfirmLocalRestoreOpen(false); setConfirmLocalRestoreOpen(false);
setConfirmReplaceOpen(false); setConfirmReplaceOpen(false);
resetPendingIntegrityWarning(); resetPendingIntegrityWarning();
return true;
} catch (error) { } catch (error) {
if (!replaceExisting && isReplaceRequiredError(error)) { if (!replaceExisting && isReplaceRequiredError(error)) {
clearRestoreProgress(); clearRestoreProgress();
setConfirmLocalRestoreOpen(false); setConfirmLocalRestoreOpen(false);
setConfirmReplaceOpen(true); setConfirmReplaceOpen(true);
return; return true;
} }
const message = error instanceof Error ? error.message : t('txt_backup_restore_failed'); showActionError(error, t('txt_backup_restore_failed'));
setLocalError(message);
props.onNotify('error', message);
window.setTimeout(() => clearRestoreProgress(), 1200); window.setTimeout(() => clearRestoreProgress(), 1200);
return false;
} finally { } finally {
setImporting(false); setImporting(false);
} }
@@ -632,11 +650,10 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
async function handleSaveSettings() { async function handleSaveSettings() {
if (savingSettings) return; if (savingSettings) return;
setPendingBackupVerification({ action: 'saveSettings' }); openBackupPasswordPrompt({ action: 'saveSettings' });
setBackupPasswordValue('');
} }
async function executeSaveSettings(masterPassword: string) { async function executeSaveSettings(masterPassword: string): Promise<boolean> {
const payload = buildSettingsPayloadForSelectedDestination(); const payload = buildSettingsPayloadForSelectedDestination();
const destinationIdToInvalidate = selectedDestinationId; const destinationIdToInvalidate = selectedDestinationId;
setSavingSettings(true); setSavingSettings(true);
@@ -656,10 +673,10 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
} }
setSelectedDestinationId(nextSelected); setSelectedDestinationId(nextSelected);
props.onNotify('success', t('txt_backup_settings_saved')); props.onNotify('success', t('txt_backup_settings_saved'));
return true;
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : t('txt_backup_settings_save_failed'); showActionError(error, t('txt_backup_settings_save_failed'));
setLocalError(message); return false;
props.onNotify('error', message);
} finally { } finally {
setSavingSettings(false); setSavingSettings(false);
} }
@@ -678,12 +695,11 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
async function handleRunRemoteBackup() { async function handleRunRemoteBackup() {
if (!selectedDestination || runningRemoteBackup) return; if (!selectedDestination || runningRemoteBackup) return;
setPendingBackupVerification({ action: 'runRemoteBackup' }); openBackupPasswordPrompt({ action: 'runRemoteBackup' });
setBackupPasswordValue('');
} }
async function executeRunRemoteBackup(masterPassword: string) { async function executeRunRemoteBackup(masterPassword: string): Promise<boolean> {
if (!selectedDestination) return; if (!selectedDestination) return false;
setRunningRemoteBackup(true); setRunningRemoteBackup(true);
setLocalError(''); setLocalError('');
try { try {
@@ -697,32 +713,31 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
setSelectedDestinationId(selectedDestination.id); setSelectedDestinationId(selectedDestination.id);
await loadRemoteBrowser(selectedDestination.id, currentRemoteBrowserPath, { force: true }); await loadRemoteBrowser(selectedDestination.id, currentRemoteBrowserPath, { force: true });
props.onNotify('success', t('txt_backup_remote_run_success_verified', { name: result.result.fileName })); props.onNotify('success', t('txt_backup_remote_run_success_verified', { name: result.result.fileName }));
return true;
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : t('txt_backup_remote_run_failed'); showActionError(error, t('txt_backup_remote_run_failed'));
setLocalError(message);
props.onNotify('error', message);
window.setTimeout(() => clearRestoreProgress(), 1200); window.setTimeout(() => clearRestoreProgress(), 1200);
return false;
} finally { } finally {
setRunningRemoteBackup(false); setRunningRemoteBackup(false);
} }
} }
async function handleDownloadRemote(path: string) { async function handleDownloadRemote(path: string) {
setPendingBackupVerification({ action: 'downloadRemote', path }); openBackupPasswordPrompt({ action: 'downloadRemote', path });
setBackupPasswordValue('');
} }
async function executeDownloadRemote(masterPassword: string, path: string) { async function executeDownloadRemote(masterPassword: string, path: string): Promise<boolean> {
if (!savedSelectedDestination) return; if (!savedSelectedDestination) return false;
setDownloadingRemotePath(path); setDownloadingRemotePath(path);
setDownloadingRemotePercent(null); setDownloadingRemotePercent(null);
setLocalError(''); setLocalError('');
try { try {
await props.onDownloadRemoteBackup(masterPassword, savedSelectedDestination.id, path, setDownloadingRemotePercent); await props.onDownloadRemoteBackup(masterPassword, savedSelectedDestination.id, path, setDownloadingRemotePercent);
return true;
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : t('txt_backup_remote_download_failed'); showActionError(error, t('txt_backup_remote_download_failed'));
setLocalError(message); return false;
props.onNotify('error', message);
} finally { } finally {
setDownloadingRemotePath(''); setDownloadingRemotePath('');
setDownloadingRemotePercent(null); setDownloadingRemotePercent(null);
@@ -732,18 +747,24 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
async function handleDeleteRemote(path: string) { async function handleDeleteRemote(path: string) {
if (deletingRemotePath) return; if (deletingRemotePath) return;
if (!savedSelectedDestination) return; if (!savedSelectedDestination) return;
openBackupPasswordPrompt({ action: 'deleteRemote', destinationId: savedSelectedDestination.id, path });
setConfirmRemoteDeleteOpen(false);
}
async function executeDeleteRemote(masterPassword: string, destinationId: string, path: string): Promise<boolean> {
if (deletingRemotePath) return false;
setDeletingRemotePath(path); setDeletingRemotePath(path);
setLocalError(''); setLocalError('');
try { try {
await props.onDeleteRemoteBackup(savedSelectedDestination.id, path); await props.onDeleteRemoteBackup(masterPassword, destinationId, path);
setConfirmRemoteDeleteOpen(false); setConfirmRemoteDeleteOpen(false);
setPendingRemoteDeletePath(''); setPendingRemoteDeletePath('');
await loadRemoteBrowser(savedSelectedDestination.id, currentRemoteBrowserPath, { force: true }); await loadRemoteBrowser(destinationId, remoteBrowserPathByDestination[destinationId] || '', { force: true });
props.onNotify('success', t('txt_backup_remote_delete_success')); props.onNotify('success', t('txt_backup_remote_delete_success'));
return true;
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : t('txt_backup_remote_delete_failed'); showActionError(error, t('txt_backup_remote_delete_failed'));
setLocalError(message); return false;
props.onNotify('error', message);
} finally { } finally {
setDeletingRemotePath(''); setDeletingRemotePath('');
} }
@@ -802,14 +823,13 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
) { ) {
if (restoringRemotePath) return; if (restoringRemotePath) return;
if (!savedSelectedDestination) return; if (!savedSelectedDestination) return;
setPendingBackupVerification({ openBackupPasswordPrompt({
action: 'restoreRemote', action: 'restoreRemote',
path, path,
replaceExisting, replaceExisting,
allowChecksumMismatch, allowChecksumMismatch,
knownIntegrity, knownIntegrity,
}); });
setBackupPasswordValue('');
} }
async function executeRemoteRestore( async function executeRemoteRestore(
@@ -818,9 +838,9 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
replaceExisting: boolean, replaceExisting: boolean,
allowChecksumMismatch: boolean = false, allowChecksumMismatch: boolean = false,
knownIntegrity?: BackupFileIntegrityCheckResult knownIntegrity?: BackupFileIntegrityCheckResult
) { ): Promise<boolean> {
if (restoringRemotePath) return; if (restoringRemotePath) return false;
if (!savedSelectedDestination) return; if (!savedSelectedDestination) return false;
setConfirmRemoteReplaceOpen(false); setConfirmRemoteReplaceOpen(false);
setConfirmIntegrityWarningOpen(false); setConfirmIntegrityWarningOpen(false);
setRestoringRemotePath(path); setRestoringRemotePath(path);
@@ -840,17 +860,17 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
const skippedMessage = buildSkippedImportMessage(result); const skippedMessage = buildSkippedImportMessage(result);
if (skippedMessage) props.onNotify('warning', skippedMessage); if (skippedMessage) props.onNotify('warning', skippedMessage);
resetPendingIntegrityWarning(); resetPendingIntegrityWarning();
return true;
} catch (error) { } catch (error) {
if (!replaceExisting && isReplaceRequiredError(error)) { if (!replaceExisting && isReplaceRequiredError(error)) {
setPendingRemoteRestorePath(path); setPendingRemoteRestorePath(path);
setConfirmRemoteReplaceOpen(true); setConfirmRemoteReplaceOpen(true);
clearRestoreProgress(); clearRestoreProgress();
return; return true;
} }
const message = error instanceof Error ? error.message : t('txt_backup_remote_restore_failed'); showActionError(error, t('txt_backup_remote_restore_failed'));
setLocalError(message);
props.onNotify('error', message);
window.setTimeout(() => clearRestoreProgress(), 1200); window.setTimeout(() => clearRestoreProgress(), 1200);
return false;
} finally { } finally {
setRestoringRemotePath(''); setRestoringRemotePath('');
} }
@@ -861,31 +881,38 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
const masterPassword = backupPasswordValue; const masterPassword = backupPasswordValue;
if (!request || backupPasswordSubmitting) return; if (!request || backupPasswordSubmitting) return;
if (!masterPassword.trim()) { if (!masterPassword.trim()) {
props.onNotify('error', t('txt_master_password_is_required')); setBackupPasswordError(t('txt_master_password_is_required'));
return; return;
} }
setBackupPasswordSubmitting(true); setBackupPasswordSubmitting(true);
setPendingBackupVerification(null); setBackupPasswordError('');
setBackupPasswordValue(''); let succeeded = false;
try { try {
if (request.action === 'export') { if (request.action === 'export') {
await executeExport(masterPassword); succeeded = await executeExport(masterPassword);
} else if (request.action === 'saveSettings') { } else if (request.action === 'saveSettings') {
await executeSaveSettings(masterPassword); succeeded = await executeSaveSettings(masterPassword);
} else if (request.action === 'deleteDestination') { } else if (request.action === 'deleteDestination') {
await executeDeleteDestination(masterPassword, request.destinationId, request.settings); succeeded = await executeDeleteDestination(masterPassword, request.destinationId, request.settings);
} else if (request.action === 'import') { } else if (request.action === 'import') {
await executeLocalRestore(masterPassword, request.replaceExisting, request.allowChecksumMismatch, request.knownIntegrity); succeeded = await executeLocalRestore(masterPassword, request.replaceExisting, request.allowChecksumMismatch, request.knownIntegrity);
} else if (request.action === 'runRemoteBackup') { } else if (request.action === 'runRemoteBackup') {
await executeRunRemoteBackup(masterPassword); succeeded = await executeRunRemoteBackup(masterPassword);
} else if (request.action === 'downloadRemote') { } else if (request.action === 'downloadRemote') {
await executeDownloadRemote(masterPassword, request.path); succeeded = await executeDownloadRemote(masterPassword, request.path);
} else if (request.action === 'deleteRemote') {
succeeded = await executeDeleteRemote(masterPassword, request.destinationId, request.path);
} else if (request.action === 'restoreRemote') { } else if (request.action === 'restoreRemote') {
await executeRemoteRestore(masterPassword, request.path, request.replaceExisting, request.allowChecksumMismatch, request.knownIntegrity); succeeded = await executeRemoteRestore(masterPassword, request.path, request.replaceExisting, request.allowChecksumMismatch, request.knownIntegrity);
} }
} finally { } finally {
setBackupPasswordSubmitting(false); setBackupPasswordSubmitting(false);
} }
if (succeeded) {
setPendingBackupVerification(null);
setBackupPasswordValue('');
setBackupPasswordError('');
}
} }
return ( return (
@@ -1031,17 +1058,27 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
if (backupPasswordSubmitting) return; if (backupPasswordSubmitting) return;
setPendingBackupVerification(null); setPendingBackupVerification(null);
setBackupPasswordValue(''); setBackupPasswordValue('');
setBackupPasswordError('');
}} }}
> >
<label className="field"> <label className="field">
<span>{t('txt_master_password')}</span> <span>{t('txt_master_password')}</span>
<input <input
id="backup-master-password"
className="input" className="input"
type="password" type="password"
autoComplete="current-password" autoComplete="current-password"
value={backupPasswordValue} value={backupPasswordValue}
onInput={(event) => setBackupPasswordValue((event.currentTarget as HTMLInputElement).value)} aria-invalid={!!backupPasswordError}
aria-describedby={backupPasswordError ? 'backup-master-password-error' : undefined}
onInput={(event) => {
setBackupPasswordValue((event.currentTarget as HTMLInputElement).value);
if (backupPasswordError) setBackupPasswordError('');
}}
/> />
{backupPasswordError ? (
<div id="backup-master-password-error" className="local-error" role="alert">{backupPasswordError}</div>
) : null}
</label> </label>
</ConfirmDialog> </ConfirmDialog>
+133 -4
View File
@@ -1,7 +1,7 @@
import { useState } from 'preact/hooks'; import { useState } from 'preact/hooks';
import { argon2idAsync } from '@noble/hashes/argon2.js'; import { argon2idAsync } from '@noble/hashes/argon2.js';
import { createPortal } from 'preact/compat'; import { createPortal } from 'preact/compat';
import { strFromU8, unzipSync } from 'fflate'; import { strFromU8, unzipSync, type UnzipFileInfo } from 'fflate';
import { BlobReader, Uint8ArrayWriter, ZipReader, configure as configureZipJs } from '@zip.js/zip.js'; import { BlobReader, Uint8ArrayWriter, ZipReader, configure as configureZipJs } from '@zip.js/zip.js';
import { Download, FileUp } from 'lucide-preact'; import { Download, FileUp } from 'lucide-preact';
import ConfirmDialog, { useDialogLifecycle } from '@/components/ConfirmDialog'; import ConfirmDialog, { useDialogLifecycle } from '@/components/ConfirmDialog';
@@ -96,6 +96,12 @@ const COMMON_IMPORT_SOURCE_IDS: ImportSourceId[] = [
'keepassx_csv', 'keepassx_csv',
]; ];
const MAX_IMPORT_ZIP_BYTES = 256 * 1024 * 1024;
const MAX_IMPORT_ZIP_ENTRY_COUNT = 10_000;
const MAX_IMPORT_TEXT_ENTRY_BYTES = 32 * 1024 * 1024;
const MAX_IMPORT_ATTACHMENT_BYTES = 100 * 1024 * 1024;
const MAX_IMPORT_ATTACHMENT_TOTAL_BYTES = 512 * 1024 * 1024;
function isRecord(value: unknown): value is Record<string, unknown> { function isRecord(value: unknown): value is Record<string, unknown> {
return !!value && typeof value === 'object'; return !!value && typeof value === 'object';
} }
@@ -171,8 +177,85 @@ function isZipPayload(bytes: Uint8Array): boolean {
return bytes.length >= 4 && bytes[0] === 0x50 && bytes[1] === 0x4b && bytes[2] === 0x03 && bytes[3] === 0x04; return bytes.length >= 4 && bytes[0] === 0x50 && bytes[1] === 0x4b && bytes[2] === 0x03 && bytes[3] === 0x04;
} }
function formatMiB(bytes: number): string {
return String(Math.floor(bytes / (1024 * 1024)));
}
function zipEntryName(rawName: unknown): string {
return String(rawName || '').trim().replace(/\\/g, '/');
}
function assertSafeZipEntryName(name: string): void {
if (!name || name.includes('\0') || name.startsWith('/') || name.includes('//')) {
throw new Error(t('txt_import_zip_unsafe_file_name'));
}
const parts = name.split('/');
if (parts.some((part) => part === '.' || part === '..')) {
throw new Error(t('txt_import_zip_unsafe_file_name'));
}
}
function assertImportZipSize(bytes: number): void {
if (bytes > MAX_IMPORT_ZIP_BYTES) {
throw new Error(t('txt_import_zip_too_large', { size: formatMiB(MAX_IMPORT_ZIP_BYTES) }));
}
}
function assertImportTextFileSize(bytes: number): void {
if (bytes > MAX_IMPORT_TEXT_ENTRY_BYTES) {
throw new Error(t('txt_import_file_too_large', { size: formatMiB(MAX_IMPORT_TEXT_ENTRY_BYTES) }));
}
}
function assertImportEntrySize(size: number, maxBytes: number): void {
if (size > maxBytes) {
throw new Error(t('txt_import_zip_entry_too_large', { size: formatMiB(maxBytes) }));
}
}
function isImportTextZipCandidate(source: ImportSourceId, name: string): boolean {
const lower = name.toLowerCase();
if (source === 'onepassword_1pux') {
return lower.endsWith('/export.data') || lower === 'export.data' || lower.endsWith('/export.json') || lower === 'export.json' || lower.endsWith('.json');
}
return lower.endsWith('/protonpass.json') || lower === 'protonpass.json' || lower.endsWith('/export.json') || lower === 'export.json' || lower.endsWith('.json');
}
function createImportTextZipFilter(source: ImportSourceId): (file: UnzipFileInfo) => boolean {
let entryCount = 0;
let totalTextBytes = 0;
return (entry: UnzipFileInfo): boolean => {
entryCount += 1;
if (entryCount > MAX_IMPORT_ZIP_ENTRY_COUNT) {
throw new Error(t('txt_import_zip_too_many_files'));
}
const name = zipEntryName(entry.name);
assertSafeZipEntryName(name);
if (!isImportTextZipCandidate(source, name)) return false;
const originalSize = Number(entry.originalSize);
if (!Number.isFinite(originalSize) || originalSize < 0) {
throw new Error(t('txt_import_zip_entry_too_large', { size: formatMiB(MAX_IMPORT_TEXT_ENTRY_BYTES) }));
}
assertImportEntrySize(originalSize, MAX_IMPORT_TEXT_ENTRY_BYTES);
totalTextBytes += originalSize;
if (totalTextBytes > MAX_IMPORT_TEXT_ENTRY_BYTES) {
throw new Error(t('txt_import_zip_expands_too_large', { size: formatMiB(MAX_IMPORT_TEXT_ENTRY_BYTES) }));
}
return true;
};
}
function readZipText(bytes: Uint8Array, source: ImportSourceId): string { function readZipText(bytes: Uint8Array, source: ImportSourceId): string {
const unzipped = unzipSync(bytes); assertImportZipSize(bytes.byteLength);
const unzippedRaw = unzipSync(bytes, { filter: createImportTextZipFilter(source) });
const unzipped: Record<string, Uint8Array> = {};
for (const [rawName, entryBytes] of Object.entries(unzippedRaw)) {
const name = zipEntryName(rawName);
assertSafeZipEntryName(name);
assertImportEntrySize(entryBytes.byteLength, MAX_IMPORT_TEXT_ENTRY_BYTES);
unzipped[name] = entryBytes;
}
const fileNames = Object.keys(unzipped); const fileNames = Object.keys(unzipped);
if (!fileNames.length) throw new Error(t('txt_import_empty_zip_archive')); if (!fileNames.length) throw new Error(t('txt_import_empty_zip_archive'));
@@ -189,10 +272,13 @@ function readZipText(bytes: Uint8Array, source: ImportSourceId): string {
async function readImportText(file: File, source: ImportSourceId): Promise<string> { async function readImportText(file: File, source: ImportSourceId): Promise<string> {
if (source !== 'onepassword_1pux' && source !== 'protonpass_json') { if (source !== 'onepassword_1pux' && source !== 'protonpass_json') {
assertImportTextFileSize(file.size);
return file.text(); return file.text();
} }
assertImportZipSize(file.size);
const bytes = new Uint8Array(await file.arrayBuffer()); const bytes = new Uint8Array(await file.arrayBuffer());
if (isZipPayload(bytes)) return readZipText(bytes, source); if (isZipPayload(bytes)) return readZipText(bytes, source);
assertImportTextFileSize(bytes.byteLength);
return new TextDecoder().decode(bytes); return new TextDecoder().decode(bytes);
} }
@@ -211,34 +297,77 @@ function looksLikeZipPasswordError(error: unknown): boolean {
return message.includes('password') || message.includes('encrypted'); return message.includes('password') || message.includes('encrypted');
} }
function bitwardenZipAttachmentMatch(name: string): RegExpMatchArray | null {
return name.match(/^attachments\/([^/]+)\/(.+)$/i);
}
function zipJsEntrySize(entry: unknown): number | null {
const size = Number((entry as { uncompressedSize?: unknown })?.uncompressedSize);
return Number.isFinite(size) && size >= 0 ? size : null;
}
function validateBitwardenZipEntries(entries: Awaited<ReturnType<ZipReader<unknown>['getEntries']>>): void {
if (entries.length > MAX_IMPORT_ZIP_ENTRY_COUNT) {
throw new Error(t('txt_import_zip_too_many_files'));
}
let totalAttachmentBytes = 0;
for (const entry of entries) {
if (entry.directory) continue;
const name = zipEntryName(entry.filename);
assertSafeZipEntryName(name);
const lower = name.toLowerCase();
const size = zipJsEntrySize(entry);
if (lower === 'data.json' && size != null) {
assertImportEntrySize(size, MAX_IMPORT_TEXT_ENTRY_BYTES);
} else if (bitwardenZipAttachmentMatch(name) && size != null) {
assertImportEntrySize(size, MAX_IMPORT_ATTACHMENT_BYTES);
totalAttachmentBytes += size;
if (totalAttachmentBytes > MAX_IMPORT_ATTACHMENT_TOTAL_BYTES) {
throw new Error(t('txt_import_zip_expands_too_large', { size: formatMiB(MAX_IMPORT_ATTACHMENT_TOTAL_BYTES) }));
}
}
}
}
async function readBitwardenZipPayload( async function readBitwardenZipPayload(
file: File, file: File,
passwordRaw: string passwordRaw: string
): Promise<{ jsonText: string; attachments: ImportAttachmentFile[] }> { ): Promise<{ jsonText: string; attachments: ImportAttachmentFile[] }> {
const password = String(passwordRaw || '').trim(); const password = String(passwordRaw || '').trim();
assertImportZipSize(file.size);
const reader = new ZipReader(new BlobReader(file), { useWebWorkers: false }); const reader = new ZipReader(new BlobReader(file), { useWebWorkers: false });
try { try {
const entries = await reader.getEntries(); const entries = await reader.getEntries();
if (!entries.length) throw new Error(t('txt_import_empty_zip_archive')); if (!entries.length) throw new Error(t('txt_import_empty_zip_archive'));
validateBitwardenZipEntries(entries);
let jsonText = ''; let jsonText = '';
let totalAttachmentBytes = 0;
const attachments: ImportAttachmentFile[] = []; const attachments: ImportAttachmentFile[] = [];
const options = password ? { password } : undefined; const options = password ? { password } : undefined;
for (const entry of entries) { for (const entry of entries) {
if (entry.directory) continue; if (entry.directory) continue;
const name = String(entry.filename || '').trim().replace(/\\/g, '/'); const name = zipEntryName(entry.filename);
if (!name) continue; if (!name) continue;
assertSafeZipEntryName(name);
const bytes = await entry.getData(new Uint8ArrayWriter(), options); const bytes = await entry.getData(new Uint8ArrayWriter(), options);
const lower = name.toLowerCase(); const lower = name.toLowerCase();
if (lower === 'data.json') { if (lower === 'data.json') {
assertImportEntrySize(bytes.byteLength, MAX_IMPORT_TEXT_ENTRY_BYTES);
jsonText = new TextDecoder().decode(bytes); jsonText = new TextDecoder().decode(bytes);
continue; continue;
} }
const attachmentMatch = name.match(/^attachments\/([^/]+)\/(.+)$/i); const attachmentMatch = bitwardenZipAttachmentMatch(name);
if (!attachmentMatch) continue; if (!attachmentMatch) continue;
assertImportEntrySize(bytes.byteLength, MAX_IMPORT_ATTACHMENT_BYTES);
totalAttachmentBytes += bytes.byteLength;
if (totalAttachmentBytes > MAX_IMPORT_ATTACHMENT_TOTAL_BYTES) {
throw new Error(t('txt_import_zip_expands_too_large', { size: formatMiB(MAX_IMPORT_ATTACHMENT_TOTAL_BYTES) }));
}
const sourceCipherId = String(attachmentMatch[1] || '').trim() || null; const sourceCipherId = String(attachmentMatch[1] || '').trim() || null;
const fileName = String(attachmentMatch[2] || '').trim() || 'attachment.bin'; const fileName = String(attachmentMatch[2] || '').trim() || 'attachment.bin';
attachments.push({ attachments.push({
+11 -2
View File
@@ -129,6 +129,10 @@ function formatReason(reason: string): string {
return translatedOrHumanized(keyFor('txt_log_reason_', reason), reason); return translatedOrHumanized(keyFor('txt_log_reason_', reason), reason);
} }
function formatTargetType(type: string): string {
return translatedOrHumanized(keyFor('txt_log_target_type_', type), type);
}
function formatTime(value: string): string { function formatTime(value: string): string {
const date = new Date(value); const date = new Date(value);
return Number.isNaN(date.getTime()) ? value : date.toLocaleString(); return Number.isNaN(date.getTime()) ? value : date.toLocaleString();
@@ -148,11 +152,16 @@ function formatMetaValueForKey(key: string, value: unknown): string {
return translatedOrHumanized(keyFor('txt_log_trigger_', value), value); return translatedOrHumanized(keyFor('txt_log_trigger_', value), value);
} }
if (key === 'type' && typeof value === 'string') { if (key === 'type' && typeof value === 'string') {
return translatedOrHumanized(keyFor('txt_log_target_type_', value), value); return formatTargetType(value);
} }
return formatMetaValue(value); return formatMetaValue(value);
} }
function formatLogTarget(log: AuditLogEntry, metadata: Record<string, unknown>): string {
const targetEmail = typeof metadata.targetEmail === 'string' ? metadata.targetEmail : '';
return log.targetUserEmail || targetEmail || log.targetId || (log.targetType ? formatTargetType(log.targetType) : t('txt_dash'));
}
function iconForCategory(category: AuditLogCategory) { function iconForCategory(category: AuditLogCategory) {
if (category === 'auth') return <ShieldAlert size={16} />; if (category === 'auth') return <ShieldAlert size={16} />;
if (category === 'security') return <UserRound size={16} />; if (category === 'security') return <UserRound size={16} />;
@@ -550,7 +559,7 @@ export default function LogCenterPage(props: LogCenterPageProps) {
<div><span>{t('txt_time')}</span><strong>{formatTime(selectedLog.createdAt)}</strong></div> <div><span>{t('txt_time')}</span><strong>{formatTime(selectedLog.createdAt)}</strong></div>
<div><span>{t('txt_log_category')}</span><strong>{t(`txt_log_category_${selectedCategory}`)}</strong></div> <div><span>{t('txt_log_category')}</span><strong>{t(`txt_log_category_${selectedCategory}`)}</strong></div>
<div><span>{t('txt_actor')}</span><strong>{selectedLog.actorEmail || selectedLog.actorUserId || t('txt_dash')}</strong></div> <div><span>{t('txt_actor')}</span><strong>{selectedLog.actorEmail || selectedLog.actorUserId || t('txt_dash')}</strong></div>
<div><span>{t('txt_target')}</span><strong>{selectedLog.targetUserEmail || String(selectedMetadata.targetEmail || '') || selectedLog.targetId || selectedLog.targetType || t('txt_dash')}</strong></div> <div><span>{t('txt_target')}</span><strong>{formatLogTarget(selectedLog, selectedMetadata)}</strong></div>
</div> </div>
<div className="log-detail-json"> <div className="log-detail-json">
<h4>{t('txt_metadata')}</h4> <h4>{t('txt_metadata')}</h4>
+18 -1
View File
@@ -12,17 +12,20 @@ import {
cardListSubtitle, cardListSubtitle,
FOLDER_SORT_STORAGE_KEY, FOLDER_SORT_STORAGE_KEY,
VAULT_SORT_STORAGE_KEY, VAULT_SORT_STORAGE_KEY,
bankAccountListSubtitle,
cipherTypeKey, cipherTypeKey,
cipherTypeLabel, cipherTypeLabel,
createEmptyDraft, createEmptyDraft,
creationTimeValue, creationTimeValue,
draftFromCipher, draftFromCipher,
driversLicenseListSubtitle,
buildCipherDuplicateSignatures, buildCipherDuplicateSignatures,
firstCipherUri, firstCipherUri,
firstPasskeyCreationTime, firstPasskeyCreationTime,
isCipherVisibleInArchive, isCipherVisibleInArchive,
isCipherVisibleInNormalVault, isCipherVisibleInNormalVault,
isCipherVisibleInTrash, isCipherVisibleInTrash,
passportListSubtitle,
sortTimeValue, sortTimeValue,
type DuplicateDetectionMode, type DuplicateDetectionMode,
type SidebarFilter, type SidebarFilter,
@@ -308,10 +311,21 @@ export default function VaultPage(props: VaultPageProps) {
const name = String(cipher.decName || cipher.name || ''); const name = String(cipher.decName || cipher.name || '');
const username = String(cipher.login?.decUsername || ''); const username = String(cipher.login?.decUsername || '');
const uri = firstCipherUri(cipher); const uri = firstCipherUri(cipher);
const typedText = [
cipher.bankAccount?.decBankName,
cipher.bankAccount?.decNameOnAccount,
cipher.bankAccount?.decAccountNumber,
cipher.driversLicense?.decLicenseNumber,
cipher.driversLicense?.decFirstName,
cipher.driversLicense?.decLastName,
cipher.passport?.decPassportNumber,
cipher.passport?.decGivenName,
cipher.passport?.decSurname,
].filter(Boolean).join('\n');
const cipherId = String(cipher.id || '').trim(); const cipherId = String(cipher.id || '').trim();
meta.set(cipher.id, { meta.set(cipher.id, {
name, name,
searchText: `${cipherId}\n${cipherId.replace(/-/g, '')}\n${name}\n${username}\n${uri}`.toLowerCase(), searchText: `${cipherId}\n${cipherId.replace(/-/g, '')}\n${name}\n${username}\n${uri}\n${typedText}`.toLowerCase(),
firstUri: uri, firstUri: uri,
typeKey: cipherTypeKey(Number(cipher.type || 1)), typeKey: cipherTypeKey(Number(cipher.type || 1)),
sortTime: sortTimeValue(cipher), sortTime: sortTimeValue(cipher),
@@ -542,6 +556,9 @@ const folderName = useCallback((id: string | null | undefined): string => {
if (Number(cipher.type || 1) === 3) { if (Number(cipher.type || 1) === 3) {
return cardListSubtitle(cipher); return cardListSubtitle(cipher);
} }
if (Number(cipher.type || 1) === 6) return bankAccountListSubtitle(cipher);
if (Number(cipher.type || 1) === 7) return driversLicenseListSubtitle(cipher);
if (Number(cipher.type || 1) === 8) return passportListSubtitle(cipher);
return cipherTypeLabel(Number(cipher.type || 1)); return cipherTypeLabel(Number(cipher.type || 1));
}, [cipherMetaById]); }, [cipherMetaById]);
@@ -327,6 +327,55 @@ export default function VaultDetailView(props: VaultDetailViewProps) {
</div> </div>
)} )}
{props.selectedCipher.bankAccount && (
<div className="card">
<h4>{t('txt_bank_account_details')}</h4>
<div className="kv-line"><span>{t('txt_bank_name')}</span><strong>{props.selectedCipher.bankAccount.decBankName || ''}</strong></div>
<div className="kv-line"><span>{t('txt_name_on_account')}</span><strong>{props.selectedCipher.bankAccount.decNameOnAccount || ''}</strong></div>
<div className="kv-line"><span>{t('txt_account_type')}</span><strong>{props.selectedCipher.bankAccount.decAccountType || ''}</strong></div>
<div className="kv-line"><span>{t('txt_account_number')}</span><strong>{props.selectedCipher.bankAccount.decAccountNumber || ''}</strong></div>
<div className="kv-line"><span>{t('txt_routing_number')}</span><strong>{props.selectedCipher.bankAccount.decRoutingNumber || ''}</strong></div>
<div className="kv-line"><span>{t('txt_branch_number')}</span><strong>{props.selectedCipher.bankAccount.decBranchNumber || ''}</strong></div>
<div className="kv-line"><span>{t('txt_pin')}</span><strong>{props.selectedCipher.bankAccount.decPin || ''}</strong></div>
<div className="kv-line"><span>{t('txt_swift_code')}</span><strong>{props.selectedCipher.bankAccount.decSwiftCode || ''}</strong></div>
<div className="kv-line"><span>{t('txt_iban')}</span><strong>{props.selectedCipher.bankAccount.decIban || ''}</strong></div>
<div className="kv-line"><span>{t('txt_bank_contact_phone')}</span><strong>{props.selectedCipher.bankAccount.decBankContactPhone || ''}</strong></div>
</div>
)}
{props.selectedCipher.driversLicense && (
<div className="card">
<h4>{t('txt_drivers_license_details')}</h4>
<div className="kv-line"><span>{t('txt_name')}</span><strong>{[props.selectedCipher.driversLicense.decFirstName, props.selectedCipher.driversLicense.decMiddleName, props.selectedCipher.driversLicense.decLastName].filter(Boolean).join(' ')}</strong></div>
<div className="kv-line"><span>{t('txt_date_of_birth')}</span><strong>{props.selectedCipher.driversLicense.decDateOfBirth || ''}</strong></div>
<div className="kv-line"><span>{t('txt_license_number')}</span><strong>{props.selectedCipher.driversLicense.decLicenseNumber || ''}</strong></div>
<div className="kv-line"><span>{t('txt_issuing_country')}</span><strong>{props.selectedCipher.driversLicense.decIssuingCountry || ''}</strong></div>
<div className="kv-line"><span>{t('txt_issuing_state')}</span><strong>{props.selectedCipher.driversLicense.decIssuingState || ''}</strong></div>
<div className="kv-line"><span>{t('txt_issue_date')}</span><strong>{props.selectedCipher.driversLicense.decIssueDate || ''}</strong></div>
<div className="kv-line"><span>{t('txt_expiration_date')}</span><strong>{props.selectedCipher.driversLicense.decExpirationDate || ''}</strong></div>
<div className="kv-line"><span>{t('txt_issuing_authority')}</span><strong>{props.selectedCipher.driversLicense.decIssuingAuthority || ''}</strong></div>
<div className="kv-line"><span>{t('txt_license_class')}</span><strong>{props.selectedCipher.driversLicense.decLicenseClass || ''}</strong></div>
</div>
)}
{props.selectedCipher.passport && (
<div className="card">
<h4>{t('txt_passport_details')}</h4>
<div className="kv-line"><span>{t('txt_name')}</span><strong>{[props.selectedCipher.passport.decGivenName, props.selectedCipher.passport.decSurname].filter(Boolean).join(' ')}</strong></div>
<div className="kv-line"><span>{t('txt_date_of_birth')}</span><strong>{props.selectedCipher.passport.decDateOfBirth || ''}</strong></div>
<div className="kv-line"><span>{t('txt_sex')}</span><strong>{props.selectedCipher.passport.decSex || ''}</strong></div>
<div className="kv-line"><span>{t('txt_birth_place')}</span><strong>{props.selectedCipher.passport.decBirthPlace || ''}</strong></div>
<div className="kv-line"><span>{t('txt_nationality')}</span><strong>{props.selectedCipher.passport.decNationality || ''}</strong></div>
<div className="kv-line"><span>{t('txt_issuing_country')}</span><strong>{props.selectedCipher.passport.decIssuingCountry || ''}</strong></div>
<div className="kv-line"><span>{t('txt_passport_number')}</span><strong>{props.selectedCipher.passport.decPassportNumber || ''}</strong></div>
<div className="kv-line"><span>{t('txt_passport_type')}</span><strong>{props.selectedCipher.passport.decPassportType || ''}</strong></div>
<div className="kv-line"><span>{t('txt_national_id_number')}</span><strong>{props.selectedCipher.passport.decNationalIdentificationNumber || ''}</strong></div>
<div className="kv-line"><span>{t('txt_issuing_authority')}</span><strong>{props.selectedCipher.passport.decIssuingAuthority || ''}</strong></div>
<div className="kv-line"><span>{t('txt_issue_date')}</span><strong>{props.selectedCipher.passport.decIssueDate || ''}</strong></div>
<div className="kv-line"><span>{t('txt_expiration_date')}</span><strong>{props.selectedCipher.passport.decExpirationDate || ''}</strong></div>
</div>
)}
{!!(props.selectedCipher.decNotes || '').trim() && ( {!!(props.selectedCipher.decNotes || '').trim() && (
<div className="card"> <div className="card">
<h4>{t('txt_notes')}</h4> <h4>{t('txt_notes')}</h4>
@@ -67,6 +67,8 @@ interface WebsiteRowProps {
onRemove: (index: number) => void; onRemove: (index: number) => void;
} }
const TOTP_QR_IMAGE_MAX_BYTES = 8 * 1024 * 1024;
function WebsiteRow(props: WebsiteRowProps) { function WebsiteRow(props: WebsiteRowProps) {
const websiteMatchOptions = getWebsiteMatchOptions(); const websiteMatchOptions = getWebsiteMatchOptions();
@@ -208,6 +210,14 @@ export default function VaultEditor(props: VaultEditorProps) {
const handleTotpQrFile = async (file: File | null) => { const handleTotpQrFile = async (file: File | null) => {
if (!file) return; if (!file) return;
if (file.type && !file.type.startsWith('image/')) {
setTotpQrStatus(t('txt_totp_qr_invalid_image_type'));
return;
}
if (file.size > TOTP_QR_IMAGE_MAX_BYTES) {
setTotpQrStatus(t('txt_totp_qr_image_too_large'));
return;
}
setTotpQrBusy(true); setTotpQrBusy(true);
setTotpQrStatus(t('txt_totp_qr_scanning')); setTotpQrStatus(t('txt_totp_qr_scanning'));
let bitmap: ImageBitmap | null = null; let bitmap: ImageBitmap | null = null;
@@ -579,6 +589,64 @@ export default function VaultEditor(props: VaultEditorProps) {
</div> </div>
)} )}
{props.draft.type === 6 && (
<div className="card">
<h4>{t('txt_bank_account_details')}</h4>
<div className="field-grid">
<label className="field"><span>{t('txt_bank_name')}</span><input className="input" value={props.draft.bankName} onInput={(e) => props.onUpdateDraft({ bankName: (e.currentTarget as HTMLInputElement).value })} /></label>
<label className="field"><span>{t('txt_name_on_account')}</span><input className="input" value={props.draft.bankNameOnAccount} onInput={(e) => props.onUpdateDraft({ bankNameOnAccount: (e.currentTarget as HTMLInputElement).value })} /></label>
<label className="field"><span>{t('txt_account_type')}</span><input className="input" value={props.draft.bankAccountType} onInput={(e) => props.onUpdateDraft({ bankAccountType: (e.currentTarget as HTMLInputElement).value })} /></label>
<label className="field"><span>{t('txt_account_number')}</span><input className="input" value={props.draft.bankAccountNumber} onInput={(e) => props.onUpdateDraft({ bankAccountNumber: (e.currentTarget as HTMLInputElement).value })} /></label>
<label className="field"><span>{t('txt_routing_number')}</span><input className="input" value={props.draft.bankRoutingNumber} onInput={(e) => props.onUpdateDraft({ bankRoutingNumber: (e.currentTarget as HTMLInputElement).value })} /></label>
<label className="field"><span>{t('txt_branch_number')}</span><input className="input" value={props.draft.bankBranchNumber} onInput={(e) => props.onUpdateDraft({ bankBranchNumber: (e.currentTarget as HTMLInputElement).value })} /></label>
<label className="field"><span>{t('txt_pin')}</span><input className="input" value={props.draft.bankPin} onInput={(e) => props.onUpdateDraft({ bankPin: (e.currentTarget as HTMLInputElement).value })} /></label>
<label className="field"><span>{t('txt_swift_code')}</span><input className="input" value={props.draft.bankSwiftCode} onInput={(e) => props.onUpdateDraft({ bankSwiftCode: (e.currentTarget as HTMLInputElement).value })} /></label>
<label className="field"><span>{t('txt_iban')}</span><input className="input" value={props.draft.bankIban} onInput={(e) => props.onUpdateDraft({ bankIban: (e.currentTarget as HTMLInputElement).value })} /></label>
<label className="field"><span>{t('txt_bank_contact_phone')}</span><input className="input" value={props.draft.bankContactPhone} onInput={(e) => props.onUpdateDraft({ bankContactPhone: (e.currentTarget as HTMLInputElement).value })} /></label>
</div>
</div>
)}
{props.draft.type === 7 && (
<div className="card">
<h4>{t('txt_drivers_license_details')}</h4>
<div className="field-grid">
<label className="field"><span>{t('txt_first_name')}</span><input className="input" value={props.draft.licenseFirstName} onInput={(e) => props.onUpdateDraft({ licenseFirstName: (e.currentTarget as HTMLInputElement).value })} /></label>
<label className="field"><span>{t('txt_middle_name')}</span><input className="input" value={props.draft.licenseMiddleName} onInput={(e) => props.onUpdateDraft({ licenseMiddleName: (e.currentTarget as HTMLInputElement).value })} /></label>
<label className="field"><span>{t('txt_last_name')}</span><input className="input" value={props.draft.licenseLastName} onInput={(e) => props.onUpdateDraft({ licenseLastName: (e.currentTarget as HTMLInputElement).value })} /></label>
<label className="field"><span>{t('txt_date_of_birth')}</span><input className="input" value={props.draft.licenseDateOfBirth} onInput={(e) => props.onUpdateDraft({ licenseDateOfBirth: (e.currentTarget as HTMLInputElement).value })} /></label>
<label className="field"><span>{t('txt_license_number')}</span><input className="input" value={props.draft.licenseNumber} onInput={(e) => props.onUpdateDraft({ licenseNumber: (e.currentTarget as HTMLInputElement).value })} /></label>
<label className="field"><span>{t('txt_issuing_country')}</span><input className="input" value={props.draft.licenseIssuingCountry} onInput={(e) => props.onUpdateDraft({ licenseIssuingCountry: (e.currentTarget as HTMLInputElement).value })} /></label>
<label className="field"><span>{t('txt_issuing_state')}</span><input className="input" value={props.draft.licenseIssuingState} onInput={(e) => props.onUpdateDraft({ licenseIssuingState: (e.currentTarget as HTMLInputElement).value })} /></label>
<label className="field"><span>{t('txt_issue_date')}</span><input className="input" value={props.draft.licenseIssueDate} onInput={(e) => props.onUpdateDraft({ licenseIssueDate: (e.currentTarget as HTMLInputElement).value })} /></label>
<label className="field"><span>{t('txt_expiration_date')}</span><input className="input" value={props.draft.licenseExpirationDate} onInput={(e) => props.onUpdateDraft({ licenseExpirationDate: (e.currentTarget as HTMLInputElement).value })} /></label>
<label className="field"><span>{t('txt_issuing_authority')}</span><input className="input" value={props.draft.licenseIssuingAuthority} onInput={(e) => props.onUpdateDraft({ licenseIssuingAuthority: (e.currentTarget as HTMLInputElement).value })} /></label>
<label className="field"><span>{t('txt_license_class')}</span><input className="input" value={props.draft.licenseClass} onInput={(e) => props.onUpdateDraft({ licenseClass: (e.currentTarget as HTMLInputElement).value })} /></label>
</div>
</div>
)}
{props.draft.type === 8 && (
<div className="card">
<h4>{t('txt_passport_details')}</h4>
<div className="field-grid">
<label className="field"><span>{t('txt_surname')}</span><input className="input" value={props.draft.passportSurname} onInput={(e) => props.onUpdateDraft({ passportSurname: (e.currentTarget as HTMLInputElement).value })} /></label>
<label className="field"><span>{t('txt_given_name')}</span><input className="input" value={props.draft.passportGivenName} onInput={(e) => props.onUpdateDraft({ passportGivenName: (e.currentTarget as HTMLInputElement).value })} /></label>
<label className="field"><span>{t('txt_date_of_birth')}</span><input className="input" value={props.draft.passportDateOfBirth} onInput={(e) => props.onUpdateDraft({ passportDateOfBirth: (e.currentTarget as HTMLInputElement).value })} /></label>
<label className="field"><span>{t('txt_sex')}</span><input className="input" value={props.draft.passportSex} onInput={(e) => props.onUpdateDraft({ passportSex: (e.currentTarget as HTMLInputElement).value })} /></label>
<label className="field"><span>{t('txt_birth_place')}</span><input className="input" value={props.draft.passportBirthPlace} onInput={(e) => props.onUpdateDraft({ passportBirthPlace: (e.currentTarget as HTMLInputElement).value })} /></label>
<label className="field"><span>{t('txt_nationality')}</span><input className="input" value={props.draft.passportNationality} onInput={(e) => props.onUpdateDraft({ passportNationality: (e.currentTarget as HTMLInputElement).value })} /></label>
<label className="field"><span>{t('txt_issuing_country')}</span><input className="input" value={props.draft.passportIssuingCountry} onInput={(e) => props.onUpdateDraft({ passportIssuingCountry: (e.currentTarget as HTMLInputElement).value })} /></label>
<label className="field"><span>{t('txt_passport_number')}</span><input className="input" value={props.draft.passportNumber} onInput={(e) => props.onUpdateDraft({ passportNumber: (e.currentTarget as HTMLInputElement).value })} /></label>
<label className="field"><span>{t('txt_passport_type')}</span><input className="input" value={props.draft.passportType} onInput={(e) => props.onUpdateDraft({ passportType: (e.currentTarget as HTMLInputElement).value })} /></label>
<label className="field"><span>{t('txt_national_id_number')}</span><input className="input" value={props.draft.passportNationalIdentificationNumber} onInput={(e) => props.onUpdateDraft({ passportNationalIdentificationNumber: (e.currentTarget as HTMLInputElement).value })} /></label>
<label className="field"><span>{t('txt_issuing_authority')}</span><input className="input" value={props.draft.passportIssuingAuthority} onInput={(e) => props.onUpdateDraft({ passportIssuingAuthority: (e.currentTarget as HTMLInputElement).value })} /></label>
<label className="field"><span>{t('txt_issue_date')}</span><input className="input" value={props.draft.passportIssueDate} onInput={(e) => props.onUpdateDraft({ passportIssueDate: (e.currentTarget as HTMLInputElement).value })} /></label>
<label className="field"><span>{t('txt_expiration_date')}</span><input className="input" value={props.draft.passportExpirationDate} onInput={(e) => props.onUpdateDraft({ passportExpirationDate: (e.currentTarget as HTMLInputElement).value })} /></label>
</div>
</div>
)}
<div className="card"> <div className="card">
<div className="section-head attachment-head"> <div className="section-head attachment-head">
<h4>{t('txt_attachments')}</h4> <h4>{t('txt_attachments')}</h4>
@@ -3,6 +3,7 @@ import type { RefObject } from 'preact';
import { import {
Archive, Archive,
ArrowUpDown, ArrowUpDown,
BookUser,
Check, Check,
Copy, Copy,
CreditCard, CreditCard,
@@ -10,7 +11,9 @@ import {
FolderPlus, FolderPlus,
FolderX, FolderX,
Globe, Globe,
IdCard,
KeyRound, KeyRound,
Landmark,
LayoutGrid, LayoutGrid,
Pencil, Pencil,
ShieldUser, ShieldUser,
@@ -117,9 +120,18 @@ export default function VaultSidebar(props: VaultSidebarProps) {
<button type="button" className={`tree-btn ${props.sidebarFilter.kind === 'type' && props.sidebarFilter.value === 'card' ? 'active' : ''}`} onClick={() => props.onChangeFilter({ kind: 'type', value: 'card' })}> <button type="button" className={`tree-btn ${props.sidebarFilter.kind === 'type' && props.sidebarFilter.value === 'card' ? 'active' : ''}`} onClick={() => props.onChangeFilter({ kind: 'type', value: 'card' })}>
<CreditCard size={14} className="tree-icon" /> <span className="tree-label">{t('txt_card')}</span> <CreditCard size={14} className="tree-icon" /> <span className="tree-label">{t('txt_card')}</span>
</button> </button>
<button type="button" className={`tree-btn ${props.sidebarFilter.kind === 'type' && props.sidebarFilter.value === 'bank' ? 'active' : ''}`} onClick={() => props.onChangeFilter({ kind: 'type', value: 'bank' })}>
<Landmark size={14} className="tree-icon" /> <span className="tree-label">{t('txt_bank_account')}</span>
</button>
<button type="button" className={`tree-btn ${props.sidebarFilter.kind === 'type' && props.sidebarFilter.value === 'identity' ? 'active' : ''}`} onClick={() => props.onChangeFilter({ kind: 'type', value: 'identity' })}> <button type="button" className={`tree-btn ${props.sidebarFilter.kind === 'type' && props.sidebarFilter.value === 'identity' ? 'active' : ''}`} onClick={() => props.onChangeFilter({ kind: 'type', value: 'identity' })}>
<ShieldUser size={14} className="tree-icon" /> <span className="tree-label">{t('txt_identity')}</span> <ShieldUser size={14} className="tree-icon" /> <span className="tree-label">{t('txt_identity')}</span>
</button> </button>
<button type="button" className={`tree-btn ${props.sidebarFilter.kind === 'type' && props.sidebarFilter.value === 'license' ? 'active' : ''}`} onClick={() => props.onChangeFilter({ kind: 'type', value: 'license' })}>
<IdCard size={14} className="tree-icon" /> <span className="tree-label">{t('txt_drivers_license')}</span>
</button>
<button type="button" className={`tree-btn ${props.sidebarFilter.kind === 'type' && props.sidebarFilter.value === 'passport' ? 'active' : ''}`} onClick={() => props.onChangeFilter({ kind: 'type', value: 'passport' })}>
<BookUser size={14} className="tree-icon" /> <span className="tree-label">{t('txt_passport')}</span>
</button>
<button type="button" className={`tree-btn ${props.sidebarFilter.kind === 'type' && props.sidebarFilter.value === 'note' ? 'active' : ''}`} onClick={() => props.onChangeFilter({ kind: 'type', value: 'note' })}> <button type="button" className={`tree-btn ${props.sidebarFilter.kind === 'type' && props.sidebarFilter.value === 'note' ? 'active' : ''}`} onClick={() => props.onChangeFilter({ kind: 'type', value: 'note' })}>
<StickyNote size={14} className="tree-icon" /> <span className="tree-label">{t('txt_note')}</span> <StickyNote size={14} className="tree-icon" /> <span className="tree-label">{t('txt_note')}</span>
</button> </button>
@@ -1,9 +1,12 @@
import { useMemo } from 'preact/hooks'; import { useMemo } from 'preact/hooks';
import { import {
BookUser,
CreditCard, CreditCard,
FileKey2, FileKey2,
Globe, Globe,
IdCard,
KeyRound, KeyRound,
Landmark,
ShieldUser, ShieldUser,
StickyNote, StickyNote,
} from 'lucide-preact'; } from 'lucide-preact';
@@ -14,7 +17,7 @@ import { firstCipherUri, hostFromUri, websiteIconUrl } from '@/lib/website-utils
import { normalizeEquivalentDomain } from '@shared/domain-normalize'; import { normalizeEquivalentDomain } from '@shared/domain-normalize';
import WebsiteIcon from './WebsiteIcon'; import WebsiteIcon from './WebsiteIcon';
export type TypeFilter = 'login' | 'card' | 'identity' | 'note' | 'ssh'; export type TypeFilter = 'login' | 'card' | 'identity' | 'note' | 'ssh' | 'bank' | 'license' | 'passport';
export type VaultSortMode = 'edited' | 'created' | 'name'; export type VaultSortMode = 'edited' | 'created' | 'name';
export type DuplicateDetectionMode = 'exact' | 'login-site' | 'login-credentials' | 'password'; export type DuplicateDetectionMode = 'exact' | 'login-site' | 'login-credentials' | 'password';
export type SidebarFilter = export type SidebarFilter =
@@ -98,6 +101,32 @@ export function cardListSubtitle(cipher: Cipher): string {
return cipherTypeLabel(3); return cipherTypeLabel(3);
} }
export function bankAccountListSubtitle(cipher: Cipher): string {
const bankName = valueOrFallback(cipher.bankAccount?.decBankName ?? cipher.bankAccount?.bankName).trim();
const accountType = valueOrFallback(cipher.bankAccount?.decAccountType ?? cipher.bankAccount?.accountType).trim();
const accountNumber = valueOrFallback(cipher.bankAccount?.decAccountNumber ?? cipher.bankAccount?.accountNumber).replace(/\D/g, '');
const last4 = accountNumber.length >= 4 ? accountNumber.slice(-4) : '';
return [bankName, accountType, last4 ? `*${last4}` : ''].filter(Boolean).join(', ') || cipherTypeLabel(6);
}
export function driversLicenseListSubtitle(cipher: Cipher): string {
const licenseNumber = valueOrFallback(cipher.driversLicense?.decLicenseNumber ?? cipher.driversLicense?.licenseNumber).trim();
const name = [
valueOrFallback(cipher.driversLicense?.decFirstName ?? cipher.driversLicense?.firstName).trim(),
valueOrFallback(cipher.driversLicense?.decLastName ?? cipher.driversLicense?.lastName).trim(),
].filter(Boolean).join(' ');
return licenseNumber || name || cipherTypeLabel(7);
}
export function passportListSubtitle(cipher: Cipher): string {
const passportNumber = valueOrFallback(cipher.passport?.decPassportNumber ?? cipher.passport?.passportNumber).trim();
const name = [
valueOrFallback(cipher.passport?.decGivenName ?? cipher.passport?.givenName).trim(),
valueOrFallback(cipher.passport?.decSurname ?? cipher.passport?.surname).trim(),
].filter(Boolean).join(' ');
return passportNumber || name || cipherTypeLabel(8);
}
export function CardBrandIcon({ brand }: { brand?: string | null }) { export function CardBrandIcon({ brand }: { brand?: string | null }) {
const display = displayCardBrand(brand); const display = displayCardBrand(brand);
const key = display.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'generic'; const key = display.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || 'generic';
@@ -118,7 +147,10 @@ export function getCreateTypeOptions(): TypeOption[] {
return [ return [
{ type: 1, label: t('txt_login') }, { type: 1, label: t('txt_login') },
{ type: 3, label: t('txt_card') }, { type: 3, label: t('txt_card') },
{ type: 6, label: t('txt_bank_account') },
{ type: 4, label: t('txt_identity') }, { type: 4, label: t('txt_identity') },
{ type: 7, label: t('txt_drivers_license') },
{ type: 8, label: t('txt_passport') },
{ type: 2, label: t('txt_note') }, { type: 2, label: t('txt_note') },
{ type: 5, label: t('txt_ssh_key') }, { type: 5, label: t('txt_ssh_key') },
]; ];
@@ -185,6 +217,9 @@ export function CreateTypeIcon({ type }: { type: number }) {
if (type === 4) return <ShieldUser size={15} />; if (type === 4) return <ShieldUser size={15} />;
if (type === 2) return <StickyNote size={15} />; if (type === 2) return <StickyNote size={15} />;
if (type === 5) return <KeyRound size={15} />; if (type === 5) return <KeyRound size={15} />;
if (type === 6) return <Landmark size={15} />;
if (type === 7) return <IdCard size={15} />;
if (type === 8) return <BookUser size={15} />;
return <FileKey2 size={15} />; return <FileKey2 size={15} />;
} }
@@ -193,7 +228,11 @@ export function cipherTypeKey(type: number): TypeFilter {
if (type === 3) return 'card'; if (type === 3) return 'card';
if (type === 4) return 'identity'; if (type === 4) return 'identity';
if (type === 2) return 'note'; if (type === 2) return 'note';
return 'ssh'; if (type === 5) return 'ssh';
if (type === 6) return 'bank';
if (type === 7) return 'license';
if (type === 8) return 'passport';
return 'note';
} }
function cipherDeletedValue(cipher: Cipher): boolean { function cipherDeletedValue(cipher: Cipher): boolean {
@@ -230,6 +269,9 @@ export function cipherTypeLabel(type: number): string {
if (type === 4) return t('txt_identity'); if (type === 4) return t('txt_identity');
if (type === 2) return t('txt_secure_note'); if (type === 2) return t('txt_secure_note');
if (type === 5) return t('txt_ssh_key'); if (type === 5) return t('txt_ssh_key');
if (type === 6) return t('txt_bank_account');
if (type === 7) return t('txt_drivers_license');
if (type === 8) return t('txt_passport');
return t('txt_item'); return t('txt_item');
} }
@@ -239,6 +281,9 @@ export function TypeIcon({ type }: { type: number }) {
if (type === 4) return <ShieldUser size={18} />; if (type === 4) return <ShieldUser size={18} />;
if (type === 2) return <StickyNote size={18} />; if (type === 2) return <StickyNote size={18} />;
if (type === 5) return <KeyRound size={18} />; if (type === 5) return <KeyRound size={18} />;
if (type === 6) return <Landmark size={18} />;
if (type === 7) return <IdCard size={18} />;
if (type === 8) return <BookUser size={18} />;
return <FileKey2 size={18} />; return <FileKey2 size={18} />;
} }
@@ -355,6 +400,52 @@ export function buildCipherDuplicateSignature(cipher: Cipher): string {
fingerprint: valueOrFallback(cipher.sshKey.decFingerprint ?? cipher.sshKey.keyFingerprint ?? cipher.sshKey.fingerprint), fingerprint: valueOrFallback(cipher.sshKey.decFingerprint ?? cipher.sshKey.keyFingerprint ?? cipher.sshKey.fingerprint),
} }
: null, : null,
bankAccount: cipher.bankAccount
? {
bankName: valueOrFallback(cipher.bankAccount.decBankName ?? cipher.bankAccount.bankName),
nameOnAccount: valueOrFallback(cipher.bankAccount.decNameOnAccount ?? cipher.bankAccount.nameOnAccount),
accountType: valueOrFallback(cipher.bankAccount.decAccountType ?? cipher.bankAccount.accountType),
accountNumber: valueOrFallback(cipher.bankAccount.decAccountNumber ?? cipher.bankAccount.accountNumber),
routingNumber: valueOrFallback(cipher.bankAccount.decRoutingNumber ?? cipher.bankAccount.routingNumber),
branchNumber: valueOrFallback(cipher.bankAccount.decBranchNumber ?? cipher.bankAccount.branchNumber),
pin: valueOrFallback(cipher.bankAccount.decPin ?? cipher.bankAccount.pin),
swiftCode: valueOrFallback(cipher.bankAccount.decSwiftCode ?? cipher.bankAccount.swiftCode),
iban: valueOrFallback(cipher.bankAccount.decIban ?? cipher.bankAccount.iban),
bankContactPhone: valueOrFallback(cipher.bankAccount.decBankContactPhone ?? cipher.bankAccount.bankContactPhone),
}
: null,
driversLicense: cipher.driversLicense
? {
firstName: valueOrFallback(cipher.driversLicense.decFirstName ?? cipher.driversLicense.firstName),
middleName: valueOrFallback(cipher.driversLicense.decMiddleName ?? cipher.driversLicense.middleName),
lastName: valueOrFallback(cipher.driversLicense.decLastName ?? cipher.driversLicense.lastName),
dateOfBirth: valueOrFallback(cipher.driversLicense.decDateOfBirth ?? cipher.driversLicense.dateOfBirth),
licenseNumber: valueOrFallback(cipher.driversLicense.decLicenseNumber ?? cipher.driversLicense.licenseNumber),
issuingCountry: valueOrFallback(cipher.driversLicense.decIssuingCountry ?? cipher.driversLicense.issuingCountry),
issuingState: valueOrFallback(cipher.driversLicense.decIssuingState ?? cipher.driversLicense.issuingState),
issueDate: valueOrFallback(cipher.driversLicense.decIssueDate ?? cipher.driversLicense.issueDate),
expirationDate: valueOrFallback(cipher.driversLicense.decExpirationDate ?? cipher.driversLicense.expirationDate),
issuingAuthority: valueOrFallback(cipher.driversLicense.decIssuingAuthority ?? cipher.driversLicense.issuingAuthority),
licenseClass: valueOrFallback(cipher.driversLicense.decLicenseClass ?? cipher.driversLicense.licenseClass),
}
: null,
passport: cipher.passport
? {
surname: valueOrFallback(cipher.passport.decSurname ?? cipher.passport.surname),
givenName: valueOrFallback(cipher.passport.decGivenName ?? cipher.passport.givenName),
dateOfBirth: valueOrFallback(cipher.passport.decDateOfBirth ?? cipher.passport.dateOfBirth),
sex: valueOrFallback(cipher.passport.decSex ?? cipher.passport.sex),
birthPlace: valueOrFallback(cipher.passport.decBirthPlace ?? cipher.passport.birthPlace),
nationality: valueOrFallback(cipher.passport.decNationality ?? cipher.passport.nationality),
issuingCountry: valueOrFallback(cipher.passport.decIssuingCountry ?? cipher.passport.issuingCountry),
passportNumber: valueOrFallback(cipher.passport.decPassportNumber ?? cipher.passport.passportNumber),
passportType: valueOrFallback(cipher.passport.decPassportType ?? cipher.passport.passportType),
nationalIdentificationNumber: valueOrFallback(cipher.passport.decNationalIdentificationNumber ?? cipher.passport.nationalIdentificationNumber),
issuingAuthority: valueOrFallback(cipher.passport.decIssuingAuthority ?? cipher.passport.issuingAuthority),
issueDate: valueOrFallback(cipher.passport.decIssueDate ?? cipher.passport.issueDate),
expirationDate: valueOrFallback(cipher.passport.decExpirationDate ?? cipher.passport.expirationDate),
}
: null,
secureNoteType: cipher.secureNote?.type ?? null, secureNoteType: cipher.secureNote?.type ?? null,
fields: (cipher.fields || []).map((field) => ({ fields: (cipher.fields || []).map((field) => ({
type: field.type ?? null, type: field.type ?? null,
@@ -427,6 +518,40 @@ export function createEmptyDraft(type: number): VaultDraft {
sshPrivateKey: '', sshPrivateKey: '',
sshPublicKey: '', sshPublicKey: '',
sshFingerprint: '', sshFingerprint: '',
bankName: '',
bankNameOnAccount: '',
bankAccountType: '',
bankAccountNumber: '',
bankRoutingNumber: '',
bankBranchNumber: '',
bankPin: '',
bankSwiftCode: '',
bankIban: '',
bankContactPhone: '',
licenseFirstName: '',
licenseMiddleName: '',
licenseLastName: '',
licenseDateOfBirth: '',
licenseNumber: '',
licenseIssuingCountry: '',
licenseIssuingState: '',
licenseIssueDate: '',
licenseExpirationDate: '',
licenseIssuingAuthority: '',
licenseClass: '',
passportSurname: '',
passportGivenName: '',
passportDateOfBirth: '',
passportSex: '',
passportBirthPlace: '',
passportNationality: '',
passportIssuingCountry: '',
passportNumber: '',
passportType: '',
passportNationalIdentificationNumber: '',
passportIssuingAuthority: '',
passportIssueDate: '',
passportExpirationDate: '',
customFields: [], customFields: [],
}; };
} }
@@ -490,6 +615,46 @@ export function draftFromCipher(cipher: Cipher): VaultDraft {
draft.sshPublicKey = cipher.sshKey.decPublicKey || ''; draft.sshPublicKey = cipher.sshKey.decPublicKey || '';
draft.sshFingerprint = cipher.sshKey.decFingerprint || ''; draft.sshFingerprint = cipher.sshKey.decFingerprint || '';
} }
if (cipher.bankAccount) {
draft.bankName = cipher.bankAccount.decBankName || '';
draft.bankNameOnAccount = cipher.bankAccount.decNameOnAccount || '';
draft.bankAccountType = cipher.bankAccount.decAccountType || '';
draft.bankAccountNumber = cipher.bankAccount.decAccountNumber || '';
draft.bankRoutingNumber = cipher.bankAccount.decRoutingNumber || '';
draft.bankBranchNumber = cipher.bankAccount.decBranchNumber || '';
draft.bankPin = cipher.bankAccount.decPin || '';
draft.bankSwiftCode = cipher.bankAccount.decSwiftCode || '';
draft.bankIban = cipher.bankAccount.decIban || '';
draft.bankContactPhone = cipher.bankAccount.decBankContactPhone || '';
}
if (cipher.driversLicense) {
draft.licenseFirstName = cipher.driversLicense.decFirstName || '';
draft.licenseMiddleName = cipher.driversLicense.decMiddleName || '';
draft.licenseLastName = cipher.driversLicense.decLastName || '';
draft.licenseDateOfBirth = cipher.driversLicense.decDateOfBirth || '';
draft.licenseNumber = cipher.driversLicense.decLicenseNumber || '';
draft.licenseIssuingCountry = cipher.driversLicense.decIssuingCountry || '';
draft.licenseIssuingState = cipher.driversLicense.decIssuingState || '';
draft.licenseIssueDate = cipher.driversLicense.decIssueDate || '';
draft.licenseExpirationDate = cipher.driversLicense.decExpirationDate || '';
draft.licenseIssuingAuthority = cipher.driversLicense.decIssuingAuthority || '';
draft.licenseClass = cipher.driversLicense.decLicenseClass || '';
}
if (cipher.passport) {
draft.passportSurname = cipher.passport.decSurname || '';
draft.passportGivenName = cipher.passport.decGivenName || '';
draft.passportDateOfBirth = cipher.passport.decDateOfBirth || '';
draft.passportSex = cipher.passport.decSex || '';
draft.passportBirthPlace = cipher.passport.decBirthPlace || '';
draft.passportNationality = cipher.passport.decNationality || '';
draft.passportIssuingCountry = cipher.passport.decIssuingCountry || '';
draft.passportNumber = cipher.passport.decPassportNumber || '';
draft.passportType = cipher.passport.decPassportType || '';
draft.passportNationalIdentificationNumber = cipher.passport.decNationalIdentificationNumber || '';
draft.passportIssuingAuthority = cipher.passport.decIssuingAuthority || '';
draft.passportIssueDate = cipher.passport.decIssueDate || '';
draft.passportExpirationDate = cipher.passport.decExpirationDate || '';
}
draft.customFields = (cipher.fields || []).map((field) => ({ draft.customFields = (cipher.fields || []).map((field) => ({
type: parseFieldType(field.type), type: parseFieldType(field.type),
label: field.decName || '', label: field.decName || '',
+2 -2
View File
@@ -86,8 +86,8 @@ export default function useBackupActions(options: UseBackupActionsOptions) {
return inspectRemoteBackupIntegrity(authedFetch, destinationId, path); return inspectRemoteBackupIntegrity(authedFetch, destinationId, path);
}, },
async deleteRemoteBackup(destinationId: string, path: string) { async deleteRemoteBackup(masterPasswordHash: string, destinationId: string, path: string) {
await deleteRemoteBackup(authedFetch, destinationId, path); await deleteRemoteBackup(authedFetch, masterPasswordHash, destinationId, path);
}, },
async restoreRemoteBackup(masterPasswordHash: string, destinationId: string, path: string, replaceExisting: boolean = false) { async restoreRemoteBackup(masterPasswordHash: string, destinationId: string, path: string, replaceExisting: boolean = false) {
+21 -8
View File
@@ -89,11 +89,29 @@ function clearRememberTwoFactorToken(): void {
localStorage.removeItem(TOTP_REMEMBER_TOKEN_KEY); localStorage.removeItem(TOTP_REMEMBER_TOKEN_KEY);
} }
function hasTwoFactorChallenge(error: TokenError): boolean {
const providers = error.TwoFactorProviders ?? error.CustomResponse?.TwoFactorProviders;
const providers2 = error.TwoFactorProviders2 ?? error.CustomResponse?.TwoFactorProviders2;
if (Array.isArray(providers)) return providers.length > 0;
if (providers && typeof providers === 'object') return Object.keys(providers as Record<string, unknown>).length > 0;
if (Array.isArray(providers2)) return providers2.length > 0;
if (providers2 && typeof providers2 === 'object') return Object.keys(providers2 as Record<string, unknown>).length > 0;
return providers != null || providers2 != null;
}
export function loadSession(): SessionState | null { export function loadSession(): SessionState | null {
try { try {
const raw = localStorage.getItem(SESSION_KEY); const raw = localStorage.getItem(SESSION_KEY);
if (!raw) return null; if (!raw) return null;
const parsed = JSON.parse(raw) as Partial<SessionState> & Partial<PersistedSessionState>; const parsed = JSON.parse(raw) as Partial<SessionState> & Partial<PersistedSessionState>;
if (parsed.email && (parsed.accessToken || parsed.refreshToken)) {
const authMode = parsed.authMode === 'web-cookie' ? 'web-cookie' : 'token';
saveSession({ email: parsed.email, authMode });
return {
email: parsed.email,
authMode,
};
}
if (parsed.authMode === 'web-cookie' && parsed.email) { if (parsed.authMode === 'web-cookie' && parsed.email) {
return { return {
email: parsed.email, email: parsed.email,
@@ -106,13 +124,7 @@ export function loadSession(): SessionState | null {
authMode: 'token', authMode: 'token',
}; };
} }
if (!parsed.accessToken || !parsed.refreshToken || !parsed.email) return null; return null;
return {
accessToken: parsed.accessToken,
refreshToken: parsed.refreshToken,
email: parsed.email,
authMode: 'token',
};
} catch { } catch {
return null; return null;
} }
@@ -280,7 +292,7 @@ export async function loginWithPassword(
const json = (await parseJson<TokenSuccess & TokenError>(resp)) || {}; const json = (await parseJson<TokenSuccess & TokenError>(resp)) || {};
if (resp.ok) { if (resp.ok) {
saveRememberTwoFactorToken((json as TokenSuccess).TwoFactorToken); saveRememberTwoFactorToken((json as TokenSuccess).TwoFactorToken);
} else if (rememberedToken) { } else if (rememberedToken && hasTwoFactorChallenge(json)) {
clearRememberTwoFactorToken(); clearRememberTwoFactorToken();
} }
if (!resp.ok) return json; if (!resp.ok) return json;
@@ -390,6 +402,7 @@ export async function revokeCurrentSession(session: SessionState | null): Promis
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/x-www-form-urlencoded', 'Content-Type': 'application/x-www-form-urlencoded',
...(session?.accessToken ? { Authorization: `Bearer ${session.accessToken}` } : {}),
...(session?.authMode === 'web-cookie' ? { [WEB_SESSION_HEADER]: '1' } : {}), ...(session?.authMode === 'web-cookie' ? { [WEB_SESSION_HEADER]: '1' } : {}),
}, },
body: body.toString(), body: body.toString(),
+6 -4
View File
@@ -403,13 +403,15 @@ export async function verifyBackupFileIntegrity(bytes: Uint8Array, fileName: str
export async function deleteRemoteBackup( export async function deleteRemoteBackup(
authedFetch: AuthedFetch, authedFetch: AuthedFetch,
masterPasswordHash: string,
destinationId: string, destinationId: string,
path: string path: string
): Promise<void> { ): Promise<void> {
const params = new URLSearchParams(); const resp = await authedFetch('/api/admin/backup/remote/file', {
params.set('destinationId', destinationId); method: 'DELETE',
params.set('path', path); headers: { 'Content-Type': 'application/json' },
const resp = await authedFetch(`/api/admin/backup/remote/file?${params.toString()}`, { method: 'DELETE' }); body: JSON.stringify({ destinationId, path, masterPasswordHash }),
});
if (!resp.ok) throw new Error(await parseErrorMessage(resp, t('txt_backup_remote_delete_failed'))); if (!resp.ok) throw new Error(await parseErrorMessage(resp, t('txt_backup_remote_delete_failed')));
} }
+198
View File
@@ -513,6 +513,30 @@ async function encryptTextValue(value: string, enc: Uint8Array, mac: Uint8Array)
return encryptBw(new TextEncoder().encode(s), enc, mac); return encryptBw(new TextEncoder().encode(s), enc, mac);
} }
function stripDecodedObjectFields(value: unknown): Record<string, unknown> {
if (!value || typeof value !== 'object' || Array.isArray(value)) return {};
const out: Record<string, unknown> = {};
for (const [key, item] of Object.entries(value)) {
if (/^dec[A-Z]/.test(key)) continue;
out[key] = item;
}
return out;
}
async function encryptObjectFields(
existing: unknown,
entries: Array<[string, string]>,
draft: VaultDraft,
enc: Uint8Array,
mac: Uint8Array
): Promise<Record<string, unknown>> {
const out = stripDecodedObjectFields(existing);
for (const [fieldName, draftKey] of entries) {
out[fieldName] = await encryptTextValue(String((draft as unknown as Record<string, unknown>)[draftKey] || ''), enc, mac);
}
return out;
}
async function encryptPasswordHistory( async function encryptPasswordHistory(
entries: CipherPasswordHistoryEntry[] | null | undefined, entries: CipherPasswordHistoryEntry[] | null | undefined,
enc: Uint8Array, enc: Uint8Array,
@@ -587,6 +611,40 @@ function draftFromDecryptedCipher(cipher: Cipher): VaultDraft {
sshPrivateKey: '', sshPrivateKey: '',
sshPublicKey: '', sshPublicKey: '',
sshFingerprint: '', sshFingerprint: '',
bankName: '',
bankNameOnAccount: '',
bankAccountType: '',
bankAccountNumber: '',
bankRoutingNumber: '',
bankBranchNumber: '',
bankPin: '',
bankSwiftCode: '',
bankIban: '',
bankContactPhone: '',
licenseFirstName: '',
licenseMiddleName: '',
licenseLastName: '',
licenseDateOfBirth: '',
licenseNumber: '',
licenseIssuingCountry: '',
licenseIssuingState: '',
licenseIssueDate: '',
licenseExpirationDate: '',
licenseIssuingAuthority: '',
licenseClass: '',
passportSurname: '',
passportGivenName: '',
passportDateOfBirth: '',
passportSex: '',
passportBirthPlace: '',
passportNationality: '',
passportIssuingCountry: '',
passportNumber: '',
passportType: '',
passportNationalIdentificationNumber: '',
passportIssuingAuthority: '',
passportIssueDate: '',
passportExpirationDate: '',
customFields: [], customFields: [],
}; };
@@ -662,6 +720,43 @@ function draftFromDecryptedCipher(cipher: Cipher): VaultDraft {
cipher.sshKey.decFingerprint, cipher.sshKey.decFingerprint,
cipher.sshKey.keyFingerprint || cipher.sshKey.fingerprint cipher.sshKey.keyFingerprint || cipher.sshKey.fingerprint
); );
} else if (type === 6 && cipher.bankAccount) {
draft.bankName = plainCipherValue(cipher.bankAccount.decBankName, cipher.bankAccount.bankName);
draft.bankNameOnAccount = plainCipherValue(cipher.bankAccount.decNameOnAccount, cipher.bankAccount.nameOnAccount);
draft.bankAccountType = plainCipherValue(cipher.bankAccount.decAccountType, cipher.bankAccount.accountType);
draft.bankAccountNumber = plainCipherValue(cipher.bankAccount.decAccountNumber, cipher.bankAccount.accountNumber);
draft.bankRoutingNumber = plainCipherValue(cipher.bankAccount.decRoutingNumber, cipher.bankAccount.routingNumber);
draft.bankBranchNumber = plainCipherValue(cipher.bankAccount.decBranchNumber, cipher.bankAccount.branchNumber);
draft.bankPin = plainCipherValue(cipher.bankAccount.decPin, cipher.bankAccount.pin);
draft.bankSwiftCode = plainCipherValue(cipher.bankAccount.decSwiftCode, cipher.bankAccount.swiftCode);
draft.bankIban = plainCipherValue(cipher.bankAccount.decIban, cipher.bankAccount.iban);
draft.bankContactPhone = plainCipherValue(cipher.bankAccount.decBankContactPhone, cipher.bankAccount.bankContactPhone);
} else if (type === 7 && cipher.driversLicense) {
draft.licenseFirstName = plainCipherValue(cipher.driversLicense.decFirstName, cipher.driversLicense.firstName);
draft.licenseMiddleName = plainCipherValue(cipher.driversLicense.decMiddleName, cipher.driversLicense.middleName);
draft.licenseLastName = plainCipherValue(cipher.driversLicense.decLastName, cipher.driversLicense.lastName);
draft.licenseDateOfBirth = plainCipherValue(cipher.driversLicense.decDateOfBirth, cipher.driversLicense.dateOfBirth);
draft.licenseNumber = plainCipherValue(cipher.driversLicense.decLicenseNumber, cipher.driversLicense.licenseNumber);
draft.licenseIssuingCountry = plainCipherValue(cipher.driversLicense.decIssuingCountry, cipher.driversLicense.issuingCountry);
draft.licenseIssuingState = plainCipherValue(cipher.driversLicense.decIssuingState, cipher.driversLicense.issuingState);
draft.licenseIssueDate = plainCipherValue(cipher.driversLicense.decIssueDate, cipher.driversLicense.issueDate);
draft.licenseExpirationDate = plainCipherValue(cipher.driversLicense.decExpirationDate, cipher.driversLicense.expirationDate);
draft.licenseIssuingAuthority = plainCipherValue(cipher.driversLicense.decIssuingAuthority, cipher.driversLicense.issuingAuthority);
draft.licenseClass = plainCipherValue(cipher.driversLicense.decLicenseClass, cipher.driversLicense.licenseClass);
} else if (type === 8 && cipher.passport) {
draft.passportSurname = plainCipherValue(cipher.passport.decSurname, cipher.passport.surname);
draft.passportGivenName = plainCipherValue(cipher.passport.decGivenName, cipher.passport.givenName);
draft.passportDateOfBirth = plainCipherValue(cipher.passport.decDateOfBirth, cipher.passport.dateOfBirth);
draft.passportSex = plainCipherValue(cipher.passport.decSex, cipher.passport.sex);
draft.passportBirthPlace = plainCipherValue(cipher.passport.decBirthPlace, cipher.passport.birthPlace);
draft.passportNationality = plainCipherValue(cipher.passport.decNationality, cipher.passport.nationality);
draft.passportIssuingCountry = plainCipherValue(cipher.passport.decIssuingCountry, cipher.passport.issuingCountry);
draft.passportNumber = plainCipherValue(cipher.passport.decPassportNumber, cipher.passport.passportNumber);
draft.passportType = plainCipherValue(cipher.passport.decPassportType, cipher.passport.passportType);
draft.passportNationalIdentificationNumber = plainCipherValue(cipher.passport.decNationalIdentificationNumber, cipher.passport.nationalIdentificationNumber);
draft.passportIssuingAuthority = plainCipherValue(cipher.passport.decIssuingAuthority, cipher.passport.issuingAuthority);
draft.passportIssueDate = plainCipherValue(cipher.passport.decIssueDate, cipher.passport.issueDate);
draft.passportExpirationDate = plainCipherValue(cipher.passport.decExpirationDate, cipher.passport.expirationDate);
} }
return draft; return draft;
@@ -983,6 +1078,10 @@ function getCipherKeyMismatchProbes(cipher: Cipher): string[] {
cipher.identity?.title, cipher.identity?.title,
cipher.identity?.firstName, cipher.identity?.firstName,
cipher.sshKey?.privateKey, cipher.sshKey?.privateKey,
cipher.bankAccount?.bankName,
cipher.bankAccount?.accountNumber,
cipher.driversLicense?.licenseNumber,
cipher.passport?.passportNumber,
...(cipher.fields || []).flatMap((field) => [field.name, field.value]), ...(cipher.fields || []).flatMap((field) => [field.name, field.value]),
]; ];
const probes: string[] = []; const probes: string[] = [];
@@ -1053,6 +1152,40 @@ function hasUnresolvedEncryptedFields(cipher: Cipher): boolean {
[cipher.sshKey?.privateKey, cipher.sshKey?.decPrivateKey], [cipher.sshKey?.privateKey, cipher.sshKey?.decPrivateKey],
[cipher.sshKey?.publicKey, cipher.sshKey?.decPublicKey], [cipher.sshKey?.publicKey, cipher.sshKey?.decPublicKey],
[cipher.sshKey?.keyFingerprint || cipher.sshKey?.fingerprint, cipher.sshKey?.decFingerprint], [cipher.sshKey?.keyFingerprint || cipher.sshKey?.fingerprint, cipher.sshKey?.decFingerprint],
[cipher.bankAccount?.bankName, cipher.bankAccount?.decBankName],
[cipher.bankAccount?.nameOnAccount, cipher.bankAccount?.decNameOnAccount],
[cipher.bankAccount?.accountType, cipher.bankAccount?.decAccountType],
[cipher.bankAccount?.accountNumber, cipher.bankAccount?.decAccountNumber],
[cipher.bankAccount?.routingNumber, cipher.bankAccount?.decRoutingNumber],
[cipher.bankAccount?.branchNumber, cipher.bankAccount?.decBranchNumber],
[cipher.bankAccount?.pin, cipher.bankAccount?.decPin],
[cipher.bankAccount?.swiftCode, cipher.bankAccount?.decSwiftCode],
[cipher.bankAccount?.iban, cipher.bankAccount?.decIban],
[cipher.bankAccount?.bankContactPhone, cipher.bankAccount?.decBankContactPhone],
[cipher.driversLicense?.firstName, cipher.driversLicense?.decFirstName],
[cipher.driversLicense?.middleName, cipher.driversLicense?.decMiddleName],
[cipher.driversLicense?.lastName, cipher.driversLicense?.decLastName],
[cipher.driversLicense?.dateOfBirth, cipher.driversLicense?.decDateOfBirth],
[cipher.driversLicense?.licenseNumber, cipher.driversLicense?.decLicenseNumber],
[cipher.driversLicense?.issuingCountry, cipher.driversLicense?.decIssuingCountry],
[cipher.driversLicense?.issuingState, cipher.driversLicense?.decIssuingState],
[cipher.driversLicense?.issueDate, cipher.driversLicense?.decIssueDate],
[cipher.driversLicense?.expirationDate, cipher.driversLicense?.decExpirationDate],
[cipher.driversLicense?.issuingAuthority, cipher.driversLicense?.decIssuingAuthority],
[cipher.driversLicense?.licenseClass, cipher.driversLicense?.decLicenseClass],
[cipher.passport?.surname, cipher.passport?.decSurname],
[cipher.passport?.givenName, cipher.passport?.decGivenName],
[cipher.passport?.dateOfBirth, cipher.passport?.decDateOfBirth],
[cipher.passport?.sex, cipher.passport?.decSex],
[cipher.passport?.birthPlace, cipher.passport?.decBirthPlace],
[cipher.passport?.nationality, cipher.passport?.decNationality],
[cipher.passport?.issuingCountry, cipher.passport?.decIssuingCountry],
[cipher.passport?.passportNumber, cipher.passport?.decPassportNumber],
[cipher.passport?.passportType, cipher.passport?.decPassportType],
[cipher.passport?.nationalIdentificationNumber, cipher.passport?.decNationalIdentificationNumber],
[cipher.passport?.issuingAuthority, cipher.passport?.decIssuingAuthority],
[cipher.passport?.issueDate, cipher.passport?.decIssueDate],
[cipher.passport?.expirationDate, cipher.passport?.decExpirationDate],
...(cipher.fields || []).flatMap((field) => [ ...(cipher.fields || []).flatMap((field) => [
[field.name, field.decName] as [unknown, unknown], [field.name, field.decName] as [unknown, unknown],
[field.value, field.decValue] as [unknown, unknown], [field.value, field.decValue] as [unknown, unknown],
@@ -1157,6 +1290,9 @@ async function buildCipherPayload(
identity: null, identity: null,
secureNote: null, secureNote: null,
sshKey: null, sshKey: null,
bankAccount: null,
driversLicense: null,
passport: null,
fields: await encryptCustomFields(draft.customFields || [], keys.enc, keys.mac), fields: await encryptCustomFields(draft.customFields || [], keys.enc, keys.mac),
passwordHistory: await encryptPasswordHistory(cipher?.passwordHistory, keys.enc, keys.mac), passwordHistory: await encryptPasswordHistory(cipher?.passwordHistory, keys.enc, keys.mac),
}; };
@@ -1222,11 +1358,73 @@ async function buildCipherPayload(
} else if (type === 5) { } else if (type === 5) {
const encryptedFingerprint = await encryptTextValue(draft.sshFingerprint, keys.enc, keys.mac); const encryptedFingerprint = await encryptTextValue(draft.sshFingerprint, keys.enc, keys.mac);
payload.sshKey = { payload.sshKey = {
...stripDecodedObjectFields(cipher?.sshKey),
privateKey: await encryptTextValue(draft.sshPrivateKey, keys.enc, keys.mac), privateKey: await encryptTextValue(draft.sshPrivateKey, keys.enc, keys.mac),
publicKey: await encryptTextValue(draft.sshPublicKey, keys.enc, keys.mac), publicKey: await encryptTextValue(draft.sshPublicKey, keys.enc, keys.mac),
keyFingerprint: encryptedFingerprint, keyFingerprint: encryptedFingerprint,
fingerprint: encryptedFingerprint, fingerprint: encryptedFingerprint,
}; };
} else if (type === 6) {
payload.bankAccount = await encryptObjectFields(
cipher?.bankAccount,
[
['bankName', 'bankName'],
['nameOnAccount', 'bankNameOnAccount'],
['accountType', 'bankAccountType'],
['accountNumber', 'bankAccountNumber'],
['routingNumber', 'bankRoutingNumber'],
['branchNumber', 'bankBranchNumber'],
['pin', 'bankPin'],
['swiftCode', 'bankSwiftCode'],
['iban', 'bankIban'],
['bankContactPhone', 'bankContactPhone'],
],
draft,
keys.enc,
keys.mac
);
} else if (type === 7) {
payload.driversLicense = await encryptObjectFields(
cipher?.driversLicense,
[
['firstName', 'licenseFirstName'],
['middleName', 'licenseMiddleName'],
['lastName', 'licenseLastName'],
['dateOfBirth', 'licenseDateOfBirth'],
['licenseNumber', 'licenseNumber'],
['issuingCountry', 'licenseIssuingCountry'],
['issuingState', 'licenseIssuingState'],
['issueDate', 'licenseIssueDate'],
['expirationDate', 'licenseExpirationDate'],
['issuingAuthority', 'licenseIssuingAuthority'],
['licenseClass', 'licenseClass'],
],
draft,
keys.enc,
keys.mac
);
} else if (type === 8) {
payload.passport = await encryptObjectFields(
cipher?.passport,
[
['surname', 'passportSurname'],
['givenName', 'passportGivenName'],
['dateOfBirth', 'passportDateOfBirth'],
['sex', 'passportSex'],
['birthPlace', 'passportBirthPlace'],
['nationality', 'passportNationality'],
['issuingCountry', 'passportIssuingCountry'],
['passportNumber', 'passportNumber'],
['passportType', 'passportType'],
['nationalIdentificationNumber', 'passportNationalIdentificationNumber'],
['issuingAuthority', 'passportIssuingAuthority'],
['issueDate', 'passportIssueDate'],
['expirationDate', 'passportExpirationDate'],
],
draft,
keys.enc,
keys.mac
);
} else if (type === 2) { } else if (type === 2) {
payload.secureNote = { type: 0 }; payload.secureNote = { type: 0 };
} }
+34 -4
View File
@@ -215,13 +215,13 @@ function mapCipherEncrypted(cipher: Cipher): Record<string, unknown> {
const login = cipher.login; const login = cipher.login;
out.login = login out.login = login
? { ? {
...cloneValue(login), ...(cloneWithoutDecodedFields(login) || {}),
username: login.username ?? null, username: login.username ?? null,
password: login.password ?? null, password: login.password ?? null,
totp: login.totp ?? null, totp: login.totp ?? null,
uris: Array.isArray(login.uris) uris: Array.isArray(login.uris)
? login.uris.map((uri) => ({ ? login.uris.map((uri) => ({
...cloneValue(uri), ...(cloneWithoutDecodedFields(uri) || {}),
uri: uri?.uri ?? null, uri: uri?.uri ?? null,
uriChecksum: uri?.uriChecksum ?? null, uriChecksum: uri?.uriChecksum ?? null,
match: (uri as { match?: unknown })?.match ?? null, match: (uri as { match?: unknown })?.match ?? null,
@@ -280,6 +280,7 @@ function mapCipherEncrypted(cipher: Cipher): Record<string, unknown> {
out.sshKey = cipher.sshKey out.sshKey = cipher.sshKey
? { ? {
...(cloneWithoutDecodedFields(cipher.sshKey) || {}),
privateKey: cipher.sshKey.privateKey ?? null, privateKey: cipher.sshKey.privateKey ?? null,
publicKey: cipher.sshKey.publicKey ?? null, publicKey: cipher.sshKey.publicKey ?? null,
keyFingerprint: cipher.sshKey.keyFingerprint ?? cipher.sshKey.fingerprint ?? null, keyFingerprint: cipher.sshKey.keyFingerprint ?? cipher.sshKey.fingerprint ?? null,
@@ -287,6 +288,9 @@ function mapCipherEncrypted(cipher: Cipher): Record<string, unknown> {
fingerprint: cipher.sshKey.keyFingerprint ?? cipher.sshKey.fingerprint ?? null, fingerprint: cipher.sshKey.keyFingerprint ?? cipher.sshKey.fingerprint ?? null,
} }
: null; : null;
out.bankAccount = cloneWithoutDecodedFields(cipher.bankAccount) ?? null;
out.driversLicense = cloneWithoutDecodedFields(cipher.driversLicense) ?? null;
out.passport = cloneWithoutDecodedFields(cipher.passport) ?? null;
return out; return out;
} }
@@ -331,8 +335,8 @@ async function mapCipherPlain(cipher: Cipher, userEnc: Uint8Array, userMac: Uint
out.login = null; out.login = null;
} }
out.card = cipher.card ? await deepDecryptUnknown(cipher.card, keyParts.enc, keyParts.mac) : null; out.card = cipher.card ? await deepDecryptUnknown(cloneWithoutDecodedFields(cipher.card), keyParts.enc, keyParts.mac) : null;
out.identity = cipher.identity ? await deepDecryptUnknown(cipher.identity, keyParts.enc, keyParts.mac) : null; out.identity = cipher.identity ? await deepDecryptUnknown(cloneWithoutDecodedFields(cipher.identity), keyParts.enc, keyParts.mac) : null;
if (cipher.sshKey) { if (cipher.sshKey) {
const fingerprint = await decryptMaybe( const fingerprint = await decryptMaybe(
cipher.sshKey.keyFingerprint ?? cipher.sshKey.fingerprint ?? null, cipher.sshKey.keyFingerprint ?? cipher.sshKey.fingerprint ?? null,
@@ -340,6 +344,7 @@ async function mapCipherPlain(cipher: Cipher, userEnc: Uint8Array, userMac: Uint
keyParts.mac keyParts.mac
); );
out.sshKey = { out.sshKey = {
...((await deepDecryptUnknown(cloneWithoutDecodedFields(cipher.sshKey), keyParts.enc, keyParts.mac)) as Record<string, unknown>),
privateKey: await decryptMaybe(cipher.sshKey.privateKey ?? null, keyParts.enc, keyParts.mac), privateKey: await decryptMaybe(cipher.sshKey.privateKey ?? null, keyParts.enc, keyParts.mac),
publicKey: await decryptMaybe(cipher.sshKey.publicKey ?? null, keyParts.enc, keyParts.mac), publicKey: await decryptMaybe(cipher.sshKey.publicKey ?? null, keyParts.enc, keyParts.mac),
keyFingerprint: fingerprint, keyFingerprint: fingerprint,
@@ -349,6 +354,15 @@ async function mapCipherPlain(cipher: Cipher, userEnc: Uint8Array, userMac: Uint
} else { } else {
out.sshKey = null; out.sshKey = null;
} }
out.bankAccount = cipher.bankAccount
? await deepDecryptUnknown(cloneWithoutDecodedFields(cipher.bankAccount), keyParts.enc, keyParts.mac)
: null;
out.driversLicense = cipher.driversLicense
? await deepDecryptUnknown(cloneWithoutDecodedFields(cipher.driversLicense), keyParts.enc, keyParts.mac)
: null;
out.passport = cipher.passport
? await deepDecryptUnknown(cloneWithoutDecodedFields(cipher.passport), keyParts.enc, keyParts.mac)
: null;
out.secureNote = cipher.secureNote out.secureNote = cipher.secureNote
? { ? {
type: normalizeNumber((cipher.secureNote as { type?: unknown }).type, 0), type: normalizeNumber((cipher.secureNote as { type?: unknown }).type, 0),
@@ -431,6 +445,9 @@ function sourceTypeLabel(type: number): string {
if (type === 3) return 'card'; if (type === 3) return 'card';
if (type === 4) return 'identity'; if (type === 4) return 'identity';
if (type === 5) return 'sshKey'; if (type === 5) return 'sshKey';
if (type === 6) return 'bankAccount';
if (type === 7) return 'driversLicense';
if (type === 8) return 'passport';
if (type === 2) return 'note'; if (type === 2) return 'note';
return `type ${type}`; return `type ${type}`;
} }
@@ -449,6 +466,16 @@ function appendRecordFieldLines(lines: string[], prefix: string, value: unknown)
} }
} }
function cloneWithoutDecodedFields(value: unknown): Record<string, unknown> | null {
if (!isRecord(value)) return null;
const out: Record<string, unknown> = {};
for (const [key, item] of Object.entries(value)) {
if (/^dec[A-Z]/.test(key)) continue;
out[key] = cloneValue(item);
}
return out;
}
const BITWARDEN_CSV_OBJECT_FIELDS: Record<string, readonly string[]> = { const BITWARDEN_CSV_OBJECT_FIELDS: Record<string, readonly string[]> = {
card: ['cardholderName', 'brand', 'number', 'expMonth', 'expYear', 'code'], card: ['cardholderName', 'brand', 'number', 'expMonth', 'expYear', 'code'],
identity: [ identity: [
@@ -472,6 +499,9 @@ const BITWARDEN_CSV_OBJECT_FIELDS: Record<string, readonly string[]> = {
'country', 'country',
], ],
sshKey: ['privateKey', 'publicKey', 'keyFingerprint', 'fingerprint'], sshKey: ['privateKey', 'publicKey', 'keyFingerprint', 'fingerprint'],
bankAccount: ['bankName', 'nameOnAccount', 'accountType', 'accountNumber', 'routingNumber', 'branchNumber', 'pin', 'swiftCode', 'iban', 'bankContactPhone'],
driversLicense: ['firstName', 'middleName', 'lastName', 'dateOfBirth', 'licenseNumber', 'issuingCountry', 'issuingState', 'issueDate', 'expirationDate', 'issuingAuthority', 'licenseClass'],
passport: ['surname', 'givenName', 'dateOfBirth', 'sex', 'birthPlace', 'nationality', 'issuingCountry', 'passportNumber', 'passportType', 'nationalIdentificationNumber', 'issuingAuthority', 'issueDate', 'expirationDate'],
}; };
function appendKnownRecordFieldLines(lines: string[], prefix: string, value: unknown): void { function appendKnownRecordFieldLines(lines: string[], prefix: string, value: unknown): void {
+48
View File
@@ -688,6 +688,35 @@ const en: Record<string, string> = {
"txt_last_name": "Last Name", "txt_last_name": "Last Name",
"txt_last_seen": "Last Seen", "txt_last_seen": "Last Seen",
"txt_license_number": "License Number", "txt_license_number": "License Number",
"txt_bank_account": "Bank Account",
"txt_bank_account_details": "Bank Account Details",
"txt_bank_name": "Bank Name",
"txt_name_on_account": "Name on Account",
"txt_account_type": "Account Type",
"txt_account_number": "Account Number",
"txt_routing_number": "Routing Number",
"txt_branch_number": "Branch Number",
"txt_pin": "PIN",
"txt_swift_code": "SWIFT Code",
"txt_iban": "IBAN",
"txt_bank_contact_phone": "Bank Contact Phone",
"txt_drivers_license": "Driver License",
"txt_drivers_license_details": "Driver License Details",
"txt_date_of_birth": "Date of Birth",
"txt_issuing_country": "Issuing Country",
"txt_issuing_state": "Issuing State",
"txt_issue_date": "Issue Date",
"txt_issuing_authority": "Issuing Authority",
"txt_license_class": "License Class",
"txt_passport": "Passport",
"txt_passport_details": "Passport Details",
"txt_surname": "Surname",
"txt_given_name": "Given Name",
"txt_sex": "Sex",
"txt_birth_place": "Place of Birth",
"txt_nationality": "Nationality",
"txt_passport_type": "Passport Type",
"txt_national_id_number": "National ID Number",
"txt_link_copied": "Link copied", "txt_link_copied": "Link copied",
"txt_linked": "Linked", "txt_linked": "Linked",
"txt_linux_desktop": "Linux Desktop", "txt_linux_desktop": "Linux Desktop",
@@ -1014,6 +1043,8 @@ const en: Record<string, string> = {
"txt_totp_qr_scanned": "TOTP value added.", "txt_totp_qr_scanned": "TOTP value added.",
"txt_totp_qr_not_found": "No QR code found in that image.", "txt_totp_qr_not_found": "No QR code found in that image.",
"txt_totp_qr_scan_failed": "Failed to scan QR code.", "txt_totp_qr_scan_failed": "Failed to scan QR code.",
"txt_totp_qr_invalid_image_type": "Choose an image file.",
"txt_totp_qr_image_too_large": "Choose an image smaller than 8 MB.",
"txt_totp_qr_unsupported": "This browser does not support QR scanning. Try Chrome or Edge, or paste the TOTP link or secret manually.", "txt_totp_qr_unsupported": "This browser does not support QR scanning. Try Chrome or Edge, or paste the TOTP link or secret manually.",
"txt_totp_qr_camera_unavailable": "Camera is unavailable. Check browser permission, or choose an image.", "txt_totp_qr_camera_unavailable": "Camera is unavailable. Check browser permission, or choose an image.",
"txt_totp_qr_choose_image": "Choose image", "txt_totp_qr_choose_image": "Choose image",
@@ -1125,6 +1156,12 @@ const en: Record<string, string> = {
"txt_import_invalid_password_protected_file": "Invalid password-protected export file.", "txt_import_invalid_password_protected_file": "Invalid password-protected export file.",
"txt_import_decrypt_failed": "Failed to decrypt import file.", "txt_import_decrypt_failed": "Failed to decrypt import file.",
"txt_import_empty_zip_archive": "Empty zip archive.", "txt_import_empty_zip_archive": "Empty zip archive.",
"txt_import_zip_too_large": "ZIP archive is too large. Maximum size is {size} MiB.",
"txt_import_file_too_large": "Import file is too large. Maximum size is {size} MiB.",
"txt_import_zip_too_many_files": "ZIP archive contains too many files.",
"txt_import_zip_entry_too_large": "ZIP archive contains a file larger than {size} MiB.",
"txt_import_zip_expands_too_large": "ZIP archive expands beyond the current import limit of {size} MiB.",
"txt_import_zip_unsafe_file_name": "ZIP archive contains an unsafe file name.",
"txt_import_no_json_found_in_zip": "No importable JSON data found in zip archive.", "txt_import_no_json_found_in_zip": "No importable JSON data found in zip archive.",
"txt_import_data_json_not_found": "data.json not found in zip archive.", "txt_import_data_json_not_found": "data.json not found in zip archive.",
"txt_import_zip_password_required": "ZIP password is required.", "txt_import_zip_password_required": "ZIP password is required.",
@@ -1219,11 +1256,18 @@ const en: Record<string, string> = {
"txt_log_action_account_api_key_create": "Create API key", "txt_log_action_account_api_key_create": "Create API key",
"txt_log_action_account_api_key_rotate": "Rotate API key", "txt_log_action_account_api_key_rotate": "Rotate API key",
"txt_log_action_account_keys_update": "Update account keys", "txt_log_action_account_keys_update": "Update account keys",
"txt_log_action_account_passkey_create": "Create login passkey",
"txt_log_action_account_passkey_delete": "Delete login passkey",
"txt_log_action_account_passkey_encryption_enable": "Enable passkey vault unlock",
"txt_log_action_account_profile_update": "Update account profile", "txt_log_action_account_profile_update": "Update account profile",
"txt_log_action_account_totp_disable": "Disable two-step login", "txt_log_action_account_totp_disable": "Disable two-step login",
"txt_log_action_account_totp_enable": "Enable two-step login", "txt_log_action_account_totp_enable": "Enable two-step login",
"txt_log_action_account_totp_recover": "Recover two-step login", "txt_log_action_account_totp_recover": "Recover two-step login",
"txt_log_action_account_verify_devices_update": "Update device verification", "txt_log_action_account_verify_devices_update": "Update device verification",
"txt_log_action_account_webauthn_2fa_delete": "Delete passkey two-step login key",
"txt_log_action_account_webauthn_2fa_enable": "Enable passkey two-step login",
"txt_log_action_account_yubikey_enable": "Update YubiKey OTP settings",
"txt_log_action_admin_audit_clear": "Clear audit logs",
"txt_log_action_admin_audit_settings_update": "Update log retention settings", "txt_log_action_admin_audit_settings_update": "Update log retention settings",
"txt_log_action_admin_backup_export": "Export backup", "txt_log_action_admin_backup_export": "Export backup",
"txt_log_action_admin_backup_import": "Import backup", "txt_log_action_admin_backup_import": "Import backup",
@@ -1246,6 +1290,8 @@ const en: Record<string, string> = {
"txt_log_action_auth_login_failed_bad_password": "Login failed: bad password", "txt_log_action_auth_login_failed_bad_password": "Login failed: bad password",
"txt_log_action_auth_login_failed_user_inactive": "Login failed: inactive account", "txt_log_action_auth_login_failed_user_inactive": "Login failed: inactive account",
"txt_log_action_auth_login_success": "Login succeeded", "txt_log_action_auth_login_success": "Login succeeded",
"txt_log_action_auth_passkey_login_failed": "Passkey login failed",
"txt_log_action_auth_passkey_login_success": "Passkey login succeeded",
"txt_log_action_auth_refresh_failed": "Refresh login failed: {reason}", "txt_log_action_auth_refresh_failed": "Refresh login failed: {reason}",
"txt_log_action_cipher_delete_permanent": "Permanently delete vault item", "txt_log_action_cipher_delete_permanent": "Permanently delete vault item",
"txt_log_action_cipher_delete_permanent_bulk": "Permanently delete vault items", "txt_log_action_cipher_delete_permanent_bulk": "Permanently delete vault items",
@@ -1296,6 +1342,7 @@ const en: Record<string, string> = {
"txt_log_meta_method": "Request method", "txt_log_meta_method": "Request method",
"txt_log_meta_path": "Request path", "txt_log_meta_path": "Request path",
"txt_log_meta_provider": "Provider", "txt_log_meta_provider": "Provider",
"txt_log_meta_prf_status": "PRF status",
"txt_log_meta_prune_error": "Cleanup error", "txt_log_meta_prune_error": "Cleanup error",
"txt_log_meta_pruned_file_count": "Cleaned files", "txt_log_meta_pruned_file_count": "Cleaned files",
"txt_log_meta_raw": "Raw data", "txt_log_meta_raw": "Raw data",
@@ -1331,6 +1378,7 @@ const en: Record<string, string> = {
"txt_log_reason_user_inactive": "User inactive", "txt_log_reason_user_inactive": "User inactive",
"txt_log_reason_user_missing": "User missing", "txt_log_reason_user_missing": "User missing",
"txt_log_target_type_attachment": "Attachment", "txt_log_target_type_attachment": "Attachment",
"txt_log_target_type_account_passkey": "Login passkey",
"txt_log_target_type_audit_log": "Log", "txt_log_target_type_audit_log": "Log",
"txt_log_target_type_backup": "Backup", "txt_log_target_type_backup": "Backup",
"txt_log_target_type_cipher": "Vault item", "txt_log_target_type_cipher": "Vault item",
+170 -122
View File
@@ -688,6 +688,35 @@ const es: Record<string, string> = {
"txt_last_name": "Apellido", "txt_last_name": "Apellido",
"txt_last_seen": "Visto por última vez", "txt_last_seen": "Visto por última vez",
"txt_license_number": "Número de licencia", "txt_license_number": "Número de licencia",
"txt_bank_account": "Cuenta bancaria",
"txt_bank_account_details": "Detalles de cuenta bancaria",
"txt_bank_name": "Nombre del banco",
"txt_name_on_account": "Nombre en la cuenta",
"txt_account_type": "Tipo de cuenta",
"txt_account_number": "Número de cuenta",
"txt_routing_number": "Número de ruta",
"txt_branch_number": "Número de sucursal",
"txt_pin": "PIN",
"txt_swift_code": "Código SWIFT",
"txt_iban": "IBAN",
"txt_bank_contact_phone": "Teléfono del banco",
"txt_drivers_license": "Licencia de conducir",
"txt_drivers_license_details": "Detalles de licencia de conducir",
"txt_date_of_birth": "Fecha de nacimiento",
"txt_issuing_country": "País emisor",
"txt_issuing_state": "Estado emisor",
"txt_issue_date": "Fecha de emisión",
"txt_issuing_authority": "Autoridad emisora",
"txt_license_class": "Clase de licencia",
"txt_passport": "Pasaporte",
"txt_passport_details": "Detalles del pasaporte",
"txt_surname": "Apellido",
"txt_given_name": "Nombre",
"txt_sex": "Sexo",
"txt_birth_place": "Lugar de nacimiento",
"txt_nationality": "Nacionalidad",
"txt_passport_type": "Tipo de pasaporte",
"txt_national_id_number": "Número de ID nacional",
"txt_link_copied": "Enlace copiado", "txt_link_copied": "Enlace copiado",
"txt_linked": "Vinculado", "txt_linked": "Vinculado",
"txt_linux_desktop": "Escritorio Linux", "txt_linux_desktop": "Escritorio Linux",
@@ -1014,6 +1043,8 @@ const es: Record<string, string> = {
"txt_totp_qr_scanned": "Valor TOTP agregado.", "txt_totp_qr_scanned": "Valor TOTP agregado.",
"txt_totp_qr_not_found": "No se encontró ningún código QR en esa imagen.", "txt_totp_qr_not_found": "No se encontró ningún código QR en esa imagen.",
"txt_totp_qr_scan_failed": "No se pudo escanear el código QR.", "txt_totp_qr_scan_failed": "No se pudo escanear el código QR.",
"txt_totp_qr_invalid_image_type": "Elija un archivo de imagen.",
"txt_totp_qr_image_too_large": "Elija una imagen de menos de 8 MB.",
"txt_totp_qr_unsupported": "Este navegador no admite escaneo QR. Pruebe Chrome o Edge, o pegue manualmente el enlace o secreto TOTP.", "txt_totp_qr_unsupported": "Este navegador no admite escaneo QR. Pruebe Chrome o Edge, o pegue manualmente el enlace o secreto TOTP.",
"txt_totp_qr_camera_unavailable": "La cámara no está disponible. Revise el permiso del navegador o elija una imagen.", "txt_totp_qr_camera_unavailable": "La cámara no está disponible. Revise el permiso del navegador o elija una imagen.",
"txt_totp_qr_choose_image": "Elegir imagen", "txt_totp_qr_choose_image": "Elegir imagen",
@@ -1125,6 +1156,12 @@ const es: Record<string, string> = {
"txt_import_invalid_password_protected_file": "Archivo de exportación protegido con contraseña no válido.", "txt_import_invalid_password_protected_file": "Archivo de exportación protegido con contraseña no válido.",
"txt_import_decrypt_failed": "Error al descifrar el archivo de importación.", "txt_import_decrypt_failed": "Error al descifrar el archivo de importación.",
"txt_import_empty_zip_archive": "El archivo ZIP está vacío.", "txt_import_empty_zip_archive": "El archivo ZIP está vacío.",
"txt_import_zip_too_large": "El archivo ZIP es demasiado grande. El tamaño máximo es {size} MiB.",
"txt_import_file_too_large": "El archivo de importación es demasiado grande. El tamaño máximo es {size} MiB.",
"txt_import_zip_too_many_files": "El archivo ZIP contiene demasiados archivos.",
"txt_import_zip_entry_too_large": "El archivo ZIP contiene un archivo mayor que {size} MiB.",
"txt_import_zip_expands_too_large": "El archivo ZIP se descomprime por encima del límite actual de importación de {size} MiB.",
"txt_import_zip_unsafe_file_name": "El archivo ZIP contiene un nombre de archivo no seguro.",
"txt_import_no_json_found_in_zip": "No se encontraron datos JSON importables en el archivo zip.", "txt_import_no_json_found_in_zip": "No se encontraron datos JSON importables en el archivo zip.",
"txt_import_data_json_not_found": "No se encontró data.json en el archivo ZIP.", "txt_import_data_json_not_found": "No se encontró data.json en el archivo ZIP.",
"txt_import_zip_password_required": "La contraseña ZIP es obligatoria.", "txt_import_zip_password_required": "La contraseña ZIP es obligatoria.",
@@ -1216,133 +1253,144 @@ const es: Record<string, string> = {
"txt_log_level_info": "Info", "txt_log_level_info": "Info",
"txt_log_level_security": "Seguridad", "txt_log_level_security": "Seguridad",
"txt_log_level_warn": "Aviso", "txt_log_level_warn": "Aviso",
"txt_log_action_account_api_key_create": "Create API key", "txt_log_action_account_api_key_create": "Crear clave de API",
"txt_log_action_account_api_key_rotate": "Rotate API key", "txt_log_action_account_api_key_rotate": "Rotar clave de API",
"txt_log_action_account_keys_update": "Update account keys", "txt_log_action_account_keys_update": "Actualizar claves de cuenta",
"txt_log_action_account_profile_update": "Update account profile", "txt_log_action_account_passkey_create": "Crear passkey de inicio de sesión",
"txt_log_action_account_totp_disable": "Disable two-step login", "txt_log_action_account_passkey_delete": "Eliminar passkey de inicio de sesión",
"txt_log_action_account_totp_enable": "Enable two-step login", "txt_log_action_account_passkey_encryption_enable": "Activar desbloqueo de la bóveda con passkey",
"txt_log_action_account_totp_recover": "Recover two-step login", "txt_log_action_account_profile_update": "Actualizar perfil de cuenta",
"txt_log_action_account_verify_devices_update": "Update device verification", "txt_log_action_account_totp_disable": "Desactivar verificación en dos pasos",
"txt_log_action_admin_audit_settings_update": "Update log retention settings", "txt_log_action_account_totp_enable": "Activar verificación en dos pasos",
"txt_log_action_admin_backup_export": "Export backup", "txt_log_action_account_totp_recover": "Recuperar verificación en dos pasos",
"txt_log_action_admin_backup_import": "Import backup", "txt_log_action_account_verify_devices_update": "Actualizar verificación de dispositivos",
"txt_log_action_admin_backup_remote_delete": "Delete remote backup", "txt_log_action_account_webauthn_2fa_delete": "Eliminar clave de verificación en dos pasos con passkey",
"txt_log_action_admin_backup_remote_manual": "Manual remote backup succeeded", "txt_log_action_account_webauthn_2fa_enable": "Activar verificación en dos pasos con passkey",
"txt_log_action_admin_backup_remote_manual_failed": "Manual remote backup failed", "txt_log_action_account_yubikey_enable": "Actualizar configuración de YubiKey OTP",
"txt_log_action_admin_backup_remote_scheduled": "Scheduled remote backup succeeded", "txt_log_action_admin_audit_clear": "Borrar registros de auditoría",
"txt_log_action_admin_backup_remote_scheduled_failed": "Scheduled remote backup failed", "txt_log_action_admin_audit_settings_update": "Actualizar retención de registros",
"txt_log_action_admin_backup_settings_repair": "Repair backup settings", "txt_log_action_admin_backup_export": "Exportar copia de seguridad",
"txt_log_action_admin_backup_settings_update": "Update backup settings", "txt_log_action_admin_backup_import": "Importar copia de seguridad",
"txt_log_action_admin_invite_create": "Create invite", "txt_log_action_admin_backup_remote_delete": "Eliminar copia remota",
"txt_log_action_admin_invite_delete": "Delete invite", "txt_log_action_admin_backup_remote_manual": "Copia remota manual completada",
"txt_log_action_admin_invite_delete_all": "Clear invites", "txt_log_action_admin_backup_remote_manual_failed": "Error en copia remota manual",
"txt_log_action_admin_invite_delete_invalid": "Delete invalid invites", "txt_log_action_admin_backup_remote_scheduled": "Copia remota programada completada",
"txt_log_action_admin_invite_revoke": "Revoke invite", "txt_log_action_admin_backup_remote_scheduled_failed": "Error en copia remota programada",
"txt_log_action_admin_user_delete": "Delete user", "txt_log_action_admin_backup_settings_repair": "Reparar configuración de copias",
"txt_log_action_admin_user_status": "Change user status", "txt_log_action_admin_backup_settings_update": "Actualizar configuración de copias",
"txt_log_action_attachment_delete": "Delete attachment", "txt_log_action_admin_invite_create": "Crear invitación",
"txt_log_action_auth_login_failed_bad_api_key": "Login failed: bad API key", "txt_log_action_admin_invite_delete": "Eliminar invitación",
"txt_log_action_auth_login_failed_bad_password": "Login failed: bad password", "txt_log_action_admin_invite_delete_all": "Borrar invitaciones",
"txt_log_action_auth_login_failed_user_inactive": "Login failed: inactive account", "txt_log_action_admin_invite_delete_invalid": "Eliminar invitaciones no válidas",
"txt_log_action_auth_login_success": "Login succeeded", "txt_log_action_admin_invite_revoke": "Revocar invitación",
"txt_log_action_auth_refresh_failed": "Refresh login failed: {reason}", "txt_log_action_admin_user_delete": "Eliminar usuario",
"txt_log_action_cipher_delete_permanent": "Permanently delete vault item", "txt_log_action_admin_user_status": "Cambiar estado del usuario",
"txt_log_action_cipher_delete_permanent_bulk": "Permanently delete vault items", "txt_log_action_attachment_delete": "Eliminar adjunto",
"txt_log_action_cipher_delete_soft": "Move vault item to trash", "txt_log_action_auth_login_failed_bad_api_key": "Inicio de sesión fallido: clave de API incorrecta",
"txt_log_action_cipher_delete_soft_bulk": "Move vault items to trash", "txt_log_action_auth_login_failed_bad_password": "Inicio de sesión fallido: contraseña incorrecta",
"txt_log_action_device_deactivate": "Deactivate device", "txt_log_action_auth_login_failed_user_inactive": "Inicio de sesión fallido: cuenta inactiva",
"txt_log_action_device_delete": "Delete device", "txt_log_action_auth_login_success": "Inicio de sesión correcto",
"txt_log_action_device_delete_all": "Delete all devices", "txt_log_action_auth_passkey_login_failed": "Error de inicio de sesión con passkey",
"txt_log_action_device_name_update": "Update device name", "txt_log_action_auth_passkey_login_success": "Inicio de sesión con passkey correcto",
"txt_log_action_device_trust_permanent": "Trust device permanently", "txt_log_action_auth_refresh_failed": "Error al renovar inicio de sesión: {reason}",
"txt_log_action_device_trust_revoke": "Revoke device trust", "txt_log_action_cipher_delete_permanent": "Eliminar elemento de bóveda permanentemente",
"txt_log_action_device_trust_revoke_batch": "Revoke device trust in bulk", "txt_log_action_cipher_delete_permanent_bulk": "Eliminar elementos de bóveda permanentemente",
"txt_log_action_folder_delete": "Delete folder", "txt_log_action_cipher_delete_soft": "Mover elemento de bóveda a la papelera",
"txt_log_action_folder_delete_bulk": "Delete folders", "txt_log_action_cipher_delete_soft_bulk": "Mover elementos de bóveda a la papelera",
"txt_log_action_send_auth_remove": "Remove Send authentication", "txt_log_action_device_deactivate": "Desactivar dispositivo",
"txt_log_action_send_delete": "Delete Send", "txt_log_action_device_delete": "Eliminar dispositivo",
"txt_log_action_send_delete_bulk": "Delete Sends", "txt_log_action_device_delete_all": "Eliminar todos los dispositivos",
"txt_log_action_send_password_remove": "Remove Send password", "txt_log_action_device_name_update": "Actualizar nombre del dispositivo",
"txt_log_action_user_password_change": "Change master password", "txt_log_action_device_trust_permanent": "Confiar permanentemente en el dispositivo",
"txt_log_action_user_register_first_admin": "Register first admin", "txt_log_action_device_trust_revoke": "Revocar confianza del dispositivo",
"txt_log_action_user_register_invite": "Register by invite", "txt_log_action_device_trust_revoke_batch": "Revocar confianza de dispositivos en lote",
"txt_log_meta_attachments": "Attachments", "txt_log_action_folder_delete": "Eliminar carpeta",
"txt_log_action_folder_delete_bulk": "Eliminar carpetas",
"txt_log_action_send_auth_remove": "Quitar autenticación de Send",
"txt_log_action_send_delete": "Eliminar Send",
"txt_log_action_send_delete_bulk": "Eliminar Sends",
"txt_log_action_send_password_remove": "Quitar contraseña de Send",
"txt_log_action_user_password_change": "Cambiar contraseña maestra",
"txt_log_action_user_register_first_admin": "Registrar primer administrador",
"txt_log_action_user_register_invite": "Registrarse por invitación",
"txt_log_meta_attachments": "Adjuntos",
"txt_log_meta_bytes": "Bytes", "txt_log_meta_bytes": "Bytes",
"txt_log_meta_changed": "Changed fields", "txt_log_meta_changed": "Campos modificados",
"txt_log_meta_checksum_mismatch_accepted": "Accepted checksum mismatch", "txt_log_meta_checksum_mismatch_accepted": "Desajuste de checksum aceptado",
"txt_log_meta_cipher_id": "Vault item ID", "txt_log_meta_cipher_id": "ID del elemento de bóveda",
"txt_log_meta_ciphers": "Vault items", "txt_log_meta_ciphers": "Elementos de bóveda",
"txt_log_meta_compat": "Compatibility", "txt_log_meta_compat": "Compatibilidad",
"txt_log_meta_compressed_bytes": "Compressed bytes", "txt_log_meta_compressed_bytes": "Bytes comprimidos",
"txt_log_meta_count": "Count", "txt_log_meta_count": "Cantidad",
"txt_log_meta_deleted": "Deleted count", "txt_log_meta_deleted": "Cantidad eliminada",
"txt_log_meta_destination_count": "Destination count", "txt_log_meta_destination_count": "Cantidad de destinos",
"txt_log_meta_destination_id": "Destination ID", "txt_log_meta_destination_id": "ID de destino",
"txt_log_meta_destination_name": "Destination name", "txt_log_meta_destination_name": "Nombre de destino",
"txt_log_meta_destination_type": "Destination type", "txt_log_meta_destination_type": "Tipo de destino",
"txt_log_meta_device_identifier": "Device ID", "txt_log_meta_device_identifier": "ID del dispositivo",
"txt_log_meta_device_type": "Device type", "txt_log_meta_device_type": "Tipo de dispositivo",
"txt_log_meta_email": "Email", "txt_log_meta_email": "Email",
"txt_log_meta_error": "Error", "txt_log_meta_error": "Error",
"txt_log_meta_expires_in_hours": "Expires in hours", "txt_log_meta_expires_in_hours": "Caduca en horas",
"txt_log_meta_file_bytes": "File bytes", "txt_log_meta_file_bytes": "Bytes del archivo",
"txt_log_meta_file_name": "File name", "txt_log_meta_file_name": "Nombre del archivo",
"txt_log_meta_folder_id": "Folder ID", "txt_log_meta_folder_id": "ID de carpeta",
"txt_log_meta_grant_type": "Login method", "txt_log_meta_grant_type": "Método de inicio de sesión",
"txt_log_meta_includes_attachments": "Includes attachments", "txt_log_meta_includes_attachments": "Incluye adjuntos",
"txt_log_meta_ip": "IP address", "txt_log_meta_ip": "Dirección IP",
"txt_log_meta_max_entries": "Entry limit", "txt_log_meta_max_entries": "Límite de entradas",
"txt_log_meta_method": "Request method", "txt_log_meta_method": "Método de solicitud",
"txt_log_meta_path": "Request path", "txt_log_meta_path": "Ruta de solicitud",
"txt_log_meta_provider": "Provider", "txt_log_meta_provider": "Proveedor",
"txt_log_meta_prune_error": "Cleanup error", "txt_log_meta_prf_status": "Estado de PRF",
"txt_log_meta_pruned_file_count": "Cleaned files", "txt_log_meta_prune_error": "Error de limpieza",
"txt_log_meta_raw": "Raw data", "txt_log_meta_pruned_file_count": "Archivos limpiados",
"txt_log_meta_reason": "Reason", "txt_log_meta_raw": "Datos sin procesar",
"txt_log_meta_remote_path": "Remote path", "txt_log_meta_reason": "Motivo",
"txt_log_meta_removed": "Removed count", "txt_log_meta_remote_path": "Ruta remota",
"txt_log_meta_removed_devices": "Removed devices", "txt_log_meta_removed": "Cantidad quitada",
"txt_log_meta_removed_sessions": "Removed sessions", "txt_log_meta_removed_devices": "Dispositivos quitados",
"txt_log_meta_removed_trusted": "Trust removals", "txt_log_meta_removed_sessions": "Sesiones quitadas",
"txt_log_meta_replace_existing": "Replace existing data", "txt_log_meta_removed_trusted": "Confianzas revocadas",
"txt_log_meta_requested": "Requested count", "txt_log_meta_replace_existing": "Reemplazar datos existentes",
"txt_log_meta_requested_count": "Requested count", "txt_log_meta_requested": "Cantidad solicitada",
"txt_log_meta_retention_days": "Retention days", "txt_log_meta_requested_count": "Cantidad solicitada",
"txt_log_meta_scheduled_destination_count": "Scheduled destinations", "txt_log_meta_retention_days": "Días de retención",
"txt_log_meta_size": "Size", "txt_log_meta_scheduled_destination_count": "Destinos programados",
"txt_log_meta_skipped_attachments": "Skipped attachments", "txt_log_meta_size": "Tamaño",
"txt_log_meta_skipped_reason": "Skip reason", "txt_log_meta_skipped_attachments": "Adjuntos omitidos",
"txt_log_meta_status": "Status", "txt_log_meta_skipped_reason": "Motivo de omisión",
"txt_log_meta_target_email": "Target email", "txt_log_meta_status": "Estado",
"txt_log_meta_trigger": "Trigger", "txt_log_meta_target_email": "Correo del destino",
"txt_log_meta_type": "Type", "txt_log_meta_trigger": "Disparador",
"txt_log_meta_updated": "Updated count", "txt_log_meta_type": "Tipo",
"txt_log_meta_upload_verification_attempts": "Upload verification attempts", "txt_log_meta_updated": "Cantidad actualizada",
"txt_log_meta_user_agent": "Browser/client", "txt_log_meta_upload_verification_attempts": "Intentos de verificación de subida",
"txt_log_meta_users": "Users", "txt_log_meta_user_agent": "Navegador/cliente",
"txt_log_meta_verify_devices": "Verify devices", "txt_log_meta_users": "Usuarios",
"txt_log_meta_web_session": "Web session", "txt_log_meta_verify_devices": "Verificar dispositivos",
"txt_log_reason_bad_api_key": "Bad API key", "txt_log_meta_web_session": "Sesión web",
"txt_log_reason_bad_password": "Bad password", "txt_log_reason_bad_api_key": "Clave de API incorrecta",
"txt_log_reason_device_missing": "Device missing", "txt_log_reason_bad_password": "Contraseña incorrecta",
"txt_log_reason_device_session_mismatch": "Device session mismatch", "txt_log_reason_device_missing": "Dispositivo no encontrado",
"txt_log_reason_token_not_found_or_expired": "Token missing or expired", "txt_log_reason_device_session_mismatch": "La sesión no coincide con el dispositivo",
"txt_log_reason_user_inactive": "User inactive", "txt_log_reason_token_not_found_or_expired": "Token no encontrado o caducado",
"txt_log_reason_user_missing": "User missing", "txt_log_reason_user_inactive": "Usuario inactivo",
"txt_log_target_type_attachment": "Attachment", "txt_log_reason_user_missing": "Usuario no encontrado",
"txt_log_target_type_audit_log": "Log", "txt_log_target_type_attachment": "Adjunto",
"txt_log_target_type_backup": "Backup", "txt_log_target_type_account_passkey": "Passkey de inicio de sesión",
"txt_log_target_type_cipher": "Vault item", "txt_log_target_type_audit_log": "Registro",
"txt_log_target_type_device": "Device", "txt_log_target_type_backup": "Copia de seguridad",
"txt_log_target_type_folder": "Folder", "txt_log_target_type_cipher": "Elemento de bóveda",
"txt_log_target_type_invite": "Invite", "txt_log_target_type_device": "Dispositivo",
"txt_log_target_type_refresh_token": "Refresh token", "txt_log_target_type_folder": "Carpeta",
"txt_log_target_type_invite": "Invitación",
"txt_log_target_type_refresh_token": "Token de renovación",
"txt_log_target_type_send": "Send", "txt_log_target_type_send": "Send",
"txt_log_target_type_user": "User", "txt_log_target_type_user": "Usuario",
"txt_log_trigger_manual": "Manual", "txt_log_trigger_manual": "Manual",
"txt_log_trigger_remote": "Remote", "txt_log_trigger_remote": "Remoto",
"txt_log_trigger_scheduled": "Scheduled", "txt_log_trigger_scheduled": "Programado",
"txt_log_max_1000": "Hasta 1000 entradas", "txt_log_max_1000": "Hasta 1000 entradas",
"txt_log_max_5000": "Hasta 5000 entradas", "txt_log_max_5000": "Hasta 5000 entradas",
"txt_log_max_10000": "Hasta 10 000 entradas", "txt_log_max_10000": "Hasta 10 000 entradas",
+172 -124
View File
@@ -688,6 +688,35 @@ const ru: Record<string, string> = {
"txt_last_name": "Фамилия", "txt_last_name": "Фамилия",
"txt_last_seen": "Последний визит", "txt_last_seen": "Последний визит",
"txt_license_number": "Номер лицензии", "txt_license_number": "Номер лицензии",
"txt_bank_account": "Банковский счет",
"txt_bank_account_details": "Данные банковского счета",
"txt_bank_name": "Название банка",
"txt_name_on_account": "Имя владельца счета",
"txt_account_type": "Тип счета",
"txt_account_number": "Номер счета",
"txt_routing_number": "Маршрутный номер",
"txt_branch_number": "Номер отделения",
"txt_pin": "PIN",
"txt_swift_code": "SWIFT-код",
"txt_iban": "IBAN",
"txt_bank_contact_phone": "Телефон банка",
"txt_drivers_license": "Водительское удостоверение",
"txt_drivers_license_details": "Данные водительского удостоверения",
"txt_date_of_birth": "Дата рождения",
"txt_issuing_country": "Страна выдачи",
"txt_issuing_state": "Регион выдачи",
"txt_issue_date": "Дата выдачи",
"txt_issuing_authority": "Орган выдачи",
"txt_license_class": "Категория",
"txt_passport": "Паспорт",
"txt_passport_details": "Данные паспорта",
"txt_surname": "Фамилия",
"txt_given_name": "Имя",
"txt_sex": "Пол",
"txt_birth_place": "Место рождения",
"txt_nationality": "Гражданство",
"txt_passport_type": "Тип паспорта",
"txt_national_id_number": "Национальный ID",
"txt_link_copied": "Ссылка скопирована", "txt_link_copied": "Ссылка скопирована",
"txt_linked": "Связано", "txt_linked": "Связано",
"txt_linux_desktop": "Рабочий стол Linux", "txt_linux_desktop": "Рабочий стол Linux",
@@ -1014,6 +1043,8 @@ const ru: Record<string, string> = {
"txt_totp_qr_scanned": "Значение TOTP добавлено.", "txt_totp_qr_scanned": "Значение TOTP добавлено.",
"txt_totp_qr_not_found": "QR-код на этом изображении не найден.", "txt_totp_qr_not_found": "QR-код на этом изображении не найден.",
"txt_totp_qr_scan_failed": "Не удалось отсканировать QR-код.", "txt_totp_qr_scan_failed": "Не удалось отсканировать QR-код.",
"txt_totp_qr_invalid_image_type": "Выберите файл изображения.",
"txt_totp_qr_image_too_large": "Выберите изображение меньше 8 МБ.",
"txt_totp_qr_unsupported": "Этот браузер не поддерживает сканирование QR. Попробуйте Chrome или Edge либо вставьте ссылку или секрет TOTP вручную.", "txt_totp_qr_unsupported": "Этот браузер не поддерживает сканирование QR. Попробуйте Chrome или Edge либо вставьте ссылку или секрет TOTP вручную.",
"txt_totp_qr_camera_unavailable": "Камера недоступна. Проверьте разрешение браузера или выберите изображение.", "txt_totp_qr_camera_unavailable": "Камера недоступна. Проверьте разрешение браузера или выберите изображение.",
"txt_totp_qr_choose_image": "Выбрать изображение", "txt_totp_qr_choose_image": "Выбрать изображение",
@@ -1125,6 +1156,12 @@ const ru: Record<string, string> = {
"txt_import_invalid_password_protected_file": "Неверный файл экспорта, защищенный паролем.", "txt_import_invalid_password_protected_file": "Неверный файл экспорта, защищенный паролем.",
"txt_import_decrypt_failed": "Не удалось расшифровать файл импорта.", "txt_import_decrypt_failed": "Не удалось расшифровать файл импорта.",
"txt_import_empty_zip_archive": "Пустой zip-архив.", "txt_import_empty_zip_archive": "Пустой zip-архив.",
"txt_import_zip_too_large": "ZIP-архив слишком большой. Максимальный размер: {size} MiB.",
"txt_import_file_too_large": "Файл импорта слишком большой. Максимальный размер: {size} MiB.",
"txt_import_zip_too_many_files": "ZIP-архив содержит слишком много файлов.",
"txt_import_zip_entry_too_large": "ZIP-архив содержит файл больше {size} MiB.",
"txt_import_zip_expands_too_large": "ZIP-архив распаковывается за текущий лимит импорта {size} MiB.",
"txt_import_zip_unsafe_file_name": "ZIP-архив содержит небезопасное имя файла.",
"txt_import_no_json_found_in_zip": "В zip-архиве не найдены импортируемые данные JSON.", "txt_import_no_json_found_in_zip": "В zip-архиве не найдены импортируемые данные JSON.",
"txt_import_data_json_not_found": "data.json не найден в zip-архиве.", "txt_import_data_json_not_found": "data.json не найден в zip-архиве.",
"txt_import_zip_password_required": "Требуется пароль ZIP.", "txt_import_zip_password_required": "Требуется пароль ZIP.",
@@ -1216,133 +1253,144 @@ const ru: Record<string, string> = {
"txt_log_level_info": "Инфо", "txt_log_level_info": "Инфо",
"txt_log_level_security": "Безопасность", "txt_log_level_security": "Безопасность",
"txt_log_level_warn": "Предупреждение", "txt_log_level_warn": "Предупреждение",
"txt_log_action_account_api_key_create": "Create API key", "txt_log_action_account_api_key_create": "Создание API-ключа",
"txt_log_action_account_api_key_rotate": "Rotate API key", "txt_log_action_account_api_key_rotate": "Ротация API-ключа",
"txt_log_action_account_keys_update": "Update account keys", "txt_log_action_account_keys_update": "Обновление ключей учетной записи",
"txt_log_action_account_profile_update": "Update account profile", "txt_log_action_account_passkey_create": "Создание ключа входа",
"txt_log_action_account_totp_disable": "Disable two-step login", "txt_log_action_account_passkey_delete": "Удаление ключа входа",
"txt_log_action_account_totp_enable": "Enable two-step login", "txt_log_action_account_passkey_encryption_enable": "Включение разблокировки хранилища ключом доступа",
"txt_log_action_account_totp_recover": "Recover two-step login", "txt_log_action_account_profile_update": "Обновление профиля учетной записи",
"txt_log_action_account_verify_devices_update": "Update device verification", "txt_log_action_account_totp_disable": "Отключение двухфакторной проверки",
"txt_log_action_admin_audit_settings_update": "Update log retention settings", "txt_log_action_account_totp_enable": "Включение двухфакторной проверки",
"txt_log_action_admin_backup_export": "Export backup", "txt_log_action_account_totp_recover": "Восстановление двухфакторной проверки",
"txt_log_action_admin_backup_import": "Import backup", "txt_log_action_account_verify_devices_update": "Обновление проверки устройств",
"txt_log_action_admin_backup_remote_delete": "Delete remote backup", "txt_log_action_account_webauthn_2fa_delete": "Удаление ключа двухфакторной проверки",
"txt_log_action_admin_backup_remote_manual": "Manual remote backup succeeded", "txt_log_action_account_webauthn_2fa_enable": "Включение двухфакторной проверки ключом доступа",
"txt_log_action_admin_backup_remote_manual_failed": "Manual remote backup failed", "txt_log_action_account_yubikey_enable": "Обновление настроек YubiKey OTP",
"txt_log_action_admin_backup_remote_scheduled": "Scheduled remote backup succeeded", "txt_log_action_admin_audit_clear": "Очистка журнала аудита",
"txt_log_action_admin_backup_remote_scheduled_failed": "Scheduled remote backup failed", "txt_log_action_admin_audit_settings_update": "Обновление хранения журналов",
"txt_log_action_admin_backup_settings_repair": "Repair backup settings", "txt_log_action_admin_backup_export": "Экспорт резервной копии",
"txt_log_action_admin_backup_settings_update": "Update backup settings", "txt_log_action_admin_backup_import": "Импорт резервной копии",
"txt_log_action_admin_invite_create": "Create invite", "txt_log_action_admin_backup_remote_delete": "Удаление удаленной резервной копии",
"txt_log_action_admin_invite_delete": "Delete invite", "txt_log_action_admin_backup_remote_manual": "Ручное удаленное резервное копирование выполнено",
"txt_log_action_admin_invite_delete_all": "Clear invites", "txt_log_action_admin_backup_remote_manual_failed": "Ошибка ручного удаленного резервного копирования",
"txt_log_action_admin_invite_delete_invalid": "Delete invalid invites", "txt_log_action_admin_backup_remote_scheduled": "Запланированное удаленное резервное копирование выполнено",
"txt_log_action_admin_invite_revoke": "Revoke invite", "txt_log_action_admin_backup_remote_scheduled_failed": "Ошибка запланированного удаленного резервного копирования",
"txt_log_action_admin_user_delete": "Delete user", "txt_log_action_admin_backup_settings_repair": "Восстановление настроек резервного копирования",
"txt_log_action_admin_user_status": "Change user status", "txt_log_action_admin_backup_settings_update": "Обновление настроек резервного копирования",
"txt_log_action_attachment_delete": "Delete attachment", "txt_log_action_admin_invite_create": "Создание приглашения",
"txt_log_action_auth_login_failed_bad_api_key": "Login failed: bad API key", "txt_log_action_admin_invite_delete": "Удаление приглашения",
"txt_log_action_auth_login_failed_bad_password": "Login failed: bad password", "txt_log_action_admin_invite_delete_all": "Очистка приглашений",
"txt_log_action_auth_login_failed_user_inactive": "Login failed: inactive account", "txt_log_action_admin_invite_delete_invalid": "Удаление недействительных приглашений",
"txt_log_action_auth_login_success": "Login succeeded", "txt_log_action_admin_invite_revoke": "Отзыв приглашения",
"txt_log_action_auth_refresh_failed": "Refresh login failed: {reason}", "txt_log_action_admin_user_delete": "Удаление пользователя",
"txt_log_action_cipher_delete_permanent": "Permanently delete vault item", "txt_log_action_admin_user_status": "Изменение статуса пользователя",
"txt_log_action_cipher_delete_permanent_bulk": "Permanently delete vault items", "txt_log_action_attachment_delete": "Удаление вложения",
"txt_log_action_cipher_delete_soft": "Move vault item to trash", "txt_log_action_auth_login_failed_bad_api_key": "Ошибка входа: неверный API-ключ",
"txt_log_action_cipher_delete_soft_bulk": "Move vault items to trash", "txt_log_action_auth_login_failed_bad_password": "Ошибка входа: неверный пароль",
"txt_log_action_device_deactivate": "Deactivate device", "txt_log_action_auth_login_failed_user_inactive": "Ошибка входа: учетная запись неактивна",
"txt_log_action_device_delete": "Delete device", "txt_log_action_auth_login_success": "Вход выполнен",
"txt_log_action_device_delete_all": "Delete all devices", "txt_log_action_auth_passkey_login_failed": "Ошибка входа по ключу доступа",
"txt_log_action_device_name_update": "Update device name", "txt_log_action_auth_passkey_login_success": "Вход по ключу доступа выполнен",
"txt_log_action_device_trust_permanent": "Trust device permanently", "txt_log_action_auth_refresh_failed": "Не удалось обновить вход: {reason}",
"txt_log_action_device_trust_revoke": "Revoke device trust", "txt_log_action_cipher_delete_permanent": "Окончательное удаление элемента хранилища",
"txt_log_action_device_trust_revoke_batch": "Revoke device trust in bulk", "txt_log_action_cipher_delete_permanent_bulk": "Окончательное удаление элементов хранилища",
"txt_log_action_folder_delete": "Delete folder", "txt_log_action_cipher_delete_soft": "Перемещение элемента хранилища в корзину",
"txt_log_action_folder_delete_bulk": "Delete folders", "txt_log_action_cipher_delete_soft_bulk": "Перемещение элементов хранилища в корзину",
"txt_log_action_send_auth_remove": "Remove Send authentication", "txt_log_action_device_deactivate": "Деактивация устройства",
"txt_log_action_send_delete": "Delete Send", "txt_log_action_device_delete": "Удаление устройства",
"txt_log_action_send_delete_bulk": "Delete Sends", "txt_log_action_device_delete_all": "Удаление всех устройств",
"txt_log_action_send_password_remove": "Remove Send password", "txt_log_action_device_name_update": "Обновление имени устройства",
"txt_log_action_user_password_change": "Change master password", "txt_log_action_device_trust_permanent": "Постоянное доверие устройству",
"txt_log_action_user_register_first_admin": "Register first admin", "txt_log_action_device_trust_revoke": "Отзыв доверия устройству",
"txt_log_action_user_register_invite": "Register by invite", "txt_log_action_device_trust_revoke_batch": "Массовый отзыв доверия устройствам",
"txt_log_meta_attachments": "Attachments", "txt_log_action_folder_delete": "Удаление папки",
"txt_log_action_folder_delete_bulk": "Удаление папок",
"txt_log_action_send_auth_remove": "Удаление проверки Send",
"txt_log_action_send_delete": "Удаление Send",
"txt_log_action_send_delete_bulk": "Удаление Send",
"txt_log_action_send_password_remove": "Удаление пароля Send",
"txt_log_action_user_password_change": "Изменение мастер-пароля",
"txt_log_action_user_register_first_admin": "Регистрация первого администратора",
"txt_log_action_user_register_invite": "Регистрация по приглашению",
"txt_log_meta_attachments": "Вложения",
"txt_log_meta_bytes": "Bytes", "txt_log_meta_bytes": "Bytes",
"txt_log_meta_changed": "Changed fields", "txt_log_meta_changed": "Измененные поля",
"txt_log_meta_checksum_mismatch_accepted": "Accepted checksum mismatch", "txt_log_meta_checksum_mismatch_accepted": "Принято несовпадение контрольной суммы",
"txt_log_meta_cipher_id": "Vault item ID", "txt_log_meta_cipher_id": "ID элемента хранилища",
"txt_log_meta_ciphers": "Vault items", "txt_log_meta_ciphers": "Элементы хранилища",
"txt_log_meta_compat": "Compatibility", "txt_log_meta_compat": "Совместимость",
"txt_log_meta_compressed_bytes": "Compressed bytes", "txt_log_meta_compressed_bytes": "Байт после сжатия",
"txt_log_meta_count": "Count", "txt_log_meta_count": "Количество",
"txt_log_meta_deleted": "Deleted count", "txt_log_meta_deleted": "Удалено",
"txt_log_meta_destination_count": "Destination count", "txt_log_meta_destination_count": "Количество назначений",
"txt_log_meta_destination_id": "Destination ID", "txt_log_meta_destination_id": "ID назначения",
"txt_log_meta_destination_name": "Destination name", "txt_log_meta_destination_name": "Имя назначения",
"txt_log_meta_destination_type": "Destination type", "txt_log_meta_destination_type": "Тип назначения",
"txt_log_meta_device_identifier": "Device ID", "txt_log_meta_device_identifier": "ID устройства",
"txt_log_meta_device_type": "Device type", "txt_log_meta_device_type": "Тип устройства",
"txt_log_meta_email": "Email", "txt_log_meta_email": "Email",
"txt_log_meta_error": "Error", "txt_log_meta_error": "Ошибка",
"txt_log_meta_expires_in_hours": "Expires in hours", "txt_log_meta_expires_in_hours": "Истекает через часов",
"txt_log_meta_file_bytes": "File bytes", "txt_log_meta_file_bytes": "Байт файла",
"txt_log_meta_file_name": "File name", "txt_log_meta_file_name": "Имя файла",
"txt_log_meta_folder_id": "Folder ID", "txt_log_meta_folder_id": "ID папки",
"txt_log_meta_grant_type": "Login method", "txt_log_meta_grant_type": "Способ входа",
"txt_log_meta_includes_attachments": "Includes attachments", "txt_log_meta_includes_attachments": "Включает вложения",
"txt_log_meta_ip": "IP address", "txt_log_meta_ip": "IP-адрес",
"txt_log_meta_max_entries": "Entry limit", "txt_log_meta_max_entries": "Лимит записей",
"txt_log_meta_method": "Request method", "txt_log_meta_method": "Метод запроса",
"txt_log_meta_path": "Request path", "txt_log_meta_path": "Путь запроса",
"txt_log_meta_provider": "Provider", "txt_log_meta_provider": "Поставщик",
"txt_log_meta_prune_error": "Cleanup error", "txt_log_meta_prf_status": "Статус PRF",
"txt_log_meta_pruned_file_count": "Cleaned files", "txt_log_meta_prune_error": "Ошибка очистки",
"txt_log_meta_raw": "Raw data", "txt_log_meta_pruned_file_count": "Очищено файлов",
"txt_log_meta_reason": "Reason", "txt_log_meta_raw": "Исходные данные",
"txt_log_meta_remote_path": "Remote path", "txt_log_meta_reason": "Причина",
"txt_log_meta_removed": "Removed count", "txt_log_meta_remote_path": "Удаленный путь",
"txt_log_meta_removed_devices": "Removed devices", "txt_log_meta_removed": "Удалено",
"txt_log_meta_removed_sessions": "Removed sessions", "txt_log_meta_removed_devices": "Удалено устройств",
"txt_log_meta_removed_trusted": "Trust removals", "txt_log_meta_removed_sessions": "Удалено сессий",
"txt_log_meta_replace_existing": "Replace existing data", "txt_log_meta_removed_trusted": "Отозвано доверий",
"txt_log_meta_requested": "Requested count", "txt_log_meta_replace_existing": "Заменить существующие данные",
"txt_log_meta_requested_count": "Requested count", "txt_log_meta_requested": "Запрошено",
"txt_log_meta_retention_days": "Retention days", "txt_log_meta_requested_count": "Запрошено",
"txt_log_meta_scheduled_destination_count": "Scheduled destinations", "txt_log_meta_retention_days": "Дней хранения",
"txt_log_meta_size": "Size", "txt_log_meta_scheduled_destination_count": "Запланированные назначения",
"txt_log_meta_skipped_attachments": "Skipped attachments", "txt_log_meta_size": "Размер",
"txt_log_meta_skipped_reason": "Skip reason", "txt_log_meta_skipped_attachments": "Пропущенные вложения",
"txt_log_meta_status": "Status", "txt_log_meta_skipped_reason": "Причина пропуска",
"txt_log_meta_target_email": "Target email", "txt_log_meta_status": "Статус",
"txt_log_meta_trigger": "Trigger", "txt_log_meta_target_email": "Email цели",
"txt_log_meta_type": "Type", "txt_log_meta_trigger": "Триггер",
"txt_log_meta_updated": "Updated count", "txt_log_meta_type": "Тип",
"txt_log_meta_upload_verification_attempts": "Upload verification attempts", "txt_log_meta_updated": "Обновлено",
"txt_log_meta_user_agent": "Browser/client", "txt_log_meta_upload_verification_attempts": "Попытки проверки загрузки",
"txt_log_meta_users": "Users", "txt_log_meta_user_agent": "Браузер/клиент",
"txt_log_meta_verify_devices": "Verify devices", "txt_log_meta_users": "Пользователи",
"txt_log_meta_web_session": "Web session", "txt_log_meta_verify_devices": "Проверка устройств",
"txt_log_reason_bad_api_key": "Bad API key", "txt_log_meta_web_session": "Веб-сессия",
"txt_log_reason_bad_password": "Bad password", "txt_log_reason_bad_api_key": "Неверный API-ключ",
"txt_log_reason_device_missing": "Device missing", "txt_log_reason_bad_password": "Неверный пароль",
"txt_log_reason_device_session_mismatch": "Device session mismatch", "txt_log_reason_device_missing": "Устройство не найдено",
"txt_log_reason_token_not_found_or_expired": "Token missing or expired", "txt_log_reason_device_session_mismatch": "Сессия не соответствует устройству",
"txt_log_reason_user_inactive": "User inactive", "txt_log_reason_token_not_found_or_expired": "Токен отсутствует или истек",
"txt_log_reason_user_missing": "User missing", "txt_log_reason_user_inactive": "Пользователь неактивен",
"txt_log_target_type_attachment": "Attachment", "txt_log_reason_user_missing": "Пользователь не найден",
"txt_log_target_type_audit_log": "Log", "txt_log_target_type_attachment": "Вложение",
"txt_log_target_type_backup": "Backup", "txt_log_target_type_account_passkey": "Ключ входа",
"txt_log_target_type_cipher": "Vault item", "txt_log_target_type_audit_log": "Журнал",
"txt_log_target_type_device": "Device", "txt_log_target_type_backup": "Резервная копия",
"txt_log_target_type_folder": "Folder", "txt_log_target_type_cipher": "Элемент хранилища",
"txt_log_target_type_invite": "Invite", "txt_log_target_type_device": "Устройство",
"txt_log_target_type_refresh_token": "Refresh token", "txt_log_target_type_folder": "Папка",
"txt_log_target_type_invite": "Приглашение",
"txt_log_target_type_refresh_token": "Токен обновления",
"txt_log_target_type_send": "Send", "txt_log_target_type_send": "Send",
"txt_log_target_type_user": "User", "txt_log_target_type_user": "Пользователь",
"txt_log_trigger_manual": "Manual", "txt_log_trigger_manual": "Вручную",
"txt_log_trigger_remote": "Remote", "txt_log_trigger_remote": "Удаленно",
"txt_log_trigger_scheduled": "Scheduled", "txt_log_trigger_scheduled": "По расписанию",
"txt_log_max_1000": "До 1 000 записей", "txt_log_max_1000": "До 1 000 записей",
"txt_log_max_5000": "До 5 000 записей", "txt_log_max_5000": "До 5 000 записей",
"txt_log_max_10000": "До 10 000 записей", "txt_log_max_10000": "До 10 000 записей",
+48
View File
@@ -688,6 +688,35 @@ const zhCN: Record<string, string> = {
"txt_last_name": "姓", "txt_last_name": "姓",
"txt_last_seen": "最后在线", "txt_last_seen": "最后在线",
"txt_license_number": "证件号", "txt_license_number": "证件号",
"txt_bank_account": "银行账户",
"txt_bank_account_details": "银行账户详情",
"txt_bank_name": "银行名称",
"txt_name_on_account": "账户姓名",
"txt_account_type": "账户类型",
"txt_account_number": "账户号码",
"txt_routing_number": "路由号码",
"txt_branch_number": "分行号码",
"txt_pin": "PIN",
"txt_swift_code": "SWIFT 代码",
"txt_iban": "IBAN",
"txt_bank_contact_phone": "银行联系电话",
"txt_drivers_license": "驾照",
"txt_drivers_license_details": "驾照详情",
"txt_date_of_birth": "出生日期",
"txt_issuing_country": "签发国家/地区",
"txt_issuing_state": "签发州/省",
"txt_issue_date": "签发日期",
"txt_issuing_authority": "签发机构",
"txt_license_class": "驾照等级",
"txt_passport": "护照",
"txt_passport_details": "护照详情",
"txt_surname": "姓",
"txt_given_name": "名",
"txt_sex": "性别",
"txt_birth_place": "出生地",
"txt_nationality": "国籍",
"txt_passport_type": "护照类型",
"txt_national_id_number": "国家身份证号",
"txt_link_copied": "链接已复制", "txt_link_copied": "链接已复制",
"txt_linked": "已关联", "txt_linked": "已关联",
"txt_linux_desktop": "Linux 桌面端", "txt_linux_desktop": "Linux 桌面端",
@@ -1014,6 +1043,8 @@ const zhCN: Record<string, string> = {
"txt_totp_qr_scanned": "TOTP 内容已填入。", "txt_totp_qr_scanned": "TOTP 内容已填入。",
"txt_totp_qr_not_found": "这张图片里没有识别到二维码。", "txt_totp_qr_not_found": "这张图片里没有识别到二维码。",
"txt_totp_qr_scan_failed": "二维码扫描失败。", "txt_totp_qr_scan_failed": "二维码扫描失败。",
"txt_totp_qr_invalid_image_type": "请选择图片文件。",
"txt_totp_qr_image_too_large": "请选择小于 8 MB 的图片。",
"txt_totp_qr_unsupported": "当前浏览器不支持二维码扫描。可尝试 Chrome 或 Edge,或手动粘贴 TOTP 链接/密钥。", "txt_totp_qr_unsupported": "当前浏览器不支持二维码扫描。可尝试 Chrome 或 Edge,或手动粘贴 TOTP 链接/密钥。",
"txt_totp_qr_camera_unavailable": "无法使用摄像头。请检查浏览器权限,或选择图片。", "txt_totp_qr_camera_unavailable": "无法使用摄像头。请检查浏览器权限,或选择图片。",
"txt_totp_qr_choose_image": "选择图片", "txt_totp_qr_choose_image": "选择图片",
@@ -1125,6 +1156,12 @@ const zhCN: Record<string, string> = {
"txt_import_invalid_password_protected_file": "密码保护导出文件格式无效。", "txt_import_invalid_password_protected_file": "密码保护导出文件格式无效。",
"txt_import_decrypt_failed": "导入文件解密失败。", "txt_import_decrypt_failed": "导入文件解密失败。",
"txt_import_empty_zip_archive": "ZIP 压缩包为空。", "txt_import_empty_zip_archive": "ZIP 压缩包为空。",
"txt_import_zip_too_large": "ZIP 压缩包过大,最大允许 {size} MiB。",
"txt_import_file_too_large": "导入文件过大,最大允许 {size} MiB。",
"txt_import_zip_too_many_files": "ZIP 压缩包内文件过多。",
"txt_import_zip_entry_too_large": "ZIP 压缩包内存在超过 {size} MiB 的文件。",
"txt_import_zip_expands_too_large": "ZIP 解压后超过当前导入限制 {size} MiB。",
"txt_import_zip_unsafe_file_name": "ZIP 压缩包包含不安全的文件名。",
"txt_import_no_json_found_in_zip": "ZIP 内未找到可导入的 JSON 数据。", "txt_import_no_json_found_in_zip": "ZIP 内未找到可导入的 JSON 数据。",
"txt_import_data_json_not_found": "ZIP 内未找到 data.json。", "txt_import_data_json_not_found": "ZIP 内未找到 data.json。",
"txt_import_zip_password_required": "该 ZIP 需要密码。", "txt_import_zip_password_required": "该 ZIP 需要密码。",
@@ -1219,11 +1256,18 @@ const zhCN: Record<string, string> = {
"txt_log_action_account_api_key_create": "创建 API 密钥", "txt_log_action_account_api_key_create": "创建 API 密钥",
"txt_log_action_account_api_key_rotate": "轮换 API 密钥", "txt_log_action_account_api_key_rotate": "轮换 API 密钥",
"txt_log_action_account_keys_update": "更新账户密钥", "txt_log_action_account_keys_update": "更新账户密钥",
"txt_log_action_account_passkey_create": "创建登录通行密钥",
"txt_log_action_account_passkey_delete": "删除登录通行密钥",
"txt_log_action_account_passkey_encryption_enable": "开启通行密钥解锁密码库",
"txt_log_action_account_profile_update": "更新账户资料", "txt_log_action_account_profile_update": "更新账户资料",
"txt_log_action_account_totp_disable": "关闭两步验证", "txt_log_action_account_totp_disable": "关闭两步验证",
"txt_log_action_account_totp_enable": "开启两步验证", "txt_log_action_account_totp_enable": "开启两步验证",
"txt_log_action_account_totp_recover": "恢复两步验证", "txt_log_action_account_totp_recover": "恢复两步验证",
"txt_log_action_account_verify_devices_update": "更新设备验证设置", "txt_log_action_account_verify_devices_update": "更新设备验证设置",
"txt_log_action_account_webauthn_2fa_delete": "删除通行密钥两步验证密钥",
"txt_log_action_account_webauthn_2fa_enable": "开启通行密钥两步验证",
"txt_log_action_account_yubikey_enable": "更新 YubiKey OTP 设置",
"txt_log_action_admin_audit_clear": "清空审计日志",
"txt_log_action_admin_audit_settings_update": "更新日志保留设置", "txt_log_action_admin_audit_settings_update": "更新日志保留设置",
"txt_log_action_admin_backup_export": "导出备份", "txt_log_action_admin_backup_export": "导出备份",
"txt_log_action_admin_backup_import": "导入备份", "txt_log_action_admin_backup_import": "导入备份",
@@ -1246,6 +1290,8 @@ const zhCN: Record<string, string> = {
"txt_log_action_auth_login_failed_bad_password": "密码错误登录失败", "txt_log_action_auth_login_failed_bad_password": "密码错误登录失败",
"txt_log_action_auth_login_failed_user_inactive": "账号停用登录失败", "txt_log_action_auth_login_failed_user_inactive": "账号停用登录失败",
"txt_log_action_auth_login_success": "登录成功", "txt_log_action_auth_login_success": "登录成功",
"txt_log_action_auth_passkey_login_failed": "通行密钥登录失败",
"txt_log_action_auth_passkey_login_success": "通行密钥登录成功",
"txt_log_action_auth_refresh_failed": "刷新登录失败:{reason}", "txt_log_action_auth_refresh_failed": "刷新登录失败:{reason}",
"txt_log_action_cipher_delete_permanent": "永久删除密码项", "txt_log_action_cipher_delete_permanent": "永久删除密码项",
"txt_log_action_cipher_delete_permanent_bulk": "批量永久删除密码项", "txt_log_action_cipher_delete_permanent_bulk": "批量永久删除密码项",
@@ -1296,6 +1342,7 @@ const zhCN: Record<string, string> = {
"txt_log_meta_method": "请求方法", "txt_log_meta_method": "请求方法",
"txt_log_meta_path": "请求路径", "txt_log_meta_path": "请求路径",
"txt_log_meta_provider": "服务提供方", "txt_log_meta_provider": "服务提供方",
"txt_log_meta_prf_status": "PRF 状态",
"txt_log_meta_prune_error": "清理错误", "txt_log_meta_prune_error": "清理错误",
"txt_log_meta_pruned_file_count": "已清理文件数", "txt_log_meta_pruned_file_count": "已清理文件数",
"txt_log_meta_raw": "原始数据", "txt_log_meta_raw": "原始数据",
@@ -1331,6 +1378,7 @@ const zhCN: Record<string, string> = {
"txt_log_reason_user_inactive": "用户未启用", "txt_log_reason_user_inactive": "用户未启用",
"txt_log_reason_user_missing": "用户不存在", "txt_log_reason_user_missing": "用户不存在",
"txt_log_target_type_attachment": "附件", "txt_log_target_type_attachment": "附件",
"txt_log_target_type_account_passkey": "登录通行密钥",
"txt_log_target_type_audit_log": "日志", "txt_log_target_type_audit_log": "日志",
"txt_log_target_type_backup": "备份", "txt_log_target_type_backup": "备份",
"txt_log_target_type_cipher": "密码项", "txt_log_target_type_cipher": "密码项",
+48
View File
@@ -688,6 +688,35 @@ const zhTW: Record<string, string> = {
"txt_last_name": "姓", "txt_last_name": "姓",
"txt_last_seen": "最後在線", "txt_last_seen": "最後在線",
"txt_license_number": "證件號", "txt_license_number": "證件號",
"txt_bank_account": "銀行帳戶",
"txt_bank_account_details": "銀行帳戶詳情",
"txt_bank_name": "銀行名稱",
"txt_name_on_account": "帳戶姓名",
"txt_account_type": "帳戶類型",
"txt_account_number": "帳戶號碼",
"txt_routing_number": "路由號碼",
"txt_branch_number": "分行號碼",
"txt_pin": "PIN",
"txt_swift_code": "SWIFT 代碼",
"txt_iban": "IBAN",
"txt_bank_contact_phone": "銀行聯絡電話",
"txt_drivers_license": "駕照",
"txt_drivers_license_details": "駕照詳情",
"txt_date_of_birth": "出生日期",
"txt_issuing_country": "簽發國家/地區",
"txt_issuing_state": "簽發州/省",
"txt_issue_date": "簽發日期",
"txt_issuing_authority": "簽發機構",
"txt_license_class": "駕照等級",
"txt_passport": "護照",
"txt_passport_details": "護照詳情",
"txt_surname": "姓",
"txt_given_name": "名",
"txt_sex": "性別",
"txt_birth_place": "出生地",
"txt_nationality": "國籍",
"txt_passport_type": "護照類型",
"txt_national_id_number": "國家身分證號",
"txt_link_copied": "鏈接已複製", "txt_link_copied": "鏈接已複製",
"txt_linked": "已關聯", "txt_linked": "已關聯",
"txt_linux_desktop": "Linux 桌面端", "txt_linux_desktop": "Linux 桌面端",
@@ -1014,6 +1043,8 @@ const zhTW: Record<string, string> = {
"txt_totp_qr_scanned": "TOTP 內容已填入。", "txt_totp_qr_scanned": "TOTP 內容已填入。",
"txt_totp_qr_not_found": "這張圖片裡沒有識別到二維碼。", "txt_totp_qr_not_found": "這張圖片裡沒有識別到二維碼。",
"txt_totp_qr_scan_failed": "二維碼掃描失敗。", "txt_totp_qr_scan_failed": "二維碼掃描失敗。",
"txt_totp_qr_invalid_image_type": "請選擇圖片檔案。",
"txt_totp_qr_image_too_large": "請選擇小於 8 MB 的圖片。",
"txt_totp_qr_unsupported": "目前瀏覽器不支援二維碼掃描。可嘗試 Chrome 或 Edge,或手動貼上 TOTP 連結/密鑰。", "txt_totp_qr_unsupported": "目前瀏覽器不支援二維碼掃描。可嘗試 Chrome 或 Edge,或手動貼上 TOTP 連結/密鑰。",
"txt_totp_qr_camera_unavailable": "無法使用攝影機。請檢查瀏覽器權限,或選擇圖片。", "txt_totp_qr_camera_unavailable": "無法使用攝影機。請檢查瀏覽器權限,或選擇圖片。",
"txt_totp_qr_choose_image": "選擇圖片", "txt_totp_qr_choose_image": "選擇圖片",
@@ -1125,6 +1156,12 @@ const zhTW: Record<string, string> = {
"txt_import_invalid_password_protected_file": "密碼保護導出文件格式無效。", "txt_import_invalid_password_protected_file": "密碼保護導出文件格式無效。",
"txt_import_decrypt_failed": "導入文件解密失敗。", "txt_import_decrypt_failed": "導入文件解密失敗。",
"txt_import_empty_zip_archive": "ZIP 壓縮包為空。", "txt_import_empty_zip_archive": "ZIP 壓縮包為空。",
"txt_import_zip_too_large": "ZIP 壓縮包過大,最大允許 {size} MiB。",
"txt_import_file_too_large": "導入文件過大,最大允許 {size} MiB。",
"txt_import_zip_too_many_files": "ZIP 壓縮包內文件過多。",
"txt_import_zip_entry_too_large": "ZIP 壓縮包內存在超過 {size} MiB 的文件。",
"txt_import_zip_expands_too_large": "ZIP 解壓後超過目前導入限制 {size} MiB。",
"txt_import_zip_unsafe_file_name": "ZIP 壓縮包包含不安全的文件名。",
"txt_import_no_json_found_in_zip": "ZIP 內未找到可導入的 JSON 數據。", "txt_import_no_json_found_in_zip": "ZIP 內未找到可導入的 JSON 數據。",
"txt_import_data_json_not_found": "ZIP 內未找到 data.json。", "txt_import_data_json_not_found": "ZIP 內未找到 data.json。",
"txt_import_zip_password_required": "該 ZIP 需要密碼。", "txt_import_zip_password_required": "該 ZIP 需要密碼。",
@@ -1219,11 +1256,18 @@ const zhTW: Record<string, string> = {
"txt_log_action_account_api_key_create": "建立 API 金鑰", "txt_log_action_account_api_key_create": "建立 API 金鑰",
"txt_log_action_account_api_key_rotate": "輪換 API 金鑰", "txt_log_action_account_api_key_rotate": "輪換 API 金鑰",
"txt_log_action_account_keys_update": "更新帳戶金鑰", "txt_log_action_account_keys_update": "更新帳戶金鑰",
"txt_log_action_account_passkey_create": "建立登入通行密鑰",
"txt_log_action_account_passkey_delete": "刪除登入通行密鑰",
"txt_log_action_account_passkey_encryption_enable": "開啟通行密鑰解鎖密碼庫",
"txt_log_action_account_profile_update": "更新帳戶資料", "txt_log_action_account_profile_update": "更新帳戶資料",
"txt_log_action_account_totp_disable": "關閉兩步驟登入", "txt_log_action_account_totp_disable": "關閉兩步驟登入",
"txt_log_action_account_totp_enable": "開啟兩步驟登入", "txt_log_action_account_totp_enable": "開啟兩步驟登入",
"txt_log_action_account_totp_recover": "復原兩步驟登入", "txt_log_action_account_totp_recover": "復原兩步驟登入",
"txt_log_action_account_verify_devices_update": "更新裝置驗證設定", "txt_log_action_account_verify_devices_update": "更新裝置驗證設定",
"txt_log_action_account_webauthn_2fa_delete": "刪除通行密鑰兩步驟驗證密鑰",
"txt_log_action_account_webauthn_2fa_enable": "開啟通行密鑰兩步驟驗證",
"txt_log_action_account_yubikey_enable": "更新 YubiKey OTP 設定",
"txt_log_action_admin_audit_clear": "清空稽核日誌",
"txt_log_action_admin_audit_settings_update": "更新日誌保留設定", "txt_log_action_admin_audit_settings_update": "更新日誌保留設定",
"txt_log_action_admin_backup_export": "匯出備份", "txt_log_action_admin_backup_export": "匯出備份",
"txt_log_action_admin_backup_import": "匯入備份", "txt_log_action_admin_backup_import": "匯入備份",
@@ -1246,6 +1290,8 @@ const zhTW: Record<string, string> = {
"txt_log_action_auth_login_failed_bad_password": "密碼錯誤登入失敗", "txt_log_action_auth_login_failed_bad_password": "密碼錯誤登入失敗",
"txt_log_action_auth_login_failed_user_inactive": "帳號停用登入失敗", "txt_log_action_auth_login_failed_user_inactive": "帳號停用登入失敗",
"txt_log_action_auth_login_success": "登入成功", "txt_log_action_auth_login_success": "登入成功",
"txt_log_action_auth_passkey_login_failed": "通行密鑰登入失敗",
"txt_log_action_auth_passkey_login_success": "通行密鑰登入成功",
"txt_log_action_auth_refresh_failed": "刷新登入失敗:{reason}", "txt_log_action_auth_refresh_failed": "刷新登入失敗:{reason}",
"txt_log_action_cipher_delete_permanent": "永久刪除密碼項", "txt_log_action_cipher_delete_permanent": "永久刪除密碼項",
"txt_log_action_cipher_delete_permanent_bulk": "批次永久刪除密碼項", "txt_log_action_cipher_delete_permanent_bulk": "批次永久刪除密碼項",
@@ -1296,6 +1342,7 @@ const zhTW: Record<string, string> = {
"txt_log_meta_method": "請求方法", "txt_log_meta_method": "請求方法",
"txt_log_meta_path": "請求路徑", "txt_log_meta_path": "請求路徑",
"txt_log_meta_provider": "服務提供方", "txt_log_meta_provider": "服務提供方",
"txt_log_meta_prf_status": "PRF 狀態",
"txt_log_meta_prune_error": "清理錯誤", "txt_log_meta_prune_error": "清理錯誤",
"txt_log_meta_pruned_file_count": "已清理檔案數", "txt_log_meta_pruned_file_count": "已清理檔案數",
"txt_log_meta_raw": "原始資料", "txt_log_meta_raw": "原始資料",
@@ -1331,6 +1378,7 @@ const zhTW: Record<string, string> = {
"txt_log_reason_user_inactive": "使用者未啟用", "txt_log_reason_user_inactive": "使用者未啟用",
"txt_log_reason_user_missing": "使用者不存在", "txt_log_reason_user_missing": "使用者不存在",
"txt_log_target_type_attachment": "附件", "txt_log_target_type_attachment": "附件",
"txt_log_target_type_account_passkey": "登入通行密鑰",
"txt_log_target_type_audit_log": "日誌", "txt_log_target_type_audit_log": "日誌",
"txt_log_target_type_backup": "備份", "txt_log_target_type_backup": "備份",
"txt_log_target_type_cipher": "密碼項", "txt_log_target_type_cipher": "密碼項",
+9 -1
View File
@@ -40,6 +40,10 @@ export interface BitwardenCipherInput {
fields?: BitwardenFieldInput[] | null; fields?: BitwardenFieldInput[] | null;
passwordHistory?: Array<{ password?: string | null; lastUsedDate?: string | null }> | null; passwordHistory?: Array<{ password?: string | null; lastUsedDate?: string | null }> | null;
sshKey?: Record<string, unknown> | null; sshKey?: Record<string, unknown> | null;
bankAccount?: Record<string, unknown> | null;
driversLicense?: Record<string, unknown> | null;
passport?: Record<string, unknown> | null;
[key: string]: unknown;
} }
export interface BitwardenJsonInput { export interface BitwardenJsonInput {
@@ -79,6 +83,7 @@ export function normalizeBitwardenImport(raw: unknown): CiphersImportPayload {
let hasAnyExplicitFolderLink = false; let hasAnyExplicitFolderLink = false;
for (const item of itemsRaw) { for (const item of itemsRaw) {
ciphers.push({ ciphers.push({
...(item && typeof item === 'object' ? item as Record<string, unknown> : {}),
id: item?.id ?? null, id: item?.id ?? null,
type: Number(item?.type || 1) || 1, type: Number(item?.type || 1) || 1,
name: item?.name ?? 'Untitled', name: item?.name ?? 'Untitled',
@@ -93,7 +98,7 @@ export function normalizeBitwardenImport(raw: unknown): CiphersImportPayload {
totp: item.login.totp ?? null, totp: item.login.totp ?? null,
fido2Credentials: Array.isArray(item.login.fido2Credentials) ? item.login.fido2Credentials : null, fido2Credentials: Array.isArray(item.login.fido2Credentials) ? item.login.fido2Credentials : null,
uris: Array.isArray(item.login.uris) uris: Array.isArray(item.login.uris)
? item.login.uris.map((u) => ({ uri: u?.uri ?? null, match: u?.match ?? null })) ? item.login.uris.map((u) => ({ ...u, uri: u?.uri ?? null, uriChecksum: u?.uriChecksum ?? null, match: u?.match ?? null }))
: null, : null,
} }
: null, : null,
@@ -114,6 +119,9 @@ export function normalizeBitwardenImport(raw: unknown): CiphersImportPayload {
.filter((x) => !!x.password) .filter((x) => !!x.password)
: null, : null,
sshKey: item?.sshKey ?? null, sshKey: item?.sshKey ?? null,
bankAccount: item?.bankAccount ?? null,
driversLicense: item?.driversLicense ?? null,
passport: item?.passport ?? null,
}); });
const folderId = txt(item?.folderId); const folderId = txt(item?.folderId);
if (!folderId) continue; if (!folderId) continue;
+122
View File
@@ -142,6 +142,86 @@ export interface CipherSshKey {
decFingerprint?: string; decFingerprint?: string;
} }
export interface CipherBankAccount {
bankName?: string | null;
nameOnAccount?: string | null;
accountType?: string | null;
accountNumber?: string | null;
routingNumber?: string | null;
branchNumber?: string | null;
pin?: string | null;
swiftCode?: string | null;
iban?: string | null;
bankContactPhone?: string | null;
decBankName?: string;
decNameOnAccount?: string;
decAccountType?: string;
decAccountNumber?: string;
decRoutingNumber?: string;
decBranchNumber?: string;
decPin?: string;
decSwiftCode?: string;
decIban?: string;
decBankContactPhone?: string;
[key: string]: unknown;
}
export interface CipherDriversLicense {
firstName?: string | null;
middleName?: string | null;
lastName?: string | null;
dateOfBirth?: string | null;
licenseNumber?: string | null;
issuingCountry?: string | null;
issuingState?: string | null;
issueDate?: string | null;
expirationDate?: string | null;
issuingAuthority?: string | null;
licenseClass?: string | null;
decFirstName?: string;
decMiddleName?: string;
decLastName?: string;
decDateOfBirth?: string;
decLicenseNumber?: string;
decIssuingCountry?: string;
decIssuingState?: string;
decIssueDate?: string;
decExpirationDate?: string;
decIssuingAuthority?: string;
decLicenseClass?: string;
[key: string]: unknown;
}
export interface CipherPassport {
surname?: string | null;
givenName?: string | null;
dateOfBirth?: string | null;
sex?: string | null;
birthPlace?: string | null;
nationality?: string | null;
issuingCountry?: string | null;
passportNumber?: string | null;
passportType?: string | null;
nationalIdentificationNumber?: string | null;
issuingAuthority?: string | null;
issueDate?: string | null;
expirationDate?: string | null;
decSurname?: string;
decGivenName?: string;
decDateOfBirth?: string;
decSex?: string;
decBirthPlace?: string;
decNationality?: string;
decIssuingCountry?: string;
decPassportNumber?: string;
decPassportType?: string;
decNationalIdentificationNumber?: string;
decIssuingAuthority?: string;
decIssueDate?: string;
decExpirationDate?: string;
[key: string]: unknown;
}
export interface CipherField { export interface CipherField {
type?: number | string | null; type?: number | string | null;
name?: string | null; name?: string | null;
@@ -175,6 +255,9 @@ export interface Cipher {
card?: CipherCard | null; card?: CipherCard | null;
identity?: CipherIdentity | null; identity?: CipherIdentity | null;
sshKey?: CipherSshKey | null; sshKey?: CipherSshKey | null;
bankAccount?: CipherBankAccount | null;
driversLicense?: CipherDriversLicense | null;
passport?: CipherPassport | null;
secureNote?: { type?: number | null } | null; secureNote?: { type?: number | null } | null;
passwordHistory?: CipherPasswordHistoryEntry[] | null; passwordHistory?: CipherPasswordHistoryEntry[] | null;
fields?: CipherField[] | null; fields?: CipherField[] | null;
@@ -276,6 +359,40 @@ export interface VaultDraft {
sshPrivateKey: string; sshPrivateKey: string;
sshPublicKey: string; sshPublicKey: string;
sshFingerprint: string; sshFingerprint: string;
bankName: string;
bankNameOnAccount: string;
bankAccountType: string;
bankAccountNumber: string;
bankRoutingNumber: string;
bankBranchNumber: string;
bankPin: string;
bankSwiftCode: string;
bankIban: string;
bankContactPhone: string;
licenseFirstName: string;
licenseMiddleName: string;
licenseLastName: string;
licenseDateOfBirth: string;
licenseNumber: string;
licenseIssuingCountry: string;
licenseIssuingState: string;
licenseIssueDate: string;
licenseExpirationDate: string;
licenseIssuingAuthority: string;
licenseClass: string;
passportSurname: string;
passportGivenName: string;
passportDateOfBirth: string;
passportSex: string;
passportBirthPlace: string;
passportNationality: string;
passportIssuingCountry: string;
passportNumber: string;
passportType: string;
passportNationalIdentificationNumber: string;
passportIssuingAuthority: string;
passportIssueDate: string;
passportExpirationDate: string;
customFields: VaultDraftField[]; customFields: VaultDraftField[];
} }
@@ -338,6 +455,11 @@ export interface TokenError {
error?: string; error?: string;
error_description?: string; error_description?: string;
TwoFactorProviders?: unknown; TwoFactorProviders?: unknown;
TwoFactorProviders2?: unknown;
CustomResponse?: {
TwoFactorProviders?: unknown;
TwoFactorProviders2?: unknown;
};
} }
export interface AccountPasskeyCredential { export interface AccountPasskeyCredential {
+24 -2
View File
@@ -19,6 +19,28 @@ const VAULT_CORE_STORE = 'vault-core';
let dbPromise: Promise<IDBDatabase | null> | null = null; let dbPromise: Promise<IDBDatabase | null> | null = null;
function stripDecryptedCacheFields<T>(value: T): T {
if (Array.isArray(value)) {
return value.map((item) => stripDecryptedCacheFields(item)) as T;
}
if (!value || typeof value !== 'object') return value;
const source = value as Record<string, unknown>;
const out: Record<string, unknown> = {};
for (const [key, item] of Object.entries(source)) {
if (/^dec[A-Z]/.test(key) || key === 'shareUrl') continue;
out[key] = stripDecryptedCacheFields(item);
}
return out as T;
}
function sanitizeSnapshotForCache(snapshot: VaultCoreSnapshot): VaultCoreSnapshot {
return {
ciphers: stripDecryptedCacheFields(Array.isArray(snapshot.ciphers) ? snapshot.ciphers : []),
folders: stripDecryptedCacheFields(Array.isArray(snapshot.folders) ? snapshot.folders : []),
sends: stripDecryptedCacheFields(Array.isArray(snapshot.sends) ? snapshot.sends : []),
};
}
function supportsIndexedDb(): boolean { function supportsIndexedDb(): boolean {
return typeof indexedDB !== 'undefined'; return typeof indexedDB !== 'undefined';
} }
@@ -72,7 +94,7 @@ export async function loadCachedVaultCoreSnapshot(cacheKey: string): Promise<Vau
const request = store.get(normalized); const request = store.get(normalized);
request.onsuccess = () => { request.onsuccess = () => {
const record = request.result as VaultCoreCacheRecord | undefined; const record = request.result as VaultCoreCacheRecord | undefined;
resolve(record || null); resolve(record ? { ...record, snapshot: sanitizeSnapshotForCache(record.snapshot) } : null);
}; };
request.onerror = () => resolve(null); request.onerror = () => resolve(null);
})); }));
@@ -90,7 +112,7 @@ export async function saveCachedVaultCoreSnapshot(
cacheKey: normalized, cacheKey: normalized,
revisionStamp, revisionStamp,
savedAt: Date.now(), savedAt: Date.now(),
snapshot, snapshot: sanitizeSnapshotForCache(snapshot),
}; };
const request = store.put(record); const request = store.put(record);
request.onsuccess = () => resolve(); request.onsuccess = () => resolve();
+61
View File
@@ -66,6 +66,31 @@ async function decryptCipherField(
return looksLikeCipherString(value) ? '' : value; return looksLikeCipherString(value) ? '' : value;
} }
async function decryptCipherObjectFields<T extends Record<string, unknown>>(
source: T | null | undefined,
fields: readonly string[],
itemEnc: Uint8Array,
itemMac: Uint8Array,
userEnc: Uint8Array,
userMac: Uint8Array,
canFallbackToUserKey: boolean
): Promise<T | null | undefined> {
if (!source || typeof source !== 'object') return source;
const next: Record<string, unknown> = { ...source };
for (const field of fields) {
const decKey = `dec${field.charAt(0).toUpperCase()}${field.slice(1)}`;
next[decKey] = await decryptCipherField(
source[field] as string | null | undefined,
itemEnc,
itemMac,
userEnc,
userMac,
canFallbackToUserKey
);
}
return next as T;
}
async function decryptFieldWithSource( async function decryptFieldWithSource(
value: string | null | undefined, value: string | null | undefined,
itemEnc: Uint8Array, itemEnc: Uint8Array,
@@ -200,6 +225,42 @@ export async function decryptVaultCore(args: DecryptVaultCoreArgs): Promise<Decr
}; };
} }
if (cipher.bankAccount) {
nextCipher.bankAccount = await decryptCipherObjectFields(
cipher.bankAccount,
['bankName', 'nameOnAccount', 'accountType', 'accountNumber', 'routingNumber', 'branchNumber', 'pin', 'swiftCode', 'iban', 'bankContactPhone'],
itemEnc,
itemMac,
userEnc,
userMac,
canFallbackToUserKey
);
}
if (cipher.driversLicense) {
nextCipher.driversLicense = await decryptCipherObjectFields(
cipher.driversLicense,
['firstName', 'middleName', 'lastName', 'dateOfBirth', 'licenseNumber', 'issuingCountry', 'issuingState', 'issueDate', 'expirationDate', 'issuingAuthority', 'licenseClass'],
itemEnc,
itemMac,
userEnc,
userMac,
canFallbackToUserKey
);
}
if (cipher.passport) {
nextCipher.passport = await decryptCipherObjectFields(
cipher.passport,
['surname', 'givenName', 'dateOfBirth', 'sex', 'birthPlace', 'nationality', 'issuingCountry', 'passportNumber', 'passportType', 'nationalIdentificationNumber', 'issuingAuthority', 'issueDate', 'expirationDate'],
itemEnc,
itemMac,
userEnc,
userMac,
canFallbackToUserKey
);
}
if (cipher.fields) { if (cipher.fields) {
nextCipher.fields = await Promise.all( nextCipher.fields = await Promise.all(
cipher.fields.map(async (field) => ({ cipher.fields.map(async (field) => ({
+42
View File
@@ -424,6 +424,48 @@ h4 {
padding: 24px; padding: 24px;
} }
.settings-home-card {
min-height: min(640px, calc(100dvh - 180px));
display: flex;
flex-direction: column;
gap: 18px;
}
.settings-home-section {
display: grid;
gap: 10px;
}
.settings-home-section h3 {
margin: 0;
color: var(--text);
font-size: var(--font-md);
}
.settings-home-spacer {
flex: 1;
min-height: 4px;
}
.mobile-settings-links {
display: grid;
gap: 8px;
align-content: start;
}
.mobile-settings-link {
display: flex;
align-items: center;
gap: 10px;
min-height: 46px;
padding: 0 12px;
border: 1px solid var(--line);
border-radius: var(--radius-md);
color: var(--text);
text-decoration: none;
font-weight: 700;
}
.auth-card h1, .auth-card h1,
.standalone-title { .standalone-title {
font-size: 26px; font-size: 26px;