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
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 (
id TEXT PRIMARY KEY,
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",
"version": "1.7.0",
"version": "1.7.1",
"description": "Minimal Bitwarden-compatible server running on Cloudflare Workers",
"author": "shuaiplus",
"license": "LGPL-3.0",
@@ -42,6 +42,11 @@
}
}
},
"overrides": {
"undici": ">=7.28.0",
"@babel/core": ">=7.29.6",
"esbuild": ">=0.28.1"
},
"devDependencies": {
"@cloudflare/workers-types": "^4.20260131.0",
"@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);
}
const inviteMarked = await storage.markInviteUsed(inviteCode, user.id);
if (!inviteMarked) {
return errorResponse('Invite code is invalid or expired', 403);
}
try {
await storage.createUser(user);
} catch (error) {
await storage.revertInviteUsed(inviteCode, user.id);
const msg = error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase();
if (msg.includes('unique') || msg.includes('constraint')) {
return errorResponse('Email already registered', 409);
}
console.error('Registration failed after invite reservation:', error);
throw error;
}
const inviteMarked = await storage.markInviteUsed(inviteCode, user.id);
if (!inviteMarked) {
await storage.deleteUserById(user.id);
return errorResponse('Invite code is invalid or expired', 403);
try {
const assigned = await storage.assignInviteUsedBy(inviteCode, user.id);
if (!assigned) {
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, {
@@ -891,7 +902,7 @@ export async function handleDisableTwoFactorProvider(request: Request, env: Env,
}
// PUT /api/accounts/totp
// enable: { enabled: true, secret: "...", token: "123456" }
// enable: { enabled: true, secret: "...", token: "123456", masterPasswordHash?: "...", userVerificationToken?: "..." }
// disable: { enabled: false, masterPasswordHash: "..." }
export async function handleSetTotpStatus(request: Request, env: Env, userId: string): Promise<Response> {
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);
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 {
body = await request.json();
} catch {
@@ -908,12 +925,24 @@ export async function handleSetTotpStatus(request: Request, env: Env, userId: st
if (body.enabled === true) {
const normalizedSecret = normalizeTotpSecret(body.secret || '');
const masterPasswordHash = readBodyString(body, ['masterPasswordHash', 'MasterPasswordHash']);
const userVerificationToken = readBodyString(body, ['userVerificationToken', 'UserVerificationToken']);
if (!isTotpEnabled(normalizedSecret)) {
return errorResponse('Invalid TOTP secret', 400);
}
if (!body.token) {
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);
if (!verified) {
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 { buildDirectUploadUrl, getSafeJwtSecret, parseDirectUploadPayload } from '../utils/direct-upload';
import { generateUUID } from '../utils/uuid';
import { sanitizeDownloadContentType } from '../utils/content-type';
import {
createAttachmentUploadToken,
createFileDownloadToken,
@@ -449,7 +450,7 @@ export async function handlePublicDownloadAttachment(
return new Response(object.body, {
headers: {
'Content-Type': object.contentType || 'application/octet-stream',
'Content-Type': sanitizeDownloadContentType(object.contentType),
'Content-Length': String(object.size),
'Content-Disposition': contentDispositionAttachment(attachment.fileName),
'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);
}
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 {
return (
request.headers.get('CF-Connecting-IP') ||
@@ -251,6 +264,9 @@ export async function handleUpdateAuthRequest(request: Request, env: Env, userId
if (approved && !key) {
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, {
approved,
+80 -12
View File
@@ -40,15 +40,51 @@ import {
uploadBackupArchive,
} from '../services/backup-uploader';
import { StorageService } from '../services/storage';
import { AuthService } from '../services/auth';
import { auditRequestMetadata, writeAuditEvent } from '../services/audit-events';
import { getBlobObject } from '../services/blob-store';
import { notifyUserBackupProgress, notifyUserBackupRestoreProgress } from '../durable/notifications-hub';
import { verifyPasskeyUserVerificationToken } from '../utils/user-verification-token';
import { unzipSync } from 'fflate';
function isAdmin(user: User): boolean {
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(
storage: StorageService,
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> {
if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403);
let body: BackupSettingsInput;
let body: BackupSettingsInput & { masterPasswordHash?: string };
try {
body = await request.json<BackupSettingsInput>();
body = await request.json<BackupSettingsInput & { masterPasswordHash?: string }>();
} catch {
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);
let previous;
try {
@@ -837,13 +876,16 @@ export async function handleGetAdminBackupSettingsRepairState(request: Request,
export async function handleRepairAdminBackupSettings(request: Request, env: Env, actorUser: User): Promise<Response> {
if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403);
let body: BackupSettingsInput;
let body: BackupSettingsInput & { masterPasswordHash?: string; userVerificationToken?: string };
try {
body = await request.json<BackupSettingsInput>();
body = await request.json<BackupSettingsInput & { masterPasswordHash?: string; userVerificationToken?: string }>();
} catch {
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);
let previous;
try {
@@ -871,15 +913,18 @@ export async function handleRunAdminConfiguredBackup(request: Request, env: Env,
if (!isAdmin(actorUser)) return errorResponse('Forbidden', 403);
try {
let body: { destinationId?: string } | null = null;
let body: { destinationId?: string; masterPasswordHash?: string } | null = null;
try {
if ((request.headers.get('Content-Type') || '').includes('application/json')) {
body = await request.json<{ destinationId?: string }>();
body = await request.json<{ destinationId?: string; masterPasswordHash?: string }>();
}
} catch {
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, {
actorUserId: actorUser.id,
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> {
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);
try {
const settings = await loadBackupSettings(storage, env, 'UTC');
const url = new URL(request.url);
const path = ensureRemoteRestoreCandidate(url.searchParams.get('path') || '');
const destination = requireBackupDestination(settings, url.searchParams.get('destinationId') || null);
const path = ensureRemoteRestoreCandidate(String(body.path || ''));
const destination = requireBackupDestination(settings, body.destinationId || null);
const remoteFile = await downloadRemoteBackupFile(destination, path);
return new Response(remoteFile.bytes, {
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> {
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 {
body = await request.json<{ destinationId?: string; path?: string; replaceExisting?: boolean }>();
} catch {
return errorResponse('Remote restore payload is invalid', 400);
}
const verificationError = await requireBackupUserVerification(actorUser, String(body.masterPasswordHash || ''), env);
if (verificationError) return verificationError;
try {
const path = ensureRemoteRestoreCandidate(String(body.path || ''));
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 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 {
if ((request.headers.get('Content-Type') || '').includes('application/json')) {
body = await request.json<{ includeAttachments?: boolean }>();
body = await request.json<{ includeAttachments?: boolean; masterPasswordHash?: string }>();
}
} catch {
return errorResponse('Backup export payload is invalid', 400);
}
const verificationError = await requireBackupUserVerification(actorUser, String(body?.masterPasswordHash || ''), env);
if (verificationError) return verificationError;
let archive: BackupArchiveBundle;
try {
const progress = async (event: {
@@ -1140,6 +1205,9 @@ export async function handleAdminImportBackup(request: Request, env: Env, actorU
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 allowChecksumMismatch = String(formData.get('allowChecksumMismatch') || '').trim() === '1';
let archiveBytes: Uint8Array;
+15 -4
View File
@@ -4,7 +4,7 @@ import { AuthService } from '../services/auth';
import { RateLimitService, getClientIdentifier } from '../services/ratelimit';
import { jsonResponse, errorResponse, identityErrorResponse } from '../utils/response';
import { LIMITS } from '../config/limits';
import { isTotpEnabled, verifyTotpToken } from '../utils/totp';
import { findMatchingTotpCounter, isTotpEnabled } from '../utils/totp';
import { createRefreshToken } from '../utils/jwt';
import { readAuthRequestDeviceInfo } from '../utils/device';
import { createRecoveryCode, recoveryCodeEquals } from '../utils/recovery-code';
@@ -21,6 +21,7 @@ import {
buildAccountPasskeyTokenUserDecryptionOption,
} from './account-passkeys';
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_PROVIDER_AUTHENTICATOR = 0;
@@ -336,6 +337,7 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
}
let validatedAuthRequestId: string | null = null;
let authRequestLoginKey: string | null = null;
let valid = false;
const normalizedAuthRequestId = String(authRequestId || '').trim();
if (normalizedAuthRequestId) {
@@ -348,10 +350,12 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
authRequest.responseDate &&
!authRequest.authenticationDate &&
!isAuthRequestExpired(authRequest) &&
!!authRequest.key &&
constantTimeEquals(authRequest.accessCode, passwordHash)
);
if (valid) {
validatedAuthRequestId = authRequest!.id;
authRequestLoginKey = authRequest!.key;
}
} else {
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.');
}
} else if (normalizedTwoFactorProvider === String(TWO_FACTOR_PROVIDER_AUTHENTICATOR)) {
const totpOk = await verifyTotpToken(effectiveTotpSecret, normalizedTwoFactorToken);
if (!totpOk) {
const matchedCounter = await findMatchingTotpCounter(effectiveTotpSecret, normalizedTwoFactorToken);
if (matchedCounter == null) {
return recordFailedTwoFactorAndBuildResponse(rateLimit, loginIdentifier);
}
const consumed = await storage.consumeTotpLoginCounter(user.id, matchedCounter);
if (!consumed) {
return recordFailedTwoFactorAndBuildResponse(rateLimit, loginIdentifier);
}
} else if (
@@ -488,7 +496,7 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
token_type: 'Bearer',
...(shouldUseWebSession(request) ? { web_session: true } : { refresh_token: refreshToken }),
...(trustedTwoFactorTokenToReturn ? { TwoFactorToken: trustedTwoFactorTokenToReturn } : {}),
Key: user.key,
Key: authRequestLoginKey || user.key,
PrivateKey: user.privateKey,
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 refreshToken = await auth.generateRefreshToken(user.id, deviceSession);
const userVerificationToken = await createPasskeyUserVerificationToken(env, user.id, 'backup.settings.repair');
const accountKeys = buildAccountKeys(user);
const webAuthnPrfOption = buildAccountPasskeyTokenUserDecryptionOption(credential);
const userDecryptionOptions = buildUserDecryptionOptions(user, webAuthnPrfOption);
@@ -621,6 +630,8 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
ApiUseKeyConnector: false,
scope: 'api offline_access',
unofficialServer: true,
UserVerificationToken: userVerificationToken,
userVerificationToken,
UserDecryptionOptions: userDecryptionOptions,
userDecryptionOptions: userDecryptionOptions,
};
+2 -1
View File
@@ -2,6 +2,7 @@ import { Env, SendType } from '../types';
import { StorageService } from '../services/storage';
import { RateLimitService, getClientIdentifier } from '../services/ratelimit';
import { jsonResponse, errorResponse } from '../utils/response';
import { sanitizeDownloadContentType } from '../utils/content-type';
import { LIMITS } from '../config/limits';
import {
createSendAccessToken,
@@ -306,7 +307,7 @@ export async function handleDownloadSendFile(
return new Response(object.body, {
headers: {
'Content-Type': object.contentType || 'application/octet-stream',
'Content-Type': sanitizeDownloadContentType(object.contentType),
'Content-Length': String(object.size),
'Content-Disposition': contentDispositionAttachment(fileName),
'Cache-Control': 'private, no-cache',
+1 -1
View File
@@ -50,7 +50,7 @@ export async function handleAdminBackupRoute(
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);
}
+3 -1
View File
@@ -27,6 +27,7 @@ import {
handleNotificationsNegotiate,
} from './handlers/notifications';
import { handlePublicUploadSendFile } from './handlers/sends';
import { isSafeWebsiteIconContentType } from './utils/content-type';
import { jsonResponse } from './utils/response';
import { StorageService } from './services/storage';
import type { Env } from './types';
@@ -241,6 +242,7 @@ function iconResponse(body: BodyInit | null, contentType: string | null): Respon
headers: {
'Content-Type': contentType || 'image/png',
'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;
const contentType = String(resp.headers.get('Content-Type') || '').toLowerCase();
if (!contentType.startsWith('image/')) continue;
if (!isSafeWebsiteIconContentType(contentType)) continue;
const contentLength = getPositiveContentLength(resp.headers);
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> {
void userId;
const now = new Date().toISOString();
const result = await db
.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();
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, ' +
'FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE CASCADE, ' +
'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_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)',
'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 (' +
'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, ' +
+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,
createAuditLog as createStoredAuditLog,
clearAuditLogs as clearStoredAuditLogs,
assignInviteUsedBy as assignStoredInviteUsedBy,
createInvite as createStoredInvite,
deleteAllInvites as deleteStoredInvites,
getInvite as findStoredInvite,
@@ -30,6 +31,7 @@ import {
markInviteUsed as markStoredInviteUsed,
pruneAuditLogs as pruneStoredAuditLogs,
pruneAuditLogsToMax as pruneStoredAuditLogsToMax,
revertInviteUsed as revertStoredInviteUsed,
revokeInvite as revokeStoredInvite,
} from './storage-admin-repo';
import {
@@ -121,6 +123,9 @@ import {
ensureUsedAttachmentDownloadTokenTable as ensureStoredAttachmentTokenTable,
consumeAttachmentDownloadToken as consumeStoredAttachmentDownloadToken,
} from './storage-attachment-token-repo';
import {
consumeTotpLoginCounter as consumeStoredTotpLoginCounter,
} from './storage-totp-replay-repo';
import {
getRevisionDate as getStoredRevisionDate,
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
// changes. Existing D1 installs only rerun ensureStorageSchema() when this value
// differs from config.schema.version.
const STORAGE_SCHEMA_VERSION = '2026-06-22-push-notifications';
const REQUIRED_SCHEMA_TABLES = ['webauthn_credentials', 'webauthn_challenges', 'auth_requests'] as const;
const STORAGE_SCHEMA_VERSION = '2026-06-23-totp-login-replay';
const REQUIRED_SCHEMA_TABLES = ['webauthn_credentials', 'webauthn_challenges', 'auth_requests', 'totp_login_replays'] as const;
// D1-backed storage.
// Contract:
@@ -162,10 +167,13 @@ export class StorageService {
private static schemaVerified = false;
private static lastRefreshTokenCleanupAt = 0;
private static lastAttachmentTokenCleanupAt = 0;
private static lastTotpReplayCleanupAt = 0;
private static readonly MAX_D1_SQL_VARIABLES = 100;
private static readonly REFRESH_TOKEN_CLEANUP_INTERVAL_MS = LIMITS.cleanup.refreshTokenCleanupIntervalMs;
private static readonly ATTACHMENT_TOKEN_CLEANUP_INTERVAL_MS = LIMITS.cleanup.attachmentTokenCleanupIntervalMs;
private static readonly 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;
constructor(private db: D1Database) {}
@@ -313,6 +321,14 @@ export class StorageService {
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> {
return revokeStoredInvite(this.db, code);
}
@@ -823,6 +839,24 @@ export class StorageService {
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 ---
async getRevisionDate(userId: string): Promise<string> {
+2
View File
@@ -466,6 +466,8 @@ export interface TokenResponse {
ResetMasterPassword: boolean;
scope: string;
unofficialServer: boolean;
UserVerificationToken?: string;
userVerificationToken?: string;
MasterPasswordPolicy?: {
minComplexity: 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-Content-Type-Options', 'nosniff');
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:");
}
return new Response(response.body, {
status: response.status,
statusText: response.statusText,
+16 -7
View File
@@ -70,17 +70,22 @@ function normalizeToken(token: string): string {
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);
if (!/^\d{6}$/.test(token)) return false;
if (!/^\d{6}$/.test(token)) return null;
const secret = base32Decode(secretRaw);
if (!secret) return false;
if (!secret) return null;
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++) {
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.
const a = new TextEncoder().encode(expected);
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++) {
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 {
+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 {
createAuthedFetch,
deriveLoginHash,
getAuthorizedDevices,
clearProfileSnapshot,
getCurrentDeviceIdentifier,
@@ -237,6 +238,7 @@ export default function App() {
const [disableTotpPassword, setDisableTotpPassword] = useState('');
const [disableTotpSubmitting, setDisableTotpSubmitting] = useState(false);
const [authRequestDialogDismissedId, setAuthRequestDialogDismissedId] = useState<string | null>(null);
const [authRequestDialogSelectedId, setAuthRequestDialogSelectedId] = useState<string | null>(null);
const [authRequestSubmittingId, setAuthRequestSubmittingId] = useState<string | null>(null);
const [recoverValues, setRecoverValues] = useState({ email: '', password: '', recoveryCode: '' });
const [themePreference, setThemePreference] = useState<ThemePreference>(() => readThemePreference());
@@ -264,6 +266,11 @@ export default function App() {
const refreshAuthorizedDevicesRef = useRef<() => Promise<void>>(async () => {});
const refreshPendingAuthRequestsRef = useRef<() => Promise<void>>(async () => {});
const repairAttemptRef = useRef<string>('');
const loginScopedBackupRepairAuthRef = useRef<{
accessToken: string;
masterPasswordHash?: string | null;
userVerificationToken?: string | null;
} | null>(null);
const uriChecksumRepairAttemptRef = useRef<string>('');
const pendingVaultCoreQueryRefreshRef = useRef<Promise<{ data?: VaultCoreSnapshot } | unknown> | null>(null);
const pendingVaultCoreRefreshRef = useRef<Promise<unknown> | null>(null);
@@ -506,6 +513,14 @@ export default function App() {
}, [phase, session?.email, location, navigate]);
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);
setProfile(login.profile);
setUnlockPreparing(false);
@@ -1085,6 +1100,15 @@ export default function App() {
enabled: !IS_DEMO_MODE && phase === 'app' && !!session?.accessToken && vaultInitialDecryptDone,
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 pendingAuthRequestsQuery = useQuery({
queryKey: pendingAuthRequestsQueryKey,
@@ -1096,7 +1120,20 @@ export default function App() {
});
const pendingAuthRequests = (pendingAuthRequestsQuery.data || []).filter(isPendingAuthRequest);
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> {
if (!session) throw new Error(t('txt_vault_key_unavailable'));
@@ -1110,6 +1147,7 @@ export default function App() {
requestApproved: true,
});
setAuthRequestDialogDismissedId(null);
setAuthRequestDialogSelectedId(null);
pushToast('success', t('txt_auth_request_approved'));
await pendingAuthRequestsQuery.refetch();
} finally {
@@ -1125,6 +1163,7 @@ export default function App() {
requestApproved: false,
});
setAuthRequestDialogDismissedId(null);
setAuthRequestDialogSelectedId(null);
pushToast('success', t('txt_auth_request_denied'));
await pendingAuthRequestsQuery.refetch();
} finally {
@@ -1189,13 +1228,25 @@ export default function App() {
if (!isAdminProfile(profile)) return;
if (repairAttemptRef.current === session.accessToken) return;
const loginScopedRepairAuth = loginScopedBackupRepairAuthRef.current?.accessToken === session.accessToken
? loginScopedBackupRepairAuthRef.current
: null;
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]);
useEffect(() => {
if (session?.accessToken) return;
repairAttemptRef.current = '';
loginScopedBackupRepairAuthRef.current = null;
uriChecksumRepairAttemptRef.current = '';
}, [session?.accessToken]);
@@ -1950,8 +2001,8 @@ export default function App() {
sendUploadPercent: vaultSendActions.sendUploadPercent,
onChangePassword: accountSecurityActions.changePassword,
onSavePasswordHint: accountSecurityActions.savePasswordHint,
onEnableTotp: async (secret: string, token: string) => {
await accountSecurityActions.enableTotp(secret, token);
onEnableTotp: async (secret: string, token: string, masterPassword: string) => {
await accountSecurityActions.enableTotp(secret, token, masterPassword);
await totpStatusQuery.refetch();
},
onOpenDisableTotp: () => setDisableTotpOpen(true),
@@ -1967,7 +2018,7 @@ export default function App() {
onRefreshPendingAuthRequests: async () => {
await pendingAuthRequestsQuery.refetch();
},
onApproveAuthRequest: approveAuthRequest,
onApproveAuthRequest: beginApproveAuthRequest,
onDenyAuthRequest: denyAuthRequest,
onLockTimeoutChange: setLockTimeoutMinutes,
onSessionTimeoutActionChange: setSessionTimeoutAction,
@@ -1992,22 +2043,50 @@ export default function App() {
onLoadAuditLogSettings: () => getAuditLogSettings(authedFetch),
onSaveAuditLogSettings: (settings: AuditLogSettings) => saveAuditLogSettings(authedFetch, settings),
onClearAuditLogs: () => clearAuditLogs(authedFetch),
onExportBackup: backupActions.exportBackup,
onImportBackup: backupActions.importBackup,
onImportBackupAllowingChecksumMismatch: backupActions.importBackupAllowingChecksumMismatch,
onExportBackup: async (masterPassword: string, includeAttachments?: boolean) => {
const hash = await deriveCurrentMasterPasswordHash(masterPassword);
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({
queryKey: ['admin-backup-settings', vaultCacheKey],
queryFn: () => backupActions.loadSettings(),
staleTime: 30_000,
}),
onSaveBackupSettings: backupActions.saveSettings,
onRunRemoteBackup: backupActions.runRemoteBackup,
onSaveBackupSettings: async (masterPassword: string, settings: AdminBackupSettings) => {
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,
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,
onDeleteRemoteBackup: backupActions.deleteRemoteBackup,
onRestoreRemoteBackup: backupActions.restoreRemoteBackup,
onRestoreRemoteBackupAllowingChecksumMismatch: backupActions.restoreRemoteBackupAllowingChecksumMismatch,
onRestoreRemoteBackup: async (masterPassword: string, destinationId: string, path: string, replaceExisting?: boolean) => {
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
? createDemoMainRoutesProps(mainRoutesProps, pushToast, {
@@ -2215,21 +2294,24 @@ export default function App() {
/>
<AuthRequestApprovalDialog
open={authRequestDialogOpen}
authRequest={latestPendingAuthRequest}
authRequest={authRequestDialogRequest}
submitting={!!authRequestSubmittingId}
onApprove={() => {
if (!latestPendingAuthRequest) return;
void approveAuthRequest(latestPendingAuthRequest).catch((error) => {
if (!authRequestDialogRequest) return;
void approveAuthRequest(authRequestDialogRequest).catch((error) => {
pushToast('error', error instanceof Error ? error.message : t('txt_auth_request_update_failed'));
});
}}
onDeny={() => {
if (!latestPendingAuthRequest) return;
void denyAuthRequest(latestPendingAuthRequest).catch((error) => {
if (!authRequestDialogRequest) return;
void denyAuthRequest(authRequestDialogRequest).catch((error) => {
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;
onChangePassword: (currentPassword: string, nextPassword: string, nextPassword2: 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;
onGetRecoveryCode: (masterPassword: string) => Promise<string>;
onGetApiKey: (masterPassword: string) => Promise<string>;
@@ -142,18 +142,18 @@ export interface AppMainRoutesProps {
onLoadAuditLogSettings: () => Promise<AuditLogSettings>;
onSaveAuditLogSettings: (settings: AuditLogSettings) => Promise<AuditLogSettings>;
onClearAuditLogs: () => Promise<number>;
onExportBackup: (includeAttachments?: boolean) => Promise<void>;
onImportBackup: (file: File, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
onImportBackupAllowingChecksumMismatch: (file: File, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
onExportBackup: (masterPassword: string, includeAttachments?: boolean) => Promise<void>;
onImportBackup: (masterPassword: string, file: File, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
onImportBackupAllowingChecksumMismatch: (masterPassword: string, file: File, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
onLoadBackupSettings: () => Promise<AdminBackupSettings>;
onSaveBackupSettings: (settings: AdminBackupSettings) => Promise<AdminBackupSettings>;
onRunRemoteBackup: (destinationId?: string | null) => Promise<AdminBackupRunResponse>;
onSaveBackupSettings: (masterPassword: string, settings: AdminBackupSettings) => Promise<AdminBackupSettings>;
onRunRemoteBackup: (masterPassword: string, destinationId?: string | null) => Promise<AdminBackupRunResponse>;
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 } }>;
onDeleteRemoteBackup: (destinationId: string, path: string) => Promise<void>;
onRestoreRemoteBackup: (destinationId: string, path: string, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
onRestoreRemoteBackupAllowingChecksumMismatch: (destinationId: string, path: string, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
onRestoreRemoteBackup: (masterPassword: string, destinationId: string, path: string, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
onRestoreRemoteBackupAllowingChecksumMismatch: (masterPassword: string, destinationId: string, path: string, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
}
export default function AppMainRoutes(props: AppMainRoutesProps) {
+161 -17
View File
@@ -34,18 +34,18 @@ import { BackupOperationsSidebar } from './backup-center/BackupOperationsSidebar
interface BackupCenterPageProps {
currentUserId: string | null;
onExport: (includeAttachments?: boolean) => Promise<void>;
onImport: (file: File, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
onImportAllowingChecksumMismatch: (file: File, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
onExport: (masterPassword: string, includeAttachments?: boolean) => Promise<void>;
onImport: (masterPassword: string, file: File, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
onImportAllowingChecksumMismatch: (masterPassword: string, file: File, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
onLoadSettings: () => Promise<AdminBackupSettings>;
onSaveSettings: (settings: AdminBackupSettings) => Promise<AdminBackupSettings>;
onRunRemoteBackup: (destinationId?: string | null) => Promise<AdminBackupRunResponse>;
onSaveSettings: (masterPassword: string, settings: AdminBackupSettings) => Promise<AdminBackupSettings>;
onRunRemoteBackup: (masterPassword: string, destinationId?: string | null) => Promise<AdminBackupRunResponse>;
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 }>;
onDeleteRemoteBackup: (destinationId: string, path: string) => Promise<void>;
onRestoreRemoteBackup: (destinationId: string, path: string, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
onRestoreRemoteBackupAllowingChecksumMismatch: (destinationId: string, path: string, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
onRestoreRemoteBackup: (masterPassword: string, destinationId: string, path: string, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
onRestoreRemoteBackupAllowingChecksumMismatch: (masterPassword: string, destinationId: string, path: string, replaceExisting?: boolean) => Promise<AdminBackupImportResponse>;
onNotify: (type: 'success' | 'error' | 'warning', text: string) => void;
}
@@ -53,6 +53,14 @@ type PendingRestoreIntegrity =
| { source: 'local'; fileName: 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 {
titleKey: string;
detailKey: string;
@@ -193,6 +201,9 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
const [confirmIntegrityWarningOpen, setConfirmIntegrityWarningOpen] = useState(false);
const [confirmDeleteDestinationOpen, setConfirmDeleteDestinationOpen] = 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 [pendingRemoteRestorePath, setPendingRemoteRestorePath] = useState('');
const [pendingRemoteDeletePath, setPendingRemoteDeletePath] = useState('');
@@ -209,7 +220,7 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
const selectedDestination = getDestinationById(settings, selectedDestinationId);
const savedSelectedDestination = getDestinationById(savedSettings, selectedDestinationId);
const selectedDestinationIsSaved = !!savedSelectedDestination;
const disableWhileBusy = exporting || importing || savingSettings || runningRemoteBackup;
const disableWhileBusy = exporting || importing || savingSettings || runningRemoteBackup || backupPasswordSubmitting;
const currentRemoteBrowserPath = savedSelectedDestination ? (remoteBrowserPathByDestination[savedSelectedDestination.id] || '') : '';
const currentRemoteBrowserKey = savedSelectedDestination ? getRemoteBrowserCacheKey(savedSelectedDestination.id, currentRemoteBrowserPath) : '';
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 canRunSelectedDestination = !!selectedDestination && selectedDestinationIsSaved;
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(() => {
let cancelled = false;
@@ -507,11 +530,17 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
}
async function handleExport() {
if (exporting) return;
setPendingBackupVerification({ action: 'export' });
setBackupPasswordValue('');
}
async function executeExport(masterPassword: string) {
setLocalError('');
setExporting(true);
try {
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'));
} catch (error) {
const message = error instanceof Error ? error.message : t('txt_backup_export_failed');
@@ -527,6 +556,28 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
replaceExisting: boolean,
allowChecksumMismatch: boolean = false,
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 (!selectedFile) {
@@ -547,8 +598,8 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
delayMs: replaceExisting ? 480 : 1400,
});
const result = allowChecksumMismatch
? await props.onImportAllowingChecksumMismatch(selectedFile, replaceExisting)
: await props.onImport(selectedFile, replaceExisting);
? await props.onImportAllowingChecksumMismatch(masterPassword, selectedFile, replaceExisting)
: await props.onImport(masterPassword, selectedFile, replaceExisting);
props.onNotify('success', `${buildIntegrityStatusMessage(integrity)} ${t('txt_backup_restore_success_relogin')}`);
const skippedMessage = buildSkippedImportMessage(result);
if (skippedMessage) props.onNotify('warning', skippedMessage);
@@ -573,12 +624,18 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
}
async function handleSaveSettings() {
if (savingSettings) return;
setPendingBackupVerification({ action: 'saveSettings' });
setBackupPasswordValue('');
}
async function executeSaveSettings(masterPassword: string) {
const payload = buildSettingsPayloadForSelectedDestination();
const destinationIdToInvalidate = selectedDestinationId;
setSavingSettings(true);
setLocalError('');
try {
const saved = await props.onSaveSettings(payload);
const saved = await props.onSaveSettings(masterPassword, payload);
const nextSelected =
(selectedDestinationId && saved.destinations.some((destination) => destination.id === selectedDestinationId) && selectedDestinationId)
|| getFirstVisibleDestinationId(saved)
@@ -613,6 +670,12 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
}
async function handleRunRemoteBackup() {
if (!selectedDestination || runningRemoteBackup) return;
setPendingBackupVerification({ action: 'runRemoteBackup' });
setBackupPasswordValue('');
}
async function executeRunRemoteBackup(masterPassword: string) {
if (!selectedDestination) return;
setRunningRemoteBackup(true);
setLocalError('');
@@ -621,7 +684,7 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
source: 'remote',
includeAttachments: !!selectedDestination.includeAttachments,
});
const result = await props.onRunRemoteBackup(selectedDestination.id);
const result = await props.onRunRemoteBackup(masterPassword, selectedDestination.id);
setSavedSettings(result.settings);
setSettings(result.settings);
setSelectedDestinationId(selectedDestination.id);
@@ -638,12 +701,17 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
}
async function handleDownloadRemote(path: string) {
setPendingBackupVerification({ action: 'downloadRemote', path });
setBackupPasswordValue('');
}
async function executeDownloadRemote(masterPassword: string, path: string) {
if (!savedSelectedDestination) return;
setDownloadingRemotePath(path);
setDownloadingRemotePercent(null);
setLocalError('');
try {
await props.onDownloadRemoteBackup(savedSelectedDestination.id, path, setDownloadingRemotePercent);
await props.onDownloadRemoteBackup(masterPassword, savedSelectedDestination.id, path, setDownloadingRemotePercent);
} catch (error) {
const message = error instanceof Error ? error.message : t('txt_backup_remote_download_failed');
setLocalError(message);
@@ -724,6 +792,25 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
replaceExisting: boolean,
allowChecksumMismatch: boolean = false,
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 (!savedSelectedDestination) return;
@@ -738,8 +825,8 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
delayMs: replaceExisting ? 480 : 1400,
});
const result = allowChecksumMismatch
? await props.onRestoreRemoteBackupAllowingChecksumMismatch(savedSelectedDestination.id, path, replaceExisting)
: await props.onRestoreRemoteBackup(savedSelectedDestination.id, path, replaceExisting);
? await props.onRestoreRemoteBackupAllowingChecksumMismatch(masterPassword, savedSelectedDestination.id, path, replaceExisting)
: await props.onRestoreRemoteBackup(masterPassword, savedSelectedDestination.id, path, replaceExisting);
setConfirmRemoteReplaceOpen(false);
setPendingRemoteRestorePath('');
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 (
<div className="backup-grid">
<input
@@ -893,6 +1010,33 @@ export default function BackupCenterPage(props: BackupCenterPageProps) {
</div>
), 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
open={confirmLocalRestoreOpen}
title={t('txt_backup_import')}
+14 -8
View File
@@ -14,7 +14,7 @@ interface SettingsPageProps {
sessionTimeoutAction: 'lock' | 'logout';
onChangePassword: (currentPassword: string, nextPassword: string, nextPassword2: 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;
onGetRecoveryCode: (masterPassword: string) => Promise<string>;
onGetApiKey: (masterPassword: string) => Promise<string>;
@@ -34,6 +34,7 @@ interface SettingsPageProps {
}
type MasterPasswordPromptAction =
| 'enableTotp'
| 'recovery'
| 'apiKey'
| 'rotateApiKey'
@@ -141,12 +142,12 @@ export default function SettingsPage(props: SettingsPageProps) {
}, [props.profile.email, secret]);
async function enableTotp(): Promise<void> {
try {
await props.onEnableTotp(secret, token);
setTotpLocked(true);
} catch {
// Keep inputs editable after a failed attempt.
if (totpLocked) return;
if (!secret.trim() || !token.trim()) {
props.onNotify?.('error', t('txt_secret_and_code_are_required'));
return;
}
openMasterPasswordPrompt('enableTotp');
}
async function refreshAccountPasskeys(): Promise<void> {
@@ -178,7 +179,10 @@ export default function SettingsPage(props: SettingsPageProps) {
const masterPassword = masterPasswordPromptValue;
setMasterPasswordPromptSubmitting(true);
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);
setRecoveryCode(code);
props.onNotify?.('success', t('txt_recovery_code_loaded'));
@@ -214,7 +218,9 @@ export default function SettingsPage(props: SettingsPageProps) {
}
const masterPasswordPromptTitle =
masterPasswordPrompt === 'recovery'
masterPasswordPrompt === 'enableTotp'
? t('txt_enable_totp')
: masterPasswordPrompt === 'recovery'
? t('txt_view_recovery_code')
: masterPasswordPrompt === 'rotateApiKey'
? 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()) {
const error = new Error(t('txt_secret_and_code_are_required'));
onNotify('error', error.message);
throw error;
}
if (!masterPassword) {
const error = new Error(t('txt_master_password_is_required'));
onNotify('error', error.message);
throw error;
}
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'));
} catch (error) {
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(
() => ({
async exportBackup(includeAttachments: boolean = false) {
async exportBackup(masterPasswordHash: string, includeAttachments: boolean = false) {
const payload = await buildCompleteAdminBackupExport(
authedFetch,
masterPasswordHash,
includeAttachments,
async (event: BackupExportClientProgressEvent) => {
dispatchBackupProgress(event);
@@ -48,14 +49,14 @@ export default function useBackupActions(options: UseBackupActionsOptions) {
});
},
async importBackup(file: File, replaceExisting: boolean = false) {
const result = await importAdminBackup(authedFetch, file, replaceExisting);
async importBackup(masterPasswordHash: string, file: File, replaceExisting: boolean = false) {
const result = await importAdminBackup(authedFetch, masterPasswordHash, file, replaceExisting);
onImported?.();
return result;
},
async importBackupAllowingChecksumMismatch(file: File, replaceExisting: boolean = false) {
const result = await importAdminBackup(authedFetch, file, replaceExisting, true);
async importBackupAllowingChecksumMismatch(masterPasswordHash: string, file: File, replaceExisting: boolean = false) {
const result = await importAdminBackup(authedFetch, masterPasswordHash, file, replaceExisting, true);
onImported?.();
return result;
},
@@ -64,20 +65,20 @@ export default function useBackupActions(options: UseBackupActionsOptions) {
return getAdminBackupSettings(authedFetch);
},
async saveSettings(settings: Parameters<typeof saveAdminBackupSettings>[1]) {
return saveAdminBackupSettings(authedFetch, settings);
async saveSettings(masterPasswordHash: string, settings: Parameters<typeof saveAdminBackupSettings>[2]) {
return saveAdminBackupSettings(authedFetch, masterPasswordHash, settings);
},
async runRemoteBackup(destinationId?: string | null) {
return runAdminBackupNow(authedFetch, destinationId);
async runRemoteBackup(masterPasswordHash: string, destinationId?: string | null) {
return runAdminBackupNow(authedFetch, masterPasswordHash, destinationId);
},
async listRemoteBackups(destinationId: string, path: string) {
return listRemoteBackups(authedFetch, destinationId, path);
},
async downloadRemoteBackup(destinationId: string, path: string, onProgress?: (percent: number | null) => void) {
const payload = await fetchRemoteBackupPayload(authedFetch, destinationId, path, onProgress);
async downloadRemoteBackup(masterPasswordHash: string, destinationId: string, path: string, onProgress?: (percent: number | null) => void) {
const payload = await fetchRemoteBackupPayload(authedFetch, masterPasswordHash, destinationId, path, onProgress);
downloadBytesAsFile(payload.bytes, payload.fileName, payload.mimeType);
},
@@ -89,14 +90,14 @@ export default function useBackupActions(options: UseBackupActionsOptions) {
await deleteRemoteBackup(authedFetch, destinationId, path);
},
async restoreRemoteBackup(destinationId: string, path: string, replaceExisting: boolean = false) {
const result = await restoreRemoteBackupRequest(authedFetch, destinationId, path, replaceExisting);
async restoreRemoteBackup(masterPasswordHash: string, destinationId: string, path: string, replaceExisting: boolean = false) {
const result = await restoreRemoteBackupRequest(authedFetch, masterPasswordHash, destinationId, path, replaceExisting);
onRestored?.();
return result;
},
async restoreRemoteBackupAllowingChecksumMismatch(destinationId: string, path: string, replaceExisting: boolean = false) {
const result = await restoreRemoteBackupRequest(authedFetch, destinationId, path, replaceExisting, true);
async restoreRemoteBackupAllowingChecksumMismatch(masterPasswordHash: string, destinationId: string, path: string, replaceExisting: boolean = false) {
const result = await restoreRemoteBackupRequest(authedFetch, masterPasswordHash, destinationId, path, replaceExisting, true);
onRestored?.();
return result;
},
+25 -10
View File
@@ -49,6 +49,11 @@ export interface BackupSettingsRepairStateResponse {
portable: BackupSettingsPortablePayload | null;
}
export interface BackupUserVerificationPayload {
masterPasswordHash?: string | null;
userVerificationToken?: string | null;
}
export interface AdminBackupRunResponse {
object: 'backup-run';
result: {
@@ -173,12 +178,13 @@ async function applyBackupFileIntegrityName(fileName: string, bytes: Uint8Array)
export async function exportAdminBackup(
authedFetch: AuthedFetch,
masterPasswordHash: string,
includeAttachments: boolean = false
): Promise<AdminBackupExportPayload> {
const resp = await authedFetch('/api/admin/backup/export', {
method: 'POST',
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')));
@@ -201,10 +207,11 @@ export async function downloadAdminBackupAttachmentBlob(
export async function buildCompleteAdminBackupExport(
authedFetch: AuthedFetch,
masterPasswordHash: string,
includeAttachments: boolean = false,
onProgress?: (event: BackupExportClientProgressEvent) => void | Promise<void>
): Promise<AdminBackupExportPayload> {
const payload = await exportAdminBackup(authedFetch, includeAttachments);
const payload = await exportAdminBackup(authedFetch, masterPasswordHash, includeAttachments);
if (!includeAttachments) {
await onProgress?.({
operation: 'backup-export',
@@ -278,12 +285,13 @@ export async function getAdminBackupSettings(authedFetch: AuthedFetch): Promise<
export async function saveAdminBackupSettings(
authedFetch: AuthedFetch,
masterPasswordHash: string,
settings: AdminBackupSettings
): Promise<AdminBackupSettings> {
const resp = await authedFetch('/api/admin/backup/settings', {
method: 'PUT',
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')));
const body = await parseJson<AdminBackupSettings>(resp);
@@ -305,12 +313,13 @@ export async function getAdminBackupSettingsRepairState(
export async function repairAdminBackupSettings(
authedFetch: AuthedFetch,
verification: BackupUserVerificationPayload,
settings: AdminBackupSettings
): Promise<AdminBackupSettings> {
const resp = await authedFetch('/api/admin/backup/settings/repair', {
method: 'POST',
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')));
const body = await parseJson<AdminBackupSettings>(resp);
@@ -320,12 +329,13 @@ export async function repairAdminBackupSettings(
export async function runAdminBackupNow(
authedFetch: AuthedFetch,
masterPasswordHash: string,
destinationId?: string | null
): Promise<AdminBackupRunResponse> {
const resp = await authedFetch('/api/admin/backup/run', {
method: 'POST',
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')));
const body = await parseJson<AdminBackupRunResponse>(resp);
@@ -351,14 +361,16 @@ export async function listRemoteBackups(
export async function downloadRemoteBackup(
authedFetch: AuthedFetch,
masterPasswordHash: string,
destinationId: string,
path: string,
onProgress?: (percent: number | null) => void
): Promise<AdminBackupExportPayload> {
const params = new URLSearchParams();
params.set('destinationId', destinationId);
params.set('path', path);
const resp = await authedFetch(`/api/admin/backup/remote/download?${params.toString()}`, { method: 'GET' });
const resp = await authedFetch('/api/admin/backup/remote/download', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ destinationId, path, masterPasswordHash }),
});
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 fileName = parseContentDispositionFileName(resp, 'nodewarden_remote_backup.zip');
@@ -418,6 +430,7 @@ export async function inspectRemoteBackupIntegrity(
export async function restoreRemoteBackup(
authedFetch: AuthedFetch,
masterPasswordHash: string,
destinationId: string,
path: string,
replaceExisting: boolean = false,
@@ -426,7 +439,7 @@ export async function restoreRemoteBackup(
const resp = await authedFetch('/api/admin/backup/remote/restore', {
method: 'POST',
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')));
const body = await parseJson<AdminBackupImportResponse>(resp);
@@ -436,12 +449,14 @@ export async function restoreRemoteBackup(
export async function importAdminBackup(
authedFetch: AuthedFetch,
masterPasswordHash: string,
file: File,
replaceExisting: boolean = false,
allowChecksumMismatch: boolean = false
): Promise<AdminBackupImportResponse> {
const formData = new FormData();
formData.set('file', file, file.name || 'nodewarden_backup.zip');
formData.set('masterPasswordHash', masterPasswordHash);
if (replaceExisting) {
formData.set('replaceExisting', '1');
}
+20 -7
View File
@@ -66,6 +66,12 @@ export interface CompletedLogin {
session: SessionState;
profile: 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 =
@@ -319,7 +325,8 @@ export async function completeLogin(
token: TokenSuccess,
email: string,
masterKey: Uint8Array,
fallbackKdfIterations: number
fallbackKdfIterations: number,
freshMasterPasswordHash?: string | null
): Promise<CompletedLogin> {
const normalizedEmail = email.trim().toLowerCase();
const fallbackProfile = loadProfileSnapshot(normalizedEmail);
@@ -348,6 +355,8 @@ export async function completeLogin(
session: { ...baseSession, ...keys },
profile,
profilePromise: getProfile(tempFetch),
freshMasterPasswordHash: freshMasterPasswordHash || null,
freshUserVerificationToken: readTokenUserVerificationToken(token),
};
}
@@ -360,7 +369,8 @@ async function completeLoginWithVaultKeys(
token: TokenSuccess,
email: string,
keys: { symEncKey: string; symMacKey: string },
fallbackKdfIterations: number
fallbackKdfIterations: number,
freshMasterPasswordHash?: string | null
): Promise<CompletedLogin> {
const normalizedEmail = email.trim().toLowerCase();
const fallbackProfile = loadProfileSnapshot(normalizedEmail);
@@ -385,6 +395,8 @@ async function completeLoginWithVaultKeys(
session: { ...baseSession, ...keys },
profile,
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) {
return {
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
): Promise<CompletedLogin> {
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(
@@ -489,7 +501,7 @@ export async function performTotpLogin(
rememberDevice,
});
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 };
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) {
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,
};
}
@@ -557,6 +569,7 @@ export async function performUnlock(
session: offline.session,
profile: offline.profile,
profilePromise: Promise.resolve(offline.profile),
freshMasterPasswordHash: null,
},
};
} catch {
@@ -589,7 +602,7 @@ export async function performUnlock(
if ('access_token' in token && token.access_token) {
return {
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(
activeSession: SessionState,
activeProfile: Profile
activeProfile: Profile,
verification?: { masterPasswordHash?: string | null; userVerificationToken?: string | null } | null
): Promise<void> {
if (activeProfile.role !== 'admin') return;
if (!activeSession.accessToken || !activeSession.symEncKey || !activeSession.symMacKey) return;
@@ -14,8 +15,9 @@ export async function silentlyRepairBackupSettingsIfNeeded(
try {
const state = await getAdminBackupSettingsRepairState(tempFetch);
if (!state.needsRepair || !state.portable) return;
if (!verification?.masterPasswordHash && !verification?.userVerificationToken) return;
const repairedSettings = await decryptPortableBackupSettings(state.portable, activeProfile, activeSession);
await repairAdminBackupSettings(tempFetch, repairedSettings);
await repairAdminBackupSettings(tempFetch, verification, repairedSettings);
} catch (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'));
return 0;
},
onExportBackup: async () => {
onExportBackup: async (_masterPassword: string) => {
notify('success', t('txt_backup_export_success'));
},
onImportBackup: async () => {
onImportBackup: async (_masterPassword: string, _file: File, _replaceExisting?: boolean) => {
resetDemoVaultState(state);
notify('success', t('txt_backup_import_success_relogin'));
return createDemoImportBackupResult();
},
onImportBackupAllowingChecksumMismatch: async () => {
onImportBackupAllowingChecksumMismatch: async (_masterPassword: string, _file: File, _replaceExisting?: boolean) => {
resetDemoVaultState(state);
notify('success', t('txt_backup_import_success_relogin'));
return createDemoImportBackupResult();
},
onLoadBackupSettings: async () => state.backupSettings,
onSaveBackupSettings: async (settings) => {
onSaveBackupSettings: async (_masterPassword: string, settings) => {
const next = cloneJson(settings);
state.setBackupSettings(next);
notify('success', t('txt_backup_settings_saved'));
return next;
},
onRunRemoteBackup: async (destinationId?: string | null) => {
onRunRemoteBackup: async (_masterPassword: string, destinationId?: string | null) => {
notify('success', t('txt_backup_remote_run_success'));
return createDemoBackupRun(state.backupSettings, destinationId);
},
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'));
},
onInspectRemoteBackup: async (_destinationId: string, path: string) => ({
@@ -1199,13 +1199,13 @@ export function createDemoMainRoutesProps(base: AppMainRoutesProps, notify: Noti
onDeleteRemoteBackup: async () => {
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');
resetDemoVaultState(state);
notify('success', t('txt_backup_remote_restore_completed_verified'));
return createDemoImportBackupResult();
},
onRestoreRemoteBackupAllowingChecksumMismatch: async (_destinationId, path) => {
onRestoreRemoteBackupAllowingChecksumMismatch: async (_masterPassword: string, _destinationId, path) => {
await runDemoRemoteRestoreProgress(path.split('/').pop() || path || 'nodewarden_backup_demo.zip');
resetDemoVaultState(state);
notify('success', t('txt_backup_remote_restore_completed_verified'));
+2
View File
@@ -314,6 +314,8 @@ export interface TokenSuccess {
ResetMasterPassword?: boolean;
scope?: string;
unofficialServer?: boolean;
UserVerificationToken?: string;
userVerificationToken?: string;
UserDecryptionOptions?: unknown;
userDecryptionOptions?: unknown;
VaultKeys?: {