mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-08-08 08:00:11 +00:00
Compare commits
36
Commits
94b5f3e975
...
v1.7.3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a0f832e8a5 | ||
|
|
8a5b210a1d | ||
|
|
ebc8e8e340 | ||
|
|
a870142b7b | ||
|
|
a366acbac0 | ||
|
|
57c5ef9da6 | ||
|
|
f532d3ace3 | ||
|
|
cc4a830be8 | ||
|
|
c6438747e3 | ||
|
|
5c8f01be59 | ||
|
|
7ac6ae50bb | ||
|
|
ace00e8e74 | ||
|
|
51428461a8 | ||
|
|
23c53bd1af | ||
|
|
ae168bea31 | ||
|
|
00e0ec0892 | ||
|
|
2df43ccdb0 | ||
|
|
fd46dffc34 | ||
|
|
56b301f2d1 | ||
|
|
f0e523376c | ||
|
|
8b2f98b847 | ||
|
|
cde4555add | ||
|
|
1bad32fd90 | ||
|
|
e376a840c2 | ||
|
|
9de0d3bd87 | ||
|
|
109593da90 | ||
|
|
01ff627ac6 | ||
|
|
d028b194e7 | ||
|
|
c53d71fc28 | ||
|
|
6e722205b1 | ||
|
|
cf14704d99 | ||
|
|
0cef6a04e9 | ||
|
|
8c481a1564 | ||
|
|
d9a36fefe6 | ||
|
|
12af18e3a3 | ||
|
|
d8cc88d9c0 |
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "nodewarden",
|
"name": "nodewarden",
|
||||||
"version": "1.7.2",
|
"version": "1.7.3",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "nodewarden",
|
"name": "nodewarden",
|
||||||
"version": "1.7.2",
|
"version": "1.7.3",
|
||||||
"license": "LGPL-3.0",
|
"license": "LGPL-3.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@noble/hashes": "^2.2.0",
|
"@noble/hashes": "^2.2.0",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "nodewarden",
|
"name": "nodewarden",
|
||||||
"version": "1.7.2",
|
"version": "1.7.3",
|
||||||
"description": "Minimal Bitwarden-compatible server running on Cloudflare Workers",
|
"description": "Minimal Bitwarden-compatible server running on Cloudflare Workers",
|
||||||
"author": "shuaiplus",
|
"author": "shuaiplus",
|
||||||
"license": "LGPL-3.0",
|
"license": "LGPL-3.0",
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
export const APP_VERSION = '1.7.2';
|
export const APP_VERSION = '1.7.3';
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+77
-13
@@ -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);
|
||||||
@@ -1089,16 +1161,8 @@ export async function handleDisableTwoFactorProvider(request: Request, env: Env,
|
|||||||
return errorResponse('Two-factor provider is not supported by this server.', 400);
|
return errorResponse('Two-factor provider is not supported by this server.', 400);
|
||||||
}
|
}
|
||||||
|
|
||||||
const key = normalizeTotpSecret(readBodyString(body, ['key', 'Key']));
|
|
||||||
const userVerificationToken = readBodyString(body, ['userVerificationToken', 'UserVerificationToken']);
|
|
||||||
const secret = readBodyString(body, ['masterPasswordHash', 'MasterPasswordHash', 'otp', 'OTP', 'secret', 'Secret']);
|
const secret = readBodyString(body, ['masterPasswordHash', 'MasterPasswordHash', 'otp', 'OTP', 'secret', 'Secret']);
|
||||||
let verified = false;
|
const verified = await verifyUserSecret(auth, user, secret);
|
||||||
if (key && userVerificationToken) {
|
|
||||||
verified = await verifyTotpUserVerificationToken(env, user, key, userVerificationToken);
|
|
||||||
}
|
|
||||||
if (!verified) {
|
|
||||||
verified = await verifyUserSecret(auth, user, secret);
|
|
||||||
}
|
|
||||||
if (!verified) return errorResponse('User verification failed.', 400);
|
if (!verified) return errorResponse('User verification failed.', 400);
|
||||||
|
|
||||||
if (type === TWO_FACTOR_PROVIDER_AUTHENTICATOR) {
|
if (type === TWO_FACTOR_PROVIDER_AUTHENTICATOR) {
|
||||||
|
|||||||
+11
-4
@@ -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 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -435,17 +439,16 @@ export async function handlePublicDownloadAttachment(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const path = getAttachmentObjectKey(cipherId, attachmentId);
|
const path = getAttachmentObjectKey(cipherId, attachmentId);
|
||||||
const object = await getBlobObject(env, path);
|
|
||||||
|
|
||||||
if (!object) {
|
|
||||||
return errorResponse('Attachment file not found', 404);
|
|
||||||
}
|
|
||||||
|
|
||||||
const firstUse = await storage.consumeAttachmentDownloadToken(claims.jti, claims.exp);
|
const firstUse = await storage.consumeAttachmentDownloadToken(claims.jti, claims.exp);
|
||||||
if (!firstUse) {
|
if (!firstUse) {
|
||||||
return errorResponse('Invalid or expired token', 401);
|
return errorResponse('Invalid or expired token', 401);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const object = await getBlobObject(env, path);
|
||||||
|
if (!object) {
|
||||||
|
return errorResponse('Attachment file not found', 404);
|
||||||
|
}
|
||||||
|
|
||||||
return new Response(object.body, {
|
return new Response(object.body, {
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': sanitizeDownloadContentType(object.contentType),
|
'Content-Type': sanitizeDownloadContentType(object.contentType),
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -94,8 +96,8 @@ function toAuthRequestResponse(request: Request, authRequest: AuthRequestRecord,
|
|||||||
RequestCountryName: authRequest.requestCountryName,
|
RequestCountryName: authRequest.requestCountryName,
|
||||||
key: authRequest.key,
|
key: authRequest.key,
|
||||||
Key: authRequest.key,
|
Key: authRequest.key,
|
||||||
masterPasswordHash: authRequest.masterPasswordHash,
|
masterPasswordHash: null,
|
||||||
MasterPasswordHash: authRequest.masterPasswordHash,
|
MasterPasswordHash: null,
|
||||||
creationDate: authRequest.creationDate,
|
creationDate: authRequest.creationDate,
|
||||||
CreationDate: authRequest.creationDate,
|
CreationDate: authRequest.creationDate,
|
||||||
responseDate: authRequest.responseDate,
|
responseDate: authRequest.responseDate,
|
||||||
@@ -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);
|
||||||
@@ -255,7 +349,6 @@ export async function handleUpdateAuthRequest(request: Request, env: Env, userId
|
|||||||
|
|
||||||
const approved = Boolean(readBodyValue(body, ['requestApproved', 'RequestApproved']));
|
const approved = Boolean(readBodyValue(body, ['requestApproved', 'RequestApproved']));
|
||||||
const key = normalizeText(readBodyValue(body, ['key', 'Key']), 20000);
|
const key = normalizeText(readBodyValue(body, ['key', 'Key']), 20000);
|
||||||
const masterPasswordHash = normalizeText(readBodyValue(body, ['masterPasswordHash', 'MasterPasswordHash']), 20000) || null;
|
|
||||||
const responseDeviceIdentifier =
|
const responseDeviceIdentifier =
|
||||||
normalizeText(readBodyValue(body, ['deviceIdentifier', 'DeviceIdentifier']), 128) ||
|
normalizeText(readBodyValue(body, ['deviceIdentifier', 'DeviceIdentifier']), 128) ||
|
||||||
readActingDeviceIdentifier(request) ||
|
readActingDeviceIdentifier(request) ||
|
||||||
@@ -272,7 +365,7 @@ export async function handleUpdateAuthRequest(request: Request, env: Env, userId
|
|||||||
approved,
|
approved,
|
||||||
responseDeviceIdentifier,
|
responseDeviceIdentifier,
|
||||||
key,
|
key,
|
||||||
masterPasswordHash,
|
masterPasswordHash: null,
|
||||||
});
|
});
|
||||||
if (!updated) return errorResponse('Auth request has already been answered.', 409);
|
if (!updated) return errorResponse('Auth request has already been answered.', 409);
|
||||||
const updatedRequest = await storage.getAuthRequestByIdForUser(id, userId);
|
const updatedRequest = await storage.getAuthRequestByIdForUser(id, userId);
|
||||||
|
|||||||
+90
-16
@@ -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) {
|
||||||
@@ -1040,12 +1062,21 @@ export async function handleDownloadAdminRemoteBackup(request: Request, env: Env
|
|||||||
export async function handleInspectAdminRemoteBackup(request: Request, env: Env, actorUser: User): Promise<Response> {
|
export async function handleInspectAdminRemoteBackup(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 integrity 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);
|
|
||||||
const remoteFile = await downloadRemoteBackupFile(destination, path);
|
const remoteFile = await downloadRemoteBackupFile(destination, path);
|
||||||
const integrity = await inspectBackupArchiveFileNameChecksum(remoteFile.bytes, remoteFile.fileName || path);
|
const integrity = await inspectBackupArchiveFileNameChecksum(remoteFile.bytes, remoteFile.fileName || path);
|
||||||
return jsonResponse({
|
return jsonResponse({
|
||||||
@@ -1063,12 +1094,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 +1236,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',
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1207,7 +1248,28 @@ export async function handleDownloadAdminBackupAttachment(request: Request, env:
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
const blobName = ensureBackupBlobName(url.searchParams.get('blobName') || '');
|
let input: { blobName?: unknown; masterPasswordHash?: unknown } = {};
|
||||||
|
if (request.method === 'POST') {
|
||||||
|
try {
|
||||||
|
input = await request.json<{ blobName?: unknown; masterPasswordHash?: unknown }>();
|
||||||
|
} catch {
|
||||||
|
return errorResponse('Backup attachment download payload is invalid', 400);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
input = {
|
||||||
|
blobName: url.searchParams.get('blobName') || '',
|
||||||
|
masterPasswordHash: url.searchParams.get('masterPasswordHash') || '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const verificationError = await requireBackupUserVerification(
|
||||||
|
actorUser,
|
||||||
|
String(input.masterPasswordHash || ''),
|
||||||
|
env
|
||||||
|
);
|
||||||
|
if (verificationError) return verificationError;
|
||||||
|
|
||||||
|
const blobName = ensureBackupBlobName(String(input.blobName || ''));
|
||||||
const object = await getBlobObject(env, blobName);
|
const object = await getBlobObject(env, blobName);
|
||||||
if (!object) {
|
if (!object) {
|
||||||
return errorResponse('Backup attachment blob not found', 404);
|
return errorResponse('Backup attachment blob not found', 404);
|
||||||
@@ -1228,6 +1290,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 +1310,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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,19 +290,27 @@ export async function handleDownloadSendFile(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const storage = new StorageService(env.DB);
|
const storage = new StorageService(env.DB);
|
||||||
const object = await getBlobObject(env, getSendFileObjectKey(sendId, fileId));
|
|
||||||
if (!object) {
|
|
||||||
return errorResponse('Send file not found', 404);
|
|
||||||
}
|
|
||||||
const send = await storage.getSend(sendId);
|
const send = await storage.getSend(sendId);
|
||||||
const data = send ? parseStoredSendData(send) : {};
|
if (!send || !isSendAvailable(send) || send.type !== SendType.File) {
|
||||||
const fileName = typeof data.fileName === 'string' ? data.fileName : fileId;
|
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 firstUse = await storage.consumeAttachmentDownloadToken(`send:${claims.jti}`, claims.exp);
|
const firstUse = await storage.consumeAttachmentDownloadToken(`send:${claims.jti}`, claims.exp);
|
||||||
if (!firstUse) {
|
if (!firstUse) {
|
||||||
return errorResponse('Invalid or expired token', 401);
|
return errorResponse('Invalid or expired token', 401);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const object = await getBlobObject(env, getSendFileObjectKey(sendId, fileId));
|
||||||
|
if (!object) {
|
||||||
|
return errorResponse('Send file not found', 404);
|
||||||
|
}
|
||||||
|
const fileName = typeof data.fileName === 'string' ? data.fileName : fileId;
|
||||||
|
|
||||||
return new Response(object.body, {
|
return new Response(object.body, {
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': sanitizeDownloadContentType(object.contentType),
|
'Content-Type': sanitizeDownloadContentType(object.contentType),
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
|||||||
+3
-3
@@ -89,7 +89,7 @@ export default {
|
|||||||
const normalizedRequest = normalizeRequestUrl(request);
|
const normalizedRequest = normalizeRequestUrl(request);
|
||||||
const assetResponse = await maybeServeAsset(normalizedRequest, env);
|
const assetResponse = await maybeServeAsset(normalizedRequest, env);
|
||||||
if (assetResponse) {
|
if (assetResponse) {
|
||||||
return applyCors(normalizedRequest, assetResponse);
|
return applyCors(normalizedRequest, assetResponse, env);
|
||||||
}
|
}
|
||||||
|
|
||||||
await ensureDatabaseInitialized(env);
|
await ensureDatabaseInitialized(env);
|
||||||
@@ -107,11 +107,11 @@ export default {
|
|||||||
},
|
},
|
||||||
500
|
500
|
||||||
);
|
);
|
||||||
return applyCors(normalizedRequest, resp);
|
return applyCors(normalizedRequest, resp, env);
|
||||||
}
|
}
|
||||||
|
|
||||||
const resp = await handleRequest(normalizedRequest, env);
|
const resp = await handleRequest(normalizedRequest, env);
|
||||||
return applyCors(normalizedRequest, resp);
|
return applyCors(normalizedRequest, resp, env);
|
||||||
},
|
},
|
||||||
|
|
||||||
async scheduled(controller: ScheduledController, env: Env, ctx: ExecutionContext): Promise<void> {
|
async scheduled(controller: ScheduledController, env: Env, ctx: ExecutionContext): Promise<void> {
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export async function handleAdminBackupRoute(
|
|||||||
return handleAdminExportBackup(request, env, actorUser);
|
return handleAdminExportBackup(request, env, actorUser);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (path === '/api/admin/backup/blob' && method === 'GET') {
|
if (path === '/api/admin/backup/blob' && (method === 'GET' || method === 'POST')) {
|
||||||
return handleDownloadAdminBackupAttachment(request, env, actorUser);
|
return handleDownloadAdminBackupAttachment(request, env, actorUser);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,7 +54,7 @@ export async function handleAdminBackupRoute(
|
|||||||
return handleDownloadAdminRemoteBackup(request, env, actorUser);
|
return handleDownloadAdminRemoteBackup(request, env, actorUser);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (path === '/api/admin/backup/remote/integrity' && method === 'GET') {
|
if (path === '/api/admin/backup/remote/integrity' && method === 'POST') {
|
||||||
return handleInspectAdminRemoteBackup(request, env, actorUser);
|
return handleInspectAdminRemoteBackup(request, env, actorUser);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
+57
-7
@@ -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,9 +32,10 @@ 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';
|
||||||
|
import { getConfiguredWebAuthnAllowedOrigins } from './utils/origins';
|
||||||
|
|
||||||
type PublicRateLimiter = (category?: string, maxRequests?: number) => Promise<Response | null>;
|
type PublicRateLimiter = (category?: string, maxRequests?: number) => Promise<Response | null>;
|
||||||
type JwtUnsafeReason = 'missing' | 'too_short' | null;
|
type JwtUnsafeReason = 'missing' | 'too_short' | null;
|
||||||
@@ -40,6 +45,12 @@ export interface WebBootstrapResponse {
|
|||||||
jwtUnsafeReason: JwtUnsafeReason;
|
jwtUnsafeReason: JwtUnsafeReason;
|
||||||
jwtSecretMinLength: number;
|
jwtSecretMinLength: number;
|
||||||
registrationInviteRequired: boolean;
|
registrationInviteRequired: boolean;
|
||||||
|
webAuthnAllowedOrigins: string[];
|
||||||
|
websiteIconsEnabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isWebsiteIconProxyEnabled(env: Env): boolean {
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isSameOriginWriteRequest(request: Request): boolean {
|
function isSameOriginWriteRequest(request: Request): boolean {
|
||||||
@@ -97,7 +108,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',
|
||||||
@@ -251,7 +262,11 @@ function iconResponse(body: BodyInit | null, contentType: string | null): Respon
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleWebsiteIcon(host: string, fallbackMode: 'default' | 'not-found' = 'default'): Promise<Response> {
|
async function handleWebsiteIcon(env: Env, host: string, fallbackMode: 'default' | 'not-found' = 'default'): Promise<Response> {
|
||||||
|
if (!isWebsiteIconProxyEnabled(env)) {
|
||||||
|
return fallbackMode === 'not-found' ? handleMissingWebsiteIcon() : handleNwFavicon();
|
||||||
|
}
|
||||||
|
|
||||||
const normalizedHost = normalizeIconHost(host);
|
const normalizedHost = normalizeIconHost(host);
|
||||||
if (!normalizedHost) return fallbackMode === 'not-found' ? handleMissingWebsiteIcon() : handleNwFavicon();
|
if (!normalizedHost) return fallbackMode === 'not-found' ? handleMissingWebsiteIcon() : handleNwFavicon();
|
||||||
|
|
||||||
@@ -318,6 +333,8 @@ export async function buildWebBootstrapResponse(env: Env): Promise<WebBootstrapR
|
|||||||
jwtUnsafeReason,
|
jwtUnsafeReason,
|
||||||
jwtSecretMinLength: LIMITS.auth.jwtSecretMinLength,
|
jwtSecretMinLength: LIMITS.auth.jwtSecretMinLength,
|
||||||
registrationInviteRequired: userCount > 0,
|
registrationInviteRequired: userCount > 0,
|
||||||
|
webAuthnAllowedOrigins: getConfiguredWebAuthnAllowedOrigins(env),
|
||||||
|
websiteIconsEnabled: isWebsiteIconProxyEnabled(env),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -350,6 +367,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);
|
||||||
@@ -362,7 +385,7 @@ export async function handlePublicRoute(
|
|||||||
const blocked = await enforcePublicRateLimit('public-icon', LIMITS.rateLimit.publicIconRequestsPerMinute);
|
const blocked = await enforcePublicRateLimit('public-icon', LIMITS.rateLimit.publicIconRequestsPerMinute);
|
||||||
if (blocked) return blocked;
|
if (blocked) return blocked;
|
||||||
const fallbackMode = new URL(request.url).searchParams.get('fallback') === '404' ? 'not-found' : 'default';
|
const fallbackMode = new URL(request.url).searchParams.get('fallback') === '404' ? 'not-found' : 'default';
|
||||||
return handleWebsiteIcon(iconMatch[1], fallbackMode);
|
return handleWebsiteIcon(env, iconMatch[1], fallbackMode);
|
||||||
}
|
}
|
||||||
|
|
||||||
const publicAttachmentMatch = path.match(/^\/api\/attachments\/([a-f0-9-]+)\/([a-f0-9-]+)$/i);
|
const publicAttachmentMatch = path.match(/^\/api\/attachments\/([a-f0-9-]+)\/([a-f0-9-]+)$/i);
|
||||||
@@ -412,13 +435,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 +488,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 +562,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;
|
||||||
|
|||||||
+74
-11
@@ -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(
|
||||||
@@ -80,20 +148,15 @@ export async function handleRequest(request: Request, env: Env): Promise<Respons
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (method === 'OPTIONS') {
|
if (method === 'OPTIONS') {
|
||||||
return handleCors(request);
|
return handleCors(request, env);
|
||||||
}
|
}
|
||||||
|
|
||||||
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)) {
|
||||||
|
|||||||
@@ -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`;
|
||||||
@@ -382,9 +442,10 @@ export function validateBackupPayloadContents(
|
|||||||
for (const row of accountPasskeyRows) {
|
for (const row of accountPasskeyRows) {
|
||||||
const id = String(row.id || '').trim();
|
const id = String(row.id || '').trim();
|
||||||
const userId = String(row.user_id || '').trim();
|
const userId = String(row.user_id || '').trim();
|
||||||
|
const purpose = row.purpose == null ? 'login' : String(row.purpose || '').trim();
|
||||||
const credentialId = String(row.credential_id || '').trim();
|
const credentialId = String(row.credential_id || '').trim();
|
||||||
const publicKey = String(row.public_key || '').trim();
|
const publicKey = String(row.public_key || '').trim();
|
||||||
if (!id || !userIds.has(userId) || !credentialId || !publicKey) {
|
if (!id || !userIds.has(userId) || !credentialId || !publicKey || (purpose !== 'login' && purpose !== 'twoFactor')) {
|
||||||
throw new Error('Backup archive contains an invalid account passkey row');
|
throw new Error('Backup archive contains an invalid account passkey row');
|
||||||
}
|
}
|
||||||
if (accountPasskeyIds.has(id)) throw new Error(`Backup archive contains duplicate account passkey id: ${id}`);
|
if (accountPasskeyIds.has(id)) throw new Error(`Backup archive contains duplicate account passkey id: ${id}`);
|
||||||
@@ -433,7 +494,7 @@ export async function buildBackupArchive(
|
|||||||
queryRows(env.DB, 'SELECT id, user_id, name, created_at, updated_at FROM folders ORDER BY created_at ASC'),
|
queryRows(env.DB, 'SELECT id, user_id, name, created_at, updated_at FROM folders ORDER BY created_at ASC'),
|
||||||
queryRows(env.DB, 'SELECT id, user_id, type, folder_id, name, notes, favorite, data, reprompt, key, created_at, updated_at, archived_at, deleted_at FROM ciphers ORDER BY created_at ASC'),
|
queryRows(env.DB, 'SELECT id, user_id, type, folder_id, name, notes, favorite, data, reprompt, key, created_at, updated_at, archived_at, deleted_at FROM ciphers ORDER BY created_at ASC'),
|
||||||
queryRows(env.DB, 'SELECT id, cipher_id, file_name, size, size_name, key FROM attachments ORDER BY cipher_id ASC, id ASC'),
|
queryRows(env.DB, 'SELECT id, cipher_id, file_name, size, size_name, key FROM attachments ORDER BY cipher_id ASC, id ASC'),
|
||||||
queryRows(env.DB, 'SELECT id, user_id, name, public_key, credential_id, counter, type, aa_guid, transports, encrypted_user_key, encrypted_public_key, encrypted_private_key, supports_prf, created_at, updated_at FROM webauthn_credentials ORDER BY created_at ASC'),
|
queryRows(env.DB, 'SELECT id, user_id, purpose, name, public_key, credential_id, counter, type, aa_guid, transports, encrypted_user_key, encrypted_public_key, encrypted_private_key, supports_prf, created_at, updated_at FROM webauthn_credentials ORDER BY created_at ASC'),
|
||||||
queryRows(env.DB, 'SELECT token, user_id, device_identifier, expires_at FROM trusted_two_factor_device_tokens WHERE expires_at >= ? ORDER BY user_id ASC, device_identifier ASC, expires_at DESC', date.getTime()),
|
queryRows(env.DB, 'SELECT token, user_id, device_identifier, expires_at FROM trusted_two_factor_device_tokens WHERE expires_at >= ? ORDER BY user_id ASC, device_identifier ASC, expires_at DESC', date.getTime()),
|
||||||
]);
|
]);
|
||||||
const exportedConfigRows = sanitizeConfigRowsForExport(configRows);
|
const exportedConfigRows = sanitizeConfigRowsForExport(configRows);
|
||||||
|
|||||||
@@ -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 {
|
||||||
@@ -67,6 +68,114 @@ function normalizePath(value: unknown): string {
|
|||||||
return asTrimmedString(value).replace(/\\/g, '/').replace(/^\/+|\/+$/g, '');
|
return asTrimmedString(value).replace(/\\/g, '/').replace(/^\/+|\/+$/g, '');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeHostnameForPolicy(hostname: string): string {
|
||||||
|
return hostname.trim().toLowerCase().replace(/^\[|\]$/g, '').replace(/\.$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseIpv4Address(hostname: string): number[] | null {
|
||||||
|
const parts = hostname.split('.');
|
||||||
|
if (parts.length !== 4) return null;
|
||||||
|
const octets = parts.map((part) => {
|
||||||
|
if (!/^\d{1,3}$/.test(part)) return -1;
|
||||||
|
const value = Number(part);
|
||||||
|
return Number.isInteger(value) && value >= 0 && value <= 255 ? value : -1;
|
||||||
|
});
|
||||||
|
return octets.every((value) => value >= 0) ? octets : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isBlockedIpv4Address(octets: number[]): boolean {
|
||||||
|
const [a, b, c] = octets;
|
||||||
|
return (
|
||||||
|
a === 0 ||
|
||||||
|
a === 10 ||
|
||||||
|
a === 127 ||
|
||||||
|
(a === 100 && b >= 64 && b <= 127) ||
|
||||||
|
(a === 169 && b === 254) ||
|
||||||
|
(a === 172 && b >= 16 && b <= 31) ||
|
||||||
|
(a === 192 && (b === 0 || b === 168)) ||
|
||||||
|
(a === 198 && (b === 18 || b === 19 || (b === 51 && c === 100))) ||
|
||||||
|
(a === 203 && b === 0 && c === 113) ||
|
||||||
|
a >= 224
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isBlockedIpv6Address(hostname: string): boolean {
|
||||||
|
if (!hostname.includes(':')) return false;
|
||||||
|
const normalized = hostname.toLowerCase();
|
||||||
|
const mappedIpv4 = normalized.match(/::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/);
|
||||||
|
if (mappedIpv4) {
|
||||||
|
const octets = parseIpv4Address(mappedIpv4[1]);
|
||||||
|
return !octets || isBlockedIpv4Address(octets);
|
||||||
|
}
|
||||||
|
const firstHextetText = normalized.split(':').find((part) => part.length > 0) || '0';
|
||||||
|
const firstHextet = Number.parseInt(firstHextetText, 16);
|
||||||
|
if (!Number.isFinite(firstHextet)) return true;
|
||||||
|
return (
|
||||||
|
firstHextet === 0 ||
|
||||||
|
(firstHextet & 0xfe00) === 0xfc00 ||
|
||||||
|
(firstHextet & 0xffc0) === 0xfe80 ||
|
||||||
|
(firstHextet & 0xff00) === 0xff00 ||
|
||||||
|
normalized.startsWith('2001:db8:')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertBackupEndpointHostAllowed(hostname: string, label: string): void {
|
||||||
|
const normalized = normalizeHostnameForPolicy(hostname);
|
||||||
|
if (!normalized) throw new Error(`${label} host is required`);
|
||||||
|
if (
|
||||||
|
normalized === 'localhost' ||
|
||||||
|
normalized === 'localhost.localdomain' ||
|
||||||
|
normalized.endsWith('.localhost.localdomain') ||
|
||||||
|
normalized.endsWith('.localhost') ||
|
||||||
|
normalized.endsWith('.local') ||
|
||||||
|
normalized.endsWith('.home.arpa') ||
|
||||||
|
normalized.endsWith('.internal') ||
|
||||||
|
normalized.endsWith('.lan') ||
|
||||||
|
normalized === 'metadata.google.internal' ||
|
||||||
|
normalized === 'localtest.me' ||
|
||||||
|
normalized.endsWith('.localtest.me') ||
|
||||||
|
normalized === 'lvh.me' ||
|
||||||
|
normalized.endsWith('.lvh.me') ||
|
||||||
|
normalized === 'vcap.me' ||
|
||||||
|
normalized.endsWith('.vcap.me') ||
|
||||||
|
normalized === 'nip.io' ||
|
||||||
|
normalized.endsWith('.nip.io') ||
|
||||||
|
normalized === 'sslip.io' ||
|
||||||
|
normalized.endsWith('.sslip.io') ||
|
||||||
|
normalized === 'xip.io' ||
|
||||||
|
normalized.endsWith('.xip.io')
|
||||||
|
) {
|
||||||
|
throw new Error(`${label} host is not allowed`);
|
||||||
|
}
|
||||||
|
const ipv4 = parseIpv4Address(normalized);
|
||||||
|
if (ipv4 && isBlockedIpv4Address(ipv4)) {
|
||||||
|
throw new Error(`${label} host is not allowed`);
|
||||||
|
}
|
||||||
|
if (isBlockedIpv6Address(normalized)) {
|
||||||
|
throw new Error(`${label} host is not allowed`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeBackupEndpointUrl(value: string, label: string): string {
|
||||||
|
let parsed: URL;
|
||||||
|
try {
|
||||||
|
parsed = new URL(value);
|
||||||
|
} catch {
|
||||||
|
throw new Error(`${label} must be a valid URL`);
|
||||||
|
}
|
||||||
|
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
||||||
|
throw new Error(`${label} must start with http:// or https://`);
|
||||||
|
}
|
||||||
|
if (parsed.username || parsed.password) {
|
||||||
|
throw new Error(`${label} must not include credentials`);
|
||||||
|
}
|
||||||
|
if (parsed.search || parsed.hash) {
|
||||||
|
throw new Error(`${label} must not include query or fragment`);
|
||||||
|
}
|
||||||
|
assertBackupEndpointHostAllowed(parsed.hostname, label);
|
||||||
|
return parsed.toString().replace(/\/+$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
function assertValidTimeZone(timezone: string): string {
|
function assertValidTimeZone(timezone: string): string {
|
||||||
try {
|
try {
|
||||||
new Intl.DateTimeFormat('en-US', { timeZone: timezone }).format(new Date());
|
new Intl.DateTimeFormat('en-US', { timeZone: timezone }).format(new Date());
|
||||||
@@ -122,7 +231,7 @@ function normalizeS3Destination(value: unknown, allowIncomplete = false): S3Back
|
|||||||
|
|
||||||
if (!allowIncomplete || endpoint) {
|
if (!allowIncomplete || endpoint) {
|
||||||
if (!endpoint) throw new Error('S3 endpoint is required');
|
if (!endpoint) throw new Error('S3 endpoint is required');
|
||||||
if (!/^https?:\/\//i.test(endpoint)) throw new Error('S3 endpoint must start with http:// or https://');
|
normalizeBackupEndpointUrl(endpoint, 'S3 endpoint');
|
||||||
}
|
}
|
||||||
if (!allowIncomplete || bucket) {
|
if (!allowIncomplete || bucket) {
|
||||||
if (!bucket) throw new Error('S3 bucket is required');
|
if (!bucket) throw new Error('S3 bucket is required');
|
||||||
@@ -135,7 +244,7 @@ function normalizeS3Destination(value: unknown, allowIncomplete = false): S3Back
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
endpoint: endpoint ? endpoint.replace(/\/+$/, '') : '',
|
endpoint: endpoint ? normalizeBackupEndpointUrl(endpoint, 'S3 endpoint') : '',
|
||||||
bucket,
|
bucket,
|
||||||
addressingStyle,
|
addressingStyle,
|
||||||
region,
|
region,
|
||||||
@@ -154,7 +263,7 @@ function normalizeWebDavDestination(value: unknown, allowIncomplete = false): We
|
|||||||
|
|
||||||
if (!allowIncomplete || baseUrl) {
|
if (!allowIncomplete || baseUrl) {
|
||||||
if (!baseUrl) throw new Error('WebDAV server URL is required');
|
if (!baseUrl) throw new Error('WebDAV server URL is required');
|
||||||
if (!/^https?:\/\//i.test(baseUrl)) throw new Error('WebDAV server URL must start with http:// or https://');
|
normalizeBackupEndpointUrl(baseUrl, 'WebDAV server URL');
|
||||||
}
|
}
|
||||||
if (!allowIncomplete || username) {
|
if (!allowIncomplete || username) {
|
||||||
if (!username) throw new Error('WebDAV username is required');
|
if (!username) throw new Error('WebDAV username is required');
|
||||||
@@ -164,7 +273,7 @@ function normalizeWebDavDestination(value: unknown, allowIncomplete = false): We
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
baseUrl: baseUrl ? baseUrl.replace(/\/+$/, '') : '',
|
baseUrl: baseUrl ? normalizeBackupEndpointUrl(baseUrl, 'WebDAV server URL') : '',
|
||||||
username,
|
username,
|
||||||
password,
|
password,
|
||||||
remotePath,
|
remotePath,
|
||||||
@@ -180,6 +289,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 +385,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 +571,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> => (
|
||||||
|
|||||||
@@ -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';
|
||||||
@@ -253,6 +254,10 @@ function cloneRows(rows: SqlRow[]): SqlRow[] {
|
|||||||
return rows.map((row) => ({ ...row }));
|
return rows.map((row) => ({ ...row }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeAccountPasskeyPurpose(value: unknown): 'login' | 'twoFactor' {
|
||||||
|
return value == null ? 'login' : String(value).trim() === 'twoFactor' ? 'twoFactor' : 'login';
|
||||||
|
}
|
||||||
|
|
||||||
function upsertConfigRow(rows: SqlRow[], key: string, value: string): SqlRow[] {
|
function upsertConfigRow(rows: SqlRow[], key: string, value: string): SqlRow[] {
|
||||||
let replaced = false;
|
let replaced = false;
|
||||||
const nextRows = rows.map((row) => {
|
const nextRows = rows.map((row) => {
|
||||||
@@ -302,7 +307,10 @@ async function importPreparedBackupRows(db: D1Database, payload: BackupPayload['
|
|||||||
domain_settings: cloneRows(payload.domain_settings || []),
|
domain_settings: cloneRows(payload.domain_settings || []),
|
||||||
user_revisions: cloneRows(payload.user_revisions || []),
|
user_revisions: cloneRows(payload.user_revisions || []),
|
||||||
trusted_two_factor_device_tokens: cloneRows(payload.trusted_two_factor_device_tokens || []),
|
trusted_two_factor_device_tokens: cloneRows(payload.trusted_two_factor_device_tokens || []),
|
||||||
webauthn_credentials: cloneRows(payload.webauthn_credentials || []),
|
webauthn_credentials: cloneRows(payload.webauthn_credentials || []).map((row) => ({
|
||||||
|
...row,
|
||||||
|
purpose: normalizeAccountPasskeyPurpose(row.purpose),
|
||||||
|
})),
|
||||||
folders: cloneRows(payload.folders || []),
|
folders: cloneRows(payload.folders || []),
|
||||||
ciphers: cloneRows(payload.ciphers || []).map((row) => ({
|
ciphers: cloneRows(payload.ciphers || []).map((row) => ({
|
||||||
...row,
|
...row,
|
||||||
@@ -462,9 +470,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(
|
||||||
@@ -656,7 +675,7 @@ async function importBackupRows(db: D1Database, payload: BackupPayload['db'], us
|
|||||||
buildInsertStatements(
|
buildInsertStatements(
|
||||||
db,
|
db,
|
||||||
tableName('webauthn_credentials'),
|
tableName('webauthn_credentials'),
|
||||||
['id', 'user_id', 'name', 'public_key', 'credential_id', 'counter', 'type', 'aa_guid', 'transports', 'encrypted_user_key', 'encrypted_public_key', 'encrypted_private_key', 'supports_prf', 'created_at', 'updated_at'],
|
['id', 'user_id', 'purpose', 'name', 'public_key', 'credential_id', 'counter', 'type', 'aa_guid', 'transports', 'encrypted_user_key', 'encrypted_public_key', 'encrypted_private_key', 'supports_prf', 'created_at', 'updated_at'],
|
||||||
payload.webauthn_credentials || []
|
payload.webauthn_credentials || []
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import {
|
|||||||
BackupDestinationType,
|
BackupDestinationType,
|
||||||
S3BackupDestination,
|
S3BackupDestination,
|
||||||
WebDavBackupDestination,
|
WebDavBackupDestination,
|
||||||
|
normalizeBackupEndpointUrl,
|
||||||
} from './backup-config';
|
} from './backup-config';
|
||||||
|
|
||||||
export interface BackupUploadResult {
|
export interface BackupUploadResult {
|
||||||
@@ -215,7 +216,7 @@ function ensureDestinationConfigReady(destination: BackupDestinationRecord): voi
|
|||||||
if (destination.type === 'webdav') {
|
if (destination.type === 'webdav') {
|
||||||
const config = destination.destination as WebDavBackupDestination;
|
const config = destination.destination as WebDavBackupDestination;
|
||||||
if (!String(config.baseUrl || '').trim()) throw new Error('WebDAV server URL is required');
|
if (!String(config.baseUrl || '').trim()) throw new Error('WebDAV server URL is required');
|
||||||
if (!/^https?:\/\//i.test(String(config.baseUrl || '').trim())) throw new Error('WebDAV server URL must start with http:// or https://');
|
normalizeBackupEndpointUrl(String(config.baseUrl || '').trim(), 'WebDAV server URL');
|
||||||
if (!String(config.username || '').trim()) throw new Error('WebDAV username is required');
|
if (!String(config.username || '').trim()) throw new Error('WebDAV username is required');
|
||||||
if (!String(config.password || '')) throw new Error('WebDAV password is required');
|
if (!String(config.password || '')) throw new Error('WebDAV password is required');
|
||||||
return;
|
return;
|
||||||
@@ -223,7 +224,7 @@ function ensureDestinationConfigReady(destination: BackupDestinationRecord): voi
|
|||||||
if (destination.type === 's3') {
|
if (destination.type === 's3') {
|
||||||
const config = destination.destination as S3BackupDestination;
|
const config = destination.destination as S3BackupDestination;
|
||||||
if (!String(config.endpoint || '').trim()) throw new Error('S3 endpoint is required');
|
if (!String(config.endpoint || '').trim()) throw new Error('S3 endpoint is required');
|
||||||
if (!/^https?:\/\//i.test(String(config.endpoint || '').trim())) throw new Error('S3 endpoint must start with http:// or https://');
|
normalizeBackupEndpointUrl(String(config.endpoint || '').trim(), 'S3 endpoint');
|
||||||
if (!String(config.bucket || '').trim()) throw new Error('S3 bucket is required');
|
if (!String(config.bucket || '').trim()) throw new Error('S3 bucket is required');
|
||||||
if (!String(config.accessKeyId || '').trim()) throw new Error('S3 access key is required');
|
if (!String(config.accessKeyId || '').trim()) throw new Error('S3 access key is required');
|
||||||
if (!String(config.secretAccessKey || '')) throw new Error('S3 secret key is required');
|
if (!String(config.secretAccessKey || '')) throw new Error('S3 secret key is required');
|
||||||
@@ -252,7 +253,7 @@ async function ensureWebDavDirectory(baseUrl: string, directoryPath: string, aut
|
|||||||
Authorization: authHeader,
|
Authorization: authHeader,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if ([200, 201, 204, 301, 302, 405].includes(response.status)) continue;
|
if ([200, 201, 204, 405].includes(response.status)) continue;
|
||||||
throw new Error(`WebDAV directory creation failed: ${response.status}`);
|
throw new Error(`WebDAV directory creation failed: ${response.status}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -275,7 +276,7 @@ async function ensureWebDavDirectoryCached(
|
|||||||
Authorization: authHeader,
|
Authorization: authHeader,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if ([200, 201, 204, 301, 302, 405].includes(response.status)) {
|
if ([200, 201, 204, 405].includes(response.status)) {
|
||||||
ensuredDirectories.add(current);
|
ensuredDirectories.add(current);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -518,7 +519,7 @@ async function signedS3Request(
|
|||||||
config.region || 'auto'
|
config.region || 'auto'
|
||||||
);
|
);
|
||||||
|
|
||||||
return fetch(url.toString(), {
|
return fetch(url, {
|
||||||
method,
|
method,
|
||||||
headers: {
|
headers: {
|
||||||
Authorization: authorization,
|
Authorization: authorization,
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import type {
|
|||||||
WebAuthnPrfDecryptionOption,
|
WebAuthnPrfDecryptionOption,
|
||||||
} from '../types';
|
} from '../types';
|
||||||
import { base64UrlToBytes, bytesToBase64Url } from './passkey';
|
import { base64UrlToBytes, bytesToBase64Url } from './passkey';
|
||||||
|
import { getConfiguredWebAuthnAllowedOrigins } from './origins';
|
||||||
|
|
||||||
const ACCOUNT_PASSKEY_TOKEN_TYPE = 'nodewarden.account-passkey.challenge.v1';
|
const ACCOUNT_PASSKEY_TOKEN_TYPE = 'nodewarden.account-passkey.challenge.v1';
|
||||||
const ACCOUNT_PASSKEY_TOKEN_TTL_MS = 17 * 60 * 1000;
|
const ACCOUNT_PASSKEY_TOKEN_TTL_MS = 17 * 60 * 1000;
|
||||||
@@ -32,6 +33,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']);
|
||||||
}
|
}
|
||||||
@@ -121,33 +160,22 @@ export function getAccountPasskeyRpConfig(request: Request, env: Env): { rpId: s
|
|||||||
const configuredRpId = String(env.WEBAUTHN_RP_ID || '').trim();
|
const configuredRpId = String(env.WEBAUTHN_RP_ID || '').trim();
|
||||||
const rpId = configuredRpId || url.hostname;
|
const rpId = configuredRpId || url.hostname;
|
||||||
const rpName = String(env.WEBAUTHN_RP_NAME || '').trim() || DEFAULT_RP_NAME;
|
const rpName = String(env.WEBAUTHN_RP_NAME || '').trim() || DEFAULT_RP_NAME;
|
||||||
const configuredOrigins = String(env.WEBAUTHN_ALLOWED_ORIGINS || '')
|
const configuredOrigins = getConfiguredWebAuthnAllowedOrigins(env);
|
||||||
.split(',')
|
|
||||||
.map((origin) => origin.trim())
|
|
||||||
.filter(Boolean);
|
|
||||||
const origins = new Set<string>([url.origin, ...configuredOrigins]);
|
const origins = new Set<string>([url.origin, ...configuredOrigins]);
|
||||||
const requestOrigin = request.headers.get('Origin');
|
|
||||||
if (
|
|
||||||
requestOrigin
|
|
||||||
&& (
|
|
||||||
requestOrigin.startsWith('chrome-extension://')
|
|
||||||
|| requestOrigin.startsWith('moz-extension://')
|
|
||||||
|| requestOrigin.startsWith('safari-web-extension://')
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
origins.add(requestOrigin);
|
|
||||||
}
|
|
||||||
return { rpId, rpName, origins: Array.from(origins) };
|
return { rpId, rpName, origins: Array.from(origins) };
|
||||||
}
|
}
|
||||||
|
|
||||||
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 +237,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 +260,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,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import type { Env } from '../types';
|
||||||
|
|
||||||
|
export function normalizeOrigin(value: unknown): string | null {
|
||||||
|
const raw = String(value || '').trim();
|
||||||
|
if (!raw) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = new URL(raw);
|
||||||
|
if (!url.protocol || !url.host) return null;
|
||||||
|
return `${url.protocol}//${url.host}`;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isBrowserExtensionOrigin(origin: unknown): boolean {
|
||||||
|
const normalized = normalizeOrigin(origin);
|
||||||
|
return !!normalized && (
|
||||||
|
normalized.startsWith('chrome-extension://')
|
||||||
|
|| normalized.startsWith('moz-extension://')
|
||||||
|
|| normalized.startsWith('safari-web-extension://')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getConfiguredWebAuthnAllowedOrigins(
|
||||||
|
env: Pick<Env, 'WEBAUTHN_ALLOWED_ORIGINS'>
|
||||||
|
): string[] {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
for (const item of String(env.WEBAUTHN_ALLOWED_ORIGINS || '').split(',')) {
|
||||||
|
const origin = normalizeOrigin(item);
|
||||||
|
if (origin) seen.add(origin);
|
||||||
|
}
|
||||||
|
return Array.from(seen);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isConfiguredWebAuthnAllowedOrigin(
|
||||||
|
env: Pick<Env, 'WEBAUTHN_ALLOWED_ORIGINS'>,
|
||||||
|
origin: unknown
|
||||||
|
): boolean {
|
||||||
|
const normalized = normalizeOrigin(origin);
|
||||||
|
return !!normalized && getConfiguredWebAuthnAllowedOrigins(env).includes(normalized);
|
||||||
|
}
|
||||||
+25
-18
@@ -1,4 +1,10 @@
|
|||||||
import { LIMITS } from '../config/limits';
|
import { LIMITS } from '../config/limits';
|
||||||
|
import type { Env } from '../types';
|
||||||
|
import {
|
||||||
|
isBrowserExtensionOrigin,
|
||||||
|
isConfiguredWebAuthnAllowedOrigin,
|
||||||
|
normalizeOrigin,
|
||||||
|
} from './origins';
|
||||||
|
|
||||||
const CORS_METHODS = 'GET, POST, PUT, DELETE, PATCH, OPTIONS';
|
const CORS_METHODS = 'GET, POST, PUT, DELETE, PATCH, OPTIONS';
|
||||||
const DEFAULT_CORS_HEADERS = [
|
const DEFAULT_CORS_HEADERS = [
|
||||||
@@ -18,35 +24,31 @@ const DEFAULT_CORS_HEADERS = [
|
|||||||
'X-NodeWarden-Web-Session',
|
'X-NodeWarden-Web-Session',
|
||||||
];
|
];
|
||||||
|
|
||||||
function isExtensionOrigin(origin: string): boolean {
|
|
||||||
return (
|
|
||||||
origin.startsWith('chrome-extension://')
|
|
||||||
|| origin.startsWith('moz-extension://')
|
|
||||||
|| origin.startsWith('safari-web-extension://')
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
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'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getCorsPolicy(request: Request): { allowOrigin: string | null; allowCredentials: boolean } {
|
function getCorsPolicy(request: Request, env: Env): { allowOrigin: string | null; allowCredentials: boolean } {
|
||||||
const url = new URL(request.url);
|
const url = new URL(request.url);
|
||||||
const origin = request.headers.get('Origin');
|
const originHeader = request.headers.get('Origin');
|
||||||
if (!origin) {
|
if (!originHeader) {
|
||||||
return isWildcardCorsPath(url.pathname)
|
return isWildcardCorsPath(url.pathname)
|
||||||
? { allowOrigin: '*', allowCredentials: false }
|
? { allowOrigin: '*', allowCredentials: false }
|
||||||
: { allowOrigin: null, allowCredentials: false };
|
: { allowOrigin: null, allowCredentials: false };
|
||||||
}
|
}
|
||||||
|
const origin = normalizeOrigin(originHeader);
|
||||||
if (origin === url.origin) {
|
if (origin === url.origin) {
|
||||||
return { allowOrigin: origin, allowCredentials: true };
|
return { allowOrigin: origin, allowCredentials: true };
|
||||||
}
|
}
|
||||||
if (isExtensionOrigin(origin)) {
|
if (isBrowserExtensionOrigin(origin) && isConfiguredWebAuthnAllowedOrigin(env, origin)) {
|
||||||
return { allowOrigin: origin, allowCredentials: true };
|
return { allowOrigin: origin, allowCredentials: true };
|
||||||
}
|
}
|
||||||
if (isWildcardCorsPath(url.pathname)) {
|
if (isWildcardCorsPath(url.pathname)) {
|
||||||
@@ -55,7 +57,7 @@ function getCorsPolicy(request: Request): { allowOrigin: string | null; allowCre
|
|||||||
return { allowOrigin: null, allowCredentials: false };
|
return { allowOrigin: null, allowCredentials: false };
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildCorsHeaders(request: Request): Record<string, string> {
|
function buildCorsHeaders(request: Request, env: Env): Record<string, string> {
|
||||||
const requestedHeaders = String(request.headers.get('Access-Control-Request-Headers') || '')
|
const requestedHeaders = String(request.headers.get('Access-Control-Request-Headers') || '')
|
||||||
.split(',')
|
.split(',')
|
||||||
.map((value) => value.trim())
|
.map((value) => value.trim())
|
||||||
@@ -69,7 +71,7 @@ function buildCorsHeaders(request: Request): Record<string, string> {
|
|||||||
'Access-Control-Max-Age': String(LIMITS.cors.preflightMaxAgeSeconds),
|
'Access-Control-Max-Age': String(LIMITS.cors.preflightMaxAgeSeconds),
|
||||||
};
|
};
|
||||||
|
|
||||||
const corsPolicy = getCorsPolicy(request);
|
const corsPolicy = getCorsPolicy(request, env);
|
||||||
if (corsPolicy.allowOrigin) {
|
if (corsPolicy.allowOrigin) {
|
||||||
headers['Access-Control-Allow-Origin'] = corsPolicy.allowOrigin;
|
headers['Access-Control-Allow-Origin'] = corsPolicy.allowOrigin;
|
||||||
if (corsPolicy.allowCredentials) {
|
if (corsPolicy.allowCredentials) {
|
||||||
@@ -83,7 +85,8 @@ function buildCorsHeaders(request: Request): Record<string, string> {
|
|||||||
|
|
||||||
export function applyCors(
|
export function applyCors(
|
||||||
request: Request,
|
request: Request,
|
||||||
response: Response
|
response: Response,
|
||||||
|
env: Env
|
||||||
): Response {
|
): Response {
|
||||||
// WebSocket upgrade responses must be returned untouched.
|
// WebSocket upgrade responses must be returned untouched.
|
||||||
const webSocket = (response as Response & { webSocket?: unknown }).webSocket;
|
const webSocket = (response as Response & { webSocket?: unknown }).webSocket;
|
||||||
@@ -92,7 +95,7 @@ export function applyCors(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const headers = new Headers(response.headers);
|
const headers = new Headers(response.headers);
|
||||||
const corsHeaders = buildCorsHeaders(request);
|
const corsHeaders = buildCorsHeaders(request, env);
|
||||||
for (const [k, v] of Object.entries(corsHeaders)) {
|
for (const [k, v] of Object.entries(corsHeaders)) {
|
||||||
headers.set(k, v);
|
headers.set(k, v);
|
||||||
}
|
}
|
||||||
@@ -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(
|
||||||
@@ -152,10 +159,10 @@ export function identityErrorResponse(message: string, error: string = 'invalid_
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Handle CORS preflight
|
// Handle CORS preflight
|
||||||
export function handleCors(request: Request): Response {
|
export function handleCors(request: Request, env: Env): Response {
|
||||||
return new Response(null, {
|
return new Response(null, {
|
||||||
status: 204,
|
status: 204,
|
||||||
headers: buildCorsHeaders(request),
|
headers: buildCorsHeaders(request, env),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -170,6 +170,7 @@
|
|||||||
(function () {
|
(function () {
|
||||||
var params = new URLSearchParams(window.location.search);
|
var params = new URLSearchParams(window.location.search);
|
||||||
var sentSuccess = false;
|
var sentSuccess = false;
|
||||||
|
var allowedParentOriginsPromise = null;
|
||||||
|
|
||||||
var text = pickText(params.get("locale") || navigator.language || "en");
|
var text = pickText(params.get("locale") || navigator.language || "en");
|
||||||
document.documentElement.lang = params.get("locale") || navigator.language || "en";
|
document.documentElement.lang = params.get("locale") || navigator.language || "en";
|
||||||
@@ -227,6 +228,81 @@
|
|||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeOrigin(value) {
|
||||||
|
if (!value) return "";
|
||||||
|
try {
|
||||||
|
var url = new URL(value);
|
||||||
|
if (!url.protocol || !url.host) return "";
|
||||||
|
return url.protocol + "//" + url.host;
|
||||||
|
} catch (_error) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isExtensionOrigin(origin) {
|
||||||
|
return (
|
||||||
|
origin.indexOf("chrome-extension://") === 0 ||
|
||||||
|
origin.indexOf("moz-extension://") === 0 ||
|
||||||
|
origin.indexOf("safari-web-extension://") === 0
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function allowedParentOrigins() {
|
||||||
|
if (allowedParentOriginsPromise) return allowedParentOriginsPromise;
|
||||||
|
allowedParentOriginsPromise = fetch("/api/web-bootstrap", {
|
||||||
|
headers: { Accept: "application/json" },
|
||||||
|
credentials: "omit",
|
||||||
|
}).then(function (response) {
|
||||||
|
if (!response.ok) return [];
|
||||||
|
return response.json();
|
||||||
|
}).then(function (body) {
|
||||||
|
var origins = Array.isArray(body && body.webAuthnAllowedOrigins)
|
||||||
|
? body.webAuthnAllowedOrigins
|
||||||
|
: [];
|
||||||
|
return origins.map(normalizeOrigin).filter(Boolean);
|
||||||
|
}).catch(function () {
|
||||||
|
return [];
|
||||||
|
});
|
||||||
|
return allowedParentOriginsPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
function trustedParentOrigin(allowedOrigins) {
|
||||||
|
var parent = decodeRepeated(params.get("parent"));
|
||||||
|
if (!parent) return "";
|
||||||
|
var parentOrigin = normalizeOrigin(parent);
|
||||||
|
if (!parentOrigin) return "";
|
||||||
|
if (parentOrigin === window.location.origin) {
|
||||||
|
return parentOrigin;
|
||||||
|
}
|
||||||
|
if (isExtensionOrigin(parentOrigin) && allowedOrigins.indexOf(parentOrigin) >= 0) {
|
||||||
|
return parentOrigin;
|
||||||
|
}
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function postResult(message) {
|
||||||
|
var parentOrigin = trustedParentOrigin(await allowedParentOrigins());
|
||||||
|
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 +355,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 +403,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({
|
await 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) {
|
||||||
|
|||||||
+16
-10
@@ -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);
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -1165,7 +1167,6 @@ export default function App() {
|
|||||||
const key = await encryptSessionUserKeyForAuthRequest(session, authRequest);
|
const key = await encryptSessionUserKeyForAuthRequest(session, authRequest);
|
||||||
await respondToAuthRequest(authedFetch, authRequest.id, {
|
await respondToAuthRequest(authedFetch, authRequest.id, {
|
||||||
key,
|
key,
|
||||||
masterPasswordHash: null,
|
|
||||||
deviceIdentifier: getCurrentDeviceIdentifier(),
|
deviceIdentifier: getCurrentDeviceIdentifier(),
|
||||||
requestApproved: true,
|
requestApproved: true,
|
||||||
});
|
});
|
||||||
@@ -1862,7 +1863,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(/\/+$/, '');
|
||||||
@@ -2120,8 +2120,14 @@ export default function App() {
|
|||||||
const hash = await deriveCurrentMasterPasswordHash(masterPassword);
|
const hash = await deriveCurrentMasterPasswordHash(masterPassword);
|
||||||
return backupActions.downloadRemoteBackup(hash, destinationId, path, onProgress);
|
return backupActions.downloadRemoteBackup(hash, destinationId, path, onProgress);
|
||||||
},
|
},
|
||||||
onInspectRemoteBackup: backupActions.inspectRemoteBackup,
|
onInspectRemoteBackup: async (masterPassword: string, destinationId: string, path: string) => {
|
||||||
onDeleteRemoteBackup: backupActions.deleteRemoteBackup,
|
const hash = await deriveCurrentMasterPasswordHash(masterPassword);
|
||||||
|
return backupActions.inspectRemoteBackup(hash, destinationId, path);
|
||||||
|
},
|
||||||
|
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'))}
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -168,8 +168,8 @@ export interface AppMainRoutesProps {
|
|||||||
onRunRemoteBackup: (masterPassword: string, destinationId?: string | null) => Promise<AdminBackupRunResponse>;
|
onRunRemoteBackup: (masterPassword: string, destinationId?: string | null) => Promise<AdminBackupRunResponse>;
|
||||||
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: (masterPassword: string, 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,43 +320,55 @@ 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="mobile-settings-links">
|
<div className="settings-home-section">
|
||||||
<Link href={props.settingsAccountRoute} className="mobile-settings-link">
|
<h3>{t('txt_settings')}</h3>
|
||||||
<SettingsIcon size={18} />
|
<div className="mobile-settings-links">
|
||||||
<span>{t('nav_account_settings')}</span>
|
<Link href={props.settingsAccountRoute} className="mobile-settings-link">
|
||||||
</Link>
|
<SettingsIcon size={18} />
|
||||||
<Link href="/settings/security/device-management" className="mobile-settings-link">
|
<span>{t('nav_account_settings')}</span>
|
||||||
<Shield size={18} />
|
|
||||||
<span>{t('nav_device_management')}</span>
|
|
||||||
</Link>
|
|
||||||
<Link href="/settings/domain-rules" className="mobile-settings-link">
|
|
||||||
<Globe2 size={18} />
|
|
||||||
<span>{t('nav_domain_rules')}</span>
|
|
||||||
</Link>
|
|
||||||
<Link href={props.importRoute} className="mobile-settings-link">
|
|
||||||
<ArrowUpDown size={18} />
|
|
||||||
<span>{t('nav_import_export')}</span>
|
|
||||||
</Link>
|
|
||||||
{isAdmin && (
|
|
||||||
<Link href="/admin" className="mobile-settings-link">
|
|
||||||
<ShieldUser size={18} />
|
|
||||||
<span>{t('nav_admin_panel')}</span>
|
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
<Link href="/settings/security/device-management" className="mobile-settings-link">
|
||||||
{isAdmin && (
|
<Shield size={18} />
|
||||||
<Link href="/logs" className="mobile-settings-link">
|
<span>{t('nav_device_management')}</span>
|
||||||
<FileClock size={18} />
|
|
||||||
<span>{t('nav_log_center')}</span>
|
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
<Link href="/settings/domain-rules" className="mobile-settings-link">
|
||||||
{isAdmin && (
|
<Globe2 size={18} />
|
||||||
<Link href="/backup" className="mobile-settings-link">
|
<span>{t('nav_domain_rules')}</span>
|
||||||
<Cloud size={18} />
|
|
||||||
<span>{t('nav_backup_strategy')}</span>
|
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
</div>
|
||||||
</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">
|
||||||
|
<ArrowUpDown size={18} />
|
||||||
|
<span>{t('nav_import_export')}</span>
|
||||||
|
</Link>
|
||||||
|
{isAdmin && (
|
||||||
|
<Link href="/backup" className="mobile-settings-link">
|
||||||
|
<Cloud size={18} />
|
||||||
|
<span>{t('nav_backup_strategy')}</span>
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</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')}
|
||||||
|
|||||||
@@ -42,8 +42,8 @@ interface BackupCenterPageProps {
|
|||||||
onRunRemoteBackup: (masterPassword: string, destinationId?: string | null) => Promise<AdminBackupRunResponse>;
|
onRunRemoteBackup: (masterPassword: string, destinationId?: string | null) => Promise<AdminBackupRunResponse>;
|
||||||
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: (masterPassword: string, 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('');
|
||||||
@@ -245,11 +247,29 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
|||||||
? t('txt_backup_save_settings')
|
? t('txt_backup_save_settings')
|
||||||
: pendingBackupVerification?.action === 'runRemoteBackup'
|
: pendingBackupVerification?.action === 'runRemoteBackup'
|
||||||
? 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 === 'restoreRemote'
|
: pendingBackupVerification?.action === 'deleteRemote'
|
||||||
? t('txt_backup_import')
|
? t('txt_delete')
|
||||||
: t('txt_backup_import');
|
: pendingBackupVerification?.action === 'restoreRemote'
|
||||||
|
? 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;
|
||||||
@@ -472,8 +492,8 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
|||||||
return verifyBackupFileIntegrity(bytes, file.name || '');
|
return verifyBackupFileIntegrity(bytes, file.name || '');
|
||||||
}
|
}
|
||||||
|
|
||||||
async function inspectRemoteBackupFile(destinationId: string, path: string): Promise<PendingRestoreIntegrity> {
|
async function inspectRemoteBackupFile(masterPassword: string, destinationId: string, path: string): Promise<PendingRestoreIntegrity> {
|
||||||
const payload = await props.onInspectRemoteBackup(destinationId, path);
|
const payload = await props.onInspectRemoteBackup(masterPassword, destinationId, path);
|
||||||
return {
|
return {
|
||||||
source: 'remote',
|
source: 'remote',
|
||||||
path,
|
path,
|
||||||
@@ -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('');
|
||||||
}
|
}
|
||||||
@@ -779,19 +800,7 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
|
|||||||
if (!savedSelectedDestination) return;
|
if (!savedSelectedDestination) return;
|
||||||
setLocalError('');
|
setLocalError('');
|
||||||
resetPendingIntegrityWarning();
|
resetPendingIntegrityWarning();
|
||||||
try {
|
await runRemoteRestore(path, false);
|
||||||
const integrity = await inspectRemoteBackupFile(savedSelectedDestination.id, path);
|
|
||||||
if (!integrity.result.matches) {
|
|
||||||
setPendingRestoreIntegrity(integrity);
|
|
||||||
setConfirmIntegrityWarningOpen(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
await runRemoteRestore(path, false, false, integrity.result);
|
|
||||||
} catch (error) {
|
|
||||||
const message = error instanceof Error ? error.message : t('txt_backup_integrity_check_failed');
|
|
||||||
setLocalError(message);
|
|
||||||
props.onNotify('error', message);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runRemoteRestore(
|
async function runRemoteRestore(
|
||||||
@@ -802,14 +811,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,15 +826,31 @@ 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);
|
||||||
setLocalError('');
|
setLocalError('');
|
||||||
try {
|
try {
|
||||||
const integrity = knownIntegrity ? { result: knownIntegrity } : await inspectRemoteBackupFile(savedSelectedDestination.id, path);
|
const integrity = knownIntegrity
|
||||||
|
? { result: knownIntegrity }
|
||||||
|
: await inspectRemoteBackupFile(masterPassword, savedSelectedDestination.id, path);
|
||||||
|
if (!allowChecksumMismatch && !integrity.result.matches) {
|
||||||
|
setPendingRestoreIntegrity(
|
||||||
|
'source' in integrity
|
||||||
|
? integrity
|
||||||
|
: {
|
||||||
|
source: 'remote',
|
||||||
|
path,
|
||||||
|
fileName: path.split('/').pop() || path,
|
||||||
|
result: integrity.result,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
setConfirmIntegrityWarningOpen(true);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
startRestoreProgress('backup-restore', path.split('/').pop() || path, {
|
startRestoreProgress('backup-restore', path.split('/').pop() || path, {
|
||||||
source: 'remote',
|
source: 'remote',
|
||||||
delayMs: replaceExisting ? 480 : 1400,
|
delayMs: replaceExisting ? 480 : 1400,
|
||||||
@@ -840,17 +864,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 +885,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 +1062,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>
|
||||||
|
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
|
import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
|
||||||
import { CheckCheck, ChevronLeft, Copy, Eye, EyeOff, File, FileText, LayoutGrid, Pencil, Plus, RefreshCw, Save, Send as SendIcon, Trash2, X } from 'lucide-preact';
|
import { CheckCheck, ChevronLeft, Copy, Eye, EyeOff, File, FileText, LayoutGrid, Lock, Pencil, Plus, RefreshCw, Save, Send as SendIcon, Trash2, X } from 'lucide-preact';
|
||||||
import { copyTextToClipboard } from '@/lib/clipboard';
|
import { copyTextToClipboard } from '@/lib/clipboard';
|
||||||
import LoadingState from '@/components/LoadingState';
|
import LoadingState from '@/components/LoadingState';
|
||||||
import type { Send, SendDraft } from '@/lib/types';
|
import type { Send, SendDraft } from '@/lib/types';
|
||||||
@@ -43,6 +43,7 @@ function buildDefaultDraft(): SendDraft {
|
|||||||
expirationDays: '0',
|
expirationDays: '0',
|
||||||
maxAccessCount: '',
|
maxAccessCount: '',
|
||||||
password: '',
|
password: '',
|
||||||
|
hasPassword: false,
|
||||||
disabled: false,
|
disabled: false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -59,6 +60,7 @@ function draftFromSend(send: Send): SendDraft {
|
|||||||
expirationDays: daysFromNow(send.expirationDate, 0),
|
expirationDays: daysFromNow(send.expirationDate, 0),
|
||||||
maxAccessCount: send.maxAccessCount !== null && send.maxAccessCount !== undefined ? String(send.maxAccessCount) : '',
|
maxAccessCount: send.maxAccessCount !== null && send.maxAccessCount !== undefined ? String(send.maxAccessCount) : '',
|
||||||
password: '',
|
password: '',
|
||||||
|
hasPassword: !!send.password,
|
||||||
disabled: !!send.disabled,
|
disabled: !!send.disabled,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -380,6 +382,7 @@ export default function SendsPage(props: SendsPageProps) {
|
|||||||
<div className="list-text">
|
<div className="list-text">
|
||||||
<span className="list-title" title={send.decName || t('txt_no_name')}>{send.decName || t('txt_no_name')}</span>
|
<span className="list-title" title={send.decName || t('txt_no_name')}>{send.decName || t('txt_no_name')}</span>
|
||||||
<span className="list-sub">
|
<span className="list-sub">
|
||||||
|
{!!send.password && <><Lock size={12} className="inline-icon" /> </>}
|
||||||
{Number(send.type) === 1 ? t('txt_file') : t('txt_text')} - {t('txt_accessed_count_times', { count: send.accessCount || 0 })}
|
{Number(send.type) === 1 ? t('txt_file') : t('txt_text')} - {t('txt_accessed_count_times', { count: send.accessCount || 0 })}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -471,12 +474,23 @@ export default function SendsPage(props: SendsPageProps) {
|
|||||||
</label>
|
</label>
|
||||||
<label className="field">
|
<label className="field">
|
||||||
<span>{t('txt_password')}</span>
|
<span>{t('txt_password')}</span>
|
||||||
<div className="password-wrap">
|
{draft.hasPassword ? (
|
||||||
<input className="input" type={showPassword ? 'text' : 'password'} value={draft.password} onInput={(e) => setDraft({ ...draft, password: (e.currentTarget as HTMLInputElement).value })} />
|
<div className="password-wrap">
|
||||||
<button type="button" className="password-toggle" onClick={() => setShowPassword((v) => !v)}>
|
<input className="input" type="password" value="••••••••" disabled />
|
||||||
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
|
{!isCreating && (
|
||||||
</button>
|
<button type="button" className="password-toggle text-red-600 hover:text-red-700" onClick={() => setDraft({ ...draft, hasPassword: false, password: '' })} title={t('txt_remove')}>
|
||||||
</div>
|
<Trash2 size={16} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="password-wrap">
|
||||||
|
<input className="input" type={showPassword ? 'text' : 'password'} value={draft.password} onInput={(e) => setDraft({ ...draft, password: (e.currentTarget as HTMLInputElement).value })} />
|
||||||
|
<button type="button" className="password-toggle" onClick={() => setShowPassword((v) => !v)}>
|
||||||
|
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</label>
|
</label>
|
||||||
<label className="field field-span-2">
|
<label className="field field-span-2">
|
||||||
<span>{t('txt_notes')}</span>
|
<span>{t('txt_notes')}</span>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
|
import { useEffect, useMemo, useRef, useState } from 'preact/hooks';
|
||||||
import { Clipboard, Globe } from 'lucide-preact';
|
import { Clipboard, Globe } from 'lucide-preact';
|
||||||
import { copyTextToClipboard as copyTextWithFeedback } from '@/lib/clipboard';
|
import { copyTextToClipboard as copyTextWithFeedback } from '@/lib/clipboard';
|
||||||
import { calcTotpNow } from '@/lib/crypto';
|
import { calcTotpNow, type TotpCodeResult } from '@/lib/crypto';
|
||||||
import { t } from '@/lib/i18n';
|
import { t } from '@/lib/i18n';
|
||||||
import type { Cipher } from '@/lib/types';
|
import type { Cipher } from '@/lib/types';
|
||||||
import LoadingState from '@/components/LoadingState';
|
import LoadingState from '@/components/LoadingState';
|
||||||
@@ -14,17 +14,9 @@ interface TotpCodesPageProps {
|
|||||||
onNotify: (type: 'success' | 'error', text: string) => void;
|
onNotify: (type: 'success' | 'error', text: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TOTP_PERIOD_SECONDS = 30;
|
|
||||||
const TOTP_RING_RADIUS = 14;
|
const TOTP_RING_RADIUS = 14;
|
||||||
const TOTP_RING_CIRCUMFERENCE = 2 * Math.PI * TOTP_RING_RADIUS;
|
const TOTP_RING_CIRCUMFERENCE = 2 * Math.PI * TOTP_RING_RADIUS;
|
||||||
const TOTP_REFRESH_BATCH_SIZE = 16;
|
const TOTP_REFRESH_BATCH_SIZE = 16;
|
||||||
function getTotpTimeState(): { windowId: number; remain: number } {
|
|
||||||
const epoch = Math.floor(Date.now() / 1000);
|
|
||||||
return {
|
|
||||||
windowId: Math.floor(epoch / TOTP_PERIOD_SECONDS),
|
|
||||||
remain: TOTP_PERIOD_SECONDS - (epoch % TOTP_PERIOD_SECONDS),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function TotpListIcon({ cipher }: { cipher: Cipher }) {
|
function TotpListIcon({ cipher }: { cipher: Cipher }) {
|
||||||
return <WebsiteIcon cipher={cipher} fallback={<Globe size={18} />} />;
|
return <WebsiteIcon cipher={cipher} fallback={<Globe size={18} />} />;
|
||||||
@@ -32,13 +24,15 @@ function TotpListIcon({ cipher }: { cipher: Cipher }) {
|
|||||||
|
|
||||||
interface TotpRowProps {
|
interface TotpRowProps {
|
||||||
cipher: Cipher;
|
cipher: Cipher;
|
||||||
live: { code: string; remain: number } | null;
|
live: TotpCodeResult | null;
|
||||||
onCopy: (value: string) => void;
|
onCopy: (value: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function TotpRow(props: TotpRowProps) {
|
function TotpRow(props: TotpRowProps) {
|
||||||
const name = props.cipher.decName || props.cipher.name || t('txt_no_name');
|
const name = props.cipher.decName || props.cipher.name || t('txt_no_name');
|
||||||
const username = props.cipher.login?.decUsername || '';
|
const username = props.cipher.login?.decUsername || '';
|
||||||
|
const period = Math.max(1, props.live?.period || 30);
|
||||||
|
const progress = props.live ? Math.max(0, Math.min(period, props.live.remain)) / period : 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="totp-code-row">
|
<div className="totp-code-row">
|
||||||
@@ -69,8 +63,7 @@ function TotpRow(props: TotpRowProps) {
|
|||||||
strokeDasharray: `${TOTP_RING_CIRCUMFERENCE} ${TOTP_RING_CIRCUMFERENCE}`,
|
strokeDasharray: `${TOTP_RING_CIRCUMFERENCE} ${TOTP_RING_CIRCUMFERENCE}`,
|
||||||
strokeDashoffset: String(
|
strokeDashoffset: String(
|
||||||
TOTP_RING_CIRCUMFERENCE -
|
TOTP_RING_CIRCUMFERENCE -
|
||||||
TOTP_RING_CIRCUMFERENCE *
|
TOTP_RING_CIRCUMFERENCE * progress
|
||||||
(Math.max(0, Math.min(TOTP_PERIOD_SECONDS, props.live?.remain ?? 0)) / TOTP_PERIOD_SECONDS)
|
|
||||||
),
|
),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -86,8 +79,7 @@ function TotpRow(props: TotpRowProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function TotpCodesPage(props: TotpCodesPageProps) {
|
export default function TotpCodesPage(props: TotpCodesPageProps) {
|
||||||
const [totpCodes, setTotpCodes] = useState<Record<string, string | null>>({});
|
const [totpCodes, setTotpCodes] = useState<Record<string, TotpCodeResult | null>>({});
|
||||||
const [remainingSeconds, setRemainingSeconds] = useState(() => getTotpTimeState().remain);
|
|
||||||
const [columnCount, setColumnCount] = useState(1);
|
const [columnCount, setColumnCount] = useState(1);
|
||||||
const listRef = useRef<HTMLDivElement | null>(null);
|
const listRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
@@ -120,11 +112,10 @@ export default function TotpCodesPage(props: TotpCodesPageProps) {
|
|||||||
let stopped = false;
|
let stopped = false;
|
||||||
let activeRun = 0;
|
let activeRun = 0;
|
||||||
let timer = 0;
|
let timer = 0;
|
||||||
let currentWindowId = -1;
|
|
||||||
|
|
||||||
const refreshCodes = async () => {
|
const refreshCodes = async () => {
|
||||||
const runId = ++activeRun;
|
const runId = ++activeRun;
|
||||||
const nextCodes: Record<string, string | null> = {};
|
const nextCodes: Record<string, TotpCodeResult | null> = {};
|
||||||
for (let start = 0; start < totpItems.length; start += TOTP_REFRESH_BATCH_SIZE) {
|
for (let start = 0; start < totpItems.length; start += TOTP_REFRESH_BATCH_SIZE) {
|
||||||
if (stopped || runId !== activeRun) return;
|
if (stopped || runId !== activeRun) return;
|
||||||
const batch = totpItems.slice(start, start + TOTP_REFRESH_BATCH_SIZE);
|
const batch = totpItems.slice(start, start + TOTP_REFRESH_BATCH_SIZE);
|
||||||
@@ -132,7 +123,7 @@ export default function TotpCodesPage(props: TotpCodesPageProps) {
|
|||||||
batch.map(async (cipher) => {
|
batch.map(async (cipher) => {
|
||||||
try {
|
try {
|
||||||
const next = await calcTotpNow(cipher.login?.decTotp || '');
|
const next = await calcTotpNow(cipher.login?.decTotp || '');
|
||||||
return [cipher.id, next?.code || null] as const;
|
return [cipher.id, next] as const;
|
||||||
} catch {
|
} catch {
|
||||||
return [cipher.id, null] as const;
|
return [cipher.id, null] as const;
|
||||||
}
|
}
|
||||||
@@ -146,15 +137,20 @@ export default function TotpCodesPage(props: TotpCodesPageProps) {
|
|||||||
if (stopped || runId !== activeRun) return;
|
if (stopped || runId !== activeRun) return;
|
||||||
setTotpCodes((prev) => {
|
setTotpCodes((prev) => {
|
||||||
let changed = false;
|
let changed = false;
|
||||||
const next: Record<string, string | null> = { ...prev };
|
const next: Record<string, TotpCodeResult | null> = { ...prev };
|
||||||
for (const id of Object.keys(next)) {
|
for (const id of Object.keys(next)) {
|
||||||
if (id in nextCodes) continue;
|
if (id in nextCodes) continue;
|
||||||
delete next[id];
|
delete next[id];
|
||||||
changed = true;
|
changed = true;
|
||||||
}
|
}
|
||||||
for (const [id, code] of Object.entries(nextCodes)) {
|
for (const [id, live] of Object.entries(nextCodes)) {
|
||||||
if (next[id] === code) continue;
|
const prevLive = next[id];
|
||||||
next[id] = code;
|
if (
|
||||||
|
prevLive?.code === live?.code &&
|
||||||
|
prevLive?.remain === live?.remain &&
|
||||||
|
prevLive?.period === live?.period
|
||||||
|
) continue;
|
||||||
|
next[id] = live;
|
||||||
changed = true;
|
changed = true;
|
||||||
}
|
}
|
||||||
return changed ? next : prev;
|
return changed ? next : prev;
|
||||||
@@ -162,10 +158,6 @@ export default function TotpCodesPage(props: TotpCodesPageProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const tick = () => {
|
const tick = () => {
|
||||||
const next = getTotpTimeState();
|
|
||||||
setRemainingSeconds((prev) => (prev === next.remain ? prev : next.remain));
|
|
||||||
if (next.windowId === currentWindowId) return;
|
|
||||||
currentWindowId = next.windowId;
|
|
||||||
void refreshCodes();
|
void refreshCodes();
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -215,7 +207,7 @@ export default function TotpCodesPage(props: TotpCodesPageProps) {
|
|||||||
<TotpRow
|
<TotpRow
|
||||||
key={cipher.id}
|
key={cipher.id}
|
||||||
cipher={cipher}
|
cipher={cipher}
|
||||||
live={totpCodes[cipher.id] ? { code: totpCodes[cipher.id] || '', remain: remainingSeconds } : null}
|
live={totpCodes[cipher.id] || null}
|
||||||
onCopy={(value) => void copyToClipboard(value)}
|
onCopy={(value) => void copyToClipboard(value)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -12,23 +12,26 @@ 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,
|
||||||
type VaultSortMode,
|
type VaultSortMode,
|
||||||
} from '@/components/vault/vault-page-helpers';
|
} from '@/components/vault/vault-page-helpers';
|
||||||
import { calcTotpNow } from '@/lib/crypto';
|
import { calcTotpNow, type TotpCodeResult } from '@/lib/crypto';
|
||||||
import { computeSshFingerprint, generateDefaultSshKeyMaterial } from '@/lib/ssh';
|
import { computeSshFingerprint, generateDefaultSshKeyMaterial } from '@/lib/ssh';
|
||||||
import { ChevronLeft } from 'lucide-preact';
|
import { ChevronLeft } from 'lucide-preact';
|
||||||
import type { Cipher, CustomFieldType, Folder, VaultDraft, VaultDraftField } from '@/lib/types';
|
import type { Cipher, CustomFieldType, Folder, VaultDraft, VaultDraftField } from '@/lib/types';
|
||||||
@@ -106,7 +109,7 @@ export default function VaultPage(props: VaultPageProps) {
|
|||||||
const [renameFolderName, setRenameFolderName] = useState('');
|
const [renameFolderName, setRenameFolderName] = useState('');
|
||||||
const [pendingDeleteFolder, setPendingDeleteFolder] = useState<Folder | null>(null);
|
const [pendingDeleteFolder, setPendingDeleteFolder] = useState<Folder | null>(null);
|
||||||
const [deleteAllFoldersOpen, setDeleteAllFoldersOpen] = useState(false);
|
const [deleteAllFoldersOpen, setDeleteAllFoldersOpen] = useState(false);
|
||||||
const [totpLive, setTotpLive] = useState<{ code: string; remain: number } | null>(null);
|
const [totpLive, setTotpLive] = useState<TotpCodeResult | null>(null);
|
||||||
const [hiddenFieldVisibleMap, setHiddenFieldVisibleMap] = useState<Record<number, boolean>>({});
|
const [hiddenFieldVisibleMap, setHiddenFieldVisibleMap] = useState<Record<number, boolean>>({});
|
||||||
const [attachmentQueue, setAttachmentQueue] = useState<File[]>([]);
|
const [attachmentQueue, setAttachmentQueue] = useState<File[]>([]);
|
||||||
const [removedAttachmentIds, setRemovedAttachmentIds] = useState<Record<string, boolean>>({});
|
const [removedAttachmentIds, setRemovedAttachmentIds] = useState<Record<string, boolean>>({});
|
||||||
@@ -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]);
|
||||||
|
|
||||||
|
|||||||
@@ -2,11 +2,11 @@ import { createPortal } from 'preact/compat';
|
|||||||
import { useEffect, useMemo, useState } from 'preact/hooks';
|
import { useEffect, useMemo, useState } from 'preact/hooks';
|
||||||
import { Archive, Clipboard, Download, Eye, EyeOff, ExternalLink, Folder, Paperclip, Pencil, RotateCcw, Trash2, X } from 'lucide-preact';
|
import { Archive, Clipboard, Download, Eye, EyeOff, ExternalLink, Folder, Paperclip, Pencil, RotateCcw, Trash2, X } from 'lucide-preact';
|
||||||
import { useDialogLifecycle } from '@/components/ConfirmDialog';
|
import { useDialogLifecycle } from '@/components/ConfirmDialog';
|
||||||
|
import type { TotpCodeResult } from '@/lib/crypto';
|
||||||
import type { Cipher } from '@/lib/types';
|
import type { Cipher } from '@/lib/types';
|
||||||
import { t } from '@/lib/i18n';
|
import { t } from '@/lib/i18n';
|
||||||
import {
|
import {
|
||||||
CardBrandIcon,
|
CardBrandIcon,
|
||||||
TOTP_PERIOD_SECONDS,
|
|
||||||
TOTP_RING_CIRCUMFERENCE,
|
TOTP_RING_CIRCUMFERENCE,
|
||||||
VaultListIcon,
|
VaultListIcon,
|
||||||
copyToClipboard,
|
copyToClipboard,
|
||||||
@@ -25,7 +25,7 @@ interface VaultDetailViewProps {
|
|||||||
selectedCipher: Cipher;
|
selectedCipher: Cipher;
|
||||||
repromptApprovedCipherId: string | null;
|
repromptApprovedCipherId: string | null;
|
||||||
showPassword: boolean;
|
showPassword: boolean;
|
||||||
totpLive: { code: string; remain: number } | null;
|
totpLive: TotpCodeResult | null;
|
||||||
passkeyCreatedAt: string | null;
|
passkeyCreatedAt: string | null;
|
||||||
hiddenFieldVisibleMap: Record<number, boolean>;
|
hiddenFieldVisibleMap: Record<number, boolean>;
|
||||||
folderName: (id: string | null | undefined) => string;
|
folderName: (id: string | null | undefined) => string;
|
||||||
@@ -42,6 +42,11 @@ interface VaultDetailViewProps {
|
|||||||
onUnarchive: (cipher: Cipher) => void | Promise<void>;
|
onUnarchive: (cipher: Cipher) => void | Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function totpProgress(live: TotpCodeResult | null): number {
|
||||||
|
const period = Math.max(1, live?.period || 30);
|
||||||
|
return live ? Math.max(0, Math.min(period, live.remain)) / period : 0;
|
||||||
|
}
|
||||||
|
|
||||||
function PasswordHistoryDialog(props: {
|
function PasswordHistoryDialog(props: {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
entries: Array<{ password: string; lastUsedDate: string | null }>;
|
entries: Array<{ password: string; lastUsedDate: string | null }>;
|
||||||
@@ -191,8 +196,7 @@ export default function VaultDetailView(props: VaultDetailViewProps) {
|
|||||||
strokeDasharray: `${TOTP_RING_CIRCUMFERENCE} ${TOTP_RING_CIRCUMFERENCE}`,
|
strokeDasharray: `${TOTP_RING_CIRCUMFERENCE} ${TOTP_RING_CIRCUMFERENCE}`,
|
||||||
strokeDashoffset: String(
|
strokeDashoffset: String(
|
||||||
TOTP_RING_CIRCUMFERENCE -
|
TOTP_RING_CIRCUMFERENCE -
|
||||||
TOTP_RING_CIRCUMFERENCE *
|
TOTP_RING_CIRCUMFERENCE * totpProgress(props.totpLive)
|
||||||
(Math.max(0, Math.min(TOTP_PERIOD_SECONDS, props.totpLive?.remain ?? 0)) / TOTP_PERIOD_SECONDS)
|
|
||||||
),
|
),
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
@@ -327,6 +331,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>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { ArrowDown, ArrowUp, CheckCheck, Download, Paperclip, Plus, QrCode, Refr
|
|||||||
import jsQR from 'jsqr';
|
import jsQR from 'jsqr';
|
||||||
import { useEffect, useRef, useState } from 'preact/hooks';
|
import { useEffect, useRef, useState } from 'preact/hooks';
|
||||||
import { useDialogLifecycle } from '@/components/ConfirmDialog';
|
import { useDialogLifecycle } from '@/components/ConfirmDialog';
|
||||||
|
import { normalizeTotpInput } from '@/lib/crypto';
|
||||||
import type { Cipher, Folder, VaultDraft, VaultDraftField } from '@/lib/types';
|
import type { Cipher, Folder, VaultDraft, VaultDraftField } from '@/lib/types';
|
||||||
import { t } from '@/lib/i18n';
|
import { t } from '@/lib/i18n';
|
||||||
import { cardBrand } from '@/lib/import-format-shared';
|
import { cardBrand } from '@/lib/import-format-shared';
|
||||||
@@ -67,6 +68,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();
|
||||||
|
|
||||||
@@ -159,9 +162,9 @@ export default function VaultEditor(props: VaultEditorProps) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const applyTotpQrValue = (value: string) => {
|
const applyTotpQrValue = (value: string) => {
|
||||||
const trimmed = value.trim();
|
const normalized = normalizeTotpInput(value);
|
||||||
if (!trimmed) return false;
|
if (!normalized) return false;
|
||||||
props.onUpdateDraft({ loginTotp: trimmed });
|
props.onUpdateDraft({ loginTotp: normalized });
|
||||||
setTotpQrStatus(t('txt_totp_qr_scanned'));
|
setTotpQrStatus(t('txt_totp_qr_scanned'));
|
||||||
setTotpQrOpen(false);
|
setTotpQrOpen(false);
|
||||||
return true;
|
return true;
|
||||||
@@ -208,6 +211,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 +590,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>
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
} from '@/lib/website-icon-cache';
|
} from '@/lib/website-icon-cache';
|
||||||
import { demoBrandIconUrl } from '@/lib/demo-brand-icons';
|
import { demoBrandIconUrl } from '@/lib/demo-brand-icons';
|
||||||
import { getCurrentNetworkStatus, subscribeNetworkStatus } from '@/lib/network-status';
|
import { getCurrentNetworkStatus, subscribeNetworkStatus } from '@/lib/network-status';
|
||||||
|
import { areWebsiteIconsEnabled } from '@/lib/website-icon-settings';
|
||||||
import { firstCipherUri, hostFromUri, websiteIconUrl } from '@/lib/website-utils';
|
import { firstCipherUri, hostFromUri, websiteIconUrl } from '@/lib/website-utils';
|
||||||
|
|
||||||
const ICON_LOAD_ROOT_MARGIN = '180px 0px';
|
const ICON_LOAD_ROOT_MARGIN = '180px 0px';
|
||||||
@@ -22,7 +23,8 @@ interface WebsiteIconProps {
|
|||||||
|
|
||||||
export default function WebsiteIcon(props: WebsiteIconProps) {
|
export default function WebsiteIcon(props: WebsiteIconProps) {
|
||||||
const host = useMemo(() => hostFromUri(firstCipherUri(props.cipher)), [props.cipher]);
|
const host = useMemo(() => hostFromUri(firstCipherUri(props.cipher)), [props.cipher]);
|
||||||
const src = host ? websiteIconUrl(host) : '';
|
const iconsEnabled = areWebsiteIconsEnabled();
|
||||||
|
const src = iconsEnabled && host ? websiteIconUrl(host) : '';
|
||||||
const nodeRef = useRef<HTMLSpanElement | null>(null);
|
const nodeRef = useRef<HTMLSpanElement | null>(null);
|
||||||
const [shouldLoad, setShouldLoad] = useState(() => (host ? getWebsiteIconStatus(host) === 'loaded' : true));
|
const [shouldLoad, setShouldLoad] = useState(() => (host ? getWebsiteIconStatus(host) === 'loaded' : true));
|
||||||
const [status, setStatus] = useState(() => (host ? getWebsiteIconStatus(host) : 'idle'));
|
const [status, setStatus] = useState(() => (host ? getWebsiteIconStatus(host) : 'idle'));
|
||||||
@@ -33,7 +35,7 @@ export default function WebsiteIcon(props: WebsiteIconProps) {
|
|||||||
useEffect(() => subscribeNetworkStatus(setNetworkStatus), []);
|
useEffect(() => subscribeNetworkStatus(setNetworkStatus), []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!host) {
|
if (!host || !iconsEnabled) {
|
||||||
setShouldLoad(true);
|
setShouldLoad(true);
|
||||||
setStatus('idle');
|
setStatus('idle');
|
||||||
setImageUrl('');
|
setImageUrl('');
|
||||||
@@ -47,7 +49,7 @@ export default function WebsiteIcon(props: WebsiteIconProps) {
|
|||||||
setStatus(next);
|
setStatus(next);
|
||||||
setImageUrl(getWebsiteIconImageUrl(host));
|
setImageUrl(getWebsiteIconImageUrl(host));
|
||||||
});
|
});
|
||||||
}, [host]);
|
}, [host, iconsEnabled]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!host || shouldLoad || status === 'loaded' || status === 'error') return;
|
if (!host || shouldLoad || status === 'loaded' || status === 'error') return;
|
||||||
@@ -81,10 +83,11 @@ export default function WebsiteIcon(props: WebsiteIconProps) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (SHOULD_LOAD_DEMO_BRAND_ICONS) return;
|
if (SHOULD_LOAD_DEMO_BRAND_ICONS) return;
|
||||||
if (demoIconUrl) return;
|
if (demoIconUrl) return;
|
||||||
|
if (!iconsEnabled) return;
|
||||||
if (networkStatus !== 'online') return;
|
if (networkStatus !== 'online') return;
|
||||||
if (!host || !src || !shouldLoad || status !== 'idle') return;
|
if (!host || !src || !shouldLoad || status !== 'idle') return;
|
||||||
beginWebsiteIconLoad(host, src);
|
beginWebsiteIconLoad(host, src);
|
||||||
}, [demoIconUrl, host, networkStatus, src, shouldLoad, status]);
|
}, [demoIconUrl, host, iconsEnabled, networkStatus, src, shouldLoad, status]);
|
||||||
|
|
||||||
if (demoIconUrl) {
|
if (demoIconUrl) {
|
||||||
return (
|
return (
|
||||||
@@ -100,7 +103,7 @@ export default function WebsiteIcon(props: WebsiteIconProps) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!host || status === 'error') {
|
if (!host || !iconsEnabled || status === 'error') {
|
||||||
return <span className="list-icon-fallback">{props.fallback ?? <Globe size={18} />}</span>;
|
return <span className="list-icon-fallback">{props.fallback ?? <Globe size={18} />}</span>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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') },
|
||||||
];
|
];
|
||||||
@@ -175,8 +207,7 @@ export function getWebsiteMatchOptions(): Array<{ value: number | null; label: s
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TOTP_PERIOD_SECONDS = 30;
|
const TOTP_RING_RADIUS = 14;
|
||||||
export const TOTP_RING_RADIUS = 14;
|
|
||||||
export const TOTP_RING_CIRCUMFERENCE = 2 * Math.PI * TOTP_RING_RADIUS;
|
export const TOTP_RING_CIRCUMFERENCE = 2 * Math.PI * TOTP_RING_RADIUS;
|
||||||
|
|
||||||
export function CreateTypeIcon({ type }: { type: number }) {
|
export function CreateTypeIcon({ type }: { type: number }) {
|
||||||
@@ -185,6 +216,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 +227,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 +268,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 +280,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 +399,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 +517,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 +614,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 || '',
|
||||||
|
|||||||
@@ -82,12 +82,12 @@ export default function useBackupActions(options: UseBackupActionsOptions) {
|
|||||||
downloadBytesAsFile(payload.bytes, payload.fileName, payload.mimeType);
|
downloadBytesAsFile(payload.bytes, payload.fileName, payload.mimeType);
|
||||||
},
|
},
|
||||||
|
|
||||||
async inspectRemoteBackup(destinationId: string, path: string) {
|
async inspectRemoteBackup(masterPasswordHash: string, destinationId: string, path: string) {
|
||||||
return inspectRemoteBackupIntegrity(authedFetch, destinationId, path);
|
return inspectRemoteBackupIntegrity(authedFetch, masterPasswordHash, 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) {
|
||||||
|
|||||||
@@ -52,7 +52,6 @@ export async function respondToAuthRequest(
|
|||||||
requestId: string,
|
requestId: string,
|
||||||
payload: {
|
payload: {
|
||||||
key?: string | null;
|
key?: string | null;
|
||||||
masterPasswordHash?: string | null;
|
|
||||||
deviceIdentifier: string;
|
deviceIdentifier: string;
|
||||||
requestApproved: boolean;
|
requestApproved: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -196,11 +196,14 @@ export async function exportAdminBackup(
|
|||||||
|
|
||||||
export async function downloadAdminBackupAttachmentBlob(
|
export async function downloadAdminBackupAttachmentBlob(
|
||||||
authedFetch: AuthedFetch,
|
authedFetch: AuthedFetch,
|
||||||
blobName: string
|
blobName: string,
|
||||||
|
masterPasswordHash: string
|
||||||
): Promise<Uint8Array> {
|
): Promise<Uint8Array> {
|
||||||
const params = new URLSearchParams();
|
const resp = await authedFetch('/api/admin/backup/blob', {
|
||||||
params.set('blobName', blobName);
|
method: 'POST',
|
||||||
const resp = await authedFetch(`/api/admin/backup/blob?${params.toString()}`, { method: 'GET' });
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ blobName, masterPasswordHash }),
|
||||||
|
});
|
||||||
if (!resp.ok) throw new Error(await parseErrorMessage(resp, t('txt_backup_export_failed')));
|
if (!resp.ok) throw new Error(await parseErrorMessage(resp, t('txt_backup_export_failed')));
|
||||||
return new Uint8Array(await resp.arrayBuffer());
|
return new Uint8Array(await resp.arrayBuffer());
|
||||||
}
|
}
|
||||||
@@ -246,7 +249,7 @@ export async function buildCompleteAdminBackupExport(
|
|||||||
stageDetail: 'txt_backup_export_progress_fetch_attachments_detail',
|
stageDetail: 'txt_backup_export_progress_fetch_attachments_detail',
|
||||||
});
|
});
|
||||||
for (const attachment of manifest.attachmentBlobs || []) {
|
for (const attachment of manifest.attachmentBlobs || []) {
|
||||||
const bytes = await downloadAdminBackupAttachmentBlob(authedFetch, attachment.blobName);
|
const bytes = await downloadAdminBackupAttachmentBlob(authedFetch, attachment.blobName, masterPasswordHash);
|
||||||
zipped[`attachments/${attachment.cipherId}/${attachment.attachmentId}.bin`] = bytes;
|
zipped[`attachments/${attachment.cipherId}/${attachment.attachmentId}.bin`] = bytes;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -403,25 +406,29 @@ 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')));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function inspectRemoteBackupIntegrity(
|
export async function inspectRemoteBackupIntegrity(
|
||||||
authedFetch: AuthedFetch,
|
authedFetch: AuthedFetch,
|
||||||
|
masterPasswordHash: string,
|
||||||
destinationId: string,
|
destinationId: string,
|
||||||
path: string
|
path: string
|
||||||
): Promise<RemoteBackupIntegrityResponse> {
|
): Promise<RemoteBackupIntegrityResponse> {
|
||||||
const params = new URLSearchParams();
|
const resp = await authedFetch('/api/admin/backup/remote/integrity', {
|
||||||
params.set('destinationId', destinationId);
|
method: 'POST',
|
||||||
params.set('path', path);
|
headers: { 'Content-Type': 'application/json' },
|
||||||
const resp = await authedFetch(`/api/admin/backup/remote/integrity?${params.toString()}`, { method: 'GET' });
|
body: JSON.stringify({ destinationId, path, masterPasswordHash }),
|
||||||
|
});
|
||||||
if (!resp.ok) throw new Error(await parseErrorMessage(resp, t('txt_backup_remote_download_failed')));
|
if (!resp.ok) throw new Error(await parseErrorMessage(resp, t('txt_backup_remote_download_failed')));
|
||||||
const body = await parseJson<RemoteBackupIntegrityResponse>(resp);
|
const body = await parseJson<RemoteBackupIntegrityResponse>(resp);
|
||||||
if (!body?.integrity || !body?.fileName) throw new Error(t('txt_backup_remote_invalid_response'));
|
if (!body?.integrity || !body?.fileName) throw new Error(t('txt_backup_remote_invalid_response'));
|
||||||
|
|||||||
@@ -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 };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import {
|
|||||||
unlockOfflineVaultWithMasterKey,
|
unlockOfflineVaultWithMasterKey,
|
||||||
} from '@/lib/offline-auth';
|
} from '@/lib/offline-auth';
|
||||||
import { probeNodeWardenService } from '@/lib/network-status';
|
import { probeNodeWardenService } from '@/lib/network-status';
|
||||||
|
import { setWebsiteIconsEnabled } from '@/lib/website-icon-settings';
|
||||||
import type { AccountPasskeyPrfOption, AppPhase, Profile, SessionState, TokenSuccess, WebBootstrapResponse } from '@/lib/types';
|
import type { AccountPasskeyPrfOption, AppPhase, Profile, SessionState, TokenSuccess, WebBootstrapResponse } from '@/lib/types';
|
||||||
|
|
||||||
export interface PendingTotp {
|
export interface PendingTotp {
|
||||||
@@ -51,6 +52,7 @@ export type JwtUnsafeReason = 'missing' | 'too_short';
|
|||||||
export interface BootstrapAppResult {
|
export interface BootstrapAppResult {
|
||||||
defaultKdfIterations: number;
|
defaultKdfIterations: number;
|
||||||
registrationInviteRequired?: boolean;
|
registrationInviteRequired?: boolean;
|
||||||
|
websiteIconsEnabled: boolean;
|
||||||
jwtWarning: { reason: JwtUnsafeReason; minLength: number } | null;
|
jwtWarning: { reason: JwtUnsafeReason; minLength: number } | null;
|
||||||
session: SessionState | null;
|
session: SessionState | null;
|
||||||
profile: Profile | null;
|
profile: Profile | null;
|
||||||
@@ -61,6 +63,7 @@ export interface BootstrapAppResult {
|
|||||||
export interface InitialAppBootstrapState {
|
export interface InitialAppBootstrapState {
|
||||||
defaultKdfIterations: number;
|
defaultKdfIterations: number;
|
||||||
registrationInviteRequired?: boolean;
|
registrationInviteRequired?: boolean;
|
||||||
|
websiteIconsEnabled: boolean;
|
||||||
jwtWarning: { reason: JwtUnsafeReason; minLength: number } | null;
|
jwtWarning: { reason: JwtUnsafeReason; minLength: number } | null;
|
||||||
session: SessionState | null;
|
session: SessionState | null;
|
||||||
phase: AppPhase;
|
phase: AppPhase;
|
||||||
@@ -229,10 +232,11 @@ function readWindowBootstrap(): WebBootstrapResponse {
|
|||||||
return raw && typeof raw === 'object' ? raw : {};
|
return raw && typeof raw === 'object' ? raw : {};
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeBootstrapResponse(boot: WebBootstrapResponse): Pick<InitialAppBootstrapState, 'defaultKdfIterations' | 'registrationInviteRequired' | 'jwtWarning'> {
|
function normalizeBootstrapResponse(boot: WebBootstrapResponse): Pick<InitialAppBootstrapState, 'defaultKdfIterations' | 'registrationInviteRequired' | 'websiteIconsEnabled' | 'jwtWarning'> {
|
||||||
const defaultKdfIterations = Number(boot.defaultKdfIterations || 600000);
|
const defaultKdfIterations = Number(boot.defaultKdfIterations || 600000);
|
||||||
const registrationInviteRequired =
|
const registrationInviteRequired =
|
||||||
typeof boot.registrationInviteRequired === 'boolean' ? boot.registrationInviteRequired : undefined;
|
typeof boot.registrationInviteRequired === 'boolean' ? boot.registrationInviteRequired : undefined;
|
||||||
|
const websiteIconsEnabled = boot.websiteIconsEnabled !== false;
|
||||||
const jwtUnsafeReason = boot.jwtUnsafeReason || null;
|
const jwtUnsafeReason = boot.jwtUnsafeReason || null;
|
||||||
const jwtWarning = jwtUnsafeReason
|
const jwtWarning = jwtUnsafeReason
|
||||||
? {
|
? {
|
||||||
@@ -244,6 +248,7 @@ function normalizeBootstrapResponse(boot: WebBootstrapResponse): Pick<InitialApp
|
|||||||
return {
|
return {
|
||||||
defaultKdfIterations,
|
defaultKdfIterations,
|
||||||
registrationInviteRequired,
|
registrationInviteRequired,
|
||||||
|
websiteIconsEnabled,
|
||||||
jwtWarning,
|
jwtWarning,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -304,7 +309,8 @@ function resolveUnauthenticatedPhase(registrationInviteRequired: boolean | undef
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function readInitialAppBootstrapState(): InitialAppBootstrapState {
|
export function readInitialAppBootstrapState(): InitialAppBootstrapState {
|
||||||
const { defaultKdfIterations, registrationInviteRequired, jwtWarning } = normalizeBootstrapResponse(readWindowBootstrap());
|
const { defaultKdfIterations, registrationInviteRequired, websiteIconsEnabled, jwtWarning } = normalizeBootstrapResponse(readWindowBootstrap());
|
||||||
|
setWebsiteIconsEnabled(websiteIconsEnabled);
|
||||||
const session = loadSession();
|
const session = loadSession();
|
||||||
const hasInviteCode = !!readInviteCodeFromUrl();
|
const hasInviteCode = !!readInviteCodeFromUrl();
|
||||||
const unauthenticatedPhase = hasInviteCode ? 'register' : 'login';
|
const unauthenticatedPhase = hasInviteCode ? 'register' : 'login';
|
||||||
@@ -312,6 +318,7 @@ export function readInitialAppBootstrapState(): InitialAppBootstrapState {
|
|||||||
return {
|
return {
|
||||||
defaultKdfIterations,
|
defaultKdfIterations,
|
||||||
registrationInviteRequired,
|
registrationInviteRequired,
|
||||||
|
websiteIconsEnabled,
|
||||||
jwtWarning,
|
jwtWarning,
|
||||||
session,
|
session,
|
||||||
phase: jwtWarning ? 'login' : session ? 'locked' : resolveUnauthenticatedPhase(registrationInviteRequired, unauthenticatedPhase),
|
phase: jwtWarning ? 'login' : session ? 'locked' : resolveUnauthenticatedPhase(registrationInviteRequired, unauthenticatedPhase),
|
||||||
@@ -323,12 +330,15 @@ export async function bootstrapAppSession(initial: InitialAppBootstrapState = re
|
|||||||
const normalizedBoot = normalizeBootstrapResponse(remoteBoot);
|
const normalizedBoot = normalizeBootstrapResponse(remoteBoot);
|
||||||
const defaultKdfIterations = normalizedBoot.defaultKdfIterations || initial.defaultKdfIterations;
|
const defaultKdfIterations = normalizedBoot.defaultKdfIterations || initial.defaultKdfIterations;
|
||||||
const registrationInviteRequired = normalizedBoot.registrationInviteRequired ?? initial.registrationInviteRequired;
|
const registrationInviteRequired = normalizedBoot.registrationInviteRequired ?? initial.registrationInviteRequired;
|
||||||
|
const websiteIconsEnabled = normalizedBoot.websiteIconsEnabled !== false;
|
||||||
|
setWebsiteIconsEnabled(websiteIconsEnabled);
|
||||||
const jwtWarning = normalizedBoot.jwtWarning ?? initial.jwtWarning;
|
const jwtWarning = normalizedBoot.jwtWarning ?? initial.jwtWarning;
|
||||||
|
|
||||||
if (jwtWarning) {
|
if (jwtWarning) {
|
||||||
return {
|
return {
|
||||||
defaultKdfIterations,
|
defaultKdfIterations,
|
||||||
registrationInviteRequired,
|
registrationInviteRequired,
|
||||||
|
websiteIconsEnabled,
|
||||||
jwtWarning,
|
jwtWarning,
|
||||||
session: null,
|
session: null,
|
||||||
profile: null,
|
profile: null,
|
||||||
@@ -341,6 +351,7 @@ export async function bootstrapAppSession(initial: InitialAppBootstrapState = re
|
|||||||
return {
|
return {
|
||||||
defaultKdfIterations,
|
defaultKdfIterations,
|
||||||
registrationInviteRequired,
|
registrationInviteRequired,
|
||||||
|
websiteIconsEnabled,
|
||||||
jwtWarning: null,
|
jwtWarning: null,
|
||||||
session: null,
|
session: null,
|
||||||
profile: null,
|
profile: null,
|
||||||
@@ -353,6 +364,7 @@ export async function bootstrapAppSession(initial: InitialAppBootstrapState = re
|
|||||||
return {
|
return {
|
||||||
defaultKdfIterations,
|
defaultKdfIterations,
|
||||||
registrationInviteRequired,
|
registrationInviteRequired,
|
||||||
|
websiteIconsEnabled,
|
||||||
jwtWarning: null,
|
jwtWarning: null,
|
||||||
session: loaded,
|
session: loaded,
|
||||||
profile: cachedProfile,
|
profile: cachedProfile,
|
||||||
@@ -364,6 +376,7 @@ export async function bootstrapAppSession(initial: InitialAppBootstrapState = re
|
|||||||
return {
|
return {
|
||||||
defaultKdfIterations,
|
defaultKdfIterations,
|
||||||
registrationInviteRequired,
|
registrationInviteRequired,
|
||||||
|
websiteIconsEnabled,
|
||||||
jwtWarning: null,
|
jwtWarning: null,
|
||||||
session: loaded,
|
session: loaded,
|
||||||
profile: null,
|
profile: null,
|
||||||
|
|||||||
+219
-27
@@ -259,17 +259,33 @@ interface TotpConfig {
|
|||||||
period: number;
|
period: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface GoogleAuthenticatorMigrationTotp {
|
||||||
|
secret: string;
|
||||||
|
name: string;
|
||||||
|
issuer: string;
|
||||||
|
algorithm: TotpHashAlgorithm;
|
||||||
|
digits: number;
|
||||||
|
period: number;
|
||||||
|
}
|
||||||
|
|
||||||
const DEFAULT_TOTP_CONFIG: Omit<TotpConfig, 'secret' | 'steam'> = {
|
const DEFAULT_TOTP_CONFIG: Omit<TotpConfig, 'secret' | 'steam'> = {
|
||||||
algorithm: 'SHA-1',
|
algorithm: 'SHA-1',
|
||||||
digits: 6,
|
digits: 6,
|
||||||
period: 30,
|
period: 30,
|
||||||
};
|
};
|
||||||
|
|
||||||
function parseTotpPositiveInt(value: string | null, fallback: number, min: number, max: number): number {
|
function parseTotpDigits(value: string | null): number {
|
||||||
if (!value) return fallback;
|
if (!value) return DEFAULT_TOTP_CONFIG.digits;
|
||||||
const parsed = Number(value);
|
const parsed = Number(value);
|
||||||
if (!Number.isInteger(parsed) || parsed < min || parsed > max) return fallback;
|
if (!Number.isInteger(parsed)) return DEFAULT_TOTP_CONFIG.digits;
|
||||||
return parsed;
|
return Math.max(0, Math.min(10, parsed));
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseTotpPeriod(value: string | null): number {
|
||||||
|
if (!value) return DEFAULT_TOTP_CONFIG.period;
|
||||||
|
const parsed = Number(value);
|
||||||
|
if (!Number.isSafeInteger(parsed)) return DEFAULT_TOTP_CONFIG.period;
|
||||||
|
return Math.max(1, parsed);
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseTotpHashAlgorithm(value: string | null): TotpHashAlgorithm {
|
function parseTotpHashAlgorithm(value: string | null): TotpHashAlgorithm {
|
||||||
@@ -279,9 +295,190 @@ function parseTotpHashAlgorithm(value: string | null): TotpHashAlgorithm {
|
|||||||
return 'SHA-1';
|
return 'SHA-1';
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseTotpConfig(raw: string): TotpConfig {
|
function base64ToBytesLoose(value: string): Uint8Array {
|
||||||
if (!raw) return { secret: '', steam: false, ...DEFAULT_TOTP_CONFIG };
|
const normalized = value.trim().replace(/\s/g, '+').replace(/-/g, '+').replace(/_/g, '/');
|
||||||
|
if (!normalized) return new Uint8Array();
|
||||||
|
const padded = normalized + '='.repeat((4 - (normalized.length % 4)) % 4);
|
||||||
|
try {
|
||||||
|
const binary = atob(padded);
|
||||||
|
return Uint8Array.from(binary, (char) => char.charCodeAt(0));
|
||||||
|
} catch {
|
||||||
|
return new Uint8Array();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function bytesToBase32(bytes: Uint8Array): string {
|
||||||
|
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
|
||||||
|
let bits = 0;
|
||||||
|
let value = 0;
|
||||||
|
let out = '';
|
||||||
|
for (const byte of bytes) {
|
||||||
|
value = (value << 8) | byte;
|
||||||
|
bits += 8;
|
||||||
|
while (bits >= 5) {
|
||||||
|
out += alphabet[(value >>> (bits - 5)) & 31];
|
||||||
|
bits -= 5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (bits > 0) {
|
||||||
|
out += alphabet[(value << (5 - bits)) & 31];
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readProtoVarint(bytes: Uint8Array, state: { offset: number }): number | null {
|
||||||
|
let result = 0;
|
||||||
|
let factor = 1;
|
||||||
|
for (let i = 0; i < 10 && state.offset < bytes.length; i += 1) {
|
||||||
|
const byte = bytes[state.offset++];
|
||||||
|
result += (byte & 0x7f) * factor;
|
||||||
|
if ((byte & 0x80) === 0) return Number.isSafeInteger(result) ? result : null;
|
||||||
|
factor *= 128;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readProtoBytes(bytes: Uint8Array, state: { offset: number }): Uint8Array | null {
|
||||||
|
const length = readProtoVarint(bytes, state);
|
||||||
|
if (length == null || length < 0 || state.offset + length > bytes.length) return null;
|
||||||
|
const out = bytes.slice(state.offset, state.offset + length);
|
||||||
|
state.offset += length;
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function skipProtoField(bytes: Uint8Array, state: { offset: number }, wireType: number): boolean {
|
||||||
|
if (wireType === 0) return readProtoVarint(bytes, state) != null;
|
||||||
|
if (wireType === 1 && state.offset + 8 <= bytes.length) {
|
||||||
|
state.offset += 8;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (wireType === 2) return readProtoBytes(bytes, state) != null;
|
||||||
|
if (wireType === 5 && state.offset + 4 <= bytes.length) {
|
||||||
|
state.offset += 4;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function googleMigrationAlgorithm(value: number): TotpHashAlgorithm | null {
|
||||||
|
if (value === 0 || value === 1) return 'SHA-1';
|
||||||
|
if (value === 2) return 'SHA-256';
|
||||||
|
if (value === 3) return 'SHA-512';
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function googleMigrationDigits(value: number): number {
|
||||||
|
if (value === 2) return 8;
|
||||||
|
return 6;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseGoogleMigrationOtpParameter(bytes: Uint8Array): GoogleAuthenticatorMigrationTotp | null {
|
||||||
|
const state = { offset: 0 };
|
||||||
|
let secretBytes: Uint8Array | null = null;
|
||||||
|
let name = '';
|
||||||
|
let issuer = '';
|
||||||
|
let algorithm: TotpHashAlgorithm | null = 'SHA-1';
|
||||||
|
let digits = 6;
|
||||||
|
let otpType = 0;
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
|
||||||
|
while (state.offset < bytes.length) {
|
||||||
|
const key = readProtoVarint(bytes, state);
|
||||||
|
if (key == null) return null;
|
||||||
|
const fieldNumber = Math.floor(key / 8);
|
||||||
|
const wireType = key % 8;
|
||||||
|
|
||||||
|
if (fieldNumber === 1 && wireType === 2) {
|
||||||
|
secretBytes = readProtoBytes(bytes, state);
|
||||||
|
} else if (fieldNumber === 2 && wireType === 2) {
|
||||||
|
const value = readProtoBytes(bytes, state);
|
||||||
|
name = value ? decoder.decode(value) : '';
|
||||||
|
} else if (fieldNumber === 3 && wireType === 2) {
|
||||||
|
const value = readProtoBytes(bytes, state);
|
||||||
|
issuer = value ? decoder.decode(value) : '';
|
||||||
|
} else if (fieldNumber === 4 && wireType === 0) {
|
||||||
|
const value = readProtoVarint(bytes, state);
|
||||||
|
algorithm = value == null ? null : googleMigrationAlgorithm(value);
|
||||||
|
} else if (fieldNumber === 5 && wireType === 0) {
|
||||||
|
const value = readProtoVarint(bytes, state);
|
||||||
|
digits = googleMigrationDigits(value ?? 0);
|
||||||
|
} else if (fieldNumber === 6 && wireType === 0) {
|
||||||
|
otpType = readProtoVarint(bytes, state) ?? 0;
|
||||||
|
} else if (!skipProtoField(bytes, state, wireType)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!secretBytes?.length || !algorithm || otpType === 1) return null;
|
||||||
|
return {
|
||||||
|
secret: bytesToBase32(secretBytes),
|
||||||
|
name,
|
||||||
|
issuer,
|
||||||
|
algorithm,
|
||||||
|
digits,
|
||||||
|
period: DEFAULT_TOTP_CONFIG.period,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseGoogleAuthenticatorMigration(raw: string): GoogleAuthenticatorMigrationTotp[] {
|
||||||
|
let data = '';
|
||||||
|
try {
|
||||||
|
data = new URL(raw).searchParams.get('data') || '';
|
||||||
|
} catch {
|
||||||
|
data = readOtpAuthParam(raw, 'data');
|
||||||
|
}
|
||||||
|
const bytes = base64ToBytesLoose(data);
|
||||||
|
if (!bytes.length) return [];
|
||||||
|
|
||||||
|
const state = { offset: 0 };
|
||||||
|
const out: GoogleAuthenticatorMigrationTotp[] = [];
|
||||||
|
while (state.offset < bytes.length) {
|
||||||
|
const key = readProtoVarint(bytes, state);
|
||||||
|
if (key == null) return [];
|
||||||
|
const fieldNumber = Math.floor(key / 8);
|
||||||
|
const wireType = key % 8;
|
||||||
|
if (fieldNumber === 1 && wireType === 2) {
|
||||||
|
const parameterBytes = readProtoBytes(bytes, state);
|
||||||
|
const parameter = parameterBytes ? parseGoogleMigrationOtpParameter(parameterBytes) : null;
|
||||||
|
if (parameter) out.push(parameter);
|
||||||
|
} else if (!skipProtoField(bytes, state, wireType)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildOtpAuthUri(account: GoogleAuthenticatorMigrationTotp): string {
|
||||||
|
const issuer = account.issuer.trim();
|
||||||
|
const name = account.name.trim();
|
||||||
|
const label = issuer && name && !name.toLowerCase().startsWith(`${issuer.toLowerCase()}:`)
|
||||||
|
? `${issuer}:${name}`
|
||||||
|
: name || issuer || 'TOTP';
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
secret: account.secret,
|
||||||
|
algorithm: account.algorithm.replace('-', ''),
|
||||||
|
digits: String(account.digits),
|
||||||
|
period: String(account.period),
|
||||||
|
});
|
||||||
|
if (issuer) params.set('issuer', issuer);
|
||||||
|
return `otpauth://totp/${encodeURIComponent(label)}?${params.toString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeTotpInput(raw: string): string {
|
||||||
const s = raw.trim();
|
const s = raw.trim();
|
||||||
|
if (!s) return '';
|
||||||
|
if (/^otpauth-migration:\/\//i.test(s)) {
|
||||||
|
const accounts = parseGoogleAuthenticatorMigration(s);
|
||||||
|
return accounts.length === 1 ? buildOtpAuthUri(accounts[0]) : '';
|
||||||
|
}
|
||||||
|
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(s) && !/^otpauth:\/\//i.test(s) && !/^steam:\/\//i.test(s)) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseTotpConfig(raw: string): TotpConfig {
|
||||||
|
const s = normalizeTotpInput(raw);
|
||||||
if (!s) return { secret: '', steam: false, ...DEFAULT_TOTP_CONFIG };
|
if (!s) return { secret: '', steam: false, ...DEFAULT_TOTP_CONFIG };
|
||||||
if (/^steam:\/\//i.test(s)) {
|
if (/^steam:\/\//i.test(s)) {
|
||||||
return {
|
return {
|
||||||
@@ -295,31 +492,20 @@ function parseTotpConfig(raw: string): TotpConfig {
|
|||||||
if (/^otpauth:\/\//i.test(s)) {
|
if (/^otpauth:\/\//i.test(s)) {
|
||||||
try {
|
try {
|
||||||
const u = new URL(s);
|
const u = new URL(s);
|
||||||
const otpType = u.hostname.toLowerCase();
|
|
||||||
if (otpType !== 'totp') {
|
|
||||||
return { secret: '', steam: false, ...DEFAULT_TOTP_CONFIG };
|
|
||||||
}
|
|
||||||
const label = decodeURIComponent((u.pathname || '').replace(/^\/+/, '')).toLowerCase();
|
|
||||||
const issuer = (u.searchParams.get('issuer') || '').trim().toLowerCase();
|
|
||||||
const algorithm = (u.searchParams.get('algorithm') || '').trim().toLowerCase();
|
|
||||||
const steam = issuer === 'steam' || label.startsWith('steam:') || algorithm === 'steam';
|
|
||||||
return {
|
return {
|
||||||
secret: normalizeTotpSecret(u.searchParams.get('secret') || ''),
|
secret: normalizeTotpSecret(u.searchParams.get('secret') || ''),
|
||||||
steam,
|
steam: false,
|
||||||
algorithm: steam ? 'SHA-1' : parseTotpHashAlgorithm(u.searchParams.get('algorithm')),
|
algorithm: parseTotpHashAlgorithm(u.searchParams.get('algorithm')),
|
||||||
digits: steam ? 5 : parseTotpPositiveInt(u.searchParams.get('digits'), DEFAULT_TOTP_CONFIG.digits, 1, 10),
|
digits: parseTotpDigits(u.searchParams.get('digits')),
|
||||||
period: parseTotpPositiveInt(u.searchParams.get('period'), DEFAULT_TOTP_CONFIG.period, 1, 3600),
|
period: parseTotpPeriod(u.searchParams.get('period')),
|
||||||
};
|
};
|
||||||
} catch {
|
} catch {
|
||||||
const issuer = readOtpAuthParam(s, 'issuer').trim().toLowerCase();
|
|
||||||
const algorithm = readOtpAuthParam(s, 'algorithm').trim().toLowerCase();
|
|
||||||
const steam = issuer === 'steam' || algorithm === 'steam';
|
|
||||||
return {
|
return {
|
||||||
secret: normalizeTotpSecret(readOtpAuthParam(s, 'secret')),
|
secret: normalizeTotpSecret(readOtpAuthParam(s, 'secret')),
|
||||||
steam,
|
steam: false,
|
||||||
algorithm: steam ? 'SHA-1' : parseTotpHashAlgorithm(algorithm),
|
algorithm: parseTotpHashAlgorithm(readOtpAuthParam(s, 'algorithm')),
|
||||||
digits: steam ? 5 : parseTotpPositiveInt(readOtpAuthParam(s, 'digits'), DEFAULT_TOTP_CONFIG.digits, 1, 10),
|
digits: parseTotpDigits(readOtpAuthParam(s, 'digits')),
|
||||||
period: parseTotpPositiveInt(readOtpAuthParam(s, 'period'), DEFAULT_TOTP_CONFIG.period, 1, 3600),
|
period: parseTotpPeriod(readOtpAuthParam(s, 'period')),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -349,7 +535,13 @@ function base32ToBytes(input: string): Uint8Array {
|
|||||||
return new Uint8Array(out);
|
return new Uint8Array(out);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function calcTotpNow(rawSecret: string, nowMs: number = Date.now()): Promise<{ code: string; remain: number } | null> {
|
export interface TotpCodeResult {
|
||||||
|
code: string;
|
||||||
|
remain: number;
|
||||||
|
period: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function calcTotpNow(rawSecret: string, nowMs: number = Date.now()): Promise<TotpCodeResult | null> {
|
||||||
const { secret, steam, algorithm, digits, period } = parseTotpConfig(rawSecret);
|
const { secret, steam, algorithm, digits, period } = parseTotpConfig(rawSecret);
|
||||||
if (!secret) return null;
|
if (!secret) return null;
|
||||||
const keyBytes = base32ToBytes(secret);
|
const keyBytes = base32ToBytes(secret);
|
||||||
@@ -378,5 +570,5 @@ export async function calcTotpNow(rawSecret: string, nowMs: number = Date.now())
|
|||||||
value = Math.floor(value / chars.length);
|
value = Math.floor(value / chars.length);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return { code, remain };
|
return { code, remain, period };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ export function createDemoInitialBootstrapState(): InitialAppBootstrapState {
|
|||||||
return {
|
return {
|
||||||
defaultKdfIterations: 600000,
|
defaultKdfIterations: 600000,
|
||||||
registrationInviteRequired: true,
|
registrationInviteRequired: true,
|
||||||
|
websiteIconsEnabled: true,
|
||||||
jwtWarning: null,
|
jwtWarning: null,
|
||||||
session: null,
|
session: null,
|
||||||
phase: 'login',
|
phase: 'login',
|
||||||
|
|||||||
@@ -790,6 +790,7 @@ export function createDemoInitialBootstrapState(): InitialAppBootstrapState {
|
|||||||
return {
|
return {
|
||||||
defaultKdfIterations: 600000,
|
defaultKdfIterations: 600000,
|
||||||
registrationInviteRequired: true,
|
registrationInviteRequired: true,
|
||||||
|
websiteIconsEnabled: true,
|
||||||
jwtWarning: null,
|
jwtWarning: null,
|
||||||
session: null,
|
session: null,
|
||||||
phase: 'login',
|
phase: 'login',
|
||||||
@@ -1200,7 +1201,7 @@ export function createDemoMainRoutesProps(base: AppMainRoutesProps, notify: Noti
|
|||||||
onDownloadRemoteBackup: async (_masterPassword: string, _destinationId: string, _path: string, _onProgress?: (percent: number | null) => void) => {
|
onDownloadRemoteBackup: async (_masterPassword: string, _destinationId: string, _path: string, _onProgress?: (percent: number | null) => void) => {
|
||||||
notify('success', t('txt_demo_download_prepared'));
|
notify('success', t('txt_demo_download_prepared'));
|
||||||
},
|
},
|
||||||
onInspectRemoteBackup: async (_destinationId: string, path: string) => ({
|
onInspectRemoteBackup: async (_masterPassword: string, _destinationId: string, path: string) => ({
|
||||||
object: 'backup-remote-integrity',
|
object: 'backup-remote-integrity',
|
||||||
destinationId: _destinationId,
|
destinationId: _destinationId,
|
||||||
path,
|
path,
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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 записей",
|
||||||
|
|||||||
@@ -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": "密码项",
|
||||||
|
|||||||
@@ -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": "密碼項",
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -197,6 +280,8 @@ export interface Send {
|
|||||||
key?: string | null;
|
key?: string | null;
|
||||||
maxAccessCount?: number | null;
|
maxAccessCount?: number | null;
|
||||||
accessCount?: number;
|
accessCount?: number;
|
||||||
|
password?: string | null;
|
||||||
|
authType?: number | null;
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
revisionDate?: string;
|
revisionDate?: string;
|
||||||
expirationDate?: string | null;
|
expirationDate?: string | null;
|
||||||
@@ -225,6 +310,7 @@ export interface SendDraft {
|
|||||||
expirationDays: string;
|
expirationDays: string;
|
||||||
maxAccessCount: string;
|
maxAccessCount: string;
|
||||||
password: string;
|
password: string;
|
||||||
|
hasPassword?: boolean;
|
||||||
disabled: boolean;
|
disabled: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -276,6 +362,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[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -294,6 +414,8 @@ export interface WebBootstrapResponse {
|
|||||||
jwtUnsafeReason?: 'missing' | 'too_short' | null;
|
jwtUnsafeReason?: 'missing' | 'too_short' | null;
|
||||||
jwtSecretMinLength?: number;
|
jwtSecretMinLength?: number;
|
||||||
registrationInviteRequired?: boolean;
|
registrationInviteRequired?: boolean;
|
||||||
|
webAuthnAllowedOrigins?: string[];
|
||||||
|
websiteIconsEnabled?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface YubiKeyOtpSettings {
|
export interface YubiKeyOtpSettings {
|
||||||
@@ -338,6 +460,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 {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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) => ({
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
let websiteIconsEnabled = true;
|
||||||
|
|
||||||
|
export function setWebsiteIconsEnabled(enabled: boolean): void {
|
||||||
|
websiteIconsEnabled = enabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function areWebsiteIconsEnabled(): boolean {
|
||||||
|
return websiteIconsEnabled;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -1269,3 +1269,8 @@ select.input.duplicate-mode-toolbar-select {
|
|||||||
@apply text-sm;
|
@apply text-sm;
|
||||||
color: var(--danger);
|
color: var(--danger);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.inline-icon {
|
||||||
|
display: inline;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user