mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-20 21:00:41 +00:00
feat: Implement admin backup export and import functionality
- Added new endpoints for exporting and importing instance-level backups. - Introduced user interface components for backup management in the web app. - Enhanced import/export logic to handle attachments and provide detailed summaries. - Updated localization files to include new strings related to backup features. - Improved styling for backup-related UI elements.
This commit is contained in:
@@ -7,6 +7,7 @@ import { generateUUID } from '../utils/uuid';
|
||||
import { LIMITS } from '../config/limits';
|
||||
import { isTotpEnabled, verifyTotpToken } from '../utils/totp';
|
||||
import { createRecoveryCode, recoveryCodeEquals } from '../utils/recovery-code';
|
||||
import { buildAccountKeys } from '../utils/user-decryption';
|
||||
|
||||
function looksLikeEncString(value: string): boolean {
|
||||
if (!value) return false;
|
||||
@@ -61,6 +62,7 @@ function jwtSecretUnsafeReason(env: Env): 'missing' | 'default' | 'too_short' |
|
||||
}
|
||||
|
||||
function toProfile(user: User, env: Env): ProfileResponse {
|
||||
void env;
|
||||
return {
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
@@ -74,7 +76,7 @@ function toProfile(user: User, env: Env): ProfileResponse {
|
||||
twoFactorEnabled: !!user.totpSecret,
|
||||
key: user.key,
|
||||
privateKey: user.privateKey,
|
||||
accountKeys: null,
|
||||
accountKeys: buildAccountKeys(user),
|
||||
securityStamp: user.securityStamp || user.id,
|
||||
organizations: [],
|
||||
providers: [],
|
||||
|
||||
@@ -3,7 +3,7 @@ import { StorageService } from '../services/storage';
|
||||
import { jsonResponse, errorResponse } from '../utils/response';
|
||||
import { generateUUID } from '../utils/uuid';
|
||||
import { createFileDownloadToken, verifyFileDownloadToken } from '../utils/jwt';
|
||||
import { cipherToResponse } from './ciphers';
|
||||
import { cipherToResponse, shouldOmitPasskeysForResponse } from './ciphers';
|
||||
import { LIMITS } from '../config/limits';
|
||||
import {
|
||||
deleteBlobObject,
|
||||
@@ -84,7 +84,9 @@ export async function handleCreateAttachment(
|
||||
attachmentId: attachmentId,
|
||||
url: `/api/ciphers/${cipherId}/attachment/${attachmentId}`,
|
||||
fileUploadType: 0, // Direct upload
|
||||
cipherResponse: cipherToResponse(updatedCipher!, attachments),
|
||||
cipherResponse: cipherToResponse(updatedCipher!, attachments, {
|
||||
omitFido2Credentials: shouldOmitPasskeysForResponse(request),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -309,7 +311,9 @@ export async function handleDeleteAttachment(
|
||||
const attachments = await storage.getAttachmentsByCipher(cipherId);
|
||||
|
||||
return jsonResponse({
|
||||
cipher: cipherToResponse(updatedCipher!, attachments),
|
||||
cipher: cipherToResponse(updatedCipher!, attachments, {
|
||||
omitFido2Credentials: shouldOmitPasskeysForResponse(request),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,678 @@
|
||||
import { zipSync, unzipSync } from 'fflate';
|
||||
import { Env, User } from '../types';
|
||||
import { StorageService } from '../services/storage';
|
||||
import { errorResponse, jsonResponse } from '../utils/response';
|
||||
import { generateUUID } from '../utils/uuid';
|
||||
import { KV_MAX_OBJECT_BYTES, deleteBlobObject, getAttachmentObjectKey, getBlobObject, getBlobStorageKind, getSendFileObjectKey, putBlobObject } from '../services/blob-store';
|
||||
|
||||
type SqlRow = Record<string, string | number | null>;
|
||||
|
||||
interface BackupManifest {
|
||||
formatVersion: 1;
|
||||
exportedAt: string;
|
||||
appVersion: string;
|
||||
storageKind: 'r2' | 'kv' | null;
|
||||
tableCounts: Record<string, number>;
|
||||
includes: {
|
||||
attachments: boolean;
|
||||
sendFiles: boolean;
|
||||
};
|
||||
blobSummary: {
|
||||
attachmentFiles: number;
|
||||
sendFiles: number;
|
||||
totalBytes: number;
|
||||
largestObjectBytes: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface BackupPayload {
|
||||
manifest: BackupManifest;
|
||||
db: {
|
||||
config: SqlRow[];
|
||||
users: SqlRow[];
|
||||
user_revisions: SqlRow[];
|
||||
folders: SqlRow[];
|
||||
ciphers: SqlRow[];
|
||||
attachments: SqlRow[];
|
||||
sends: SqlRow[];
|
||||
};
|
||||
}
|
||||
|
||||
function isAdmin(user: User): boolean {
|
||||
return user.role === 'admin' && user.status === 'active';
|
||||
}
|
||||
|
||||
async function writeAuditLog(
|
||||
storage: StorageService,
|
||||
actorUserId: string | null,
|
||||
action: string,
|
||||
targetType: string | null,
|
||||
targetId: string | null,
|
||||
metadata: Record<string, unknown> | null
|
||||
): Promise<void> {
|
||||
await storage.createAuditLog({
|
||||
id: generateUUID(),
|
||||
actorUserId,
|
||||
action,
|
||||
targetType,
|
||||
targetId,
|
||||
metadata: metadata ? JSON.stringify(metadata) : null,
|
||||
createdAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
||||
async function queryRows(db: D1Database, sql: string, ...values: unknown[]): Promise<SqlRow[]> {
|
||||
const result = await db.prepare(sql).bind(...values).all<SqlRow>();
|
||||
return (result.results || []).map((row) => ({ ...row }));
|
||||
}
|
||||
|
||||
async function streamToBytes(stream: ReadableStream | null): Promise<Uint8Array> {
|
||||
if (!stream) return new Uint8Array();
|
||||
const buffer = await new Response(stream).arrayBuffer();
|
||||
return new Uint8Array(buffer);
|
||||
}
|
||||
|
||||
function parseSendFileId(data: string | null): string | null {
|
||||
if (!data) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(data) as Record<string, unknown>;
|
||||
return typeof parsed.id === 'string' && parsed.id.trim() ? parsed.id.trim() : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function buildBackupFileName(date: Date = new Date()): string {
|
||||
const parts = [
|
||||
date.getUTCFullYear().toString().padStart(4, '0'),
|
||||
(date.getUTCMonth() + 1).toString().padStart(2, '0'),
|
||||
date.getUTCDate().toString().padStart(2, '0'),
|
||||
date.getUTCHours().toString().padStart(2, '0'),
|
||||
date.getUTCMinutes().toString().padStart(2, '0'),
|
||||
date.getUTCSeconds().toString().padStart(2, '0'),
|
||||
];
|
||||
return `nodewarden_instance_backup_${parts[0]}${parts[1]}${parts[2]}_${parts[3]}${parts[4]}${parts[5]}.zip`;
|
||||
}
|
||||
|
||||
async function ensureImportTargetIsFresh(db: D1Database): Promise<void> {
|
||||
const counts = await Promise.all([
|
||||
db.prepare('SELECT COUNT(*) AS count FROM ciphers').first<{ count: number }>(),
|
||||
db.prepare('SELECT COUNT(*) AS count FROM folders').first<{ count: number }>(),
|
||||
db.prepare('SELECT COUNT(*) AS count FROM attachments').first<{ count: number }>(),
|
||||
db.prepare('SELECT COUNT(*) AS count FROM sends').first<{ count: number }>(),
|
||||
]);
|
||||
const total = counts.reduce((sum, row) => sum + Number(row?.count || 0), 0);
|
||||
if (total > 0) {
|
||||
throw new Error('Backup import requires a fresh instance with no vault or send data');
|
||||
}
|
||||
}
|
||||
|
||||
async function clearExistingBlobFiles(env: Env, db: D1Database): Promise<void> {
|
||||
const attachmentRows = await queryRows(
|
||||
db,
|
||||
`SELECT a.id, a.cipher_id
|
||||
FROM attachments a
|
||||
INNER JOIN ciphers c ON c.id = a.cipher_id`
|
||||
);
|
||||
for (const row of attachmentRows) {
|
||||
const cipherId = String(row.cipher_id || '').trim();
|
||||
const attachmentId = String(row.id || '').trim();
|
||||
if (!cipherId || !attachmentId) continue;
|
||||
await deleteBlobObject(env, getAttachmentObjectKey(cipherId, attachmentId));
|
||||
}
|
||||
|
||||
const sendRows = await queryRows(db, 'SELECT id, data FROM sends');
|
||||
for (const row of sendRows) {
|
||||
const sendId = String(row.id || '').trim();
|
||||
const fileId = parseSendFileId(typeof row.data === 'string' ? row.data : null);
|
||||
if (!sendId || !fileId) continue;
|
||||
await deleteBlobObject(env, getSendFileObjectKey(sendId, fileId));
|
||||
}
|
||||
}
|
||||
|
||||
async function resetImportTarget(db: D1Database): Promise<void> {
|
||||
const statements = [
|
||||
'DELETE FROM attachments',
|
||||
'DELETE FROM ciphers',
|
||||
'DELETE FROM folders',
|
||||
'DELETE FROM sends',
|
||||
'DELETE FROM trusted_two_factor_device_tokens',
|
||||
'DELETE FROM devices',
|
||||
'DELETE FROM refresh_tokens',
|
||||
'DELETE FROM invites',
|
||||
'DELETE FROM audit_logs',
|
||||
'DELETE FROM user_revisions',
|
||||
'DELETE FROM users',
|
||||
'DELETE FROM config',
|
||||
'DELETE FROM login_attempts_ip',
|
||||
'DELETE FROM api_rate_limits',
|
||||
'DELETE FROM used_attachment_download_tokens',
|
||||
].map((sql) => db.prepare(sql));
|
||||
await db.batch(statements);
|
||||
}
|
||||
|
||||
function getRequiredZipEntries(db: BackupPayload['db']): string[] {
|
||||
const entries: string[] = [];
|
||||
for (const row of db.attachments) {
|
||||
const cipherId = String(row.cipher_id || '').trim();
|
||||
const attachmentId = String(row.id || '').trim();
|
||||
if (!cipherId || !attachmentId) continue;
|
||||
entries.push(`attachments/${cipherId}/${attachmentId}.bin`);
|
||||
}
|
||||
for (const row of db.sends) {
|
||||
const sendId = String(row.id || '').trim();
|
||||
const fileId = parseSendFileId(typeof row.data === 'string' ? row.data : null);
|
||||
if (!sendId || !fileId) continue;
|
||||
entries.push(`send-files/${sendId}/${fileId}.bin`);
|
||||
}
|
||||
return entries;
|
||||
}
|
||||
|
||||
function parseBackupArchive(bytes: Uint8Array): { payload: BackupPayload; files: Record<string, Uint8Array> } {
|
||||
let zipped: Record<string, Uint8Array>;
|
||||
try {
|
||||
zipped = unzipSync(bytes);
|
||||
} catch {
|
||||
throw new Error('Invalid backup archive');
|
||||
}
|
||||
|
||||
const manifestBytes = zipped['manifest.json'];
|
||||
const dbBytes = zipped['db.json'];
|
||||
if (!manifestBytes || !dbBytes) {
|
||||
throw new Error('Backup archive is missing manifest.json or db.json');
|
||||
}
|
||||
|
||||
const decoder = new TextDecoder();
|
||||
let manifest: BackupManifest;
|
||||
let db: BackupPayload['db'];
|
||||
try {
|
||||
manifest = JSON.parse(decoder.decode(manifestBytes)) as BackupManifest;
|
||||
db = JSON.parse(decoder.decode(dbBytes)) as BackupPayload['db'];
|
||||
} catch {
|
||||
throw new Error('Backup archive contains invalid JSON metadata');
|
||||
}
|
||||
|
||||
if (manifest?.formatVersion !== 1) {
|
||||
throw new Error('Unsupported backup format version');
|
||||
}
|
||||
if (!db || typeof db !== 'object') {
|
||||
throw new Error('Backup archive database payload is invalid');
|
||||
}
|
||||
|
||||
const requiredEntries = getRequiredZipEntries(db);
|
||||
for (const entry of requiredEntries) {
|
||||
if (!zipped[entry]) {
|
||||
throw new Error(`Backup archive is missing required file: ${entry}`);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
payload: { manifest, db },
|
||||
files: zipped,
|
||||
};
|
||||
}
|
||||
|
||||
function ensureRowArray(value: unknown, table: string): SqlRow[] {
|
||||
if (!Array.isArray(value)) {
|
||||
throw new Error(`Backup archive table ${table} is invalid`);
|
||||
}
|
||||
return value as SqlRow[];
|
||||
}
|
||||
|
||||
function validateBackupPayloadContents(payload: BackupPayload, files: Record<string, Uint8Array>): void {
|
||||
const configRows = ensureRowArray(payload.db.config, 'config');
|
||||
const userRows = ensureRowArray(payload.db.users, 'users');
|
||||
const revisionRows = ensureRowArray(payload.db.user_revisions, 'user_revisions');
|
||||
const folderRows = ensureRowArray(payload.db.folders, 'folders');
|
||||
const cipherRows = ensureRowArray(payload.db.ciphers, 'ciphers');
|
||||
const attachmentRows = ensureRowArray(payload.db.attachments, 'attachments');
|
||||
const sendRows = ensureRowArray(payload.db.sends, 'sends');
|
||||
|
||||
const userIds = new Set<string>();
|
||||
for (const row of userRows) {
|
||||
const id = String(row.id || '').trim();
|
||||
const email = String(row.email || '').trim();
|
||||
if (!id || !email) throw new Error('Backup archive contains an invalid user row');
|
||||
if (userIds.has(id)) throw new Error(`Backup archive contains duplicate user id: ${id}`);
|
||||
userIds.add(id);
|
||||
}
|
||||
|
||||
for (const row of configRows) {
|
||||
const key = String(row.key || '').trim();
|
||||
if (!key) throw new Error('Backup archive contains an invalid config row');
|
||||
}
|
||||
|
||||
for (const row of revisionRows) {
|
||||
const userId = String(row.user_id || '').trim();
|
||||
if (!userId || !userIds.has(userId)) {
|
||||
throw new Error(`Backup archive contains a revision for an unknown user: ${userId || '(empty)'}`);
|
||||
}
|
||||
}
|
||||
|
||||
const folderIds = new Set<string>();
|
||||
for (const row of folderRows) {
|
||||
const id = String(row.id || '').trim();
|
||||
const userId = String(row.user_id || '').trim();
|
||||
if (!id || !userIds.has(userId)) throw new Error('Backup archive contains an invalid folder row');
|
||||
if (folderIds.has(id)) throw new Error(`Backup archive contains duplicate folder id: ${id}`);
|
||||
folderIds.add(id);
|
||||
}
|
||||
|
||||
const cipherIds = new Set<string>();
|
||||
for (const row of cipherRows) {
|
||||
const id = String(row.id || '').trim();
|
||||
const userId = String(row.user_id || '').trim();
|
||||
const folderId = String(row.folder_id || '').trim();
|
||||
if (!id || !userIds.has(userId)) throw new Error('Backup archive contains an invalid cipher row');
|
||||
if (folderId && !folderIds.has(folderId)) {
|
||||
throw new Error(`Backup archive contains a cipher that references a missing folder: ${id}`);
|
||||
}
|
||||
if (cipherIds.has(id)) throw new Error(`Backup archive contains duplicate cipher id: ${id}`);
|
||||
cipherIds.add(id);
|
||||
}
|
||||
|
||||
const attachmentIds = new Set<string>();
|
||||
for (const row of attachmentRows) {
|
||||
const id = String(row.id || '').trim();
|
||||
const cipherId = String(row.cipher_id || '').trim();
|
||||
if (!id || !cipherIds.has(cipherId)) throw new Error('Backup archive contains an invalid attachment row');
|
||||
if (attachmentIds.has(id)) throw new Error(`Backup archive contains duplicate attachment id: ${id}`);
|
||||
attachmentIds.add(id);
|
||||
|
||||
const path = `attachments/${cipherId}/${id}.bin`;
|
||||
const entry = files[path];
|
||||
if (!(entry instanceof Uint8Array)) {
|
||||
throw new Error(`Backup archive is missing required file: ${path}`);
|
||||
}
|
||||
}
|
||||
|
||||
const sendIds = new Set<string>();
|
||||
for (const row of sendRows) {
|
||||
const id = String(row.id || '').trim();
|
||||
const userId = String(row.user_id || '').trim();
|
||||
if (!id || !userIds.has(userId)) throw new Error('Backup archive contains an invalid send row');
|
||||
if (sendIds.has(id)) throw new Error(`Backup archive contains duplicate send id: ${id}`);
|
||||
sendIds.add(id);
|
||||
|
||||
const fileId = parseSendFileId(typeof row.data === 'string' ? row.data : null);
|
||||
if (!fileId) continue;
|
||||
const path = `send-files/${id}/${fileId}.bin`;
|
||||
const entry = files[path];
|
||||
if (!(entry instanceof Uint8Array)) {
|
||||
throw new Error(`Backup archive is missing required file: ${path}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function validateImportBlobLimits(env: Env, payload: BackupPayload, files: Record<string, Uint8Array>): void {
|
||||
const storageKind = getBlobStorageKind(env);
|
||||
const hasBlobFiles =
|
||||
payload.db.attachments.length > 0 ||
|
||||
payload.db.sends.some((row) => !!parseSendFileId(typeof row.data === 'string' ? row.data : null));
|
||||
|
||||
if (!storageKind && hasBlobFiles) {
|
||||
throw new Error('Backup contains files but attachment storage is not configured on the target instance');
|
||||
}
|
||||
|
||||
if (storageKind !== 'kv') return;
|
||||
|
||||
let largestObjectBytes = 0;
|
||||
for (const row of payload.db.attachments) {
|
||||
const cipherId = String(row.cipher_id || '').trim();
|
||||
const attachmentId = String(row.id || '').trim();
|
||||
if (!cipherId || !attachmentId) continue;
|
||||
const entry = files[`attachments/${cipherId}/${attachmentId}.bin`];
|
||||
if (!entry) continue;
|
||||
largestObjectBytes = Math.max(largestObjectBytes, entry.byteLength);
|
||||
}
|
||||
|
||||
for (const row of payload.db.sends) {
|
||||
const sendId = String(row.id || '').trim();
|
||||
const fileId = parseSendFileId(typeof row.data === 'string' ? row.data : null);
|
||||
if (!sendId || !fileId) continue;
|
||||
const entry = files[`send-files/${sendId}/${fileId}.bin`];
|
||||
if (!entry) continue;
|
||||
largestObjectBytes = Math.max(largestObjectBytes, entry.byteLength);
|
||||
}
|
||||
|
||||
if (largestObjectBytes > KV_MAX_OBJECT_BYTES) {
|
||||
throw new Error(`Backup contains a file larger than the Workers KV ${Math.floor(KV_MAX_OBJECT_BYTES / (1024 * 1024))} MiB per-object limit`);
|
||||
}
|
||||
}
|
||||
|
||||
async function insertRows(db: D1Database, table: string, columns: string[], rows: SqlRow[], upsert = false): Promise<void> {
|
||||
if (!rows.length) return;
|
||||
const placeholders = columns.map(() => '?').join(', ');
|
||||
const updateSql = upsert
|
||||
? ' ON CONFLICT(key) DO UPDATE SET value = excluded.value'
|
||||
: '';
|
||||
const sql = `INSERT INTO ${table}(${columns.join(', ')}) VALUES(${placeholders})${updateSql}`;
|
||||
const statements: D1PreparedStatement[] = rows.map((row) =>
|
||||
db.prepare(sql).bind(...columns.map((column) => row[column] ?? null))
|
||||
);
|
||||
const chunkSize = 32;
|
||||
for (let i = 0; i < statements.length; i += chunkSize) {
|
||||
await db.batch(statements.slice(i, i + chunkSize));
|
||||
}
|
||||
}
|
||||
|
||||
async function restoreBlobFiles(env: Env, db: BackupPayload['db'], files: Record<string, Uint8Array>): Promise<{ attachments: number; sendFiles: number }> {
|
||||
let attachmentCount = 0;
|
||||
let sendFileCount = 0;
|
||||
|
||||
for (const row of db.attachments) {
|
||||
const cipherId = String(row.cipher_id || '').trim();
|
||||
const attachmentId = String(row.id || '').trim();
|
||||
if (!cipherId || !attachmentId) continue;
|
||||
const zipPath = `attachments/${cipherId}/${attachmentId}.bin`;
|
||||
const bytes = files[zipPath];
|
||||
await putBlobObject(env, getAttachmentObjectKey(cipherId, attachmentId), bytes, {
|
||||
size: bytes.byteLength,
|
||||
contentType: 'application/octet-stream',
|
||||
customMetadata: { cipherId, attachmentId },
|
||||
});
|
||||
attachmentCount += 1;
|
||||
}
|
||||
|
||||
for (const row of db.sends) {
|
||||
const sendId = String(row.id || '').trim();
|
||||
const fileId = parseSendFileId(typeof row.data === 'string' ? row.data : null);
|
||||
if (!sendId || !fileId) continue;
|
||||
const zipPath = `send-files/${sendId}/${fileId}.bin`;
|
||||
const bytes = files[zipPath];
|
||||
await putBlobObject(env, getSendFileObjectKey(sendId, fileId), bytes, {
|
||||
size: bytes.byteLength,
|
||||
contentType: 'application/octet-stream',
|
||||
customMetadata: { sendId, fileId },
|
||||
});
|
||||
sendFileCount += 1;
|
||||
}
|
||||
|
||||
return { attachments: attachmentCount, sendFiles: sendFileCount };
|
||||
}
|
||||
|
||||
// POST /api/admin/backup/export
|
||||
export async function handleAdminExportBackup(
|
||||
request: Request,
|
||||
env: Env,
|
||||
actorUser: User
|
||||
): Promise<Response> {
|
||||
void request;
|
||||
if (!isAdmin(actorUser)) {
|
||||
return errorResponse('Forbidden', 403);
|
||||
}
|
||||
|
||||
const storage = new StorageService(env.DB);
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
const [configRows, userRows, revisionRows, folderRows, cipherRows, attachmentRows, sendRows] = await Promise.all([
|
||||
queryRows(env.DB, 'SELECT key, value FROM config ORDER BY key ASC'),
|
||||
queryRows(env.DB, 'SELECT id, email, name, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, totp_secret, totp_recovery_code, created_at, updated_at FROM users ORDER BY created_at ASC'),
|
||||
queryRows(env.DB, 'SELECT user_id, revision_date FROM user_revisions ORDER BY user_id ASC'),
|
||||
queryRows(env.DB, 'SELECT id, user_id, name, created_at, updated_at FROM folders ORDER BY created_at ASC'),
|
||||
queryRows(env.DB, 'SELECT id, user_id, type, folder_id, name, notes, favorite, data, reprompt, key, created_at, updated_at, deleted_at FROM ciphers ORDER BY created_at ASC'),
|
||||
queryRows(env.DB, 'SELECT id, cipher_id, file_name, size, size_name, key FROM attachments ORDER BY cipher_id ASC, id ASC'),
|
||||
queryRows(env.DB, 'SELECT id, user_id, type, name, notes, data, key, password_hash, password_salt, password_iterations, auth_type, emails, max_access_count, access_count, disabled, hide_email, created_at, updated_at, expiration_date, deletion_date FROM sends ORDER BY created_at ASC'),
|
||||
]);
|
||||
|
||||
let attachmentBlobCount = 0;
|
||||
let sendFileBlobCount = 0;
|
||||
let totalBlobBytes = 0;
|
||||
let largestObjectBytes = 0;
|
||||
|
||||
const files: Record<string, Uint8Array> = {
|
||||
'manifest.json': encoder.encode(
|
||||
JSON.stringify(
|
||||
{
|
||||
formatVersion: 1,
|
||||
exportedAt: new Date().toISOString(),
|
||||
appVersion: '1.0',
|
||||
storageKind: getBlobStorageKind(env),
|
||||
tableCounts: {
|
||||
config: configRows.length,
|
||||
users: userRows.length,
|
||||
user_revisions: revisionRows.length,
|
||||
folders: folderRows.length,
|
||||
ciphers: cipherRows.length,
|
||||
attachments: attachmentRows.length,
|
||||
sends: sendRows.length,
|
||||
},
|
||||
includes: {
|
||||
attachments: true,
|
||||
sendFiles: true,
|
||||
},
|
||||
blobSummary: {
|
||||
attachmentFiles: 0,
|
||||
sendFiles: 0,
|
||||
totalBytes: 0,
|
||||
largestObjectBytes: 0,
|
||||
},
|
||||
} satisfies BackupManifest,
|
||||
null,
|
||||
2
|
||||
)
|
||||
),
|
||||
'db.json': encoder.encode(
|
||||
JSON.stringify(
|
||||
{
|
||||
config: configRows,
|
||||
users: userRows,
|
||||
user_revisions: revisionRows,
|
||||
folders: folderRows,
|
||||
ciphers: cipherRows,
|
||||
attachments: attachmentRows,
|
||||
sends: sendRows,
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
),
|
||||
};
|
||||
|
||||
for (const row of attachmentRows) {
|
||||
const cipherId = String(row.cipher_id || '').trim();
|
||||
const attachmentId = String(row.id || '').trim();
|
||||
if (!cipherId || !attachmentId) continue;
|
||||
const object = await getBlobObject(env, getAttachmentObjectKey(cipherId, attachmentId));
|
||||
if (!object) {
|
||||
return errorResponse(`Attachment blob missing for ${cipherId}/${attachmentId}`, 409);
|
||||
}
|
||||
const bytes = await streamToBytes(object.body);
|
||||
files[`attachments/${cipherId}/${attachmentId}.bin`] = bytes;
|
||||
attachmentBlobCount += 1;
|
||||
totalBlobBytes += bytes.byteLength;
|
||||
largestObjectBytes = Math.max(largestObjectBytes, bytes.byteLength);
|
||||
}
|
||||
|
||||
for (const row of sendRows) {
|
||||
const sendId = String(row.id || '').trim();
|
||||
const fileId = parseSendFileId(typeof row.data === 'string' ? row.data : null);
|
||||
if (!sendId || !fileId) continue;
|
||||
const object = await getBlobObject(env, getSendFileObjectKey(sendId, fileId));
|
||||
if (!object) {
|
||||
return errorResponse(`Send file blob missing for ${sendId}/${fileId}`, 409);
|
||||
}
|
||||
const bytes = await streamToBytes(object.body);
|
||||
files[`send-files/${sendId}/${fileId}.bin`] = bytes;
|
||||
sendFileBlobCount += 1;
|
||||
totalBlobBytes += bytes.byteLength;
|
||||
largestObjectBytes = Math.max(largestObjectBytes, bytes.byteLength);
|
||||
}
|
||||
|
||||
files['manifest.json'] = encoder.encode(
|
||||
JSON.stringify(
|
||||
{
|
||||
formatVersion: 1,
|
||||
exportedAt: new Date().toISOString(),
|
||||
appVersion: '1.0',
|
||||
storageKind: getBlobStorageKind(env),
|
||||
tableCounts: {
|
||||
config: configRows.length,
|
||||
users: userRows.length,
|
||||
user_revisions: revisionRows.length,
|
||||
folders: folderRows.length,
|
||||
ciphers: cipherRows.length,
|
||||
attachments: attachmentRows.length,
|
||||
sends: sendRows.length,
|
||||
},
|
||||
includes: {
|
||||
attachments: true,
|
||||
sendFiles: true,
|
||||
},
|
||||
blobSummary: {
|
||||
attachmentFiles: attachmentBlobCount,
|
||||
sendFiles: sendFileBlobCount,
|
||||
totalBytes: totalBlobBytes,
|
||||
largestObjectBytes,
|
||||
},
|
||||
} satisfies BackupManifest,
|
||||
null,
|
||||
2
|
||||
)
|
||||
);
|
||||
|
||||
const zipped = zipSync(files, { level: 0 });
|
||||
await writeAuditLog(storage, actorUser.id, 'admin.backup.export', 'backup', null, {
|
||||
users: userRows.length,
|
||||
ciphers: cipherRows.length,
|
||||
attachments: attachmentRows.length,
|
||||
sends: sendRows.length,
|
||||
});
|
||||
|
||||
return new Response(zipped, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/zip',
|
||||
'Content-Disposition': `attachment; filename="${buildBackupFileName()}"`,
|
||||
'Cache-Control': 'no-store',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// POST /api/admin/backup/import
|
||||
export async function handleAdminImportBackup(
|
||||
request: Request,
|
||||
env: Env,
|
||||
actorUser: User
|
||||
): Promise<Response> {
|
||||
if (!isAdmin(actorUser)) {
|
||||
return errorResponse('Forbidden', 403);
|
||||
}
|
||||
|
||||
const storage = new StorageService(env.DB);
|
||||
|
||||
let formData: FormData;
|
||||
try {
|
||||
formData = await request.formData();
|
||||
} catch {
|
||||
return errorResponse('Content-Type must be multipart/form-data', 400);
|
||||
}
|
||||
|
||||
const file = formData.get('file');
|
||||
if (!file || typeof file !== 'object' || !('arrayBuffer' in file)) {
|
||||
return errorResponse('Backup file is required', 400);
|
||||
}
|
||||
const backupFile = file as { arrayBuffer(): Promise<ArrayBuffer> };
|
||||
const replaceExisting = String(formData.get('replaceExisting') || '').trim() === '1';
|
||||
|
||||
let archiveBytes: Uint8Array;
|
||||
try {
|
||||
archiveBytes = new Uint8Array(await backupFile.arrayBuffer());
|
||||
} catch {
|
||||
return errorResponse('Unable to read backup file', 400);
|
||||
}
|
||||
|
||||
let parsed: { payload: BackupPayload; files: Record<string, Uint8Array> };
|
||||
try {
|
||||
parsed = parseBackupArchive(archiveBytes);
|
||||
} catch (error) {
|
||||
return errorResponse(error instanceof Error ? error.message : 'Invalid backup archive', 400);
|
||||
}
|
||||
|
||||
try {
|
||||
validateBackupPayloadContents(parsed.payload, parsed.files);
|
||||
} catch (error) {
|
||||
return errorResponse(error instanceof Error ? error.message : 'Backup archive contents are invalid', 400);
|
||||
}
|
||||
|
||||
try {
|
||||
validateImportBlobLimits(env, parsed.payload, parsed.files);
|
||||
} catch (error) {
|
||||
return errorResponse(error instanceof Error ? error.message : 'Backup import is not supported by the current storage backend', 409);
|
||||
}
|
||||
|
||||
let targetIsFresh = true;
|
||||
try {
|
||||
await ensureImportTargetIsFresh(env.DB);
|
||||
} catch (error) {
|
||||
targetIsFresh = false;
|
||||
if (!replaceExisting) {
|
||||
return errorResponse(error instanceof Error ? error.message : 'Backup import requires a fresh instance', 409);
|
||||
}
|
||||
}
|
||||
|
||||
const { db } = parsed.payload;
|
||||
try {
|
||||
if (!targetIsFresh) {
|
||||
await clearExistingBlobFiles(env, env.DB);
|
||||
await resetImportTarget(env.DB);
|
||||
} else {
|
||||
await resetImportTarget(env.DB);
|
||||
}
|
||||
await insertRows(env.DB, 'config', ['key', 'value'], db.config || [], true);
|
||||
await insertRows(
|
||||
env.DB,
|
||||
'users',
|
||||
['id', 'email', 'name', 'master_password_hash', 'key', 'private_key', 'public_key', 'kdf_type', 'kdf_iterations', 'kdf_memory', 'kdf_parallelism', 'security_stamp', 'role', 'status', 'totp_secret', 'totp_recovery_code', 'created_at', 'updated_at'],
|
||||
db.users || []
|
||||
);
|
||||
await insertRows(env.DB, 'user_revisions', ['user_id', 'revision_date'], db.user_revisions || []);
|
||||
await insertRows(env.DB, 'folders', ['id', 'user_id', 'name', 'created_at', 'updated_at'], db.folders || []);
|
||||
await insertRows(
|
||||
env.DB,
|
||||
'ciphers',
|
||||
['id', 'user_id', 'type', 'folder_id', 'name', 'notes', 'favorite', 'data', 'reprompt', 'key', 'created_at', 'updated_at', 'deleted_at'],
|
||||
db.ciphers || []
|
||||
);
|
||||
await insertRows(
|
||||
env.DB,
|
||||
'attachments',
|
||||
['id', 'cipher_id', 'file_name', 'size', 'size_name', 'key'],
|
||||
db.attachments || []
|
||||
);
|
||||
await insertRows(
|
||||
env.DB,
|
||||
'sends',
|
||||
['id', 'user_id', 'type', 'name', 'notes', 'data', 'key', 'password_hash', 'password_salt', 'password_iterations', 'auth_type', 'emails', 'max_access_count', 'access_count', 'disabled', 'hide_email', 'created_at', 'updated_at', 'expiration_date', 'deletion_date'],
|
||||
db.sends || []
|
||||
);
|
||||
|
||||
const blobCounts = await restoreBlobFiles(env, db, parsed.files);
|
||||
await storage.setRegistered();
|
||||
const importedActorUserId = (db.users || []).some((row) => String(row.id || '').trim() === actorUser.id) ? actorUser.id : null;
|
||||
await writeAuditLog(storage, importedActorUserId, 'admin.backup.import', 'backup', null, {
|
||||
users: (db.users || []).length,
|
||||
ciphers: (db.ciphers || []).length,
|
||||
attachments: blobCounts.attachments,
|
||||
sendFiles: blobCounts.sendFiles,
|
||||
replaceExisting,
|
||||
});
|
||||
|
||||
return jsonResponse({
|
||||
object: 'instance-backup-import',
|
||||
imported: {
|
||||
config: (db.config || []).length,
|
||||
users: (db.users || []).length,
|
||||
userRevisions: (db.user_revisions || []).length,
|
||||
folders: (db.folders || []).length,
|
||||
ciphers: (db.ciphers || []).length,
|
||||
attachments: (db.attachments || []).length,
|
||||
sends: (db.sends || []).length,
|
||||
attachmentFiles: blobCounts.attachments,
|
||||
sendFiles: blobCounts.sendFiles,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
return errorResponse(error instanceof Error ? error.message : 'Backup import failed', 500);
|
||||
}
|
||||
}
|
||||
+110
-29
@@ -15,31 +15,82 @@ function getAliasedProp(source: any, aliases: string[]): { present: boolean; val
|
||||
return { present: false, value: undefined };
|
||||
}
|
||||
|
||||
// Android 2026.2.0 expects fido2Credentials[].counter to be a string.
|
||||
export function normalizeCipherLoginForCompatibility(login: any): any {
|
||||
if (!login || typeof login !== 'object') return login ?? null;
|
||||
function looksLikeCipherString(value: unknown): boolean {
|
||||
return /^\d+\.[A-Za-z0-9+/=]+\|[A-Za-z0-9+/=]+(?:\|[A-Za-z0-9+/=]+)?$/.test(String(value || '').trim());
|
||||
}
|
||||
|
||||
const fido2 = Array.isArray(login.fido2Credentials)
|
||||
? login.fido2Credentials.map((cred: any) => {
|
||||
if (!cred || typeof cred !== 'object') return cred;
|
||||
const rawCounter = cred.counter;
|
||||
const counter =
|
||||
rawCounter === null || rawCounter === undefined
|
||||
? '0'
|
||||
: String(rawCounter);
|
||||
return {
|
||||
...cred,
|
||||
counter,
|
||||
};
|
||||
})
|
||||
: login.fido2Credentials;
|
||||
export function shouldOmitPasskeysForResponse(request: Request | null | undefined): boolean {
|
||||
const userAgent = String(request?.headers.get('user-agent') || '').toLowerCase();
|
||||
if (!userAgent) return false;
|
||||
|
||||
// Temporary compatibility fallback:
|
||||
// mobile clients expect official EncString payloads for most FIDO2 fields.
|
||||
// Keep passkeys available everywhere, but suppress only legacy malformed data
|
||||
// for mobile clients so newly-saved credentials can flow through unchanged.
|
||||
return (
|
||||
userAgent.includes('android') ||
|
||||
userAgent.includes('iphone') ||
|
||||
userAgent.includes('ipad') ||
|
||||
userAgent.includes('ios')
|
||||
);
|
||||
}
|
||||
|
||||
export function normalizeCipherLoginForStorage(login: any): any {
|
||||
if (!login || typeof login !== 'object') return login ?? null;
|
||||
|
||||
return {
|
||||
...login,
|
||||
fido2Credentials: fido2,
|
||||
fido2Credentials: Array.isArray(login.fido2Credentials) ? login.fido2Credentials : null,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeCipherLoginForCompatibility(
|
||||
login: any,
|
||||
options?: { omitFido2Credentials?: boolean }
|
||||
): any {
|
||||
const normalized = normalizeCipherLoginForStorage(login);
|
||||
if (!normalized || typeof normalized !== 'object') return normalized ?? null;
|
||||
if (!options?.omitFido2Credentials) return normalized;
|
||||
|
||||
const credentials = Array.isArray(normalized.fido2Credentials) ? normalized.fido2Credentials : null;
|
||||
if (!credentials?.length) return normalized;
|
||||
|
||||
const hasMalformedCredential = credentials.some((credential: any) => {
|
||||
if (!credential || typeof credential !== 'object') return true;
|
||||
const requiredEncryptedFields = [
|
||||
credential.credentialId,
|
||||
credential.keyType,
|
||||
credential.keyAlgorithm,
|
||||
credential.keyCurve,
|
||||
credential.keyValue,
|
||||
credential.rpId,
|
||||
credential.counter,
|
||||
credential.discoverable,
|
||||
];
|
||||
const optionalEncryptedFields = [
|
||||
credential.userHandle,
|
||||
credential.userName,
|
||||
credential.rpName,
|
||||
credential.userDisplayName,
|
||||
];
|
||||
|
||||
if (requiredEncryptedFields.some((value) => !looksLikeCipherString(value))) {
|
||||
return true;
|
||||
}
|
||||
if (optionalEncryptedFields.some((value) => value != null && !looksLikeCipherString(value))) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
return hasMalformedCredential
|
||||
? {
|
||||
...normalized,
|
||||
fido2Credentials: null,
|
||||
}
|
||||
: normalized;
|
||||
}
|
||||
|
||||
// Android 2026.2.0 requires sshKey.keyFingerprint in sync payloads.
|
||||
// Keep legacy alias "fingerprint" in parallel for older web payloads.
|
||||
export function normalizeCipherSshKeyForCompatibility(sshKey: any): any {
|
||||
@@ -81,10 +132,14 @@ export function formatAttachments(attachments: Attachment[]): any[] | null {
|
||||
// Uses opaque passthrough: spreads ALL stored fields (including unknown/future ones),
|
||||
// then overlays server-computed fields. This ensures new Bitwarden client fields
|
||||
// survive a round-trip without code changes.
|
||||
export function cipherToResponse(cipher: Cipher, attachments: Attachment[] = []): CipherResponse {
|
||||
export function cipherToResponse(
|
||||
cipher: Cipher,
|
||||
attachments: Attachment[] = [],
|
||||
options?: { omitFido2Credentials?: boolean }
|
||||
): CipherResponse {
|
||||
// Strip internal-only fields that must not appear in the API response
|
||||
const { userId, createdAt, updatedAt, deletedAt, ...passthrough } = cipher;
|
||||
const normalizedLogin = normalizeCipherLoginForCompatibility((passthrough as any).login ?? null);
|
||||
const normalizedLogin = normalizeCipherLoginForCompatibility((passthrough as any).login ?? null, options);
|
||||
const normalizedSshKey = normalizeCipherSshKeyForCompatibility((passthrough as any).sshKey ?? null);
|
||||
|
||||
return {
|
||||
@@ -119,6 +174,7 @@ export async function handleGetCiphers(request: Request, env: Env, userId: strin
|
||||
const url = new URL(request.url);
|
||||
const includeDeleted = url.searchParams.get('deleted') === 'true';
|
||||
const pagination = parsePagination(url);
|
||||
const omitFido2Credentials = shouldOmitPasskeysForResponse(request);
|
||||
|
||||
let filteredCiphers: Cipher[];
|
||||
let continuationToken: string | null = null;
|
||||
@@ -145,7 +201,7 @@ export async function handleGetCiphers(request: Request, env: Env, userId: strin
|
||||
const cipherResponses = [];
|
||||
for (const cipher of filteredCiphers) {
|
||||
const attachments = attachmentsByCipher.get(cipher.id) || [];
|
||||
cipherResponses.push(cipherToResponse(cipher, attachments));
|
||||
cipherResponses.push(cipherToResponse(cipher, attachments, { omitFido2Credentials }));
|
||||
}
|
||||
|
||||
return jsonResponse({
|
||||
@@ -165,7 +221,11 @@ export async function handleGetCipher(request: Request, env: Env, userId: string
|
||||
}
|
||||
|
||||
const attachments = await storage.getAttachmentsByCipher(cipher.id);
|
||||
return jsonResponse(cipherToResponse(cipher, attachments));
|
||||
return jsonResponse(
|
||||
cipherToResponse(cipher, attachments, {
|
||||
omitFido2Credentials: shouldOmitPasskeysForResponse(request),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
async function verifyFolderOwnership(storage: StorageService, folderId: string | null | undefined, userId: string): Promise<boolean> {
|
||||
@@ -204,7 +264,7 @@ export async function handleCreateCipher(request: Request, env: Env, userId: str
|
||||
updatedAt: now,
|
||||
deletedAt: null,
|
||||
};
|
||||
cipher.login = normalizeCipherLoginForCompatibility(cipher.login);
|
||||
cipher.login = normalizeCipherLoginForStorage(cipher.login);
|
||||
cipher.sshKey = normalizeCipherSshKeyForCompatibility(cipher.sshKey);
|
||||
const createFields = getAliasedProp(cipherData, ['fields', 'Fields']);
|
||||
cipher.fields = createFields.present ? (createFields.value ?? null) : (cipher.fields ?? null);
|
||||
@@ -218,7 +278,12 @@ export async function handleCreateCipher(request: Request, env: Env, userId: str
|
||||
await storage.saveCipher(cipher);
|
||||
await storage.updateRevisionDate(userId);
|
||||
|
||||
return jsonResponse(cipherToResponse(cipher), 200);
|
||||
return jsonResponse(
|
||||
cipherToResponse(cipher, [], {
|
||||
omitFido2Credentials: shouldOmitPasskeysForResponse(request),
|
||||
}),
|
||||
200
|
||||
);
|
||||
}
|
||||
|
||||
// PUT /api/ciphers/:id
|
||||
@@ -256,7 +321,7 @@ export async function handleUpdateCipher(request: Request, env: Env, userId: str
|
||||
updatedAt: new Date().toISOString(),
|
||||
deletedAt: existingCipher.deletedAt,
|
||||
};
|
||||
cipher.login = normalizeCipherLoginForCompatibility(cipher.login);
|
||||
cipher.login = normalizeCipherLoginForStorage(cipher.login);
|
||||
cipher.sshKey = normalizeCipherSshKeyForCompatibility(cipher.sshKey);
|
||||
|
||||
// Custom fields deletion compatibility:
|
||||
@@ -279,7 +344,11 @@ export async function handleUpdateCipher(request: Request, env: Env, userId: str
|
||||
await storage.saveCipher(cipher);
|
||||
await storage.updateRevisionDate(userId);
|
||||
|
||||
return jsonResponse(cipherToResponse(cipher));
|
||||
return jsonResponse(
|
||||
cipherToResponse(cipher, [], {
|
||||
omitFido2Credentials: shouldOmitPasskeysForResponse(request),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// DELETE /api/ciphers/:id
|
||||
@@ -297,7 +366,11 @@ export async function handleDeleteCipher(request: Request, env: Env, userId: str
|
||||
await storage.saveCipher(cipher);
|
||||
await storage.updateRevisionDate(userId);
|
||||
|
||||
return jsonResponse(cipherToResponse(cipher));
|
||||
return jsonResponse(
|
||||
cipherToResponse(cipher, [], {
|
||||
omitFido2Credentials: shouldOmitPasskeysForResponse(request),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// DELETE /api/ciphers/:id (compat mode)
|
||||
@@ -355,7 +428,11 @@ export async function handleRestoreCipher(request: Request, env: Env, userId: st
|
||||
await storage.saveCipher(cipher);
|
||||
await storage.updateRevisionDate(userId);
|
||||
|
||||
return jsonResponse(cipherToResponse(cipher));
|
||||
return jsonResponse(
|
||||
cipherToResponse(cipher, [], {
|
||||
omitFido2Credentials: shouldOmitPasskeysForResponse(request),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// PUT /api/ciphers/:id/partial - Update only favorite/folderId
|
||||
@@ -389,7 +466,11 @@ export async function handlePartialUpdateCipher(request: Request, env: Env, user
|
||||
await storage.saveCipher(cipher);
|
||||
await storage.updateRevisionDate(userId);
|
||||
|
||||
return jsonResponse(cipherToResponse(cipher));
|
||||
return jsonResponse(
|
||||
cipherToResponse(cipher, [], {
|
||||
omitFido2Credentials: shouldOmitPasskeysForResponse(request),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// POST/PUT /api/ciphers/move - Bulk move to folder
|
||||
|
||||
+20
-32
@@ -9,6 +9,10 @@ import { createRefreshToken } from '../utils/jwt';
|
||||
import { readAuthRequestDeviceInfo } from '../utils/device';
|
||||
import { createRecoveryCode, recoveryCodeEquals } from '../utils/recovery-code';
|
||||
import { issueSendAccessToken } from './sends';
|
||||
import {
|
||||
buildAccountKeys,
|
||||
buildUserDecryptionOptions,
|
||||
} from '../utils/user-decryption';
|
||||
|
||||
const TWO_FACTOR_REMEMBER_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
||||
const TWO_FACTOR_PROVIDER_AUTHENTICATOR = 0;
|
||||
@@ -241,30 +245,22 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
||||
...(trustedTwoFactorTokenToReturn ? { TwoFactorToken: trustedTwoFactorTokenToReturn } : {}),
|
||||
Key: user.key,
|
||||
PrivateKey: user.privateKey,
|
||||
AccountKeys: buildAccountKeys(user),
|
||||
accountKeys: buildAccountKeys(user),
|
||||
Kdf: user.kdfType,
|
||||
KdfIterations: user.kdfIterations,
|
||||
KdfMemory: user.kdfMemory,
|
||||
KdfParallelism: user.kdfParallelism,
|
||||
ForcePasswordReset: false,
|
||||
ResetMasterPassword: false,
|
||||
MasterPasswordPolicy: {
|
||||
Object: 'masterPasswordPolicy',
|
||||
},
|
||||
ApiUseKeyConnector: false,
|
||||
scope: 'api offline_access',
|
||||
unofficialServer: true,
|
||||
UserDecryptionOptions: {
|
||||
HasMasterPassword: true,
|
||||
Object: 'userDecryptionOptions',
|
||||
MasterPasswordUnlock: {
|
||||
Kdf: {
|
||||
KdfType: user.kdfType,
|
||||
Iterations: user.kdfIterations,
|
||||
Memory: user.kdfMemory || null,
|
||||
Parallelism: user.kdfParallelism || null,
|
||||
},
|
||||
MasterKeyEncryptedUserKey: user.key,
|
||||
MasterKeyWrappedUserKey: user.key,
|
||||
Salt: email, // email is already lowercased above
|
||||
Object: 'masterPasswordUnlock',
|
||||
},
|
||||
},
|
||||
UserDecryptionOptions: buildUserDecryptionOptions(user),
|
||||
userDecryptionOptions: buildUserDecryptionOptions(user),
|
||||
};
|
||||
|
||||
return jsonResponse(response);
|
||||
@@ -360,30 +356,22 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
||||
refresh_token: newRefreshToken,
|
||||
Key: user.key,
|
||||
PrivateKey: user.privateKey,
|
||||
AccountKeys: buildAccountKeys(user),
|
||||
accountKeys: buildAccountKeys(user),
|
||||
Kdf: user.kdfType,
|
||||
KdfIterations: user.kdfIterations,
|
||||
KdfMemory: user.kdfMemory,
|
||||
KdfParallelism: user.kdfParallelism,
|
||||
ForcePasswordReset: false,
|
||||
ResetMasterPassword: false,
|
||||
MasterPasswordPolicy: {
|
||||
Object: 'masterPasswordPolicy',
|
||||
},
|
||||
ApiUseKeyConnector: false,
|
||||
scope: 'api offline_access',
|
||||
unofficialServer: true,
|
||||
UserDecryptionOptions: {
|
||||
HasMasterPassword: true,
|
||||
Object: 'userDecryptionOptions',
|
||||
MasterPasswordUnlock: {
|
||||
Kdf: {
|
||||
KdfType: user.kdfType,
|
||||
Iterations: user.kdfIterations,
|
||||
Memory: user.kdfMemory || null,
|
||||
Parallelism: user.kdfParallelism || null,
|
||||
},
|
||||
MasterKeyEncryptedUserKey: user.key,
|
||||
MasterKeyWrappedUserKey: user.key,
|
||||
Salt: user.email.toLowerCase(),
|
||||
Object: 'masterPasswordUnlock',
|
||||
},
|
||||
},
|
||||
UserDecryptionOptions: buildUserDecryptionOptions(user),
|
||||
userDecryptionOptions: buildUserDecryptionOptions(user),
|
||||
};
|
||||
|
||||
return jsonResponse(response);
|
||||
|
||||
@@ -3,7 +3,7 @@ import { StorageService } from '../services/storage';
|
||||
import { errorResponse, jsonResponse } from '../utils/response';
|
||||
import { generateUUID } from '../utils/uuid';
|
||||
import { LIMITS } from '../config/limits';
|
||||
import { normalizeCipherLoginForCompatibility, normalizeCipherSshKeyForCompatibility } from './ciphers';
|
||||
import { normalizeCipherLoginForStorage, normalizeCipherSshKeyForCompatibility } from './ciphers';
|
||||
|
||||
// Bitwarden client import request format
|
||||
interface CiphersImportRequest {
|
||||
@@ -232,7 +232,7 @@ export async function handleCiphersImport(request: Request, env: Env, userId: st
|
||||
updatedAt: now,
|
||||
deletedAt: null,
|
||||
};
|
||||
cipher.login = normalizeCipherLoginForCompatibility(cipher.login);
|
||||
cipher.login = normalizeCipherLoginForStorage(cipher.login);
|
||||
|
||||
cipherRows.push(cipher);
|
||||
cipherMapRows.push({ index: i, sourceId, id: cipher.id });
|
||||
|
||||
+15
-31
@@ -4,6 +4,11 @@ import { errorResponse } from '../utils/response';
|
||||
import { cipherToResponse } from './ciphers';
|
||||
import { sendToResponse } from './sends';
|
||||
import { LIMITS } from '../config/limits';
|
||||
import {
|
||||
buildAccountKeys,
|
||||
buildUserDecryptionCompat,
|
||||
buildUserDecryptionOptions,
|
||||
} from '../utils/user-decryption';
|
||||
|
||||
interface SyncCacheEntry {
|
||||
body: string;
|
||||
@@ -43,6 +48,12 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr
|
||||
const url = new URL(request.url);
|
||||
const excludeDomainsParam = url.searchParams.get('excludeDomains');
|
||||
const excludeDomains = excludeDomainsParam !== null && /^(1|true|yes)$/i.test(excludeDomainsParam);
|
||||
const userAgent = String(request.headers.get('user-agent') || '').toLowerCase();
|
||||
const omitFido2Credentials =
|
||||
userAgent.includes('android') ||
|
||||
userAgent.includes('iphone') ||
|
||||
userAgent.includes('ipad') ||
|
||||
userAgent.includes('ios');
|
||||
|
||||
const user = await storage.getUserById(userId);
|
||||
if (!user) {
|
||||
@@ -78,7 +89,7 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr
|
||||
twoFactorEnabled: !!user.totpSecret,
|
||||
key: user.key,
|
||||
privateKey: user.privateKey,
|
||||
accountKeys: null,
|
||||
accountKeys: buildAccountKeys(user),
|
||||
securityStamp: user.securityStamp || user.id,
|
||||
organizations: [],
|
||||
providers: [],
|
||||
@@ -93,7 +104,7 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr
|
||||
const cipherResponses: CipherResponse[] = [];
|
||||
for (const cipher of ciphers) {
|
||||
const attachments = attachmentsByCipher.get(cipher.id) || [];
|
||||
cipherResponses.push(cipherToResponse(cipher, attachments));
|
||||
cipherResponses.push(cipherToResponse(cipher, attachments, { omitFido2Credentials }));
|
||||
}
|
||||
|
||||
// Build folder responses
|
||||
@@ -119,36 +130,9 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr
|
||||
policies: [],
|
||||
sends: sends.map(sendToResponse),
|
||||
// PascalCase for desktop/browser clients
|
||||
UserDecryptionOptions: {
|
||||
HasMasterPassword: true,
|
||||
Object: 'userDecryptionOptions',
|
||||
MasterPasswordUnlock: {
|
||||
Kdf: {
|
||||
KdfType: user.kdfType,
|
||||
Iterations: user.kdfIterations,
|
||||
Memory: user.kdfMemory || null,
|
||||
Parallelism: user.kdfParallelism || null,
|
||||
},
|
||||
MasterKeyEncryptedUserKey: user.key,
|
||||
MasterKeyWrappedUserKey: user.key,
|
||||
Salt: user.email.toLowerCase(),
|
||||
Object: 'masterPasswordUnlock',
|
||||
},
|
||||
},
|
||||
UserDecryptionOptions: buildUserDecryptionOptions(user),
|
||||
// camelCase for Android client (SyncResponseJson uses @SerialName("userDecryption"))
|
||||
userDecryption: {
|
||||
masterPasswordUnlock: {
|
||||
kdf: {
|
||||
kdfType: user.kdfType,
|
||||
iterations: user.kdfIterations,
|
||||
memory: user.kdfMemory || null,
|
||||
parallelism: user.kdfParallelism || null,
|
||||
},
|
||||
masterKeyWrappedUserKey: user.key,
|
||||
masterKeyEncryptedUserKey: user.key,
|
||||
salt: user.email.toLowerCase(),
|
||||
},
|
||||
},
|
||||
userDecryption: buildUserDecryptionCompat(user) as SyncResponse['userDecryption'],
|
||||
object: 'sync',
|
||||
};
|
||||
|
||||
|
||||
+17
-4
@@ -99,6 +99,10 @@ import {
|
||||
handleAdminSetUserStatus,
|
||||
handleAdminDeleteUser,
|
||||
} from './handlers/admin';
|
||||
import {
|
||||
handleAdminExportBackup,
|
||||
handleAdminImportBackup,
|
||||
} from './handlers/backup';
|
||||
|
||||
function isSameOriginWriteRequest(request: Request): boolean {
|
||||
const targetOrigin = new URL(request.url).origin;
|
||||
@@ -269,11 +273,12 @@ export async function handleRequest(request: Request, env: Env): Promise<Respons
|
||||
try {
|
||||
|
||||
// Reject oversized bodies before any path-specific parsing.
|
||||
// File upload paths enforce their own limits and are exempt here.
|
||||
const isFileUploadPath =
|
||||
// Large file/archive upload paths enforce their own limits and are exempt here.
|
||||
const isLargeUploadPath =
|
||||
/^\/api\/ciphers\/[a-f0-9-]+\/attachment\/[a-f0-9-]+$/i.test(path) ||
|
||||
/^\/api\/sends\/[a-f0-9-]+\/file\/[a-f0-9-]+$/i.test(path);
|
||||
if (!isFileUploadPath) {
|
||||
/^\/api\/sends\/[a-f0-9-]+\/file\/[a-f0-9-]+$/i.test(path) ||
|
||||
path === '/api/admin/backup/import';
|
||||
if (!isLargeUploadPath) {
|
||||
const contentLength = parseInt(request.headers.get('Content-Length') || '0', 10);
|
||||
if (contentLength > LIMITS.request.maxBodyBytes) {
|
||||
return errorResponse('Request body too large', 413);
|
||||
@@ -771,6 +776,14 @@ export async function handleRequest(request: Request, env: Env): Promise<Respons
|
||||
return handleAdminListUsers(request, env, currentUser);
|
||||
}
|
||||
|
||||
if (path === '/api/admin/backup/export' && method === 'POST') {
|
||||
return handleAdminExportBackup(request, env, currentUser);
|
||||
}
|
||||
|
||||
if (path === '/api/admin/backup/import' && method === 'POST') {
|
||||
return handleAdminImportBackup(request, env, currentUser);
|
||||
}
|
||||
|
||||
if (path === '/api/admin/invites') {
|
||||
if (method === 'GET') return handleAdminListInvites(request, env, currentUser);
|
||||
if (method === 'POST') return handleAdminCreateInvite(request, env, currentUser);
|
||||
|
||||
@@ -284,6 +284,8 @@ export interface UserDecryptionOptions {
|
||||
Object: string;
|
||||
// Bitwarden Android 2026.1.x expects this to exist; missing it breaks unlock when the vault is empty.
|
||||
MasterPasswordUnlock: MasterPasswordUnlock;
|
||||
TrustedDeviceOption: null;
|
||||
KeyConnectorOption: null;
|
||||
}
|
||||
|
||||
// API Response types
|
||||
@@ -303,7 +305,14 @@ export interface TokenResponse {
|
||||
ResetMasterPassword: boolean;
|
||||
scope: string;
|
||||
unofficialServer: boolean;
|
||||
MasterPasswordPolicy?: {
|
||||
Object: string;
|
||||
} | null;
|
||||
ApiUseKeyConnector?: boolean;
|
||||
AccountKeys?: any | null;
|
||||
accountKeys?: any | null;
|
||||
UserDecryptionOptions: UserDecryptionOptions;
|
||||
userDecryptionOptions?: UserDecryptionOptions;
|
||||
}
|
||||
|
||||
export interface ProfileResponse {
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { User, UserDecryptionOptions } from '../types';
|
||||
|
||||
export function buildAccountKeys(user: Pick<User, 'privateKey' | 'publicKey'>): Record<string, unknown> | null {
|
||||
if (!user.privateKey || !user.publicKey) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
publicKeyEncryptionKeyPair: {
|
||||
wrappedPrivateKey: user.privateKey,
|
||||
publicKey: user.publicKey,
|
||||
Object: 'publicKeyEncryptionKeyPair',
|
||||
},
|
||||
Object: 'privateKeys',
|
||||
};
|
||||
}
|
||||
|
||||
export function buildMasterPasswordUnlock(
|
||||
user: Pick<User, 'email' | 'key' | 'kdfType' | 'kdfIterations' | 'kdfMemory' | 'kdfParallelism'>
|
||||
): UserDecryptionOptions['MasterPasswordUnlock'] {
|
||||
return {
|
||||
Kdf: {
|
||||
KdfType: user.kdfType,
|
||||
Iterations: user.kdfIterations,
|
||||
Memory: user.kdfMemory ?? null,
|
||||
Parallelism: user.kdfParallelism ?? null,
|
||||
},
|
||||
MasterKeyEncryptedUserKey: user.key,
|
||||
MasterKeyWrappedUserKey: user.key,
|
||||
Salt: user.email.toLowerCase(),
|
||||
Object: 'masterPasswordUnlock',
|
||||
};
|
||||
}
|
||||
|
||||
export function buildUserDecryptionOptions(
|
||||
user: Pick<User, 'email' | 'key' | 'kdfType' | 'kdfIterations' | 'kdfMemory' | 'kdfParallelism'>
|
||||
): UserDecryptionOptions {
|
||||
return {
|
||||
HasMasterPassword: true,
|
||||
Object: 'userDecryptionOptions',
|
||||
MasterPasswordUnlock: buildMasterPasswordUnlock(user),
|
||||
TrustedDeviceOption: null,
|
||||
KeyConnectorOption: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function buildUserDecryptionCompat(
|
||||
user: Pick<User, 'email' | 'key' | 'kdfType' | 'kdfIterations' | 'kdfMemory' | 'kdfParallelism'>
|
||||
): Record<string, unknown> {
|
||||
return {
|
||||
masterPasswordUnlock: {
|
||||
kdf: {
|
||||
kdfType: user.kdfType,
|
||||
iterations: user.kdfIterations,
|
||||
memory: user.kdfMemory ?? null,
|
||||
parallelism: user.kdfParallelism ?? null,
|
||||
},
|
||||
masterKeyWrappedUserKey: user.key,
|
||||
masterKeyEncryptedUserKey: user.key,
|
||||
salt: user.email.toLowerCase(),
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user