Compare commits

..

6 Commits

Author SHA1 Message Date
shuaiplus bf51309fbb fix: update version to v1.7.1 2026-06-24 01:55:09 +08:00
shuaiplus 23b23f39b9 fix: require reauthentication for auth request approval 2026-06-24 01:44:50 +08:00
shuaiplus 0daad46591 chore: add package overrides for undici, @babel/core, and esbuild 2026-06-24 01:44:50 +08:00
shuaiplus a2a8f1c7b6 fix:Harden authentication and sensitive file handling 2026-06-24 01:44:50 +08:00
shuaiplus 850fe0f044 fix: two-phase invite consumption to prevent registration race condition 2026-06-24 01:44:50 +08:00
shuaiplus 7279668955 fix: address security issue 2026-06-24 01:44:50 +08:00
32 changed files with 1440 additions and 563 deletions
+10
View File
@@ -228,6 +228,16 @@ CREATE TABLE IF NOT EXISTS trusted_two_factor_device_tokens (
CREATE INDEX IF NOT EXISTS idx_trusted_two_factor_device_tokens_user_device CREATE INDEX IF NOT EXISTS idx_trusted_two_factor_device_tokens_user_device
ON trusted_two_factor_device_tokens(user_id, device_identifier); ON trusted_two_factor_device_tokens(user_id, device_identifier);
CREATE TABLE IF NOT EXISTS totp_login_replays (
user_id TEXT NOT NULL,
time_counter INTEGER NOT NULL,
consumed_at INTEGER NOT NULL,
PRIMARY KEY (user_id, time_counter),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_totp_login_replays_consumed_at
ON totp_login_replays(consumed_at);
CREATE TABLE IF NOT EXISTS webauthn_credentials ( CREATE TABLE IF NOT EXISTS webauthn_credentials (
id TEXT PRIMARY KEY, id TEXT PRIMARY KEY,
user_id TEXT NOT NULL, user_id TEXT NOT NULL,
+639 -426
View File
File diff suppressed because it is too large Load Diff
+6 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "nodewarden", "name": "nodewarden",
"version": "1.7.0", "version": "1.7.1",
"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",
@@ -42,6 +42,11 @@
} }
} }
}, },
"overrides": {
"undici": ">=7.28.0",
"@babel/core": ">=7.29.6",
"esbuild": ">=0.28.1"
},
"devDependencies": { "devDependencies": {
"@cloudflare/workers-types": "^4.20260131.0", "@cloudflare/workers-types": "^4.20260131.0",
"@preact/preset-vite": "^2.10.3", "@preact/preset-vite": "^2.10.3",
+1 -1
View File
@@ -1 +1 @@
export const APP_VERSION = '1.7.0'; export const APP_VERSION = '1.7.1';
+35 -6
View File
@@ -353,20 +353,31 @@ export async function handleRegister(request: Request, env: Env): Promise<Respon
return errorResponse('Invite code is required', 403); return errorResponse('Invite code is required', 403);
} }
const inviteMarked = await storage.markInviteUsed(inviteCode, user.id);
if (!inviteMarked) {
return errorResponse('Invite code is invalid or expired', 403);
}
try { try {
await storage.createUser(user); await storage.createUser(user);
} catch (error) { } catch (error) {
await storage.revertInviteUsed(inviteCode, user.id);
const msg = error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase(); const msg = error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase();
if (msg.includes('unique') || msg.includes('constraint')) { if (msg.includes('unique') || msg.includes('constraint')) {
return errorResponse('Email already registered', 409); return errorResponse('Email already registered', 409);
} }
console.error('Registration failed after invite reservation:', error);
throw error; throw error;
} }
const inviteMarked = await storage.markInviteUsed(inviteCode, user.id); try {
if (!inviteMarked) { const assigned = await storage.assignInviteUsedBy(inviteCode, user.id);
await storage.deleteUserById(user.id); if (!assigned) {
return errorResponse('Invite code is invalid or expired', 403); console.warn('Invite used_by was not assigned after registration', { inviteCode, userId: user.id });
}
} catch (error) {
// The invite is already consumed. Do not reactivate it after the user row exists.
console.error('Invite used_by assignment failed after registration:', error);
} }
await writeAuditEvent(storage, { await writeAuditEvent(storage, {
@@ -891,7 +902,7 @@ export async function handleDisableTwoFactorProvider(request: Request, env: Env,
} }
// PUT /api/accounts/totp // PUT /api/accounts/totp
// enable: { enabled: true, secret: "...", token: "123456" } // enable: { enabled: true, secret: "...", token: "123456", masterPasswordHash?: "...", userVerificationToken?: "..." }
// disable: { enabled: false, masterPasswordHash: "..." } // disable: { enabled: false, masterPasswordHash: "..." }
export async function handleSetTotpStatus(request: Request, env: Env, userId: string): Promise<Response> { export async function handleSetTotpStatus(request: Request, env: Env, userId: string): Promise<Response> {
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
@@ -899,7 +910,13 @@ export async function handleSetTotpStatus(request: Request, env: Env, userId: st
const user = await storage.getUserById(userId); const user = await storage.getUserById(userId);
if (!user) return errorResponse('User not found', 404); if (!user) return errorResponse('User not found', 404);
let body: { enabled?: boolean; secret?: string; token?: string; masterPasswordHash?: string }; let body: {
enabled?: boolean;
secret?: string;
token?: string;
masterPasswordHash?: string;
userVerificationToken?: string;
};
try { try {
body = await request.json(); body = await request.json();
} catch { } catch {
@@ -908,12 +925,24 @@ export async function handleSetTotpStatus(request: Request, env: Env, userId: st
if (body.enabled === true) { if (body.enabled === true) {
const normalizedSecret = normalizeTotpSecret(body.secret || ''); const normalizedSecret = normalizeTotpSecret(body.secret || '');
const masterPasswordHash = readBodyString(body, ['masterPasswordHash', 'MasterPasswordHash']);
const userVerificationToken = readBodyString(body, ['userVerificationToken', 'UserVerificationToken']);
if (!isTotpEnabled(normalizedSecret)) { if (!isTotpEnabled(normalizedSecret)) {
return errorResponse('Invalid TOTP secret', 400); return errorResponse('Invalid TOTP secret', 400);
} }
if (!body.token) { if (!body.token) {
return errorResponse('TOTP token is required', 400); return errorResponse('TOTP token is required', 400);
} }
let verifiedUser = false;
if (userVerificationToken) {
verifiedUser = await verifyTotpUserVerificationToken(env, user, normalizedSecret, userVerificationToken);
}
if (!verifiedUser && masterPasswordHash) {
verifiedUser = await auth.verifyPassword(masterPasswordHash, user.masterPasswordHash, user.email);
}
if (!verifiedUser) {
return errorResponse('User verification failed.', 400);
}
const verified = await verifyTotpToken(normalizedSecret, body.token); const verified = await verifyTotpToken(normalizedSecret, body.token);
if (!verified) { if (!verified) {
return errorResponse('Invalid TOTP token', 400); return errorResponse('Invalid TOTP token', 400);
+2 -1
View File
@@ -4,6 +4,7 @@ import { StorageService } from '../services/storage';
import { jsonResponse, errorResponse } from '../utils/response'; import { jsonResponse, errorResponse } from '../utils/response';
import { buildDirectUploadUrl, getSafeJwtSecret, parseDirectUploadPayload } from '../utils/direct-upload'; import { buildDirectUploadUrl, getSafeJwtSecret, parseDirectUploadPayload } from '../utils/direct-upload';
import { generateUUID } from '../utils/uuid'; import { generateUUID } from '../utils/uuid';
import { sanitizeDownloadContentType } from '../utils/content-type';
import { import {
createAttachmentUploadToken, createAttachmentUploadToken,
createFileDownloadToken, createFileDownloadToken,
@@ -449,7 +450,7 @@ export async function handlePublicDownloadAttachment(
return new Response(object.body, { return new Response(object.body, {
headers: { headers: {
'Content-Type': object.contentType || 'application/octet-stream', 'Content-Type': sanitizeDownloadContentType(object.contentType),
'Content-Length': String(object.size), 'Content-Length': String(object.size),
'Content-Disposition': contentDispositionAttachment(attachment.fileName), 'Content-Disposition': contentDispositionAttachment(attachment.fileName),
'Cache-Control': 'private, no-cache', 'Cache-Control': 'private, no-cache',
+16
View File
@@ -14,6 +14,19 @@ function normalizeText(value: unknown, maxLength: number): string {
return String(value ?? '').trim().slice(0, maxLength); return String(value ?? '').trim().slice(0, maxLength);
} }
function isSerializedEncString(value: unknown): value is string {
const text = String(value || '').trim();
if (!text) return false;
const parts = text.split('.');
if (parts.length !== 2) return false;
const type = Number(parts[0]);
const bodyParts = parts[1].split('|');
if (type === 2) return bodyParts.length === 3 && bodyParts.every(Boolean);
if (type === 3 || type === 4) return bodyParts.length === 1 && !!bodyParts[0];
if (type === 5 || type === 6) return bodyParts.length === 2 && bodyParts.every(Boolean);
return false;
}
function getClientIp(request: Request): string | null { function getClientIp(request: Request): string | null {
return ( return (
request.headers.get('CF-Connecting-IP') || request.headers.get('CF-Connecting-IP') ||
@@ -251,6 +264,9 @@ export async function handleUpdateAuthRequest(request: Request, env: Env, userId
if (approved && !key) { if (approved && !key) {
return errorResponse('Encrypted key is required to approve the request.', 400); return errorResponse('Encrypted key is required to approve the request.', 400);
} }
if (approved && !isSerializedEncString(key)) {
return errorResponse('Encrypted key is not a valid encrypted string.', 400);
}
const updated = await storage.updateAuthRequestResponse(id, userId, { const updated = await storage.updateAuthRequestResponse(id, userId, {
approved, approved,
+80 -12
View File
@@ -40,15 +40,51 @@ import {
uploadBackupArchive, uploadBackupArchive,
} from '../services/backup-uploader'; } from '../services/backup-uploader';
import { StorageService } from '../services/storage'; import { StorageService } from '../services/storage';
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 { verifyPasskeyUserVerificationToken } from '../utils/user-verification-token';
import { unzipSync } from 'fflate'; import { unzipSync } from 'fflate';
function isAdmin(user: User): boolean { function isAdmin(user: User): boolean {
return user.role === 'admin' && user.status === 'active'; return user.role === 'admin' && user.status === 'active';
} }
async function requireBackupUserVerification(actorUser: User, masterPasswordHash: string, env: Env): Promise<Response | null> {
const normalized = String(masterPasswordHash || '').trim();
if (!normalized) {
return errorResponse('masterPasswordHash is required', 400);
}
const auth = new AuthService(env);
const valid = await auth.verifyPassword(normalized, actorUser.masterPasswordHash, actorUser.email);
if (!valid) {
return errorResponse('Invalid password', 400);
}
return null;
}
async function requireBackupRepairVerification(
actorUser: User,
body: { masterPasswordHash?: string; userVerificationToken?: string },
env: Env
): Promise<Response | null> {
const masterPasswordHash = String(body.masterPasswordHash || '').trim();
if (masterPasswordHash) {
return requireBackupUserVerification(actorUser, masterPasswordHash, env);
}
const userVerificationToken = String(body.userVerificationToken || '').trim();
if (!userVerificationToken) {
return errorResponse('masterPasswordHash or userVerificationToken is required', 400);
}
const valid = await verifyPasskeyUserVerificationToken(env, userVerificationToken, actorUser.id, 'backup.settings.repair');
if (!valid) {
return errorResponse('Invalid user verification token', 400);
}
return null;
}
async function writeAuditLog( async function writeAuditLog(
storage: StorageService, storage: StorageService,
actorUserId: string | null, actorUserId: string | null,
@@ -787,13 +823,16 @@ export async function handleGetAdminBackupSettings(request: Request, env: Env, a
export async function handleUpdateAdminBackupSettings(request: Request, env: Env, actorUser: User): Promise<Response> { export async function handleUpdateAdminBackupSettings(request: Request, env: Env, actorUser: User): Promise<Response> {
if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403); if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403);
let body: BackupSettingsInput; let body: BackupSettingsInput & { masterPasswordHash?: string };
try { try {
body = await request.json<BackupSettingsInput>(); body = await request.json<BackupSettingsInput & { masterPasswordHash?: string }>();
} catch { } catch {
return errorResponse('Backup settings payload is invalid', 400); return errorResponse('Backup settings 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);
let previous; let previous;
try { try {
@@ -837,13 +876,16 @@ export async function handleGetAdminBackupSettingsRepairState(request: Request,
export async function handleRepairAdminBackupSettings(request: Request, env: Env, actorUser: User): Promise<Response> { export async function handleRepairAdminBackupSettings(request: Request, env: Env, actorUser: User): Promise<Response> {
if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403); if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403);
let body: BackupSettingsInput; let body: BackupSettingsInput & { masterPasswordHash?: string; userVerificationToken?: string };
try { try {
body = await request.json<BackupSettingsInput>(); body = await request.json<BackupSettingsInput & { masterPasswordHash?: string; userVerificationToken?: string }>();
} catch { } catch {
return errorResponse('Backup settings repair payload is invalid', 400); return errorResponse('Backup settings repair payload is invalid', 400);
} }
const verificationError = await requireBackupRepairVerification(actorUser, body, env);
if (verificationError) return verificationError;
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
let previous; let previous;
try { try {
@@ -871,15 +913,18 @@ export async function handleRunAdminConfiguredBackup(request: Request, env: Env,
if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403); if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403);
try { try {
let body: { destinationId?: string } | null = null; let body: { destinationId?: string; masterPasswordHash?: string } | null = null;
try { try {
if ((request.headers.get('Content-Type') || '').includes('application/json')) { if ((request.headers.get('Content-Type') || '').includes('application/json')) {
body = await request.json<{ destinationId?: string }>(); body = await request.json<{ destinationId?: string; masterPasswordHash?: string }>();
} }
} catch { } catch {
return errorResponse('Backup run payload is invalid', 400); return errorResponse('Backup run payload is invalid', 400);
} }
const verificationError = await requireBackupUserVerification(actorUser, String(body?.masterPasswordHash || ''), env);
if (verificationError) return verificationError;
const outcome = await runConfiguredBackupInDurableObject(env, { const outcome = await runConfiguredBackupInDurableObject(env, {
actorUserId: actorUser.id, actorUserId: actorUser.id,
auditMetadata: auditRequestMetadata(request), auditMetadata: auditRequestMetadata(request),
@@ -928,12 +973,21 @@ export async function handleListAdminRemoteBackups(request: Request, env: Env, a
export async function handleDownloadAdminRemoteBackup(request: Request, env: Env, actorUser: User): Promise<Response> { export async function handleDownloadAdminRemoteBackup(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 download 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);
return new Response(remoteFile.bytes, { return new Response(remoteFile.bytes, {
status: 200, status: 200,
@@ -994,13 +1048,22 @@ export async function handleDeleteAdminRemoteBackup(request: Request, env: Env,
export async function handleRestoreAdminRemoteBackup(request: Request, env: Env, actorUser: User): Promise<Response> { export async function handleRestoreAdminRemoteBackup(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; replaceExisting?: boolean; allowChecksumMismatch?: boolean }; let body: {
destinationId?: string;
path?: string;
replaceExisting?: boolean;
allowChecksumMismatch?: boolean;
masterPasswordHash?: string;
};
try { try {
body = await request.json<{ destinationId?: string; path?: string; replaceExisting?: boolean }>(); body = await request.json<{ destinationId?: string; path?: string; replaceExisting?: boolean }>();
} catch { } catch {
return errorResponse('Remote restore payload is invalid', 400); return errorResponse('Remote restore payload is invalid', 400);
} }
const verificationError = await requireBackupUserVerification(actorUser, String(body.masterPasswordHash || ''), env);
if (verificationError) return verificationError;
try { try {
const path = ensureRemoteRestoreCandidate(String(body.path || '')); const path = ensureRemoteRestoreCandidate(String(body.path || ''));
const targetDeviceIdentifier = String(request.headers.get('X-NodeWarden-Acting-Device-Id') || '').trim() || null; const targetDeviceIdentifier = String(request.headers.get('X-NodeWarden-Acting-Device-Id') || '').trim() || null;
@@ -1028,14 +1091,16 @@ export async function handleAdminExportBackup(request: Request, env: Env, actorU
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
const targetDeviceIdentifier = String(request.headers.get('X-NodeWarden-Acting-Device-Id') || '').trim() || null; const targetDeviceIdentifier = String(request.headers.get('X-NodeWarden-Acting-Device-Id') || '').trim() || null;
let body: { includeAttachments?: boolean } | null = null; let body: { includeAttachments?: boolean; masterPasswordHash?: string } | null = null;
try { try {
if ((request.headers.get('Content-Type') || '').includes('application/json')) { if ((request.headers.get('Content-Type') || '').includes('application/json')) {
body = await request.json<{ includeAttachments?: boolean }>(); body = await request.json<{ includeAttachments?: boolean; masterPasswordHash?: string }>();
} }
} catch { } catch {
return errorResponse('Backup export payload is invalid', 400); return errorResponse('Backup export payload is invalid', 400);
} }
const verificationError = await requireBackupUserVerification(actorUser, String(body?.masterPasswordHash || ''), env);
if (verificationError) return verificationError;
let archive: BackupArchiveBundle; let archive: BackupArchiveBundle;
try { try {
const progress = async (event: { const progress = async (event: {
@@ -1140,6 +1205,9 @@ export async function handleAdminImportBackup(request: Request, env: Env, actorU
return errorResponse('Backup file is required', 400); return errorResponse('Backup file is required', 400);
} }
const verificationError = await requireBackupUserVerification(actorUser, String(formData.get('masterPasswordHash') || ''), env);
if (verificationError) return verificationError;
const replaceExisting = String(formData.get('replaceExisting') || '').trim() === '1'; const replaceExisting = String(formData.get('replaceExisting') || '').trim() === '1';
const allowChecksumMismatch = String(formData.get('allowChecksumMismatch') || '').trim() === '1'; const allowChecksumMismatch = String(formData.get('allowChecksumMismatch') || '').trim() === '1';
let archiveBytes: Uint8Array; let archiveBytes: Uint8Array;
+15 -4
View File
@@ -4,7 +4,7 @@ import { AuthService } from '../services/auth';
import { RateLimitService, getClientIdentifier } from '../services/ratelimit'; import { RateLimitService, getClientIdentifier } from '../services/ratelimit';
import { jsonResponse, errorResponse, identityErrorResponse } from '../utils/response'; import { jsonResponse, errorResponse, identityErrorResponse } from '../utils/response';
import { LIMITS } from '../config/limits'; import { LIMITS } from '../config/limits';
import { isTotpEnabled, verifyTotpToken } from '../utils/totp'; import { findMatchingTotpCounter, isTotpEnabled } from '../utils/totp';
import { createRefreshToken } from '../utils/jwt'; import { createRefreshToken } from '../utils/jwt';
import { readAuthRequestDeviceInfo } from '../utils/device'; import { readAuthRequestDeviceInfo } from '../utils/device';
import { createRecoveryCode, recoveryCodeEquals } from '../utils/recovery-code'; import { createRecoveryCode, recoveryCodeEquals } from '../utils/recovery-code';
@@ -21,6 +21,7 @@ import {
buildAccountPasskeyTokenUserDecryptionOption, buildAccountPasskeyTokenUserDecryptionOption,
} from './account-passkeys'; } from './account-passkeys';
import { isAuthRequestExpired } from '../services/storage-auth-request-repo'; import { isAuthRequestExpired } from '../services/storage-auth-request-repo';
import { createPasskeyUserVerificationToken } from '../utils/user-verification-token';
const TWO_FACTOR_REMEMBER_TTL_MS = 30 * 24 * 60 * 60 * 1000; const TWO_FACTOR_REMEMBER_TTL_MS = 30 * 24 * 60 * 60 * 1000;
const TWO_FACTOR_PROVIDER_AUTHENTICATOR = 0; const TWO_FACTOR_PROVIDER_AUTHENTICATOR = 0;
@@ -336,6 +337,7 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
} }
let validatedAuthRequestId: string | null = null; let validatedAuthRequestId: string | null = null;
let authRequestLoginKey: string | null = null;
let valid = false; let valid = false;
const normalizedAuthRequestId = String(authRequestId || '').trim(); const normalizedAuthRequestId = String(authRequestId || '').trim();
if (normalizedAuthRequestId) { if (normalizedAuthRequestId) {
@@ -348,10 +350,12 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
authRequest.responseDate && authRequest.responseDate &&
!authRequest.authenticationDate && !authRequest.authenticationDate &&
!isAuthRequestExpired(authRequest) && !isAuthRequestExpired(authRequest) &&
!!authRequest.key &&
constantTimeEquals(authRequest.accessCode, passwordHash) constantTimeEquals(authRequest.accessCode, passwordHash)
); );
if (valid) { if (valid) {
validatedAuthRequestId = authRequest!.id; validatedAuthRequestId = authRequest!.id;
authRequestLoginKey = authRequest!.key;
} }
} else { } else {
valid = await auth.verifyPassword(passwordHash, user.masterPasswordHash, user.email); valid = await auth.verifyPassword(passwordHash, user.masterPasswordHash, user.email);
@@ -408,8 +412,12 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
return twoFactorRequiredResponse('Two factor required.'); return twoFactorRequiredResponse('Two factor required.');
} }
} else if (normalizedTwoFactorProvider === String(TWO_FACTOR_PROVIDER_AUTHENTICATOR)) { } else if (normalizedTwoFactorProvider === String(TWO_FACTOR_PROVIDER_AUTHENTICATOR)) {
const totpOk = await verifyTotpToken(effectiveTotpSecret, normalizedTwoFactorToken); const matchedCounter = await findMatchingTotpCounter(effectiveTotpSecret, normalizedTwoFactorToken);
if (!totpOk) { if (matchedCounter == null) {
return recordFailedTwoFactorAndBuildResponse(rateLimit, loginIdentifier);
}
const consumed = await storage.consumeTotpLoginCounter(user.id, matchedCounter);
if (!consumed) {
return recordFailedTwoFactorAndBuildResponse(rateLimit, loginIdentifier); return recordFailedTwoFactorAndBuildResponse(rateLimit, loginIdentifier);
} }
} else if ( } else if (
@@ -488,7 +496,7 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
token_type: 'Bearer', token_type: 'Bearer',
...(shouldUseWebSession(request) ? { web_session: true } : { refresh_token: refreshToken }), ...(shouldUseWebSession(request) ? { web_session: true } : { refresh_token: refreshToken }),
...(trustedTwoFactorTokenToReturn ? { TwoFactorToken: trustedTwoFactorTokenToReturn } : {}), ...(trustedTwoFactorTokenToReturn ? { TwoFactorToken: trustedTwoFactorTokenToReturn } : {}),
Key: user.key, Key: authRequestLoginKey || user.key,
PrivateKey: user.privateKey, PrivateKey: user.privateKey,
AccountKeys: accountKeys, AccountKeys: accountKeys,
accountKeys: accountKeys, accountKeys: accountKeys,
@@ -583,6 +591,7 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
const accessToken = await auth.generateAccessToken(user, deviceSession); const accessToken = await auth.generateAccessToken(user, deviceSession);
const refreshToken = await auth.generateRefreshToken(user.id, deviceSession); const refreshToken = await auth.generateRefreshToken(user.id, deviceSession);
const userVerificationToken = await createPasskeyUserVerificationToken(env, user.id, 'backup.settings.repair');
const accountKeys = buildAccountKeys(user); const accountKeys = buildAccountKeys(user);
const webAuthnPrfOption = buildAccountPasskeyTokenUserDecryptionOption(credential); const webAuthnPrfOption = buildAccountPasskeyTokenUserDecryptionOption(credential);
const userDecryptionOptions = buildUserDecryptionOptions(user, webAuthnPrfOption); const userDecryptionOptions = buildUserDecryptionOptions(user, webAuthnPrfOption);
@@ -621,6 +630,8 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
ApiUseKeyConnector: false, ApiUseKeyConnector: false,
scope: 'api offline_access', scope: 'api offline_access',
unofficialServer: true, unofficialServer: true,
UserVerificationToken: userVerificationToken,
userVerificationToken,
UserDecryptionOptions: userDecryptionOptions, UserDecryptionOptions: userDecryptionOptions,
userDecryptionOptions: userDecryptionOptions, userDecryptionOptions: userDecryptionOptions,
}; };
+2 -1
View File
@@ -2,6 +2,7 @@ import { Env, SendType } from '../types';
import { StorageService } from '../services/storage'; import { StorageService } from '../services/storage';
import { RateLimitService, getClientIdentifier } from '../services/ratelimit'; import { RateLimitService, getClientIdentifier } from '../services/ratelimit';
import { jsonResponse, errorResponse } from '../utils/response'; import { jsonResponse, errorResponse } from '../utils/response';
import { sanitizeDownloadContentType } from '../utils/content-type';
import { LIMITS } from '../config/limits'; import { LIMITS } from '../config/limits';
import { import {
createSendAccessToken, createSendAccessToken,
@@ -306,7 +307,7 @@ export async function handleDownloadSendFile(
return new Response(object.body, { return new Response(object.body, {
headers: { headers: {
'Content-Type': object.contentType || 'application/octet-stream', 'Content-Type': sanitizeDownloadContentType(object.contentType),
'Content-Length': String(object.size), 'Content-Length': String(object.size),
'Content-Disposition': contentDispositionAttachment(fileName), 'Content-Disposition': contentDispositionAttachment(fileName),
'Cache-Control': 'private, no-cache', 'Cache-Control': 'private, no-cache',
+1 -1
View File
@@ -50,7 +50,7 @@ export async function handleAdminBackupRoute(
return handleListAdminRemoteBackups(request, env, actorUser); return handleListAdminRemoteBackups(request, env, actorUser);
} }
if (path === '/api/admin/backup/remote/download' && method === 'GET') { if (path === '/api/admin/backup/remote/download' && method === 'POST') {
return handleDownloadAdminRemoteBackup(request, env, actorUser); return handleDownloadAdminRemoteBackup(request, env, actorUser);
} }
+3 -1
View File
@@ -27,6 +27,7 @@ import {
handleNotificationsNegotiate, handleNotificationsNegotiate,
} from './handlers/notifications'; } from './handlers/notifications';
import { handlePublicUploadSendFile } from './handlers/sends'; import { handlePublicUploadSendFile } from './handlers/sends';
import { isSafeWebsiteIconContentType } from './utils/content-type';
import { jsonResponse } from './utils/response'; import { jsonResponse } from './utils/response';
import { StorageService } from './services/storage'; import { StorageService } from './services/storage';
import type { Env } from './types'; import type { Env } from './types';
@@ -241,6 +242,7 @@ function iconResponse(body: BodyInit | null, contentType: string | null): Respon
headers: { headers: {
'Content-Type': contentType || 'image/png', 'Content-Type': contentType || 'image/png',
'Cache-Control': `public, max-age=${LIMITS.cache.iconTtlSeconds}, immutable`, 'Cache-Control': `public, max-age=${LIMITS.cache.iconTtlSeconds}, immutable`,
'Content-Security-Policy': "default-src 'none'; img-src 'self' data:; sandbox",
}, },
}); });
} }
@@ -272,7 +274,7 @@ async function handleWebsiteIcon(host: string, fallbackMode: 'default' | 'not-fo
if (!resp.ok) continue; if (!resp.ok) continue;
const contentType = String(resp.headers.get('Content-Type') || '').toLowerCase(); const contentType = String(resp.headers.get('Content-Type') || '').toLowerCase();
if (!contentType.startsWith('image/')) continue; if (!isSafeWebsiteIconContentType(contentType)) continue;
const contentLength = getPositiveContentLength(resp.headers); const contentLength = getPositiveContentLength(resp.headers);
if (contentLength !== null && contentLength > ICON_MAX_BUFFER_BYTES) continue; if (contentLength !== null && contentLength > ICON_MAX_BUFFER_BYTES) continue;
+26 -2
View File
@@ -117,12 +117,36 @@ export async function listInvites(db: D1Database, includeInactive: boolean = fal
} }
export async function markInviteUsed(db: D1Database, code: string, userId: string): Promise<boolean> { export async function markInviteUsed(db: D1Database, code: string, userId: string): Promise<boolean> {
void userId;
const now = new Date().toISOString(); const now = new Date().toISOString();
const result = await db const result = await db
.prepare( .prepare(
"UPDATE invites SET status = 'used', used_by = ?, updated_at = ? WHERE code = ? AND status = 'active' AND expires_at > ?" "UPDATE invites SET status = 'used', used_by = NULL, updated_at = ? WHERE code = ? AND status = 'active' AND expires_at > ?"
) )
.bind(userId, now, code, now) .bind(now, code, now)
.run();
return (result.meta.changes ?? 0) > 0;
}
export async function assignInviteUsedBy(db: D1Database, code: string, userId: string): Promise<boolean> {
const now = new Date().toISOString();
const result = await db
.prepare(
"UPDATE invites SET used_by = ?, updated_at = ? WHERE code = ? AND status = 'used' AND used_by IS NULL"
)
.bind(userId, now, code)
.run();
return (result.meta.changes ?? 0) > 0;
}
export async function revertInviteUsed(db: D1Database, code: string, userId: string): Promise<boolean> {
void userId;
const now = new Date().toISOString();
const result = await db
.prepare(
"UPDATE invites SET status = 'active', used_by = NULL, updated_at = ? WHERE code = ? AND status = 'used' AND used_by IS NULL"
)
.bind(now, code)
.run(); .run();
return (result.meta.changes ?? 0) > 0; return (result.meta.changes ?? 0) > 0;
} }
+7
View File
@@ -78,6 +78,7 @@ const SCHEMA_STATEMENTS: readonly string[] = [
'code TEXT PRIMARY KEY, created_by TEXT NOT NULL, used_by TEXT, expires_at TEXT NOT NULL, status TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, ' + 'code TEXT PRIMARY KEY, created_by TEXT NOT NULL, used_by TEXT, expires_at TEXT NOT NULL, status TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, ' +
'FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE CASCADE, ' + 'FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE CASCADE, ' +
'FOREIGN KEY (used_by) REFERENCES users(id) ON DELETE SET NULL)', 'FOREIGN KEY (used_by) REFERENCES users(id) ON DELETE SET NULL)',
'ALTER TABLE invites ADD COLUMN used_by TEXT',
'CREATE INDEX IF NOT EXISTS idx_invites_status_expires ON invites(status, expires_at)', 'CREATE INDEX IF NOT EXISTS idx_invites_status_expires ON invites(status, expires_at)',
'CREATE INDEX IF NOT EXISTS idx_invites_created_by ON invites(created_by, created_at)', 'CREATE INDEX IF NOT EXISTS idx_invites_created_by ON invites(created_by, created_at)',
@@ -126,6 +127,12 @@ const SCHEMA_STATEMENTS: readonly string[] = [
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)', 'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)',
'CREATE INDEX IF NOT EXISTS idx_trusted_two_factor_device_tokens_user_device ON trusted_two_factor_device_tokens(user_id, device_identifier)', 'CREATE INDEX IF NOT EXISTS idx_trusted_two_factor_device_tokens_user_device ON trusted_two_factor_device_tokens(user_id, device_identifier)',
'CREATE TABLE IF NOT EXISTS totp_login_replays (' +
'user_id TEXT NOT NULL, time_counter INTEGER NOT NULL, consumed_at INTEGER NOT NULL, ' +
'PRIMARY KEY (user_id, time_counter), ' +
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)',
'CREATE INDEX IF NOT EXISTS idx_totp_login_replays_consumed_at ON totp_login_replays(consumed_at)',
'CREATE TABLE IF NOT EXISTS webauthn_credentials (' + 'CREATE TABLE IF NOT EXISTS webauthn_credentials (' +
'id TEXT PRIMARY KEY, user_id TEXT NOT NULL, name TEXT NOT NULL, public_key TEXT NOT NULL, credential_id TEXT NOT NULL, counter INTEGER NOT NULL DEFAULT 0, ' + 'id TEXT PRIMARY KEY, user_id TEXT NOT NULL, name TEXT NOT NULL, public_key TEXT NOT NULL, credential_id TEXT NOT NULL, counter INTEGER NOT NULL DEFAULT 0, ' +
'type TEXT, aa_guid TEXT, transports TEXT, encrypted_user_key TEXT, encrypted_public_key TEXT, encrypted_private_key TEXT, supports_prf INTEGER NOT NULL DEFAULT 0, ' + 'type TEXT, aa_guid TEXT, transports TEXT, encrypted_user_key TEXT, encrypted_public_key TEXT, encrypted_private_key TEXT, supports_prf INTEGER NOT NULL DEFAULT 0, ' +
+35
View File
@@ -0,0 +1,35 @@
type ShouldRunPeriodicCleanup = (lastRunAt: number, intervalMs: number) => boolean;
export async function consumeTotpLoginCounter(
db: D1Database,
shouldRunPeriodicCleanup: ShouldRunPeriodicCleanup,
lastCleanupAt: number,
cleanupIntervalMs: number,
userId: string,
timeCounter: number,
consumedAtMs: number,
markerTtlMs: number
): Promise<{ consumed: boolean; cleanedUpAt: number | null }> {
let cleanedUpAt: number | null = null;
if (shouldRunPeriodicCleanup(lastCleanupAt, cleanupIntervalMs)) {
await db
.prepare('DELETE FROM totp_login_replays WHERE consumed_at < ?')
.bind(consumedAtMs - markerTtlMs)
.run();
cleanedUpAt = consumedAtMs;
}
const result = await db
.prepare(
'INSERT INTO totp_login_replays(user_id, time_counter, consumed_at) VALUES(?, ?, ?) ' +
'ON CONFLICT(user_id, time_counter) DO NOTHING'
)
.bind(userId, timeCounter, consumedAtMs)
.run();
return {
consumed: (result.meta.changes ?? 0) > 0,
cleanedUpAt,
};
}
+36 -2
View File
@@ -22,6 +22,7 @@ import {
type AuditLogListOptions, type AuditLogListOptions,
createAuditLog as createStoredAuditLog, createAuditLog as createStoredAuditLog,
clearAuditLogs as clearStoredAuditLogs, clearAuditLogs as clearStoredAuditLogs,
assignInviteUsedBy as assignStoredInviteUsedBy,
createInvite as createStoredInvite, createInvite as createStoredInvite,
deleteAllInvites as deleteStoredInvites, deleteAllInvites as deleteStoredInvites,
getInvite as findStoredInvite, getInvite as findStoredInvite,
@@ -30,6 +31,7 @@ import {
markInviteUsed as markStoredInviteUsed, markInviteUsed as markStoredInviteUsed,
pruneAuditLogs as pruneStoredAuditLogs, pruneAuditLogs as pruneStoredAuditLogs,
pruneAuditLogsToMax as pruneStoredAuditLogsToMax, pruneAuditLogsToMax as pruneStoredAuditLogsToMax,
revertInviteUsed as revertStoredInviteUsed,
revokeInvite as revokeStoredInvite, revokeInvite as revokeStoredInvite,
} from './storage-admin-repo'; } from './storage-admin-repo';
import { import {
@@ -121,6 +123,9 @@ import {
ensureUsedAttachmentDownloadTokenTable as ensureStoredAttachmentTokenTable, ensureUsedAttachmentDownloadTokenTable as ensureStoredAttachmentTokenTable,
consumeAttachmentDownloadToken as consumeStoredAttachmentDownloadToken, consumeAttachmentDownloadToken as consumeStoredAttachmentDownloadToken,
} from './storage-attachment-token-repo'; } from './storage-attachment-token-repo';
import {
consumeTotpLoginCounter as consumeStoredTotpLoginCounter,
} from './storage-totp-replay-repo';
import { import {
getRevisionDate as getStoredRevisionDate, getRevisionDate as getStoredRevisionDate,
updateRevisionDate as updateStoredRevisionDate, updateRevisionDate as updateStoredRevisionDate,
@@ -148,8 +153,8 @@ const STORAGE_SCHEMA_VERSION_KEY = 'schema.version';
// Bump this whenever src/services/storage-schema.ts or migrations/0001_init.sql // Bump this whenever src/services/storage-schema.ts or migrations/0001_init.sql
// changes. Existing D1 installs only rerun ensureStorageSchema() when this value // changes. Existing D1 installs only rerun ensureStorageSchema() when this value
// differs from config.schema.version. // differs from config.schema.version.
const STORAGE_SCHEMA_VERSION = '2026-06-22-push-notifications'; const STORAGE_SCHEMA_VERSION = '2026-06-23-totp-login-replay';
const REQUIRED_SCHEMA_TABLES = ['webauthn_credentials', 'webauthn_challenges', 'auth_requests'] as const; const REQUIRED_SCHEMA_TABLES = ['webauthn_credentials', 'webauthn_challenges', 'auth_requests', 'totp_login_replays'] as const;
// D1-backed storage. // D1-backed storage.
// Contract: // Contract:
@@ -162,10 +167,13 @@ export class StorageService {
private static schemaVerified = false; private static schemaVerified = false;
private static lastRefreshTokenCleanupAt = 0; private static lastRefreshTokenCleanupAt = 0;
private static lastAttachmentTokenCleanupAt = 0; private static lastAttachmentTokenCleanupAt = 0;
private static lastTotpReplayCleanupAt = 0;
private static readonly MAX_D1_SQL_VARIABLES = 100; private static readonly MAX_D1_SQL_VARIABLES = 100;
private static readonly REFRESH_TOKEN_CLEANUP_INTERVAL_MS = LIMITS.cleanup.refreshTokenCleanupIntervalMs; private static readonly REFRESH_TOKEN_CLEANUP_INTERVAL_MS = LIMITS.cleanup.refreshTokenCleanupIntervalMs;
private static readonly ATTACHMENT_TOKEN_CLEANUP_INTERVAL_MS = LIMITS.cleanup.attachmentTokenCleanupIntervalMs; private static readonly ATTACHMENT_TOKEN_CLEANUP_INTERVAL_MS = LIMITS.cleanup.attachmentTokenCleanupIntervalMs;
private static readonly TOTP_REPLAY_CLEANUP_INTERVAL_MS = 10 * 60 * 1000;
private static readonly TOTP_REPLAY_MARKER_TTL_MS = 5 * 60 * 1000;
private static readonly PERIODIC_CLEANUP_PROBABILITY = LIMITS.cleanup.cleanupProbability; private static readonly PERIODIC_CLEANUP_PROBABILITY = LIMITS.cleanup.cleanupProbability;
constructor(private db: D1Database) {} constructor(private db: D1Database) {}
@@ -313,6 +321,14 @@ export class StorageService {
return markStoredInviteUsed(this.db, code, userId); return markStoredInviteUsed(this.db, code, userId);
} }
async assignInviteUsedBy(code: string, userId: string): Promise<boolean> {
return assignStoredInviteUsedBy(this.db, code, userId);
}
async revertInviteUsed(code: string, userId: string): Promise<boolean> {
return revertStoredInviteUsed(this.db, code, userId);
}
async revokeInvite(code: string): Promise<boolean> { async revokeInvite(code: string): Promise<boolean> {
return revokeStoredInvite(this.db, code); return revokeStoredInvite(this.db, code);
} }
@@ -823,6 +839,24 @@ export class StorageService {
return findStoredTrustedTokenUserId(this.db, this.trustedTwoFactorTokenKey.bind(this), token, deviceIdentifier); return findStoredTrustedTokenUserId(this.db, this.trustedTwoFactorTokenKey.bind(this), token, deviceIdentifier);
} }
async consumeTotpLoginCounter(userId: string, timeCounter: number, consumedAtMs: number = Date.now()): Promise<boolean> {
if (!Number.isSafeInteger(timeCounter) || timeCounter < 0) return false;
const result = await consumeStoredTotpLoginCounter(
this.db,
this.shouldRunPeriodicCleanup.bind(this),
StorageService.lastTotpReplayCleanupAt,
StorageService.TOTP_REPLAY_CLEANUP_INTERVAL_MS,
userId,
timeCounter,
consumedAtMs,
StorageService.TOTP_REPLAY_MARKER_TTL_MS
);
if (result.cleanedUpAt !== null) {
StorageService.lastTotpReplayCleanupAt = result.cleanedUpAt;
}
return result.consumed;
}
// --- Revision dates --- // --- Revision dates ---
async getRevisionDate(userId: string): Promise<string> { async getRevisionDate(userId: string): Promise<string> {
+2
View File
@@ -466,6 +466,8 @@ export interface TokenResponse {
ResetMasterPassword: boolean; ResetMasterPassword: boolean;
scope: string; scope: string;
unofficialServer: boolean; unofficialServer: boolean;
UserVerificationToken?: string;
userVerificationToken?: string;
MasterPasswordPolicy?: { MasterPasswordPolicy?: {
minComplexity: number; minComplexity: number;
minLength: number; minLength: number;
+38
View File
@@ -0,0 +1,38 @@
const ACTIVE_DOWNLOAD_MEDIA_TYPES = new Set([
'application/xhtml+xml',
'application/xml',
'image/svg+xml',
'text/html',
'text/xml',
]);
const SAFE_ICON_MEDIA_TYPES = new Set([
'image/avif',
'image/bmp',
'image/gif',
'image/jpeg',
'image/png',
'image/vnd.microsoft.icon',
'image/webp',
'image/x-icon',
]);
function normalizeMediaType(contentType: string | null | undefined): string {
return String(contentType || '')
.split(';', 1)[0]
.trim()
.toLowerCase();
}
export function isSafeWebsiteIconContentType(contentType: string | null | undefined): boolean {
return SAFE_ICON_MEDIA_TYPES.has(normalizeMediaType(contentType));
}
export function sanitizeDownloadContentType(contentType: string | null | undefined): string {
const mediaType = normalizeMediaType(contentType);
if (!mediaType) return 'application/octet-stream';
if (ACTIVE_DOWNLOAD_MEDIA_TYPES.has(mediaType)) {
return 'application/octet-stream';
}
return contentType || mediaType;
}
+2
View File
@@ -100,7 +100,9 @@ export function applyCors(
headers.set('X-Frame-Options', 'DENY'); headers.set('X-Frame-Options', 'DENY');
headers.set('X-Content-Type-Options', 'nosniff'); headers.set('X-Content-Type-Options', 'nosniff');
headers.set('Referrer-Policy', 'strict-origin-when-cross-origin'); headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');
if (!headers.has('Content-Security-Policy')) {
headers.set('Content-Security-Policy', "frame-ancestors 'none'; img-src 'self' data:"); headers.set('Content-Security-Policy', "frame-ancestors 'none'; img-src 'self' data:");
}
return new Response(response.body, { return new Response(response.body, {
status: response.status, status: response.status,
statusText: response.statusText, statusText: response.statusText,
+16 -7
View File
@@ -70,17 +70,22 @@ function normalizeToken(token: string): string {
return token.replace(/\s+/g, ''); return token.replace(/\s+/g, '');
} }
export async function verifyTotpToken(secretRaw: string, tokenRaw: string, nowMs: number = Date.now()): Promise<boolean> { export async function findMatchingTotpCounter(
secretRaw: string,
tokenRaw: string,
nowMs: number = Date.now()
): Promise<number | null> {
const token = normalizeToken(tokenRaw); const token = normalizeToken(tokenRaw);
if (!/^\d{6}$/.test(token)) return false; if (!/^\d{6}$/.test(token)) return null;
const secret = base32Decode(secretRaw); const secret = base32Decode(secretRaw);
if (!secret) return false; if (!secret) return null;
const currentCounter = Math.floor(nowMs / 1000 / TOTP_STEP_SECONDS); const currentCounter = Math.floor(nowMs / 1000 / TOTP_STEP_SECONDS);
let matched = false; let matchedCounter: number | null = null;
for (let delta = -TOTP_WINDOW; delta <= TOTP_WINDOW; delta++) { for (let delta = -TOTP_WINDOW; delta <= TOTP_WINDOW; delta++) {
const expected = await hotp(secret, currentCounter + delta); const candidateCounter = currentCounter + delta;
const expected = await hotp(secret, candidateCounter);
// Constant-time comparison: always check all windows, never short-circuit. // Constant-time comparison: always check all windows, never short-circuit.
const a = new TextEncoder().encode(expected); const a = new TextEncoder().encode(expected);
const b = new TextEncoder().encode(token); const b = new TextEncoder().encode(token);
@@ -88,9 +93,13 @@ export async function verifyTotpToken(secretRaw: string, tokenRaw: string, nowMs
for (let i = 0; i < a.length && i < b.length; i++) { for (let i = 0; i < a.length && i < b.length; i++) {
diff |= a[i] ^ b[i]; diff |= a[i] ^ b[i];
} }
if (diff === 0) matched = true; if (diff === 0 && matchedCounter == null) matchedCounter = candidateCounter;
} }
return matched; return matchedCounter;
}
export async function verifyTotpToken(secretRaw: string, tokenRaw: string, nowMs: number = Date.now()): Promise<boolean> {
return (await findMatchingTotpCounter(secretRaw, tokenRaw, nowMs)) != null;
} }
export function isTotpEnabled(secretRaw: string | undefined | null): boolean { export function isTotpEnabled(secretRaw: string | undefined | null): boolean {
+89
View File
@@ -0,0 +1,89 @@
import type { Env } from '../types';
import { base64UrlToBytes, bytesToBase64Url } from './passkey';
const USER_VERIFICATION_TOKEN_TYPE = 'nodewarden.user-verification.v1';
const USER_VERIFICATION_TOKEN_TTL_MS = 5 * 60 * 1000;
export type UserVerificationPurpose = 'backup.settings.repair';
interface UserVerificationTokenPayload {
typ: typeof USER_VERIFICATION_TOKEN_TYPE;
userId: string;
method: 'passkey';
purpose: UserVerificationPurpose;
iat: number;
exp: number;
}
function textBytes(value: string): Uint8Array {
return new TextEncoder().encode(value);
}
async function importHmacKey(secret: string): Promise<CryptoKey> {
return crypto.subtle.importKey('raw', textBytes(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign', 'verify']);
}
async function hmacSha256(secret: string, data: string): Promise<Uint8Array> {
const key = await importHmacKey(secret);
return new Uint8Array(await crypto.subtle.sign('HMAC', key, textBytes(data)));
}
function encodeJson(value: unknown): string {
return bytesToBase64Url(textBytes(JSON.stringify(value)));
}
function decodeJson<T>(value: string): T | null {
try {
return JSON.parse(new TextDecoder().decode(base64UrlToBytes(value))) as T;
} catch {
return null;
}
}
export async function createPasskeyUserVerificationToken(
env: Env,
userId: string,
purpose: UserVerificationPurpose
): Promise<string> {
const now = Date.now();
const payload: UserVerificationTokenPayload = {
typ: USER_VERIFICATION_TOKEN_TYPE,
userId,
method: 'passkey',
purpose,
iat: now,
exp: now + USER_VERIFICATION_TOKEN_TTL_MS,
};
const header = { alg: 'HS256', typ: 'JWT' };
const data = `${encodeJson(header)}.${encodeJson(payload)}`;
const signature = bytesToBase64Url(await hmacSha256(env.JWT_SECRET, data));
return `${data}.${signature}`;
}
export async function verifyPasskeyUserVerificationToken(
env: Env,
token: string,
userId: string,
purpose: UserVerificationPurpose
): Promise<boolean> {
try {
const parts = String(token || '').split('.');
if (parts.length !== 3) return false;
const data = `${parts[0]}.${parts[1]}`;
const expected = await hmacSha256(env.JWT_SECRET, data);
const actual = base64UrlToBytes(parts[2]);
if (actual.length !== expected.length) return false;
let diff = 0;
for (let i = 0; i < actual.length; i += 1) diff |= actual[i] ^ expected[i];
if (diff !== 0) return false;
const payload = decodeJson<UserVerificationTokenPayload>(parts[1]);
if (!payload || payload.typ !== USER_VERIFICATION_TOKEN_TYPE) return false;
if (payload.userId !== userId || payload.purpose !== purpose || payload.method !== 'passkey') return false;
if (!Number.isFinite(payload.exp) || payload.exp < Date.now()) return false;
return true;
} catch {
return false;
}
}
+101 -19
View File
@@ -11,6 +11,7 @@ import RecoverTwoFactorPage from '@/components/RecoverTwoFactorPage';
import JwtWarningPage from '@/components/JwtWarningPage'; import JwtWarningPage from '@/components/JwtWarningPage';
import { import {
createAuthedFetch, createAuthedFetch,
deriveLoginHash,
getAuthorizedDevices, getAuthorizedDevices,
clearProfileSnapshot, clearProfileSnapshot,
getCurrentDeviceIdentifier, getCurrentDeviceIdentifier,
@@ -237,6 +238,7 @@ export default function App() {
const [disableTotpPassword, setDisableTotpPassword] = useState(''); const [disableTotpPassword, setDisableTotpPassword] = useState('');
const [disableTotpSubmitting, setDisableTotpSubmitting] = useState(false); const [disableTotpSubmitting, setDisableTotpSubmitting] = useState(false);
const [authRequestDialogDismissedId, setAuthRequestDialogDismissedId] = useState<string | null>(null); const [authRequestDialogDismissedId, setAuthRequestDialogDismissedId] = useState<string | null>(null);
const [authRequestDialogSelectedId, setAuthRequestDialogSelectedId] = useState<string | null>(null);
const [authRequestSubmittingId, setAuthRequestSubmittingId] = useState<string | null>(null); const [authRequestSubmittingId, setAuthRequestSubmittingId] = useState<string | null>(null);
const [recoverValues, setRecoverValues] = useState({ email: '', password: '', recoveryCode: '' }); const [recoverValues, setRecoverValues] = useState({ email: '', password: '', recoveryCode: '' });
const [themePreference, setThemePreference] = useState<ThemePreference>(() => readThemePreference()); const [themePreference, setThemePreference] = useState<ThemePreference>(() => readThemePreference());
@@ -264,6 +266,11 @@ export default function App() {
const refreshAuthorizedDevicesRef = useRef<() => Promise<void>>(async () => {}); const refreshAuthorizedDevicesRef = useRef<() => Promise<void>>(async () => {});
const refreshPendingAuthRequestsRef = useRef<() => Promise<void>>(async () => {}); const refreshPendingAuthRequestsRef = useRef<() => Promise<void>>(async () => {});
const repairAttemptRef = useRef<string>(''); const repairAttemptRef = useRef<string>('');
const loginScopedBackupRepairAuthRef = useRef<{
accessToken: string;
masterPasswordHash?: string | null;
userVerificationToken?: string | null;
} | null>(null);
const uriChecksumRepairAttemptRef = useRef<string>(''); const uriChecksumRepairAttemptRef = useRef<string>('');
const pendingVaultCoreQueryRefreshRef = useRef<Promise<{ data?: VaultCoreSnapshot } | unknown> | null>(null); const pendingVaultCoreQueryRefreshRef = useRef<Promise<{ data?: VaultCoreSnapshot } | unknown> | null>(null);
const pendingVaultCoreRefreshRef = useRef<Promise<unknown> | null>(null); const pendingVaultCoreRefreshRef = useRef<Promise<unknown> | null>(null);
@@ -506,6 +513,14 @@ export default function App() {
}, [phase, session?.email, location, navigate]); }, [phase, session?.email, location, navigate]);
async function finalizeLogin(login: CompletedLogin) { async function finalizeLogin(login: CompletedLogin) {
loginScopedBackupRepairAuthRef.current =
login.session.accessToken && (login.freshMasterPasswordHash || login.freshUserVerificationToken)
? {
accessToken: login.session.accessToken,
masterPasswordHash: login.freshMasterPasswordHash || null,
userVerificationToken: login.freshUserVerificationToken || null,
}
: null;
setSession(login.session); setSession(login.session);
setProfile(login.profile); setProfile(login.profile);
setUnlockPreparing(false); setUnlockPreparing(false);
@@ -1085,6 +1100,15 @@ export default function App() {
enabled: !IS_DEMO_MODE && phase === 'app' && !!session?.accessToken && vaultInitialDecryptDone, enabled: !IS_DEMO_MODE && phase === 'app' && !!session?.accessToken && vaultInitialDecryptDone,
staleTime: 30_000, staleTime: 30_000,
}); });
async function deriveCurrentMasterPasswordHash(masterPassword: string): Promise<string> {
const email = String(profile?.email || session?.email || '').trim().toLowerCase();
if (!email) throw new Error(t('txt_profile_unavailable'));
const normalizedPassword = String(masterPassword || '');
if (!normalizedPassword) throw new Error(t('txt_master_password_is_required'));
const derived = await deriveLoginHash(email, normalizedPassword, defaultKdfIterations);
return derived.hash;
}
const pendingAuthRequestsQueryKey = useMemo(() => ['auth-requests-pending', vaultCacheKey || session?.email] as const, [vaultCacheKey, session?.email]); const pendingAuthRequestsQueryKey = useMemo(() => ['auth-requests-pending', vaultCacheKey || session?.email] as const, [vaultCacheKey, session?.email]);
const pendingAuthRequestsQuery = useQuery({ const pendingAuthRequestsQuery = useQuery({
queryKey: pendingAuthRequestsQueryKey, queryKey: pendingAuthRequestsQueryKey,
@@ -1096,7 +1120,20 @@ export default function App() {
}); });
const pendingAuthRequests = (pendingAuthRequestsQuery.data || []).filter(isPendingAuthRequest); const pendingAuthRequests = (pendingAuthRequestsQuery.data || []).filter(isPendingAuthRequest);
const latestPendingAuthRequest = pendingAuthRequests[0] || null; const latestPendingAuthRequest = pendingAuthRequests[0] || null;
const authRequestDialogOpen = !!latestPendingAuthRequest && latestPendingAuthRequest.id !== authRequestDialogDismissedId; const selectedPendingAuthRequest = authRequestDialogSelectedId
? pendingAuthRequests.find((request) => request.id === authRequestDialogSelectedId) || null
: null;
const authRequestDialogRequest = selectedPendingAuthRequest || (
latestPendingAuthRequest && latestPendingAuthRequest.id !== authRequestDialogDismissedId
? latestPendingAuthRequest
: null
);
const authRequestDialogOpen = !!authRequestDialogRequest;
async function beginApproveAuthRequest(authRequest: AuthRequest): Promise<void> {
setAuthRequestDialogSelectedId(authRequest.id);
setAuthRequestDialogDismissedId(null);
}
async function approveAuthRequest(authRequest: AuthRequest): Promise<void> { async function approveAuthRequest(authRequest: AuthRequest): Promise<void> {
if (!session) throw new Error(t('txt_vault_key_unavailable')); if (!session) throw new Error(t('txt_vault_key_unavailable'));
@@ -1110,6 +1147,7 @@ export default function App() {
requestApproved: true, requestApproved: true,
}); });
setAuthRequestDialogDismissedId(null); setAuthRequestDialogDismissedId(null);
setAuthRequestDialogSelectedId(null);
pushToast('success', t('txt_auth_request_approved')); pushToast('success', t('txt_auth_request_approved'));
await pendingAuthRequestsQuery.refetch(); await pendingAuthRequestsQuery.refetch();
} finally { } finally {
@@ -1125,6 +1163,7 @@ export default function App() {
requestApproved: false, requestApproved: false,
}); });
setAuthRequestDialogDismissedId(null); setAuthRequestDialogDismissedId(null);
setAuthRequestDialogSelectedId(null);
pushToast('success', t('txt_auth_request_denied')); pushToast('success', t('txt_auth_request_denied'));
await pendingAuthRequestsQuery.refetch(); await pendingAuthRequestsQuery.refetch();
} finally { } finally {
@@ -1189,13 +1228,25 @@ export default function App() {
if (!isAdminProfile(profile)) return; if (!isAdminProfile(profile)) return;
if (repairAttemptRef.current === session.accessToken) return; if (repairAttemptRef.current === session.accessToken) return;
const loginScopedRepairAuth = loginScopedBackupRepairAuthRef.current?.accessToken === session.accessToken
? loginScopedBackupRepairAuthRef.current
: null;
repairAttemptRef.current = session.accessToken; repairAttemptRef.current = session.accessToken;
void silentlyRepairBackupSettingsIfNeeded(session, profile); void (async () => {
try {
await silentlyRepairBackupSettingsIfNeeded(session, profile, loginScopedRepairAuth);
} finally {
if (loginScopedBackupRepairAuthRef.current?.accessToken === session.accessToken) {
loginScopedBackupRepairAuthRef.current = null;
}
}
})();
}, [phase, session?.accessToken, session?.symEncKey, session?.symMacKey, profile, vaultInitialDecryptDone]); }, [phase, session?.accessToken, session?.symEncKey, session?.symMacKey, profile, vaultInitialDecryptDone]);
useEffect(() => { useEffect(() => {
if (session?.accessToken) return; if (session?.accessToken) return;
repairAttemptRef.current = ''; repairAttemptRef.current = '';
loginScopedBackupRepairAuthRef.current = null;
uriChecksumRepairAttemptRef.current = ''; uriChecksumRepairAttemptRef.current = '';
}, [session?.accessToken]); }, [session?.accessToken]);
@@ -1950,8 +2001,8 @@ export default function App() {
sendUploadPercent: vaultSendActions.sendUploadPercent, sendUploadPercent: vaultSendActions.sendUploadPercent,
onChangePassword: accountSecurityActions.changePassword, onChangePassword: accountSecurityActions.changePassword,
onSavePasswordHint: accountSecurityActions.savePasswordHint, onSavePasswordHint: accountSecurityActions.savePasswordHint,
onEnableTotp: async (secret: string, token: string) => { onEnableTotp: async (secret: string, token: string, masterPassword: string) => {
await accountSecurityActions.enableTotp(secret, token); await accountSecurityActions.enableTotp(secret, token, masterPassword);
await totpStatusQuery.refetch(); await totpStatusQuery.refetch();
}, },
onOpenDisableTotp: () => setDisableTotpOpen(true), onOpenDisableTotp: () => setDisableTotpOpen(true),
@@ -1967,7 +2018,7 @@ export default function App() {
onRefreshPendingAuthRequests: async () => { onRefreshPendingAuthRequests: async () => {
await pendingAuthRequestsQuery.refetch(); await pendingAuthRequestsQuery.refetch();
}, },
onApproveAuthRequest: approveAuthRequest, onApproveAuthRequest: beginApproveAuthRequest,
onDenyAuthRequest: denyAuthRequest, onDenyAuthRequest: denyAuthRequest,
onLockTimeoutChange: setLockTimeoutMinutes, onLockTimeoutChange: setLockTimeoutMinutes,
onSessionTimeoutActionChange: setSessionTimeoutAction, onSessionTimeoutActionChange: setSessionTimeoutAction,
@@ -1992,22 +2043,50 @@ export default function App() {
onLoadAuditLogSettings: () => getAuditLogSettings(authedFetch), onLoadAuditLogSettings: () => getAuditLogSettings(authedFetch),
onSaveAuditLogSettings: (settings: AuditLogSettings) => saveAuditLogSettings(authedFetch, settings), onSaveAuditLogSettings: (settings: AuditLogSettings) => saveAuditLogSettings(authedFetch, settings),
onClearAuditLogs: () => clearAuditLogs(authedFetch), onClearAuditLogs: () => clearAuditLogs(authedFetch),
onExportBackup: backupActions.exportBackup, onExportBackup: async (masterPassword: string, includeAttachments?: boolean) => {
onImportBackup: backupActions.importBackup, const hash = await deriveCurrentMasterPasswordHash(masterPassword);
onImportBackupAllowingChecksumMismatch: backupActions.importBackupAllowingChecksumMismatch, return backupActions.exportBackup(hash, includeAttachments);
},
onImportBackup: async (masterPassword: string, file: File, replaceExisting?: boolean) => {
const hash = await deriveCurrentMasterPasswordHash(masterPassword);
return backupActions.importBackup(hash, file, replaceExisting);
},
onImportBackupAllowingChecksumMismatch: async (masterPassword: string, file: File, replaceExisting?: boolean) => {
const hash = await deriveCurrentMasterPasswordHash(masterPassword);
return backupActions.importBackupAllowingChecksumMismatch(hash, file, replaceExisting);
},
onLoadBackupSettings: () => queryClient.ensureQueryData({ onLoadBackupSettings: () => queryClient.ensureQueryData({
queryKey: ['admin-backup-settings', vaultCacheKey], queryKey: ['admin-backup-settings', vaultCacheKey],
queryFn: () => backupActions.loadSettings(), queryFn: () => backupActions.loadSettings(),
staleTime: 30_000, staleTime: 30_000,
}), }),
onSaveBackupSettings: backupActions.saveSettings, onSaveBackupSettings: async (masterPassword: string, settings: AdminBackupSettings) => {
onRunRemoteBackup: backupActions.runRemoteBackup, const hash = await deriveCurrentMasterPasswordHash(masterPassword);
const saved = await backupActions.saveSettings(hash, settings);
queryClient.setQueryData(['admin-backup-settings', vaultCacheKey], saved);
return saved;
},
onRunRemoteBackup: async (masterPassword: string, destinationId?: string | null) => {
const hash = await deriveCurrentMasterPasswordHash(masterPassword);
const result = await backupActions.runRemoteBackup(hash, destinationId);
queryClient.setQueryData(['admin-backup-settings', vaultCacheKey], result.settings);
return result;
},
onListRemoteBackups: backupActions.listRemoteBackups, onListRemoteBackups: backupActions.listRemoteBackups,
onDownloadRemoteBackup: backupActions.downloadRemoteBackup, onDownloadRemoteBackup: async (masterPassword: string, destinationId: string, path: string, onProgress?: (percent: number | null) => void) => {
const hash = await deriveCurrentMasterPasswordHash(masterPassword);
return backupActions.downloadRemoteBackup(hash, destinationId, path, onProgress);
},
onInspectRemoteBackup: backupActions.inspectRemoteBackup, onInspectRemoteBackup: backupActions.inspectRemoteBackup,
onDeleteRemoteBackup: backupActions.deleteRemoteBackup, onDeleteRemoteBackup: backupActions.deleteRemoteBackup,
onRestoreRemoteBackup: backupActions.restoreRemoteBackup, onRestoreRemoteBackup: async (masterPassword: string, destinationId: string, path: string, replaceExisting?: boolean) => {
onRestoreRemoteBackupAllowingChecksumMismatch: backupActions.restoreRemoteBackupAllowingChecksumMismatch, const hash = await deriveCurrentMasterPasswordHash(masterPassword);
return backupActions.restoreRemoteBackup(hash, destinationId, path, replaceExisting);
},
onRestoreRemoteBackupAllowingChecksumMismatch: async (masterPassword: string, destinationId: string, path: string, replaceExisting?: boolean) => {
const hash = await deriveCurrentMasterPasswordHash(masterPassword);
return backupActions.restoreRemoteBackupAllowingChecksumMismatch(hash, destinationId, path, replaceExisting);
},
}; };
const effectiveMainRoutesProps = IS_DEMO_MODE const effectiveMainRoutesProps = IS_DEMO_MODE
? createDemoMainRoutesProps(mainRoutesProps, pushToast, { ? createDemoMainRoutesProps(mainRoutesProps, pushToast, {
@@ -2215,21 +2294,24 @@ export default function App() {
/> />
<AuthRequestApprovalDialog <AuthRequestApprovalDialog
open={authRequestDialogOpen} open={authRequestDialogOpen}
authRequest={latestPendingAuthRequest} authRequest={authRequestDialogRequest}
submitting={!!authRequestSubmittingId} submitting={!!authRequestSubmittingId}
onApprove={() => { onApprove={() => {
if (!latestPendingAuthRequest) return; if (!authRequestDialogRequest) return;
void approveAuthRequest(latestPendingAuthRequest).catch((error) => { void approveAuthRequest(authRequestDialogRequest).catch((error) => {
pushToast('error', error instanceof Error ? error.message : t('txt_auth_request_update_failed')); pushToast('error', error instanceof Error ? error.message : t('txt_auth_request_update_failed'));
}); });
}} }}
onDeny={() => { onDeny={() => {
if (!latestPendingAuthRequest) return; if (!authRequestDialogRequest) return;
void denyAuthRequest(latestPendingAuthRequest).catch((error) => { void denyAuthRequest(authRequestDialogRequest).catch((error) => {
pushToast('error', error instanceof Error ? error.message : t('txt_auth_request_update_failed')); pushToast('error', error instanceof Error ? error.message : t('txt_auth_request_update_failed'));
}); });
}} }}
onClose={() => setAuthRequestDialogDismissedId(latestPendingAuthRequest?.id || null)} onClose={() => {
setAuthRequestDialogSelectedId(null);
setAuthRequestDialogDismissedId(authRequestDialogRequest?.id || null);
}}
/> />
</> </>
); );
+9 -9
View File
@@ -107,7 +107,7 @@ export interface AppMainRoutesProps {
sendUploadPercent: number | null; sendUploadPercent: number | null;
onChangePassword: (currentPassword: string, nextPassword: string, nextPassword2: string) => Promise<void>; onChangePassword: (currentPassword: string, nextPassword: string, nextPassword2: string) => Promise<void>;
onSavePasswordHint: (masterPasswordHint: string) => Promise<void>; onSavePasswordHint: (masterPasswordHint: string) => Promise<void>;
onEnableTotp: (secret: string, token: string) => Promise<void>; onEnableTotp: (secret: string, token: string, masterPassword: string) => Promise<void>;
onOpenDisableTotp: () => void; onOpenDisableTotp: () => void;
onGetRecoveryCode: (masterPassword: string) => Promise<string>; onGetRecoveryCode: (masterPassword: string) => Promise<string>;
onGetApiKey: (masterPassword: string) => Promise<string>; onGetApiKey: (masterPassword: string) => Promise<string>;
@@ -142,18 +142,18 @@ export interface AppMainRoutesProps {
onLoadAuditLogSettings: () => Promise<AuditLogSettings>; onLoadAuditLogSettings: () => Promise<AuditLogSettings>;
onSaveAuditLogSettings: (settings: AuditLogSettings) => Promise<AuditLogSettings>; onSaveAuditLogSettings: (settings: AuditLogSettings) => Promise<AuditLogSettings>;
onClearAuditLogs: () => Promise<number>; onClearAuditLogs: () => Promise<number>;
onExportBackup: (includeAttachments?: boolean) => Promise<void>; onExportBackup: (masterPassword: string, includeAttachments?: boolean) => Promise<void>;
onImportBackup: (file: File, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>; onImportBackup: (masterPassword: string, file: File, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
onImportBackupAllowingChecksumMismatch: (file: File, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>; onImportBackupAllowingChecksumMismatch: (masterPassword: string, file: File, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
onLoadBackupSettings: () => Promise<AdminBackupSettings>; onLoadBackupSettings: () => Promise<AdminBackupSettings>;
onSaveBackupSettings: (settings: AdminBackupSettings) => Promise<AdminBackupSettings>; onSaveBackupSettings: (masterPassword: string, settings: AdminBackupSettings) => Promise<AdminBackupSettings>;
onRunRemoteBackup: (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: (destinationId: string, path: string, onProgress?: (percent: number | null) => void) => Promise<void>; onDownloadRemoteBackup: (masterPassword: string, destinationId: string, path: string, onProgress?: (percent: number | null) => void) => Promise<void>;
onInspectRemoteBackup: (destinationId: string, path: string) => Promise<{ object: 'backup-remote-integrity'; destinationId: string; path: string; fileName: string; integrity: { hasChecksumPrefix: boolean; expectedPrefix: string | null; actualPrefix: string; matches: boolean } }>; onInspectRemoteBackup: (destinationId: string, path: string) => Promise<{ object: 'backup-remote-integrity'; destinationId: string; path: string; fileName: string; integrity: { hasChecksumPrefix: boolean; expectedPrefix: string | null; actualPrefix: string; matches: boolean } }>;
onDeleteRemoteBackup: (destinationId: string, path: string) => Promise<void>; onDeleteRemoteBackup: (destinationId: string, path: string) => Promise<void>;
onRestoreRemoteBackup: (destinationId: string, path: string, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>; onRestoreRemoteBackup: (masterPassword: string, destinationId: string, path: string, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
onRestoreRemoteBackupAllowingChecksumMismatch: (destinationId: string, path: string, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>; onRestoreRemoteBackupAllowingChecksumMismatch: (masterPassword: string, destinationId: string, path: string, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
} }
export default function AppMainRoutes(props: AppMainRoutesProps) { export default function AppMainRoutes(props: AppMainRoutesProps) {
+161 -17
View File
@@ -34,18 +34,18 @@ import { BackupOperationsSidebar } from './backup-center/BackupOperationsSidebar
interface BackupCenterPageProps { interface BackupCenterPageProps {
currentUserId: string | null; currentUserId: string | null;
onExport: (includeAttachments?: boolean) => Promise<void>; onExport: (masterPassword: string, includeAttachments?: boolean) => Promise<void>;
onImport: (file: File, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>; onImport: (masterPassword: string, file: File, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
onImportAllowingChecksumMismatch: (file: File, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>; onImportAllowingChecksumMismatch: (masterPassword: string, file: File, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
onLoadSettings: () => Promise<AdminBackupSettings>; onLoadSettings: () => Promise<AdminBackupSettings>;
onSaveSettings: (settings: AdminBackupSettings) => Promise<AdminBackupSettings>; onSaveSettings: (masterPassword: string, settings: AdminBackupSettings) => Promise<AdminBackupSettings>;
onRunRemoteBackup: (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: (destinationId: string, path: string, onProgress?: (percent: number | null) => void) => Promise<void>; onDownloadRemoteBackup: (masterPassword: string, destinationId: string, path: string, onProgress?: (percent: number | null) => void) => Promise<void>;
onInspectRemoteBackup: (destinationId: string, path: string) => Promise<{ object: 'backup-remote-integrity'; destinationId: string; path: string; fileName: string; integrity: BackupFileIntegrityCheckResult }>; onInspectRemoteBackup: (destinationId: string, path: string) => Promise<{ object: 'backup-remote-integrity'; destinationId: string; path: string; fileName: string; integrity: BackupFileIntegrityCheckResult }>;
onDeleteRemoteBackup: (destinationId: string, path: string) => Promise<void>; onDeleteRemoteBackup: (destinationId: string, path: string) => Promise<void>;
onRestoreRemoteBackup: (destinationId: string, path: string, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>; onRestoreRemoteBackup: (masterPassword: string, destinationId: string, path: string, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
onRestoreRemoteBackupAllowingChecksumMismatch: (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;
} }
@@ -53,6 +53,14 @@ type PendingRestoreIntegrity =
| { source: 'local'; fileName: string; result: BackupFileIntegrityCheckResult } | { source: 'local'; fileName: string; result: BackupFileIntegrityCheckResult }
| { source: 'remote'; fileName: string; path: string; result: BackupFileIntegrityCheckResult }; | { source: 'remote'; fileName: string; path: string; result: BackupFileIntegrityCheckResult };
type PendingBackupVerification =
| { action: 'export' }
| { action: 'saveSettings' }
| { action: 'import'; replaceExisting: boolean; allowChecksumMismatch: boolean; knownIntegrity?: BackupFileIntegrityCheckResult }
| { action: 'runRemoteBackup' }
| { action: 'downloadRemote'; path: string }
| { action: 'restoreRemote'; path: string; replaceExisting: boolean; allowChecksumMismatch: boolean; knownIntegrity?: BackupFileIntegrityCheckResult };
interface BackupProgressPhase { interface BackupProgressPhase {
titleKey: string; titleKey: string;
detailKey: string; detailKey: string;
@@ -193,6 +201,9 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
const [confirmIntegrityWarningOpen, setConfirmIntegrityWarningOpen] = useState(false); const [confirmIntegrityWarningOpen, setConfirmIntegrityWarningOpen] = useState(false);
const [confirmDeleteDestinationOpen, setConfirmDeleteDestinationOpen] = useState(false); const [confirmDeleteDestinationOpen, setConfirmDeleteDestinationOpen] = useState(false);
const [confirmRemoteDeleteOpen, setConfirmRemoteDeleteOpen] = useState(false); const [confirmRemoteDeleteOpen, setConfirmRemoteDeleteOpen] = useState(false);
const [pendingBackupVerification, setPendingBackupVerification] = useState<PendingBackupVerification | null>(null);
const [backupPasswordValue, setBackupPasswordValue] = useState('');
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('');
const [pendingRemoteDeletePath, setPendingRemoteDeletePath] = useState(''); const [pendingRemoteDeletePath, setPendingRemoteDeletePath] = useState('');
@@ -209,7 +220,7 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
const selectedDestination = getDestinationById(settings, selectedDestinationId); const selectedDestination = getDestinationById(settings, selectedDestinationId);
const savedSelectedDestination = getDestinationById(savedSettings, selectedDestinationId); const savedSelectedDestination = getDestinationById(savedSettings, selectedDestinationId);
const selectedDestinationIsSaved = !!savedSelectedDestination; const selectedDestinationIsSaved = !!savedSelectedDestination;
const disableWhileBusy = exporting || importing || savingSettings || runningRemoteBackup; const disableWhileBusy = exporting || importing || savingSettings || runningRemoteBackup || backupPasswordSubmitting;
const currentRemoteBrowserPath = savedSelectedDestination ? (remoteBrowserPathByDestination[savedSelectedDestination.id] || '') : ''; const currentRemoteBrowserPath = savedSelectedDestination ? (remoteBrowserPathByDestination[savedSelectedDestination.id] || '') : '';
const currentRemoteBrowserKey = savedSelectedDestination ? getRemoteBrowserCacheKey(savedSelectedDestination.id, currentRemoteBrowserPath) : ''; const currentRemoteBrowserKey = savedSelectedDestination ? getRemoteBrowserCacheKey(savedSelectedDestination.id, currentRemoteBrowserPath) : '';
const remoteBrowser = currentRemoteBrowserKey ? remoteBrowserCache[currentRemoteBrowserKey] || null : null; const remoteBrowser = currentRemoteBrowserKey ? remoteBrowserCache[currentRemoteBrowserKey] || null : null;
@@ -226,6 +237,18 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
const recommendedS3Providers = RECOMMENDED_PROVIDERS.filter((provider) => provider.protocol === 's3'); const recommendedS3Providers = RECOMMENDED_PROVIDERS.filter((provider) => provider.protocol === 's3');
const canRunSelectedDestination = !!selectedDestination && selectedDestinationIsSaved; const canRunSelectedDestination = !!selectedDestination && selectedDestinationIsSaved;
const canBrowseSelectedDestination = !!savedSelectedDestination; const canBrowseSelectedDestination = !!savedSelectedDestination;
const backupPasswordPromptTitle =
pendingBackupVerification?.action === 'export'
? t('txt_backup_export')
: pendingBackupVerification?.action === 'saveSettings'
? t('txt_backup_save_settings')
: pendingBackupVerification?.action === 'runRemoteBackup'
? t('txt_backup_run_manual')
: pendingBackupVerification?.action === 'downloadRemote'
? t('txt_backup_remote_download')
: pendingBackupVerification?.action === 'restoreRemote'
? t('txt_backup_import')
: t('txt_backup_import');
useEffect(() => { useEffect(() => {
let cancelled = false; let cancelled = false;
@@ -507,11 +530,17 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
} }
async function handleExport() { async function handleExport() {
if (exporting) return;
setPendingBackupVerification({ action: 'export' });
setBackupPasswordValue('');
}
async function executeExport(masterPassword: string) {
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(exportIncludeAttachments); await props.onExport(masterPassword, exportIncludeAttachments);
props.onNotify('success', t('txt_backup_export_success')); props.onNotify('success', t('txt_backup_export_success'));
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : t('txt_backup_export_failed'); const message = error instanceof Error ? error.message : t('txt_backup_export_failed');
@@ -527,6 +556,28 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
replaceExisting: boolean, replaceExisting: boolean,
allowChecksumMismatch: boolean = false, allowChecksumMismatch: boolean = false,
knownIntegrity?: BackupFileIntegrityCheckResult knownIntegrity?: BackupFileIntegrityCheckResult
) {
if (importing) return;
if (!selectedFile) {
const message = t('txt_backup_file_required');
setLocalError(message);
props.onNotify('error', message);
return;
}
setPendingBackupVerification({
action: 'import',
replaceExisting,
allowChecksumMismatch,
knownIntegrity,
});
setBackupPasswordValue('');
}
async function executeLocalRestore(
masterPassword: string,
replaceExisting: boolean,
allowChecksumMismatch: boolean = false,
knownIntegrity?: BackupFileIntegrityCheckResult
) { ) {
if (importing) return; if (importing) return;
if (!selectedFile) { if (!selectedFile) {
@@ -547,8 +598,8 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
delayMs: replaceExisting ? 480 : 1400, delayMs: replaceExisting ? 480 : 1400,
}); });
const result = allowChecksumMismatch const result = allowChecksumMismatch
? await props.onImportAllowingChecksumMismatch(selectedFile, replaceExisting) ? await props.onImportAllowingChecksumMismatch(masterPassword, selectedFile, replaceExisting)
: await props.onImport(selectedFile, replaceExisting); : await props.onImport(masterPassword, selectedFile, replaceExisting);
props.onNotify('success', `${buildIntegrityStatusMessage(integrity)} ${t('txt_backup_restore_success_relogin')}`); props.onNotify('success', `${buildIntegrityStatusMessage(integrity)} ${t('txt_backup_restore_success_relogin')}`);
const skippedMessage = buildSkippedImportMessage(result); const skippedMessage = buildSkippedImportMessage(result);
if (skippedMessage) props.onNotify('warning', skippedMessage); if (skippedMessage) props.onNotify('warning', skippedMessage);
@@ -573,12 +624,18 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
} }
async function handleSaveSettings() { async function handleSaveSettings() {
if (savingSettings) return;
setPendingBackupVerification({ action: 'saveSettings' });
setBackupPasswordValue('');
}
async function executeSaveSettings(masterPassword: string) {
const payload = buildSettingsPayloadForSelectedDestination(); const payload = buildSettingsPayloadForSelectedDestination();
const destinationIdToInvalidate = selectedDestinationId; const destinationIdToInvalidate = selectedDestinationId;
setSavingSettings(true); setSavingSettings(true);
setLocalError(''); setLocalError('');
try { try {
const saved = await props.onSaveSettings(payload); const saved = await props.onSaveSettings(masterPassword, payload);
const nextSelected = const nextSelected =
(selectedDestinationId && saved.destinations.some((destination) => destination.id === selectedDestinationId) && selectedDestinationId) (selectedDestinationId && saved.destinations.some((destination) => destination.id === selectedDestinationId) && selectedDestinationId)
|| getFirstVisibleDestinationId(saved) || getFirstVisibleDestinationId(saved)
@@ -613,6 +670,12 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
} }
async function handleRunRemoteBackup() { async function handleRunRemoteBackup() {
if (!selectedDestination || runningRemoteBackup) return;
setPendingBackupVerification({ action: 'runRemoteBackup' });
setBackupPasswordValue('');
}
async function executeRunRemoteBackup(masterPassword: string) {
if (!selectedDestination) return; if (!selectedDestination) return;
setRunningRemoteBackup(true); setRunningRemoteBackup(true);
setLocalError(''); setLocalError('');
@@ -621,7 +684,7 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
source: 'remote', source: 'remote',
includeAttachments: !!selectedDestination.includeAttachments, includeAttachments: !!selectedDestination.includeAttachments,
}); });
const result = await props.onRunRemoteBackup(selectedDestination.id); const result = await props.onRunRemoteBackup(masterPassword, selectedDestination.id);
setSavedSettings(result.settings); setSavedSettings(result.settings);
setSettings(result.settings); setSettings(result.settings);
setSelectedDestinationId(selectedDestination.id); setSelectedDestinationId(selectedDestination.id);
@@ -638,12 +701,17 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
} }
async function handleDownloadRemote(path: string) { async function handleDownloadRemote(path: string) {
setPendingBackupVerification({ action: 'downloadRemote', path });
setBackupPasswordValue('');
}
async function executeDownloadRemote(masterPassword: string, path: string) {
if (!savedSelectedDestination) return; if (!savedSelectedDestination) return;
setDownloadingRemotePath(path); setDownloadingRemotePath(path);
setDownloadingRemotePercent(null); setDownloadingRemotePercent(null);
setLocalError(''); setLocalError('');
try { try {
await props.onDownloadRemoteBackup(savedSelectedDestination.id, path, setDownloadingRemotePercent); await props.onDownloadRemoteBackup(masterPassword, savedSelectedDestination.id, path, setDownloadingRemotePercent);
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : t('txt_backup_remote_download_failed'); const message = error instanceof Error ? error.message : t('txt_backup_remote_download_failed');
setLocalError(message); setLocalError(message);
@@ -724,6 +792,25 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
replaceExisting: boolean, replaceExisting: boolean,
allowChecksumMismatch: boolean = false, allowChecksumMismatch: boolean = false,
knownIntegrity?: BackupFileIntegrityCheckResult knownIntegrity?: BackupFileIntegrityCheckResult
) {
if (restoringRemotePath) return;
if (!savedSelectedDestination) return;
setPendingBackupVerification({
action: 'restoreRemote',
path,
replaceExisting,
allowChecksumMismatch,
knownIntegrity,
});
setBackupPasswordValue('');
}
async function executeRemoteRestore(
masterPassword: string,
path: string,
replaceExisting: boolean,
allowChecksumMismatch: boolean = false,
knownIntegrity?: BackupFileIntegrityCheckResult
) { ) {
if (restoringRemotePath) return; if (restoringRemotePath) return;
if (!savedSelectedDestination) return; if (!savedSelectedDestination) return;
@@ -738,8 +825,8 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
delayMs: replaceExisting ? 480 : 1400, delayMs: replaceExisting ? 480 : 1400,
}); });
const result = allowChecksumMismatch const result = allowChecksumMismatch
? await props.onRestoreRemoteBackupAllowingChecksumMismatch(savedSelectedDestination.id, path, replaceExisting) ? await props.onRestoreRemoteBackupAllowingChecksumMismatch(masterPassword, savedSelectedDestination.id, path, replaceExisting)
: await props.onRestoreRemoteBackup(savedSelectedDestination.id, path, replaceExisting); : await props.onRestoreRemoteBackup(masterPassword, savedSelectedDestination.id, path, replaceExisting);
setConfirmRemoteReplaceOpen(false); setConfirmRemoteReplaceOpen(false);
setPendingRemoteRestorePath(''); setPendingRemoteRestorePath('');
props.onNotify('success', `${buildIntegrityStatusMessage(integrity.result, { remote: true })} ${t('txt_backup_restore_success_relogin')}`); props.onNotify('success', `${buildIntegrityStatusMessage(integrity.result, { remote: true })} ${t('txt_backup_restore_success_relogin')}`);
@@ -762,6 +849,36 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
} }
} }
async function submitBackupPasswordPrompt(): Promise<void> {
const request = pendingBackupVerification;
const masterPassword = backupPasswordValue;
if (!request || backupPasswordSubmitting) return;
if (!masterPassword.trim()) {
props.onNotify('error', t('txt_master_password_is_required'));
return;
}
setBackupPasswordSubmitting(true);
setPendingBackupVerification(null);
setBackupPasswordValue('');
try {
if (request.action === 'export') {
await executeExport(masterPassword);
} else if (request.action === 'saveSettings') {
await executeSaveSettings(masterPassword);
} else if (request.action === 'import') {
await executeLocalRestore(masterPassword, request.replaceExisting, request.allowChecksumMismatch, request.knownIntegrity);
} else if (request.action === 'runRemoteBackup') {
await executeRunRemoteBackup(masterPassword);
} else if (request.action === 'downloadRemote') {
await executeDownloadRemote(masterPassword, request.path);
} else if (request.action === 'restoreRemote') {
await executeRemoteRestore(masterPassword, request.path, request.replaceExisting, request.allowChecksumMismatch, request.knownIntegrity);
}
} finally {
setBackupPasswordSubmitting(false);
}
}
return ( return (
<div className="backup-grid"> <div className="backup-grid">
<input <input
@@ -893,6 +1010,33 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
</div> </div>
), document.body) : null} ), document.body) : null}
<ConfirmDialog
open={pendingBackupVerification !== null}
title={backupPasswordPromptTitle}
message={t('txt_enter_master_password_to_continue')}
confirmText={t('txt_continue')}
cancelText={t('txt_cancel')}
confirmDisabled={backupPasswordSubmitting || !backupPasswordValue.trim()}
cancelDisabled={backupPasswordSubmitting}
onConfirm={() => void submitBackupPasswordPrompt()}
onCancel={() => {
if (backupPasswordSubmitting) return;
setPendingBackupVerification(null);
setBackupPasswordValue('');
}}
>
<label className="field">
<span>{t('txt_master_password')}</span>
<input
className="input"
type="password"
autoComplete="current-password"
value={backupPasswordValue}
onInput={(event) => setBackupPasswordValue((event.currentTarget as HTMLInputElement).value)}
/>
</label>
</ConfirmDialog>
<ConfirmDialog <ConfirmDialog
open={confirmLocalRestoreOpen} open={confirmLocalRestoreOpen}
title={t('txt_backup_import')} title={t('txt_backup_import')}
+14 -8
View File
@@ -14,7 +14,7 @@ interface SettingsPageProps {
sessionTimeoutAction: 'lock' | 'logout'; sessionTimeoutAction: 'lock' | 'logout';
onChangePassword: (currentPassword: string, nextPassword: string, nextPassword2: string) => Promise<void>; onChangePassword: (currentPassword: string, nextPassword: string, nextPassword2: string) => Promise<void>;
onSavePasswordHint: (masterPasswordHint: string) => Promise<void>; onSavePasswordHint: (masterPasswordHint: string) => Promise<void>;
onEnableTotp: (secret: string, token: string) => Promise<void>; onEnableTotp: (secret: string, token: string, masterPassword: string) => Promise<void>;
onOpenDisableTotp: () => void; onOpenDisableTotp: () => void;
onGetRecoveryCode: (masterPassword: string) => Promise<string>; onGetRecoveryCode: (masterPassword: string) => Promise<string>;
onGetApiKey: (masterPassword: string) => Promise<string>; onGetApiKey: (masterPassword: string) => Promise<string>;
@@ -34,6 +34,7 @@ interface SettingsPageProps {
} }
type MasterPasswordPromptAction = type MasterPasswordPromptAction =
| 'enableTotp'
| 'recovery' | 'recovery'
| 'apiKey' | 'apiKey'
| 'rotateApiKey' | 'rotateApiKey'
@@ -141,12 +142,12 @@ export default function SettingsPage(props: SettingsPageProps) {
}, [props.profile.email, secret]); }, [props.profile.email, secret]);
async function enableTotp(): Promise<void> { async function enableTotp(): Promise<void> {
try { if (totpLocked) return;
await props.onEnableTotp(secret, token); if (!secret.trim() || !token.trim()) {
setTotpLocked(true); props.onNotify?.('error', t('txt_secret_and_code_are_required'));
} catch { return;
// Keep inputs editable after a failed attempt.
} }
openMasterPasswordPrompt('enableTotp');
} }
async function refreshAccountPasskeys(): Promise<void> { async function refreshAccountPasskeys(): Promise<void> {
@@ -178,7 +179,10 @@ export default function SettingsPage(props: SettingsPageProps) {
const masterPassword = masterPasswordPromptValue; const masterPassword = masterPasswordPromptValue;
setMasterPasswordPromptSubmitting(true); setMasterPasswordPromptSubmitting(true);
try { try {
if (masterPasswordPrompt === 'recovery') { if (masterPasswordPrompt === 'enableTotp') {
await props.onEnableTotp(secret, token, masterPassword);
setTotpLocked(true);
} else if (masterPasswordPrompt === 'recovery') {
const code = await props.onGetRecoveryCode(masterPassword); const code = await props.onGetRecoveryCode(masterPassword);
setRecoveryCode(code); setRecoveryCode(code);
props.onNotify?.('success', t('txt_recovery_code_loaded')); props.onNotify?.('success', t('txt_recovery_code_loaded'));
@@ -214,7 +218,9 @@ export default function SettingsPage(props: SettingsPageProps) {
} }
const masterPasswordPromptTitle = const masterPasswordPromptTitle =
masterPasswordPrompt === 'recovery' masterPasswordPrompt === 'enableTotp'
? t('txt_enable_totp')
: masterPasswordPrompt === 'recovery'
? t('txt_view_recovery_code') ? t('txt_view_recovery_code')
: masterPasswordPrompt === 'rotateApiKey' : masterPasswordPrompt === 'rotateApiKey'
? t('txt_rotate_api_key') ? t('txt_rotate_api_key')
+18 -2
View File
@@ -145,14 +145,30 @@ export default function useAccountSecurityActions(options: UseAccountSecurityAct
} }
}, },
async enableTotp(secret: string, token: string) { async enableTotp(secret: string, token: string, masterPassword: string) {
if (!profile) {
const error = new Error(t('txt_profile_unavailable'));
onNotify('error', error.message);
throw error;
}
if (!secret.trim() || !token.trim()) { if (!secret.trim() || !token.trim()) {
const error = new Error(t('txt_secret_and_code_are_required')); const error = new Error(t('txt_secret_and_code_are_required'));
onNotify('error', error.message); onNotify('error', error.message);
throw error; throw error;
} }
if (!masterPassword) {
const error = new Error(t('txt_master_password_is_required'));
onNotify('error', error.message);
throw error;
}
try { try {
await setTotp(authedFetch, { enabled: true, secret: secret.trim(), token: token.trim() }); const derived = await deriveLoginHash(profile.email, masterPassword, defaultKdfIterations);
await setTotp(authedFetch, {
enabled: true,
secret: secret.trim(),
token: token.trim(),
masterPasswordHash: derived.hash,
});
onNotify('success', t('txt_totp_enabled')); onNotify('success', t('txt_totp_enabled'));
} catch (error) { } catch (error) {
onNotify('error', error instanceof Error ? error.message : t('txt_enable_totp_failed')); onNotify('error', error instanceof Error ? error.message : t('txt_enable_totp_failed'));
+16 -15
View File
@@ -27,9 +27,10 @@ export default function useBackupActions(options: UseBackupActionsOptions) {
return useMemo( return useMemo(
() => ({ () => ({
async exportBackup(includeAttachments: boolean = false) { async exportBackup(masterPasswordHash: string, includeAttachments: boolean = false) {
const payload = await buildCompleteAdminBackupExport( const payload = await buildCompleteAdminBackupExport(
authedFetch, authedFetch,
masterPasswordHash,
includeAttachments, includeAttachments,
async (event: BackupExportClientProgressEvent) => { async (event: BackupExportClientProgressEvent) => {
dispatchBackupProgress(event); dispatchBackupProgress(event);
@@ -48,14 +49,14 @@ export default function useBackupActions(options: UseBackupActionsOptions) {
}); });
}, },
async importBackup(file: File, replaceExisting: boolean = false) { async importBackup(masterPasswordHash: string, file: File, replaceExisting: boolean = false) {
const result = await importAdminBackup(authedFetch, file, replaceExisting); const result = await importAdminBackup(authedFetch, masterPasswordHash, file, replaceExisting);
onImported?.(); onImported?.();
return result; return result;
}, },
async importBackupAllowingChecksumMismatch(file: File, replaceExisting: boolean = false) { async importBackupAllowingChecksumMismatch(masterPasswordHash: string, file: File, replaceExisting: boolean = false) {
const result = await importAdminBackup(authedFetch, file, replaceExisting, true); const result = await importAdminBackup(authedFetch, masterPasswordHash, file, replaceExisting, true);
onImported?.(); onImported?.();
return result; return result;
}, },
@@ -64,20 +65,20 @@ export default function useBackupActions(options: UseBackupActionsOptions) {
return getAdminBackupSettings(authedFetch); return getAdminBackupSettings(authedFetch);
}, },
async saveSettings(settings: Parameters<typeof saveAdminBackupSettings>[1]) { async saveSettings(masterPasswordHash: string, settings: Parameters<typeof saveAdminBackupSettings>[2]) {
return saveAdminBackupSettings(authedFetch, settings); return saveAdminBackupSettings(authedFetch, masterPasswordHash, settings);
}, },
async runRemoteBackup(destinationId?: string | null) { async runRemoteBackup(masterPasswordHash: string, destinationId?: string | null) {
return runAdminBackupNow(authedFetch, destinationId); return runAdminBackupNow(authedFetch, masterPasswordHash, destinationId);
}, },
async listRemoteBackups(destinationId: string, path: string) { async listRemoteBackups(destinationId: string, path: string) {
return listRemoteBackups(authedFetch, destinationId, path); return listRemoteBackups(authedFetch, destinationId, path);
}, },
async downloadRemoteBackup(destinationId: string, path: string, onProgress?: (percent: number | null) => void) { async downloadRemoteBackup(masterPasswordHash: string, destinationId: string, path: string, onProgress?: (percent: number | null) => void) {
const payload = await fetchRemoteBackupPayload(authedFetch, destinationId, path, onProgress); const payload = await fetchRemoteBackupPayload(authedFetch, masterPasswordHash, destinationId, path, onProgress);
downloadBytesAsFile(payload.bytes, payload.fileName, payload.mimeType); downloadBytesAsFile(payload.bytes, payload.fileName, payload.mimeType);
}, },
@@ -89,14 +90,14 @@ export default function useBackupActions(options: UseBackupActionsOptions) {
await deleteRemoteBackup(authedFetch, destinationId, path); await deleteRemoteBackup(authedFetch, destinationId, path);
}, },
async restoreRemoteBackup(destinationId: string, path: string, replaceExisting: boolean = false) { async restoreRemoteBackup(masterPasswordHash: string, destinationId: string, path: string, replaceExisting: boolean = false) {
const result = await restoreRemoteBackupRequest(authedFetch, destinationId, path, replaceExisting); const result = await restoreRemoteBackupRequest(authedFetch, masterPasswordHash, destinationId, path, replaceExisting);
onRestored?.(); onRestored?.();
return result; return result;
}, },
async restoreRemoteBackupAllowingChecksumMismatch(destinationId: string, path: string, replaceExisting: boolean = false) { async restoreRemoteBackupAllowingChecksumMismatch(masterPasswordHash: string, destinationId: string, path: string, replaceExisting: boolean = false) {
const result = await restoreRemoteBackupRequest(authedFetch, destinationId, path, replaceExisting, true); const result = await restoreRemoteBackupRequest(authedFetch, masterPasswordHash, destinationId, path, replaceExisting, true);
onRestored?.(); onRestored?.();
return result; return result;
}, },
+25 -10
View File
@@ -49,6 +49,11 @@ export interface BackupSettingsRepairStateResponse {
portable: BackupSettingsPortablePayload | null; portable: BackupSettingsPortablePayload | null;
} }
export interface BackupUserVerificationPayload {
masterPasswordHash?: string | null;
userVerificationToken?: string | null;
}
export interface AdminBackupRunResponse { export interface AdminBackupRunResponse {
object: 'backup-run'; object: 'backup-run';
result: { result: {
@@ -173,12 +178,13 @@ async function applyBackupFileIntegrityName(fileName: string, bytes: Uint8Array)
export async function exportAdminBackup( export async function exportAdminBackup(
authedFetch: AuthedFetch, authedFetch: AuthedFetch,
masterPasswordHash: string,
includeAttachments: boolean = false includeAttachments: boolean = false
): Promise<AdminBackupExportPayload> { ): Promise<AdminBackupExportPayload> {
const resp = await authedFetch('/api/admin/backup/export', { const resp = await authedFetch('/api/admin/backup/export', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ includeAttachments }), body: JSON.stringify({ includeAttachments, 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')));
@@ -201,10 +207,11 @@ export async function downloadAdminBackupAttachmentBlob(
export async function buildCompleteAdminBackupExport( export async function buildCompleteAdminBackupExport(
authedFetch: AuthedFetch, authedFetch: AuthedFetch,
masterPasswordHash: string,
includeAttachments: boolean = false, includeAttachments: boolean = false,
onProgress?: (event: BackupExportClientProgressEvent) => void | Promise<void> onProgress?: (event: BackupExportClientProgressEvent) => void | Promise<void>
): Promise<AdminBackupExportPayload> { ): Promise<AdminBackupExportPayload> {
const payload = await exportAdminBackup(authedFetch, includeAttachments); const payload = await exportAdminBackup(authedFetch, masterPasswordHash, includeAttachments);
if (!includeAttachments) { if (!includeAttachments) {
await onProgress?.({ await onProgress?.({
operation: 'backup-export', operation: 'backup-export',
@@ -278,12 +285,13 @@ export async function getAdminBackupSettings(authedFetch: AuthedFetch): Promise<
export async function saveAdminBackupSettings( export async function saveAdminBackupSettings(
authedFetch: AuthedFetch, authedFetch: AuthedFetch,
masterPasswordHash: string,
settings: AdminBackupSettings settings: AdminBackupSettings
): Promise<AdminBackupSettings> { ): Promise<AdminBackupSettings> {
const resp = await authedFetch('/api/admin/backup/settings', { const resp = await authedFetch('/api/admin/backup/settings', {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(settings), body: JSON.stringify({ ...settings, masterPasswordHash }),
}); });
if (!resp.ok) throw new Error(await parseErrorMessage(resp, t('txt_backup_settings_save_failed'))); if (!resp.ok) throw new Error(await parseErrorMessage(resp, t('txt_backup_settings_save_failed')));
const body = await parseJson<AdminBackupSettings>(resp); const body = await parseJson<AdminBackupSettings>(resp);
@@ -305,12 +313,13 @@ export async function getAdminBackupSettingsRepairState(
export async function repairAdminBackupSettings( export async function repairAdminBackupSettings(
authedFetch: AuthedFetch, authedFetch: AuthedFetch,
verification: BackupUserVerificationPayload,
settings: AdminBackupSettings settings: AdminBackupSettings
): Promise<AdminBackupSettings> { ): Promise<AdminBackupSettings> {
const resp = await authedFetch('/api/admin/backup/settings/repair', { const resp = await authedFetch('/api/admin/backup/settings/repair', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(settings), body: JSON.stringify({ ...settings, ...verification }),
}); });
if (!resp.ok) throw new Error(await parseErrorMessage(resp, t('txt_backup_settings_save_failed'))); if (!resp.ok) throw new Error(await parseErrorMessage(resp, t('txt_backup_settings_save_failed')));
const body = await parseJson<AdminBackupSettings>(resp); const body = await parseJson<AdminBackupSettings>(resp);
@@ -320,12 +329,13 @@ export async function repairAdminBackupSettings(
export async function runAdminBackupNow( export async function runAdminBackupNow(
authedFetch: AuthedFetch, authedFetch: AuthedFetch,
masterPasswordHash: string,
destinationId?: string | null destinationId?: string | null
): Promise<AdminBackupRunResponse> { ): Promise<AdminBackupRunResponse> {
const resp = await authedFetch('/api/admin/backup/run', { const resp = await authedFetch('/api/admin/backup/run', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(destinationId ? { destinationId } : {}), body: JSON.stringify(destinationId ? { destinationId, masterPasswordHash } : { masterPasswordHash }),
}); });
if (!resp.ok) throw new Error(await parseErrorMessage(resp, t('txt_backup_remote_run_failed'))); if (!resp.ok) throw new Error(await parseErrorMessage(resp, t('txt_backup_remote_run_failed')));
const body = await parseJson<AdminBackupRunResponse>(resp); const body = await parseJson<AdminBackupRunResponse>(resp);
@@ -351,14 +361,16 @@ export async function listRemoteBackups(
export async function downloadRemoteBackup( export async function downloadRemoteBackup(
authedFetch: AuthedFetch, authedFetch: AuthedFetch,
masterPasswordHash: string,
destinationId: string, destinationId: string,
path: string, path: string,
onProgress?: (percent: number | null) => void onProgress?: (percent: number | null) => void
): Promise<AdminBackupExportPayload> { ): Promise<AdminBackupExportPayload> {
const params = new URLSearchParams(); const resp = await authedFetch('/api/admin/backup/remote/download', {
params.set('destinationId', destinationId); method: 'POST',
params.set('path', path); headers: { 'Content-Type': 'application/json' },
const resp = await authedFetch(`/api/admin/backup/remote/download?${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 mimeType = String(resp.headers.get('Content-Type') || 'application/zip').trim() || 'application/zip'; const mimeType = String(resp.headers.get('Content-Type') || 'application/zip').trim() || 'application/zip';
const fileName = parseContentDispositionFileName(resp, 'nodewarden_remote_backup.zip'); const fileName = parseContentDispositionFileName(resp, 'nodewarden_remote_backup.zip');
@@ -418,6 +430,7 @@ export async function inspectRemoteBackupIntegrity(
export async function restoreRemoteBackup( export async function restoreRemoteBackup(
authedFetch: AuthedFetch, authedFetch: AuthedFetch,
masterPasswordHash: string,
destinationId: string, destinationId: string,
path: string, path: string,
replaceExisting: boolean = false, replaceExisting: boolean = false,
@@ -426,7 +439,7 @@ export async function restoreRemoteBackup(
const resp = await authedFetch('/api/admin/backup/remote/restore', { const resp = await authedFetch('/api/admin/backup/remote/restore', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ destinationId, path, replaceExisting, allowChecksumMismatch }), body: JSON.stringify({ destinationId, path, replaceExisting, allowChecksumMismatch, masterPasswordHash }),
}); });
if (!resp.ok) throw new Error(await parseErrorMessage(resp, t('txt_backup_remote_restore_failed'))); if (!resp.ok) throw new Error(await parseErrorMessage(resp, t('txt_backup_remote_restore_failed')));
const body = await parseJson<AdminBackupImportResponse>(resp); const body = await parseJson<AdminBackupImportResponse>(resp);
@@ -436,12 +449,14 @@ export async function restoreRemoteBackup(
export async function importAdminBackup( export async function importAdminBackup(
authedFetch: AuthedFetch, authedFetch: AuthedFetch,
masterPasswordHash: string,
file: File, file: File,
replaceExisting: boolean = false, replaceExisting: boolean = false,
allowChecksumMismatch: boolean = false allowChecksumMismatch: boolean = false
): Promise<AdminBackupImportResponse> { ): Promise<AdminBackupImportResponse> {
const formData = new FormData(); const formData = new FormData();
formData.set('file', file, file.name || 'nodewarden_backup.zip'); formData.set('file', file, file.name || 'nodewarden_backup.zip');
formData.set('masterPasswordHash', masterPasswordHash);
if (replaceExisting) { if (replaceExisting) {
formData.set('replaceExisting', '1'); formData.set('replaceExisting', '1');
} }
+20 -7
View File
@@ -66,6 +66,12 @@ export interface CompletedLogin {
session: SessionState; session: SessionState;
profile: Profile; profile: Profile;
profilePromise: Promise<Profile>; profilePromise: Promise<Profile>;
freshMasterPasswordHash?: string | null;
freshUserVerificationToken?: string | null;
}
function readTokenUserVerificationToken(token: TokenSuccess): string | null {
return String(token.UserVerificationToken || token.userVerificationToken || '').trim() || null;
} }
export type PasswordLoginResult = export type PasswordLoginResult =
@@ -319,7 +325,8 @@ export async function completeLogin(
token: TokenSuccess, token: TokenSuccess,
email: string, email: string,
masterKey: Uint8Array, masterKey: Uint8Array,
fallbackKdfIterations: number fallbackKdfIterations: number,
freshMasterPasswordHash?: string | null
): Promise<CompletedLogin> { ): Promise<CompletedLogin> {
const normalizedEmail = email.trim().toLowerCase(); const normalizedEmail = email.trim().toLowerCase();
const fallbackProfile = loadProfileSnapshot(normalizedEmail); const fallbackProfile = loadProfileSnapshot(normalizedEmail);
@@ -348,6 +355,8 @@ export async function completeLogin(
session: { ...baseSession, ...keys }, session: { ...baseSession, ...keys },
profile, profile,
profilePromise: getProfile(tempFetch), profilePromise: getProfile(tempFetch),
freshMasterPasswordHash: freshMasterPasswordHash || null,
freshUserVerificationToken: readTokenUserVerificationToken(token),
}; };
} }
@@ -360,7 +369,8 @@ async function completeLoginWithVaultKeys(
token: TokenSuccess, token: TokenSuccess,
email: string, email: string,
keys: { symEncKey: string; symMacKey: string }, keys: { symEncKey: string; symMacKey: string },
fallbackKdfIterations: number fallbackKdfIterations: number,
freshMasterPasswordHash?: string | null
): Promise<CompletedLogin> { ): Promise<CompletedLogin> {
const normalizedEmail = email.trim().toLowerCase(); const normalizedEmail = email.trim().toLowerCase();
const fallbackProfile = loadProfileSnapshot(normalizedEmail); const fallbackProfile = loadProfileSnapshot(normalizedEmail);
@@ -385,6 +395,8 @@ async function completeLoginWithVaultKeys(
session: { ...baseSession, ...keys }, session: { ...baseSession, ...keys },
profile, profile,
profilePromise: getProfile(tempFetch), profilePromise: getProfile(tempFetch),
freshMasterPasswordHash: freshMasterPasswordHash || null,
freshUserVerificationToken: readTokenUserVerificationToken(token),
}; };
} }
@@ -400,7 +412,7 @@ export async function performPasswordLogin(
if ('access_token' in token && token.access_token) { if ('access_token' in token && token.access_token) {
return { return {
kind: 'success', kind: 'success',
login: await completeLogin(token, normalizedEmail, derived.masterKey, derived.kdfIterations), login: await completeLogin(token, normalizedEmail, derived.masterKey, derived.kdfIterations, derived.hash),
}; };
} }
@@ -476,7 +488,7 @@ export async function completePasskeyPasswordLogin(
password: string password: string
): Promise<CompletedLogin> { ): Promise<CompletedLogin> {
const derived = await deriveLoginHashLocally(pending.email, password, pending.kdfIterations); const derived = await deriveLoginHashLocally(pending.email, password, pending.kdfIterations);
return completeLogin(pending.token, pending.email, derived.masterKey, pending.kdfIterations); return completeLogin(pending.token, pending.email, derived.masterKey, pending.kdfIterations, derived.hash);
} }
export async function performTotpLogin( export async function performTotpLogin(
@@ -489,7 +501,7 @@ export async function performTotpLogin(
rememberDevice, rememberDevice,
}); });
if ('access_token' in token && token.access_token) { if ('access_token' in token && token.access_token) {
return completeLogin(token, pendingTotp.email, pendingTotp.masterKey, pendingTotp.kdfIterations); return completeLogin(token, pendingTotp.email, pendingTotp.masterKey, pendingTotp.kdfIterations, pendingTotp.passwordHash);
} }
const tokenError = token as { error_description?: string; error?: string }; const tokenError = token as { error_description?: string; error?: string };
throw new Error(translateServerError(tokenError.error_description || tokenError.error, t('txt_totp_verify_failed'))); throw new Error(translateServerError(tokenError.error_description || tokenError.error, t('txt_totp_verify_failed')));
@@ -508,7 +520,7 @@ export async function performRecoverTwoFactorLogin(
if ('access_token' in token && token.access_token) { if ('access_token' in token && token.access_token) {
return { return {
login: await completeLogin(token, normalizedEmail, derived.masterKey, derived.kdfIterations), login: await completeLogin(token, normalizedEmail, derived.masterKey, derived.kdfIterations, derived.hash),
newRecoveryCode: recovered.newRecoveryCode || null, newRecoveryCode: recovered.newRecoveryCode || null,
}; };
} }
@@ -557,6 +569,7 @@ export async function performUnlock(
session: offline.session, session: offline.session,
profile: offline.profile, profile: offline.profile,
profilePromise: Promise.resolve(offline.profile), profilePromise: Promise.resolve(offline.profile),
freshMasterPasswordHash: null,
}, },
}; };
} catch { } catch {
@@ -589,7 +602,7 @@ export async function performUnlock(
if ('access_token' in token && token.access_token) { if ('access_token' in token && token.access_token) {
return { return {
kind: 'success', kind: 'success',
login: await completeLogin(token, normalizedEmail, derived.masterKey, derived.kdfIterations), login: await completeLogin(token, normalizedEmail, derived.masterKey, derived.kdfIterations, derived.hash),
}; };
} }
+4 -2
View File
@@ -5,7 +5,8 @@ import type { Profile, SessionState } from './types';
export async function silentlyRepairBackupSettingsIfNeeded( export async function silentlyRepairBackupSettingsIfNeeded(
activeSession: SessionState, activeSession: SessionState,
activeProfile: Profile activeProfile: Profile,
verification?: { masterPasswordHash?: string | null; userVerificationToken?: string | null } | null
): Promise<void> { ): Promise<void> {
if (activeProfile.role !== 'admin') return; if (activeProfile.role !== 'admin') return;
if (!activeSession.accessToken || !activeSession.symEncKey || !activeSession.symMacKey) return; if (!activeSession.accessToken || !activeSession.symEncKey || !activeSession.symMacKey) return;
@@ -14,8 +15,9 @@ export async function silentlyRepairBackupSettingsIfNeeded(
try { try {
const state = await getAdminBackupSettingsRepairState(tempFetch); const state = await getAdminBackupSettingsRepairState(tempFetch);
if (!state.needsRepair || !state.portable) return; if (!state.needsRepair || !state.portable) return;
if (!verification?.masterPasswordHash && !verification?.userVerificationToken) return;
const repairedSettings = await decryptPortableBackupSettings(state.portable, activeProfile, activeSession); const repairedSettings = await decryptPortableBackupSettings(state.portable, activeProfile, activeSession);
await repairAdminBackupSettings(tempFetch, repairedSettings); await repairAdminBackupSettings(tempFetch, verification, repairedSettings);
} catch (error) { } catch (error) {
console.error('Backup settings auto-repair failed:', error); console.error('Backup settings auto-repair failed:', error);
} }
+8 -8
View File
@@ -1156,32 +1156,32 @@ export function createDemoMainRoutesProps(base: AppMainRoutesProps, notify: Noti
notify('success', t('txt_logs_cleared')); notify('success', t('txt_logs_cleared'));
return 0; return 0;
}, },
onExportBackup: async () => { onExportBackup: async (_masterPassword: string) => {
notify('success', t('txt_backup_export_success')); notify('success', t('txt_backup_export_success'));
}, },
onImportBackup: async () => { onImportBackup: async (_masterPassword: string, _file: File, _replaceExisting?: boolean) => {
resetDemoVaultState(state); resetDemoVaultState(state);
notify('success', t('txt_backup_import_success_relogin')); notify('success', t('txt_backup_import_success_relogin'));
return createDemoImportBackupResult(); return createDemoImportBackupResult();
}, },
onImportBackupAllowingChecksumMismatch: async () => { onImportBackupAllowingChecksumMismatch: async (_masterPassword: string, _file: File, _replaceExisting?: boolean) => {
resetDemoVaultState(state); resetDemoVaultState(state);
notify('success', t('txt_backup_import_success_relogin')); notify('success', t('txt_backup_import_success_relogin'));
return createDemoImportBackupResult(); return createDemoImportBackupResult();
}, },
onLoadBackupSettings: async () => state.backupSettings, onLoadBackupSettings: async () => state.backupSettings,
onSaveBackupSettings: async (settings) => { onSaveBackupSettings: async (_masterPassword: string, settings) => {
const next = cloneJson(settings); const next = cloneJson(settings);
state.setBackupSettings(next); state.setBackupSettings(next);
notify('success', t('txt_backup_settings_saved')); notify('success', t('txt_backup_settings_saved'));
return next; return next;
}, },
onRunRemoteBackup: async (destinationId?: string | null) => { onRunRemoteBackup: async (_masterPassword: string, destinationId?: string | null) => {
notify('success', t('txt_backup_remote_run_success')); notify('success', t('txt_backup_remote_run_success'));
return createDemoBackupRun(state.backupSettings, destinationId); return createDemoBackupRun(state.backupSettings, destinationId);
}, },
onListRemoteBackups: async (destinationId: string, path: string) => createDemoRemoteBrowser(destinationId, path), onListRemoteBackups: async (destinationId: string, path: string) => createDemoRemoteBrowser(destinationId, path),
onDownloadRemoteBackup: async () => { 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 (_destinationId: string, path: string) => ({
@@ -1199,13 +1199,13 @@ export function createDemoMainRoutesProps(base: AppMainRoutesProps, notify: Noti
onDeleteRemoteBackup: async () => { onDeleteRemoteBackup: async () => {
notify('success', t('txt_backup_remote_delete_success')); notify('success', t('txt_backup_remote_delete_success'));
}, },
onRestoreRemoteBackup: async (_destinationId, path) => { onRestoreRemoteBackup: async (_masterPassword: string, _destinationId, path) => {
await runDemoRemoteRestoreProgress(path.split('/').pop() || path || 'nodewarden_backup_demo.zip'); await runDemoRemoteRestoreProgress(path.split('/').pop() || path || 'nodewarden_backup_demo.zip');
resetDemoVaultState(state); resetDemoVaultState(state);
notify('success', t('txt_backup_remote_restore_completed_verified')); notify('success', t('txt_backup_remote_restore_completed_verified'));
return createDemoImportBackupResult(); return createDemoImportBackupResult();
}, },
onRestoreRemoteBackupAllowingChecksumMismatch: async (_destinationId, path) => { onRestoreRemoteBackupAllowingChecksumMismatch: async (_masterPassword: string, _destinationId, path) => {
await runDemoRemoteRestoreProgress(path.split('/').pop() || path || 'nodewarden_backup_demo.zip'); await runDemoRemoteRestoreProgress(path.split('/').pop() || path || 'nodewarden_backup_demo.zip');
resetDemoVaultState(state); resetDemoVaultState(state);
notify('success', t('txt_backup_remote_restore_completed_verified')); notify('success', t('txt_backup_remote_restore_completed_verified'));
+2
View File
@@ -314,6 +314,8 @@ export interface TokenSuccess {
ResetMasterPassword?: boolean; ResetMasterPassword?: boolean;
scope?: string; scope?: string;
unofficialServer?: boolean; unofficialServer?: boolean;
UserVerificationToken?: string;
userVerificationToken?: string;
UserDecryptionOptions?: unknown; UserDecryptionOptions?: unknown;
userDecryptionOptions?: unknown; userDecryptionOptions?: unknown;
VaultKeys?: { VaultKeys?: {